From 87d0ef8c05dfbacb99247312276d277ba7faf9fb Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 19:13:33 +0900 Subject: [PATCH] Extract dashboard overview route (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering * Extract dashboard simple route table * Extract dashboard settings routes * Extract dashboard service routes * Extract dashboard room timeline routes * Extract dashboard task routes * Extract dashboard overview route --- src/web-dashboard-overview-routes.test.ts | 156 ++++++++++++++++++++++ src/web-dashboard-overview-routes.ts | 63 +++++++++ src/web-dashboard-server.ts | 32 ++--- 3 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 src/web-dashboard-overview-routes.test.ts create mode 100644 src/web-dashboard-overview-routes.ts diff --git a/src/web-dashboard-overview-routes.test.ts b/src/web-dashboard-overview-routes.test.ts new file mode 100644 index 0000000..f516fdb --- /dev/null +++ b/src/web-dashboard-overview-routes.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import type { StatusSnapshot } from './status-dashboard.js'; +import type { PairedTask, ScheduledTask } from './types.js'; +import { + handleOverviewRoute, + type OverviewRouteDependencies, +} from './web-dashboard-overview-routes.js'; +import type { ServiceRestartRecord } from './web-dashboard-service-routes.js'; + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { + 'content-type': 'application/json; charset=utf-8', + ...(init?.headers as Record | undefined), + }, + }); +} + +function makeTask(overrides: Partial): ScheduledTask { + return { + id: 'task-1', + group_folder: 'ops', + chat_jid: 'dc:ops', + agent_type: null, + status_message_id: null, + status_started_at: null, + prompt: 'run task', + schedule_type: 'once', + schedule_value: '2026-04-26T05:20:00.000Z', + context_mode: 'isolated', + next_run: '2026-04-26T05:20:00.000Z', + last_run: null, + last_result: null, + status: 'active', + suspended_until: null, + created_at: '2026-04-26T05:00:00.000Z', + ...overrides, + }; +} + +function makePairedTask(overrides: Partial): PairedTask { + return { + id: 'paired-1', + chat_jid: 'dc:ops', + group_folder: 'ops', + owner_service_id: 'codex-main', + reviewer_service_id: 'claude-reviewer', + owner_agent_type: 'codex', + reviewer_agent_type: 'claude-code', + arbiter_agent_type: 'codex', + title: 'Review dashboard', + source_ref: null, + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: 0, + task_done_then_user_reopen_count: 0, + empty_step_done_streak: 0, + status: 'review_ready', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-04-26T04:00:00.000Z', + updated_at: '2026-04-26T05:00:00.000Z', + ...overrides, + }; +} + +function route( + pathname: string, + overrides: Partial = {}, +): Response | null { + return handleOverviewRoute({ + url: new URL(`http://localhost${pathname}`), + jsonResponse, + loadPairedTasks: () => [], + loadTasks: () => [], + readSnapshots: () => [], + recentServiceRestarts: [], + statusMaxAgeMs: 1234, + now: () => '2026-04-26T05:10:00.000Z', + ...overrides, + }); +} + +describe('web dashboard overview route', () => { + it('serves overview JSON with operation restart records', async () => { + const recentServiceRestarts: ServiceRestartRecord[] = [ + { + id: 'web-restart-1', + target: 'stack', + requestedAt: '2026-04-26T05:00:00.000Z', + completedAt: '2026-04-26T05:00:01.000Z', + status: 'success', + services: ['ejclaw'], + }, + ]; + const response = route('/api/overview', { + loadTasks: () => [makeTask({ id: 'task-active' })], + recentServiceRestarts, + }); + + expect(response?.status).toBe(200); + await expect(response?.json()).resolves.toMatchObject({ + generatedAt: '2026-04-26T05:10:00.000Z', + tasks: { total: 1, active: 1 }, + operations: { + serviceRestarts: [ + { + id: 'web-restart-1', + status: 'success', + services: ['ejclaw'], + }, + ], + }, + }); + }); + + it('filters dismissed inbox items and falls through for other paths', async () => { + const snapshots: StatusSnapshot[] = [ + { + serviceId: 'codex-main', + agentType: 'codex', + assistantName: 'Codex', + updatedAt: '2026-04-26T05:00:00.000Z', + entries: [ + { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex', + status: 'waiting', + elapsedMs: 2500, + pendingMessages: true, + pendingTasks: 1, + }, + ], + }, + ]; + const overview = route('/api/overview', { + loadPairedTasks: () => [makePairedTask({ id: 'review-1' })], + readSnapshots: () => snapshots, + isInboxItemDismissed: () => true, + }); + + expect(overview?.status).toBe(200); + await expect(overview?.json()).resolves.toMatchObject({ inbox: [] }); + + const unmatched = route('/api/tasks'); + expect(unmatched).toBeNull(); + }); +}); diff --git a/src/web-dashboard-overview-routes.ts b/src/web-dashboard-overview-routes.ts new file mode 100644 index 0000000..354ae51 --- /dev/null +++ b/src/web-dashboard-overview-routes.ts @@ -0,0 +1,63 @@ +import type { StatusSnapshot } from './status-dashboard.js'; +import type { PairedTask, ScheduledTask } from './types.js'; +import { buildWebDashboardOverview } from './web-dashboard-data.js'; +import type { ServiceRestartRecord } from './web-dashboard-service-routes.js'; + +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + +type DismissableInboxItem = { + id: string; + lastOccurredAt: string; +}; + +export interface OverviewRouteDependencies { + isInboxItemDismissed?: (item: DismissableInboxItem) => boolean; + loadPairedTasks: () => PairedTask[]; + loadTasks: () => ScheduledTask[]; + readSnapshots: (maxAgeMs: number) => StatusSnapshot[]; + recentServiceRestarts: ServiceRestartRecord[]; + statusMaxAgeMs: number; + now?: () => string; +} + +interface OverviewRouteContext extends OverviewRouteDependencies { + url: URL; + jsonResponse: JsonResponse; +} + +export function handleOverviewRoute({ + isInboxItemDismissed, + jsonResponse, + loadPairedTasks, + loadTasks, + now, + readSnapshots, + recentServiceRestarts, + statusMaxAgeMs, + url, +}: OverviewRouteContext): Response | null { + if (url.pathname !== '/api/overview') return null; + + const snapshots = readSnapshots(statusMaxAgeMs); + const tasks = loadTasks(); + const pairedTasks = loadPairedTasks(); + const overview = buildWebDashboardOverview({ + now: now?.(), + snapshots, + tasks, + pairedTasks, + }); + return jsonResponse({ + ...overview, + operations: { + serviceRestarts: recentServiceRestarts, + }, + inbox: isInboxItemDismissed + ? overview.inbox.filter((item) => !isInboxItemDismissed(item)) + : overview.inbox, + }); +} diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index ad69677..112326e 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -38,7 +38,7 @@ import type { RegisteredGroup, ScheduledTask, } from './types.js'; -import { buildWebDashboardOverview } from './web-dashboard-data.js'; +import { handleOverviewRoute } from './web-dashboard-overview-routes.js'; import { handleRoomTimelineRoute, startRoomsTimelineCacheRefresh, @@ -822,24 +822,18 @@ export function createWebDashboardHandler( }); if (simpleGetRoute) return simpleGetRoute; - if (url.pathname === '/api/overview') { - const snapshots = readSnapshots(statusMaxAgeMs); - const tasks = loadTasks(); - const pairedTasks = loadPairedTasks(); - const overview = buildWebDashboardOverview({ - now: opts.now?.(), - snapshots, - tasks, - pairedTasks, - }); - return jsonResponse({ - ...overview, - operations: { - serviceRestarts: recentServiceRestarts, - }, - inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)), - }); - } + const overviewRoute = handleOverviewRoute({ + url, + jsonResponse, + isInboxItemDismissed, + loadPairedTasks, + loadTasks, + readSnapshots, + recentServiceRestarts, + statusMaxAgeMs, + now: opts.now, + }); + if (overviewRoute) return overviewRoute; if (url.pathname.startsWith('/api/')) { return jsonResponse({ error: 'Not found' }, { status: 404 });