From 47ef5ee38094fb1e59d66b00e477abd13360486f Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Fri, 3 Apr 2026 20:27:50 +0200 Subject: [PATCH] fix: add latency metrics visualization to dashboard --- dashboard/src/__tests__/LatencyPanel.test.tsx | 44 +++++++ dashboard/src/__tests__/MetricCards.test.tsx | 22 ++-- dashboard/src/__tests__/schemas.test.ts | 12 ++ .../src/__tests__/useSessionPolling.test.ts | 26 ++-- dashboard/src/api/client.ts | 9 ++ dashboard/src/api/schemas.ts | 28 +++++ .../src/components/metrics/LatencyPanel.tsx | 112 ++++++++++++++++++ .../src/components/overview/MetricCards.tsx | 22 +++- dashboard/src/hooks/useSessionPolling.ts | 40 +++---- dashboard/src/pages/SessionDetailPage.tsx | 12 +- dashboard/src/types/index.ts | 28 +++++ 11 files changed, 306 insertions(+), 49 deletions(-) create mode 100644 dashboard/src/__tests__/LatencyPanel.test.tsx create mode 100644 dashboard/src/components/metrics/LatencyPanel.tsx diff --git a/dashboard/src/__tests__/LatencyPanel.test.tsx b/dashboard/src/__tests__/LatencyPanel.test.tsx new file mode 100644 index 00000000..1097926b --- /dev/null +++ b/dashboard/src/__tests__/LatencyPanel.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { LatencyPanel } from '../components/metrics/LatencyPanel'; + +describe('LatencyPanel', () => { + it('shows loading state', () => { + render(); + expect(screen.getByText('Loading latency metrics...')).toBeDefined(); + }); + + it('shows empty state when no latency data exists', () => { + render(); + expect(screen.getByText('No latency samples yet.')).toBeDefined(); + }); + + it('renders session latency cards with aggregated values', () => { + render( + , + ); + + expect(screen.getByText('Latency')).toBeDefined(); + expect(screen.getByText('State Change Detection')).toBeDefined(); + expect(screen.getByText('Hook Processing')).toBeDefined(); + expect(screen.getAllByText('22 ms').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('320 ms').length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dashboard/src/__tests__/MetricCards.test.tsx b/dashboard/src/__tests__/MetricCards.test.tsx index e0d3e99c..d3690b07 100644 --- a/dashboard/src/__tests__/MetricCards.test.tsx +++ b/dashboard/src/__tests__/MetricCards.test.tsx @@ -28,22 +28,26 @@ describe('MetricCards polling strategy', () => { completed: 1, failed: 0, avg_duration_sec: 42, + avg_messages_per_session: 3, }, + auto_approvals: 0, + webhooks_sent: 0, + webhooks_failed: 0, + screenshots_taken: 0, + pipelines_created: 0, + batches_created: 0, prompt_delivery: { + sent: 1, delivered: 1, failed: 0, success_rate: 100, }, - permission: { - prompts: 0, - approved: 0, - rejected: 0, + latency: { + hook_latency_ms: { min: 2, max: 6, avg: 4, count: 2 }, + state_change_detection_ms: { min: 2, max: 6, avg: 4, count: 2 }, + permission_response_ms: { min: 20, max: 40, avg: 30, count: 2 }, + channel_delivery_ms: { min: 3, max: 7, avg: 5, count: 2 }, }, - throughput: { - messages_per_min: 0, - tool_calls_per_min: 0, - }, - timestamp: new Date().toISOString(), }); mockGetHealth.mockResolvedValue({ diff --git a/dashboard/src/__tests__/schemas.test.ts b/dashboard/src/__tests__/schemas.test.ts index 67375d8f..f13e3067 100644 --- a/dashboard/src/__tests__/schemas.test.ts +++ b/dashboard/src/__tests__/schemas.test.ts @@ -94,6 +94,12 @@ describe('GlobalMetricsSchema', () => { failed: 1, success_rate: 0.93, }, + latency: { + hook_latency_ms: { min: 1, max: 7, avg: 3, count: 5 }, + state_change_detection_ms: { min: 2, max: 9, avg: 4, count: 5 }, + permission_response_ms: { min: 10, max: 40, avg: 22, count: 3 }, + channel_delivery_ms: { min: 3, max: 18, avg: 8, count: 5 }, + }, }; it('accepts valid payload', () => { @@ -129,6 +135,12 @@ describe('GlobalMetricsSchema', () => { expect(result.success).toBe(false); }); + it('rejects missing latency block', () => { + const { latency, ...noLatency } = validPayload; + const result = GlobalMetricsSchema.safeParse(noLatency); + expect(result.success).toBe(false); + }); + it('rejects extra unknown field in sessions', () => { const result = GlobalMetricsSchema.safeParse({ ...validPayload, diff --git a/dashboard/src/__tests__/useSessionPolling.test.ts b/dashboard/src/__tests__/useSessionPolling.test.ts index d9705b4c..1d46ff26 100644 --- a/dashboard/src/__tests__/useSessionPolling.test.ts +++ b/dashboard/src/__tests__/useSessionPolling.test.ts @@ -12,7 +12,7 @@ vi.mock('../api/client', () => ({ getSessionHealth: vi.fn(), getSessionPane: vi.fn(), getSessionMetrics: vi.fn(), - getSessionSummary: vi.fn(), + getSessionLatency: vi.fn(), subscribeSSE: vi.fn(), })); @@ -24,7 +24,7 @@ vi.mock('../store/useToastStore', () => ({ useToastStore: vi.fn(), })); -import { getSession, getSessionHealth, getSessionPane, getSessionMetrics, getSessionSummary, subscribeSSE } from '../api/client'; +import { getSession, getSessionHealth, getSessionPane, getSessionMetrics, getSessionLatency, subscribeSSE } from '../api/client'; import { useStore } from '../store/useStore'; import { useToastStore } from '../store/useToastStore'; @@ -32,7 +32,7 @@ const mockedGetSession = vi.mocked(getSession); const mockedGetSessionHealth = vi.mocked(getSessionHealth); const mockedGetSessionPane = vi.mocked(getSessionPane); const mockedGetSessionMetrics = vi.mocked(getSessionMetrics); -const mockedGetSessionSummary = vi.mocked(getSessionSummary); +const mockedGetSessionLatency = vi.mocked(getSessionLatency); describe('useSessionPolling', () => { let capturedHandler: ((e: MessageEvent) => void) | null = null; @@ -80,15 +80,19 @@ describe('useSessionPolling', () => { autoApprovals: 0, statusChanges: [], }); - mockedGetSessionSummary.mockResolvedValue({ + mockedGetSessionLatency.mockResolvedValue({ sessionId: 'session-a', - windowName: 'test', - status: 'idle', - totalMessages: 0, - messages: [], - createdAt: Date.now(), - lastActivity: Date.now(), - permissionMode: 'default', + realtime: { + hook_latency_ms: 12, + state_change_detection_ms: 12, + permission_response_ms: null, + }, + aggregated: { + hook_latency_ms: { min: 12, max: 12, avg: 12, count: 1 }, + state_change_detection_ms: { min: 12, max: 12, avg: 12, count: 1 }, + permission_response_ms: { min: null, max: null, avg: null, count: 0 }, + channel_delivery_ms: { min: null, max: null, avg: null, count: 0 }, + }, }); (subscribeSSE as any).mockImplementation( diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 696924d2..8b2d31c4 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -14,6 +14,7 @@ import type { SessionHealth, MessagesResponse, SessionMetrics, + SessionLatency, PaneResponse, SessionSummary, OkResponse, @@ -35,6 +36,7 @@ import { SessionsListResponseSchema, SessionHealthSchema, SessionMetricsSchema, + SessionLatencySchema, SessionMessagesSchema, GlobalMetricsSchema, GlobalSSEEventSchema, @@ -248,6 +250,13 @@ export function getSessionMetrics(id: string): Promise { }); } +export function getSessionLatency(id: string): Promise { + return request(`/v1/sessions/${encodeURIComponent(id)}/latency`, { + schema: SessionLatencySchema, + schemaContext: 'getSessionLatency', + }); +} + // ── Session Pane ──────────────────────────────────────────────── export function getSessionPane(id: string): Promise { diff --git a/dashboard/src/api/schemas.ts b/dashboard/src/api/schemas.ts index e98c2d7f..04048e6e 100644 --- a/dashboard/src/api/schemas.ts +++ b/dashboard/src/api/schemas.ts @@ -133,6 +133,28 @@ export const SessionMetricsSchema = z.object({ statusChanges: z.array(z.string()), }); +const LatencySummaryStatSchema = z.object({ + min: z.number().nullable(), + max: z.number().nullable(), + avg: z.number().nullable(), + count: z.number(), +}); + +export const SessionLatencySchema = z.object({ + sessionId: z.string(), + realtime: z.object({ + hook_latency_ms: z.number().nullable(), + state_change_detection_ms: z.number().nullable(), + permission_response_ms: z.number().nullable(), + }).nullable(), + aggregated: z.object({ + hook_latency_ms: LatencySummaryStatSchema, + state_change_detection_ms: LatencySummaryStatSchema, + permission_response_ms: LatencySummaryStatSchema, + channel_delivery_ms: LatencySummaryStatSchema, + }).nullable(), +}); + // ── ParsedEntry ────────────────────────────────────────────────── const ParsedEntrySchema = z.object({ @@ -177,6 +199,12 @@ export const GlobalMetricsSchema = z.object({ failed: z.number(), success_rate: z.number().nullable(), }), + latency: z.object({ + hook_latency_ms: LatencySummaryStatSchema, + state_change_detection_ms: LatencySummaryStatSchema, + permission_response_ms: LatencySummaryStatSchema, + channel_delivery_ms: LatencySummaryStatSchema, + }), }); // ── SSE Event Data (Issue #410) ──────────────────────────────── diff --git a/dashboard/src/components/metrics/LatencyPanel.tsx b/dashboard/src/components/metrics/LatencyPanel.tsx new file mode 100644 index 00000000..26227a47 --- /dev/null +++ b/dashboard/src/components/metrics/LatencyPanel.tsx @@ -0,0 +1,112 @@ +import type { SessionLatency } from '../../types'; + +interface LatencyPanelProps { + latency: SessionLatency | null; + loading: boolean; +} + +interface LatencyCard { + label: string; + latest: number | null; + avg: number | null; + max: number | null; + count: number; +} + +function formatMs(value: number | null): string { + if (value === null) return '--'; + return `${Math.round(value)} ms`; +} + +function barWidth(avg: number | null): string { + if (avg === null) return '0%'; + const clamped = Math.min(100, Math.round((avg / 500) * 100)); + return `${clamped}%`; +} + +export function LatencyPanel({ latency, loading }: LatencyPanelProps) { + if (loading) { + return ( +
+ Loading latency metrics... +
+ ); + } + + if (!latency || !latency.aggregated) { + return ( +
+ No latency samples yet. +
+ ); + } + + const cards: LatencyCard[] = [ + { + label: 'State Change Detection', + latest: latency.realtime?.state_change_detection_ms ?? null, + avg: latency.aggregated.state_change_detection_ms.avg, + max: latency.aggregated.state_change_detection_ms.max, + count: latency.aggregated.state_change_detection_ms.count, + }, + { + label: 'Channel Delivery', + latest: null, + avg: latency.aggregated.channel_delivery_ms.avg, + max: latency.aggregated.channel_delivery_ms.max, + count: latency.aggregated.channel_delivery_ms.count, + }, + { + label: 'Permission Response', + latest: latency.realtime?.permission_response_ms ?? null, + avg: latency.aggregated.permission_response_ms.avg, + max: latency.aggregated.permission_response_ms.max, + count: latency.aggregated.permission_response_ms.count, + }, + { + label: 'Hook Processing', + latest: latency.realtime?.hook_latency_ms ?? null, + avg: latency.aggregated.hook_latency_ms.avg, + max: latency.aggregated.hook_latency_ms.max, + count: latency.aggregated.hook_latency_ms.count, + }, + ]; + + return ( +
+

Latency

+
+ {cards.map((card) => ( +
+
+ {card.label} + {card.count} sample{card.count === 1 ? '' : 's'} +
+ +
+
+
+ +
+
+
Latest
+
{formatMs(card.latest)}
+
+
+
Avg
+
{formatMs(card.avg)}
+
+
+
Max
+
{formatMs(card.max)}
+
+
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/overview/MetricCards.tsx b/dashboard/src/components/overview/MetricCards.tsx index c274cd80..11635671 100644 --- a/dashboard/src/components/overview/MetricCards.tsx +++ b/dashboard/src/components/overview/MetricCards.tsx @@ -42,9 +42,14 @@ export default function MetricCards() { const totalCreated = metrics?.sessions.total_created ?? health?.sessions.total ?? 0; const deliveryRate = metrics?.prompt_delivery.success_rate; const uptime = health?.uptime ?? metrics?.uptime ?? 0; + const hookLatency = metrics?.latency?.hook_latency_ms.avg ?? null; + const permissionLatency = metrics?.latency?.permission_response_ms.avg ?? null; + const channelLatency = metrics?.latency?.channel_delivery_ms.avg ?? null; + + const formatLatency = (value: number | null): string => (value === null ? '—' : `${Math.round(value)} ms`); return ( -
+
} /> + } + /> + } + /> + } + /> void; } @@ -47,10 +47,8 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { // Metrics state const [metrics, setMetrics] = useState(null); const [metricsLoading, setMetricsLoading] = useState(true); - - // Summary state - const [summary, setSummary] = useState(null); - const [summaryLoading, setSummaryLoading] = useState(true); + const [latency, setLatency] = useState(null); + const [latencyLoading, setLatencyLoading] = useState(true); // Refs for stable callbacks const sessionIdRef = useRef(sessionId); @@ -88,7 +86,7 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { }, [addToast]); loadSessionAndHealthRef.current = loadSessionAndHealth; - // Fetch pane + metrics + summary + // Fetch pane + metrics const loadPaneAndMetrics = useCallback(async () => { if (cancelledRef.current) return; const sid = sessionIdRef.current; @@ -112,12 +110,12 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { } try { - const data = await getSessionSummary(sid); - if (!cancelledRef.current) setSummary(data); + const data = await getSessionLatency(sid); + if (!cancelledRef.current) setLatency(data); } catch (e: unknown) { - addToast('warning', 'Failed to load session summary', e instanceof Error ? e.message : undefined); + addToast('warning', 'Failed to load session latency', e instanceof Error ? e.message : undefined); } finally { - if (!cancelledRef.current) setSummaryLoading(false); + if (!cancelledRef.current) setLatencyLoading(false); } }, [addToast]); loadPaneAndMetricsRef.current = loadPaneAndMetrics; @@ -129,7 +127,7 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { setLoading(true); setPaneLoading(true); setMetricsLoading(true); - setSummaryLoading(true); + setLatencyLoading(true); loadSessionAndHealth(); loadPaneAndMetrics(); @@ -163,7 +161,7 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { }, 1000); }, []); - // SSE subscription -- drives all refetching + // SSE subscription — drives all refetching useEffect(() => { if (!sessionId) return; @@ -192,12 +190,12 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { break; case 'ended': - // Final state -- re-fetch everything immediately + // Final state — re-fetch everything immediately loadSessionAndHealthRef.current?.(); loadPaneAndMetricsRef.current?.(); break; - // 'heartbeat', 'system', 'hook', 'subagent_start', 'subagent_stop' -- no action needed + // 'heartbeat', 'system', 'hook', 'subagent_start', 'subagent_stop' — no action needed } } catch { // ignore malformed events @@ -216,8 +214,8 @@ export function useSessionPolling(sessionId: string): UseSessionPollingReturn { paneLoading, metrics, metricsLoading, - summary, - summaryLoading, + latency, + latencyLoading, refetchPaneAndMetrics: loadPaneAndMetrics, }; -} \ No newline at end of file +} diff --git a/dashboard/src/pages/SessionDetailPage.tsx b/dashboard/src/pages/SessionDetailPage.tsx index d7b97f9e..e91f4f17 100644 --- a/dashboard/src/pages/SessionDetailPage.tsx +++ b/dashboard/src/pages/SessionDetailPage.tsx @@ -23,8 +23,8 @@ import { SessionHeader } from '../components/session/SessionHeader'; import { TranscriptViewer } from '../components/session/TranscriptViewer'; import { LiveTerminal } from '../components/session/LiveTerminal'; import { SessionMetricsPanel } from '../components/session/SessionMetricsPanel'; +import { LatencyPanel } from '../components/metrics/LatencyPanel'; import { ApprovalBanner } from '../components/session/ApprovalBanner'; -import { SessionSummaryCard } from '../components/session/SessionSummaryCard'; interface ScreenshotState { image: string; @@ -49,11 +49,9 @@ export default function SessionDetailPage() { const { session, health, notFound, loading, metrics, metricsLoading, - summary, summaryLoading, + latency, latencyLoading, } = useSessionPolling(id ?? ''); - - const [msgInput, setMsgInput] = useState(''); const [sending, setSending] = useState(false); const [selectedSlashCommand, setSelectedSlashCommand] = useState(COMMON_SLASH_COMMANDS[0]); @@ -242,9 +240,6 @@ export default function SessionDetailPage() { onKill={handleKill} /> - {/* Session summary */} - - {/* Tab bar — full-width stretch on mobile */}
{TABS.map(tab => ( @@ -299,6 +294,9 @@ export default function SessionDetailPage() { {activeTab === 'metrics' && (
+
+ +
)}
diff --git a/dashboard/src/types/index.ts b/dashboard/src/types/index.ts index ff81baf1..6ef0c58b 100644 --- a/dashboard/src/types/index.ts +++ b/dashboard/src/types/index.ts @@ -112,6 +112,28 @@ export interface SessionMetrics { statusChanges: string[]; } +export interface LatencySummaryStat { + min: number | null; + max: number | null; + avg: number | null; + count: number; +} + +export interface SessionLatency { + sessionId: string; + realtime: { + hook_latency_ms: number | null; + state_change_detection_ms: number | null; + permission_response_ms: number | null; + } | null; + aggregated: { + hook_latency_ms: LatencySummaryStat; + state_change_detection_ms: LatencySummaryStat; + permission_response_ms: LatencySummaryStat; + channel_delivery_ms: LatencySummaryStat; + } | null; +} + export interface GlobalMetrics { uptime: number; sessions: { @@ -134,6 +156,12 @@ export interface GlobalMetrics { failed: number; success_rate: number | null; }; + latency: { + hook_latency_ms: LatencySummaryStat; + state_change_detection_ms: LatencySummaryStat; + permission_response_ms: LatencySummaryStat; + channel_delivery_ms: LatencySummaryStat; + }; } // ── SSE Events ──────────────────────────────────────────────────