feat: ExchangeDetail and AgentHealth pages

ExchangeDetail (/exchanges/:id): exchange header card with ID/route/
status/duration, ProcessorTimeline for the specific exchange, step-by-
step exchange inspector using Collapsible+CodeBlock for headers/body at
each processor step, and error details block for failed exchanges.

AgentHealth (/agents): 6-card system overview strip, 2-column grid of
agent cards (StatusDot, name, version, tps, uptime, last-seen, CPU/mem
usage, active routes), expandable per-agent LineCharts for throughput
and error rate trends. Both pages use AppShell + shared Sidebar layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 10:22:11 +01:00
parent ebf653e848
commit 82f7f88820
4 changed files with 1049 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
/* System overview strip */
.overviewStrip {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
margin-bottom: 16px;
}
.overviewCard {
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;
}
.overviewLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 4px;
}
.overviewValue {
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
line-height: 1.2;
}
.valueLive { color: var(--success); }
.valueStale { color: var(--warning); }
.valueDead { color: var(--error); }
/* Section header */
.sectionHeaderRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sectionTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.sectionMeta {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* Agent cards grid */
.agentGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
/* Agent card */
.agentCard {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
padding: 0 !important;
}
/* Agent card header */
.agentCardHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
cursor: pointer;
transition: background 0.15s;
}
.agentCardHeader:hover {
background: var(--bg-hover);
}
.agentCardLeft {
display: flex;
align-items: center;
gap: 10px;
}
.agentCardName {
font-size: 14px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
}
.agentCardService {
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
}
.agentCardRight {
display: flex;
align-items: center;
gap: 10px;
}
.expandIcon {
font-size: 10px;
color: var(--text-muted);
}
/* Agent metrics row */
.agentMetrics {
display: flex;
flex-wrap: wrap;
gap: 0;
padding: 6px 12px 12px;
border-top: 1px solid var(--border-subtle);
}
.agentMetric {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 6px 12px;
min-width: 80px;
}
.metricLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 2px;
}
.metricValue {
color: var(--text-primary);
}
.metricValueWarn {
color: var(--warning);
font-family: var(--font-mono);
font-size: 12px;
}
.metricValueError {
color: var(--error);
font-family: var(--font-mono);
font-size: 12px;
}
/* Expanded charts area */
.agentCharts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px 16px;
background: var(--bg-raised);
border-top: 1px solid var(--border-subtle);
}
.agentChart {
display: flex;
flex-direction: column;
gap: 6px;
}
.chartTitle {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}

View File

