From ebf653e8489021090ebccd7ed7a8f23402b7eacb Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:22:02 +0100 Subject: [PATCH] feat: RouteDetail page Implements the /routes/:id route with route header card (name, status badge, description), 5-card KPI strip (total executions, success rate, p50/p99 latency, inflight count), ProcessorTimeline showing aggregate processor stats across all executions, filtered DataTable of recent executions for the route, and error patterns section grouped by exception class. Uses useParams() to get route ID and navigates to /exchanges/:id on row click. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/RouteDetail/RouteDetail.module.css | 280 +++++++++++++ src/pages/RouteDetail/RouteDetail.tsx | 411 +++++++++++++++++++ 2 files changed, 691 insertions(+) create mode 100644 src/pages/RouteDetail/RouteDetail.module.css create mode 100644 src/pages/RouteDetail/RouteDetail.tsx diff --git a/src/pages/RouteDetail/RouteDetail.module.css b/src/pages/RouteDetail/RouteDetail.module.css new file mode 100644 index 0000000..c52cdbf --- /dev/null +++ b/src/pages/RouteDetail/RouteDetail.module.css @@ -0,0 +1,280 @@ +/* Scrollable content area */ +.content { + flex: 1; + overflow-y: auto; + padding: 20px 24px 40px; + min-width: 0; + background: var(--bg-body); +} + +/* Route header card */ +.routeHeader { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px 20px; + margin-bottom: 14px; +} + +.routeTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.routeTitleGroup { + display: flex; + align-items: center; + gap: 10px; +} + +.routeName { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + font-family: var(--font-mono); + letter-spacing: -0.3px; +} + +.routeMeta { + display: flex; + align-items: center; + gap: 12px; +} + +.routeGroup { + font-size: 11px; + font-family: var(--font-mono); + color: var(--text-muted); + background: var(--bg-inset); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 2px 8px; +} + +.routeDescription { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* KPI strip */ +.kpiStrip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + margin-bottom: 16px; +} + +.kpiCard { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: 12px 16px; + text-align: center; +} + +.kpiLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.kpiValue { + font-size: 22px; + font-weight: 700; + font-family: var(--font-mono); + color: var(--text-primary); + line-height: 1.2; +} + +.kpiGood { color: var(--success); } +.kpiWarn { color: var(--warning); } +.kpiError { color: var(--error); } +.kpiRunning { color: var(--running); } + +/* Section layout */ +.section { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + margin-bottom: 16px; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.sectionTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.sectionMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.emptyMsg { + padding: 32px; + text-align: center; + font-size: 12px; + color: var(--text-muted); +} + +/* Executions table */ +.tableSection { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + margin-bottom: 16px; +} + +.tableHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.tableTitle { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.tableRight { + display: flex; + align-items: center; + gap: 10px; +} + +.tableMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* Status cell */ +.statusCell { + display: flex; + align-items: center; + gap: 5px; +} + +/* Customer text */ +.customerText { + color: var(--text-secondary); +} + +/* Duration color classes */ +.durFast { color: var(--success); } +.durNormal { color: var(--text-secondary); } +.durSlow { color: var(--warning); } +.durBreach { color: var(--error); } + +/* Agent badge */ +.agentBadge { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); +} + +.agentDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #5db866; + box-shadow: 0 0 4px rgba(93, 184, 102, 0.4); + flex-shrink: 0; +} + +/* Inline error row */ +.inlineError { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 12px; + background: var(--error-bg); + border-left: 3px solid var(--error-border); +} + +.inlineErrorIcon { + color: var(--error); + font-size: 14px; + flex-shrink: 0; + margin-top: 1px; +} + +.errorClass { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--error); + margin-bottom: 4px; +} + +.errorText { + font-size: 11px; + color: var(--error); + font-family: var(--font-mono); + line-height: 1.4; +} + +/* Error patterns section */ +.errorPatterns { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.errorPattern { + background: var(--error-bg); + border: 1px solid var(--error-border); + border-radius: var(--radius-md); + padding: 10px 14px; +} + +.errorPatternHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.errorPatternMessage { + font-size: 11px; + color: var(--text-secondary); + font-family: var(--font-mono); + line-height: 1.5; + margin-bottom: 4px; + white-space: pre-wrap; + word-break: break-word; +} + +.errorPatternTime { + font-size: 10px; + color: var(--text-muted); + font-family: var(--font-mono); +} diff --git a/src/pages/RouteDetail/RouteDetail.tsx b/src/pages/RouteDetail/RouteDetail.tsx new file mode 100644 index 0000000..41478f3 --- /dev/null +++ b/src/pages/RouteDetail/RouteDetail.tsx @@ -0,0 +1,411 @@ +import { useMemo, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import styles from './RouteDetail.module.css' + +// Layout +import { AppShell } from '../../design-system/layout/AppShell/AppShell' +import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar' +import { TopBar } from '../../design-system/layout/TopBar/TopBar' + +// Composites +import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline' +import { DataTable } from '../../design-system/composites/DataTable/DataTable' +import type { Column } from '../../design-system/composites/DataTable/types' + +// Primitives +import { Badge } from '../../design-system/primitives/Badge/Badge' +import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot' +import { MonoText } from '../../design-system/primitives/MonoText/MonoText' +import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout' + +// Mock data +import { routes } from '../../mocks/routes' +import { executions, type Execution } from '../../mocks/executions' +import { agents } from '../../mocks/agents' + +// ─── Sidebar data (shared) ──────────────────────────────────────────────────── +const APPS = [ + { id: 'order-service', name: 'order-service', agentCount: 2, health: 'live' as const, execCount: 1433 }, + { id: 'payment-svc', name: 'payment-svc', agentCount: 1, health: 'live' as const, execCount: 912 }, + { id: 'shipment-tracker', name: 'shipment-tracker', agentCount: 2, health: 'live' as const, execCount: 471 }, + { id: 'notification-hub', name: 'notification-hub', agentCount: 1, health: 'stale' as const, execCount: 128 }, +] + +const SIDEBAR_ROUTES = routes.slice(0, 3).map((r) => ({ + id: r.id, + name: r.name, + execCount: r.execCount, +})) + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function formatDuration(ms: number): string { + if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s` + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s` + return `${ms}ms` +} + +function formatTimestamp(date: Date): string { + return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +function statusToVariant(status: Execution['status']): 'success' | 'error' | 'running' | 'warning' { + switch (status) { + case 'completed': return 'success' + case 'failed': return 'error' + case 'running': return 'running' + case 'warning': return 'warning' + } +} + +function statusLabel(status: Execution['status']): string { + switch (status) { + case 'completed': return 'OK' + case 'failed': return 'ERR' + case 'running': return 'RUN' + case 'warning': return 'WARN' + } +} + +function routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success' | 'warning' | 'error' { + switch (status) { + case 'healthy': return 'success' + case 'degraded': return 'warning' + case 'down': return 'error' + } +} + +function durationClass(ms: number, status: Execution['status']): string { + if (status === 'failed') return styles.durBreach + if (ms < 100) return styles.durFast + if (ms < 200) return styles.durNormal + if (ms < 300) return styles.durSlow + return styles.durBreach +} + +// ─── Columns for executions table ──────────────────────────────────────────── +const EXEC_COLUMNS: Column[] = [ + { + key: 'status', + header: 'Status', + width: '80px', + render: (_, row) => ( + + + {statusLabel(row.status)} + + ), + }, + { + key: 'id', + header: 'Execution ID', + render: (_, row) => {row.id}, + }, + { + key: 'orderId', + header: 'Order ID', + sortable: true, + render: (_, row) => {row.orderId}, + }, + { + key: 'customer', + header: 'Customer', + render: (_, row) => {row.customer}, + }, + { + key: 'timestamp', + header: 'Started', + sortable: true, + render: (_, row) => {formatTimestamp(row.timestamp)}, + }, + { + key: 'durationMs', + header: 'Duration', + sortable: true, + render: (_, row) => ( + + {formatDuration(row.durationMs)} + + ), + }, + { + key: 'agent', + header: 'Agent', + render: (_, row) => ( + + + {row.agent} + + ), + }, +] + +// ─── RouteDetail component ──────────────────────────────────────────────────── +export function RouteDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const [activeItem, setActiveItem] = useState(id ?? '') + + const route = useMemo(() => routes.find((r) => r.id === id), [id]) + const routeExecutions = useMemo( + () => executions.filter((e) => e.route === id), + [id], + ) + + // Error patterns grouped by exception class + const errorPatterns = useMemo(() => { + const patterns: Record = {} + for (const exec of routeExecutions) { + if (exec.status === 'failed' && exec.errorClass) { + if (!patterns[exec.errorClass]) { + patterns[exec.errorClass] = { + count: 0, + lastMessage: exec.errorMessage ?? '', + lastTime: exec.timestamp, + } + } + patterns[exec.errorClass].count++ + if (exec.timestamp > patterns[exec.errorClass].lastTime) { + patterns[exec.errorClass].lastTime = exec.timestamp + patterns[exec.errorClass].lastMessage = exec.errorMessage ?? '' + } + } + } + return Object.entries(patterns) + }, [routeExecutions]) + + // Build aggregate processor timeline from all executions for this route + const aggregateProcessors = useMemo(() => { + if (routeExecutions.length === 0) return [] + // Use the first execution's processors as the template, with averaged durations + const templateExec = routeExecutions[0] + if (!templateExec) return [] + return templateExec.processors.map((proc) => { + const allDurations = routeExecutions + .flatMap((e) => e.processors) + .filter((p) => p.name === proc.name) + .map((p) => p.durationMs) + const avgDuration = allDurations.length + ? Math.round(allDurations.reduce((a, b) => a + b, 0) / allDurations.length) + : proc.durationMs + const hasFailures = routeExecutions.some((e) => + e.processors.some((p) => p.name === proc.name && p.status === 'fail'), + ) + const hasSlows = routeExecutions.some((e) => + e.processors.some((p) => p.name === proc.name && p.status === 'slow'), + ) + return { + ...proc, + durationMs: avgDuration, + status: hasFailures ? ('fail' as const) : hasSlows ? ('slow' as const) : ('ok' as const), + } + }) + }, [routeExecutions]) + + const totalAggregateMs = aggregateProcessors.reduce((sum, p) => sum + p.durationMs, 0) + + const inflightCount = routeExecutions.filter((e) => e.status === 'running').length + const successCount = routeExecutions.filter((e) => e.status === 'completed').length + const errorCount = routeExecutions.filter((e) => e.status === 'failed').length + const successRate = routeExecutions.length + ? ((successCount / routeExecutions.length) * 100).toFixed(1) + : '0.0' + + function handleItemClick(itemId: string) { + setActiveItem(itemId) + const r = routes.find((route) => route.id === itemId) + if (r) navigate(`/routes/${itemId}`) + } + + // Not found state + if (!route) { + return ( + + } + > + +
+ Route "{id}" not found in mock data. +
+
+ ) + } + + const statusVariant = routeStatusVariant(route.status) + + return ( + + } + > + {/* Top bar */} + + + {/* Scrollable content */} +
+ + {/* Route header card */} +
+
+
+ +

