Files
design-system/src/pages/ExchangeDetail/ExchangeDetail.tsx
hsiegeln 9c9063dc1b
All checks were successful
Build & Publish / publish (push) Successful in 44s
refactor: unify /apps routing with application and route filtering
- Table columns: Status, Route, Application, Started (yyyy-mm-dd hh:mm:ss),
  Duration, Agent (removed Order ID and Customer)
- /apps shows all exchanges, /apps/:id filters by application,
  /apps/:id/:routeId filters by application and route
- Route paths changed from /routes/:id to /apps/:appId/:routeId across
  sidebar, search, breadcrumbs, metrics, and exchange detail
- Added buildRouteToAppMap utility for route→application lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:39:45 +01:00

299 lines
11 KiB
TypeScript

import { 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'
// 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 { 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 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 exchange = useMemo(() => exchanges.find((e) => e.id === id), [id])
// Not found state
if (!exchange) {
return (
<AppShell
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/apps' },
{ label: 'Exchanges' },
{ label: id ?? 'Unknown' },
]}
environment="PRODUCTION"
user={{ name: 'hendrik' }}
/>
<div className={styles.content}>
<InfoCallout variant="warning">Exchange "{id}" not found in mock data.</InfoCallout>
</div>
</AppShell>
)
}
const statusVariant = statusToVariant(exchange.status)
const statusLabel = statusToLabel(exchange.status)
return (
<AppShell
sidebar={
<Sidebar apps={SIDEBAR_APPS} />
}
>
{/* Top bar */}
<TopBar
breadcrumb={[
{ label: 'Applications', href: '/apps' },
{ label: exchange.route, href: `/apps/${ROUTE_TO_APP.get(exchange.route) ?? exchange.route}/${exchange.route}` },
{ label: exchange.id },
]}
environment="PRODUCTION"
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">{exchange.id}</MonoText>
<Badge label={statusLabel} color={statusVariant} variant="filled" />
</div>
<div className={styles.exchangeRoute}>
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${ROUTE_TO_APP.get(exchange.route) ?? exchange.route}/${exchange.route}`)}>{exchange.route}</span>
<span className={styles.headerDivider}>·</span>
Order: <MonoText size="xs">{exchange.orderId}</MonoText>
<span className={styles.headerDivider}>·</span>
Customer: <MonoText size="xs">{exchange.customer}</MonoText>
</div>
</div>
</div>
<div className={styles.headerRight}>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Duration</div>
<div className={styles.headerStatValue}>{formatDuration(exchange.durationMs)}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Agent</div>
<div className={styles.headerStatValue}>{exchange.agent}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Started</div>
<div className={styles.headerStatValue}>
{exchange.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}>{exchange.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(exchange.durationMs)}</span>
</div>
<div className={styles.timelineWrap}>
<ProcessorTimeline
processors={exchange.processors}
totalMs={exchange.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}>{exchange.processors.length} processor steps</span>
</div>
<div className={styles.inspectorSteps}>
{exchange.processors.map((proc, index) => {
const snapshot = generateExchangeSnapshot(proc, exchange.orderId, exchange.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) */}
{exchange.status === 'failed' && exchange.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}>{exchange.errorClass}</div>
<pre className={styles.errorMessage}>{exchange.errorMessage}</pre>
<div className={styles.errorHint}>
Failed at processor: <MonoText size="xs">
{exchange.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'}
</MonoText>
</div>
</div>
</div>
)}
</div>
</AppShell>
)
}