@@ -0,0 +1,262 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import styles from './AgentHealth.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 { LineChart } from '../../design-system/composites/LineChart/LineChart'
// Primitives
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { Badge } from '../../design-system/primitives/Badge/Badge'
import { Card } from '../../design-system/primitives/Card/Card'
// Mock data
import { agents } from '../../mocks/agents'
import { routes } from '../../mocks/routes'
// ─── 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,
}))
// ─── Build trend data for each agent ─────────────────────────────────────────
function buildAgentTrendSeries(agentId: string) {
const baseValues: Record<string, { throughput: number; errorRate: number }> = {
'prod-1': { throughput: 14.2, errorRate: 0.2 },
'prod-2': { throughput: 11.8, errorRate: 3.1 },
'prod-3': { throughput: 12.1, errorRate: 0.5 },
'prod-4': { throughput: 9.1, errorRate: 0.3 },
}
const base = baseValues[agentId] ?? { throughput: 10, errorRate: 1 }
const now = new Date('2026-03-18T09:15:00')
const points = 20
const intervalMs = (3 * 60 * 60 * 1000) / points // 3 hours
const throughputData = Array.from({ length: points }, (_, i) => ({
x: new Date(now.getTime() - (points - i) * intervalMs),
y: Math.max(0, base.throughput + (Math.random() - 0.5) * 4),
}))
const errorRateData = Array.from({ length: points }, (_, i) => ({
x: new Date(now.getTime() - (points - i) * intervalMs),
y: Math.max(0, base.errorRate + (Math.random() - 0.5) * 2),
}))
return { throughputData, errorRateData }
}
// ─── Summary stats ────────────────────────────────────────────────────────────
const liveCount = agents.filter((a) => a.status === 'live').length
const totalTps = agents.reduce((sum, a) => sum + parseFloat(a.tps), 0)
const totalActiveRoutes = agents.reduce((sum, a) => sum + a.activeRoutes, 0)
// ─── AgentHealth page ─────────────────────────────────────────────────────────
export function AgentHealth() {
const navigate = useNavigate()
const [activeItem, setActiveItem] = useState('agents')
const [expandedAgent, setExpandedAgent] = useState<string | null>(null)
function handleItemClick(id: string) {
setActiveItem(id)
const route = routes.find((r) => r.id === id)
if (route) navigate(`/routes/${id}`)
}
function toggleAgent(id: string) {
setExpandedAgent((prev) => (prev === id ? null : id))
}
return (
<AppShell
sidebar={
<Sidebar
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
}
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Agents' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* System overview strip */}
<div className={styles.overviewStrip}>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Total Agents</div>
<div className={styles.overviewValue}>{agents.length}</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Live</div>
<div className={`${styles.overviewValue} ${styles.valueLive}`}>{liveCount}</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Stale</div>
<div className={`${styles.overviewValue} ${agents.some(a => a.status === 'stale') ? styles.valueStale : ''}`}>
{agents.filter((a) => a.status === 'stale').length}
</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Dead</div>
<div className={`${styles.overviewValue} ${agents.some(a => a.status === 'dead') ? styles.valueDead : ''}`}>
{agents.filter((a) => a.status === 'dead').length}
</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Total TPS</div>
<div className={styles.overviewValue}>{totalTps.toFixed(1)}/s</div>
</div>
<div className={styles.overviewCard}>
<div className={styles.overviewLabel}>Active Routes</div>
<div className={styles.overviewValue}>{totalActiveRoutes}</div>
</div>
</div>
{/* Section header */}
<div className={styles.sectionHeaderRow}>
<span className={styles.sectionTitle}>Agent Details</span>
<span className={styles.sectionMeta}>{liveCount}/{agents.length} live · Click to expand charts</span>
</div>
{/* Agent cards grid */}
<div className={styles.agentGrid}>
{agents.map((agent) => {
const isExpanded = expandedAgent === agent.id
const trendData = isExpanded ? buildAgentTrendSeries(agent.id) : null
const statusVariant = agent.status === 'live' ? 'live' : agent.status === 'stale' ? 'stale' : 'dead'
const cardAccent = agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'
return (
<Card
key={agent.id}
accent={cardAccent as 'success' | 'warning' | 'error'}
className={styles.agentCard}
>
{/* Agent card header */}
<div
className={styles.agentCardHeader}
onClick={() => toggleAgent(agent.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') toggleAgent(agent.id)
}}
aria-expanded={isExpanded}
>
<div className={styles.agentCardLeft}>
<StatusDot variant={statusVariant} />
<div>
<div className={styles.agentCardName}>{agent.name}</div>
<div className={styles.agentCardService}>{agent.service} {agent.version}</div>
</div>
</div>
<div className={styles.agentCardRight}>
<Badge
label={agent.status.toUpperCase()}
color={agent.status === 'live' ? 'success' : agent.status === 'stale' ? 'warning' : 'error'}
variant="filled"
/>
<span className={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</span>
</div>
</div>
{/* Agent metrics row */}
<div className={styles.agentMetrics}>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>TPS</span>
<MonoText size="sm" className={styles.metricValue}>{agent.tps}</MonoText>
</div>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Uptime</span>
<MonoText size="sm" className={styles.metricValue}>{agent.uptime}</MonoText>
</div>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Last Seen</span>
<MonoText size="sm" className={styles.metricValue}>{agent.lastSeen}</MonoText>
</div>
{agent.errorRate && (
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Error Rate</span>
<MonoText size="sm" className={styles.metricValueError}>{agent.errorRate}</MonoText>
</div>
)}
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>CPU</span>
<MonoText size="sm" className={agent.cpuUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}>
{agent.cpuUsagePct}%
</MonoText>
</div>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Memory</span>
<MonoText size="sm" className={agent.memoryUsagePct > 80 ? styles.metricValueError : agent.memoryUsagePct > 70 ? styles.metricValueWarn : styles.metricValue}>
{agent.memoryUsagePct}%
</MonoText>
</div>
<div className={styles.agentMetric}>
<span className={styles.metricLabel}>Routes</span>
<MonoText size="sm" className={styles.metricValue}>
{agent.activeRoutes}/{agent.totalRoutes}
</MonoText>
</div>
</div>
{/* Expanded detail: trend charts */}
{isExpanded && trendData && (
<div className={styles.agentCharts}>
<div className={styles.agentChart}>
<div className={styles.chartTitle}>Throughput (msg/s)</div>
<LineChart
series={[{ label: 'tps', data: trendData.throughputData }]}
height={140}
width={380}
yLabel="msg/s"
/>
</div>
<div className={styles.agentChart}>
<div className={styles.chartTitle}>Error Rate (err/h)</div>
<LineChart
series={[{ label: 'errors', data: trendData.errorRateData, color: 'var(--error)' }]}
height={140}
width={380}
yLabel="err/h"
/>
</div>
</div>
)}
</Card>
)
})}
</div>
</div>
</AppShell>
)
}

View File

@@ -0,0 +1,264 @@
/* Scrollable content area */
.content {
flex: 1;
overflow-y: auto;
padding: 20px 24px 40px;
min-width: 0;
background: var(--bg-body);
}
/* Exchange header card */
.exchangeHeader {
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;
}
.headerRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.headerLeft {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
}
.exchangeId {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.exchangeRoute {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.routeLink {
color: var(--amber);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.routeLink:hover {
color: var(--amber-deep);
}
.headerDivider {
color: var(--text-faint);
}
.headerRight {
display: flex;
gap: 20px;
flex-shrink: 0;
}
.headerStat {
text-align: center;
}
.headerStatLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 2px;
}
.headerStatValue {
font-size: 14px;
font-weight: 600;
font-family: var(--font-mono);
color: var(--text-primary);
}
/* 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);
}
/* Timeline wrapper */
.timelineWrap {
padding: 12px 16px;
}
/* Inspector steps */
.inspectorSteps {
display: flex;
flex-direction: column;
}
.stepCollapsible {
border-bottom: 1px solid var(--border-subtle);
}
.stepCollapsible:last-child {
border-bottom: none;
}
.stepTitle {
display: flex;
align-items: center;
gap: 10px;
}
.stepIndex {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
font-family: var(--font-mono);
flex-shrink: 0;
}
.stepOk {
background: var(--success-bg);
color: var(--success);
border: 1px solid var(--success-border);
}
.stepSlow {
background: var(--warning-bg);
color: var(--warning);
border: 1px solid var(--warning-border);
}
.stepFail {
background: var(--error-bg);
color: var(--error);
border: 1px solid var(--error-border);
}
.stepName {
font-size: 12px;
font-weight: 500;
font-family: var(--font-mono);
color: var(--text-primary);
flex: 1;
}
.stepDuration {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-muted);
margin-left: auto;
flex-shrink: 0;
}
/* Step body (two-column layout) */
.stepBody {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 12px;
padding: 12px 16px;
background: var(--bg-raised);
}
.stepPanel {
display: flex;
flex-direction: column;
gap: 6px;
}
.stepPanelLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
}
.codeBlock {
flex: 1;
max-height: 200px;
overflow-y: auto;
}
/* Error section */
.errorSection {
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 16px;
}
.errorBody {
padding: 16px;
}
.errorClass {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
color: var(--error);
margin-bottom: 8px;
}
.errorMessage {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-surface);
border: 1px solid var(--error-border);
border-radius: var(--radius-sm);
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
margin-bottom: 8px;
}
.errorHint {
font-size: 11px;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 5px;
}

