From a108b5759175a50ef16f5f127911668868e0b14c Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Sat, 14 Mar 2026 22:14:23 +0100
Subject: [PATCH] Fix route diagram open issues: bugs, visual polish,
interactive features
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Batch 1 — Bug fixes:
- #51: Pass group+routeId to stats/timeseries API for route-scoped data
- #55: Propagate processor FAILED status to diagram error node highlighting
Batch 2 — Visual polish:
- #56: Brighter canvas background with amber/cyan radial gradients
- #57: Stronger glow filters (stdDeviation 3→6, opacity 0.4→0.6)
- #58: Uniform 200×40px leaf nodes with label truncation at 22 chars
- #59: Diagram legend (node types, edge types, overlay indicators)
- #64: SVG
tooltips on all nodes showing type, status, duration
Batch 3 — Interactive features:
- #60: Draggable minimap viewport (click-to-center, drag-to-pan)
- #62: CSS View Transitions slide animation, back arrow, Backspace key
Batch 4 — Advanced features:
- #50: Execution picker dropdown scoped to group+routeId
- #49: Iteration count badge (×N) on compound nodes
- #63: Route header stats (Executions Today, Success Rate, Avg, P99)
Closes #49 #50 #51 #55 #56 #57 #58 #59 #60 #62 #63 #64
Co-Authored-By: Claude Opus 4.6 (1M context)
---
ui/src/api/queries/executions.ts | 22 ++-
ui/src/hooks/useExecutionOverlay.ts | 15 +-
ui/src/pages/routes/PerformanceTab.tsx | 4 +-
ui/src/pages/routes/RouteHeader.tsx | 33 ++++
ui/src/pages/routes/RoutePage.module.css | 64 +++++++
ui/src/pages/routes/RoutePage.tsx | 30 +++-
ui/src/pages/routes/diagram/DiagramCanvas.tsx | 4 +
ui/src/pages/routes/diagram/DiagramLegend.tsx | 73 ++++++++
.../pages/routes/diagram/DiagramMinimap.tsx | 49 +++++-
ui/src/pages/routes/diagram/DiagramNode.tsx | 58 ++++--
.../pages/routes/diagram/ExecutionPicker.tsx | 75 ++++++++
.../pages/routes/diagram/RouteDiagramSvg.tsx | 57 ++++--
ui/src/pages/routes/diagram/SvgDefs.tsx | 24 +--
.../pages/routes/diagram/diagram.module.css | 165 +++++++++++++++++-
ui/src/theme/tokens.css | 28 +++
15 files changed, 643 insertions(+), 58 deletions(-)
create mode 100644 ui/src/pages/routes/diagram/DiagramLegend.tsx
create mode 100644 ui/src/pages/routes/diagram/ExecutionPicker.tsx
diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts
index ceaaa1fb..a8142881 100644
--- a/ui/src/api/queries/executions.ts
+++ b/ui/src/api/queries/executions.ts
@@ -2,15 +2,22 @@ import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
import type { SearchRequest } from '../types';
-export function useExecutionStats(timeFrom: string | undefined, timeTo: string | undefined) {
+export function useExecutionStats(
+ timeFrom: string | undefined,
+ timeTo: string | undefined,
+ routeId?: string,
+ group?: string,
+) {
return useQuery({
- queryKey: ['executions', 'stats', timeFrom, timeTo],
+ queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, group],
queryFn: async () => {
const { data, error } = await api.GET('/search/stats', {
params: {
query: {
from: timeFrom!,
to: timeTo || undefined,
+ routeId: routeId || undefined,
+ group: group || undefined,
},
},
});
@@ -38,9 +45,14 @@ export function useSearchExecutions(filters: SearchRequest, live = false) {
});
}
-export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string | undefined) {
+export function useStatsTimeseries(
+ timeFrom: string | undefined,
+ timeTo: string | undefined,
+ routeId?: string,
+ group?: string,
+) {
return useQuery({
- queryKey: ['executions', 'timeseries', timeFrom, timeTo],
+ queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, group],
queryFn: async () => {
const { data, error } = await api.GET('/search/stats/timeseries', {
params: {
@@ -48,6 +60,8 @@ export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string
from: timeFrom!,
to: timeTo || undefined,
buckets: 24,
+ routeId: routeId || undefined,
+ group: group || undefined,
},
},
});
diff --git a/ui/src/hooks/useExecutionOverlay.ts b/ui/src/hooks/useExecutionOverlay.ts
index 7a8bbc6e..69c0e7ec 100644
--- a/ui/src/hooks/useExecutionOverlay.ts
+++ b/ui/src/hooks/useExecutionOverlay.ts
@@ -13,6 +13,7 @@ export interface OverlayState {
executedEdges: Set;
durations: Map;
sequences: Map;
+ statuses: Map;
iterationData: Map;
selectedNodeId: string | null;
selectNode: (nodeId: string | null) => void;
@@ -25,6 +26,7 @@ function collectProcessorData(
executedNodes: Set,
durations: Map,
sequences: Map,
+ statuses: Map,
counter: { seq: number },
) {
for (const proc of processors) {
@@ -33,9 +35,10 @@ function collectProcessorData(
executedNodes.add(nodeId);
durations.set(nodeId, proc.durationMs ?? 0);
sequences.set(nodeId, ++counter.seq);
+ if (proc.status) statuses.set(nodeId, proc.status);
}
if (proc.children && proc.children.length > 0) {
- collectProcessorData(proc.children, executedNodes, durations, sequences, counter);
+ collectProcessorData(proc.children, executedNodes, durations, sequences, statuses, counter);
}
}
}
@@ -68,19 +71,20 @@ export function useExecutionOverlay(
if (execution) setIsActive(true);
}, [execution]);
- const { executedNodes, durations, sequences, iterationData } = useMemo(() => {
+ const { executedNodes, durations, sequences, statuses, iterationData } = useMemo(() => {
const en = new Set();
const dur = new Map();
const seq = new Map();
+ const st = new Map();
const iter = new Map();
if (!execution?.processors) {
- return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
+ return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter };
}
- collectProcessorData(execution.processors, en, dur, seq, { seq: 0 });
+ collectProcessorData(execution.processors, en, dur, seq, st, { seq: 0 });
- return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
+ return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter };
}, [execution]);
const executedEdges = useMemo(
@@ -119,6 +123,7 @@ export function useExecutionOverlay(
executedEdges,
durations,
sequences,
+ statuses,
iterationData: new Map([...iterationData].map(([k, v]) => {
const current = iterations.get(k) ?? v.current;
return [k, { ...v, current }];
diff --git a/ui/src/pages/routes/PerformanceTab.tsx b/ui/src/pages/routes/PerformanceTab.tsx
index 02476d31..34faa170 100644
--- a/ui/src/pages/routes/PerformanceTab.tsx
+++ b/ui/src/pages/routes/PerformanceTab.tsx
@@ -23,8 +23,8 @@ export function PerformanceTab({ group, routeId }: PerformanceTabProps) {
const timeTo = new Date().toISOString();
// Use scoped stats/timeseries via group+routeId query params
- const { data: stats } = useExecutionStats(timeFrom, timeTo);
- const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
+ const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, group);
+ const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, group);
const buckets = timeseries?.buckets ?? [];
const sparkTotal = buckets.map((b) => b.totalCount ?? 0);
diff --git a/ui/src/pages/routes/RouteHeader.tsx b/ui/src/pages/routes/RouteHeader.tsx
index c93f8ad1..1c1da077 100644
--- a/ui/src/pages/routes/RouteHeader.tsx
+++ b/ui/src/pages/routes/RouteHeader.tsx
@@ -1,4 +1,5 @@
import type { DiagramLayout } from '../../api/types';
+import { useExecutionStats } from '../../api/queries/executions';
import styles from './RoutePage.module.css';
interface RouteHeaderProps {
@@ -9,6 +10,12 @@ interface RouteHeaderProps {
export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
const nodeCount = layout?.nodes?.length ?? 0;
+ const timeFrom = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
+ const { data: stats } = useExecutionStats(timeFrom, undefined, routeId, group);
+
+ const successRate = stats && stats.totalCount > 0
+ ? ((1 - stats.failedCount / stats.totalCount) * 100).toFixed(1)
+ : null;
return (
@@ -24,6 +31,32 @@ export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
)}
+ {stats && (
+
+
+ {stats.totalToday.toLocaleString()}
+ Executions Today
+
+
+
+ {successRate ? `${successRate}%` : '--'}
+
+ Success Rate
+
+
+
+ {stats.avgDurationMs != null ? `${stats.avgDurationMs}ms` : '--'}
+
+ Avg Duration
+
+
+
+ {stats.p99LatencyMs != null ? `${stats.p99LatencyMs}ms` : '--'}
+
+ P99 Latency
+
+
+ )}
);
}
diff --git a/ui/src/pages/routes/RoutePage.module.css b/ui/src/pages/routes/RoutePage.module.css
index bde2ad5b..18bc7f31 100644
--- a/ui/src/pages/routes/RoutePage.module.css
+++ b/ui/src/pages/routes/RoutePage.module.css
@@ -7,6 +7,28 @@
font-size: 12px;
}
+.backBtn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-raised);
+ color: var(--text-muted);
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.15s;
+ margin-right: 4px;
+}
+
+.backBtn:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+ border-color: var(--amber);
+}
+
.breadcrumbLink {
color: var(--text-muted);
text-decoration: none;
@@ -89,6 +111,48 @@
background: var(--green);
}
+.headerStatsRow {
+ display: flex;
+ gap: 24px;
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border-subtle);
+}
+
+.headerStat {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.headerStatValue {
+ font-family: var(--font-mono);
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+ letter-spacing: -0.5px;
+}
+
+.headerStatGreen {
+ color: var(--green);
+}
+
+.headerStatCyan {
+ color: var(--cyan);
+}
+
+.headerStatAmber {
+ color: var(--amber);
+}
+
+.headerStatLabel {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ color: var(--text-muted);
+}
+
/* ─── Toolbar & Tabs ─── */
.toolbar {
display: flex;
diff --git a/ui/src/pages/routes/RoutePage.tsx b/ui/src/pages/routes/RoutePage.tsx
index 514d1871..999d4110 100644
--- a/ui/src/pages/routes/RoutePage.tsx
+++ b/ui/src/pages/routes/RoutePage.tsx
@@ -1,5 +1,5 @@
-import { useState } from 'react';
-import { useParams, useSearchParams, NavLink } from 'react-router';
+import { useState, useEffect, useCallback } from 'react';
+import { useParams, useSearchParams, NavLink, useNavigate } from 'react-router';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useExecutionDetail } from '../../api/queries/executions';
import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
@@ -7,6 +7,7 @@ import { RouteHeader } from './RouteHeader';
import { DiagramTab } from './DiagramTab';
import { PerformanceTab } from './PerformanceTab';
import { ProcessorTree } from '../executions/ProcessorTree';
+import { ExecutionPicker } from './diagram/ExecutionPicker';
import styles from './RoutePage.module.css';
type Tab = 'diagram' | 'performance' | 'processors';
@@ -16,6 +17,29 @@ export function RoutePage() {
const [searchParams] = useSearchParams();
const execId = searchParams.get('exec');
const [activeTab, setActiveTab] = useState('diagram');
+ const navigate = useNavigate();
+
+ const goBack = useCallback(() => {
+ const doc = document as Document & { startViewTransition?: (cb: () => void) => void };
+ if (doc.startViewTransition) {
+ doc.startViewTransition(() => navigate(-1));
+ } else {
+ navigate(-1);
+ }
+ }, [navigate]);
+
+ // Backspace navigates back (unless user is in an input)
+ useEffect(() => {
+ function handleKey(e: KeyboardEvent) {
+ if (e.key !== 'Backspace') return;
+ const tag = (e.target as HTMLElement).tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
+ e.preventDefault();
+ goBack();
+ }
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [goBack]);
const { data: layout, isLoading: layoutLoading } = useDiagramByRoute(group, routeId);
const { data: execution } = useExecutionDetail(execId);
@@ -33,6 +57,7 @@ export function RoutePage() {
<>
{/* Breadcrumb */}