feat: latency heatmap overlay on process diagram (#94)
Some checks failed
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>
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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})`}>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user