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) <noreply@anthropic.com> --- 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<string>; durations: Map<string, number>; sequences: Map<string, number>; + statuses: Map<string, string>; iterationData: Map<string, IterationData>; selectedNodeId: string | null; selectNode: (nodeId: string | null) => void; @@ -25,6 +26,7 @@ function collectProcessorData( executedNodes: Set<string>, durations: Map<string, number>, sequences: Map<string, number>, + statuses: Map<string, string>, 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<string>(); const dur = new Map<string, number>(); const seq = new Map<string, number>(); + const st = new Map<string, string>(); const iter = new Map<string, IterationData>(); 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 ( <div className={styles.routeHeader}> @@ -24,6 +31,32 @@ export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) { )} </div> </div> + {stats && ( + <div className={styles.headerStatsRow}> + <div className={styles.headerStat}> + <span className={styles.headerStatValue}>{stats.totalToday.toLocaleString()}</span> + <span className={styles.headerStatLabel}>Executions Today</span> + </div> + <div className={styles.headerStat}> + <span className={`${styles.headerStatValue} ${styles.headerStatGreen}`}> + {successRate ? `${successRate}%` : '--'} + </span> + <span className={styles.headerStatLabel}>Success Rate</span> + </div> + <div className={styles.headerStat}> + <span className={`${styles.headerStatValue} ${styles.headerStatCyan}`}> + {stats.avgDurationMs != null ? `${stats.avgDurationMs}ms` : '--'} + </span> + <span className={styles.headerStatLabel}>Avg Duration</span> + </div> + <div className={styles.headerStat}> + <span className={`${styles.headerStatValue} ${styles.headerStatAmber}`}> + {stats.p99LatencyMs != null ? `${stats.p99LatencyMs}ms` : '--'} + </span> + <span className={styles.headerStatLabel}>P99 Latency</span> + </div> + </div> + )} </div> ); } 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<Tab>('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 */} <nav className={styles.breadcrumb}> + <button className={styles.backBtn} onClick={goBack} title="Back (Backspace)">←</button> <NavLink to="/executions" className={styles.breadcrumbLink}>Transactions</NavLink> <span className={styles.breadcrumbSep}>/</span> <span className={styles.breadcrumbText}>{group}</span> @@ -68,6 +93,7 @@ export function RoutePage() { {activeTab === 'diagram' && ( <div className={styles.toolbarRight}> + <ExecutionPicker group={group} routeId={routeId} /> <button className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`} onClick={overlay.toggle} diff --git a/ui/src/pages/routes/diagram/DiagramCanvas.tsx b/ui/src/pages/routes/diagram/DiagramCanvas.tsx index 4c9627ee..db9526f0 100644 --- a/ui/src/pages/routes/diagram/DiagramCanvas.tsx +++ b/ui/src/pages/routes/diagram/DiagramCanvas.tsx @@ -4,6 +4,7 @@ import type { DiagramLayout } from '../../../api/types'; import type { OverlayState } from '../../../hooks/useExecutionOverlay'; import { RouteDiagramSvg } from './RouteDiagramSvg'; import { DiagramMinimap } from './DiagramMinimap'; +import { DiagramLegend } from './DiagramLegend'; import styles from './diagram.module.css'; interface DiagramCanvasProps { @@ -98,12 +99,15 @@ export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) { </div> </div> + <DiagramLegend /> + <DiagramMinimap nodes={layout.nodes ?? []} edges={layout.edges ?? []} diagramWidth={layout.width ?? 600} diagramHeight={layout.height ?? 400} viewBox={viewBox} + panzoomRef={panzoomRef} /> </div> ); diff --git a/ui/src/pages/routes/diagram/DiagramLegend.tsx b/ui/src/pages/routes/diagram/DiagramLegend.tsx new file mode 100644 index 00000000..ca72fb07 --- /dev/null +++ b/ui/src/pages/routes/diagram/DiagramLegend.tsx @@ -0,0 +1,73 @@ +import styles from './diagram.module.css'; + +interface LegendItem { + label: string; + color: string; + dashed?: boolean; + shape?: 'circle' | 'line'; +} + +const NODE_TYPES: LegendItem[] = [ + { label: 'Endpoint', color: '#58a6ff', shape: 'circle' }, + { label: 'EIP Pattern', color: '#b87aff', shape: 'circle' }, + { label: 'Processor', color: '#3fb950', shape: 'circle' }, + { label: 'Error Handler', color: '#f85149', shape: 'circle' }, + { label: 'Cross-route', color: '#39d2e0', shape: 'circle', dashed: true }, +]; + +const EDGE_TYPES: LegendItem[] = [ + { label: 'Flow', color: '#4a5e7a', shape: 'line' }, + { label: 'Error', color: '#f85149', shape: 'line', dashed: true }, + { label: 'Cross-route', color: '#39d2e0', shape: 'line', dashed: true }, +]; + +const OVERLAY_TYPES: LegendItem[] = [ + { label: 'Executed', color: '#3fb950', shape: 'circle' }, + { label: 'Execution path', color: '#3fb950', shape: 'line' }, + { label: 'Not executed', color: '#4a5e7a', shape: 'circle' }, +]; + +function LegendRow({ item }: { item: LegendItem }) { + return ( + <div className={styles.legendRow}> + {item.shape === 'circle' ? ( + <span + className={styles.legendDot} + style={{ + background: item.color, + border: item.dashed ? `1px dashed ${item.color}` : undefined, + opacity: item.label === 'Not executed' ? 0.3 : 1, + }} + /> + ) : ( + <span + className={styles.legendLine} + style={{ + background: item.color, + borderStyle: item.dashed ? 'dashed' : 'solid', + }} + /> + )} + <span className={styles.legendLabel}>{item.label}</span> + </div> + ); +} + +export function DiagramLegend() { + return ( + <div className={styles.legend}> + <div className={styles.legendSection}> + <span className={styles.legendTitle}>Nodes</span> + {NODE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)} + </div> + <div className={styles.legendSection}> + <span className={styles.legendTitle}>Edges</span> + {EDGE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)} + </div> + <div className={styles.legendSection}> + <span className={styles.legendTitle}>Overlay</span> + {OVERLAY_TYPES.map((t) => <LegendRow key={t.label} item={t} />)} + </div> + </div> + ); +} diff --git a/ui/src/pages/routes/diagram/DiagramMinimap.tsx b/ui/src/pages/routes/diagram/DiagramMinimap.tsx index 4fc78162..29fb4547 100644 --- a/ui/src/pages/routes/diagram/DiagramMinimap.tsx +++ b/ui/src/pages/routes/diagram/DiagramMinimap.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import { useMemo, useCallback, useRef, type MutableRefObject } from 'react'; +import type { PanZoom } from 'panzoom'; import type { PositionedNode, PositionedEdge } from '../../../api/types'; import { getNodeStyle } from './nodeStyles'; import styles from './diagram.module.css'; @@ -9,12 +10,15 @@ interface DiagramMinimapProps { diagramWidth: number; diagramHeight: number; viewBox: { x: number; y: number; w: number; h: number }; + panzoomRef: MutableRefObject<PanZoom | null>; } const MINIMAP_W = 160; const MINIMAP_H = 100; -export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, viewBox }: DiagramMinimapProps) { +export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, viewBox, panzoomRef }: DiagramMinimapProps) { + const dragging = useRef(false); + const scale = useMemo(() => { if (diagramWidth === 0 || diagramHeight === 0) return 1; return Math.min(MINIMAP_W / diagramWidth, MINIMAP_H / diagramHeight); @@ -27,9 +31,48 @@ export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, view h: viewBox.h * scale, }), [viewBox, scale]); + const panTo = useCallback((clientX: number, clientY: number, svg: SVGSVGElement) => { + const pz = panzoomRef.current; + if (!pz) return; + const rect = svg.getBoundingClientRect(); + // Convert minimap mouse coords to diagram coords + const mx = (clientX - rect.left) / scale; + const my = (clientY - rect.top) / scale; + // Center viewport on clicked point + const t = pz.getTransform(); + const targetX = -(mx - viewBox.w / 2) * t.scale; + const targetY = -(my - viewBox.h / 2) * t.scale; + pz.moveTo(targetX, targetY); + }, [panzoomRef, scale, viewBox.w, viewBox.h]); + + const handleMouseDown = useCallback((e: React.MouseEvent<SVGSVGElement>) => { + e.preventDefault(); + dragging.current = true; + panTo(e.clientX, e.clientY, e.currentTarget); + }, [panTo]); + + const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => { + if (!dragging.current) return; + e.preventDefault(); + panTo(e.clientX, e.clientY, e.currentTarget); + }, [panTo]); + + const handleMouseUp = useCallback(() => { + dragging.current = false; + }, []); + return ( <div className={styles.minimap}> - <svg width={MINIMAP_W} height={MINIMAP_H} viewBox={`0 0 ${MINIMAP_W} ${MINIMAP_H}`}> + <svg + width={MINIMAP_W} + height={MINIMAP_H} + viewBox={`0 0 ${MINIMAP_W} ${MINIMAP_H}`} + style={{ cursor: 'pointer' }} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseUp} + > <rect width={MINIMAP_W} height={MINIMAP_H} fill="#0d1117" rx={4} /> {/* Edges */} {edges.map((e) => { diff --git a/ui/src/pages/routes/diagram/DiagramNode.tsx b/ui/src/pages/routes/diagram/DiagramNode.tsx index f8a3862f..a19992c8 100644 --- a/ui/src/pages/routes/diagram/DiagramNode.tsx +++ b/ui/src/pages/routes/diagram/DiagramNode.tsx @@ -2,6 +2,24 @@ import type { PositionedNode } from '../../../api/types'; import { getNodeStyle, isCompoundType } from './nodeStyles'; import styles from './diagram.module.css'; +const FIXED_W = 200; +const FIXED_H = 40; +const MAX_LABEL = 22; + +function truncateLabel(label: string | undefined): string { + if (!label) return ''; + return label.length > MAX_LABEL ? label.slice(0, MAX_LABEL - 1) + '\u2026' : label; +} + +function buildTooltip(node: PositionedNode, isOverlayActive: boolean, isExecuted: boolean, isError: boolean, duration?: number): string { + const parts = [`${node.type ?? 'PROCESSOR'}: ${node.label ?? ''}`]; + if (isOverlayActive && isExecuted) { + parts.push(`Status: ${isError ? 'FAILED' : 'OK'}`); + if (duration != null) parts.push(`Duration: ${duration}ms`); + } + return parts.join('\n'); +} + interface DiagramNodeProps { node: PositionedNode; isExecuted: boolean; @@ -35,6 +53,8 @@ export function DiagramNode({ ? (isError ? '#f85149' : '#3fb950') : style.border; + const tooltip = buildTooltip(node, isOverlayActive, isExecuted, isError, duration); + if (isCompound) { return ( <g @@ -43,6 +63,7 @@ export function DiagramNode({ role="img" aria-label={`${node.type} container: ${node.label}`} > + <title>{tooltip} + {tooltip} - {node.label} + {truncateLabel(node.label)} {/* Duration badge */} {isOverlayActive && isExecuted && duration != null && ( (null); + + const { data } = useSearchExecutions({ + group, + routeId, + sortField: 'startTime', + sortDir: 'DESC', + offset: 0, + limit: 20, + }); + + // Close on outside click + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + const executions = data?.data ?? []; + + const select = (execId: string) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('exec', execId); + return next; + }); + setOpen(false); + }; + + return ( +
+ + {open && ( +
+ {executions.length === 0 && ( +
No recent executions
+ )} + {executions.map((ex) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/pages/routes/diagram/RouteDiagramSvg.tsx b/ui/src/pages/routes/diagram/RouteDiagramSvg.tsx index f6fd02fc..df30e670 100644 --- a/ui/src/pages/routes/diagram/RouteDiagramSvg.tsx +++ b/ui/src/pages/routes/diagram/RouteDiagramSvg.tsx @@ -45,19 +45,48 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) { {/* Compound container nodes (background) */} - {compoundNodes.map((node) => ( - - ))} + {compoundNodes.map((node) => { + const iterData = node.id ? overlay.iterationData.get(node.id) : undefined; + return ( + + + {/* Iteration count badge */} + {overlay.isActive && iterData && iterData.count > 1 && ( + + + + {'\u00D7'}{iterData.count} + + + )} + + ); + })} {/* Edges */} {/* Glow filters */} - - - + + + - - - + + + - - - + + + - - - + + + diff --git a/ui/src/pages/routes/diagram/diagram.module.css b/ui/src/pages/routes/diagram/diagram.module.css index b0045bfe..fcdce4e6 100644 --- a/ui/src/pages/routes/diagram/diagram.module.css +++ b/ui/src/pages/routes/diagram/diagram.module.css @@ -3,7 +3,10 @@ position: relative; flex: 1; min-height: 0; - background: var(--bg-deep); + background: + radial-gradient(ellipse at 20% 50%, rgba(240, 180, 41, 0.04) 0%, transparent 60%), + radial-gradient(ellipse at 80% 50%, rgba(34, 211, 238, 0.04) 0%, transparent 60%), + var(--bg-surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; @@ -306,6 +309,162 @@ cursor: default; } +/* ─── Execution Picker ─── */ +.execPicker { + position: relative; +} + +.execPickerBtn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-raised); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.execPickerBtn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.execPickerChevron { + font-size: 10px; + color: var(--text-muted); +} + +.execPickerDropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + width: 260px; + max-height: 300px; + overflow-y: auto; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + z-index: 50; +} + +.execPickerEmpty { + padding: 16px; + text-align: center; + color: var(--text-muted); + font-size: 12px; +} + +.execPickerItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + background: none; + color: var(--text-secondary); + font-size: 11px; + font-family: var(--font-mono); + cursor: pointer; + transition: background 0.1s; +} + +.execPickerItem:hover { + background: var(--bg-hover); +} + +.execPickerItemActive { + background: var(--bg-raised); + color: var(--text-primary); +} + +.execPickerStatus { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.execPickerOk { + background: var(--green); +} + +.execPickerFailed { + background: var(--rose); +} + +.execPickerTime { + flex: 1; + text-align: left; +} + +.execPickerDuration { + color: var(--text-muted); +} + +/* ─── Legend ─── */ +.legend { + position: absolute; + bottom: 12px; + left: 12px; + display: flex; + gap: 16px; + background: rgba(13, 17, 23, 0.85); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 10px 14px; + z-index: 10; + backdrop-filter: blur(4px); +} + +.legendSection { + display: flex; + flex-direction: column; + gap: 4px; +} + +.legendTitle { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-muted); + margin-bottom: 2px; +} + +.legendRow { + display: flex; + align-items: center; + gap: 6px; +} + +.legendDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.legendLine { + width: 16px; + height: 2px; + flex-shrink: 0; + border-radius: 1px; +} + +.legendLabel { + font-size: 10px; + color: var(--text-secondary); + white-space: nowrap; +} + /* ─── Responsive ─── */ @media (max-width: 768px) { .splitLayout { @@ -322,4 +481,8 @@ .minimap { display: none; } + + .legend { + display: none; + } } diff --git a/ui/src/theme/tokens.css b/ui/src/theme/tokens.css index 673e3e6c..bf20cfbc 100644 --- a/ui/src/theme/tokens.css +++ b/ui/src/theme/tokens.css @@ -141,3 +141,31 @@ body::after { .mono { font-family: var(--font-mono); font-size: 12px; } .text-muted { color: var(--text-muted); } .text-secondary { color: var(--text-secondary); } + +/* ─── View Transitions (progressive enhancement) ─── */ +@keyframes slide-out-left { + to { opacity: 0; transform: translateX(-60px); } +} +@keyframes slide-in-right { + from { opacity: 0; transform: translateX(60px); } +} +@keyframes slide-out-right { + to { opacity: 0; transform: translateX(60px); } +} +@keyframes slide-in-left { + from { opacity: 0; transform: translateX(-60px); } +} + +::view-transition-old(root) { + animation: slide-out-left 0.2s ease-in both; +} +::view-transition-new(root) { + animation: slide-in-right 0.2s ease-out both; +} + +.back-nav::view-transition-old(root) { + animation: slide-out-right 0.2s ease-in both; +} +.back-nav::view-transition-new(root) { + animation: slide-in-left 0.2s ease-out both; +}