Fix route diagram open issues: bugs, visual polish, interactive features
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:
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
73
ui/src/pages/routes/diagram/DiagramLegend.tsx
Normal file
73
ui/src/pages/routes/diagram/DiagramLegend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
75
ui/src/pages/routes/diagram/ExecutionPicker.tsx
Normal file
75
ui/src/pages/routes/diagram/ExecutionPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user