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>
This commit is contained in:
@@ -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
|
||||
========================================================================== */
|
||||
|
||||
210
ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx
Normal file
210
ui/src/components/ExecutionDiagram/ExecutionDiagram.tsx
Normal file
@@ -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<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>
|
||||
);
|
||||
}
|
||||
2
ui/src/components/ExecutionDiagram/index.ts
Normal file
2
ui/src/components/ExecutionDiagram/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ExecutionDiagram } from './ExecutionDiagram';
|
||||
export type { NodeExecutionState, IterationInfo, DetailTab } from './types';
|
||||
Reference in New Issue
Block a user