View File

@@ -0,0 +1,331 @@
import { useMemo, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import styles from './ExchangeDetail.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 type { ProcessorStep } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
// 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 { Collapsible } from '../../design-system/primitives/Collapsible/Collapsible'
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
// Mock data
import { executions } from '../../mocks/executions'
import { routes } from '../../mocks/routes'
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 statusToVariant(status: 'completed' | 'failed' | 'running' | 'warning'): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string {
switch (status) {
case 'completed': return 'COMPLETED'
case 'failed': return 'FAILED'
case 'running': return 'RUNNING'
case 'warning': return 'WARNING'
}
}
// ─── Exchange body mock generator ────────────────────────────────────────────
// For each processor step, generate a plausible exchange body snapshot
function generateExchangeSnapshot(
step: ProcessorStep,
orderId: string,
customer: string,
stepIndex: number,
) {
const baseBody = {
orderId,
customer,
status: step.status === 'fail' ? 'ERROR' : 'PROCESSING',
processorStep: step.name,
stepIndex,
}
const headers: Record<string, string> = {
'CamelCorrelationId': `cmr-${Math.random().toString(36).slice(2, 10)}`,
'Content-Type': 'application/json',
'CamelTimerName': step.name,
'CamelBreadcrumbId': `${orderId}-${stepIndex}`,
}
if (stepIndex === 0) {
return {
headers,
body: JSON.stringify({
...baseBody,
raw: { orderId, customer, items: ['ITEM-001', 'ITEM-002'], total: 142.50 },
}, null, 2),
}
}
if (step.type === 'enrich') {
return {
headers: {
...headers,
'enrichedBy': step.name.replace('enrich(', '').replace(')', ''),
},
body: JSON.stringify({
...baseBody,
enrichment: { source: step.name, addedFields: ['customerId', 'address', 'tier'] },
}, null, 2),
}
}
return {
headers,
body: JSON.stringify(baseBody, null, 2),
}
}
// ─── ExchangeDetail component ─────────────────────────────────────────────────
export function ExchangeDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [activeItem, setActiveItem] = useState('')
const execution = useMemo(() => executions.find((e) => e.id === id), [id])
function handleItemClick(itemId: string) {
setActiveItem(itemId)
const route = routes.find((r) => r.id === itemId)
if (route) navigate(`/routes/${itemId}`)
}
// Not found state
if (!execution) {
return (
<AppShell
sidebar={
<Sidebar
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
}
>
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: 'Exchanges' },
{ label: id ?? 'Unknown' },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
</div>
</AppShell>
)
}
const statusVariant = statusToVariant(execution.status)
const statusLabel = statusToLabel(execution.status)
return (
<AppShell
sidebar={
<Sidebar
apps={APPS}
routes={SIDEBAR_ROUTES}
agents={agents}
activeItem={activeItem}
onItemClick={handleItemClick}
/>
}
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Dashboard', href: '/' },
{ label: execution.route, href: `/routes/${execution.route}` },
{ label: execution.id },
]}
environment="PRODUCTION"
shift="Day (06:00-18:00)"
user={{ name: 'hendrik' }}
/>
{/* Scrollable content */}
<div className={styles.content}>
{/* Exchange header */}
<div className={styles.exchangeHeader}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
<StatusDot variant={statusVariant} />
<div>
<div className={styles.exchangeId}>
<MonoText size="md">{execution.id}</MonoText>
<Badge label={statusLabel} color={statusVariant} variant="filled" />
</div>
<div className={styles.exchangeRoute}>
Route: <span className={styles.routeLink} onClick={() => navigate(`/routes/${execution.route}`)}>{execution.route}</span>
<span className={styles.headerDivider}>·</span>
Order: <MonoText size="xs">{execution.orderId}</MonoText>
<span className={styles.headerDivider}>·</span>
Customer: <MonoText size="xs">{execution.customer}</MonoText>
</div>
</div>
</div>
<div className={styles.headerRight}>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Duration</div>
<div className={styles.headerStatValue}>{formatDuration(execution.durationMs)}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Agent</div>
<div className={styles.headerStatValue}>{execution.agent}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Started</div>
<div className={styles.headerStatValue}>
{execution.timestamp.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Processors</div>
<div className={styles.headerStatValue}>{execution.processors.length}</div>
</div>
</div>
</div>
</div>
{/* Processor timeline */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Processor Timeline</span>
<span className={styles.sectionMeta}>Total: {formatDuration(execution.durationMs)}</span>
</div>
<div className={styles.timelineWrap}>
<ProcessorTimeline
processors={execution.processors}
totalMs={execution.durationMs}
/>
</div>
</div>
{/* Step-by-step inspector */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Exchange Inspector</span>
<span className={styles.sectionMeta}>{execution.processors.length} processor steps</span>
</div>
<div className={styles.inspectorSteps}>
{execution.processors.map((proc, index) => {
const snapshot = generateExchangeSnapshot(proc, execution.orderId, execution.customer, index)
const stepStatusClass =
proc.status === 'fail'
? styles.stepFail
: proc.status === 'slow'
? styles.stepSlow
: styles.stepOk
return (
<Collapsible
key={index}
title={
<div className={styles.stepTitle}>
<span className={`${styles.stepIndex} ${stepStatusClass}`}>{index + 1}</span>
<span className={styles.stepName}>{proc.name}</span>
<Badge
label={proc.status.toUpperCase()}
color={proc.status === 'fail' ? 'error' : proc.status === 'slow' ? 'warning' : 'success'}
variant="outlined"
/>
<span className={styles.stepDuration}>{formatDuration(proc.durationMs)}</span>
</div>
}
defaultOpen={proc.status === 'fail'}
className={styles.stepCollapsible}
>
<div className={styles.stepBody}>
<div className={styles.stepPanel}>
<div className={styles.stepPanelLabel}>Exchange Headers</div>
<CodeBlock
content={JSON.stringify(snapshot.headers, null, 2)}
language="json"
copyable
className={styles.codeBlock}
/>
</div>
<div className={styles.stepPanel}>
<div className={styles.stepPanelLabel}>Exchange Body</div>
<CodeBlock
content={snapshot.body}
language="json"
copyable
className={styles.codeBlock}
/>
</div>
</div>
</Collapsible>
)
})}
</div>
</div>
{/* Error block (if failed) */}
{execution.status === 'failed' && execution.errorMessage && (
<div className={styles.errorSection}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Error Details</span>
<Badge label="FAILED" color="error" />
</div>
<div className={styles.errorBody}>
<div className={styles.errorClass}>{execution.errorClass}</div>
<pre className={styles.errorMessage}>{execution.errorMessage}</pre>
<div className={styles.errorHint}>
Failed at processor: <MonoText size="xs">
{execution.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'}
</MonoText>
</div>
</div>
</div>
)}
</div>
</AppShell>
)
}