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:
25
ui/src/api/queries/processor-metrics.ts
Normal file
25
ui/src/api/queries/processor-metrics.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
39
ui/src/pages/Routes/RouteDetail.module.css
Normal file
39
ui/src/pages/Routes/RouteDetail.module.css
Normal 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); }
|
||||
301
ui/src/pages/Routes/RouteDetail.tsx
Normal file
301
ui/src/pages/Routes/RouteDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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> },
|
||||
|
||||
Reference in New Issue
Block a user