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)} +
+
+ ))} +
+
+ )} + +
+
+ ) +}