Fix route diagram open issues: bugs, visual polish, interactive features
Some checks failed
CI / build (push) Successful in 1m12s
CI / deploy (push) Has been cancelled
CI / docker (push) Has been cancelled

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 <title> 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>
This commit is contained in:
hsiegeln
2026-03-14 22:14:23 +01:00
parent 7553139cf2
commit a108b57591
15 changed files with 643 additions and 58 deletions

View File

@@ -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,
},
},
});

View File

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

View File

@@ -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);

View File

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

View File

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

View File

@@ -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)">&larr;</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}

View File

@@ -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>
);

View File

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

View File

@@ -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) => {

View File

@@ -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}</title>
<rect
x={node.x}
y={node.y}
@@ -71,6 +92,12 @@ export function DiagramNode({
);
}
// Uniform dimensions for leaf nodes — use fixed size, centered on ELK position
const cx = (node.x ?? 0) + (node.width ?? FIXED_W) / 2;
const cy = (node.y ?? 0) + (node.height ?? FIXED_H) / 2;
const rx = cx - FIXED_W / 2;
const ry = cy - FIXED_H / 2;
return (
<g
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`}
@@ -81,11 +108,12 @@ export function DiagramNode({
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
tabIndex={0}
>
<title>{tooltip}</title>
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
x={rx}
y={ry}
width={FIXED_W}
height={FIXED_H}
rx={8}
fill={style.bg}
stroke={isSelected ? '#f0b429' : borderColor}
@@ -94,23 +122,23 @@ export function DiagramNode({
filter={glowFilter}
/>
<text
x={(node.x ?? 0) + (node.width ?? 0) / 2}
y={(node.y ?? 0) + (node.height ?? 0) / 2 + 4}
x={cx}
y={cy + 4}
fill="#fff"
fontSize={12}
fontFamily="JetBrains Mono, monospace"
fontWeight={500}
textAnchor="middle"
>
{node.label}
{truncateLabel(node.label)}
</text>
{/* Duration badge */}
{isOverlayActive && isExecuted && duration != null && (
<g>
<rect
x={(node.x ?? 0) + (node.width ?? 0) - 28}
y={(node.y ?? 0) - 8}
x={rx + FIXED_W - 28}
y={ry - 8}
width={36}
height={16}
rx={8}
@@ -118,8 +146,8 @@ export function DiagramNode({
opacity={0.9}
/>
<text
x={(node.x ?? 0) + (node.width ?? 0) - 10}
y={(node.y ?? 0) + 4}
x={rx + FIXED_W - 10}
y={ry + 4}
fill="#fff"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
@@ -135,16 +163,16 @@ export function DiagramNode({
{isOverlayActive && isExecuted && sequence != null && (
<g>
<circle
cx={(node.x ?? 0) + 8}
cy={(node.y ?? 0) - 4}
cx={rx + 8}
cy={ry - 4}
r={8}
fill="#21262d"
stroke={isError ? '#f85149' : '#3fb950'}
strokeWidth={1.5}
/>
<text
x={(node.x ?? 0) + 8}
y={(node.y ?? 0) - 1}
x={rx + 8}
y={ry - 1}
fill="#fff"
fontSize={8}
fontFamily="JetBrains Mono, monospace"

View File

@@ -0,0 +1,75 @@
import { useState, useRef, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { useSearchExecutions } from '../../../api/queries/executions';
import styles from './diagram.module.css';
interface ExecutionPickerProps {
group: string;
routeId: string;
}
export function ExecutionPicker({ group, routeId }: ExecutionPickerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const currentExecId = searchParams.get('exec');
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className={styles.execPicker} ref={ref}>
<button className={styles.execPickerBtn} onClick={() => setOpen(!open)}>
{currentExecId ? `Exec: ${currentExecId.slice(0, 8)}...` : 'Select Execution'}
<span className={styles.execPickerChevron}>{open ? '\u25B4' : '\u25BE'}</span>
</button>
{open && (
<div className={styles.execPickerDropdown}>
{executions.length === 0 && (
<div className={styles.execPickerEmpty}>No recent executions</div>
)}
{executions.map((ex) => (
<button
key={ex.executionId}
className={`${styles.execPickerItem} ${ex.executionId === currentExecId ? styles.execPickerItemActive : ''}`}
onClick={() => select(ex.executionId)}
>
<span className={`${styles.execPickerStatus} ${ex.status === 'FAILED' ? styles.execPickerFailed : styles.execPickerOk}`} />
<span className={styles.execPickerTime}>
{new Date(ex.startTime).toLocaleTimeString()}
</span>
<span className={styles.execPickerDuration}>{ex.durationMs}ms</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -45,19 +45,48 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
<SvgDefs />
{/* Compound container nodes (background) */}
{compoundNodes.map((node) => (
<DiagramNode
key={node.id}
node={node}
isExecuted={!!node.id && overlay.executedNodes.has(node.id)}
isError={false}
isOverlayActive={overlay.isActive}
duration={node.id ? overlay.durations.get(node.id) : undefined}
sequence={undefined}
isSelected={overlay.selectedNodeId === node.id}
onClick={overlay.selectNode}
/>
))}
{compoundNodes.map((node) => {
const iterData = node.id ? overlay.iterationData.get(node.id) : undefined;
return (
<g key={node.id}>
<DiagramNode
node={node}
isExecuted={!!node.id && overlay.executedNodes.has(node.id)}
isError={!!node.id && overlay.statuses.get(node.id) === 'FAILED'}
isOverlayActive={overlay.isActive}
duration={node.id ? overlay.durations.get(node.id) : undefined}
sequence={undefined}
isSelected={overlay.selectedNodeId === node.id}
onClick={overlay.selectNode}
/>
{/* Iteration count badge */}
{overlay.isActive && iterData && iterData.count > 1 && (
<g>
<rect
x={(node.x ?? 0) + (node.width ?? 0) - 32}
y={(node.y ?? 0) + 2}
width={28}
height={16}
rx={8}
fill="#b87aff"
opacity={0.9}
/>
<text
x={(node.x ?? 0) + (node.width ?? 0) - 18}
y={(node.y ?? 0) + 13}
fill="#fff"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
fontWeight={600}
textAnchor="middle"
>
{'\u00D7'}{iterData.count}
</text>
</g>
)}
</g>
);
})}
{/* Edges */}
<EdgeLayer
@@ -81,7 +110,7 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
key={nodeId}
node={node}
isExecuted={overlay.executedNodes.has(nodeId)}
isError={false}
isError={overlay.statuses.get(nodeId) === 'FAILED'}
isOverlayActive={overlay.isActive}
duration={overlay.durations.get(nodeId)}
sequence={overlay.sequences.get(nodeId)}

View File

@@ -17,36 +17,36 @@ export function SvgDefs() {
</marker>
{/* Glow filters */}
<filter id="glow-green" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#3fb950" floodOpacity="0.4" result="color" />
<filter id="glow-green" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feFlood floodColor="#3fb950" floodOpacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-red" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#f85149" floodOpacity="0.4" result="color" />
<filter id="glow-red" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feFlood floodColor="#f85149" floodOpacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-blue" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#58a6ff" floodOpacity="0.4" result="color" />
<filter id="glow-blue" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feFlood floodColor="#58a6ff" floodOpacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-purple" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#b87aff" floodOpacity="0.4" result="color" />
<filter id="glow-purple" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feFlood floodColor="#b87aff" floodOpacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />

View File

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

View File

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