diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx
index bf1b2e2..0db7305 100644
--- a/apps/dashboard/src/App.tsx
+++ b/apps/dashboard/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
import {
type ClaudeAccountSummary,
@@ -53,6 +53,7 @@ import { formatDate, statusLabel } from './dashboardHelpers';
import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel';
import { RoomBoardV2 } from './RoomBoardV2';
import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel';
+import { UsagePanel } from './UsagePanel';
import { EmptyState } from './EmptyState';
import { ParsedBody } from './ParsedBody';
import './styles.css';
@@ -63,10 +64,6 @@ interface DashboardState {
tasks: DashboardTask[];
}
-type UsageRow = DashboardOverview['usage']['rows'][number];
-type RiskLevel = 'ok' | 'warn' | 'critical';
-type UsageGroup = 'primary' | 'codex';
-type UsageLimitWindow = 'h5' | 'd7';
type ServiceActionKey = 'stack:restart';
type HealthLevel = 'ok' | 'stale' | 'down';
type FreshnessLevel = DashboardFreshness;
@@ -192,82 +189,6 @@ function canUsePwaCore(): boolean {
);
}
-function formatPct(value: number): string {
- if (value < 0) return '-';
- return `${Math.round(value)}%`;
-}
-
-function usagePeak(row: UsageRow): number {
- return Math.max(row.h5pct, row.d7pct);
-}
-
-function usageLimitWindow(row: UsageRow): UsageLimitWindow {
- return row.d7pct >= row.h5pct ? 'd7' : 'h5';
-}
-
-function usageWindowRemaining(
- row: UsageRow,
- window: UsageLimitWindow,
-): number | null {
- const pct = window === 'h5' ? row.h5pct : row.d7pct;
- if (pct < 0) return null;
- return Math.max(0, 100 - pct);
-}
-
-function usageRiskLevel(row: UsageRow): RiskLevel {
- const peak = usagePeak(row);
- if (peak >= 85) return 'critical';
- if (peak >= 65) return 'warn';
- return 'ok';
-}
-
-function usageActive(row: UsageRow): boolean {
- return row.name.includes('*');
-}
-
-function usageLimited(row: UsageRow): boolean {
- return row.name.includes('!');
-}
-
-function usageNameParts(row: UsageRow): {
- account: string;
- plan: string | null;
-} {
- const cleaned = row.name.replace(/[*!]/g, '').replace(/\s+/g, ' ').trim();
- const parts = cleaned.split(' ');
- const plan = parts.at(-1) ?? null;
- if (plan && ['max', 'mid', 'pro', 'team'].includes(plan.toLowerCase())) {
- return { account: parts.slice(0, -1).join(' ') || cleaned, plan };
- }
- return { account: cleaned, plan: null };
-}
-
-function usageWindowReset(row: UsageRow, window: UsageLimitWindow): string {
- return (window === 'd7' ? row.d7reset : row.h5reset).trim();
-}
-
-function usageBurnRate(row: UsageRow): number | null {
- if (row.h5pct < 0) return null;
- return row.h5pct / 5;
-}
-
-function usageSpeedLevel(rate: number | null): RiskLevel {
- if (rate === null) return 'ok';
- if (rate >= 12) return 'critical';
- if (rate >= 7) return 'warn';
- return 'ok';
-}
-
-function formatUsageRate(rate: number | null): string {
- if (rate === null) return '-';
- if (rate > 0 && rate < 1) return '<1%/h';
- return `${Math.round(rate)}%/h`;
-}
-
-function usageGroup(row: UsageRow): UsageGroup {
- return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary';
-}
-
function formatDuration(value: number | null, t: Messages): string {
if (value === null) return '-';
const seconds = Math.floor(value / 1000);
@@ -1264,168 +1185,6 @@ function HealthPanel({
);
}
-function UsageQuotaMeter({
- row,
- rowName,
- window,
- t,
-}: {
- row: UsageRow;
- rowName: string;
- window: UsageLimitWindow;
- t: Messages;
-}) {
- const remaining = usageWindowRemaining(row, window);
- const reset = usageWindowReset(row, window);
- const tightest = usageLimitWindow(row) === window;
- const label = t.usage.quota[window];
-
- return (
-
-
- {label}
- {remaining === null ? '-' : formatPct(remaining)}
-
-
-
{reset ? `${t.usage.reset} ${reset}` : t.usage.noReset}
-
- );
-}
-
-function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
- const rate = usageBurnRate(row);
- const level = usageSpeedLevel(rate);
-
- return (
-
- {t.usage.speed}
- {formatUsageRate(rate)}
- {t.usage.speedLabel[level]}
-
- );
-}
-
-function UsagePanel({
- overview,
- t,
-}: {
- overview: DashboardOverview;
- t: Messages;
-}) {
- const rows = useMemo(
- () =>
- [...overview.usage.rows].sort((a, b) => {
- if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1;
- return usagePeak(b) - usagePeak(a);
- }),
- [overview.usage.rows],
- );
- const watched = rows.filter((row) => usagePeak(row) >= 65).length;
-
- if (rows.length === 0) {
- return {t.usage.empty};
- }
-
- const activeRows = rows.filter(usageActive);
- const focusRows = activeRows.length > 0 ? activeRows : rows.slice(0, 1);
- const focusLabel = activeRows.length > 0 ? t.usage.current : t.usage.tightest;
- const focusValue = focusRows
- .map((row) => {
- const { account } = usageNameParts(row);
- const h5Remaining = usageWindowRemaining(row, 'h5');
- const d7Remaining = usageWindowRemaining(row, 'd7');
- return `${account} ${t.usage.quota.h5} ${
- h5Remaining === null ? '-' : formatPct(h5Remaining)
- } · ${t.usage.quota.d7} ${
- d7Remaining === null ? '-' : formatPct(d7Remaining)
- }`;
- })
- .join(' · ');
- const groups = [
- {
- key: 'primary' as const,
- label: t.usage.groupPrimary,
- rows: rows.filter((row) => usageGroup(row) === 'primary'),
- },
- {
- key: 'codex' as const,
- label: t.usage.groupCodex,
- rows: rows.filter((row) => usageGroup(row) === 'codex'),
- },
- ].filter((group) => group.rows.length > 0);
-
- return (
-
-
-
- {focusLabel}
- {focusValue}
-
-
- {t.usage.watch}
- {watched}
-
-
-
-
-
- {t.usage.usage}
- {t.usage.quota.h5}
- {t.usage.quota.d7}
- {t.usage.speed}
-
- {groups.map((group) => (
-
-
- {group.label}
-
- {group.rows.map((row) => {
- const risk = usageRiskLevel(row);
- const { account, plan } = usageNameParts(row);
- return (
-
-
-
{account}
-
- {usageActive(row) ? (
- {t.usage.inUse}
- ) : null}
- {plan ? {plan} : null}
- {usageLimited(row) || risk !== 'ok' ? (
-
- {t.usage.risk[risk]}
-
- ) : null}
-
-
-
-
-
-
- );
- })}
-
- ))}
-
-
- );
-}
function DashboardErrorCard({
error,
onRetry,
diff --git a/apps/dashboard/src/UsagePanel.test.ts b/apps/dashboard/src/UsagePanel.test.ts
new file mode 100644
index 0000000..e1a3f39
--- /dev/null
+++ b/apps/dashboard/src/UsagePanel.test.ts
@@ -0,0 +1,71 @@
+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 { UsagePanel, type UsagePanelProps } from './UsagePanel';
+
+const t = messages.en;
+
+function overview(rows: DashboardOverview['usage']['rows']): DashboardOverview {
+ return {
+ generatedAt: '2026-04-28T04:00: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:00:00.000Z', rows },
+ };
+}
+
+const baseProps: UsagePanelProps = {
+ overview: overview([
+ {
+ d7pct: 88,
+ d7reset: '2d',
+ h5pct: 52,
+ h5reset: '1h',
+ name: '*Claude max',
+ },
+ {
+ d7pct: 12,
+ d7reset: '',
+ h5pct: 45,
+ h5reset: '3h',
+ name: 'codex-pro mid',
+ },
+ ]),
+ t,
+};
+
+describe('UsagePanel', () => {
+ it('renders grouped usage rows, quota remaining, and risk labels', () => {
+ const html = renderToStaticMarkup(createElement(UsagePanel, baseProps));
+
+ expect(html).toContain(t.usage.groupPrimary);
+ expect(html).toContain(t.usage.groupCodex);
+ expect(html).toContain('Claude');
+ expect(html).toContain('codex-pro');
+ expect(html).toContain(t.usage.inUse);
+ expect(html).toContain(t.usage.risk.critical);
+ expect(html).toContain('12%');
+ expect(html).toContain('48%');
+ expect(html).toContain('10%/h');
+ });
+
+ it('renders an empty state without usage rows', () => {
+ const html = renderToStaticMarkup(
+ createElement(UsagePanel, { ...baseProps, overview: overview([]) }),
+ );
+
+ expect(html).toContain(t.usage.empty);
+ });
+});
diff --git a/apps/dashboard/src/UsagePanel.tsx b/apps/dashboard/src/UsagePanel.tsx
new file mode 100644
index 0000000..2cc74e7
--- /dev/null
+++ b/apps/dashboard/src/UsagePanel.tsx
@@ -0,0 +1,249 @@
+import { useMemo } from 'react';
+
+import type { DashboardOverview } from './api';
+import { EmptyState } from './EmptyState';
+import type { Messages } from './i18n';
+
+export type UsageRow = DashboardOverview['usage']['rows'][number];
+
+type RiskLevel = 'ok' | 'warn' | 'critical';
+type UsageGroup = 'primary' | 'codex';
+type UsageLimitWindow = 'h5' | 'd7';
+
+export interface UsagePanelProps {
+ overview: DashboardOverview;
+ t: Messages;
+}
+
+function formatPct(value: number): string {
+ if (value < 0) return '-';
+ return `${Math.round(value)}%`;
+}
+
+function usagePeak(row: UsageRow): number {
+ return Math.max(row.h5pct, row.d7pct);
+}
+
+function usageLimitWindow(row: UsageRow): UsageLimitWindow {
+ return row.d7pct >= row.h5pct ? 'd7' : 'h5';
+}
+
+function usageWindowRemaining(
+ row: UsageRow,
+ window: UsageLimitWindow,
+): number | null {
+ const pct = window === 'h5' ? row.h5pct : row.d7pct;
+ if (pct < 0) return null;
+ return Math.max(0, 100 - pct);
+}
+
+function usageRiskLevel(row: UsageRow): RiskLevel {
+ const peak = usagePeak(row);
+ if (peak >= 85) return 'critical';
+ if (peak >= 65) return 'warn';
+ return 'ok';
+}
+
+function usageActive(row: UsageRow): boolean {
+ return row.name.includes('*');
+}
+
+function usageLimited(row: UsageRow): boolean {
+ return row.name.includes('!');
+}
+
+function usageNameParts(row: UsageRow): {
+ account: string;
+ plan: string | null;
+} {
+ const cleaned = row.name.replace(/[*!]/g, '').replace(/\s+/g, ' ').trim();
+ const parts = cleaned.split(' ');
+ const plan = parts.at(-1) ?? null;
+ if (plan && ['max', 'mid', 'pro', 'team'].includes(plan.toLowerCase())) {
+ return { account: parts.slice(0, -1).join(' ') || cleaned, plan };
+ }
+ return { account: cleaned, plan: null };
+}
+
+function usageWindowReset(row: UsageRow, window: UsageLimitWindow): string {
+ return (window === 'd7' ? row.d7reset : row.h5reset).trim();
+}
+
+function usageBurnRate(row: UsageRow): number | null {
+ if (row.h5pct < 0) return null;
+ return row.h5pct / 5;
+}
+
+function usageSpeedLevel(rate: number | null): RiskLevel {
+ if (rate === null) return 'ok';
+ if (rate >= 12) return 'critical';
+ if (rate >= 7) return 'warn';
+ return 'ok';
+}
+
+function formatUsageRate(rate: number | null): string {
+ if (rate === null) return '-';
+ if (rate > 0 && rate < 1) return '<1%/h';
+ return `${Math.round(rate)}%/h`;
+}
+
+function usageGroup(row: UsageRow): UsageGroup {
+ return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary';
+}
+
+function UsageQuotaMeter({
+ row,
+ rowName,
+ window,
+ t,
+}: {
+ row: UsageRow;
+ rowName: string;
+ window: UsageLimitWindow;
+ t: Messages;
+}) {
+ const remaining = usageWindowRemaining(row, window);
+ const reset = usageWindowReset(row, window);
+ const tightest = usageLimitWindow(row) === window;
+ const label = t.usage.quota[window];
+
+ return (
+
+
+ {label}
+ {remaining === null ? '-' : formatPct(remaining)}
+
+
+
{reset ? `${t.usage.reset} ${reset}` : t.usage.noReset}
+
+ );
+}
+
+function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
+ const rate = usageBurnRate(row);
+ const level = usageSpeedLevel(rate);
+
+ return (
+
+ {t.usage.speed}
+ {formatUsageRate(rate)}
+ {t.usage.speedLabel[level]}
+
+ );
+}
+
+export function UsagePanel({ overview, t }: UsagePanelProps) {
+ const rows = useMemo(
+ () =>
+ [...overview.usage.rows].sort((a, b) => {
+ if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1;
+ return usagePeak(b) - usagePeak(a);
+ }),
+ [overview.usage.rows],
+ );
+ const watched = rows.filter((row) => usagePeak(row) >= 65).length;
+
+ if (rows.length === 0) {
+ return {t.usage.empty};
+ }
+
+ const activeRows = rows.filter(usageActive);
+ const focusRows = activeRows.length > 0 ? activeRows : rows.slice(0, 1);
+ const focusLabel = activeRows.length > 0 ? t.usage.current : t.usage.tightest;
+ const focusValue = focusRows
+ .map((row) => {
+ const { account } = usageNameParts(row);
+ const h5Remaining = usageWindowRemaining(row, 'h5');
+ const d7Remaining = usageWindowRemaining(row, 'd7');
+ return `${account} ${t.usage.quota.h5} ${
+ h5Remaining === null ? '-' : formatPct(h5Remaining)
+ } · ${t.usage.quota.d7} ${
+ d7Remaining === null ? '-' : formatPct(d7Remaining)
+ }`;
+ })
+ .join(' · ');
+ const groups = [
+ {
+ key: 'primary' as const,
+ label: t.usage.groupPrimary,
+ rows: rows.filter((row) => usageGroup(row) === 'primary'),
+ },
+ {
+ key: 'codex' as const,
+ label: t.usage.groupCodex,
+ rows: rows.filter((row) => usageGroup(row) === 'codex'),
+ },
+ ].filter((group) => group.rows.length > 0);
+
+ return (
+
+
+
+ {focusLabel}
+ {focusValue}
+
+
+ {t.usage.watch}
+ {watched}
+
+
+
+
+
+ {t.usage.usage}
+ {t.usage.quota.h5}
+ {t.usage.quota.d7}
+ {t.usage.speed}
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+ {group.rows.map((row) => {
+ const risk = usageRiskLevel(row);
+ const { account, plan } = usageNameParts(row);
+ return (
+
+
+
{account}
+
+ {usageActive(row) ? (
+ {t.usage.inUse}
+ ) : null}
+ {plan ? {plan} : null}
+ {usageLimited(row) || risk !== 'ok' ? (
+
+ {t.usage.risk[risk]}
+
+ ) : null}
+
+
+
+
+
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}