Add route diagram page with execution overlay and group-aware APIs
All checks were successful
CI / build (push) Successful in 1m10s
CI / docker (push) Successful in 1m3s
CI / deploy (push) Successful in 31s

Backend: Add group filtering to agent list, search, stats, and timeseries
endpoints. Add diagram lookup by group+routeId. Resolve application group
to agent IDs server-side for ClickHouse IN-clause queries.

Frontend: New route detail page at /apps/{group}/routes/{routeId} with
three tabs (Diagram, Performance, Processor Tree). SVG diagram rendering
with panzoom, execution overlay (glow effects, duration/sequence badges,
flow particles, minimap), and processor detail panel. uPlot charts for
performance tab replacing old SVG sparklines. Ctrl+Click from
ExecutionExplorer navigates to route diagram with overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 21:35:42 +01:00
parent b64edaa16f
commit 7778793e7b
41 changed files with 2770 additions and 26 deletions

View File

@@ -0,0 +1,130 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import type { ExecutionDetail, ProcessorNode } from '../api/types';
export interface IterationData {
count: number;
current: number;
}
export interface OverlayState {
isActive: boolean;
toggle: () => void;
executedNodes: Set<string>;
executedEdges: Set<string>;
durations: Map<string, number>;
sequences: Map<string, number>;
iterationData: Map<string, IterationData>;
selectedNodeId: string | null;
selectNode: (nodeId: string | null) => void;
setIteration: (nodeId: string, iteration: number) => void;
}
/** Walk the processor tree and collect execution data keyed by diagramNodeId */
function collectProcessorData(
processors: ProcessorNode[],
executedNodes: Set<string>,
durations: Map<string, number>,
sequences: Map<string, number>,
counter: { seq: number },
) {
for (const proc of processors) {
const nodeId = proc.diagramNodeId;
if (nodeId) {
executedNodes.add(nodeId);
durations.set(nodeId, proc.durationMs ?? 0);
sequences.set(nodeId, ++counter.seq);
}
if (proc.children && proc.children.length > 0) {
collectProcessorData(proc.children, executedNodes, durations, sequences, counter);
}
}
}
/** Determine which edges are executed (both source and target are executed) */
function computeExecutedEdges(
executedNodes: Set<string>,
edges: Array<{ sourceId?: string; targetId?: string }>,
): Set<string> {
const result = new Set<string>();
for (const edge of edges) {
if (edge.sourceId && edge.targetId
&& executedNodes.has(edge.sourceId) && executedNodes.has(edge.targetId)) {
result.add(`${edge.sourceId}->${edge.targetId}`);
}
}
return result;
}
export function useExecutionOverlay(
execution: ExecutionDetail | null | undefined,
edges: Array<{ sourceId?: string; targetId?: string }> = [],
): OverlayState {
const [isActive, setIsActive] = useState(!!execution);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [iterations, setIterations] = useState<Map<string, number>>(new Map());
// Activate overlay when an execution is loaded
useEffect(() => {
if (execution) setIsActive(true);
}, [execution]);
const { executedNodes, durations, sequences, iterationData } = useMemo(() => {
const en = new Set<string>();
const dur = new Map<string, number>();
const seq = new Map<string, number>();
const iter = new Map<string, IterationData>();
if (!execution?.processors) {
return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
}
collectProcessorData(execution.processors, en, dur, seq, { seq: 0 });
return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
}, [execution]);
const executedEdges = useMemo(
() => computeExecutedEdges(executedNodes, edges),
[executedNodes, edges],
);
const toggle = useCallback(() => setIsActive((v) => !v), []);
const selectNode = useCallback((nodeId: string | null) => setSelectedNodeId(nodeId), []);
const setIteration = useCallback((nodeId: string, iteration: number) => {
setIterations((prev) => {
const next = new Map(prev);
next.set(nodeId, iteration);
return next;
});
}, []);
// Keyboard shortcut: E to toggle overlay
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'e' || e.key === 'E') {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
e.preventDefault();
setIsActive((v) => !v);
}
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);
return {
isActive,
toggle,
executedNodes,
executedEdges,
durations,
sequences,
iterationData: new Map([...iterationData].map(([k, v]) => {
const current = iterations.get(k) ?? v.current;
return [k, { ...v, current }];
})),
selectedNodeId,
selectNode,
setIteration,
};
}