Remove dashboard health top-level navigation (#123)
This commit is contained in:
@@ -32,8 +32,8 @@ import {
|
|||||||
} from './DashboardNav';
|
} from './DashboardNav';
|
||||||
import { formatDate, statusLabel } from './dashboardHelpers';
|
import { formatDate, statusLabel } from './dashboardHelpers';
|
||||||
import { RoomBoardV2 } from './RoomBoardV2';
|
import { RoomBoardV2 } from './RoomBoardV2';
|
||||||
import { ServicePanel, type ServiceActionKey } from './ServicePanel';
|
|
||||||
import { SettingsPanel } from './SettingsPanel';
|
import { SettingsPanel } from './SettingsPanel';
|
||||||
|
import { SystemStatusStrip } from './SystemStatusStrip';
|
||||||
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 './styles.css';
|
import './styles.css';
|
||||||
@@ -65,7 +65,6 @@ function isDashboardView(
|
|||||||
): value is DashboardView {
|
): value is DashboardView {
|
||||||
return (
|
return (
|
||||||
value === 'usage' ||
|
value === 'usage' ||
|
||||||
value === 'health' ||
|
|
||||||
value === 'rooms' ||
|
value === 'rooms' ||
|
||||||
value === 'scheduled' ||
|
value === 'scheduled' ||
|
||||||
value === 'settings'
|
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 (
|
|
||||||
<section className="ops-strip" id="overview" aria-label={t.control.aria}>
|
|
||||||
<div className={`ops-tile-freshness ops-${freshness}`}>
|
|
||||||
<span>{t.pwa.updated}</span>
|
|
||||||
<strong>{freshnessLabel(freshness, t)}</strong>
|
|
||||||
<small>
|
|
||||||
{formatDate(data.overview.generatedAt, locale)}
|
|
||||||
{age === null ? '' : ` · ${formatDuration(age, t)}`}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div className="ops-tile-pwa">
|
|
||||||
<span>{t.pwa.app}</span>
|
|
||||||
<strong>
|
|
||||||
{!secureContext
|
|
||||||
? t.pwa.secureRequired
|
|
||||||
: installed
|
|
||||||
? t.pwa.installed
|
|
||||||
: offlineReady
|
|
||||||
? t.pwa.ready
|
|
||||||
: t.pwa.app}
|
|
||||||
</strong>
|
|
||||||
{canInstall ? (
|
|
||||||
<button onClick={onInstall} type="button">
|
|
||||||
{t.pwa.install}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<small>
|
|
||||||
{!secureContext
|
|
||||||
? t.pwa.secureRequired
|
|
||||||
: offlineReady
|
|
||||||
? t.pwa.cached
|
|
||||||
: t.pwa.online}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{t.metrics.rooms}</span>
|
|
||||||
<strong>
|
|
||||||
{data.overview.rooms.active + data.overview.rooms.waiting}/
|
|
||||||
{data.overview.rooms.total}
|
|
||||||
</strong>
|
|
||||||
<small>{t.control.activeRooms}</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{t.control.queue}</span>
|
|
||||||
<strong>{queue.pendingTasks}</strong>
|
|
||||||
<small>
|
|
||||||
{queue.pendingMessageRooms} {t.control.pendingRooms}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{t.metrics.agents}</span>
|
|
||||||
<strong>{data.overview.services.length}</strong>
|
|
||||||
<small>{t.panels.heartbeat}</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{t.metrics.ciWatchers}</span>
|
|
||||||
<strong>{data.overview.tasks.watchers.active}</strong>
|
|
||||||
<small>
|
|
||||||
{data.overview.tasks.watchers.paused} {t.status.paused}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardErrorCard({
|
function DashboardErrorCard({
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
@@ -388,8 +287,7 @@ function App() {
|
|||||||
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
|
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [serviceActionKey, setServiceActionKey] =
|
const [serviceRestarting, setServiceRestarting] = useState(false);
|
||||||
useState<ServiceActionKey | null>(null);
|
|
||||||
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
||||||
const [selectedRoomJid, setSelectedRoomJid] = useState<string | null>(null);
|
const [selectedRoomJid, setSelectedRoomJid] = useState<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -498,7 +396,7 @@ function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServiceActionKey('stack:restart');
|
setServiceRestarting(true);
|
||||||
try {
|
try {
|
||||||
await runServiceAction('stack', 'restart', {
|
await runServiceAction('stack', 'restart', {
|
||||||
requestId: makeClientRequestId(),
|
requestId: makeClientRequestId(),
|
||||||
@@ -507,7 +405,7 @@ function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setServiceActionKey(null);
|
setServiceRestarting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,43 +691,12 @@ function App() {
|
|||||||
{data ? (
|
{data ? (
|
||||||
<div className={`view-stack view-${activeView}`}>
|
<div className={`view-stack view-${activeView}`}>
|
||||||
{activeView === 'usage' ? (
|
{activeView === 'usage' ? (
|
||||||
<>
|
<section className="panel usage-first" id="usage">
|
||||||
<section className="panel usage-first" id="usage">
|
|
||||||
<div className="panel-title">
|
|
||||||
<h2>{t.panels.usage}</h2>
|
|
||||||
<span>{t.panels.usageWindow}</span>
|
|
||||||
</div>
|
|
||||||
<UsagePanel overview={data.overview} t={t} />
|
|
||||||
</section>
|
|
||||||
<ControlRail
|
|
||||||
canInstall={canInstall}
|
|
||||||
data={data}
|
|
||||||
installed={installed}
|
|
||||||
locale={locale}
|
|
||||||
offlineReady={offlineReady}
|
|
||||||
online={online}
|
|
||||||
onInstall={() => void handleInstallApp()}
|
|
||||||
secureContext={secureContext}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeView === 'health' ? (
|
|
||||||
<section className="panel view-panel" id="health">
|
|
||||||
<div className="panel-title">
|
<div className="panel-title">
|
||||||
<h2>{t.panels.health}</h2>
|
<h2>{t.panels.usage}</h2>
|
||||||
<span>{t.panels.healthSignals}</span>
|
<span>{t.panels.usageWindow}</span>
|
||||||
</div>
|
</div>
|
||||||
<ServicePanel
|
<UsagePanel overview={data.overview} t={t} />
|
||||||
formatDuration={formatDuration}
|
|
||||||
locale={locale}
|
|
||||||
onRestartStack={() => void handleServiceRestart()}
|
|
||||||
overview={data.overview}
|
|
||||||
serviceActionKey={serviceActionKey}
|
|
||||||
snapshots={data.snapshots}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -839,6 +706,7 @@ function App() {
|
|||||||
<h2>{t.panels.rooms}</h2>
|
<h2>{t.panels.rooms}</h2>
|
||||||
<span>{t.panels.queue}</span>
|
<span>{t.panels.queue}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<SystemStatusStrip overview={data.overview} t={t} />
|
||||||
<RoomBoardV2
|
<RoomBoardV2
|
||||||
{...ROOM_BOARD_FORMATTERS}
|
{...ROOM_BOARD_FORMATTERS}
|
||||||
createRequestId={makeClientRequestId}
|
createRequestId={makeClientRequestId}
|
||||||
@@ -890,7 +758,9 @@ function App() {
|
|||||||
nickname={nickname}
|
nickname={nickname}
|
||||||
onLocaleChange={setDashboardLocale}
|
onLocaleChange={setDashboardLocale}
|
||||||
onNicknameChange={setNickname}
|
onNicknameChange={setNickname}
|
||||||
onRestartStack={() => void handleServiceRestart()}
|
onRestartStack={() => {
|
||||||
|
if (!serviceRestarting) void handleServiceRestart();
|
||||||
|
}}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -12,12 +11,7 @@ import {
|
|||||||
import { type DashboardOverview, type StatusSnapshot } from './api';
|
import { type DashboardOverview, type StatusSnapshot } from './api';
|
||||||
import { type Messages } from './i18n';
|
import { type Messages } from './i18n';
|
||||||
|
|
||||||
export type DashboardView =
|
export type DashboardView = 'usage' | 'rooms' | 'scheduled' | 'settings';
|
||||||
| 'usage'
|
|
||||||
| 'health'
|
|
||||||
| 'rooms'
|
|
||||||
| 'scheduled'
|
|
||||||
| 'settings';
|
|
||||||
export type DashboardFreshness = 'fresh' | 'stale' | 'offline';
|
export type DashboardFreshness = 'fresh' | 'stale' | 'offline';
|
||||||
|
|
||||||
interface DashboardNavData {
|
interface DashboardNavData {
|
||||||
@@ -27,7 +21,6 @@ interface DashboardNavData {
|
|||||||
|
|
||||||
const NAV_ICONS: Record<DashboardView, ReactNode> = {
|
const NAV_ICONS: Record<DashboardView, ReactNode> = {
|
||||||
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
|
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
|
||||||
health: <Activity size={20} strokeWidth={2} aria-hidden />,
|
|
||||||
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
|
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
|
||||||
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
|
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
|
||||||
settings: <Settings size={20} strokeWidth={2} aria-hidden />,
|
settings: <Settings size={20} strokeWidth={2} aria-hidden />,
|
||||||
@@ -37,7 +30,6 @@ function navItems(t: Messages) {
|
|||||||
return [
|
return [
|
||||||
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
|
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
|
||||||
{ href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' 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: '#/usage', label: t.nav.usage, view: 'usage' as const },
|
||||||
{ href: '#/settings', label: t.nav.settings, view: 'settings' as const },
|
{ href: '#/settings', label: t.nav.settings, view: 'settings' as const },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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> = {},
|
|
||||||
): 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
99
apps/dashboard/src/SystemStatusStrip.test.ts
Normal file
99
apps/dashboard/src/SystemStatusStrip.test.ts
Normal file
@@ -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> = {},
|
||||||
|
): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/dashboard/src/SystemStatusStrip.tsx
Normal file
66
apps/dashboard/src/SystemStatusStrip.tsx
Normal file
@@ -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 (
|
||||||
|
<aside
|
||||||
|
aria-label={t.health.signals}
|
||||||
|
className={`system-status-strip system-status-${level}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">{t.health.system}</span>
|
||||||
|
<strong>{t.health.levels[level]}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{signals.join(' · ')}</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1576,6 +1576,54 @@ dd,
|
|||||||
padding: 12px;
|
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 {
|
.panel-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -2875,6 +2923,10 @@ progress::-moz-progress-bar {
|
|||||||
.ops-strip {
|
.ops-strip {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-status-strip {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
@@ -128,8 +128,9 @@ async function main() {
|
|||||||
await page.waitForURL(/#\/rooms$/);
|
await page.waitForURL(/#\/rooms$/);
|
||||||
await assertVisible(page.locator('#rooms .rooms-v2'));
|
await assertVisible(page.locator('#rooms .rooms-v2'));
|
||||||
assert.equal(await page.locator('a[href="#/inbox"]').count(), 0);
|
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());
|
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(
|
assert.equal(
|
||||||
await page.getByRole('button', { name: 'Dismiss' }).count(),
|
await page.getByRole('button', { name: 'Dismiss' }).count(),
|
||||||
0,
|
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');
|
console.log('dashboard:ux passed');
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user