From 74cb43b1d87c49e25c590afc6adf8c57f5c04fd6 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 16:18:54 +0900 Subject: [PATCH] Extract dashboard ServicePanel component (#66) 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 --- apps/dashboard/src/App.tsx | 222 ++---------------------- apps/dashboard/src/ServicePanel.test.ts | 150 ++++++++++++++++ apps/dashboard/src/ServicePanel.tsx | 214 +++++++++++++++++++++++ 3 files changed, 376 insertions(+), 210 deletions(-) create mode 100644 apps/dashboard/src/ServicePanel.test.ts create mode 100644 apps/dashboard/src/ServicePanel.tsx diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 0db7305..84a2903 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -52,9 +52,9 @@ import { import { formatDate, statusLabel } from './dashboardHelpers'; import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel'; import { RoomBoardV2 } from './RoomBoardV2'; +import { ServicePanel, type ServiceActionKey } from './ServicePanel'; import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel'; import { UsagePanel } from './UsagePanel'; -import { EmptyState } from './EmptyState'; import { ParsedBody } from './ParsedBody'; import './styles.css'; @@ -64,8 +64,6 @@ interface DashboardState { tasks: DashboardTask[]; } -type ServiceActionKey = 'stack:restart'; -type HealthLevel = 'ok' | 'stale' | 'down'; type FreshnessLevel = DashboardFreshness; type BeforeInstallPromptEvent = Event & { @@ -76,8 +74,6 @@ type BeforeInstallPromptEvent = Event & { const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2'; const DEFAULT_VIEW: DashboardView = 'rooms'; -const HEALTH_STALE_MS = 5 * 60_000; -const HEALTH_DOWN_MS = 15 * 60_000; const DASHBOARD_STALE_MS = 75_000; function makeClientRequestId(): string { @@ -122,6 +118,12 @@ function readInitialLocale(): Locale { return 'en'; } +function persistNickname(trimmed: string): void { + if (typeof window === 'undefined') return; + if (trimmed) window.localStorage.setItem('ejclaw-nickname', trimmed); + else window.localStorage.removeItem('ejclaw-nickname'); +} + function humanizeError(raw: string, t: Messages): string { const lower = raw.toLowerCase(); if (/abort|timeout|timed out/.test(lower)) return t.error.timeout; @@ -247,27 +249,6 @@ function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { ); } -function serviceAgeMs( - service: DashboardOverview['services'][number], - generatedAt: string, -): number | null { - const updated = new Date(service.updatedAt).getTime(); - const now = new Date(generatedAt).getTime(); - if (Number.isNaN(updated) || Number.isNaN(now)) return null; - return Math.max(0, now - updated); -} - -function serviceHealthLevel( - service: DashboardOverview['services'][number], - generatedAt: string, -): HealthLevel { - const age = serviceAgeMs(service, generatedAt); - if (age === null) return 'stale'; - if (age >= HEALTH_DOWN_MS) return 'down'; - if (age >= HEALTH_STALE_MS) return 'stale'; - return 'ok'; -} - function LanguageSelector({ locale, onLocaleChange, @@ -1007,184 +988,6 @@ function ControlRail({ ); } -function HealthPanel({ - data, - locale, - onRestartStack, - serviceActionKey, - t, -}: { - data: DashboardState; - locale: Locale; - onRestartStack: () => void; - serviceActionKey: ServiceActionKey | null; - t: Messages; -}) { - const services = data.overview.services; - const restarts = data.overview.operations?.serviceRestarts ?? []; - const serviceLevels = services.map((service) => ({ - service, - level: serviceHealthLevel(service, data.overview.generatedAt), - age: serviceAgeMs(service, data.overview.generatedAt), - })); - const down = serviceLevels.filter((item) => item.level === 'down').length; - const stale = serviceLevels.filter((item) => item.level === 'stale').length; - const queue = data.snapshots.reduce( - (acc, snapshot) => { - for (const entry of snapshot.entries) { - acc.pendingTasks += entry.pendingTasks; - if (entry.pendingMessages) acc.pendingMessageRooms += 1; - } - return acc; - }, - { pendingTasks: 0, pendingMessageRooms: 0 }, - ); - const ciFailures = data.overview.inbox.reduce( - (count, item) => - item.kind === 'ci-failure' ? count + item.occurrences : count, - 0, - ); - const healthLevel: HealthLevel = - down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok'; - const affectedServices = serviceLevels.filter((item) => item.level !== 'ok'); - - return ( -
-
- {t.health.system} - {t.health.levels[healthLevel]} -
- -
-
- {t.health.services} - - {services.length - stale - down}/{services.length} - - {t.health.fresh} -
-
- {t.health.stale} - {stale + down} - - {down} {t.health.levels.down} - -
-
- {t.health.queue} - {queue.pendingTasks} - - {queue.pendingMessageRooms} {t.control.pendingRooms} - -
-
- {t.health.ciFailures} - {ciFailures} -
-
- -
-
- {t.health.restart} - {t.health.restartStack} - {t.health.restartHint} -
- -
- - {restarts.length > 0 ? ( -
- - {t.health.restartLog} - {restarts.length} - -
- {restarts.map((restart) => { - const pill = - restart.status === 'success' - ? 'ok' - : restart.status === 'failed' - ? 'error' - : 'stale'; - return ( -
-
- {t.health.restartTarget} - {restart.target} -
- - {restart.status} - -
- {t.health.restartRequested} - {formatDate(restart.requestedAt, locale)} -
-
- {t.health.restartServices} - - {restart.services.length > 0 - ? restart.services.join(', ') - : '-'} - -
- {restart.error ? ( -

{restart.error}

- ) : null} -
- ); - })} -
-
- ) : null} - - {services.length === 0 ? ( - {t.service.empty} - ) : affectedServices.length === 0 ? null : ( -
- - {t.health.affectedServices} - {affectedServices.length} - -
- {affectedServices.map(({ service, level, age }) => ( -
-
- {service.assistantName || service.serviceId} -
- - {t.health.levels[level]} - -
- {t.service.updated} - {formatDate(service.updatedAt, locale)} - {formatDuration(age, t)} -
-
- {t.service.rooms} - - {service.activeRooms}/{service.totalRooms} - -
-
- ))} -
-
- )} -
- ); -} - function DashboardErrorCard({ error, onRetry, @@ -1252,10 +1055,7 @@ function App() { function setNickname(next: string) { const trimmed = next.trim().slice(0, 32); setNicknameState(trimmed); - if (typeof window !== 'undefined') { - if (trimmed) window.localStorage.setItem('ejclaw-nickname', trimmed); - else window.localStorage.removeItem('ejclaw-nickname'); - } + persistNickname(trimmed); } const t = messages[locale]; const secureContext = @@ -1714,11 +1514,13 @@ function App() {

{t.panels.health}

{t.panels.healthSignals} - void handleServiceRestart()} + overview={data.overview} serviceActionKey={serviceActionKey} + snapshots={data.snapshots} t={t} /> diff --git a/apps/dashboard/src/ServicePanel.test.ts b/apps/dashboard/src/ServicePanel.test.ts new file mode 100644 index 0000000..57d1eac --- /dev/null +++ b/apps/dashboard/src/ServicePanel.test.ts @@ -0,0 +1,150 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import type { DashboardOverview, StatusSnapshot } from './api'; +import { messages, type Messages } from './i18n'; +import { ServicePanel, type ServicePanelProps } from './ServicePanel'; + +const t = messages.en; + +function formatDuration(value: number | null, t: Messages): string { + if (value === null) return '-'; + const seconds = Math.floor(value / 1000); + if (seconds < 60) return `${seconds}${t.units.second}`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}${t.units.minute}`; + const hours = Math.floor(minutes / 60); + return `${hours}${t.units.hour} ${minutes % 60}${t.units.minute}`; +} + +function overview( + services: DashboardOverview['services'], + overrides: Partial = {}, +): DashboardOverview { + return { + generatedAt: '2026-04-28T04:15:00.000Z', + inbox: [], + operations: { serviceRestarts: [] }, + rooms: { active: 0, inactive: 0, total: 0, waiting: 0 }, + services, + tasks: { + active: 0, + completed: 0, + paused: 0, + total: 0, + watchers: { active: 0, completed: 0, paused: 0 }, + }, + usage: { fetchedAt: '2026-04-28T04:15:00.000Z', rows: [] }, + ...overrides, + }; +} + +const snapshots: StatusSnapshot[] = [ + { + agentType: 'codex', + assistantName: 'owner', + entries: [ + { + agentType: 'codex', + elapsedMs: null, + folder: 'eyejokerdb', + jid: 'room-1', + name: 'eyejokerdb-main', + pendingMessages: true, + pendingTasks: 3, + status: 'waiting', + }, + ], + serviceId: 'svc-owner', + updatedAt: '2026-04-28T04:14:00.000Z', + }, +]; + +const baseProps: ServicePanelProps = { + formatDuration, + locale: 'en', + onRestartStack: () => {}, + overview: overview( + [ + { + activeRooms: 2, + agentType: 'codex', + assistantName: 'owner', + serviceId: 'svc-owner', + totalRooms: 3, + updatedAt: '2026-04-28T04:14:00.000Z', + }, + { + activeRooms: 0, + agentType: 'claude', + assistantName: 'reviewer', + serviceId: 'svc-reviewer', + totalRooms: 1, + updatedAt: '2026-04-28T04:08:00.000Z', + }, + ], + { + inbox: [ + { + createdAt: '2026-04-28T04:10:00.000Z', + groupFolder: 'eyejokerdb', + groupKey: 'ci', + id: 'ci-1', + kind: 'ci-failure', + lastOccurredAt: '2026-04-28T04:12:00.000Z', + occurrences: 2, + occurredAt: '2026-04-28T04:10:00.000Z', + severity: 'error', + source: 'status-snapshot', + summary: 'CI failed', + title: 'CI failed', + }, + ], + operations: { + serviceRestarts: [ + { + completedAt: null, + id: 'restart-1', + requestedAt: '2026-04-28T04:13:00.000Z', + services: ['owner', 'reviewer'], + status: 'running', + target: 'stack', + }, + ], + }, + }, + ), + serviceActionKey: null, + snapshots, + t, +}; + +describe('ServicePanel', () => { + it('renders service health, queue signals, and restart log', () => { + const html = renderToStaticMarkup(createElement(ServicePanel, baseProps)); + + expect(html).toContain(t.health.levels.stale); + expect(html).toContain(t.health.ciFailures); + expect(html).toContain('2'); + expect(html).toContain('3'); + expect(html).toContain('reviewer'); + expect(html).toContain(t.health.restartLog); + expect(html).toContain('owner, reviewer'); + }); + + it('renders restart pending state and empty services state', () => { + const html = renderToStaticMarkup( + createElement(ServicePanel, { + ...baseProps, + overview: overview([]), + serviceActionKey: 'stack:restart', + snapshots: [], + }), + ); + + expect(html).toContain(t.health.restarting); + expect(html).toContain('disabled=""'); + expect(html).toContain(t.service.empty); + }); +}); diff --git a/apps/dashboard/src/ServicePanel.tsx b/apps/dashboard/src/ServicePanel.tsx new file mode 100644 index 0000000..63c5c97 --- /dev/null +++ b/apps/dashboard/src/ServicePanel.tsx @@ -0,0 +1,214 @@ +import type { DashboardOverview, StatusSnapshot } from './api'; +import { formatDate } from './dashboardHelpers'; +import { EmptyState } from './EmptyState'; +import type { Locale, Messages } from './i18n'; + +export type ServiceActionKey = 'stack:restart'; + +type HealthLevel = 'ok' | 'stale' | 'down'; +type ServiceRow = DashboardOverview['services'][number]; + +export interface ServicePanelProps { + formatDuration: (value: number | null, t: Messages) => string; + locale: Locale; + onRestartStack: () => void; + overview: DashboardOverview; + serviceActionKey: ServiceActionKey | null; + snapshots: StatusSnapshot[]; + t: Messages; +} + +const HEALTH_STALE_MS = 5 * 60_000; +const HEALTH_DOWN_MS = 15 * 60_000; + +function serviceAgeMs(service: ServiceRow, generatedAt: string): number | null { + const updated = new Date(service.updatedAt).getTime(); + const now = new Date(generatedAt).getTime(); + if (Number.isNaN(updated) || Number.isNaN(now)) return null; + return Math.max(0, now - updated); +} + +function serviceHealthLevel( + service: ServiceRow, + generatedAt: string, +): HealthLevel { + const age = serviceAgeMs(service, generatedAt); + if (age === null) return 'stale'; + if (age >= HEALTH_DOWN_MS) return 'down'; + if (age >= HEALTH_STALE_MS) return 'stale'; + return 'ok'; +} + +export function ServicePanel({ + formatDuration, + locale, + onRestartStack, + overview, + serviceActionKey, + snapshots, + t, +}: ServicePanelProps) { + const services = overview.services; + const restarts = overview.operations?.serviceRestarts ?? []; + const serviceLevels = services.map((service) => ({ + service, + level: serviceHealthLevel(service, overview.generatedAt), + age: serviceAgeMs(service, overview.generatedAt), + })); + const down = serviceLevels.filter((item) => item.level === 'down').length; + const stale = serviceLevels.filter((item) => item.level === 'stale').length; + const queue = snapshots.reduce( + (acc, snapshot) => { + for (const entry of snapshot.entries) { + acc.pendingTasks += entry.pendingTasks; + if (entry.pendingMessages) acc.pendingMessageRooms += 1; + } + return acc; + }, + { pendingTasks: 0, pendingMessageRooms: 0 }, + ); + const ciFailures = overview.inbox.reduce( + (count, item) => + item.kind === 'ci-failure' ? count + item.occurrences : count, + 0, + ); + const healthLevel: HealthLevel = + down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok'; + const affectedServices = serviceLevels.filter((item) => item.level !== 'ok'); + + return ( +
+
+ {t.health.system} + {t.health.levels[healthLevel]} +
+ +
+
+ {t.health.services} + + {services.length - stale - down}/{services.length} + + {t.health.fresh} +
+
+ {t.health.stale} + {stale + down} + + {down} {t.health.levels.down} + +
+
+ {t.health.queue} + {queue.pendingTasks} + + {queue.pendingMessageRooms} {t.control.pendingRooms} + +
+
+ {t.health.ciFailures} + {ciFailures} +
+
+ +
+
+ {t.health.restart} + {t.health.restartStack} + {t.health.restartHint} +
+ +
+ + {restarts.length > 0 ? ( +
+ + {t.health.restartLog} + {restarts.length} + +
+ {restarts.map((restart) => { + const pill = + restart.status === 'success' + ? 'ok' + : restart.status === 'failed' + ? 'error' + : 'stale'; + return ( +
+
+ {t.health.restartTarget} + {restart.target} +
+ + {restart.status} + +
+ {t.health.restartRequested} + {formatDate(restart.requestedAt, locale)} +
+
+ {t.health.restartServices} + + {restart.services.length > 0 + ? restart.services.join(', ') + : '-'} + +
+ {restart.error ? ( +

{restart.error}

+ ) : null} +
+ ); + })} +
+
+ ) : null} + + {services.length === 0 ? ( + {t.service.empty} + ) : affectedServices.length === 0 ? null : ( +
+ + {t.health.affectedServices} + {affectedServices.length} + +
+ {affectedServices.map(({ service, level, age }) => ( +
+
+ {service.assistantName || service.serviceId} +
+ + {t.health.levels[level]} + +
+ {t.service.updated} + {formatDate(service.updatedAt, locale)} + {formatDuration(age, t)} +
+
+ {t.service.rooms} + + {service.activeRooms}/{service.totalRooms} + +
+
+ ))} +
+
+ )} +
+ ); +}