Remove dashboard health top-level navigation (#123)

This commit is contained in:
Eyejoker
2026-05-02 20:20:37 +09:00
committed by GitHub
parent 7e013b5314
commit 6ea4ec5b68
8 changed files with 259 additions and 503 deletions

View File

@@ -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,7 +691,6 @@ 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"> <div className="panel-title">
<h2>{t.panels.usage}</h2> <h2>{t.panels.usage}</h2>
@@ -801,36 +698,6 @@ function App() {
</div> </div>
<UsagePanel overview={data.overview} t={t} /> <UsagePanel overview={data.overview} t={t} />
</section> </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">
<h2>{t.panels.health}</h2>
<span>{t.panels.healthSignals}</span>
</div>
<ServicePanel
formatDuration={formatDuration}
locale={locale}
onRestartStack={() => void handleServiceRestart()}
overview={data.overview}
serviceActionKey={serviceActionKey}
snapshots={data.snapshots}
t={t}
/>
</section>
) : null} ) : null}
{activeView === 'rooms' ? ( {activeView === 'rooms' ? (
@@ -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>

View File

@@ -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 },
]; ];

View File

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

View File

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

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

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

View File

@@ -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) {

View File

@@ -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();