Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions dashboard/src/__tests__/LatencyPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LatencyPanel latency={null} loading />);
expect(screen.getByText('Loading latency metrics...')).toBeDefined();
});

it('shows empty state when no latency data exists', () => {
render(<LatencyPanel latency={null} loading={false} />);
expect(screen.getByText('No latency samples yet.')).toBeDefined();
});

it('renders session latency cards with aggregated values', () => {
render(
<LatencyPanel
loading={false}
latency={{
sessionId: 'session-1',
realtime: {
hook_latency_ms: 22,
state_change_detection_ms: 18,
permission_response_ms: 320,
},
aggregated: {
hook_latency_ms: { min: 10, max: 40, avg: 22, count: 3 },
state_change_detection_ms: { min: 8, max: 30, avg: 18, count: 3 },
permission_response_ms: { min: 100, max: 500, avg: 320, count: 2 },
channel_delivery_ms: { min: 14, max: 42, avg: 26, count: 4 },
},
}}
/>,
);

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);
});
});
22 changes: 13 additions & 9 deletions dashboard/src/__tests__/MetricCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions dashboard/src/__tests__/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 15 additions & 11 deletions dashboard/src/__tests__/useSessionPolling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));

Expand All @@ -24,15 +24,15 @@ 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';

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;
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions dashboard/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
SessionHealth,
MessagesResponse,
SessionMetrics,
SessionLatency,
PaneResponse,
SessionSummary,
OkResponse,
Expand All @@ -35,6 +36,7 @@ import {
SessionsListResponseSchema,
SessionHealthSchema,
SessionMetricsSchema,
SessionLatencySchema,
SessionMessagesSchema,
GlobalMetricsSchema,
GlobalSSEEventSchema,
Expand Down Expand Up @@ -248,6 +250,13 @@ export function getSessionMetrics(id: string): Promise<SessionMetrics> {
});
}

export function getSessionLatency(id: string): Promise<SessionLatency> {
return request(`/v1/sessions/${encodeURIComponent(id)}/latency`, {
schema: SessionLatencySchema,
schemaContext: 'getSessionLatency',
});
}

// ── Session Pane ────────────────────────────────────────────────

export function getSessionPane(id: string): Promise<PaneResponse> {
Expand Down
28 changes: 28 additions & 0 deletions dashboard/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) ────────────────────────────────
Expand Down
112 changes: 112 additions & 0 deletions dashboard/src/components/metrics/LatencyPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-center h-36 text-[#555] text-sm animate-pulse">
Loading latency metrics...
</div>
);
}

if (!latency || !latency.aggregated) {
return (
<div className="rounded-lg border border-[#1a1a2e] bg-[#111118] p-4 text-sm text-[#888]">
No latency samples yet.
</div>
);
}

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 (
<div className="space-y-3">
<h3 className="text-xs text-[#888] uppercase tracking-wider">Latency</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{cards.map((card) => (
<div
key={card.label}
className="rounded-lg border border-[#1a1a2e] bg-[#111118] p-4"
>
<div className="flex items-center justify-between text-xs text-[#888] uppercase tracking-wider">
<span>{card.label}</span>
<span>{card.count} sample{card.count === 1 ? '' : 's'}</span>
</div>

<div className="mt-3 h-1.5 rounded bg-[#1a1a2e] overflow-hidden">
<div className="h-full bg-[#00e5ff]/70 transition-all duration-300" style={{ width: barWidth(card.avg) }} />
</div>

<div className="mt-3 grid grid-cols-3 gap-2 text-xs font-mono text-[#bbb]">
<div>
<div className="text-[#666] mb-1">Latest</div>
<div className="text-[#00e5ff]">{formatMs(card.latest)}</div>
</div>
<div>
<div className="text-[#666] mb-1">Avg</div>
<div className="text-[#00ff88]">{formatMs(card.avg)}</div>
</div>
<div>
<div className="text-[#666] mb-1">Max</div>
<div className="text-[#ffaa00]">{formatMs(card.max)}</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}
22 changes: 21 additions & 1 deletion dashboard/src/components/overview/MetricCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
<MetricCard
label="Active Sessions"
value={activeSessions}
Expand All @@ -61,6 +66,21 @@ export default function MetricCards() {
suffix="%"
icon={<Zap className="h-4 w-4" />}
/>
<MetricCard
label="Avg Hook Latency"
value={formatLatency(hookLatency)}
icon={<Clock className="h-4 w-4" />}
/>
<MetricCard
label="Avg Permission Latency"
value={formatLatency(permissionLatency)}
icon={<Clock className="h-4 w-4" />}
/>
<MetricCard
label="Avg Channel Latency"
value={formatLatency(channelLatency)}
icon={<Clock className="h-4 w-4" />}
/>
<MetricCard
label="Uptime"
value={formatUptime(uptime)}
Expand Down
Loading