332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
|
|
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>
|
||
|
|
)
|
||
|
|
}
|