- ))}
+ )
+ })}
{errorHandlers.length > 0 && (
diff --git a/src/design-system/layout/Sidebar/Sidebar.module.css b/src/design-system/layout/Sidebar/Sidebar.module.css
index c9a4185..46a7c60 100644
--- a/src/design-system/layout/Sidebar/Sidebar.module.css
+++ b/src/design-system/layout/Sidebar/Sidebar.module.css
@@ -214,9 +214,9 @@
.treeSectionToggle {
display: flex;
align-items: center;
- gap: 6px;
+ gap: 2px;
width: 100%;
- padding: 8px 12px 4px;
+ padding: 8px 0 4px;
}
.treeSectionChevronBtn {
diff --git a/src/mocks/exchanges.ts b/src/mocks/exchanges.ts
index ad1ec5a..a8d5d84 100644
--- a/src/mocks/exchanges.ts
+++ b/src/mocks/exchanges.ts
@@ -20,6 +20,7 @@ export interface Exchange {
errorMessage?: string
errorClass?: string
processors: ProcessorData[]
+ correlationGroup?: string
}
export const exchanges: Exchange[] = [
@@ -34,6 +35,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:12:04'),
correlationId: 'cmr-f4a1c82b-9d3e',
agent: 'prod-1',
+ correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 4 },
@@ -53,6 +55,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:11:22'),
correlationId: 'cmr-7b2d9f14-c5a8',
agent: 'prod-2',
+ correlationGroup: 'payment-flow-001',
processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 5 },
@@ -72,6 +75,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:13:44'),
correlationId: 'cmr-3c8e1a7f-d2b6',
agent: 'prod-1',
+ correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'enrich(inventory-api)', type: 'enrich', durationMs: 29990, status: 'slow', startMs: 5 },
@@ -88,6 +92,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:09:47'),
correlationId: 'cmr-a9f3b2c1-e4d7',
agent: 'prod-3',
+ correlationGroup: 'shipment-flow-001',
processors: [
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 6, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 8, status: 'ok', startMs: 6 },
@@ -106,6 +111,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:06:11'),
correlationId: 'cmr-9a4f2b71-e8c3',
agent: 'prod-2',
+ correlationGroup: 'payment-flow-002',
errorMessage: 'org.apache.camel.CamelExecutionException: Payment gateway timeout after 5000ms — POST https://pay.provider.com/v2/charge returned HTTP 504. Retry exhausted (3/3).',
errorClass: 'org.apache.camel.CamelExecutionException',
processors: [
@@ -145,6 +151,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T09:00:15'),
correlationId: 'cmr-2e5f8d9a-b4c1',
agent: 'prod-3',
+ correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 5, status: 'ok', startMs: 3 },
@@ -164,6 +171,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:58:33'),
correlationId: 'cmr-d1a3e7f4-c2b8',
agent: 'prod-1',
+ correlationGroup: 'payment-flow-001',
processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ name: 'validate(payment-schema)', type: 'process', durationMs: 14, status: 'ok', startMs: 4 },
@@ -199,6 +207,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:50:41'),
correlationId: 'cmr-f3c7a1b9-d5e2',
agent: 'prod-1',
+ correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 3, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 6, status: 'ok', startMs: 3 },
@@ -218,6 +227,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:46:19'),
correlationId: 'cmr-a2d8f5c3-b9e1',
agent: 'prod-2',
+ correlationGroup: 'payment-flow-001',
processors: [
{ name: 'from(jms:payments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'validate(payment-schema)', type: 'process', durationMs: 12, status: 'ok', startMs: 5 },
@@ -254,6 +264,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:31:05'),
correlationId: 'cmr-7e9a2c5f-d1b4',
agent: 'prod-2',
+ correlationGroup: 'payment-flow-002',
errorMessage: 'org.apache.camel.component.http.HttpOperationFailedException: HTTP operation failed invoking https://pay.provider.com/v2/charge with statusCode: 422 — Unprocessable Entity: card declined (insufficient funds)',
errorClass: 'org.apache.camel.component.http.HttpOperationFailedException',
processors: [
@@ -273,6 +284,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:22:44'),
correlationId: 'cmr-b5c8d2a7-f4e3',
agent: 'prod-3',
+ correlationGroup: 'shipment-flow-001',
processors: [
{ name: 'from(jms:shipments)', type: 'consumer', durationMs: 5, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 5 },
@@ -291,6 +303,7 @@ export const exchanges: Exchange[] = [
timestamp: new Date('2026-03-18T08:15:19'),
correlationId: 'cmr-d9e3f7b1-a6c5',
agent: 'prod-4',
+ correlationGroup: 'order-flow-001',
processors: [
{ name: 'from(jms:orders)', type: 'consumer', durationMs: 4, status: 'ok', startMs: 0 },
{ name: 'unmarshal(json)', type: 'transform', durationMs: 7, status: 'ok', startMs: 4 },
diff --git a/src/pages/Dashboard/Dashboard.module.css b/src/pages/Dashboard/Dashboard.module.css
index 33fac93..29e7112 100644
--- a/src/pages/Dashboard/Dashboard.module.css
+++ b/src/pages/Dashboard/Dashboard.module.css
@@ -223,3 +223,43 @@
font-family: var(--font-mono);
word-break: break-word;
}
+
+/* Inspect exchange icon in table */
+.inspectLink {
+ background: transparent;
+ border: none;
+ color: var(--text-faint);
+ opacity: 0.75;
+ cursor: pointer;
+ font-size: 13px;
+ padding: 2px 4px;
+ border-radius: var(--radius-sm);
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.15s, opacity 0.15s;
+}
+
+.inspectLink:hover {
+ color: var(--text-primary);
+ opacity: 1;
+}
+
+/* Open full details link in panel */
+.openDetailLink {
+ background: transparent;
+ border: none;
+ color: var(--amber);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 0;
+ font-family: var(--font-body);
+ transition: color 0.1s;
+}
+
+.openDetailLink:hover {
+ color: var(--amber-deep);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx
index e7c2f82..59477c7 100644
--- a/src/pages/Dashboard/Dashboard.tsx
+++ b/src/pages/Dashboard/Dashboard.tsx
@@ -1,5 +1,5 @@
import { useState, useMemo } from 'react'
-import { useParams } from 'react-router-dom'
+import { useParams, useNavigate } from 'react-router-dom'
import styles from './Dashboard.module.css'
// Layout
@@ -68,8 +68,8 @@ function statusLabel(status: Exchange['status']): string {
}
}
-// ─── Table columns ────────────────────────────────────────────────────────────
-const COLUMNS: Column
[] = [
+// ─── Table columns (base, without navigate action) ──────────────────────────
+const BASE_COLUMNS: Column[] = [
{
key: 'status',
header: 'Status',
@@ -97,6 +97,14 @@ const COLUMNS: Column[] = [
{ROUTE_TO_APP.get(row.route) ?? row.routeGroup}
),
},
+ {
+ key: 'id',
+ header: 'Exchange ID',
+ sortable: true,
+ render: (_, row) => (
+ {row.id}
+ ),
+ },
{
key: 'timestamp',
header: 'Started',
@@ -145,10 +153,34 @@ const SHORTCUTS = [
// ─── Dashboard component ──────────────────────────────────────────────────────
export function Dashboard() {
const { id: appId, routeId } = useParams<{ id: string; routeId: string }>()
+ const navigate = useNavigate()
const [selectedId, setSelectedId] = useState()
const [panelOpen, setPanelOpen] = useState(false)
const [selectedExchange, setSelectedExchange] = useState(null)
+ // Build columns with inspect action as second column
+ const COLUMNS: Column[] = useMemo(() => {
+ const inspectCol: Column = {
+ key: 'correlationId' as keyof Exchange,
+ header: '',
+ width: '36px',
+ render: (_, row) => (
+
+ ),
+ }
+ const [statusCol, ...rest] = BASE_COLUMNS
+ return [statusCol, inspectCol, ...rest]
+ }, [navigate])
+
const { isInTimeRange, statusFilters } = useGlobalFilters()
// Build set of route IDs belonging to the selected app (if any)
@@ -232,6 +264,16 @@ export function Dashboard() {
onClose={() => setPanelOpen(false)}
title={`${selectedExchange.orderId} — ${selectedExchange.route}`}
>
+ {/* Link to full detail page */}
+
+
+
+
{/* Overview */}
Overview
diff --git a/src/pages/ExchangeDetail/ExchangeDetail.module.css b/src/pages/ExchangeDetail/ExchangeDetail.module.css
index ea0060f..adacbc4 100644
--- a/src/pages/ExchangeDetail/ExchangeDetail.module.css
+++ b/src/pages/ExchangeDetail/ExchangeDetail.module.css
@@ -7,7 +7,9 @@
background: var(--bg-body);
}
-/* Exchange header card */
+/* ==========================================================================
+ EXCHANGE HEADER CARD
+ ========================================================================== */
.exchangeHeader {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
@@ -88,17 +90,85 @@
color: var(--text-primary);
}
-/* Section layout */
-.section {
+/* ==========================================================================
+ CORRELATION CHAIN
+ ========================================================================== */
+.correlationChain {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ padding-top: 12px;
+ margin-top: 12px;
+ border-top: 1px solid var(--border-subtle);
+ flex-wrap: wrap;
+}
+
+.chainLabel {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-muted);
+ margin-right: 4px;
+}
+
+.chainNode {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border-subtle);
+ font-size: 11px;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ transition: all 0.12s;
+}
+
+.chainNode:hover {
+ border-color: var(--text-faint);
+ background: var(--bg-hover);
+}
+
+.chainNodeCurrent {
+ background: var(--amber-bg);
+ border-color: var(--amber-light);
+ color: var(--amber-deep);
+ font-weight: 600;
+}
+
+.chainNodeSuccess {
+ border-left: 3px solid var(--success);
+}
+
+.chainNodeError {
+ border-left: 3px solid var(--error);
+}
+
+.chainNodeRunning {
+ border-left: 3px solid var(--running);
+}
+
+.chainNodeWarning {
+ border-left: 3px solid var(--warning);
+}
+
+/* ==========================================================================
+ TIMELINE SECTION
+ ========================================================================== */
+.timelineSection {
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;
+ overflow: hidden;
}
-.sectionHeader {
+.timelineHeader {
display: flex;
align-items: center;
justify-content: space-between;
@@ -106,159 +176,255 @@
border-bottom: 1px solid var(--border-subtle);
}
-.sectionTitle {
+.timelineTitle {
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;
+ gap: 8px;
}
-.stepIndex {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 22px;
- height: 22px;
- border-radius: 50%;
- font-size: 11px;
- font-weight: 700;
+.procCount {
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-size: 10px;
font-weight: 500;
- font-family: var(--font-mono);
- color: var(--text-primary);
- flex: 1;
-}
-
-.stepDuration {
- font-size: 11px;
- font-family: var(--font-mono);
+ padding: 1px 8px;
+ border-radius: 10px;
+ background: var(--bg-inset);
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;
+.timelineToggle {
+ display: inline-flex;
+ gap: 0;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+
+.toggleBtn {
+ padding: 4px 12px;
+ font-size: 11px;
+ font-family: var(--font-body);
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: var(--text-secondary);
+ transition: all 0.12s;
+}
+
+.toggleBtn:hover {
+ background: var(--bg-hover);
+}
+
+.toggleBtnActive {
+ background: var(--amber);
+ color: #fff;
+ font-weight: 600;
+}
+
+.toggleBtnActive:hover {
+ background: var(--amber-deep);
+}
+
+.timelineBody {
padding: 12px 16px;
- background: var(--bg-raised);
}
-.stepPanel {
+/* ==========================================================================
+ DETAIL SPLIT (IN / OUT panels)
+ ========================================================================== */
+.detailSplit {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.detailPanel {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-card);
+ overflow: hidden;
+}
+
+.detailPanelError {
+ border-color: var(--error-border);
+}
+
+.panelHeader {
display: flex;
- flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border-subtle);
+ background: var(--bg-raised);
+ gap: 8px;
+}
+
+.detailPanelError .panelHeader {
+ background: var(--error-bg);
+ border-bottom-color: var(--error-border);
+}
+
+.panelTitle {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ display: flex;
+ flex-direction: row;
+ align-items: center;
gap: 6px;
}
-.stepPanelLabel {
+.arrowIn {
+ color: var(--success);
+ font-weight: 700;
+}
+
+.arrowOut {
+ color: var(--running);
+ font-weight: 700;
+}
+
+.arrowError {
+ color: var(--error);
+ font-weight: 700;
+ font-size: 16px;
+}
+
+.panelTag {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 8px;
+ background: var(--bg-inset);
+ color: var(--text-muted);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.panelBody {
+ padding: 16px;
+}
+
+/* Headers section */
+.headersSection {
+ margin-bottom: 12px;
+}
+
+.headerList {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.headerKvRow {
+ display: grid;
+ grid-template-columns: 140px 1fr;
+ padding: 4px 0;
+ border-bottom: 1px solid var(--border-subtle);
+ font-size: 11px;
+}
+
+.headerKvRow:last-child {
+ border-bottom: none;
+}
+
+.headerKey {
+ font-family: var(--font-mono);
+ font-weight: 600;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.headerValue {
+ font-family: var(--font-mono);
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Body section */
+.bodySection {
+ margin-top: 12px;
+}
+
+.sectionLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
}
-.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 {
+.count {
font-family: var(--font-mono);
- font-size: 11px;
- font-weight: 700;
- color: var(--error);
+ font-size: 10px;
+ padding: 0 5px;
+ border-radius: 8px;
+ background: var(--bg-inset);
+ color: var(--text-faint);
+}
+
+/* Error panel styles */
+.errorBadgeRow {
+ display: flex;
+ gap: 8px;
margin-bottom: 8px;
}
-.errorMessage {
+.errorHttpBadge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 600;
+ background: var(--error-bg);
+ color: var(--error);
+ border: 1px solid var(--error-border);
+}
+
+.errorMessageBox {
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);
+ background: var(--error-bg);
padding: 10px 12px;
- white-space: pre-wrap;
- word-break: break-word;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--error-border);
+ margin-bottom: 12px;
line-height: 1.5;
- margin-bottom: 8px;
+ word-break: break-word;
+ white-space: pre-wrap;
}
-.errorHint {
+.errorDetailGrid {
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ gap: 4px 12px;
font-size: 11px;
- color: var(--text-muted);
- display: flex;
- align-items: center;
- gap: 5px;
+}
+
+.errorDetailLabel {
+ font-weight: 600;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+}
+
+.errorDetailValue {
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ word-break: break-all;
}
diff --git a/src/pages/ExchangeDetail/ExchangeDetail.tsx b/src/pages/ExchangeDetail/ExchangeDetail.tsx
index c90374c..7f687e2 100644
--- a/src/pages/ExchangeDetail/ExchangeDetail.tsx
+++ b/src/pages/ExchangeDetail/ExchangeDetail.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from 'react'
+import { useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import styles from './ExchangeDetail.module.css'
@@ -10,12 +10,13 @@ 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 { Collapsible } from '../../design-system/primitives/Collapsible/Collapsible'
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
@@ -50,8 +51,7 @@ function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'):
}
}
-// ─── Exchange body mock generator ────────────────────────────────────────────
-// For each processor step, generate a plausible exchange body snapshot
+// ─── Exchange body mock generators ──────────────────────────────────────────
function generateExchangeSnapshot(
step: ProcessorStep,
orderId: string,
@@ -67,7 +67,7 @@ function generateExchangeSnapshot(
}
const headers: Record
= {
- 'CamelCorrelationId': `cmr-${Math.random().toString(36).slice(2, 10)}`,
+ 'CamelCorrelationId': `cmr-${orderId.toLowerCase().replace('op-', '')}-${stepIndex}`,
'Content-Type': 'application/json',
'CamelTimerName': step.name,
'CamelBreadcrumbId': `${orderId}-${stepIndex}`,
@@ -102,6 +102,61 @@ function generateExchangeSnapshot(
}
}
+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 }>()
@@ -109,6 +164,35 @@ export function ExchangeDetail() {
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 (
@@ -124,7 +208,6 @@ export function ExchangeDetail() {
{ label: id ?? 'Unknown' },
]}
environment="PRODUCTION"
-
user={{ name: 'hendrik' }}
/>
@@ -136,6 +219,14 @@ export function ExchangeDetail() {
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 (
- {/* Exchange header */}
+ {/* Exchange header card */}
@@ -169,9 +260,9 @@ export function ExchangeDetail() {
Route: navigate(`/apps/${ROUTE_TO_APP.get(exchange.route) ?? exchange.route}/${exchange.route}`)}>{exchange.route}
- ·
+ ·
Order: {exchange.orderId}
- ·
+ ·
Customer: {exchange.customer}
@@ -197,98 +288,168 @@ export function ExchangeDetail() {
-