fix: add groupName to ExecutionDetail, rewrite ExchangeDetail to match mock
Some checks failed
CI / build (push) Failing after 40s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

- 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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-23 21:02:14 +01:00
parent a950feaef1
commit 2ae2871822
6 changed files with 497 additions and 114 deletions

View File

@@ -20,6 +20,7 @@ public class DetailService {
List<ProcessorNode> roots = buildTree(processors); List<ProcessorNode> roots = buildTree(processors);
return new ExecutionDetail( return new ExecutionDetail(
exec.executionId(), exec.routeId(), exec.agentId(), exec.executionId(), exec.routeId(), exec.agentId(),
exec.groupName(),
exec.status(), exec.startTime(), exec.endTime(), exec.status(), exec.startTime(), exec.endTime(),
exec.durationMs() != null ? exec.durationMs() : 0L, exec.durationMs() != null ? exec.durationMs() : 0L,
exec.correlationId(), exec.exchangeId(), exec.correlationId(), exec.exchangeId(),

View File

@@ -27,6 +27,7 @@ public record ExecutionDetail(
String executionId, String executionId,
String routeId, String routeId,
String agentId, String agentId,
String groupName,
String status, String status,
Instant startTime, Instant startTime,
Instant endTime, Instant endTime,

View File

@@ -40,13 +40,15 @@ export default function Dashboard() {
offset: 0, limit: 50, offset: 0, limit: 50,
}, true); }, true);
const { data: detail } = useExecutionDetail(selectedId); const { data: detail } = useExecutionDetail(selectedId);
const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId);
const rows: Row[] = useMemo(() => const rows: Row[] = useMemo(() =>
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })), (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult], [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 totalCount = stats?.totalCount ?? 0;
const failedCount = stats?.failedCount ?? 0; const failedCount = stats?.failedCount ?? 0;
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100; const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;

View File

@@ -21,6 +21,37 @@
flex: 1; 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 { .headerRight {
display: flex; display: flex;
gap: 20px; gap: 20px;
@@ -47,6 +78,62 @@
color: var(--text-primary); 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 { .timelineSection {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
@@ -68,12 +155,59 @@
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); 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 { .timelineBody {
padding: 12px 16px; padding: 12px 16px;
} }
/* Detail Split (IN / OUT panels) */
.detailSplit { .detailSplit {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -89,6 +223,10 @@
overflow: hidden; overflow: hidden;
} }
.detailPanelError {
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
}
.panelHeader { .panelHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -96,18 +234,66 @@
padding: 10px 16px; padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
background: var(--bg-raised); 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 { .panelTitle {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); 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 { .panelBody {
padding: 16px; padding: 16px;
} }
/* Headers section */
.headersSection {
margin-bottom: 12px;
}
.headerList {
display: flex;
flex-direction: column;
gap: 0;
}
.headerKvRow { .headerKvRow {
display: grid; display: grid;
grid-template-columns: 140px 1fr; grid-template-columns: 140px 1fr;
@@ -124,6 +310,9 @@
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.headerValue { .headerValue {
@@ -131,6 +320,12 @@
color: var(--text-primary); color: var(--text-primary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
}
/* Body section */
.bodySection {
margin-top: 12px;
} }
.sectionLabel { .sectionLabel {
@@ -140,44 +335,50 @@
letter-spacing: 0.6px; letter-spacing: 0.6px;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 6px; 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; display: flex;
align-items: center; align-items: center;
gap: 6px; 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;
}

View File

@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { import {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout, Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Breadcrumb, Spinner, SegmentedTabs, RouteFlow, ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'; import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
import { useCorrelationChain } from '../../api/queries/correlation'; 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); 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<string, string> {
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
const result: Record<string, string> = {};
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() { export default function ExchangeDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: detail, isLoading } = useExecutionDetail(id ?? null); const { data: detail, isLoading } = useExecutionDetail(id ?? null);
const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null); const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt');
const [viewMode, setViewMode] = useState<'timeline' | 'flow'>('timeline');
const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor);
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null); const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId); 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<number | null>(null);
const activeIndex = selectedProcessorIndex ?? defaultIndex;
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
const processors = useMemo(() => { const processors = useMemo(() => {
if (!detail?.children) return []; if (!procList.length) return [];
const result: any[] = []; const result: any[] = [];
let offset = 0; let offset = 0;
function walk(node: any) { function walk(node: any) {
@@ -39,9 +74,18 @@ export default function ExchangeDetail() {
offset += node.durationMs ?? 0; offset += node.durationMs ?? 0;
if (node.children) node.children.forEach(walk); if (node.children) node.children.forEach(walk);
} }
detail.children.forEach(walk); procList.forEach(walk);
return result; 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 <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>; if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>; if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
@@ -54,93 +98,116 @@ export default function ExchangeDetail() {
{ label: id?.slice(0, 12) || '' }, { label: id?.slice(0, 12) || '' },
]} /> ]} />
{/* Exchange header card */}
<div className={styles.exchangeHeader}> <div className={styles.exchangeHeader}>
<div className={styles.headerRow}> <div className={styles.headerRow}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} /> <StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
<div> <div>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} /> <div className={styles.exchangeId}>
<MonoText>{id}</MonoText> <MonoText size="md">{id}</MonoText>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
</div>
<div className={styles.exchangeRoute}>
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.groupName}/${detail.routeId}`)}>{detail.routeId}</span>
{detail.groupName && (
<>
<span className={styles.headerDivider}>&middot;</span>
App: <MonoText size="xs">{detail.groupName}</MonoText>
</>
)}
</div>
</div> </div>
</div> </div>
<div className={styles.headerRight}> <div className={styles.headerRight}>
<div className={styles.headerStat}> <div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Duration</div> <div className={styles.headerStatLabel}>Duration</div>
<div className={styles.headerStatValue}>{detail.durationMs}ms</div> <div className={styles.headerStatValue}>{formatDuration(detail.durationMs)}</div>
</div> </div>
<div className={styles.headerStat}> <div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Agent</div> <div className={styles.headerStatLabel}>Agent</div>
<div className={styles.headerStatValue}>{detail.agentId}</div> <div className={styles.headerStatValue}>{detail.agentId}</div>
</div> </div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Started</div>
<div className={styles.headerStatValue}>
{detail.startTime ? new Date(detail.startTime).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—'}
</div>
</div>
<div className={styles.headerStat}> <div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Processors</div> <div className={styles.headerStatLabel}>Processors</div>
<div className={styles.headerStatValue}>{countProcessors(detail.processors || detail.children || [])}</div> <div className={styles.headerStatValue}>{countProcessors(procList)}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Route</div>
<div className={styles.headerStatValue}>{detail.routeId}</div>
</div>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Application</div>
<div className={styles.headerStatValue}>{detail.groupName || 'unknown'}</div>
</div> </div>
</div> </div>
</div> </div>
</div>
{correlationData?.data && correlationData.data.length > 1 && ( {/* Correlation Chain */}
<div className={styles.correlationChain}> {correlationData?.data && correlationData.data.length > 1 && (
<div className={styles.panelHeader}> <div className={styles.correlationChain}>
<span className={styles.panelTitle}>Correlation Chain</span> <span className={styles.chainLabel}>Correlated Exchanges</span>
</div> {correlationData.data.map((exec: any) => {
<div className={styles.chainRow}> const isCurrent = exec.executionId === id;
{correlationData.data.map((exec, i) => ( const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
<React.Fragment key={exec.executionId}> const statusCls =
{i > 0 && <span className={styles.chainArrow}></span>} variant === 'success' ? styles.chainNodeSuccess
<a : variant === 'error' ? styles.chainNodeError
href={`/exchanges/${exec.executionId}`} : styles.chainNodeRunning;
className={`${styles.chainCard} ${exec.executionId === id ? styles.chainCardActive : ''}`} return (
onClick={(e) => { e.preventDefault(); navigate(`/exchanges/${exec.executionId}`); }} <button
key={exec.executionId}
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
title={`${exec.executionId}${exec.routeId}`}
> >
<StatusDot variant={exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'warning'} /> <StatusDot variant={variant as any} />
<span className={styles.chainRoute}>{exec.routeId}</span> <span>{exec.routeId}</span>
<span className={styles.chainDuration}>{exec.durationMs}ms</span> </button>
</a> );
</React.Fragment> })}
))}
{correlationData.total > 20 && ( {correlationData.total > 20 && (
<span className={styles.chainMore}>+{correlationData.total - 20} more</span> <span className={styles.chainMore}>+{correlationData.total - 20} more</span>
)} )}
</div> </div>
</div> )}
)} </div>
{/* Error callout */}
{detail.errorMessage && ( {detail.errorMessage && (
<InfoCallout variant="error"> <InfoCallout variant="error">
{detail.errorMessage} {detail.errorMessage}
</InfoCallout> </InfoCallout>
)} )}
{/* Processor Timeline / Flow Section */}
<div className={styles.timelineSection}> <div className={styles.timelineSection}>
<div className={styles.timelineHeader}> <div className={styles.timelineHeader}>
<span className={styles.timelineTitle}>Processors</span> <span className={styles.timelineTitle}>
<SegmentedTabs Processor Timeline
tabs={[ <span className={styles.procCount}>{processors.length} processors</span>
{ label: 'Timeline', value: 'timeline' }, </span>
{ label: 'Flow', value: 'flow' }, <div className={styles.timelineToggle}>
]} <button
active={viewMode} className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
onChange={(v) => setViewMode(v as 'timeline' | 'flow')} onClick={() => setTimelineView('gantt')}
/> >
Timeline
</button>
<button
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
onClick={() => setTimelineView('flow')}
>
Flow
</button>
</div>
</div> </div>
<div className={styles.timelineBody}> <div className={styles.timelineBody}>
{viewMode === 'timeline' ? ( {timelineView === 'gantt' ? (
processors.length > 0 ? ( processors.length > 0 ? (
<ProcessorTimeline <ProcessorTimeline
processors={processors} processors={processors}
totalMs={detail.durationMs} totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setSelectedProcessor(i)} onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
selectedIndex={selectedProcessor ?? undefined} selectedIndex={activeIndex}
/> />
) : ( ) : (
<InfoCallout>No processor data available</InfoCallout> <InfoCallout>No processor data available</InfoCallout>
@@ -148,9 +215,9 @@ export default function ExchangeDetail() {
) : ( ) : (
diagram ? ( diagram ? (
<RouteFlow <RouteFlow
nodes={mapDiagramToRouteNodes(diagram.nodes || [], detail.processors || detail.children || [])} nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
onNodeClick={(_node, i) => setSelectedProcessor(i)} onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
selectedIndex={selectedProcessor ?? undefined} selectedIndex={activeIndex}
/> />
) : ( ) : (
<Spinner /> <Spinner />
@@ -159,46 +226,102 @@ export default function ExchangeDetail() {
</div> </div>
</div> </div>
{snapshot && ( {/* Processor Detail: Message IN / Message OUT or Error */}
<> {selectedProc && snapshot && (
<div className={styles.sectionLabel}>Exchange Snapshot</div> <div className={styles.detailSplit}>
<div className={styles.detailSplit}> {/* Message IN */}
<div className={styles.detailPanel}> <div className={styles.detailPanel}>
<div className={styles.panelHeader}> <div className={styles.panelHeader}>
<span className={styles.panelTitle}>Input Body</span> <span className={styles.panelTitle}>
</div> <span className={styles.arrowIn}>&rarr;</span> Message IN
<div className={styles.panelBody}> </span>
<CodeBlock content={String(snapshot.inputBody ?? 'null')} /> <span className={styles.panelTag}>at processor #{activeIndex + 1} entry</span>
</div>
</div> </div>
<div className={styles.detailPanel}> <div className={styles.panelBody}>
<div className={styles.panelHeader}> {Object.keys(inputHeaders).length > 0 && (
<span className={styles.panelTitle}>Output Body</span> <div className={styles.headersSection}>
</div> <div className={styles.sectionLabel}>
<div className={styles.panelBody}> Headers <span className={styles.count}>{Object.keys(inputHeaders).length}</span>
<CodeBlock content={String(snapshot.outputBody ?? 'null')} /> </div>
<div className={styles.headerList}>
{Object.entries(inputHeaders).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={inputBody ?? 'null'} />
</div> </div>
</div> </div>
</div> </div>
<div className={styles.detailSplit}>
<div className={styles.detailPanel}> {/* Message OUT or Error */}
{isSelectedFailed ? (
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
<div className={styles.panelHeader}> <div className={styles.panelHeader}>
<span className={styles.panelTitle}>Input Headers</span> <span className={styles.panelTitle}>
<span className={styles.arrowError}>&times;</span> Error at Processor #{activeIndex + 1}
</span>
<Badge label="FAILED" color="error" variant="filled" />
</div> </div>
<div className={styles.panelBody}> <div className={styles.panelBody}>
<CodeBlock content={JSON.stringify(snapshot.inputHeaders ?? {}, null, 2)} /> {detail.errorMessage && (
<div className={styles.errorMessageBox}>{detail.errorMessage}</div>
)}
<div className={styles.errorDetailGrid}>
<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> </div>
) : (
<div className={styles.detailPanel}> <div className={styles.detailPanel}>
<div className={styles.panelHeader}> <div className={styles.panelHeader}>
<span className={styles.panelTitle}>Output Headers</span> <span className={styles.panelTitle}>
<span className={styles.arrowOut}>&larr;</span> Message OUT
</span>
<span className={styles.panelTag}>after processor #{activeIndex + 1}</span>
</div> </div>
<div className={styles.panelBody}> <div className={styles.panelBody}>
<CodeBlock content={JSON.stringify(snapshot.outputHeaders ?? {}, null, 2)} /> {Object.keys(outputHeaders).length > 0 && (
<div className={styles.headersSection}>
<div className={styles.sectionLabel}>
Headers <span className={styles.count}>{Object.keys(outputHeaders).length}</span>
</div>
<div className={styles.headerList}>
{Object.entries(outputHeaders).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={outputBody ?? 'null'} />
</div>
</div> </div>
</div> </div>
</div> )}
</> </div>
)}
{/* No snapshot loaded yet - show prompt */}
{selectedProc && !snapshot && procList.length > 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
Loading exchange snapshot...
</div>
)} )}
</div> </div>
); );

View File

@@ -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<string, (typeof flatProcessors)[0]>();
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,
};
});
}