diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css index c4964460..4c50b751 100644 --- a/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.module.css @@ -1,3 +1,105 @@ +/* ========================================================================== + EXECUTION DIAGRAM — LAYOUT + ========================================================================== */ +.executionDiagram { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 400px; + overflow: hidden; +} + +.exchangeBar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--bg-surface, #FFFFFF); + border-bottom: 1px solid var(--border, #E4DFD8); + font-size: 12px; + color: var(--text-secondary, #5C5347); + flex-shrink: 0; +} + +.exchangeLabel { + font-weight: 600; + color: var(--text-primary, #1A1612); +} + +.exchangeId { + font-size: 11px; + background: var(--bg-hover, #F5F0EA); + padding: 2px 6px; + border-radius: 3px; + color: var(--text-primary, #1A1612); +} + +.exchangeMeta { + color: var(--text-muted, #9C9184); +} + +.jumpToError { + margin-left: auto; + font-size: 10px; + padding: 3px 10px; + border: 1px solid var(--error, #C0392B); + background: #FDF2F0; + color: var(--error, #C0392B); + border-radius: 4px; + cursor: pointer; + font-weight: 500; + font-family: inherit; +} + +.jumpToError:hover { + background: #F9E0DC; +} + +.diagramArea { + overflow: hidden; + position: relative; +} + +.splitter { + height: 4px; + background: var(--border, #E4DFD8); + cursor: row-resize; + flex-shrink: 0; +} + +.splitter:hover { + background: var(--amber, #C6820E); +} + +.detailArea { + overflow: hidden; + min-height: 120px; +} + +.loadingState { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-muted, #9C9184); + font-size: 13px; +} + +.errorState { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--error, #C0392B); + font-size: 13px; +} + +.statusRunning { + color: var(--amber, #C6820E); + background: #FFF8F0; +} + /* ========================================================================== DETAIL PANEL ========================================================================== */ diff --git a/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx new file mode 100644 index 00000000..e19ddf7e --- /dev/null +++ b/ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx @@ -0,0 +1,210 @@ +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; + onNodeAction?: (nodeId: string, action: NodeAction) => void; + nodeConfigs?: Map; + 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(''); + + // 6. Resizable splitter state + const [splitPercent, setSplitPercent] = useState(60); + const containerRef = useRef(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 ( +
+
Loading execution data...
+
+ ); + } + + // Error state + if (detailError) { + return ( +
+
Failed to load execution detail
+
+ ); + } + + if (diagramError) { + return ( +
+
Failed to load diagram
+
+ ); + } + + if (!detail) { + return ( +
+
No execution data
+
+ ); + } + + return ( +
+ {/* Exchange summary bar */} +
+ Exchange + {detail.exchangeId || detail.executionId} + + {detail.status} + + + {detail.applicationName} / {detail.routeId} + + {detail.durationMs}ms + {detail.status === 'FAILED' && ( + + )} +
+ + {/* Diagram area */} +
+ +
+ + {/* Resizable splitter */} +
+ + {/* Detail panel */} +
+ +
+
+ ); +} diff --git a/ui/src/components/ExecutionDiagram/index.ts b/ui/src/components/ExecutionDiagram/index.ts new file mode 100644 index 00000000..45e339eb --- /dev/null +++ b/ui/src/components/ExecutionDiagram/index.ts @@ -0,0 +1,2 @@ +export { ExecutionDiagram } from './ExecutionDiagram'; +export type { NodeExecutionState, IterationInfo, DetailTab } from './types';