Added centerOnNodeId prop to ProcessDiagram. When set, the diagram pans to center the specified node in the viewport. Jump to Error now selects the failed processor AND centers the viewport on it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
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>): 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,
|
|
centerOnNodeId,
|
|
}: ProcessDiagramProps) {
|
|
// Route stack for drill-down navigation
|
|
const [routeStack, setRouteStack] = useState<string[]>([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<string>();
|
|
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
|
|
|
|
// Center on a specific node when centerOnNodeId changes
|
|
useEffect(() => {
|
|
if (!centerOnNodeId || sections.length === 0) return;
|
|
const node = findNodeById(sections, centerOnNodeId);
|
|
if (!node) return;
|
|
const container = zoom.containerRef.current;
|
|
if (!container) return;
|
|
// Compute the node center in diagram coordinates
|
|
const nodeX = (node.x ?? 0) + (node.width ?? 160) / 2;
|
|
const nodeY = (node.y ?? 0) + (node.height ?? 40) / 2;
|
|
// Find which section the node is in to add its offsetY
|
|
let sectionOffsetY = 0;
|
|
for (const section of sections) {
|
|
const found = findNodeInSection(section.nodes, centerOnNodeId);
|
|
if (found) { sectionOffsetY = section.offsetY; break; }
|
|
}
|
|
const adjustedY = nodeY + sectionOffsetY;
|
|
// Pan so the node center is at the viewport center
|
|
const cw = container.clientWidth;
|
|
const ch = container.clientHeight;
|
|
const scale = zoom.state.scale;
|
|
zoom.panTo(
|
|
cw / 2 - nodeX * scale,
|
|
ch / 2 - adjustedY * scale,
|
|
);
|
|
}, [centerOnNodeId]); // 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 (
|
|
<div className={`${styles.container} ${className ?? ''}`}>
|
|
<div className={styles.loading}>Loading diagram...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className={`${styles.container} ${className ?? ''}`}>
|
|
<div className={styles.error}>Failed to load diagram</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sections.length === 0) {
|
|
return (
|
|
<div className={`${styles.container} ${className ?? ''}`}>
|
|
<div className={styles.loading}>No diagram data available</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const mainSection = sections[0];
|
|
const handlerSections = sections.slice(1);
|
|
|
|
return (
|
|
<div
|
|
ref={zoom.containerRef}
|
|
className={`${styles.container} ${className ?? ''}`}
|
|
>
|
|
{/* Breadcrumb bar — only shown when drilled down */}
|
|
{routeStack.length > 1 && (
|
|
<div className={styles.breadcrumbs}>
|
|
{routeStack.map((route, i) => (
|
|
<span key={i} className={styles.breadcrumbItem}>
|
|
{i > 0 && <span className={styles.breadcrumbSep}>/</span>}
|
|
{i < routeStack.length - 1 ? (
|
|
<button
|
|
className={styles.breadcrumbLink}
|
|
onClick={() => handleBreadcrumbClick(i)}
|
|
>
|
|
{route}
|
|
</button>
|
|
) : (
|
|
<span className={styles.breadcrumbCurrent}>{route}</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<svg
|
|
ref={zoom.svgRef}
|
|
className={styles.svg}
|
|
onPointerDown={zoom.onPointerDown}
|
|
onPointerMove={zoom.onPointerMove}
|
|
onPointerUp={zoom.onPointerUp}
|
|
onKeyDown={handleKeyDown}
|
|
tabIndex={0}
|
|
onClick={() => onNodeSelect?.('')}
|
|
>
|
|
<defs>
|
|
<marker
|
|
id="arrowhead"
|
|
markerWidth="8"
|
|
markerHeight="6"
|
|
refX="7"
|
|
refY="3"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 8 3, 0 6" fill="#9CA3AF" />
|
|
</marker>
|
|
<marker
|
|
id="arrowhead-green"
|
|
markerWidth="8"
|
|
markerHeight="6"
|
|
refX="7"
|
|
refY="3"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 8 3, 0 6" fill="#3D7C47" />
|
|
</marker>
|
|
</defs>
|
|
|
|
<g style={{ transform: zoom.transform, transformOrigin: '0 0' }}>
|
|
{/* Main section top-level edges (not inside compounds) */}
|
|
<g className="edges">
|
|
{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 (
|
|
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} traversed={isTraversed} />
|
|
);
|
|
})}
|
|
</g>
|
|
|
|
{/* Main section nodes */}
|
|
<g className="nodes">
|
|
{mainSection.nodes.map(node => {
|
|
if (isCompoundType(node.type) && node.children && node.children.length > 0) {
|
|
return (
|
|
<CompoundNode
|
|
key={node.id}
|
|
node={node}
|
|
edges={mainSection.edges}
|
|
selectedNodeId={selectedNodeId}
|
|
hoveredNodeId={toolbar.hoveredNodeId}
|
|
nodeConfigs={nodeConfigs}
|
|
executionOverlay={effectiveOverlay}
|
|
overlayActive={overlayActive}
|
|
iterationState={iterationState}
|
|
onIterationChange={onIterationChange}
|
|
onNodeClick={handleNodeClick}
|
|
onNodeDoubleClick={handleNodeDoubleClick}
|
|
onNodeEnter={toolbar.onNodeEnter}
|
|
onNodeLeave={toolbar.onNodeLeave}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<DiagramNode
|
|
key={node.id}
|
|
node={node}
|
|
isHovered={toolbar.hoveredNodeId === node.id}
|
|
isSelected={selectedNodeId === node.id}
|
|
config={node.id ? nodeConfigs?.get(node.id) : undefined}
|
|
executionState={getNodeExecutionState(node.id, node.type)}
|
|
overlayActive={overlayActive}
|
|
onClick={() => node.id && handleNodeClick(node.id)}
|
|
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
|
|
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}
|
|
onMouseLeave={toolbar.onNodeLeave}
|
|
/>
|
|
);
|
|
})}
|
|
</g>
|
|
|
|
{/* Toolbar rendered as HTML overlay below */}
|
|
|
|
{/* Handler sections (completion, then error) */}
|
|
{handlerSections.map((section, i) => (
|
|
<ErrorSection
|
|
key={`handler-${i}`}
|
|
section={section}
|
|
totalWidth={totalWidth}
|
|
selectedNodeId={selectedNodeId}
|
|
hoveredNodeId={toolbar.hoveredNodeId}
|
|
nodeConfigs={nodeConfigs}
|
|
executionOverlay={executionOverlay}
|
|
overlayActive={overlayActive}
|
|
iterationState={iterationState}
|
|
onIterationChange={onIterationChange}
|
|
onNodeClick={handleNodeClick}
|
|
onNodeDoubleClick={handleNodeDoubleClick}
|
|
onNodeEnter={toolbar.onNodeEnter}
|
|
onNodeLeave={toolbar.onNodeLeave}
|
|
/>
|
|
))}
|
|
</g>
|
|
</svg>
|
|
|
|
{/* 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 (
|
|
<NodeToolbar
|
|
nodeId={toolbar.hoveredNodeId!}
|
|
screenX={screenX}
|
|
screenY={screenY}
|
|
onAction={handleNodeAction}
|
|
onMouseEnter={toolbar.onToolbarEnter}
|
|
onMouseLeave={toolbar.onToolbarLeave}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
<Minimap
|
|
sections={sections}
|
|
totalWidth={totalWidth}
|
|
totalHeight={totalHeight}
|
|
scale={zoom.state.scale}
|
|
translateX={zoom.state.translateX}
|
|
translateY={zoom.state.translateY}
|
|
containerWidth={zoom.containerRef.current?.clientWidth ?? 0}
|
|
containerHeight={zoom.containerRef.current?.clientHeight ?? 0}
|
|
onPan={zoom.panTo}
|
|
/>
|
|
|
|
<ZoomControls
|
|
onZoomIn={zoom.zoomIn}
|
|
onZoomOut={zoom.zoomOut}
|
|
onFitToView={() => zoom.fitToView(contentWidth, contentHeight)}
|
|
scale={zoom.state.scale}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 findNodeInSection(
|
|
nodes: DiagramNodeType[],
|
|
nodeId: string,
|
|
): boolean {
|
|
return !!findInChildren(nodes, nodeId) || nodes.some(n => n.id === nodeId);
|
|
}
|
|
|
|
function topLevelEdge(
|
|
edge: import('../../api/queries/diagrams').DiagramEdge,
|
|
nodes: DiagramNodeType[],
|
|
): boolean {
|
|
const compoundChildIds = new Set<string>();
|
|
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<string>) {
|
|
for (const n of nodes) {
|
|
if (n.id) set.add(n.id);
|
|
if (n.children) collectDescendantIds(n.children, set);
|
|
}
|
|
}
|