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:
@@ -52,9 +52,9 @@ import {
|
|||||||
import { formatDate, statusLabel } from './dashboardHelpers';
|
import { formatDate, statusLabel } from './dashboardHelpers';
|
||||||
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
|
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
|
||||||
import { RoomBoardV2 } from './RoomBoardV2';
|
import { RoomBoardV2 } from './RoomBoardV2';
|
||||||
|
import { ServicePanel, type ServiceActionKey } from './ServicePanel';
|
||||||
import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel';
|
import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel';
|
||||||
import { UsagePanel } from './UsagePanel';
|
import { UsagePanel } from './UsagePanel';
|
||||||
import { EmptyState } from './EmptyState';
|
|
||||||
import { ParsedBody } from './ParsedBody';
|
import { ParsedBody } from './ParsedBody';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
@@ -64,8 +64,6 @@ interface DashboardState {
|
|||||||
tasks: DashboardTask[];
|
tasks: DashboardTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServiceActionKey = 'stack:restart';
|
|
||||||
type HealthLevel = 'ok' | 'stale' | 'down';
|
|
||||||
type FreshnessLevel = DashboardFreshness;
|
type FreshnessLevel = DashboardFreshness;
|
||||||
|
|
||||||
type BeforeInstallPromptEvent = Event & {
|
type BeforeInstallPromptEvent = Event & {
|
||||||
@@ -76,8 +74,6 @@ type BeforeInstallPromptEvent = Event & {
|
|||||||
const REFRESH_INTERVAL_MS = 15_000;
|
const REFRESH_INTERVAL_MS = 15_000;
|
||||||
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
|
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
|
||||||
const DEFAULT_VIEW: DashboardView = 'rooms';
|
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;
|
const DASHBOARD_STALE_MS = 75_000;
|
||||||
|
|
||||||
function makeClientRequestId(): string {
|
function makeClientRequestId(): string {
|
||||||
@@ -122,6 +118,12 @@ function readInitialLocale(): Locale {
|
|||||||
return 'en';
|
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 {
|
function humanizeError(raw: string, t: Messages): string {
|
||||||
const lower = raw.toLowerCase();
|
const lower = raw.toLowerCase();
|
||||||
if (/abort|timeout|timed out/.test(lower)) return t.error.timeout;
|
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({
|
function LanguageSelector({
|
||||||
locale,
|
locale,
|
||||||
onLocaleChange,
|
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({
|
function DashboardErrorCard({
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
@@ -1252,10 +1055,7 @@ function App() {
|
|||||||
function setNickname(next: string) {
|
function setNickname(next: string) {
|
||||||
const trimmed = next.trim().slice(0, 32);
|
const trimmed = next.trim().slice(0, 32);
|
||||||
setNicknameState(trimmed);
|
setNicknameState(trimmed);
|
||||||
if (typeof window !== 'undefined') {
|
persistNickname(trimmed);
|
||||||
if (trimmed) window.localStorage.setItem('ejclaw-nickname', trimmed);
|
|
||||||
else window.localStorage.removeItem('ejclaw-nickname');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const t = messages[locale];
|
const t = messages[locale];
|
||||||
const secureContext =
|
const secureContext =
|
||||||
@@ -1714,11 +1514,13 @@ function App() {
|
|||||||
<h2>{t.panels.health}</h2>
|
<h2>{t.panels.health}</h2>
|
||||||
<span>{t.panels.healthSignals}</span>
|
<span>{t.panels.healthSignals}</span>
|
||||||
</div>
|
</div>
|
||||||
<HealthPanel
|
<ServicePanel
|
||||||
data={data}
|
formatDuration={formatDuration}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onRestartStack={() => void handleServiceRestart()}
|
onRestartStack={() => void handleServiceRestart()}
|
||||||
|
overview={data.overview}
|
||||||
serviceActionKey={serviceActionKey}
|
serviceActionKey={serviceActionKey}
|
||||||
|
snapshots={data.snapshots}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
150
apps/dashboard/src/ServicePanel.test.ts
Normal file
150
apps/dashboard/src/ServicePanel.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
214
apps/dashboard/src/ServicePanel.tsx
Normal file
214
apps/dashboard/src/ServicePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user