Files
cameleer-server/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx
hsiegeln 5ccefa3cdb feat: add ExecutionDiagram wrapper component
Composes ProcessDiagram with execution overlay data, exchange summary
bar, resizable splitter, and detail panel into a single root component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:05:43 +01:00

211 lines
6.9 KiB
TypeScript

import { useCallback, useRef, useState } from 'react';
import type { NodeAction, NodeConfig } from '../ProcessDiagram/types';
import type { ExecutionDetail, ProcessorNode } from './types';
import { useExecutionDetail } from '../../api/queries/executions';
import { useDiagramLayout } from '../../api/queries/diagrams';
import { ProcessDiagram } from '../ProcessDiagram';
import { DetailPanel } from './DetailPanel';
import { useExecutionOverlay } from './useExecutionOverlay';
import { useIterationState } from './useIterationState';
import styles from './ExecutionDiagram.module.css';
interface ExecutionDiagramProps {
executionId: string;
executionDetail?: ExecutionDetail;
direction?: 'LR' | 'TB';
knownRouteIds?: Set<string>;
onNodeAction?: (nodeId: string, action: NodeAction) => void;
nodeConfigs?: Map<string, NodeConfig>;
className?: string;
}
function findProcessorInTree(
nodes: ProcessorNode[] | undefined,
processorId: string | null,
): ProcessorNode | null {
if (!nodes || !processorId) return null;
for (const n of nodes) {
if (n.processorId === processorId) return n;
if (n.children) {
const found = findProcessorInTree(n.children, processorId);
if (found) return found;
}
}
return null;
}
function findFailedProcessor(nodes: ProcessorNode[]): ProcessorNode | null {
for (const n of nodes) {
if (n.status === 'FAILED') return n;
if (n.children) {
const found = findFailedProcessor(n.children);
if (found) return found;
}
}
return null;
}
function statusBadgeClass(status: string): string {
const s = status?.toUpperCase();
if (s === 'COMPLETED') return `${styles.statusBadge} ${styles.statusCompleted}`;
if (s === 'FAILED') return `${styles.statusBadge} ${styles.statusFailed}`;
if (s === 'RUNNING') return `${styles.statusBadge} ${styles.statusRunning}`;
return styles.statusBadge;
}
export function ExecutionDiagram({
executionId,
executionDetail: externalDetail,
direction = 'LR',
knownRouteIds,
onNodeAction,
nodeConfigs,
className,
}: ExecutionDiagramProps) {
// 1. Fetch execution data (skip if pre-fetched prop provided)
const detailQuery = useExecutionDetail(externalDetail ? null : executionId);
const detail = externalDetail ?? detailQuery.data;
const detailLoading = !externalDetail && detailQuery.isLoading;
const detailError = !externalDetail && detailQuery.error;
// 2. Load diagram by content hash
const diagramQuery = useDiagramLayout(detail?.diagramContentHash ?? null, direction);
const diagramLayout = diagramQuery.data;
const diagramLoading = diagramQuery.isLoading;
const diagramError = diagramQuery.error;
// 3. Initialize iteration state
const { iterationState, setIteration } = useIterationState(detail?.processors);
// 4. Compute overlay
const overlay = useExecutionOverlay(detail?.processors, iterationState);
// 5. Manage selection
const [selectedProcessorId, setSelectedProcessorId] = useState<string>('');
// 6. Resizable splitter state
const [splitPercent, setSplitPercent] = useState(60);
const containerRef = useRef<HTMLDivElement>(null);
const handleSplitterDown = useCallback((e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
const container = containerRef.current;
if (!container) return;
const onMove = (me: PointerEvent) => {
const rect = container.getBoundingClientRect();
const y = me.clientY - rect.top;
const pct = Math.min(85, Math.max(30, (y / rect.height) * 100));
setSplitPercent(pct);
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
}, []);
// Jump to error: find first FAILED processor and select it
const handleJumpToError = useCallback(() => {
if (!detail?.processors) return;
const failed = findFailedProcessor(detail.processors);
if (failed?.processorId) {
setSelectedProcessorId(failed.processorId);
}
}, [detail?.processors]);
// Loading state
if (detailLoading || (detail && diagramLoading)) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.loadingState}>Loading execution data...</div>
</div>
);
}
// Error state
if (detailError) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.errorState}>Failed to load execution detail</div>
</div>
);
}
if (diagramError) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.errorState}>Failed to load diagram</div>
</div>
);
}
if (!detail) {
return (
<div className={`${styles.executionDiagram} ${className ?? ''}`}>
<div className={styles.loadingState}>No execution data</div>
</div>
);
}
return (
<div ref={containerRef} className={`${styles.executionDiagram} ${className ?? ''}`}>
{/* Exchange summary bar */}
<div className={styles.exchangeBar}>
<span className={styles.exchangeLabel}>Exchange</span>
<code className={styles.exchangeId}>{detail.exchangeId || detail.executionId}</code>
<span className={statusBadgeClass(detail.status)}>
{detail.status}
</span>
<span className={styles.exchangeMeta}>
{detail.applicationName} / {detail.routeId}
</span>
<span className={styles.exchangeMeta}>{detail.durationMs}ms</span>
{detail.status === 'FAILED' && (
<button
className={styles.jumpToError}
onClick={handleJumpToError}
type="button"
>
Jump to Error
</button>
)}
</div>
{/* Diagram area */}
<div className={styles.diagramArea} style={{ height: `${splitPercent}%` }}>
<ProcessDiagram
application={detail.applicationName}
routeId={detail.routeId}
direction={direction}
diagramLayout={diagramLayout}
selectedNodeId={selectedProcessorId}
onNodeSelect={setSelectedProcessorId}
onNodeAction={onNodeAction}
nodeConfigs={nodeConfigs}
knownRouteIds={knownRouteIds}
executionOverlay={overlay}
iterationState={iterationState}
onIterationChange={setIteration}
/>
</div>
{/* Resizable splitter */}
<div
className={styles.splitter}
onPointerDown={handleSplitterDown}
/>
{/* Detail panel */}
<div className={styles.detailArea} style={{ height: `${100 - splitPercent}%` }}>
<DetailPanel
selectedProcessor={findProcessorInTree(detail.processors, selectedProcessorId || null)}
executionDetail={detail}
executionId={executionId}
onSelectProcessor={setSelectedProcessorId}
/>
</div>
</div>
);
}