- 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>
329 lines
14 KiB
TypeScript
329 lines
14 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useParams, useNavigate } from 'react-router';
|
|
import {
|
|
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
|
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
|
|
} from '@cameleer/design-system';
|
|
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
|
import { useCorrelationChain } from '../../api/queries/correlation';
|
|
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
|
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
|
import styles from './ExchangeDetail.module.css';
|
|
|
|
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<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() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
|
|
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<number | null>(null);
|
|
const activeIndex = selectedProcessorIndex ?? defaultIndex;
|
|
|
|
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
|
|
|
|
const processors = useMemo(() => {
|
|
if (!procList.length) return [];
|
|
const result: any[] = [];
|
|
let offset = 0;
|
|
function walk(node: any) {
|
|
result.push({
|
|
name: node.processorId || node.processorType,
|
|
type: node.processorType,
|
|
durationMs: node.durationMs ?? 0,
|
|
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
|
|
startMs: offset,
|
|
});
|
|
offset += node.durationMs ?? 0;
|
|
if (node.children) node.children.forEach(walk);
|
|
}
|
|
procList.forEach(walk);
|
|
return result;
|
|
}, [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 (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
|
|
|
|
return (
|
|
<div>
|
|
<Breadcrumb items={[
|
|
{ label: 'Dashboard', href: '/apps' },
|
|
{ label: detail.groupName || 'App', href: `/apps/${detail.groupName}` },
|
|
{ label: id?.slice(0, 12) || '' },
|
|
]} />
|
|
|
|
{/* Exchange header card */}
|
|
<div className={styles.exchangeHeader}>
|
|
<div className={styles.headerRow}>
|
|
<div className={styles.headerLeft}>
|
|
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
|
|
<div>
|
|
<div className={styles.exchangeId}>
|
|
<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 className={styles.headerRight}>
|
|
<div className={styles.headerStat}>
|
|
<div className={styles.headerStatLabel}>Duration</div>
|
|
<div className={styles.headerStatValue}>{formatDuration(detail.durationMs)}</div>
|
|
</div>
|
|
<div className={styles.headerStat}>
|
|
<div className={styles.headerStatLabel}>Agent</div>
|
|
<div className={styles.headerStatValue}>{detail.agentId}</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.headerStatLabel}>Processors</div>
|
|
<div className={styles.headerStatValue}>{countProcessors(procList)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Correlation Chain */}
|
|
{correlationData?.data && correlationData.data.length > 1 && (
|
|
<div className={styles.correlationChain}>
|
|
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
|
{correlationData.data.map((exec: any) => {
|
|
const isCurrent = exec.executionId === id;
|
|
const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
|
|
const statusCls =
|
|
variant === 'success' ? styles.chainNodeSuccess
|
|
: variant === 'error' ? styles.chainNodeError
|
|
: styles.chainNodeRunning;
|
|
return (
|
|
<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={variant as any} />
|
|
<span>{exec.routeId}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{correlationData.total > 20 && (
|
|
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error callout */}
|
|
{detail.errorMessage && (
|
|
<InfoCallout variant="error">
|
|
{detail.errorMessage}
|
|
</InfoCallout>
|
|
)}
|
|
|
|
{/* Processor Timeline / Flow Section */}
|
|
<div className={styles.timelineSection}>
|
|
<div className={styles.timelineHeader}>
|
|
<span className={styles.timelineTitle}>
|
|
Processor Timeline
|
|
<span className={styles.procCount}>{processors.length} processors</span>
|
|
</span>
|
|
<div className={styles.timelineToggle}>
|
|
<button
|
|
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
|
|
onClick={() => setTimelineView('gantt')}
|
|
>
|
|
Timeline
|
|
</button>
|
|
<button
|
|
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
|
|
onClick={() => setTimelineView('flow')}
|
|
>
|
|
Flow
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className={styles.timelineBody}>
|
|
{timelineView === 'gantt' ? (
|
|
processors.length > 0 ? (
|
|
<ProcessorTimeline
|
|
processors={processors}
|
|
totalMs={detail.durationMs}
|
|
onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
|
|
selectedIndex={activeIndex}
|
|
/>
|
|
) : (
|
|
<InfoCallout>No processor data available</InfoCallout>
|
|
)
|
|
) : (
|
|
diagram ? (
|
|
<RouteFlow
|
|
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
|
onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
|
|
selectedIndex={activeIndex}
|
|
/>
|
|
) : (
|
|
<Spinner />
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Processor Detail: Message IN / Message OUT or Error */}
|
|
{selectedProc && snapshot && (
|
|
<div className={styles.detailSplit}>
|
|
{/* Message IN */}
|
|
<div className={styles.detailPanel}>
|
|
<div className={styles.panelHeader}>
|
|
<span className={styles.panelTitle}>
|
|
<span className={styles.arrowIn}>→</span> Message IN
|
|
</span>
|
|
<span className={styles.panelTag}>at processor #{activeIndex + 1} entry</span>
|
|
</div>
|
|
<div className={styles.panelBody}>
|
|
{Object.keys(inputHeaders).length > 0 && (
|
|
<div className={styles.headersSection}>
|
|
<div className={styles.sectionLabel}>
|
|
Headers <span className={styles.count}>{Object.keys(inputHeaders).length}</span>
|
|
</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>
|
|
|
|
{/* Message OUT or Error */}
|
|
{isSelectedFailed ? (
|
|
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
|
|
<div className={styles.panelHeader}>
|
|
<span className={styles.panelTitle}>
|
|
<span className={styles.arrowError}>×</span> Error at Processor #{activeIndex + 1}
|
|
</span>
|
|
<Badge label="FAILED" color="error" variant="filled" />
|
|
</div>
|
|
<div className={styles.panelBody}>
|
|
{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 className={styles.detailPanel}>
|
|
<div className={styles.panelHeader}>
|
|
<span className={styles.panelTitle}>
|
|
<span className={styles.arrowOut}>←</span> Message OUT
|
|
</span>
|
|
<span className={styles.panelTag}>after processor #{activeIndex + 1}</span>
|
|
</div>
|
|
<div className={styles.panelBody}>
|
|
{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>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|