diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts index ceaaa1fb..a8142881 100644 --- a/ui/src/api/queries/executions.ts +++ b/ui/src/api/queries/executions.ts @@ -2,15 +2,22 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../client'; import type { SearchRequest } from '../types'; -export function useExecutionStats(timeFrom: string | undefined, timeTo: string | undefined) { +export function useExecutionStats( + timeFrom: string | undefined, + timeTo: string | undefined, + routeId?: string, + group?: string, +) { return useQuery({ - queryKey: ['executions', 'stats', timeFrom, timeTo], + queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, group], queryFn: async () => { const { data, error } = await api.GET('/search/stats', { params: { query: { from: timeFrom!, to: timeTo || undefined, + routeId: routeId || undefined, + group: group || undefined, }, }, }); @@ -38,9 +45,14 @@ export function useSearchExecutions(filters: SearchRequest, live = false) { }); } -export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string | undefined) { +export function useStatsTimeseries( + timeFrom: string | undefined, + timeTo: string | undefined, + routeId?: string, + group?: string, +) { return useQuery({ - queryKey: ['executions', 'timeseries', timeFrom, timeTo], + queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, group], queryFn: async () => { const { data, error } = await api.GET('/search/stats/timeseries', { params: { @@ -48,6 +60,8 @@ export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string from: timeFrom!, to: timeTo || undefined, buckets: 24, + routeId: routeId || undefined, + group: group || undefined, }, }, }); diff --git a/ui/src/hooks/useExecutionOverlay.ts b/ui/src/hooks/useExecutionOverlay.ts index 7a8bbc6e..69c0e7ec 100644 --- a/ui/src/hooks/useExecutionOverlay.ts +++ b/ui/src/hooks/useExecutionOverlay.ts @@ -13,6 +13,7 @@ export interface OverlayState { executedEdges: Set; durations: Map; sequences: Map; + statuses: Map; iterationData: Map; selectedNodeId: string | null; selectNode: (nodeId: string | null) => void; @@ -25,6 +26,7 @@ function collectProcessorData( executedNodes: Set, durations: Map, sequences: Map, + statuses: Map, counter: { seq: number }, ) { for (const proc of processors) { @@ -33,9 +35,10 @@ function collectProcessorData( executedNodes.add(nodeId); durations.set(nodeId, proc.durationMs ?? 0); sequences.set(nodeId, ++counter.seq); + if (proc.status) statuses.set(nodeId, proc.status); } if (proc.children && proc.children.length > 0) { - collectProcessorData(proc.children, executedNodes, durations, sequences, counter); + collectProcessorData(proc.children, executedNodes, durations, sequences, statuses, counter); } } } @@ -68,19 +71,20 @@ export function useExecutionOverlay( if (execution) setIsActive(true); }, [execution]); - const { executedNodes, durations, sequences, iterationData } = useMemo(() => { + const { executedNodes, durations, sequences, statuses, iterationData } = useMemo(() => { const en = new Set(); const dur = new Map(); const seq = new Map(); + const st = new Map(); const iter = new Map(); if (!execution?.processors) { - return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter }; + return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter }; } - collectProcessorData(execution.processors, en, dur, seq, { seq: 0 }); + collectProcessorData(execution.processors, en, dur, seq, st, { seq: 0 }); - return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter }; + return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter }; }, [execution]); const executedEdges = useMemo( @@ -119,6 +123,7 @@ export function useExecutionOverlay( executedEdges, durations, sequences, + statuses, iterationData: new Map([...iterationData].map(([k, v]) => { const current = iterations.get(k) ?? v.current; return [k, { ...v, current }]; diff --git a/ui/src/pages/routes/PerformanceTab.tsx b/ui/src/pages/routes/PerformanceTab.tsx index 02476d31..34faa170 100644 --- a/ui/src/pages/routes/PerformanceTab.tsx +++ b/ui/src/pages/routes/PerformanceTab.tsx @@ -23,8 +23,8 @@ export function PerformanceTab({ group, routeId }: PerformanceTabProps) { const timeTo = new Date().toISOString(); // Use scoped stats/timeseries via group+routeId query params - const { data: stats } = useExecutionStats(timeFrom, timeTo); - const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo); + const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, group); + const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, group); const buckets = timeseries?.buckets ?? []; const sparkTotal = buckets.map((b) => b.totalCount ?? 0); diff --git a/ui/src/pages/routes/RouteHeader.tsx b/ui/src/pages/routes/RouteHeader.tsx index c93f8ad1..1c1da077 100644 --- a/ui/src/pages/routes/RouteHeader.tsx +++ b/ui/src/pages/routes/RouteHeader.tsx @@ -1,4 +1,5 @@ import type { DiagramLayout } from '../../api/types'; +import { useExecutionStats } from '../../api/queries/executions'; import styles from './RoutePage.module.css'; interface RouteHeaderProps { @@ -9,6 +10,12 @@ interface RouteHeaderProps { export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) { const nodeCount = layout?.nodes?.length ?? 0; + const timeFrom = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const { data: stats } = useExecutionStats(timeFrom, undefined, routeId, group); + + const successRate = stats && stats.totalCount > 0 + ? ((1 - stats.failedCount / stats.totalCount) * 100).toFixed(1) + : null; return (
@@ -24,6 +31,32 @@ export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) { )}
+ {stats && ( +
+
+ {stats.totalToday.toLocaleString()} + Executions Today +
+
+ + {successRate ? `${successRate}%` : '--'} + + Success Rate +
+
+ + {stats.avgDurationMs != null ? `${stats.avgDurationMs}ms` : '--'} + + Avg Duration +
+
+ + {stats.p99LatencyMs != null ? `${stats.p99LatencyMs}ms` : '--'} + + P99 Latency +
+
+ )} ); } diff --git a/ui/src/pages/routes/RoutePage.module.css b/ui/src/pages/routes/RoutePage.module.css index bde2ad5b..18bc7f31 100644 --- a/ui/src/pages/routes/RoutePage.module.css +++ b/ui/src/pages/routes/RoutePage.module.css @@ -7,6 +7,28 @@ font-size: 12px; } +.backBtn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-raised); + color: var(--text-muted); + font-size: 14px; + cursor: pointer; + transition: all 0.15s; + margin-right: 4px; +} + +.backBtn:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--amber); +} + .breadcrumbLink { color: var(--text-muted); text-decoration: none; @@ -89,6 +111,48 @@ background: var(--green); } +.headerStatsRow { + display: flex; + gap: 24px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border-subtle); +} + +.headerStat { + display: flex; + flex-direction: column; + gap: 2px; +} + +.headerStatValue { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.5px; +} + +.headerStatGreen { + color: var(--green); +} + +.headerStatCyan { + color: var(--cyan); +} + +.headerStatAmber { + color: var(--amber); +} + +.headerStatLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-muted); +} + /* ─── Toolbar & Tabs ─── */ .toolbar { display: flex; diff --git a/ui/src/pages/routes/RoutePage.tsx b/ui/src/pages/routes/RoutePage.tsx index 514d1871..999d4110 100644 --- a/ui/src/pages/routes/RoutePage.tsx +++ b/ui/src/pages/routes/RoutePage.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { useParams, useSearchParams, NavLink } from 'react-router'; +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useSearchParams, NavLink, useNavigate } from 'react-router'; import { useDiagramByRoute } from '../../api/queries/diagrams'; import { useExecutionDetail } from '../../api/queries/executions'; import { useExecutionOverlay } from '../../hooks/useExecutionOverlay'; @@ -7,6 +7,7 @@ import { RouteHeader } from './RouteHeader'; import { DiagramTab } from './DiagramTab'; import { PerformanceTab } from './PerformanceTab'; import { ProcessorTree } from '../executions/ProcessorTree'; +import { ExecutionPicker } from './diagram/ExecutionPicker'; import styles from './RoutePage.module.css'; type Tab = 'diagram' | 'performance' | 'processors'; @@ -16,6 +17,29 @@ export function RoutePage() { const [searchParams] = useSearchParams(); const execId = searchParams.get('exec'); const [activeTab, setActiveTab] = useState('diagram'); + const navigate = useNavigate(); + + const goBack = useCallback(() => { + const doc = document as Document & { startViewTransition?: (cb: () => void) => void }; + if (doc.startViewTransition) { + doc.startViewTransition(() => navigate(-1)); + } else { + navigate(-1); + } + }, [navigate]); + + // Backspace navigates back (unless user is in an input) + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key !== 'Backspace') return; + const tag = (e.target as HTMLElement).tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + e.preventDefault(); + goBack(); + } + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [goBack]); const { data: layout, isLoading: layoutLoading } = useDiagramByRoute(group, routeId); const { data: execution } = useExecutionDetail(execId); @@ -33,6 +57,7 @@ export function RoutePage() { <> {/* Breadcrumb */}