From 932dc9dcbd2dcc6933d853bc0782c7f033d1048c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:15:28 +0100 Subject: [PATCH] feat: redesign exchange detail page with interactive processor inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite ExchangeDetail with split Message IN/OUT panels that update on processor click, error panel for failed processors, and Timeline/Flow toggle for the processor visualization - Add correlation chain in header with status-colored clickable nodes sorted by start time, labeled "Correlated Exchanges" - Add Exchange ID column and inspect button (↗) to Dashboard table - Add "Open full details" link in the exchange slide-in panel - Add selectedIndex prop to ProcessorTimeline and RouteFlow for highlighting the active processor - Add onNodeClick + selectedIndex to RouteFlow for interactive use - Add correlationGroup field to exchange mock data - Fix sidebar section toggle indentation alignment Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProcessorTimeline.module.css | 7 + .../ProcessorTimeline/ProcessorTimeline.tsx | 12 +- .../composites/RouteFlow/RouteFlow.module.css | 16 + .../composites/RouteFlow/RouteFlow.tsx | 71 ++- .../layout/Sidebar/Sidebar.module.css | 4 +- src/mocks/exchanges.ts | 13 + src/pages/Dashboard/Dashboard.module.css | 40 ++ src/pages/Dashboard/Dashboard.tsx | 48 +- .../ExchangeDetail/ExchangeDetail.module.css | 410 ++++++++++++------ src/pages/ExchangeDetail/ExchangeDetail.tsx | 355 ++++++++++----- 10 files changed, 725 insertions(+), 251 deletions(-) diff --git a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css index b1e0af4..177055e 100644 --- a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css +++ b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.module.css @@ -89,6 +89,13 @@ text-align: right; } +.selectedRow { + background: var(--amber-bg); + border-left: 3px solid var(--amber); + border-radius: var(--radius-sm); + padding: 2px 0 2px 4px; +} + .empty { color: var(--text-muted); font-size: 12px; diff --git a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx index b1f58aa..0bd3c71 100644 --- a/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx +++ b/src/design-system/composites/ProcessorTimeline/ProcessorTimeline.tsx @@ -11,7 +11,8 @@ export interface ProcessorStep { interface ProcessorTimelineProps { processors: ProcessorStep[] totalMs: number - onProcessorClick?: (processor: ProcessorStep) => void + onProcessorClick?: (processor: ProcessorStep, index: number) => void + selectedIndex?: number className?: string } @@ -24,6 +25,7 @@ export function ProcessorTimeline({ processors, totalMs, onProcessorClick, + selectedIndex, className, }: ProcessorTimelineProps) { const safeTotal = totalMs || 1 @@ -49,17 +51,19 @@ export function ProcessorTimeline({ .filter(Boolean) .join(' ') + const isSelected = selectedIndex === i + return (
onProcessorClick?.(proc)} + className={`${styles.row} ${onProcessorClick ? styles.clickable : ''} ${isSelected ? styles.selectedRow : ''}`} + onClick={() => onProcessorClick?.(proc, i)} role={onProcessorClick ? 'button' : undefined} tabIndex={onProcessorClick ? 0 : undefined} onKeyDown={(e) => { if (onProcessorClick && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault() - onProcessorClick(proc) + onProcessorClick(proc, i) } }} aria-label={`${proc.name}: ${formatDuration(proc.durationMs)} (${proc.status})`} diff --git a/src/design-system/composites/RouteFlow/RouteFlow.module.css b/src/design-system/composites/RouteFlow/RouteFlow.module.css index e2a38a3..be45d67 100644 --- a/src/design-system/composites/RouteFlow/RouteFlow.module.css +++ b/src/design-system/composites/RouteFlow/RouteFlow.module.css @@ -176,6 +176,22 @@ padding-left: 2px; } +/* Selected node */ +.nodeSelected { + box-shadow: 0 0 0 2px var(--amber); + border-color: var(--amber); +} + +/* Clickable node */ +.nodeClickable { + cursor: pointer; +} + +.nodeClickable:focus-visible { + outline: 2px solid var(--amber); + outline-offset: 2px; +} + /* Bottleneck badge */ .bottleneckBadge { position: absolute; diff --git a/src/design-system/composites/RouteFlow/RouteFlow.tsx b/src/design-system/composites/RouteFlow/RouteFlow.tsx index 44feeba..ca9e0ac 100644 --- a/src/design-system/composites/RouteFlow/RouteFlow.tsx +++ b/src/design-system/composites/RouteFlow/RouteFlow.tsx @@ -10,6 +10,8 @@ export interface RouteNode { interface RouteFlowProps { nodes: RouteNode[] + onNodeClick?: (node: RouteNode, index: number) => void + selectedIndex?: number className?: string } @@ -50,37 +52,60 @@ function nodeStatusClass(node: RouteNode): string { return styles.nodeHealthy } -export function RouteFlow({ nodes, className }: RouteFlowProps) { +export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) { const mainNodes = nodes.filter((n) => n.type !== 'error-handler') const errorHandlers = nodes.filter((n) => n.type === 'error-handler') + // Map from mainNodes index back to original nodes index + const mainNodeOriginalIndices = nodes.reduce((acc, n, idx) => { + if (n.type !== 'error-handler') acc.push(idx) + return acc + }, []) + return (
- {mainNodes.map((node, i) => ( -
- {i > 0 && ( -
-
-
-
- )} -
- {node.isBottleneck && BOTTLENECK} -
- {TYPE_ICONS[node.type] ?? '\u25A2'} -
-
-
{node.type}
-
{node.name}
-
-
-
- {formatDuration(node.durationMs)} + {mainNodes.map((node, i) => { + const originalIndex = mainNodeOriginalIndices[i] + const isSelected = selectedIndex === originalIndex + const isClickable = !!onNodeClick + + return ( +
+ {i > 0 && ( +
+
+
+
+ )} +
onNodeClick?.(node, originalIndex)} + role={isClickable ? 'button' : undefined} + tabIndex={isClickable ? 0 : undefined} + onKeyDown={(e) => { + if (isClickable && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onNodeClick?.(node, originalIndex) + } + }} + > + {node.isBottleneck && BOTTLENECK} +
+ {TYPE_ICONS[node.type] ?? '\u25A2'} +
+
+
{node.type}
+
{node.name}
+
+
+
+ {formatDuration(node.durationMs)} +
-
- ))} + ) + })} {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() {
-
- {/* Processor timeline */} -
-
- Processor Timeline - Total: {formatDuration(exchange.durationMs)} -
-
- -
-
- - {/* Step-by-step inspector */} -
-
- Exchange Inspector - {exchange.processors.length} processor steps -
-
- {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 ( - - {index + 1} - {proc.name} - - {formatDuration(proc.durationMs)} -
- } - defaultOpen={proc.status === 'fail'} - className={styles.stepCollapsible} - > -
-
-
Exchange Headers
- -
-
-
Exchange Body
- -
-
- - ) - })} -
-
- - {/* Error block (if failed) */} - {exchange.status === 'failed' && exchange.errorMessage && ( -
-
- Error Details - + {/* 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 ( + + ) + })}
-
-
{exchange.errorClass}
-
{exchange.errorMessage}
-
- Failed at processor: - {exchange.processors.find((p) => p.status === 'fail')?.name ?? 'unknown'} - + )} +
+ + {/* 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
+ +
+
+
+ )}
)}