Files
cameleer-server/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx
hsiegeln 4572230c9c
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
fix: align all pages with design system mocks — stat cards, tables, detail panels
Dashboard: correct stat card labels (Exchanges/Success Rate/Errors/Throughput/Latency p99),
add detail text, trends, sparklines on all cards, Agent column, LIVE badge,
expanded detail panel with Agent/Correlation/Timestamp, "Open full details" link.

Agent Health: per-group meta (TPS/routes) in GroupCard header, proper HTML table
with column headers for instance list.

Agent Instance: stat card detail props (heap info, start date), scope trail with
inline status/version/routes badges.

Routes: 5th In-Flight stat card, enriched stat card props (detail/trend/sparkline),
SLA threshold line on latency chart.

Exchange Detail: Agent stat box in header.

Also: vite proxy CORS fix, cross-env dev scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:28:56 +01:00

206 lines
8.5 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Breadcrumb, Spinner, SegmentedTabs, 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);
}
export default function ExchangeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'timeline' | 'flow'>('timeline');
const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor);
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId);
const processors = useMemo(() => {
if (!detail?.children) 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);
}
detail.children.forEach(walk);
return result;
}, [detail]);
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) || '' },
]} />
<div className={styles.exchangeHeader}>
<div className={styles.headerRow}>
<div className={styles.headerLeft}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
<div>
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} />
<MonoText>{id}</MonoText>
</div>
</div>
<div className={styles.headerRight}>
<div className={styles.headerStat}>
<div className={styles.headerStatLabel}>Duration</div>
<div className={styles.headerStatValue}>{detail.durationMs}ms</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}>Processors</div>
<div className={styles.headerStatValue}>{countProcessors(detail.processors || detail.children || [])}</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>
{correlationData?.data && correlationData.data.length > 1 && (
<div className={styles.correlationChain}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Correlation Chain</span>
</div>
<div className={styles.chainRow}>
{correlationData.data.map((exec, i) => (
<React.Fragment key={exec.executionId}>
{i > 0 && <span className={styles.chainArrow}></span>}
<a
href={`/exchanges/${exec.executionId}`}
className={`${styles.chainCard} ${exec.executionId === id ? styles.chainCardActive : ''}`}
onClick={(e) => { e.preventDefault(); navigate(`/exchanges/${exec.executionId}`); }}
>
<StatusDot variant={exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'warning'} />
<span className={styles.chainRoute}>{exec.routeId}</span>
<span className={styles.chainDuration}>{exec.durationMs}ms</span>
</a>
</React.Fragment>
))}
{correlationData.total > 20 && (
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
)}
</div>
</div>
)}
{detail.errorMessage && (
<InfoCallout variant="error">
{detail.errorMessage}
</InfoCallout>
)}
<div className={styles.timelineSection}>
<div className={styles.timelineHeader}>
<span className={styles.timelineTitle}>Processors</span>
<SegmentedTabs
tabs={[
{ label: 'Timeline', value: 'timeline' },
{ label: 'Flow', value: 'flow' },
]}
active={viewMode}
onChange={(v) => setViewMode(v as 'timeline' | 'flow')}
/>
</div>
<div className={styles.timelineBody}>
{viewMode === 'timeline' ? (
processors.length > 0 ? (
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setSelectedProcessor(i)}
selectedIndex={selectedProcessor ?? undefined}
/>
) : (
<InfoCallout>No processor data available</InfoCallout>
)
) : (
diagram ? (
<RouteFlow
nodes={mapDiagramToRouteNodes(diagram.nodes || [], detail.processors || detail.children || [])}
onNodeClick={(_node, i) => setSelectedProcessor(i)}
selectedIndex={selectedProcessor ?? undefined}
/>
) : (
<Spinner />
)
)}
</div>
</div>
{snapshot && (
<>
<div className={styles.sectionLabel}>Exchange Snapshot</div>
<div className={styles.detailSplit}>
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Input Body</span>
</div>
<div className={styles.panelBody}>
<CodeBlock content={String(snapshot.inputBody ?? 'null')} />
</div>
</div>
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Output Body</span>
</div>
<div className={styles.panelBody}>
<CodeBlock content={String(snapshot.outputBody ?? 'null')} />
</div>
</div>
</div>
<div className={styles.detailSplit}>
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Input Headers</span>
</div>
<div className={styles.panelBody}>
<CodeBlock content={JSON.stringify(snapshot.inputHeaders ?? {}, null, 2)} />
</div>
</div>
<div className={styles.detailPanel}>
<div className={styles.panelHeader}>
<span className={styles.panelTitle}>Output Headers</span>
</div>
<div className={styles.panelBody}>
<CodeBlock content={JSON.stringify(snapshot.outputHeaders ?? {}, null, 2)} />
</div>
</div>
</div>
</>
)}
</div>
);
}