feat: latency heatmap overlay on process diagram (#94)
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

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>
This commit is contained in:
hsiegeln
2026-03-29 23:32:42 +02:00
parent 213aa86c47
commit b5c19b6774
4 changed files with 55 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
import type { DiagramNode as DiagramNodeType, DiagramEdge as DiagramEdgeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
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';
@@ -27,6 +27,7 @@ interface CompoundNodeProps {
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;
@@ -36,7 +37,7 @@ interface CompoundNodeProps {
export function CompoundNode({
node, edges, parentX = 0, parentY = 0,
selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
overlayActive, iterationState, onIterationChange, latencyHeatmap,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
}: CompoundNodeProps) {
const x = (node.x ?? 0) - parentX;
@@ -61,7 +62,7 @@ export function CompoundNode({
const childProps = {
edges, selectedNodeId, hoveredNodeId, nodeConfigs, executionOverlay,
overlayActive, iterationState, onIterationChange,
overlayActive, iterationState, onIterationChange, latencyHeatmap,
onNodeClick, onNodeDoubleClick, onNodeEnter, onNodeLeave,
};
@@ -243,6 +244,7 @@ function renderChildren(
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)}

View File

@@ -1,5 +1,5 @@
import type { DiagramNode as DiagramNodeType } from '../../api/queries/diagrams';
import type { NodeConfig } from './types';
import type { NodeConfig, LatencyHeatmapEntry } from './types';
import type { NodeExecutionState } from '../ExecutionDiagram/types';
import { colorForType, iconForType, type IconElement } from './node-colors';
import { ConfigBadge } from './ConfigBadge';
@@ -16,12 +16,27 @@ interface DiagramNodeProps {
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`;
@@ -29,7 +44,7 @@ function formatDuration(ms: number): string {
export function DiagramNode({
node, isHovered, isSelected, config,
executionState, overlayActive,
executionState, overlayActive, heatmapEntry,
onClick, onDoubleClick, onMouseEnter, onMouseLeave,
}: DiagramNodeProps) {
const x = node.x ?? 0;
@@ -49,7 +64,7 @@ export function DiagramNode({
const isFailed = executionState?.status === 'FAILED';
const isSkipped = overlayActive && !executionState;
// Colors based on execution state
// 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;
@@ -67,6 +82,11 @@ export function DiagramNode({
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;
@@ -195,6 +215,20 @@ export function DiagramNode({
</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})`}>

View File

@@ -34,6 +34,7 @@ export function ProcessDiagram({
iterationState,
onIterationChange,
centerOnNodeId,
latencyHeatmap,
}: ProcessDiagramProps) {
// Route stack for drill-down navigation
const [routeStack, setRouteStack] = useState<string[]>([routeId]);
@@ -338,6 +339,7 @@ export function ProcessDiagram({
overlayActive={overlayActive}
iterationState={iterationState}
onIterationChange={onIterationChange}
latencyHeatmap={latencyHeatmap}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeEnter={toolbar.onNodeEnter}
@@ -354,6 +356,7 @@ export function ProcessDiagram({
config={node.id ? nodeConfigs?.get(node.id) : undefined}
executionState={getNodeExecutionState(node.id, node.type)}
overlayActive={overlayActive}
heatmapEntry={node.id ? latencyHeatmap?.get(node.id) : undefined}
onClick={() => node.id && handleNodeClick(node.id)}
onDoubleClick={() => node.id && handleNodeDoubleClick(node.id)}
onMouseEnter={() => node.id && toolbar.onNodeEnter(node.id)}

View File

@@ -16,6 +16,13 @@ export interface DiagramSection {
variant?: 'error' | 'completion';
}
export interface LatencyHeatmapEntry {
avgDurationMs: number;
p99DurationMs: number;
/** Percentage of total route time this processor consumes (0-100) */
pctOfRoute: number;
}
export interface ProcessDiagramProps {
application: string;
routeId: string;
@@ -39,4 +46,7 @@ export interface ProcessDiagramProps {
onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void;
/** When set, the diagram pans to center this node in the viewport */
centerOnNodeId?: string;
/** Latency heatmap: maps processor ID → aggregate performance data.
* When provided, nodes are colored green→yellow→red by relative latency. */
latencyHeatmap?: Map<string, LatencyHeatmapEntry>;
}