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; }