From ee2c4b3d7719731309f0e3709b26ae63acb081da Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:24:53 +0000 Subject: [PATCH 1/6] feat(web): monitor (context-driven detail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 Task 44. SelectedPanel reads useSelected() and renders one of four bodies inside a pinned : - agent: AgentDefinition details (kind, model, tools, routes, prompt excerpt) looked up from agentsByName. - tool_call: ToolCall details (agent, status, risk, args JSON) found locally by @ id. - message: placeholder showing the turn id. - null: spec § 16.2 empty-state copy. Each body has a lowercase "kind · subject" eyebrow tag matching the selection. "Not found" fallback when an id doesn't resolve in the current session. Tests: 4 component tests cover empty, agent, tool_call, and not-found paths. Full suite 134/0 green. Typecheck clean. --- web/src/monitors/SelectedPanel.tsx | 160 +++++++++++++++++++++ web/tests/component/SelectedPanel.test.tsx | 87 +++++++++++ 2 files changed, 247 insertions(+) create mode 100644 web/src/monitors/SelectedPanel.tsx create mode 100644 web/tests/component/SelectedPanel.test.tsx diff --git a/web/src/monitors/SelectedPanel.tsx b/web/src/monitors/SelectedPanel.tsx new file mode 100644 index 0000000..69d1974 --- /dev/null +++ b/web/src/monitors/SelectedPanel.tsx @@ -0,0 +1,160 @@ +import type { CSSProperties, ReactNode } from 'react'; +import type { AgentDefinition, ToolCall } from '@/api/types'; +import { useSelected } from '@/state/selectedRef'; +import { Monitor } from './Monitor'; + +interface SelectedPanelProps { + agentsByName: Record; + toolCalls: ToolCall[]; +} + +const eyebrow: CSSProperties = { + display: 'inline-block', + padding: '2px 6px', + background: 'var(--acc-soft)', + color: 'var(--acc)', + fontFamily: 'var(--ff-mono)', + fontSize: 9, + letterSpacing: '0.14em', + textTransform: 'uppercase', + marginBottom: 8, +}; + +const kv: CSSProperties = { + display: 'flex', + gap: 6, + fontFamily: 'var(--ff-mono)', + fontSize: 11, + color: 'var(--ink-3)', + marginBottom: 4, +}; + +const kvKey: CSSProperties = { width: 80, color: 'var(--ink-3)' }; +const kvVal: CSSProperties = { flex: 1, color: 'var(--ink-1)' }; + +function Empty(): ReactNode { + return ( +
+ Click an agent, tool, or message to see detail. +
+ ); +} + +function NotFound(): ReactNode { + return ( +
+ Selected item not found in current session. +
+ ); +} + +function AgentBody({ agent }: { agent: AgentDefinition }): ReactNode { + return ( + <> +
agent · {agent.name}
+
+ Kind + {agent.kind} +
+
+ Model + {agent.model} +
+
+ Tools + {agent.tools.join(', ') || '—'} +
+ {Object.keys(agent.routes).length > 0 && ( +
+ Routes + + {Object.entries(agent.routes) + .map(([k, v]) => `${k} → ${v}`) + .join(' · ')} + +
+ )} + {agent.system_prompt_excerpt && ( +
+ {agent.system_prompt_excerpt} +
+ )} + + ); +} + +function ToolCallBody({ tc }: { tc: ToolCall }): ReactNode { + return ( + <> +
tool · {tc.tool}
+
+ Agent + {tc.agent} +
+
+ Status + {tc.status} +
+
+ Risk + {tc.risk ?? '—'} +
+
+ Args +
+
+        {JSON.stringify(tc.args, null, 2)}
+      
+ + ); +} + +function MessageBody({ id }: { id: string | undefined }): ReactNode { + return ( + <> +
message · {id ?? '—'}
+
+ Message · {id ?? '—'} +
+ + ); +} + +export function SelectedPanel({ agentsByName, toolCalls }: SelectedPanelProps) { + const selected = useSelected(); + let body: ReactNode; + if (selected.kind === null) { + body = ; + } else if (selected.kind === 'agent') { + const a = agentsByName[selected.id ?? '']; + body = a ? : ; + } else if (selected.kind === 'tool_call') { + const found = toolCalls.find((t) => `${t.tool}@${t.ts}` === selected.id); + body = found ? : ; + } else { + body = ; + } + return ( + + {body} + + ); +} diff --git a/web/tests/component/SelectedPanel.test.tsx b/web/tests/component/SelectedPanel.test.tsx new file mode 100644 index 0000000..9aa63e1 --- /dev/null +++ b/web/tests/component/SelectedPanel.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { useEffect } from 'react'; +import { render, screen } from '../_helpers/render'; +import { SelectedPanel } from '@/monitors/SelectedPanel'; +import { SelectedRefProvider, useSetSelected } from '@/state/selectedRef'; +import type { AgentDefinition, ToolCall } from '@/api/types'; + +const agents: Record = { + intake: { + name: 'intake', + kind: 'responsive', + model: 'gpt', + tools: ['obs:get_logs'], + routes: { success: 'triage' }, + system_prompt_excerpt: 'You triage incidents…', + }, +}; + +const tc: ToolCall = { + agent: 'triage', + tool: 'obs:get_logs', + args: { service: 'api' }, + result: 'ok', + ts: '2026-05-15T14:16:50Z', + risk: 'low', + status: 'executed', + approver: null, + approved_at: null, + approval_rationale: null, +}; + +interface SetterRef { + kind: 'agent' | 'tool_call' | 'message' | null; + id?: string; +} + +function Setter({ ref }: { ref: SetterRef }) { + const set = useSetSelected(); + useEffect(() => { + set(ref); + }, [set, ref]); + return null; +} + +describe('', () => { + it('renders empty state when nothing selected', () => { + render( + + + , + ); + expect(screen.getByText(/Click an agent, tool, or message/i)).toBeInTheDocument(); + }); + + it('renders agent details when kind=agent', () => { + render( + + + + , + ); + expect(screen.getByText(/intake/)).toBeInTheDocument(); + expect(screen.getByText(/responsive/)).toBeInTheDocument(); + expect(screen.getByText(/gpt/)).toBeInTheDocument(); + }); + + it('renders tool call details when kind=tool_call', () => { + render( + + + + , + ); + expect(screen.getByText(/obs:get_logs/)).toBeInTheDocument(); + expect(screen.getByText(/low/i)).toBeInTheDocument(); + }); + + it('falls back to "not found" when selected id has no match', () => { + render( + + + + , + ); + expect(screen.getByText(/not found/i)).toBeInTheDocument(); + }); +}); From 728e15aaf1e932ae6997d483ca9e954f0d42e35a Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:23:49 +0000 Subject: [PATCH 2/6] feat(web): monitor (cross-session mini-tiles) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 Task 45. Props-driven panel that renders compact mini-tiles for every session except the active one, giving the operator at-a-glance cross-session awareness without leaving the current canvas. Tile layout (~90x72): id + color-coded state caps · label (truncated) · active_agent. Wrapped in with live count badge. Click a tile → onSelect(sid). Files: - web/src/monitors/OtherSessionsPanel.tsx - web/tests/component/OtherSessionsPanel.test.tsx (6 tests, all green) Verification: vitest run (136/136 pass) + tsc -b (clean). --- web/src/monitors/OtherSessionsPanel.tsx | 97 +++++++++++++++++++ .../component/OtherSessionsPanel.test.tsx | 50 ++++++++++ 2 files changed, 147 insertions(+) create mode 100644 web/src/monitors/OtherSessionsPanel.tsx create mode 100644 web/tests/component/OtherSessionsPanel.test.tsx diff --git a/web/src/monitors/OtherSessionsPanel.tsx b/web/src/monitors/OtherSessionsPanel.tsx new file mode 100644 index 0000000..849bd04 --- /dev/null +++ b/web/src/monitors/OtherSessionsPanel.tsx @@ -0,0 +1,97 @@ +import type { CSSProperties } from 'react'; +import type { SessionSummary } from '@/state/useSessionList'; +import { Monitor } from './Monitor'; + +interface Props { + sessions: SessionSummary[]; + activeSid: string | null; + onSelect: (sid: string) => void; +} + +const tile: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 2, + padding: '6px 8px', + border: '1px solid var(--hair)', + background: 'var(--bg-elev)', + cursor: 'pointer', + fontFamily: 'var(--ff-sans)', + marginBottom: 4, +}; + +const stateColor: Record = { + in_progress: 'var(--acc)', + awaiting_input: 'var(--warn)', + resolved: 'var(--good)', + error: 'var(--danger)', + stopped: 'var(--ink-3)', + matched: 'var(--good)', + escalated: 'var(--danger)', + new: 'var(--acc)', +}; + +function shortStatus(s: string): string { + return s.replace('_', ' ').slice(0, 6).toUpperCase(); +} + +export function OtherSessionsPanel({ sessions, activeSid, onSelect }: Props) { + const others = sessions.filter((s) => s.id !== activeSid); + return ( + + {others.length === 0 ? ( +
+ Only this session is active right now. +
+ ) : ( + others.map((s) => ( +
onSelect(s.id)} style={tile}> +
+ + {s.id} + + + {shortStatus(s.status)} + +
+
+ {s.label ?? '—'} +
+ {s.active_agent && ( +
+ {s.active_agent} +
+ )} +
+ )) + )} +
+ ); +} diff --git a/web/tests/component/OtherSessionsPanel.test.tsx b/web/tests/component/OtherSessionsPanel.test.tsx new file mode 100644 index 0000000..7b677cf --- /dev/null +++ b/web/tests/component/OtherSessionsPanel.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { OtherSessionsPanel } from '@/monitors/OtherSessionsPanel'; +import type { SessionSummary } from '@/state/useSessionList'; + +const sessions: SessionSummary[] = [ + { id: 'SES-1', status: 'in_progress', label: 'A', created_at: 't0', updated_at: 't1', active_agent: 'triage' }, + { id: 'SES-2', status: 'awaiting_input', label: 'B paused', created_at: 't0', updated_at: 't2' }, + { id: 'SES-3', status: 'resolved', label: 'C done', created_at: 't0', updated_at: 't3' }, +]; + +describe('', () => { + it('excludes the active session from the list', () => { + render( {}} />); + expect(screen.queryByText('SES-2')).not.toBeInTheDocument(); + expect(screen.getByText('SES-1')).toBeInTheDocument(); + expect(screen.getByText('SES-3')).toBeInTheDocument(); + }); + + it('renders all sessions when activeSid is null', () => { + render( {}} />); + expect(screen.getByText('SES-1')).toBeInTheDocument(); + expect(screen.getByText('SES-2')).toBeInTheDocument(); + expect(screen.getByText('SES-3')).toBeInTheDocument(); + }); + + it('shows count in the header', () => { + render( {}} />); + // count = 2 (excluding active) + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('shows label and active agent', () => { + render( {}} />); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText(/triage/)).toBeInTheDocument(); + }); + + it('shows empty message when no other sessions', () => { + render( {}} />); + expect(screen.getByText(/Only this session is active/i)).toBeInTheDocument(); + }); + + it('calls onSelect on tile click', () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText('SES-1').closest('[data-tile]')!); + expect(onSelect).toHaveBeenCalledWith('SES-1'); + }); +}); From ced573818ea31aab075b4123b2f4d8efe5bb7f21 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:23:30 +0000 Subject: [PATCH 3/6] feat(web): monitor Phase 5 Task 46. Props-driven panel showing pending approvals across sessions. Each row: id + label + age since updated_at. Click row -> onSelect(sid). Wrapped in with count badge and empty-state copy. Tests: 4 (count, items, empty-state, click). Co-Authored-By: Claude Opus 4.7 --- web/src/monitors/ApprovalsQueuePanel.tsx | 58 +++++++++++++++++++ .../component/ApprovalsQueuePanel.test.tsx | 35 +++++++++++ 2 files changed, 93 insertions(+) create mode 100644 web/src/monitors/ApprovalsQueuePanel.tsx create mode 100644 web/tests/component/ApprovalsQueuePanel.test.tsx diff --git a/web/src/monitors/ApprovalsQueuePanel.tsx b/web/src/monitors/ApprovalsQueuePanel.tsx new file mode 100644 index 0000000..5ee9aaf --- /dev/null +++ b/web/src/monitors/ApprovalsQueuePanel.tsx @@ -0,0 +1,58 @@ +import type { CSSProperties } from 'react'; +import type { SessionSummary } from '@/state/useSessionList'; +import { Monitor } from './Monitor'; + +interface Props { + queue: SessionSummary[]; + onSelect: (sid: string) => void; +} + +const row: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 8px', + borderBottom: '1px solid var(--hair)', + cursor: 'pointer', + fontFamily: 'var(--ff-sans)', +}; + +function ageStr(iso: string): string { + const t = new Date(iso).getTime(); + if (isNaN(t)) return '—'; + const sec = Math.max(0, Math.floor((Date.now() - t) / 1000)); + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec / 60)}m`; + return `${Math.floor(sec / 3600)}h`; +} + +export function ApprovalsQueuePanel({ queue, onSelect }: Props) { + return ( + + {queue.length === 0 ? ( +
No approvals waiting.
+ ) : ( + queue.map((s) => ( +
onSelect(s.id)} style={row}> + {s.id} + + {s.label ?? '—'} + + + {ageStr(s.updated_at)} + +
+ )) + )} +
+ ); +} diff --git a/web/tests/component/ApprovalsQueuePanel.test.tsx b/web/tests/component/ApprovalsQueuePanel.test.tsx new file mode 100644 index 0000000..0910558 --- /dev/null +++ b/web/tests/component/ApprovalsQueuePanel.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { ApprovalsQueuePanel } from '@/monitors/ApprovalsQueuePanel'; +import type { SessionSummary } from '@/state/useSessionList'; + +const queue: SessionSummary[] = [ + { id: 'SES-3', status: 'awaiting_input', label: 'oldest', created_at: 't0', updated_at: '2026-05-15T14:00:00Z' }, + { id: 'SES-2', status: 'awaiting_input', label: 'newer', created_at: 't0', updated_at: '2026-05-15T14:05:00Z' }, +]; + +describe('', () => { + it('renders the count in header', () => { + render( {}} />); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('renders each queue item with id + label', () => { + render( {}} />); + expect(screen.getByText('SES-3')).toBeInTheDocument(); + expect(screen.getByText('oldest')).toBeInTheDocument(); + expect(screen.getByText('SES-2')).toBeInTheDocument(); + }); + + it('shows empty state when queue=[]', () => { + render( {}} />); + expect(screen.getByText(/No approvals waiting/i)).toBeInTheDocument(); + }); + + it('calls onSelect with sid on row click', () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText('SES-3').closest('[data-row]')!); + expect(onSelect).toHaveBeenCalledWith('SES-3'); + }); +}); From 73f59966a59e77caa0518d6accff9a2f6b6b37b6 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:23:57 +0000 Subject: [PATCH 4/6] feat(web): monitor (react-query per-session lessons) Phase 5 Task 47. Adds wrapping , collapsed by default. Uses @tanstack/react-query to fetch GET /api/v1/sessions/{sid}/lessons with 60s staleTime; query is disabled when sessionId is null so the header always renders but no fetch fires. Lesson type is local to the file (loose backend shape). 3 component tests cover: header-only render when sid=null, fetch + render of titles after expand, and the exact URL/method shape. --- web/src/monitors/LessonsPanel.tsx | 73 +++++++++++++++++++++++ web/tests/component/LessonsPanel.test.tsx | 55 +++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 web/src/monitors/LessonsPanel.tsx create mode 100644 web/tests/component/LessonsPanel.test.tsx diff --git a/web/src/monitors/LessonsPanel.tsx b/web/src/monitors/LessonsPanel.tsx new file mode 100644 index 0000000..7814ea5 --- /dev/null +++ b/web/src/monitors/LessonsPanel.tsx @@ -0,0 +1,73 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '@/api/client'; +import { Monitor } from './Monitor'; + +interface Lesson { + id?: string; + title?: string; + summary?: string; + agent?: string; + [k: string]: unknown; +} + +interface Props { + sessionId: string | null; +} + +export function LessonsPanel({ sessionId }: Props) { + const { data } = useQuery({ + queryKey: ['lessons', sessionId], + queryFn: () => apiFetch(`/sessions/${sessionId}/lessons`), + enabled: sessionId !== null, + staleTime: 60_000, + }); + + return ( + + {!data || data.length === 0 ? ( +
+ No lessons relevant to this session yet. +
+ ) : ( + data.map((l, i) => ( +
+
+ {l.title ?? '—'} +
+ {l.summary && ( +
+ {l.summary} +
+ )} + {l.agent && ( +
+ {l.agent} +
+ )} +
+ )) + )} +
+ ); +} diff --git a/web/tests/component/LessonsPanel.test.tsx b/web/tests/component/LessonsPanel.test.tsx new file mode 100644 index 0000000..599fc8e --- /dev/null +++ b/web/tests/component/LessonsPanel.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '../_helpers/render'; +import { LessonsPanel } from '@/monitors/LessonsPanel'; + +const lessons = [ + { + id: 'L1', + title: 'Always check deploy diff', + summary: 'When p99 spikes within 10m of deploy, the deploy is the prime suspect.', + agent: 'triage', + }, + { + id: 'L2', + title: 'Restart only with rationale', + summary: 'Restart requires documented rationale per policy.', + agent: 'investigate', + }, +]; + +describe('', () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(lessons), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + + it('renders nothing in the body when sessionId is null', () => { + render(); + // header always renders + expect(screen.getByText(/Lessons/i)).toBeInTheDocument(); + }); + + it('fetches lessons and renders titles', async () => { + render(); + // header is collapsed by default — click to expand + const header = screen.getByText(/Lessons/i); + header.click(); + await waitFor(() => expect(screen.getByText('Always check deploy diff')).toBeInTheDocument()); + expect(screen.getByText('Restart only with rationale')).toBeInTheDocument(); + }); + + it('hits /api/v1/sessions/{sid}/lessons', async () => { + render(); + // expand + screen.getByText(/Lessons/i).click(); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/sessions/SES-42/lessons', + expect.objectContaining({ method: 'GET' }), + ); + }); +}); From ef71ddd20fd7607b6209468564caad6f61409369 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:23:57 +0000 Subject: [PATCH 5/6] feat(web): monitor (react-query cache-once for /tools) Phase 5 Task 48 of React UI v2.0. Adds a collapsed-by-default monitor listing the orchestrator's tool catalog with name, description, and risk badge. Uses react-query with staleTime/gcTime=Infinity to fetch GET /api/v1/tools once per session. --- web/src/monitors/ToolsPanel.tsx | 50 +++++++++++++++++++++++++ web/tests/component/ToolsPanel.test.tsx | 38 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 web/src/monitors/ToolsPanel.tsx create mode 100644 web/tests/component/ToolsPanel.test.tsx diff --git a/web/src/monitors/ToolsPanel.tsx b/web/src/monitors/ToolsPanel.tsx new file mode 100644 index 0000000..1dbcb1e --- /dev/null +++ b/web/src/monitors/ToolsPanel.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiFetch } from '@/api/client'; +import { Monitor } from './Monitor'; + +interface Tool { + name: string; + description?: string; + risk?: 'low' | 'medium' | 'high'; +} + +const riskColor: Record = { + low: 'var(--good)', + medium: 'var(--warn)', + high: 'var(--danger)', +}; + +export function ToolsPanel() { + const { data } = useQuery({ + queryKey: ['tools'], + queryFn: () => apiFetch('/tools'), + staleTime: Infinity, + gcTime: Infinity, + }); + + return ( + + {!data || data.length === 0 ? ( +
No tools registered.
+ ) : ( + data.map((t) => ( +
+
+ {t.name} + {t.risk && ( + + {t.risk.toUpperCase()} + + )} +
+ {t.description && ( + + {t.description} + + )} +
+ )) + )} +
+ ); +} diff --git a/web/tests/component/ToolsPanel.test.tsx b/web/tests/component/ToolsPanel.test.tsx new file mode 100644 index 0000000..9e2ebfb --- /dev/null +++ b/web/tests/component/ToolsPanel.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '../_helpers/render'; +import { ToolsPanel } from '@/monitors/ToolsPanel'; + +const tools = [ + { name: 'obs:get_logs', description: 'Fetch service logs', risk: 'low' }, + { name: 'rem:restart_service', description: 'Restart a service', risk: 'high' }, +]; + +describe('', () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(tools), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + }); + + it('renders header always', () => { + render(); + expect(screen.getByText(/Tool Catalog/i)).toBeInTheDocument(); + }); + + it('fetches tools and renders names when expanded', async () => { + render(); + screen.getByText(/Tool Catalog/i).click(); + await waitFor(() => expect(screen.getByText('obs:get_logs')).toBeInTheDocument()); + expect(screen.getByText('rem:restart_service')).toBeInTheDocument(); + }); + + it('hits /api/v1/tools', async () => { + render(); + screen.getByText(/Tool Catalog/i).click(); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + expect(global.fetch).toHaveBeenCalledWith( + '/api/v1/tools', + expect.objectContaining({ method: 'GET' }), + ); + }); +}); From d299d53a70582403cde567062690d1e8e3dfcb14 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 06:24:15 +0000 Subject: [PATCH 6/6] feat(web): monitor (30s polling of /health) --- web/src/monitors/HealthPanel.tsx | 74 ++++++++++++++++++++++++ web/tests/component/HealthPanel.test.tsx | 48 +++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 web/src/monitors/HealthPanel.tsx create mode 100644 web/tests/component/HealthPanel.test.tsx diff --git a/web/src/monitors/HealthPanel.tsx b/web/src/monitors/HealthPanel.tsx new file mode 100644 index 0000000..ee10802 --- /dev/null +++ b/web/src/monitors/HealthPanel.tsx @@ -0,0 +1,74 @@ +import { useQuery } from '@tanstack/react-query'; +import { Monitor } from './Monitor'; + +interface Health { + status: 'ok' | 'degraded' | 'down' | string; + uptime_seconds?: number; +} + +const dotColor: Record = { + ok: 'var(--good)', + degraded: 'var(--warn)', + down: 'var(--danger)', +}; + +function uptimeStr(sec: number | undefined): string | null { + if (sec === undefined || sec === null) return null; + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec / 60)}m`; + if (sec < 86400) return `${Math.floor(sec / 3600)}h`; + return `${Math.floor(sec / 86400)}d`; +} + +export function HealthPanel() { + const { data, dataUpdatedAt } = useQuery({ + queryKey: ['health'], + // /health is at the server root (NOT /api/v1) — bypass apiFetch's prefix. + queryFn: async () => { + const r = await fetch('/health', { method: 'GET' }); + if (!r.ok) throw new Error('health check failed'); + return (await r.json()) as Health; + }, + refetchInterval: 30_000, + }); + + const status = data?.status ?? 'unknown'; + const uptime = uptimeStr(data?.uptime_seconds); + const lastPoll = dataUpdatedAt + ? new Date(dataUpdatedAt).toLocaleTimeString(undefined, { + hour12: false, + }) + : null; + + return ( + +
+ + + {status} + +
+ {uptime !== null && ( +
+ uptime {uptime} +
+ )} + {lastPoll !== null && ( +
+ last poll {lastPoll} +
+ )} +
+ ); +} diff --git a/web/tests/component/HealthPanel.test.tsx b/web/tests/component/HealthPanel.test.tsx new file mode 100644 index 0000000..8f4d8ae --- /dev/null +++ b/web/tests/component/HealthPanel.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '../_helpers/render'; +import { HealthPanel } from '@/monitors/HealthPanel'; + +describe('', () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: 'ok', uptime_seconds: 3600 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + + it('renders header', () => { + render(); + expect(screen.getByText(/System Health/i)).toBeInTheDocument(); + }); + + it('shows status label after fetch when expanded', async () => { + render(); + screen.getByText(/System Health/i).click(); + await waitFor(() => expect(screen.getByText(/ok/i)).toBeInTheDocument()); + }); + + it('hits /health (NOT /api/v1/health)', async () => { + render(); + screen.getByText(/System Health/i).click(); + await waitFor(() => expect(global.fetch).toHaveBeenCalled()); + expect(global.fetch).toHaveBeenCalledWith( + '/health', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('shows degraded color when status=degraded', async () => { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: 'degraded' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const { container } = render(); + screen.getByText(/System Health/i).click(); + await waitFor(() => expect(screen.getByText(/degraded/i)).toBeInTheDocument()); + expect(container.querySelector('[data-health-dot]')).toHaveAttribute('data-status', 'degraded'); + }); +});