Extract dashboard overview route (#75)

* 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
This commit is contained in:
Eyejoker
2026-04-28 19:13:33 +09:00
committed by GitHub
parent 69aa1c72c2
commit 87d0ef8c05
3 changed files with 232 additions and 19 deletions

View File

@@ -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<string, string> | undefined),
},
});
}
function makeTask(overrides: Partial<ScheduledTask>): 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>): 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<OverviewRouteDependencies> = {},
): 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();
});
});

View File

@@ -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,
});
}

View File

@@ -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 });