UI overhaul: unified sidebar layout with app-scoped views
Some checks failed
CI / build (push) Failing after 48s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped

Replace disconnected Transactions/Applications pages with a persistent
collapsible sidebar listing apps by health status. Add app-scoped view
(/apps/:group) with filtered stats, route chips, and scoped table.
Merge Processor Tree into diagram detail panel with Inspector/Tree
toggle and resizable divider. Remove max-width constraint for full
viewport usage. All view states are deep-linkable via URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-15 15:47:33 +01:00
parent 0b56590e3f
commit 7fd8a787d0
16 changed files with 1111 additions and 327 deletions

View File

@@ -1,132 +0,0 @@
.pageHeader {
margin-bottom: 24px;
}
.pageHeader h1 {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.5px;
}
.subtitle {
color: var(--text-muted);
font-size: 13px;
margin-top: 4px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 20px;
transition: border-color 0.15s;
}
.card:hover {
border-color: var(--border);
}
.cardHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.statusDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.live { background: var(--green); }
.stale { background: var(--amber); }
.dead { background: var(--text-muted); }
.groupName {
font-family: var(--font-mono);
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.agentCount {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
.statusBar {
display: flex;
gap: 12px;
font-size: 12px;
margin-bottom: 14px;
}
.statusLive { color: var(--green); }
.statusStale { color: var(--amber); }
.statusDead { color: var(--text-muted); }
.routes {
border-top: 1px solid var(--border-subtle);
padding-top: 12px;
}
.routesLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
display: block;
margin-bottom: 8px;
}
.routeList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.routeLink {
display: inline-block;
padding: 3px 10px;
border-radius: var(--radius-sm);
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.15s;
}
.routeLink:hover {
color: var(--amber);
border-color: var(--amber-dim);
background: var(--amber-glow);
}
.loading,
.empty {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 14px;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,107 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router';
import { useAgents } from '../../api/queries/agents';
import type { AgentInstance } from '../../api/types';
import styles from './ApplicationsPage.module.css';
interface GroupInfo {
group: string;
agents: AgentInstance[];
routeIds: string[];
liveCount: number;
staleCount: number;
deadCount: number;
}
function groupStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
if (g.liveCount > 0) return 'live';
if (g.staleCount > 0) return 'stale';
return 'dead';
}
export function ApplicationsPage() {
const { data: agents, isLoading } = useAgents();
const groups = useMemo(() => {
if (!agents) return [];
const map = new Map<string, GroupInfo>();
for (const agent of agents) {
const key = agent.group ?? 'default';
let entry = map.get(key);
if (!entry) {
entry = { group: key, agents: [], routeIds: [], liveCount: 0, staleCount: 0, deadCount: 0 };
map.set(key, entry);
}
entry.agents.push(agent);
if (agent.status === 'LIVE') entry.liveCount++;
else if (agent.status === 'STALE') entry.staleCount++;
else entry.deadCount++;
// Collect unique routeIds
if (agent.routeIds) {
for (const rid of agent.routeIds) {
if (!entry.routeIds.includes(rid)) entry.routeIds.push(rid);
}
}
}
return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
}, [agents]);
if (isLoading) {
return <div className={styles.loading}>Loading applications...</div>;
}
return (
<>
<div className={`${styles.pageHeader} animate-in`}>
<h1>Applications</h1>
<div className={styles.subtitle}>Monitored Camel applications and their routes</div>
</div>
{groups.length === 0 ? (
<div className={styles.empty}>No applications found. Agents will appear here once they connect.</div>
) : (
<div className={styles.grid}>
{groups.map((g) => {
const status = groupStatus(g);
return (
<div key={g.group} className={styles.card}>
<div className={styles.cardHeader}>
<div className={`${styles.statusDot} ${styles[status]}`} />
<span className={styles.groupName}>{g.group}</span>
<span className={styles.agentCount}>
{g.agents.length} instance{g.agents.length !== 1 ? 's' : ''}
</span>
</div>
<div className={styles.statusBar}>
{g.liveCount > 0 && <span className={styles.statusLive}>{g.liveCount} live</span>}
{g.staleCount > 0 && <span className={styles.statusStale}>{g.staleCount} stale</span>}
{g.deadCount > 0 && <span className={styles.statusDead}>{g.deadCount} dead</span>}
</div>
{g.routeIds.length > 0 && (
<div className={styles.routes}>
<span className={styles.routesLabel}>Routes</span>
<ul className={styles.routeList}>
{g.routeIds.map((rid) => (
<li key={rid}>
<Link
to={`/apps/${encodeURIComponent(g.group)}/routes/${encodeURIComponent(rid)}`}
className={styles.routeLink}
>
{rid}
</Link>
</li>
))}
</ul>
</div>
)}
</div>
);
})}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,214 @@
/* ─── Breadcrumb ─── */
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 12px;
}
.breadcrumbLink {
color: var(--text-muted);
text-decoration: none;
transition: color 0.15s;
}
.breadcrumbLink:hover {
color: var(--amber);
}
.breadcrumbSep {
color: var(--text-muted);
opacity: 0.5;
}
.breadcrumbCurrent {
color: var(--text-primary);
font-family: var(--font-mono);
font-weight: 500;
}
/* ─── App Header ─── */
.appHeader {
position: relative;
margin-bottom: 20px;
padding: 16px 20px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.appHeader::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--amber), var(--cyan));
}
.appTitle {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.agentSummary {
display: flex;
gap: 12px;
margin-top: 6px;
font-size: 12px;
}
.agentLive { color: var(--green); }
.agentStale { color: var(--amber); }
.agentDead { color: var(--text-muted); }
/* ─── Stats Bar ─── */
.statsBar {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
/* ─── Route Chips ─── */
.routeChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.routeChip {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 99px;
background: none;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.15s;
}
.routeChip:hover {
background: var(--bg-raised);
color: var(--text-primary);
border-color: var(--text-muted);
}
.routeChipActive {
background: var(--amber-glow);
color: var(--amber);
border-color: rgba(245, 158, 11, 0.3);
}
/* ─── Results Header ─── */
.resultsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
}
.resultsCount {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.resultsCount strong {
color: var(--text-secondary);
}
/* ─── Filter Bar ─── */
.filterBar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filterGroup {
display: flex;
align-items: center;
gap: 6px;
}
.filterLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
/* ─── Live Toggle ─── */
.liveToggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-family: var(--font-mono);
font-weight: 500;
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
transition: all 0.15s ease;
margin-left: auto;
}
.liveOn {
color: var(--green);
border-color: var(--green);
}
.liveOff {
color: var(--text-muted);
}
.liveDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.liveOn .liveDot {
background: var(--green);
animation: livePulse 2s ease-in-out infinite;
}
.liveOff .liveDot {
background: var(--text-muted);
}
@keyframes livePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
}
/* ─── Loading / Empty ─── */
.loading {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
}
/* ─── Responsive ─── */
@media (max-width: 1200px) {
.statsBar { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.statsBar { grid-template-columns: 1fr 1fr; }
}

View File

@@ -0,0 +1,184 @@
import { useState, useMemo, useCallback } from 'react';
import { useParams, useNavigate, NavLink } from 'react-router';
import { useAgents } from '../../api/queries/agents';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { StatCard } from '../../components/shared/StatCard';
import { ResultsTable } from '../executions/ResultsTable';
import { Pagination } from '../../components/shared/Pagination';
import { FilterChip } from '../../components/shared/FilterChip';
import type { SearchRequest } from '../../api/types';
import styles from './AppScopedView.module.css';
function todayMidnight(): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
}
function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
const arrow = pct > 0 ? '\u2191' : '\u2193';
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
}
export function AppScopedView() {
const { group } = useParams<{ group: string }>();
const navigate = useNavigate();
const { data: agents } = useAgents();
const [selectedRoute, setSelectedRoute] = useState<string | null>(null);
const [status, setStatus] = useState<string[]>(['COMPLETED', 'FAILED']);
const [live, setLive] = useState(true);
const [offset, setOffset] = useState(0);
const limit = 25;
// Find agents belonging to this group
const groupAgents = useMemo(() => {
if (!agents || !group) return [];
return agents.filter((a) => (a.group ?? 'default') === group);
}, [agents, group]);
const liveCount = groupAgents.filter((a) => a.status === 'LIVE').length;
const staleCount = groupAgents.filter((a) => a.status === 'STALE').length;
const deadCount = groupAgents.filter((a) => a.status === 'DEAD').length;
// Collect unique routes from agents
const routeIds = useMemo(() => {
const set = new Set<string>();
for (const a of groupAgents) {
if (a.routeIds) for (const rid of a.routeIds) set.add(rid);
}
return Array.from(set).sort();
}, [groupAgents]);
// Build search request scoped to this group
const timeFrom = todayMidnight();
const timeFromIso = new Date(timeFrom).toISOString();
const searchRequest: SearchRequest = useMemo(() => ({
group: group || undefined,
routeId: selectedRoute || undefined,
status: status.length > 0 && status.length < 3 ? status.join(',') : undefined,
timeFrom: timeFromIso,
offset,
limit,
sortField: 'startTime',
sortDir: 'desc',
}), [group, selectedRoute, status, timeFromIso, offset, limit]);
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest, live);
const { data: stats } = useExecutionStats(timeFromIso, undefined, selectedRoute || undefined, group);
const { data: timeseries } = useStatsTimeseries(timeFromIso, undefined, selectedRoute || undefined, group);
const sparkTotal = timeseries?.buckets.map((b) => b.totalCount) ?? [];
const sparkFailed = timeseries?.buckets.map((b) => b.failedCount) ?? [];
const sparkAvgDuration = timeseries?.buckets.map((b) => b.avgDurationMs) ?? [];
const sparkP99 = timeseries?.buckets.map((b) => b.p99DurationMs) ?? [];
const sparkActive = timeseries?.buckets.map((b) => b.activeCount) ?? [];
const total = data?.total ?? 0;
const results = data?.data ?? [];
const failureRate = stats && stats.totalCount > 0
? (stats.failedCount / stats.totalCount) * 100 : 0;
const prevFailureRate = stats && stats.prevTotalCount > 0
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
const avgChange = stats ? pctChange(stats.avgDurationMs, stats.prevAvgDurationMs) : null;
const failRateChange = stats ? pctChange(failureRate, prevFailureRate) : null;
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
const showFrom = total > 0 ? offset + 1 : 0;
const showTo = Math.min(offset + limit, total);
const toggleRoute = useCallback((rid: string) => {
setSelectedRoute((prev) => prev === rid ? null : rid);
setOffset(0);
}, []);
if (!group) {
return <div className={styles.loading}>Missing group parameter</div>;
}
return (
<>
{/* Breadcrumb */}
<nav className={styles.breadcrumb}>
<NavLink to="/executions" className={styles.breadcrumbLink}>All</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbCurrent}>{group}</span>
</nav>
{/* App Header */}
<div className={styles.appHeader}>
<div className={styles.appTitle}>{group}</div>
<div className={styles.agentSummary}>
{liveCount > 0 && <span className={styles.agentLive}>{liveCount} live</span>}
{staleCount > 0 && <span className={styles.agentStale}>{staleCount} stale</span>}
{deadCount > 0 && <span className={styles.agentDead}>{deadCount} dead</span>}
{groupAgents.length === 0 && <span className={styles.agentDead}>no agents</span>}
</div>
</div>
{/* Stats Bar */}
<div className={styles.statsBar}>
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={stats ? `of ${formatCompact(stats.totalToday)} today` : 'from current search'} sparkData={sparkTotal} />
<StatCard label="Avg Duration" value={stats ? `${stats.avgDurationMs.toLocaleString()}ms` : '--'} accent="cyan" change={avgChange?.text} changeDirection={avgChange?.direction} sparkData={sparkAvgDuration} />
<StatCard label="Failure Rate" value={stats ? `${failureRate.toFixed(1)}%` : '--'} accent="rose" change={failRateChange?.text} changeDirection={failRateChange?.direction} sparkData={sparkFailed} />
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs.toLocaleString()}ms` : '--'} accent="green" change={p99Change?.text} changeDirection={p99Change?.direction} sparkData={sparkP99} />
<StatCard label="In-Flight" value={stats ? stats.activeCount.toLocaleString() : '--'} accent="blue" change="running executions" sparkData={sparkActive} />
</div>
{/* Route Chips + Status Filters */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Status</label>
<FilterChip label="Completed" accent="green" active={status.includes('COMPLETED')} onClick={() => setStatus((s) => s.includes('COMPLETED') ? s.filter((x) => x !== 'COMPLETED') : [...s, 'COMPLETED'])} />
<FilterChip label="Failed" accent="rose" active={status.includes('FAILED')} onClick={() => setStatus((s) => s.includes('FAILED') ? s.filter((x) => x !== 'FAILED') : [...s, 'FAILED'])} />
<FilterChip label="Running" accent="blue" active={status.includes('RUNNING')} onClick={() => setStatus((s) => s.includes('RUNNING') ? s.filter((x) => x !== 'RUNNING') : [...s, 'RUNNING'])} />
</div>
<button className={`${styles.liveToggle} ${live ? styles.liveOn : styles.liveOff}`} onClick={() => setLive(!live)}>
<span className={styles.liveDot} />
{live ? 'LIVE' : 'PAUSED'}
</button>
</div>
{/* Route Chips */}
{routeIds.length > 0 && (
<div className={styles.routeChips}>
{routeIds.map((rid) => (
<button
key={rid}
className={`${styles.routeChip} ${selectedRoute === rid ? styles.routeChipActive : ''}`}
onClick={() => toggleRoute(rid)}
>
{rid}
</button>
))}
</div>
)}
{/* Results Header */}
<div className={styles.resultsHeader}>
<span className={styles.resultsCount}>
Showing <strong>{showFrom}{showTo}</strong> of <strong>{total.toLocaleString()}</strong> results
{isFetching && !isLoading && ' · updating...'}
</span>
</div>
{/* Results Table */}
<ResultsTable results={results} loading={isLoading} />
{/* Pagination */}
<Pagination total={total} offset={offset} limit={limit} onChange={setOffset} />
</>
);
}

View File

@@ -1,26 +1,87 @@
import { useState, useCallback } from 'react';
import type { DiagramLayout, ExecutionDetail } from '../../api/types';
import type { OverlayState } from '../../hooks/useExecutionOverlay';
import { DiagramCanvas } from './diagram/DiagramCanvas';
import { ProcessorDetailPanel } from './diagram/ProcessorDetailPanel';
import { ProcessorTree } from '../executions/ProcessorTree';
import { ResizableDivider } from '../../components/shared/ResizableDivider';
import styles from './diagram/diagram.module.css';
const PANEL_WIDTH_KEY = 'cameleer-diagram-panel-width';
const DEFAULT_WIDTH = 340;
type DetailMode = 'inspector' | 'tree';
interface DiagramTabProps {
layout: DiagramLayout;
overlay: OverlayState;
execution: ExecutionDetail | null | undefined;
executionId?: string | null;
}
export function DiagramTab({ layout, overlay, execution }: DiagramTabProps) {
export function DiagramTab({ layout, overlay, execution, executionId }: DiagramTabProps) {
const [panelWidth, setPanelWidth] = useState(() => {
try {
const saved = localStorage.getItem(PANEL_WIDTH_KEY);
return saved ? Number(saved) : DEFAULT_WIDTH;
} catch { return DEFAULT_WIDTH; }
});
const [detailMode, setDetailMode] = useState<DetailMode>('inspector');
const handleResize = useCallback((width: number) => {
setPanelWidth(width);
try { localStorage.setItem(PANEL_WIDTH_KEY, String(width)); }
catch { /* ignore */ }
}, []);
const showPanel = overlay.isActive && execution;
return (
<div className={styles.splitLayout}>
<div className={styles.diagramSide}>
<DiagramCanvas layout={layout} overlay={overlay} />
</div>
{overlay.isActive && execution && (
<ProcessorDetailPanel
execution={execution}
selectedNodeId={overlay.selectedNodeId}
/>
{showPanel && (
<>
<ResizableDivider
panelWidth={panelWidth}
onResize={handleResize}
minWidth={240}
maxWidth={600}
/>
<div className={styles.sidePanel} style={{ width: panelWidth }}>
{/* Mode toggle */}
<div className={styles.detailModeTabs}>
<button
className={`${styles.detailModeTab} ${detailMode === 'inspector' ? styles.detailModeTabActive : ''}`}
onClick={() => setDetailMode('inspector')}
>
Inspector
</button>
<button
className={`${styles.detailModeTab} ${detailMode === 'tree' ? styles.detailModeTabActive : ''}`}
onClick={() => setDetailMode('tree')}
>
Tree
</button>
</div>
{detailMode === 'inspector' ? (
<ProcessorDetailPanel
execution={execution}
selectedNodeId={overlay.selectedNodeId}
/>
) : (
executionId ? (
<div className={styles.treeContainer}>
<ProcessorTree executionId={executionId} />
</div>
) : (
<div className={styles.detailEmpty}>Select an execution to view the processor tree</div>
)
)}
</div>
</>
)}
</div>
);

View File

@@ -6,12 +6,11 @@ import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
import { RouteHeader } from './RouteHeader';
import { DiagramTab } from './DiagramTab';
import { PerformanceTab } from './PerformanceTab';
import { ProcessorTree } from '../executions/ProcessorTree';
import { ExchangeTab } from './ExchangeTab';
import { ExecutionPicker } from './diagram/ExecutionPicker';
import styles from './RoutePage.module.css';
type Tab = 'diagram' | 'performance' | 'processors' | 'exchange';
type Tab = 'diagram' | 'performance' | 'exchange';
export function RoutePage() {
const { group, routeId } = useParams<{ group: string; routeId: string }>();
@@ -54,16 +53,16 @@ export function RoutePage() {
return <div className={styles.error}>Missing group or routeId parameters</div>;
}
const needsExecPicker = activeTab === 'diagram' || activeTab === 'processors' || activeTab === 'exchange';
const needsExecPicker = activeTab === 'diagram' || activeTab === 'exchange';
return (
<>
{/* Breadcrumb */}
<nav className={styles.breadcrumb}>
<button className={styles.backBtn} onClick={goBack} title="Back (Backspace)">&larr;</button>
<NavLink to="/executions" className={styles.breadcrumbLink}>Transactions</NavLink>
<NavLink to="/executions" className={styles.breadcrumbLink}>All</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbText}>{group}</span>
<NavLink to={`/apps/${encodeURIComponent(group)}`} className={styles.breadcrumbLink}>{group}</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbCurrent}>{routeId}</span>
</nav>
@@ -86,12 +85,6 @@ export function RoutePage() {
>
Performance
</button>
<button
className={`${styles.tab} ${activeTab === 'processors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('processors')}
>
Processor Tree
</button>
<button
className={`${styles.tab} ${activeTab === 'exchange' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('exchange')}
@@ -128,7 +121,7 @@ export function RoutePage() {
layoutLoading ? (
<div className={styles.loading}>Loading diagram...</div>
) : layout ? (
<DiagramTab layout={layout} overlay={overlay} execution={execution} />
<DiagramTab layout={layout} overlay={overlay} execution={execution} executionId={execId} />
) : (
<div className={styles.emptyState}>No diagram available for this route</div>
)
@@ -138,16 +131,6 @@ export function RoutePage() {
<PerformanceTab group={group} routeId={routeId} />
)}
{activeTab === 'processors' && execId && (
<ProcessorTree executionId={execId} />
)}
{activeTab === 'processors' && !execId && (
<div className={styles.emptyState}>
Select an execution to view the processor tree
</div>
)}
{activeTab === 'exchange' && execId && (
<ExchangeTab executionId={execId} />
)}

View File

@@ -100,7 +100,7 @@
.splitLayout {
display: flex;
gap: 0;
height: 100%;
height: calc(100vh - 56px - 200px);
min-height: 500px;
}
@@ -111,12 +111,20 @@
flex-direction: column;
}
/* ─── Processor Detail Panel ─── */
.detailPanel {
width: 340px;
/* ─── Side Panel (wraps mode tabs + detail/tree) ─── */
.sidePanel {
flex-shrink: 0;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ─── Processor Detail Panel ─── */
.detailPanel {
flex: 1;
min-height: 0;
background: var(--bg-surface);
padding: 16px;
overflow-y: auto;
display: flex;
@@ -124,6 +132,41 @@
gap: 16px;
}
/* ─── Detail Mode Tabs ─── */
.detailModeTabs {
display: flex;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.detailModeTab {
flex: 1;
padding: 8px 16px;
border: none;
background: none;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.detailModeTab:hover {
color: var(--text-secondary);
}
.detailModeTabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.treeContainer {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.detailEmpty {
color: var(--text-muted);
font-size: 13px;
@@ -584,6 +627,12 @@
flex-direction: column;
}
.sidePanel {
width: 100% !important;
max-height: 300px;
border-top: 1px solid var(--border-subtle);
}
.detailPanel {
width: 100%;
max-height: 300px;

View File

@@ -1,5 +1,4 @@
.container {
max-width: 1440px;
margin: 0 auto;
margin: 0;
padding: 24px;
}