Files
cameleer-server/ui/src/components/ProcessDiagram/DiagramNode.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

245 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig, LatencyHeatmapEntry } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types';
import { colorForType, iconForType, type IconElement } from './node-colors';
import { ConfigBadge } from './ConfigBadge';
const TOP_BAR_HEIGHT = 6;
const TEXT_LEFT = 32;
const TEXT_RIGHT_PAD = 24;
const CORNER_RADIUS = 4;
interface DiagramNodeProps {
node: DiagramNodeType;
isHovered: boolean;
isSelected: boolean;
config?: NodeConfig;
executionState?: NodeExecutionState;
overlayActive?: boolean;
heatmapEntry?: LatencyHeatmapEntry;
onClick: () => void;
onDoubleClick?: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
/** Interpolate green (120°) → yellow (60°) → red (0°) based on pctOfRoute */
function heatmapColor(pct: number): string {
const clamped = Math.max(0, Math.min(100, pct));
// 0% → hue 120 (green), 50% → hue 60 (yellow), 100% → hue 0 (red)
const hue = 120 - (clamped / 100) * 120;
return `hsl(${Math.round(hue)}, 70%, 92%)`;
}
function heatmapBorderColor(pct: number): string {
const clamped = Math.max(0, Math.min(100, pct));
const hue = 120 - (clamped / 100) * 120;
return `hsl(${Math.round(hue)}, 60%, 50%)`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export function DiagramNode({
node, isHovered, isSelected, config,
executionState, overlayActive, heatmapEntry,
onClick, onDoubleClick, onMouseEnter, onMouseLeave,
}: DiagramNodeProps) {
const x = node.x ?? 0;
const y = node.y ?? 0;
const w = node.width ?? 120;
const h = node.height ?? 40;
const color = colorForType(node.type);
const iconElements = iconForType(node.type);
// Extract label parts: type name and detail
const typeName = node.type?.replace(/^EIP_/, '').replace(/_/g, ' ') ?? '';
const detail = node.label || '';
const resolvedUri = executionState?.resolvedEndpointUri;
// Overlay state derivation
const isCompleted = executionState?.status === 'COMPLETED';
const isFailed = executionState?.status === 'FAILED';
const isSkipped = overlayActive && !executionState;
// Colors based on execution state (heatmap takes priority when no execution overlay)
let cardFill = isHovered ? '#F5F0EA' : 'white';
let borderStroke = isHovered || isSelected ? color : '#E4DFD8';
let borderWidth = isHovered || isSelected ? 1.5 : 1;
let topBarColor = color;
let labelColor = '#1A1612';
if (isCompleted) {
cardFill = isHovered ? '#E4F5E6' : '#F0F9F1';
borderStroke = '#3D7C47';
borderWidth = 1.5;
topBarColor = '#3D7C47';
} else if (isFailed) {
cardFill = isHovered ? '#F9E4E1' : '#FDF2F0';
borderStroke = '#C0392B';
borderWidth = 2;
topBarColor = '#C0392B';
labelColor = '#C0392B';
} else if (heatmapEntry && !overlayActive) {
cardFill = heatmapColor(heatmapEntry.pctOfRoute);
borderStroke = heatmapBorderColor(heatmapEntry.pctOfRoute);
borderWidth = 1.5;
topBarColor = heatmapBorderColor(heatmapEntry.pctOfRoute);
}
const statusColor = isCompleted ? '#3D7C47' : isFailed ? '#C0392B' : undefined;
return (
<g
data-node-id={node.id}
transform={`translate(${x}, ${y})`}
onClick={(e) => { e.stopPropagation(); onClick(); }}
onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick?.(); }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{ cursor: 'pointer' }}
opacity={isSkipped ? 0.35 : undefined}
>
{/* Selection ring */}
{isSelected && (
<rect
x={-2}
y={-2}
width={w + 4}
height={h + 4}
rx={CORNER_RADIUS + 2}
fill="none"
stroke="#C6820E"
strokeWidth={2.5}
/>
)}
{/* Card background */}
<rect
x={0}
y={0}
width={w}
height={h}
rx={CORNER_RADIUS}
fill={cardFill}
stroke={borderStroke}
strokeWidth={borderWidth}
/>
{/* Colored top bar */}
<rect x={0} y={0} width={w} height={TOP_BAR_HEIGHT} rx={CORNER_RADIUS} fill={topBarColor} />
<rect x={CORNER_RADIUS} y={0} width={w - CORNER_RADIUS * 2} height={TOP_BAR_HEIGHT} fill={topBarColor} />
{/* Clip path for text area */}
<clipPath id={`clip-${node.id}`}>
<rect x={TEXT_LEFT} y={TOP_BAR_HEIGHT} width={w - TEXT_LEFT - TEXT_RIGHT_PAD} height={h - TOP_BAR_HEIGHT} />
</clipPath>
{/* Icon (lucide 24×24 scaled to 14px) */}
<g transform={`translate(6, ${h / 2 - 7}) scale(0.583)`}>
{iconElements.map((el: IconElement, i: number) =>
'd' in el
? <path key={i} d={el.d} fill="none" stroke={statusColor ?? color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
: <circle key={i} cx={el.cx} cy={el.cy} r={el.r} fill="none" stroke={statusColor ?? color} strokeWidth={2} />
)}
</g>
{/* Type name + detail + resolved URI (clipped to available width) */}
<g clipPath={`url(#clip-${node.id})`}>
{resolvedUri ? (
<>
<text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + 12} fill={labelColor} fontSize={11} fontWeight={600}>
{typeName}
</text>
{detail && detail !== typeName && (
<text x={TEXT_LEFT} y={TOP_BAR_HEIGHT + 24} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}>
{detail}
</text>
)}
<text x={TEXT_LEFT} y={h - 5} fill="#1A7F8E" fontSize={9} fontStyle="italic">
{resolvedUri.split('?')[0]}
</text>
</>
) : (
<>
<text x={TEXT_LEFT} y={h / 2 - 1} fill={labelColor} fontSize={11} fontWeight={600}>
{typeName}
</text>
{detail && detail !== typeName && (
<text x={TEXT_LEFT} y={h / 2 + 12} fill={isFailed ? '#C0392B' : '#5C5347'} fontSize={10}>
{detail}
</text>
)}
</>
)}
</g>
{/* Config badges */}
{(config || executionState?.hasTraceData) && (
<ConfigBadge nodeWidth={w} config={config ?? {}} hasTraceData={executionState?.hasTraceData} />
)}
{/* Execution overlay: status badge inside card, top-right corner */}
{isCompleted && (
<>
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#3D7C47" />
<path
d={`M${w - 13} ${TOP_BAR_HEIGHT + 8} l2 2 4-4`}
fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"
/>
</>
)}
{isFailed && (
<>
<circle cx={w - 10} cy={TOP_BAR_HEIGHT + 8} r={6} fill="#C0392B" />
<path
d={`M${w - 10} ${TOP_BAR_HEIGHT + 5} v4 M${w - 10} ${TOP_BAR_HEIGHT + 10.5} v0.5`}
fill="none" stroke="white" strokeWidth={1.5} strokeLinecap="round"
/>
</>
)}
{/* Execution overlay: duration text at bottom-right */}
{executionState && statusColor && (
<text
x={w - 6}
y={h - 4}
textAnchor="end"
fill={statusColor}
fontSize={9}
fontWeight={500}
>
{formatDuration(executionState.durationMs)}
</text>
)}
{/* Heatmap: avg duration label at bottom-right */}
{heatmapEntry && !overlayActive && !executionState && (
<text
x={w - 6}
y={h - 4}
textAnchor="end"
fill={heatmapBorderColor(heatmapEntry.pctOfRoute)}
fontSize={9}
fontWeight={600}
>
{formatDuration(heatmapEntry.avgDurationMs)}
</text>
)}
{/* Sub-route failure: drill-down arrow at bottom-left */}
{isFailed && executionState?.subRouteFailed && (
<g transform={`translate(4, ${h - 14})`}>
<path
d="M2 2 v5 a3 3 0 003 3 h5"
fill="none" stroke="#C0392B" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round"
/>
<path d="M8 8 l2 2 -2 2" fill="none" stroke="#C0392B" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
</g>
)}
</g>
);
}