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>
211 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|