Some checks failed
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>
245 lines
8.2 KiB
TypeScript
245 lines
8.2 KiB
TypeScript
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>
|
||
);
|
||
}
|