Files
cameleer-server/ui/src/components/ProcessDiagram/ProcessDiagram.tsx
hsiegeln d7166b6d0a feat: Jump to Error centers the failed node in the viewport
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>
2026-03-27 19:51:00 +01:00

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);
}
}