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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 10:22:02 +01:00
parent 9dd78a7d2e
commit ebf653e848
2 changed files with 691 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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<Execution>[] = [
{
key: 'status',
header: 'Status',
width: '80px',
render: (_, row) => (
<span className={styles.statusCell}>
<StatusDot variant={statusToVariant(row.status)} />
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
</span>
),
},
{
key: 'id',
header: 'Execution ID',
render: (_, row) => <MonoText size="xs">{row.id}</MonoText>,
},
{
key: 'orderId',
header: 'Order ID',
sortable: true,
render: (_, row) => <MonoText size="sm">{row.orderId}</MonoText>,
},
{
key: 'customer',
header: 'Customer',
render: (_, row) => <MonoText size="xs" className={styles.customerText}>{row.customer}</MonoText>,
},
{
key: 'timestamp',
header: 'Started',
sortable: true,
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
},
{
key: 'durationMs',
header: 'Duration',
sortable: true,
render: (_, row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
{formatDuration(row.durationMs)}
</MonoText>
),
},
{
key: 'agent',
header: 'Agent',
render: (_, row) => (
<span className={styles.agentBadge}>
<span className={styles.agentDot} />
{row.agent}
</span>
),
},
]
// ─── 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<string, { count: number; lastMessage: string; lastTime: Date }> = {}
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 (
<AppShell
sidebar={
<Sidebar
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
}
>
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Routes' },
{ label: id ?? 'Unknown' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
<InfoCallout variant="warning">Route "{id}" not found in mock data.</InfoCallout>
</div>
</AppShell>
)
}
const statusVariant = routeStatusVariant(route.status)
return (
<AppShell
sidebar={
<Sidebar
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
}
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Routes', href: '/' },
{ label: route.name },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Route header card */}
<div className={styles.routeHeader}>
<div className={styles.routeTitleRow}>
<div className={styles.routeTitleGroup}>
<StatusDot variant={statusVariant} />
<h1 className={styles.routeName}>{route.name}</h1>
<Badge
label={route.status.toUpperCase()}
color={statusVariant}
variant="filled"
/>
</div>
<div className={styles.routeMeta}>
<span className={styles.routeGroup}>{route.group}</span>
</div>
</div>
<p className={styles.routeDescription}>{route.description}</p>
</div>
{/* KPI strip */}
<div className={styles.kpiStrip}>
<div className={styles.kpiCard}>
<div className={styles.kpiLabel}>Total Executions</div>
<div className={styles.kpiValue}>{route.execCount.toLocaleString()}</div>
</div>
<div className={styles.kpiCard}>
<div className={styles.kpiLabel}>Success Rate</div>
<div className={`${styles.kpiValue} ${
route.successRate >= 99 ? styles.kpiGood : route.successRate >= 97 ? styles.kpiWarn : styles.kpiError
}`}>
{route.successRate}%
</div>
</div>
<div className={styles.kpiCard}>
<div className={styles.kpiLabel}>Avg Latency (p50)</div>
<div className={styles.kpiValue}>{route.avgDurationMs}ms</div>
</div>
<div className={styles.kpiCard}>
<div className={styles.kpiLabel}>p99 Latency</div>
<div className={`${styles.kpiValue} ${route.p99DurationMs > 300 ? styles.kpiError : route.p99DurationMs > 200 ? styles.kpiWarn : styles.kpiGood}`}>
{route.p99DurationMs}ms
</div>
</div>
<div className={styles.kpiCard}>
<div className={styles.kpiLabel}>Inflight</div>
<div className={`${styles.kpiValue} ${inflightCount > 0 ? styles.kpiRunning : ''}`}>
{inflightCount}
</div>
</div>
</div>
{/* Processor timeline (aggregate view) */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Processor Performance (aggregate avg)</span>
<span className={styles.sectionMeta}>Based on {routeExecutions.length} executions</span>
</div>
{aggregateProcessors.length > 0 ? (
<ProcessorTimeline
processors={aggregateProcessors}
totalMs={totalAggregateMs}
/>
) : (
<div className={styles.emptyMsg}>No execution data for this route in mock set.</div>
)}
</div>
{/* Recent executions table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Executions</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{routeExecutions.length} executions · {errorCount} errors
</span>
<Badge
label={`${successRate}% ok`}
color={parseFloat(successRate) >= 99 ? 'success' : parseFloat(successRate) >= 97 ? 'warning' : 'error'}
/>
</div>
</div>
<DataTable
columns={EXEC_COLUMNS}
data={routeExecutions}
sortable
rowAccent={(row) => {
if (row.status === 'failed') return 'error'
if (row.status === 'warning') return 'warning'
return undefined
}}
expandedContent={(row) =>
row.errorMessage ? (
<div className={styles.inlineError}>
<span className={styles.inlineErrorIcon}></span>
<div>
<div className={styles.errorClass}>{row.errorClass}</div>
<div className={styles.errorText}>{row.errorMessage}</div>
</div>
</div>
) : null
}
onRowClick={(row) => navigate(`/exchanges/${row.id}`)}
/>
</div>
{/* Error patterns section */}
{errorPatterns.length > 0 && (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Error Patterns</span>
<span className={styles.sectionMeta}>{errorPatterns.length} distinct exception types</span>
</div>
<div className={styles.errorPatterns}>
{errorPatterns.map(([cls, info]) => (
<div key={cls} className={styles.errorPattern}>
<div className={styles.errorPatternHeader}>
<MonoText size="xs" className={styles.errorClass}>{cls}</MonoText>
<Badge label={`${info.count}x`} color="error" />
</div>
<div className={styles.errorPatternMessage}>{info.lastMessage}</div>
<div className={styles.errorPatternTime}>
Last seen: {formatTimestamp(info.lastTime)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</AppShell>
)
}