fix: add groupName to ExecutionDetail, rewrite ExchangeDetail to match mock
- 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:
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}>·</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}>→</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}>×</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}>←</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>
|
||||||
);
|
);
|
||||||
|
|||||||
55
ui/src/utils/diagram-mapping.ts
Normal file
55
ui/src/utils/diagram-mapping.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user