From 72f71d5abf53bc0222351183be22e6fc902717cf Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 28 Apr 2026 15:46:47 +0900 Subject: [PATCH] Extract dashboard UsagePanel component (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- apps/dashboard/src/App.tsx | 245 +------------------------ apps/dashboard/src/UsagePanel.test.ts | 71 ++++++++ apps/dashboard/src/UsagePanel.tsx | 249 ++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 243 deletions(-) create mode 100644 apps/dashboard/src/UsagePanel.test.ts create mode 100644 apps/dashboard/src/UsagePanel.tsx 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} +
+
+ + + +
+ ); + })} +
+ ))} +
+
+ ); +}