Files
cameleer-server/ui/src/components/ProcessDiagram/ProcessDiagram.tsx
hsiegeln ff59dc5d57 feat: add execution overlay types and extend ProcessDiagram with diagramLayout prop
Define the execution overlay type system (NodeExecutionState, IterationInfo,
DetailTab) and extend ProcessDiagramProps with optional overlay props. Add
diagramLayout prop so ExecutionDiagram can pass a pre-fetched layout by content
hash, bypassing the internal route-based fetch in useDiagramData.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:40:57 +01:00

368 lines
12 KiB
TypeScript

import { useCallback, useEffect, 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,
}: 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 { sections, totalWidth, totalHeight, isLoading, error } = useDiagramData(
application, currentRouteId, direction, diagramLayout,
);
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
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
className={styles.svg}
onWheel={zoom.onWheel}
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>
</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) => (
<DiagramEdge key={`${edge.sourceId}-${edge.targetId}-${i}`} edge={edge} />
))}
</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}
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}
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}
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 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);
}
}