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:
280
src/pages/RouteDetail/RouteDetail.module.css
Normal file
280
src/pages/RouteDetail/RouteDetail.module.css
Normal 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);
|
||||
}
|
||||
411
src/pages/RouteDetail/RouteDetail.tsx
Normal file
411
src/pages/RouteDetail/RouteDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user