- RouteDetail.tsx was missed in page stripping pass — remove AppShell + Sidebar wrapper, replace with fragment - LayoutSection.tsx used StatusDot status= instead of variant= Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
369 lines
13 KiB
TypeScript
369 lines
13 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 { 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'
|
|
|
|
// ─── 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 (
|
|
<>
|
|
<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>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const statusVariant = routeStatusVariant(route.status)
|
|
|
|
return (
|
|
<>
|
|
{/* 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>
|
|
</>
|
|
)
|
|
}
|