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