feat: add Route Detail page with diagram, processor stats, and tabbed sections

Replaces the filtered RoutesMetrics view at /routes/:appId/:routeId with a
dedicated RouteDetail page showing route diagram, processor stats table,
performance charts, recent executions, and client-side grouped error patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-23 18:25:58 +01:00
parent 8e27f45a2b
commit 94d1e81852
4 changed files with 367 additions and 1 deletions

View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export function useProcessorMetrics(routeId: string | null, appId?: string) {
return useQuery({
queryKey: ['processor-metrics', routeId, appId],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (routeId) params.set('routeId', routeId);
if (appId) params.set('appId', appId);
const res = await fetch(`${config.apiBaseUrl}/routes/metrics/processors?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
},
enabled: !!routeId,
refetchInterval: 30_000,
});
}

View File

@@ -0,0 +1,39 @@
.headerCard {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
padding: 16px; margin-bottom: 16px;
}
.headerRow { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.headerLeft { display: flex; align-items: center; gap: 12px; }
.headerRight { display: flex; gap: 20px; }
.headerStat { text-align: center; }
.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; }
.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
.diagramStatsGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.diagramPane, .statsPane {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
}
.paneTitle { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; }
.tabSection { margin-top: 20px; }
.chartGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.chartCard {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
}
.chartTitle { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 12px; }
.executionsTable {
background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden;
}
.errorPatterns { display: flex; flex-direction: column; gap: 8px; }
.errorRow {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 12px; background: var(--bg-surface); border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg); font-size: 12px;
}
.errorMessage { flex: 1; font-family: var(--font-mono); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 400px; }
.errorCount { font-weight: 700; color: var(--error); margin: 0 12px; }
.errorTime { color: var(--text-muted); font-size: 11px; }
.backLink { font-size: 13px; color: var(--text-muted); text-decoration: none; margin-bottom: 12px; display: inline-block; }
.backLink:hover { color: var(--text-primary); }

View File

@@ -0,0 +1,301 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import {
Badge, StatusDot, DataTable, Tabs,
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
MonoText,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useGlobalFilters } from '@cameleer/design-system';
import { useRouteCatalog } from '../../api/queries/catalog';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
import { useStatsTimeseries, useSearchExecutions } from '../../api/queries/executions';
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
import styles from './RouteDetail.module.css';
interface ExchangeRow extends ExecutionSummary {
id: string;
}
interface ProcessorRow {
id: string;
processorId: string;
callCount: number;
avgDurationMs: number;
p99DurationMs: number;
errorCount: number;
}
interface ErrorPattern {
message: string;
count: number;
lastSeen: string;
}
export default function RouteDetail() {
const { appId, routeId } = useParams();
const navigate = useNavigate();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const [activeTab, setActiveTab] = useState('performance');
const { data: catalog } = useRouteCatalog();
const { data: diagram } = useDiagramByRoute(appId, routeId);
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
timeFrom,
timeTo,
routeId: routeId || undefined,
group: appId || undefined,
offset: 0,
limit: 50,
});
const { data: errorResult } = useSearchExecutions({
timeFrom,
timeTo,
routeId: routeId || undefined,
group: appId || undefined,
status: 'FAILED',
offset: 0,
limit: 200,
});
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
[catalog, appId],
);
const routeSummary: RouteSummary | undefined = useMemo(() =>
appEntry?.routes?.find((r: RouteSummary) => r.routeId === routeId),
[appEntry, routeId],
);
const health = appEntry?.health ?? 'unknown';
const exchangeCount = routeSummary?.exchangeCount ?? 0;
const lastSeen = routeSummary?.lastSeen
? new Date(routeSummary.lastSeen).toLocaleString()
: '—';
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
const h = health.toLowerCase();
if (h === 'healthy') return 'success';
if (h === 'degraded') return 'warning';
if (h === 'unhealthy') return 'error';
return 'dead';
}, [health]);
const diagramNodes = useMemo(() => {
if (!diagram?.nodes) return [];
return mapDiagramToRouteNodes(diagram.nodes, []);
}, [diagram]);
const processorRows: ProcessorRow[] = useMemo(() =>
(processorMetrics || []).map((p: any) => ({
id: p.processorId,
processorId: p.processorId,
callCount: p.callCount ?? 0,
avgDurationMs: p.avgDurationMs ?? 0,
p99DurationMs: p.p99DurationMs ?? 0,
errorCount: p.errorCount ?? 0,
})),
[processorMetrics],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
})),
[timeseries],
);
const exchangeRows: ExchangeRow[] = useMemo(() =>
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[recentResult],
);
const errorPatterns: ErrorPattern[] = useMemo(() => {
const failed = (errorResult?.data || []) as ExecutionSummary[];
const grouped = new Map<string, { count: number; lastSeen: string }>();
for (const ex of failed) {
const msg = ex.errorMessage || 'Unknown error';
const existing = grouped.get(msg);
if (!existing) {
grouped.set(msg, { count: 1, lastSeen: ex.startTime ?? '' });
} else {
existing.count += 1;
if ((ex.startTime ?? '') > existing.lastSeen) {
existing.lastSeen = ex.startTime ?? '';
}
}
}
return Array.from(grouped.entries())
.map(([message, { count, lastSeen: ls }]) => ({
message,
count,
lastSeen: ls ? new Date(ls).toLocaleString() : '—',
}))
.sort((a, b) => b.count - a.count);
}, [errorResult]);
const processorColumns: Column<ProcessorRow>[] = [
{ key: 'processorId', header: 'Processor', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'callCount', header: 'Calls', sortable: true },
{ key: 'avgDurationMs', header: 'Avg', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
{ key: 'errorCount', header: 'Errors', sortable: true, render: (v) => {
const n = v as number;
return n > 0 ? <span style={{ color: 'var(--error)', fontWeight: 700 }}>{n}</span> : <span>0</span>;
}},
];
const exchangeColumns: Column<ExchangeRow>[] = [
{
key: 'status', header: 'Status', width: '80px',
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
},
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
{ key: 'durationMs', header: 'Duration', sortable: true, render: (v) => `${v}ms` },
];
const tabs = [
{ label: 'Performance', value: 'performance' },
{ label: 'Recent Executions', value: 'executions', count: exchangeRows.length },
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
];
return (
<div>
<Link to={`/routes/${appId}`} className={styles.backLink}>
{appId} routes
</Link>
<div className={styles.headerCard}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
<StatusDot variant={healthVariant} />
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700 }}>{routeId}</h2>
<Badge label={appId ?? ''} color="auto" />
</div>
<div className={styles.headerRight}>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Exchanges</div>
<div className={styles.headerStatValue}>{exchangeCount.toLocaleString()}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Last Seen</div>
<div className={styles.headerStatValue}>{lastSeen}</div>
</div>
</div>
</div>
</div>
<div className={styles.diagramStatsGrid}>
<div className={styles.diagramPane}>
<div className={styles.paneTitle}>Route Diagram</div>
{diagramNodes.length > 0 ? (
<RouteFlow nodes={diagramNodes} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
No diagram available for this route.
</div>
)}
</div>
<div className={styles.statsPane}>
<div className={styles.paneTitle}>Processor Stats</div>
{processorLoading ? (
<Spinner size="sm" />
) : processorRows.length > 0 ? (
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
) : (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
No processor data available.
</div>
)}
</div>
</div>
<div className={styles.tabSection}>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'performance' && (
<div className={styles.chartGrid} style={{ marginTop: 16 }}>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Throughput</div>
<AreaChart
series={[{ label: 'Throughput', data: chartData.map((d, i) => ({ x: i, y: d.throughput })) }]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Latency</div>
<LineChart
series={[{ label: 'Latency', data: chartData.map((d, i) => ({ x: i, y: d.latency })) }]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Errors</div>
<BarChart
series={[{ label: 'Errors', data: chartData.map((d) => ({ x: d.time, y: d.errors })) }]}
height={200}
/>
</div>
<div className={styles.chartCard}>
<div className={styles.chartTitle}>Success Rate</div>
<AreaChart
series={[{ label: 'Success Rate', data: chartData.map((d, i) => ({ x: i, y: d.successRate })) }]}
height={200}
/>
</div>
</div>
)}
{activeTab === 'executions' && (
<div className={styles.executionsTable} style={{ marginTop: 16 }}>
{recentLoading ? (
<div style={{ padding: 24, display: 'flex', justifyContent: 'center' }}>
<Spinner size="sm" />
</div>
) : (
<DataTable
columns={exchangeColumns}
data={exchangeRows}
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
sortable
pageSize={20}
/>
)}
</div>
)}
{activeTab === 'errors' && (
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
{errorPatterns.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
No error patterns found in the selected time range.
</div>
) : (
errorPatterns.map((ep, i) => (
<div key={i} className={styles.errorRow}>
<span className={styles.errorMessage} title={ep.message}>{ep.message}</span>
<span className={styles.errorCount}>{ep.count}x</span>
<span className={styles.errorTime}>{ep.lastSeen}</span>
</div>
))
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { Spinner } from '@cameleer/design-system';
const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'));
const ExchangeDetail = lazy(() => import('./pages/ExchangeDetail/ExchangeDetail'));
const RoutesMetrics = lazy(() => import('./pages/Routes/RoutesMetrics'));
const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail'));
const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth'));
const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance'));
const RbacPage = lazy(() => import('./pages/Admin/RbacPage'));
@@ -42,7 +43,7 @@ export const router = createBrowserRouter([
{ path: 'exchanges/:id', element: <SuspenseWrapper><ExchangeDetail /></SuspenseWrapper> },
{ path: 'routes', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> },
{ path: 'routes/:appId', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> },
{ path: 'routes/:appId/:routeId', element: <SuspenseWrapper><RoutesMetrics /></SuspenseWrapper> },
{ path: 'routes/:appId/:routeId', element: <SuspenseWrapper><RouteDetail /></SuspenseWrapper> },
{ path: 'agents', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
{ path: 'agents/:appId', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
{ path: 'agents/:appId/:instanceId', element: <SuspenseWrapper><AgentInstance /></SuspenseWrapper> },