Files
design-system/src/pages/RouteDetail/RouteDetail.tsx

412 lines
15 KiB
TypeScript
Raw Normal View History

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