import { useState, useMemo } 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' import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow' import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow' // 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 { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock' import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout' // Mock data import { exchanges } from '../../mocks/exchanges' import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar' const ROUTE_TO_APP = buildRouteToAppMap() // ─── 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 generators ────────────────────────────────────────── 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 = { 'CamelCorrelationId': `cmr-${orderId.toLowerCase().replace('op-', '')}-${stepIndex}`, '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), } } function generateExchangeSnapshotOut( step: ProcessorStep, orderId: string, customer: string, stepIndex: number, ) { const statusResult = step.status === 'fail' ? 'ERROR' : step.status === 'slow' ? 'SLOW_OK' : 'OK' const baseBody = { orderId, customer, status: statusResult, processorStep: step.name, stepIndex, processed: true, } const headers: Record = { 'CamelCorrelationId': `cmr-${orderId.toLowerCase().replace('op-', '')}-${stepIndex}`, 'Content-Type': 'application/json', 'CamelTimerName': step.name, 'CamelBreadcrumbId': `${orderId}-${stepIndex}`, 'CamelProcessedAt': new Date().toISOString(), } if (step.type === 'enrich') { const source = step.name.replace('enrich(', '').replace(')', '') return { headers: { ...headers, 'enrichedBy': source, 'enrichmentComplete': 'true', }, body: JSON.stringify({ ...baseBody, enrichment: { source: step.name, addedFields: ['customerId', 'address', 'tier'], resolved: true }, }, null, 2), } } return { headers, body: JSON.stringify(baseBody, null, 2), } } // Map processor types to RouteNode types function toRouteNodeType(procType: string): RouteNode['type'] { switch (procType) { case 'consumer': return 'from' case 'transform': return 'process' case 'enrich': return 'process' default: return procType as RouteNode['type'] } } // ─── ExchangeDetail component ───────────────────────────────────────────────── export function ExchangeDetail() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const exchange = useMemo(() => exchanges.find((e) => e.id === id), [id]) // Find correlated exchanges, sorted by start time const correlatedExchanges = useMemo(() => { if (!exchange?.correlationGroup) return [] return exchanges .filter((e) => e.correlationGroup === exchange.correlationGroup) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) }, [exchange]) // Default selected processor: first failed, or 0 const defaultIndex = useMemo(() => { if (!exchange) return 0 const failIdx = exchange.processors.findIndex((p) => p.status === 'fail') return failIdx >= 0 ? failIdx : 0 }, [exchange]) const [selectedProcessorIndex, setSelectedProcessorIndex] = useState(defaultIndex) const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt') // Build RouteFlow nodes from exchange processors const routeNodes: RouteNode[] = useMemo(() => { if (!exchange) return [] return exchange.processors.map((p) => ({ name: p.name, type: toRouteNodeType(p.type), durationMs: p.durationMs, status: p.status, })) }, [exchange]) // Not found state if (!exchange) { return ( } >
Exchange "{id}" not found in mock data.
) } const statusVariant = statusToVariant(exchange.status) const statusLabel = statusToLabel(exchange.status) const selectedProc = exchange.processors[selectedProcessorIndex] const snapshotIn = selectedProc ? generateExchangeSnapshot(selectedProc, exchange.orderId, exchange.customer, selectedProcessorIndex) : null const snapshotOut = selectedProc ? generateExchangeSnapshotOut(selectedProc, exchange.orderId, exchange.customer, selectedProcessorIndex) : null const isSelectedFailed = selectedProc?.status === 'fail' return ( } > {/* Top bar */} {/* Scrollable content */}
{/* Exchange header card */}
{exchange.id}
Route: navigate(`/apps/${ROUTE_TO_APP.get(exchange.route) ?? exchange.route}/${exchange.route}`)}>{exchange.route} · Order: {exchange.orderId} · Customer: {exchange.customer}
Duration
{formatDuration(exchange.durationMs)}
Agent
{exchange.agent}
Started
{exchange.timestamp.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
Processors
{exchange.processors.length}
{/* Correlation Chain */} {correlatedExchanges.length > 1 && (
Correlated Exchanges {correlatedExchanges.map((ce) => { const isCurrent = ce.id === exchange.id const variant = statusToVariant(ce.status) const statusCls = variant === 'success' ? styles.chainNodeSuccess : variant === 'error' ? styles.chainNodeError : variant === 'running' ? styles.chainNodeRunning : styles.chainNodeWarning return ( ) })}
)}
{/* Processor Timeline Section */}
Processor Timeline {exchange.processors.length} processors
{timelineView === 'gantt' ? ( setSelectedProcessorIndex(index)} selectedIndex={selectedProcessorIndex} /> ) : ( setSelectedProcessorIndex(index)} selectedIndex={selectedProcessorIndex} /> )}
{/* Processor Detail Panel (split IN / OUT) */} {selectedProc && snapshotIn && snapshotOut && (
{/* Message IN */}
Message IN at processor #{selectedProcessorIndex + 1} entry
Headers {Object.keys(snapshotIn.headers).length}
{Object.entries(snapshotIn.headers).map(([key, value]) => (
{key} {value}
))}
Body
{/* Message OUT or Error */} {isSelectedFailed ? (
× Error at Processor #{selectedProcessorIndex + 1}
{exchange.errorClass && (
{exchange.errorClass.split('.').pop()}
)} {exchange.errorMessage && (
{exchange.errorMessage}
)}
Error Class {exchange.errorClass ?? 'Unknown'} Processor {selectedProc.name} Duration {formatDuration(selectedProc.durationMs)} Status {selectedProc.status.toUpperCase()}
) : (
Message OUT after processor #{selectedProcessorIndex + 1}
Headers {Object.keys(snapshotOut.headers).length}
{Object.entries(snapshotOut.headers).map(([key, value]) => (
{key} {value}
))}
Body
)}
)}
) }