Files
cameleer-server/ui/src/components/ProcessDiagram/CompoundNode.tsx
hsiegeln b5c19b6774
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 29s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
SonarQube / sonarqube (push) Failing after 1m10s
feat: latency heatmap overlay on process diagram (#94)
Add latencyHeatmap prop to ProcessDiagram that colors nodes green→yellow→red
based on their relative contribution to route latency (pctOfRoute). Shows avg
duration label on each node. Threaded through CompoundNode for nested EIP
patterns. Heatmap is active only when no execution overlay is present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:32:42 +02:00

265 lines
9.6 KiB
TypeScript

import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
import type { NodeConfig, LatencyHeatmapEntry } from './types';
import type { NodeExecutionState, IterationInfo } from '../ExecutionDiagram/types';
import { colorForType, isCompoundType, iconForType, type IconElement } from './node-colors';
import { DiagramNode } from './DiagramNode';
import { DiagramEdge } from './DiagramEdge';
import styles from './ProcessDiagram.module.css';
const HEADER_HEIGHT = 22;
const CORNER_RADIUS = 4;
interface CompoundNodeProps {
node: DiagramNodeType;
/** All edges for this section — compound filters to its own internal edges */
edges: DiagramEdgeType[];
/** Absolute offset of the nearest compound ancestor (for coordinate adjustment) */
parentX?: number;
parentY?: number;
selectedNodeId?: string;
hoveredNodeId: string | null;
nodeConfigs?: Map<string, NodeConfig>;
/** Execution overlay for edge traversal coloring */
executionOverlay?: Map<string, NodeExecutionState>;
/** Whether an execution overlay is active (enables dimming of skipped nodes) */
overlayActive?: boolean;
/** Per-compound iteration state */
iterationState?: Map<string, IterationInfo>;
/** Called when user changes iteration on a compound stepper */
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
latencyHeatmap?: Map<string, LatencyHeatmapEntry>;
onNodeClick: (nodeId: string) => void;
onNodeDoubleClick?: (nodeId: string) => void;
onNodeEnter: (nodeId: string) => void;
onNodeLeave: () => void;
}
export function CompoundNode({
node, edges, parentX = 0, parentY = 0,
selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange, latencyHeatmap,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) {
const x = (node.x ?? 0) - parentX;
const y = (node.y ?? 0) - parentY;
const absX = node.x ?? 0;
const absY = node.y ?? 0;
const w = node.width ?? 200;
const h = node.height ?? 100;
const color = colorForType(node.type);
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const label = node.label ? `${typeName}: ${node.label}` : typeName;
const iterationInfo = node.id ? iterationState?.get(node.id) : undefined;
const headerWidth = w;
// Collect all descendant node IDs to filter edges that belong inside this compound
const descendantIds = new Set<string>();
collectIds(node.children ?? [], descendantIds);
const internalEdges = edges.filter(
e => descendantIds.has(e.sourceId) && descendantIds.has(e.targetId),
);
const childProps = {
edges, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange, latencyHeatmap,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
};
// _TRY_BODY / _CB_MAIN: transparent wrapper — no header, no border, just layout
if (node.type === '_TRY_BODY' || node.type === '_CB_MAIN') {
return (
<g transform={`translate(${x}, ${y})`}>
{renderInternalEdges(internalEdges, absX, absY, executionOverlay)}
{renderChildren(node, absX, absY, childProps)}
</g>
);
}
// _CB_FALLBACK: section styling with EIP purple
if (node.type === '_CB_FALLBACK') {
const fallbackColor = '#7C3AED'; // EIP purple
return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill={fallbackColor} fillOpacity={0.06} />
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill="none" stroke={fallbackColor} strokeWidth={1} strokeOpacity={0.4} strokeDasharray="4 2" />
<text x={8} y={12} fill={fallbackColor} fontSize={10} fontWeight={600}>
fallback
</text>
{renderInternalEdges(internalEdges, absX, absY, executionOverlay)}
{renderChildren(node, absX, absY, childProps)}
</g>
);
}
// DO_CATCH / DO_FINALLY: section-like styling (tinted bg, thin border, label)
if (node.type === 'DO_CATCH' || node.type === 'DO_FINALLY') {
const sectionLabel = node.type === 'DO_CATCH'
? (node.label ? `catch: ${node.label}` : 'catch')
: (node.label ? `finally: ${node.label}` : 'finally');
return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
{/* Tinted background */}
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill={color} fillOpacity={0.06} />
{/* Border */}
<rect x={0} y={0} width={w} height={h} rx={CORNER_RADIUS}
fill="none" stroke={color} strokeWidth={1} strokeOpacity={0.4} />
{/* Section label */}
<text x={8} y={12} fill={color} fontSize={10} fontWeight={600}>
{sectionLabel}
</text>
{renderInternalEdges(internalEdges, absX, absY, executionOverlay)}
{renderChildren(node, absX, absY, childProps)}
</g>
);
}
// Default compound rendering (DO_TRY, EIP_CHOICE, etc.)
return (
<g data-node-id={node.id} transform={`translate(${x}, ${y})`}>
{/* Container body */}
<rect
x={0}
y={0}
width={w}
height={h}
rx={CORNER_RADIUS}
fill="white"
stroke={color}
strokeWidth={1.5}
/>
{/* Colored header bar */}
<rect x={0} y={0} width={w} height={HEADER_HEIGHT} rx={CORNER_RADIUS} fill={color} />
<rect x={CORNER_RADIUS} y={CORNER_RADIUS} width={w - CORNER_RADIUS * 2} height={HEADER_HEIGHT - CORNER_RADIUS} fill={color} />
{/* Header icon (left-aligned) */}
<g transform={`translate(6, ${HEADER_HEIGHT / 2 - 5}) scale(0.417)`}>
{iconForType(node.type).map((el: IconElement, i: number) =>
'd' in el
? <path key={i} d={el.d} fill="none" stroke="white" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
: <circle key={i} cx={el.cx} cy={el.cy} r={el.r} fill="none" stroke="white" strokeWidth={2} />
)}
</g>
{/* Header label (centered) */}
<text
x={w / 2}
y={HEADER_HEIGHT / 2 + 4}
fill="white"
fontSize={10}
fontWeight={600}
textAnchor="middle"
>
{label}
</text>
{/* Iteration stepper (for LOOP, SPLIT, MULTICAST with overlay data) */}
{iterationInfo && (
<foreignObject x={headerWidth - 80} y={1} width={75} height={20}>
<div className={styles.iterationStepper}>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current - 1); }}
disabled={iterationInfo.current <= 0}
>
&lsaquo;
</button>
<span>{iterationInfo.current + 1} / {iterationInfo.total}</span>
<button
onClick={(e) => { e.stopPropagation(); onIterationChange?.(node.id!, iterationInfo.current + 1); }}
disabled={iterationInfo.current >= iterationInfo.total - 1}
>
&rsaquo;
</button>
</div>
</foreignObject>
)}
{renderInternalEdges(internalEdges, absX, absY, executionOverlay)}
{renderChildren(node, absX, absY, childProps)}
</g>
);
}
/** Render internal edges adjusted for compound coordinates */
function renderInternalEdges(
internalEdges: DiagramEdgeType[],
absX: number, absY: number,
executionOverlay?: Map<string, NodeExecutionState>,
) {
return (
<g className="edges">
{internalEdges.map((edge, i) => {
const isTraversed = executionOverlay
? (executionOverlay.has(edge.sourceId) && executionOverlay.has(edge.targetId))
: undefined;
return (
<DiagramEdge
key={`${edge.sourceId}-${edge.targetId}-${i}`}
edge={{
...edge,
points: edge.points.map(p => [p[0] - absX, p[1] - absY]),
}}
traversed={isTraversed}
/>
);
})}
</g>
);
}
/** Render children — compounds recurse, leaves become DiagramNode */
function renderChildren(
node: DiagramNodeType,
absX: number, absY: number,
props: Omit<CompoundNodeProps, 'node' | 'parentX' | 'parentY'>,
) {
return (
<>
{node.children?.map(child => {
if (isCompoundType(child.type) && child.children && child.children.length > 0) {
return (
<CompoundNode
key={child.id}
node={child}
parentX={absX}
parentY={absY}
{...props}
/>
);
}
return (
<DiagramNode
key={child.id}
node={{
...child,
x: (child.x ?? 0) - absX,
y: (child.y ?? 0) - absY,
}}
isHovered={props.hoveredNodeId === child.id}
isSelected={props.selectedNodeId === child.id}
config={child.id ? props.nodeConfigs?.get(child.id) : undefined}
executionState={props.executionOverlay?.get(child.id ?? '')}
overlayActive={props.overlayActive}
heatmapEntry={child.id ? props.latencyHeatmap?.get(child.id) : undefined}
onClick={() => child.id && props.onNodeClick(child.id)}
onDoubleClick={() => child.id && props.onNodeDoubleClick?.(child.id)}
onMouseEnter={() => child.id && props.onNodeEnter(child.id)}
onMouseLeave={props.onNodeLeave}
/>
);
})}
</>
);
}
function collectIds(nodes: DiagramNodeType[], set: Set<string>) {
for (const n of nodes) {
if (n.id) set.add(n.id);
if (n.children) collectIds(n.children, set);
}
}