{route.name}

+ +
+
+ {route.group} +
+
+

{route.description}

+
+ + {/* KPI strip */} +
+
+
Total Executions
+
{route.execCount.toLocaleString()}
+
+
+
Success Rate
+
= 99 ? styles.kpiGood : route.successRate >= 97 ? styles.kpiWarn : styles.kpiError + }`}> + {route.successRate}% +
+
+
+
Avg Latency (p50)
+
{route.avgDurationMs}ms
+
+
+
p99 Latency
+
300 ? styles.kpiError : route.p99DurationMs > 200 ? styles.kpiWarn : styles.kpiGood}`}> + {route.p99DurationMs}ms +
+
+
+
Inflight
+
0 ? styles.kpiRunning : ''}`}> + {inflightCount} +
+
+
+ + {/* Processor timeline (aggregate view) */} +
+
+ Processor Performance (aggregate avg) + Based on {routeExecutions.length} executions +
+ {aggregateProcessors.length > 0 ? ( + + ) : ( +
No execution data for this route in mock set.
+ )} +
+ + {/* Recent executions table */} +
+
+ Recent Executions +
+ + {routeExecutions.length} executions · {errorCount} errors + + = 99 ? 'success' : parseFloat(successRate) >= 97 ? 'warning' : 'error'} + /> +
+
+ { + if (row.status === 'failed') return 'error' + if (row.status === 'warning') return 'warning' + return undefined + }} + expandedContent={(row) => + row.errorMessage ? ( +
+ +
+
{row.errorClass}
+
{row.errorMessage}
+
+
+ ) : null + } + onRowClick={(row) => navigate(`/exchanges/${row.id}`)} + /> +
+ + {/* Error patterns section */} + {errorPatterns.length > 0 && ( +
+
+ Error Patterns + {errorPatterns.length} distinct exception types +
+
+ {errorPatterns.map(([cls, info]) => ( +
+
+ {cls} + +
+
{info.lastMessage}
+
+ Last seen: {formatTimestamp(info.lastTime)} +
+
+ ))} +
+
+ )} + +
+
+ ) +}