From 2ae2871822e189bf97c0b461b269784d3c343a72 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:02:14 +0100 Subject: [PATCH] fix: add groupName to ExecutionDetail, rewrite ExchangeDetail to match mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add groupName field to ExecutionDetail record and DetailService - Dashboard: fix TDZ error (rows referenced before definition), add selectedRow fallback for diagram groupName lookup - ExchangeDetail: rewrite to match mock layout — auto-select first processor, Message IN/OUT split panels with header key-value rows, error panel for failed processors, Timeline/Flow toggle buttons - Track diagram-mapping utility (was untracked, caused CI build failure) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/core/detail/DetailService.java | 1 + .../server/core/detail/ExecutionDetail.java | 1 + ui/src/pages/Dashboard/Dashboard.tsx | 4 +- .../ExchangeDetail/ExchangeDetail.module.css | 265 ++++++++++++++-- .../pages/ExchangeDetail/ExchangeDetail.tsx | 285 +++++++++++++----- ui/src/utils/diagram-mapping.ts | 55 ++++ 6 files changed, 497 insertions(+), 114 deletions(-) create mode 100644 ui/src/utils/diagram-mapping.ts diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java index 7f6b31ce..b0ba5d1d 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/DetailService.java @@ -20,6 +20,7 @@ public class DetailService { List roots = buildTree(processors); return new ExecutionDetail( exec.executionId(), exec.routeId(), exec.agentId(), + exec.groupName(), exec.status(), exec.startTime(), exec.endTime(), exec.durationMs() != null ? exec.durationMs() : 0L, exec.correlationId(), exec.exchangeId(), diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ExecutionDetail.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ExecutionDetail.java index 1b474ba0..0e50abf0 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ExecutionDetail.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/detail/ExecutionDetail.java @@ -27,6 +27,7 @@ public record ExecutionDetail( String executionId, String routeId, String agentId, + String groupName, String status, Instant startTime, Instant endTime, diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index 53c7af21..2e3ab8aa 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -40,13 +40,15 @@ export default function Dashboard() { offset: 0, limit: 50, }, true); const { data: detail } = useExecutionDetail(selectedId); - const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId); const rows: Row[] = useMemo(() => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })), [searchResult], ); + const selectedRow = rows.find(r => r.id === selectedId); + const { data: diagram } = useDiagramByRoute(detail?.groupName ?? selectedRow?.groupName, detail?.routeId); + const totalCount = stats?.totalCount ?? 0; const failedCount = stats?.failedCount ?? 0; const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100; diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css index 552cc066..51cc83df 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css @@ -21,6 +21,37 @@ flex: 1; } +.exchangeId { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.exchangeRoute { + font-size: 12px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.routeLink { + color: var(--accent, #c6820e); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.routeLink:hover { + color: var(--amber-deep, #a36b0b); +} + +.headerDivider { + color: var(--text-faint); +} + .headerRight { display: flex; gap: 20px; @@ -47,6 +78,62 @@ color: var(--text-primary); } +/* 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, 4px); + 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, rgba(198, 130, 14, 0.08)); + border-color: var(--accent, #c6820e); + color: var(--accent, #c6820e); + 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); } + +.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; } + +/* Timeline Section */ .timelineSection { background: var(--bg-surface); border: 1px solid var(--border-subtle); @@ -68,12 +155,59 @@ font-size: 13px; font-weight: 600; color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.procCount { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + padding: 1px 8px; + border-radius: 10px; + background: var(--bg-inset); + color: var(--text-muted); +} + +.timelineToggle { + display: inline-flex; + gap: 0; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm, 4px); + 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(--accent, #c6820e); + color: #fff; + font-weight: 600; +} + +.toggleBtnActive:hover { + background: var(--amber-deep, #a36b0b); } .timelineBody { padding: 12px 16px; } +/* Detail Split (IN / OUT panels) */ .detailSplit { display: grid; grid-template-columns: 1fr 1fr; @@ -89,6 +223,10 @@ overflow: hidden; } +.detailPanelError { + border-color: var(--error-border, rgba(220, 38, 38, 0.3)); +} + .panelHeader { display: flex; align-items: center; @@ -96,18 +234,66 @@ padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); background: var(--bg-raised); + gap: 8px; +} + +.detailPanelError .panelHeader { + background: var(--error-bg, rgba(220, 38, 38, 0.06)); + border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3)); } .panelTitle { font-size: 13px; font-weight: 600; color: var(--text-primary); + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} + +.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; @@ -124,6 +310,9 @@ font-family: var(--font-mono); font-weight: 600; color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .headerValue { @@ -131,6 +320,12 @@ color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; +} + +/* Body section */ +.bodySection { + margin-top: 12px; } .sectionLabel { @@ -140,44 +335,50 @@ letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 6px; -} - -.correlationChain { margin-bottom: 16px; } - -.chainRow { - display: flex; - align-items: center; - gap: 8px; - overflow-x: auto; - padding: 12px; - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); -} - -.chainArrow { color: var(--text-muted); font-size: 16px; flex-shrink: 0; } - -.chainCard { display: flex; align-items: center; gap: 6px; - padding: 6px 10px; - background: var(--bg-raised); - border: 1px solid var(--border-subtle); - border-radius: 6px; - font-size: 12px; - text-decoration: none; - color: var(--text-primary); - flex-shrink: 0; - cursor: pointer; } -.chainCard:hover { background: var(--bg-hover); } +.count { + font-family: var(--font-mono); + font-size: 10px; + padding: 0 5px; + border-radius: 8px; + background: var(--bg-inset); + color: var(--text-faint); +} -.chainCardActive { border-color: var(--accent); background: var(--bg-hover); } +/* Error panel styles */ +.errorMessageBox { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + background: var(--error-bg, rgba(220, 38, 38, 0.06)); + padding: 10px 12px; + border-radius: var(--radius-sm, 4px); + border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3)); + margin-bottom: 12px; + line-height: 1.5; + word-break: break-word; + white-space: pre-wrap; +} -.chainRoute { font-weight: 600; } +.errorDetailGrid { + display: grid; + grid-template-columns: 120px 1fr; + gap: 4px 12px; + font-size: 11px; +} -.chainDuration { color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; } +.errorDetailLabel { + font-weight: 600; + color: var(--text-muted); + font-family: var(--font-mono); +} -.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; } +.errorDetailValue { + color: var(--text-primary); + font-family: var(--font-mono); + word-break: break-all; +} diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx index 6e39446a..1d5b8b18 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router'; import { Badge, StatusDot, MonoText, CodeBlock, InfoCallout, - ProcessorTimeline, Breadcrumb, Spinner, SegmentedTabs, RouteFlow, + ProcessorTimeline, Breadcrumb, Spinner, RouteFlow, } from '@cameleer/design-system'; import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'; import { useCorrelationChain } from '../../api/queries/correlation'; @@ -14,18 +14,53 @@ function countProcessors(nodes: any[]): number { return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0); } +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 parseHeaders(raw: string | undefined | null): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (typeof parsed === 'object' && parsed !== null) { + const result: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + result[k] = typeof v === 'string' ? v : JSON.stringify(v); + } + return result; + } + } catch { /* ignore */ } + return {}; +} + export default function ExchangeDetail() { const { id } = useParams(); const navigate = useNavigate(); const { data: detail, isLoading } = useExecutionDetail(id ?? null); - const [selectedProcessor, setSelectedProcessor] = useState(null); - const [viewMode, setViewMode] = useState<'timeline' | 'flow'>('timeline'); - const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor); + const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt'); const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null); const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId); + const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : []; + + // Auto-select first failed processor, or 0 + const defaultIndex = useMemo(() => { + if (!procList.length) return 0; + const failIdx = procList.findIndex((p: any) => + (p.status || '').toUpperCase() === 'FAILED' || p.status === 'fail' + ); + return failIdx >= 0 ? failIdx : 0; + }, [procList]); + + const [selectedProcessorIndex, setSelectedProcessorIndex] = useState(null); + const activeIndex = selectedProcessorIndex ?? defaultIndex; + + const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null); + const processors = useMemo(() => { - if (!detail?.children) return []; + if (!procList.length) return []; const result: any[] = []; let offset = 0; function walk(node: any) { @@ -39,9 +74,18 @@ export default function ExchangeDetail() { offset += node.durationMs ?? 0; if (node.children) node.children.forEach(walk); } - detail.children.forEach(walk); + procList.forEach(walk); return result; - }, [detail]); + }, [procList]); + + const selectedProc = processors[activeIndex]; + const isSelectedFailed = selectedProc?.status === 'fail'; + + // Parse snapshot headers + const inputHeaders = parseHeaders(snapshot?.inputHeaders); + const outputHeaders = parseHeaders(snapshot?.outputHeaders); + const inputBody = snapshot?.inputBody ?? null; + const outputBody = snapshot?.outputBody ?? null; if (isLoading) return
; if (!detail) return Exchange not found; @@ -54,93 +98,116 @@ export default function ExchangeDetail() { { label: id?.slice(0, 12) || '' }, ]} /> + {/* Exchange header card */}
- - {id} +
+ {id} + +
+
+ Route: navigate(`/apps/${detail.groupName}/${detail.routeId}`)}>{detail.routeId} + {detail.groupName && ( + <> + · + App: {detail.groupName} + + )} +
Duration
-
{detail.durationMs}ms
+
{formatDuration(detail.durationMs)}
Agent
{detail.agentId}
+
+
Started
+
+ {detail.startTime ? new Date(detail.startTime).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—'} +
+
Processors
-
{countProcessors(detail.processors || detail.children || [])}
-
-
-
Route
-
{detail.routeId}
-
-
-
Application
-
{detail.groupName || 'unknown'}
+
{countProcessors(procList)}
-
- {correlationData?.data && correlationData.data.length > 1 && ( -
-
- Correlation Chain -
-
- {correlationData.data.map((exec, i) => ( - - {i > 0 && } - { e.preventDefault(); navigate(`/exchanges/${exec.executionId}`); }} + {/* Correlation Chain */} + {correlationData?.data && correlationData.data.length > 1 && ( + -
- )} + )} +
+ {/* Error callout */} {detail.errorMessage && ( {detail.errorMessage} )} + {/* Processor Timeline / Flow Section */}
- Processors - setViewMode(v as 'timeline' | 'flow')} - /> + + Processor Timeline + {processors.length} processors + +
+ + +
- {viewMode === 'timeline' ? ( + {timelineView === 'gantt' ? ( processors.length > 0 ? ( setSelectedProcessor(i)} - selectedIndex={selectedProcessor ?? undefined} + onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)} + selectedIndex={activeIndex} /> ) : ( No processor data available @@ -148,9 +215,9 @@ export default function ExchangeDetail() { ) : ( diagram ? ( setSelectedProcessor(i)} - selectedIndex={selectedProcessor ?? undefined} + nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)} + onNodeClick={(_node, i) => setSelectedProcessorIndex(i)} + selectedIndex={activeIndex} /> ) : ( @@ -159,46 +226,102 @@ export default function ExchangeDetail() {
- {snapshot && ( - <> -
Exchange Snapshot
-
-
-
- Input Body -
-
- -
+ {/* Processor Detail: Message IN / Message OUT or Error */} + {selectedProc && snapshot && ( +
+ {/* Message IN */} +
+
+ + Message IN + + at processor #{activeIndex + 1} entry
-
-
- Output Body -
-
- +
+ {Object.keys(inputHeaders).length > 0 && ( +
+
+ Headers {Object.keys(inputHeaders).length} +
+
+ {Object.entries(inputHeaders).map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+
+ )} +
+
Body
+
-
-
+ + {/* Message OUT or Error */} + {isSelectedFailed ? ( +
- Input Headers + + × Error at Processor #{activeIndex + 1} + +
- + {detail.errorMessage && ( +
{detail.errorMessage}
+ )} +
+ Processor + {selectedProc.name} + Duration + {formatDuration(selectedProc.durationMs)} + Status + {selectedProc.status.toUpperCase()} +
+ ) : (
- Output Headers + + Message OUT + + after processor #{activeIndex + 1}
- + {Object.keys(outputHeaders).length > 0 && ( +
+
+ Headers {Object.keys(outputHeaders).length} +
+
+ {Object.entries(outputHeaders).map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+
+ )} +
+
Body
+ +
-
- + )} +
+ )} + + {/* No snapshot loaded yet - show prompt */} + {selectedProc && !snapshot && procList.length > 0 && ( +
+ Loading exchange snapshot... +
)}
); diff --git a/ui/src/utils/diagram-mapping.ts b/ui/src/utils/diagram-mapping.ts new file mode 100644 index 00000000..8f6146cb --- /dev/null +++ b/ui/src/utils/diagram-mapping.ts @@ -0,0 +1,55 @@ +import type { RouteNode } from '@cameleer/design-system'; + +// Map NodeType strings to RouteNode types +function mapNodeType(type: string): RouteNode['type'] { + const lower = type?.toLowerCase() || ''; + if (lower.includes('from') || lower === 'endpoint') return 'from'; + if (lower.includes('to')) return 'to'; + if (lower.includes('choice') || lower.includes('when') || lower.includes('otherwise')) return 'choice'; + if (lower.includes('error') || lower.includes('dead')) return 'error-handler'; + return 'process'; +} + +function mapStatus(status: string | undefined): RouteNode['status'] { + if (!status) return 'ok'; + const s = status.toUpperCase(); + if (s === 'FAILED') return 'fail'; + if (s === 'RUNNING') return 'slow'; + return 'ok'; +} + +/** + * Maps diagram PositionedNodes + execution ProcessorNodes to RouteFlow RouteNode[] format. + * Joins on diagramNodeId → node.id. + */ +export function mapDiagramToRouteNodes( + diagramNodes: Array<{ id?: string; label?: string; type?: string }>, + processors: Array<{ diagramNodeId?: string; processorId?: string; status?: string; durationMs?: number; children?: any[] }> +): RouteNode[] { + // Flatten processor tree + const flatProcessors: typeof processors = []; + function flatten(nodes: typeof processors) { + for (const n of nodes) { + flatProcessors.push(n); + if (n.children) flatten(n.children); + } + } + flatten(processors || []); + + // Build lookup: diagramNodeId → processor + const procMap = new Map(); + for (const p of flatProcessors) { + if (p.diagramNodeId) procMap.set(p.diagramNodeId, p); + } + + return diagramNodes.map(node => { + const proc = procMap.get(node.id ?? ''); + return { + name: node.label || node.id || '', + type: mapNodeType(node.type ?? ''), + durationMs: proc?.durationMs ?? 0, + status: mapStatus(proc?.status), + isBottleneck: false, + }; + }); +}