Files
design-system/src/pages/ExchangeDetail/ExchangeDetail.tsx
hsiegeln 638b868649
All checks were successful
Build & Publish / publish (push) Successful in 1m12s
refactor: extract duplicated utility functions into shared format-utils
Consolidate formatDuration, statusToVariant, statusLabel, formatTimestamp,
toRouteNodeType, and durationClass from 5 page/component files into one
shared utils module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:28:32 +02:00

428 lines
17 KiB
TypeScript

import { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import styles from './ExchangeDetail.module.css'
// Layout
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'
// Utils
import { formatDuration, statusToVariant, toRouteNodeType } from '../../utils/format-utils'
// Mock data
import { exchanges } from '../../mocks/exchanges'
import { buildRouteToAppMap } from '../../mocks/sidebar'
const ROUTE_TO_APP = buildRouteToAppMap()
// ─── Helpers ──────────────────────────────────────────────────────────────────
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<string, string> = {
'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<string, string> = {
'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),
}
}
// ─── 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<number>(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 (
<>
<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>
</>
)
}
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 */}
<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 card */}
<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}>&middot;</span>
Order: <MonoText size="xs">{exchange.orderId}</MonoText>
<span className={styles.headerDivider}>&middot;</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>
{/* Correlation Chain */}
{correlatedExchanges.length > 1 && (
<div className={styles.correlationChain}>
<span className={styles.chainLabel}>Correlated Exchanges</span>
{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 (
<button
key={ce.id}
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
onClick={() => {
if (!isCurrent) navigate(`/exchanges/${ce.id}`)
}}
title={`${ce.id}${ce.route}`}
>
<StatusDot variant={variant} />
<span>{ce.route}</span>
</button>
)
})}
</div>
)}
</div>
{/* Processor Timeline Section */}
<div className={styles.timelineSection}>
<div className={styles.timelineHeader}>
<span className={styles.timelineTitle}>
Processor Timeline
<span className={styles.procCount}>{exchange.processors.length} processors</span>
</span>
<div className={styles.timelineToggle}>
<button
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('gantt')}
>
Timeline
</button>
<button
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('flow')}
>
Flow
</button>
</div>
</div>
<div className={styles.timelineBody}>
{timelineView === 'gantt' ? (
<ProcessorTimeline
processors={exchange.processors}
totalMs={exchange.durationMs}
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
selectedIndex={selectedProcessorIndex}
/>
) : (
<RouteFlow
nodes={routeNodes}
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
selectedIndex={selectedProcessorIndex}
/>
)}
</div>
</div>
{/* Processor Detail Panel (split IN / OUT) */}
{selectedProc && snapshotIn && snapshotOut && (
<div className={styles.detailSplit}>
{/* Message IN */}
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowIn}>&rarr;</span> Message IN
</span>
<span className={styles.panelTag}>at processor #{selectedProcessorIndex + 1} entry</span>
</div>
<div className={styles.panelBody}>
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(snapshotIn.headers).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(snapshotIn.headers).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={snapshotIn.body} language="json" copyable />
</div>
</div>
</div>
{/* Message OUT or Error */}
{isSelectedFailed ? (
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowError}>&times;</span> Error at Processor #{selectedProcessorIndex + 1}
</span>
<Badge label="FAILED" color="error" variant="filled" />
</div>
<div className={styles.panelBody}>
{exchange.errorClass && (
<div className={styles.errorBadgeRow}>
<span className={styles.errorHttpBadge}>{exchange.errorClass.split('.').pop()}</span>
</div>
)}
{exchange.errorMessage && (
<div className={styles.errorMessageBox}>{exchange.errorMessage}</div>
)}
<div className={styles.errorDetailGrid}>
<span className={styles.errorDetailLabel}>Error Class</span>
<span className={styles.errorDetailValue}>{exchange.errorClass ?? 'Unknown'}</span>
<span className={styles.errorDetailLabel}>Processor</span>
<span className={styles.errorDetailValue}>{selectedProc.name}</span>
<span className={styles.errorDetailLabel}>Duration</span>
<span className={styles.errorDetailValue}>{formatDuration(selectedProc.durationMs)}</span>
<span className={styles.errorDetailLabel}>Status</span>
<span className={styles.errorDetailValue}>{selectedProc.status.toUpperCase()}</span>
</div>
</div>
</div>
) : (
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>
<span className={styles.arrowOut}>&larr;</span> Message OUT
</span>
<span className={styles.panelTag}>after processor #{selectedProcessorIndex + 1}</span>
</div>
<div className={styles.panelBody}>
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(snapshotOut.headers).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(snapshotOut.headers).map(([key, value]) => (
<div key={key} className={styles.headerKvRow}>
<span className={styles.headerKey}>{key}</span>
<span className={styles.headerValue}>{value}</span>
</div>
))}
</div>
</div>
<div className={styles.bodySection}>
<div className={styles.sectionLabel}>Body</div>
<CodeBlock content={snapshotOut.body} language="json" copyable />
</div>
</div>
</div>
)}
</div>
)}
</div>
</>
)
}