Files
design-system/src/pages/RouteDetail/RouteDetail.tsx
hsiegeln 433d582da6
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
feat: migrate all icons to Lucide React
Replace unicode characters, emoji, and inline SVGs with lucide-react
components across the entire design system and page layer. Update
tests to assert on SVG elements instead of text content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:25:43 +01:00

381 lines
14 KiB
TypeScript

import { useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { AlertTriangle } from 'lucide-react'
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 { exchanges, type Exchange } from '../../mocks/exchanges'
import { SIDEBAR_APPS } from '../../mocks/sidebar'
// ─── 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: Exchange['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: Exchange['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: Exchange['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 exchanges table ────────────────────────────────────────────
const EXCHANGE_COLUMNS: Column<Exchange>[] = [
{
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: 'Exchange 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 route = useMemo(() => routes.find((r) => r.id === id), [id])
const routeExchanges = useMemo(
() => exchanges.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 routeExchanges) {
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)
}, [routeExchanges])
// Build aggregate processor timeline from all exchanges for this route
const aggregateProcessors = useMemo(() => {
if (routeExchanges.length === 0) return []
// Use the first exchange's processors as the template, with averaged durations
const templateExec = routeExchanges[0]
if (!templateExec) return []
return templateExec.processors.map((proc) => {
const allDurations = routeExchanges
.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 = routeExchanges.some((e) =>
e.processors.some((p) => p.name === proc.name && p.status === 'fail'),
)
const hasSlows = routeExchanges.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),
}
})
}, [routeExchanges])
const totalAggregateMs = aggregateProcessors.reduce((sum, p) => sum + p.durationMs, 0)
const inflightCount = routeExchanges.filter((e) => e.status === 'running').length
const successCount = routeExchanges.filter((e) => e.status === 'completed').length
const errorCount = routeExchanges.filter((e) => e.status === 'failed').length
const successRate = routeExchanges.length
? ((successCount / routeExchanges.length) * 100).toFixed(1)
: '0.0'
// Not found state
if (!route) {
return (
<AppShell
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/apps' },
{ label: 'Routes' },
{ label: id ?? 'Unknown' },
]}
environment="PRODUCTION"
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={SIDEBAR_APPS} />
}
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/apps' },
{ label: 'Routes', href: '/apps' },
{ label: route.name },
]}
environment="PRODUCTION"
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 Exchanges</div>
<div className={styles.kpiValue}>{route.exchangeCount.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 {routeExchanges.length} exchanges</span>
</div>
{aggregateProcessors.length > 0 ? (
<ProcessorTimeline
processors={aggregateProcessors}
totalMs={totalAggregateMs}
/>
) : (
<div className={styles.emptyMsg}>No exchange data for this route in mock set.</div>
)}
</div>
{/* Recent exchanges table */}
<div className={styles.tableSection}>
<div className={styles.tableHeader}>
<span className={styles.tableTitle}>Recent Exchanges</span>
<div className={styles.tableRight}>
<span className={styles.tableMeta}>
{routeExchanges.length} exchanges · {errorCount} errors
</span>
<Badge
label={`${successRate}% ok`}
color={parseFloat(successRate) >= 99 ? 'success' : parseFloat(successRate) >= 97 ? 'warning' : 'error'}
/>
</div>
</div>
<DataTable
columns={EXCHANGE_COLUMNS}
data={routeExchanges}
sortable
flush
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}><AlertTriangle size={14} /></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>
)
}