From e978cc651730e1bad7711186bea51b579ec5cfab Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 20:13:18 +0900 Subject: [PATCH] remove dashboard health top-level navigation --- apps/dashboard/src/App.tsx | 154 ++------------ apps/dashboard/src/DashboardNav.tsx | 10 +- apps/dashboard/src/ServicePanel.test.ts | 141 ------------- apps/dashboard/src/ServicePanel.tsx | 210 ------------------- apps/dashboard/src/SystemStatusStrip.test.ts | 99 +++++++++ apps/dashboard/src/SystemStatusStrip.tsx | 66 ++++++ apps/dashboard/src/styles.css | 52 +++++ scripts/dashboard-ux.ts | 30 ++- 8 files changed, 259 insertions(+), 503 deletions(-) delete mode 100644 apps/dashboard/src/ServicePanel.test.ts delete mode 100644 apps/dashboard/src/ServicePanel.tsx create mode 100644 apps/dashboard/src/SystemStatusStrip.test.ts create mode 100644 apps/dashboard/src/SystemStatusStrip.tsx diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index b956148..544dc87 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -32,8 +32,8 @@ import { } from './DashboardNav'; import { formatDate, statusLabel } from './dashboardHelpers'; import { RoomBoardV2 } from './RoomBoardV2'; -import { ServicePanel, type ServiceActionKey } from './ServicePanel'; import { SettingsPanel } from './SettingsPanel'; +import { SystemStatusStrip } from './SystemStatusStrip'; import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel'; import { UsagePanel } from './UsagePanel'; import './styles.css'; @@ -65,7 +65,6 @@ function isDashboardView( ): value is DashboardView { return ( value === 'usage' || - value === 'health' || value === 'rooms' || value === 'scheduled' || value === 'settings' @@ -246,106 +245,6 @@ function LoadingSkeleton({ t }: { t: Messages }) { ); } -function ControlRail({ - canInstall, - data, - installed, - locale, - offlineReady, - onInstall, - online, - secureContext, - t, -}: { - canInstall: boolean; - data: DashboardState; - installed: boolean; - locale: Locale; - offlineReady: boolean; - onInstall: () => void; - online: boolean; - secureContext: boolean; - t: Messages; -}) { - 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 freshness = dashboardFreshness(online, data.overview.generatedAt); - const age = dashboardAgeMs(data.overview.generatedAt); - - return ( -
-
- {t.pwa.updated} - {freshnessLabel(freshness, t)} - - {formatDate(data.overview.generatedAt, locale)} - {age === null ? '' : ` · ${formatDuration(age, t)}`} - -
-
- {t.pwa.app} - - {!secureContext - ? t.pwa.secureRequired - : installed - ? t.pwa.installed - : offlineReady - ? t.pwa.ready - : t.pwa.app} - - {canInstall ? ( - - ) : ( - - {!secureContext - ? t.pwa.secureRequired - : offlineReady - ? t.pwa.cached - : t.pwa.online} - - )} -
-
- {t.metrics.rooms} - - {data.overview.rooms.active + data.overview.rooms.waiting}/ - {data.overview.rooms.total} - - {t.control.activeRooms} -
-
- {t.control.queue} - {queue.pendingTasks} - - {queue.pendingMessageRooms} {t.control.pendingRooms} - -
-
- {t.metrics.agents} - {data.overview.services.length} - {t.panels.heartbeat} -
-
- {t.metrics.ciWatchers} - {data.overview.tasks.watchers.active} - - {data.overview.tasks.watchers.paused} {t.status.paused} - -
-
- ); -} - function DashboardErrorCard({ error, onRetry, @@ -388,8 +287,7 @@ function App() { const [taskActionKey, setTaskActionKey] = useState( null, ); - const [serviceActionKey, setServiceActionKey] = - useState(null); + const [serviceRestarting, setServiceRestarting] = useState(false); const [roomMessageKey, setRoomMessageKey] = useState(null); const [selectedRoomJid, setSelectedRoomJid] = useState(null); const { @@ -498,7 +396,7 @@ function App() { return; } - setServiceActionKey('stack:restart'); + setServiceRestarting(true); try { await runServiceAction('stack', 'restart', { requestId: makeClientRequestId(), @@ -507,7 +405,7 @@ function App() { } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { - setServiceActionKey(null); + setServiceRestarting(false); } } @@ -793,43 +691,12 @@ function App() { {data ? (
{activeView === 'usage' ? ( - <> -
-
-

{t.panels.usage}

- {t.panels.usageWindow} -
- -
- void handleInstallApp()} - secureContext={secureContext} - t={t} - /> - - ) : null} - - {activeView === 'health' ? ( -
+
-

{t.panels.health}

- {t.panels.healthSignals} +

{t.panels.usage}

+ {t.panels.usageWindow}
- void handleServiceRestart()} - overview={data.overview} - serviceActionKey={serviceActionKey} - snapshots={data.snapshots} - t={t} - /> +
) : null} @@ -839,6 +706,7 @@ function App() {

{t.panels.rooms}

{t.panels.queue}
+ void handleServiceRestart()} + onRestartStack={() => { + if (!serviceRestarting) void handleServiceRestart(); + }} t={t} /> diff --git a/apps/dashboard/src/DashboardNav.tsx b/apps/dashboard/src/DashboardNav.tsx index 2ab40b6..f0d98cf 100644 --- a/apps/dashboard/src/DashboardNav.tsx +++ b/apps/dashboard/src/DashboardNav.tsx @@ -1,6 +1,5 @@ import { type ReactNode } from 'react'; import { - Activity, Clock, Download, Gauge, @@ -12,12 +11,7 @@ import { import { type DashboardOverview, type StatusSnapshot } from './api'; import { type Messages } from './i18n'; -export type DashboardView = - | 'usage' - | 'health' - | 'rooms' - | 'scheduled' - | 'settings'; +export type DashboardView = 'usage' | 'rooms' | 'scheduled' | 'settings'; export type DashboardFreshness = 'fresh' | 'stale' | 'offline'; interface DashboardNavData { @@ -27,7 +21,6 @@ interface DashboardNavData { const NAV_ICONS: Record = { usage: , - health: , rooms: , scheduled: , settings: , @@ -37,7 +30,6 @@ function navItems(t: Messages) { return [ { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, - { href: '#/health', label: t.nav.health, view: 'health' as const }, { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, { href: '#/settings', label: t.nav.settings, view: 'settings' as const }, ]; diff --git a/apps/dashboard/src/ServicePanel.test.ts b/apps/dashboard/src/ServicePanel.test.ts deleted file mode 100644 index 416fb8a..0000000 --- a/apps/dashboard/src/ServicePanel.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -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', - }, - ], - { - operations: { - serviceRestarts: [ - { - completedAt: null, - id: 'restart-1', - requestedAt: '2026-04-28T04:13:00.000Z', - services: ['owner', 'reviewer'], - status: 'running', - target: 'stack', - }, - ], - }, - tasks: { - active: 0, - completed: 0, - paused: 2, - total: 2, - watchers: { active: 0, completed: 0, paused: 2 }, - }, - }, - ), - 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 deleted file mode 100644 index e87ab5a..0000000 --- a/apps/dashboard/src/ServicePanel.tsx +++ /dev/null @@ -1,210 +0,0 @@ -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.tasks.watchers.paused; - 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} - -
-
- ))} -
-
- )} -
- ); -} diff --git a/apps/dashboard/src/SystemStatusStrip.test.ts b/apps/dashboard/src/SystemStatusStrip.test.ts new file mode 100644 index 0000000..7a48555 --- /dev/null +++ b/apps/dashboard/src/SystemStatusStrip.test.ts @@ -0,0 +1,99 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import type { DashboardOverview } from './api'; +import { messages } from './i18n'; +import { SystemStatusStrip } from './SystemStatusStrip'; + +const t = messages.en; + +function overview( + overrides: Partial = {}, +): DashboardOverview { + return { + generatedAt: '2026-04-28T04:15:00.000Z', + inbox: [], + operations: { serviceRestarts: [] }, + rooms: { active: 0, inactive: 0, total: 0, waiting: 0 }, + services: [ + { + activeRooms: 2, + agentType: 'codex', + assistantName: 'owner', + serviceId: 'svc-owner', + totalRooms: 3, + updatedAt: '2026-04-28T04:14:00.000Z', + }, + ], + 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, + }; +} + +describe('SystemStatusStrip', () => { + it('stays hidden when services and CI watchers are healthy', () => { + const html = renderToStaticMarkup( + createElement(SystemStatusStrip, { overview: overview(), t }), + ); + + expect(html).toBe(''); + }); + + it('surfaces paused CI watchers as a room-level system warning', () => { + const html = renderToStaticMarkup( + createElement(SystemStatusStrip, { + overview: overview({ + tasks: { + active: 0, + completed: 0, + paused: 2, + total: 2, + watchers: { active: 0, completed: 0, paused: 2 }, + }, + }), + t, + }), + ); + + expect(html).toContain('system-status-strip'); + expect(html).toContain(t.health.ciFailures); + expect(html).toContain('2'); + }); + + it('surfaces stale and missing service heartbeat signals', () => { + const staleHtml = renderToStaticMarkup( + createElement(SystemStatusStrip, { + overview: overview({ + services: [ + { + activeRooms: 0, + agentType: 'claude', + assistantName: 'reviewer', + serviceId: 'svc-reviewer', + totalRooms: 1, + updatedAt: '2026-04-28T03:58:00.000Z', + }, + ], + }), + t, + }), + ); + const missingHtml = renderToStaticMarkup( + createElement(SystemStatusStrip, { + overview: overview({ services: [] }), + t, + }), + ); + + expect(staleHtml).toContain(t.health.levels.down); + expect(missingHtml).toContain(t.service.empty); + }); +}); diff --git a/apps/dashboard/src/SystemStatusStrip.tsx b/apps/dashboard/src/SystemStatusStrip.tsx new file mode 100644 index 0000000..408b763 --- /dev/null +++ b/apps/dashboard/src/SystemStatusStrip.tsx @@ -0,0 +1,66 @@ +import type { DashboardOverview } from './api'; +import type { Messages } from './i18n'; + +type HealthLevel = 'stale' | 'down'; +type ServiceRow = DashboardOverview['services'][number]; + +export interface SystemStatusStripProps { + overview: DashboardOverview; + 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 | null { + 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 null; +} + +export function SystemStatusStrip({ overview, t }: SystemStatusStripProps) { + const serviceLevels = overview.services.map((service) => + serviceHealthLevel(service, overview.generatedAt), + ); + const down = serviceLevels.filter((level) => level === 'down').length; + const stale = serviceLevels.filter((level) => level === 'stale').length; + const ciPaused = overview.tasks.watchers.paused; + const hasNoHeartbeat = overview.services.length === 0; + const level: HealthLevel = hasNoHeartbeat || down > 0 ? 'down' : 'stale'; + + if (!hasNoHeartbeat && down === 0 && stale === 0 && ciPaused === 0) { + return null; + } + + const signals: string[] = []; + if (hasNoHeartbeat) signals.push(t.service.empty); + if (down > 0) signals.push(`${down} ${t.health.levels.down}`); + if (stale > 0) signals.push(`${stale} ${t.health.stale}`); + if (ciPaused > 0) signals.push(`${ciPaused} ${t.health.ciFailures}`); + + return ( + + ); +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 7a125d7..00b2069 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -1576,6 +1576,54 @@ dd, padding: 12px; } +.system-status-strip { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 12px 14px; + border: 1px solid rgba(181, 138, 34, 0.32); + border-radius: 16px; + background: + linear-gradient( + 135deg, + rgba(255, 241, 213, 0.86), + rgba(255, 250, 240, 0.94) + ), + var(--bg-card); +} + +.system-status-strip.system-status-down { + border-color: rgba(183, 71, 52, 0.36); + background: + linear-gradient( + 135deg, + rgba(255, 230, 220, 0.88), + rgba(255, 250, 240, 0.96) + ), + var(--bg-card); +} + +.system-status-strip > div { + display: grid; + gap: 2px; + min-width: 0; +} + +.system-status-strip strong { + color: var(--bg-ink); + font-size: var(--text-lg); + line-height: 1; +} + +.system-status-strip p { + margin: 0; + color: var(--muted); + font-size: var(--text-sm); + font-weight: 700; + overflow-wrap: anywhere; +} + .panel-title { display: flex; gap: 10px; @@ -2875,6 +2923,10 @@ progress::-moz-progress-bar { .ops-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .system-status-strip { + grid-template-columns: 1fr; + } } @media (max-width: 640px) { diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index ef8ba40..6064971 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -128,8 +128,9 @@ async function main() { await page.waitForURL(/#\/rooms$/); await assertVisible(page.locator('#rooms .rooms-v2')); assert.equal(await page.locator('a[href="#/inbox"]').count(), 0); + assert.equal(await page.locator('a[href="#/health"]').count(), 0); await assertVisible(page.getByText(/승인|Approval/).first()); - assert.equal(await page.getByText(/CI 실패|CI failure/).count(), 0); + await assertVisible(page.locator('.system-status-strip')); assert.equal( await page.getByRole('button', { name: 'Dismiss' }).count(), 0, @@ -141,6 +142,33 @@ async function main() { }, ); + await runScenario( + 'health route redirects to rooms and degraded state is conditional', + browser, + baseUrl, + async (page, state) => { + state.approvalAction = true; + + await page.goto(new URL('/#/rooms', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + await assertVisible(page.locator('#rooms .rooms-v2')); + assert.equal(await page.locator('.system-status-strip').count(), 0); + + state.ciWatcherFailures = 2; + await page.goto(new URL('/?degraded=1#/health', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + + await page.waitForURL(/#\/rooms$/); + await assertVisible(page.locator('#rooms .rooms-v2')); + await assertVisible(page.locator('.system-status-strip')); + assert.equal(await page.locator('#health').count(), 0); + assert.equal(await page.locator('a[href="#/health"]').count(), 0); + await assertVisible(page.getByText(/CI 실패|CI failure/).first()); + }, + ); + console.log('dashboard:ux passed'); } finally { await browser.close();