import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ProcessDiagramProps } from './types'; import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams'; import { useDiagramData } from './useDiagramData'; import { useZoomPan } from './useZoomPan'; import { useToolbarHover, NodeToolbar } from './NodeToolbar'; import { DiagramNode } from './DiagramNode'; import { DiagramEdge } from './DiagramEdge'; import { CompoundNode } from './CompoundNode'; import { ErrorSection } from './ErrorSection'; import { ZoomControls } from './ZoomControls'; import { Minimap } from './Minimap'; import { isCompoundType } from './node-colors'; import styles from './ProcessDiagram.module.css'; const PADDING = 40; /** Types that support drill-down — double-click navigates to the target route */ const DRILLDOWN_TYPES = new Set(['DIRECT', 'SEDA']); /** Extract the target endpoint name from a node's label */ function extractTargetEndpoint(node: DiagramNodeType): string | null { // Labels like "to: direct:orderProcessing" or "direct:orderProcessing" const label = node.label ?? ''; const match = label.match(/(?:to:\s*)?(?:direct|seda):(\S+)/i); return match ? match[1] : null; } /** Convert camelCase to kebab-case: "callGetProduct" → "call-get-product" */ function camelToKebab(s: string): string { return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); } /** * Resolve a direct/seda endpoint name to a routeId. * Tries: exact match, kebab-case conversion, then gives up. */ function resolveRouteId(endpoint: string, knownRouteIds: Set): string | null { if (knownRouteIds.has(endpoint)) return endpoint; const kebab = camelToKebab(endpoint); if (knownRouteIds.has(kebab)) return kebab; return null; } export function ProcessDiagram({ application, routeId, direction = 'LR', selectedNodeId, onNodeSelect, onNodeAction, nodeConfigs, knownRouteIds, className, diagramLayout, executionOverlay, iterationState, onIterationChange, }: ProcessDiagramProps) { // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([routeId]); // Reset stack when the external routeId prop changes useEffect(() => { setRouteStack([routeId]); }, [routeId]); const currentRouteId = routeStack[routeStack.length - 1]; const isDrilledDown = currentRouteId !== routeId; // Disable overlay when drilled down — the execution data is for the root route // and doesn't map to sub-route node IDs. Sub-route shows topology only. const overlayActive = !!executionOverlay && !isDrilledDown; const effectiveOverlay = isDrilledDown ? undefined : executionOverlay; // Only use the pre-fetched diagramLayout for the root route. const effectiveLayout = isDrilledDown ? undefined : diagramLayout; const { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData( application, currentRouteId, direction, effectiveLayout, ); // Collect ENDPOINT node IDs — these are always "traversed" when overlay is active // because the endpoint is the route entry point (not in the processor execution tree). const endpointNodeIds = useMemo(() => { const ids = new Set(); if (!overlayActive || !sections.length) return ids; for (const section of sections) { for (const node of section.nodes) { if (node.type === 'ENDPOINT' && node.id) ids.add(node.id); } } return ids; }, [overlayActive, sections]); const zoom = useZoomPan(); const toolbar = useToolbarHover(); const contentWidth = totalWidth + PADDING * 2; const contentHeight = totalHeight + PADDING * 2; // Reset to 100% at top-left when route changes useEffect(() => { if (totalWidth > 0 && totalHeight > 0) { zoom.resetView(); } }, [totalWidth, totalHeight, currentRouteId]); // eslint-disable-line react-hooks/exhaustive-deps // Resolve execution state for a node. ENDPOINT nodes (the route's "from:") // don't appear in the processor execution tree, but should be marked as // COMPLETED when the route executed (i.e., overlay has any entries). const getNodeExecutionState = useCallback( (nodeId: string | undefined, nodeType: string | undefined) => { if (!nodeId || !effectiveOverlay) return undefined; const state = effectiveOverlay.get(nodeId); if (state) return state; // Synthesize COMPLETED for ENDPOINT nodes when overlay is active if (nodeType === 'ENDPOINT' && effectiveOverlay.size > 0) { return { status: 'COMPLETED' as const, durationMs: 0, hasTraceData: false }; } return undefined; }, [effectiveOverlay], ); const handleNodeClick = useCallback( (nodeId: string) => { onNodeSelect?.(nodeId); }, [onNodeSelect], ); const handleNodeDoubleClick = useCallback( (nodeId: string) => { const node = findNodeById(sections, nodeId); if (!node || !DRILLDOWN_TYPES.has(node.type ?? '')) return; const endpoint = extractTargetEndpoint(node); if (!endpoint) return; const resolved = knownRouteIds ? resolveRouteId(endpoint, knownRouteIds) : endpoint; if (resolved) { onNodeSelect?.(''); setRouteStack(prev => [...prev, resolved]); } }, [sections, onNodeSelect, knownRouteIds], ); const handleBreadcrumbClick = useCallback( (index: number) => { onNodeSelect?.(''); setRouteStack(prev => prev.slice(0, index + 1)); }, [onNodeSelect], ); const handleNodeAction = useCallback( (nodeId: string, action: import('./types').NodeAction) => { if (action === 'inspect') onNodeSelect?.(nodeId); onNodeAction?.(nodeId, action); }, [onNodeSelect, onNodeAction], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { if (routeStack.length > 1) { // Go back one level setRouteStack(prev => prev.slice(0, -1)); } else { onNodeSelect?.(''); } return; } zoom.onKeyDown(e, contentWidth, contentHeight); }, [onNodeSelect, zoom, contentWidth, contentHeight, routeStack.length], ); if (isLoading) { return (
Loading diagram...
); } if (error) { return (
Failed to load diagram
); } if (sections.length === 0) { return (
No diagram data available
); } const mainSection = sections[0]; const handlerSections = sections.slice(1); return (
{/* Breadcrumb bar — only shown when drilled down */} {routeStack.length > 1 && (
{routeStack.map((route, i) => ( {i > 0 && /} {i < routeStack.length - 1 ? ( ) : ( {route} )} ))}
)} onNodeSelect?.('')} > {/* Main section top-level edges (not inside compounds) */} {mainSection.edges.filter(e => topLevelEdge(e, mainSection.nodes)).map((edge, i) => { const sourceHasState = effectiveOverlay?.has(edge.sourceId) || endpointNodeIds.has(edge.sourceId); const targetHasState = effectiveOverlay?.has(edge.targetId) || endpointNodeIds.has(edge.targetId); const isTraversed = effectiveOverlay ? (!!sourceHasState && !!targetHasState) : undefined; return ( ); })} {/* Main section nodes */} {mainSection.nodes.map(node => { if (isCompoundType(node.type) && node.children && node.children.length > 0) { return ( ); } return ( node.id && handleNodeClick(node.id)} onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)} onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)} onMouseLeave={toolbar.onNodeLeave} /> ); })} {/* Toolbar rendered as HTML overlay below */} {/* Handler sections (completion, then error) */} {handlerSections.map((section, i) => ( ))} {/* Node toolbar — HTML overlay, fixed size regardless of zoom */} {toolbar.hoveredNodeId && onNodeAction && (() => { const hNode = findNodeById(sections, toolbar.hoveredNodeId!); if (!hNode) return null; const nodeCenter = (hNode.x ?? 0) + (hNode.width ?? 160) / 2; const nodeTop = hNode.y ?? 0; const screenX = nodeCenter * zoom.state.scale + zoom.state.translateX; const screenY = nodeTop * zoom.state.scale + zoom.state.translateY; return ( ); })()} zoom.fitToView(contentWidth, contentHeight)} scale={zoom.state.scale} />
); } function findNodeById( sections: import('./types').DiagramSection[], nodeId: string, ): DiagramNodeType | undefined { for (const section of sections) { for (const node of section.nodes) { if (node.id === nodeId) return node; if (node.children) { const found = findInChildren(node.children, nodeId); if (found) return found; } } } return undefined; } function findInChildren( nodes: DiagramNodeType[], nodeId: string, ): DiagramNodeType | undefined { for (const n of nodes) { if (n.id === nodeId) return n; if (n.children) { const found = findInChildren(n.children, nodeId); if (found) return found; } } return undefined; } function topLevelEdge( edge: import('../../api/queries/diagrams').DiagramEdge, nodes: DiagramNodeType[], ): boolean { const compoundChildIds = new Set(); for (const n of nodes) { if (n.children && n.children.length > 0) { collectDescendantIds(n.children, compoundChildIds); } } return !compoundChildIds.has(edge.sourceId) && !compoundChildIds.has(edge.targetId); } function collectDescendantIds(nodes: DiagramNodeType[], set: Set) { for (const n of nodes) { if (n.id) set.add(n.id); if (n.children) collectDescendantIds(n.children, set); } }