From b5c19b677425f4dd74a60f330922953db546280c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:32:42 +0200 Subject: [PATCH] feat: latency heatmap overlay on process diagram (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ProcessDiagram/CompoundNode.tsx | 8 ++-- .../components/ProcessDiagram/DiagramNode.tsx | 40 +++++++++++++++++-- .../ProcessDiagram/ProcessDiagram.tsx | 3 ++ ui/src/components/ProcessDiagram/types.ts | 10 +++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/ui/src/components/ProcessDiagram/CompoundNode.tsx b/ui/src/components/ProcessDiagram/CompoundNode.tsx index 00850621..e6980d27 100644 --- a/ui/src/components/ProcessDiagram/CompoundNode.tsx +++ b/ui/src/components/ProcessDiagram/CompoundNode.tsx @@ -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; /** Called when user changes iteration on a compound stepper */ onIterationChange?: (compoundNodeId: string, iterationIndex: number) => void; + latencyHeatmap?: Map; 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)} diff --git a/ui/src/components/ProcessDiagram/DiagramNode.tsx b/ui/src/components/ProcessDiagram/DiagramNode.tsx index 3600cbe2..4ede627c 100644 --- a/ui/src/components/ProcessDiagram/DiagramNode.tsx +++ b/ui/src/components/ProcessDiagram/DiagramNode.tsx @@ -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({ )} + {/* Heatmap: avg duration label at bottom-right */} + {heatmapEntry && !overlayActive && !executionState && ( + + {formatDuration(heatmapEntry.avgDurationMs)} + + )} + {/* Sub-route failure: drill-down arrow at bottom-left */} {isFailed && executionState?.subRouteFailed && ( diff --git a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx index cf1540d1..917650ab 100644 --- a/ui/src/components/ProcessDiagram/ProcessDiagram.tsx +++ b/ui/src/components/ProcessDiagram/ProcessDiagram.tsx @@ -34,6 +34,7 @@ export function ProcessDiagram({ iterationState, onIterationChange, centerOnNodeId, + latencyHeatmap, }: ProcessDiagramProps) { // Route stack for drill-down navigation const [routeStack, setRouteStack] = useState([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)} diff --git a/ui/src/components/ProcessDiagram/types.ts b/ui/src/components/ProcessDiagram/types.ts index ccc0b288..69bc8fcd 100644 --- a/ui/src/components/ProcessDiagram/types.ts +++ b/ui/src/components/ProcessDiagram/types.ts @@ -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; }