Extract dashboard ServicePanel component (#66)

* 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
This commit is contained in:
Eyejoker
2026-04-28 16:18:54 +09:00
committed by GitHub
parent 5902960144
commit 74cb43b1d8
3 changed files with 376 additions and 210 deletions

View File

@@ -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 (
<div className="health-board">
<section className={`health-overview health-${healthLevel}`}>
<span className="eyebrow">{t.health.system}</span>
<strong>{t.health.levels[healthLevel]}</strong>
</section>
<section className="health-signals" aria-label={t.health.signals}>
<div>
<span>{t.health.services}</span>
<strong>
{services.length - stale - down}/{services.length}
</strong>
<small>{t.health.fresh}</small>
</div>
<div>
<span>{t.health.stale}</span>
<strong>{stale + down}</strong>
<small>
{down} {t.health.levels.down}
</small>
</div>
<div>
<span>{t.health.queue}</span>
<strong>{queue.pendingTasks}</strong>
<small>
{queue.pendingMessageRooms} {t.control.pendingRooms}
</small>
</div>
<div>
<span>{t.health.ciFailures}</span>
<strong>{ciFailures}</strong>
</div>
</section>
<section className="health-actions" aria-label={t.health.restart}>
<div>
<span className="eyebrow">{t.health.restart}</span>
<strong>{t.health.restartStack}</strong>
<small>{t.health.restartHint}</small>
</div>
<button
disabled={serviceActionKey === 'stack:restart'}
onClick={onRestartStack}
type="button"
>
{serviceActionKey === 'stack:restart'
? t.health.restarting
: t.health.restartStack}
</button>
</section>
{restarts.length > 0 ? (
<details className="health-restart-log">
<summary>
{t.health.restartLog}
<strong>{restarts.length}</strong>
</summary>
<div className="health-restart-list">
{restarts.map((restart) => {
const pill =
restart.status === 'success'
? 'ok'
: restart.status === 'failed'
? 'error'
: 'stale';
return (
<article className="health-restart-record" key={restart.id}>
<div>
<small>{t.health.restartTarget}</small>
<strong>{restart.target}</strong>
</div>
<span
aria-label={`${t.health.restartStatus}: ${restart.status}`}
className={`pill pill-${pill}`}
>
{restart.status}
</span>
<div>
<small>{t.health.restartRequested}</small>
<strong>{formatDate(restart.requestedAt, locale)}</strong>
</div>
<div>
<small>{t.health.restartServices}</small>
<strong>
{restart.services.length > 0
? restart.services.join(', ')
: '-'}
</strong>
</div>
{restart.error ? (
<p className="health-restart-error">{restart.error}</p>
) : null}
</article>
);
})}
</div>
</details>
) : null}
{services.length === 0 ? (
<EmptyState>{t.service.empty}</EmptyState>
) : affectedServices.length === 0 ? null : (
<details className="health-service-details">
<summary>
{t.health.affectedServices}
<strong>{affectedServices.length}</strong>
</summary>
<div className="health-service-list">
{affectedServices.map(({ service, level, age }) => (
<article className="health-service" key={service.serviceId}>
<div>
<strong>{service.assistantName || service.serviceId}</strong>
</div>
<span className={`pill pill-${level}`}>
{t.health.levels[level]}
</span>
<div>
<small>{t.service.updated}</small>
<strong>{formatDate(service.updatedAt, locale)}</strong>
<em>{formatDuration(age, t)}</em>
</div>
<div>
<small>{t.service.rooms}</small>
<strong>
{service.activeRooms}/{service.totalRooms}
</strong>
</div>
</article>
))}
</div>
</details>
)}
</div>
);
}
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() {
<h2>{t.panels.health}</h2>
<span>{t.panels.healthSignals}</span>
</div>
<HealthPanel
data={data}
<ServicePanel
formatDuration={formatDuration}
locale={locale}
onRestartStack={() => void handleServiceRestart()}
overview={data.overview}
serviceActionKey={serviceActionKey}
snapshots={data.snapshots}
t={t}
/>
</section>

View File

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

View File

@@ -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 (
<div className="health-board">
<section className={`health-overview health-${healthLevel}`}>
<span className="eyebrow">{t.health.system}</span>
<strong>{t.health.levels[healthLevel]}</strong>
</section>
<section className="health-signals" aria-label={t.health.signals}>
<div>
<span>{t.health.services}</span>
<strong>
{services.length - stale - down}/{services.length}
</strong>
<small>{t.health.fresh}</small>
</div>
<div>
<span>{t.health.stale}</span>
<strong>{stale + down}</strong>
<small>
{down} {t.health.levels.down}
</small>
</div>
<div>
<span>{t.health.queue}</span>
<strong>{queue.pendingTasks}</strong>
<small>
{queue.pendingMessageRooms} {t.control.pendingRooms}
</small>
</div>
<div>
<span>{t.health.ciFailures}</span>
<strong>{ciFailures}</strong>
</div>
</section>
<section className="health-actions" aria-label={t.health.restart}>
<div>
<span className="eyebrow">{t.health.restart}</span>
<strong>{t.health.restartStack}</strong>
<small>{t.health.restartHint}</small>
</div>
<button
disabled={serviceActionKey === 'stack:restart'}
onClick={onRestartStack}
type="button"
>
{serviceActionKey === 'stack:restart'
? t.health.restarting
: t.health.restartStack}
</button>
</section>
{restarts.length > 0 ? (
<details className="health-restart-log">
<summary>
{t.health.restartLog}
<strong>{restarts.length}</strong>
</summary>
<div className="health-restart-list">
{restarts.map((restart) => {
const pill =
restart.status === 'success'
? 'ok'
: restart.status === 'failed'
? 'error'
: 'stale';
return (
<article className="health-restart-record" key={restart.id}>
<div>
<small>{t.health.restartTarget}</small>
<strong>{restart.target}</strong>
</div>
<span
aria-label={`${t.health.restartStatus}: ${restart.status}`}
className={`pill pill-${pill}`}
>
{restart.status}
</span>
<div>
<small>{t.health.restartRequested}</small>
<strong>{formatDate(restart.requestedAt, locale)}</strong>
</div>
<div>
<small>{t.health.restartServices}</small>
<strong>
{restart.services.length > 0
? restart.services.join(', ')
: '-'}
</strong>
</div>
{restart.error ? (
<p className="health-restart-error">{restart.error}</p>
) : null}
</article>
);
})}
</div>
</details>
) : null}
{services.length === 0 ? (
<EmptyState>{t.service.empty}</EmptyState>
) : affectedServices.length === 0 ? null : (
<details className="health-service-details">
<summary>
{t.health.affectedServices}
<strong>{affectedServices.length}</strong>
</summary>
<div className="health-service-list">
{affectedServices.map(({ service, level, age }) => (
<article className="health-service" key={service.serviceId}>
<div>
<strong>{service.assistantName || service.serviceId}</strong>
</div>
<span className={`pill pill-${level}`}>
{t.health.levels[level]}
</span>
<div>
<small>{t.service.updated}</small>
<strong>{formatDate(service.updatedAt, locale)}</strong>
<em>{formatDuration(age, t)}</em>
</div>
<div>
<small>{t.service.rooms}</small>
<strong>
{service.activeRooms}/{service.totalRooms}
</strong>
</div>
</article>
))}
</div>
</details>
)}
</div>
);
}