Extract dashboard UsagePanel component (#64)
* 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
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ClaudeAccountSummary,
|
type ClaudeAccountSummary,
|
||||||
@@ -53,6 +53,7 @@ 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 { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel';
|
import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel';
|
||||||
|
import { UsagePanel } from './UsagePanel';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { ParsedBody } from './ParsedBody';
|
import { ParsedBody } from './ParsedBody';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
@@ -63,10 +64,6 @@ interface DashboardState {
|
|||||||
tasks: DashboardTask[];
|
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 ServiceActionKey = 'stack:restart';
|
||||||
type HealthLevel = 'ok' | 'stale' | 'down';
|
type HealthLevel = 'ok' | 'stale' | 'down';
|
||||||
type FreshnessLevel = DashboardFreshness;
|
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 {
|
function formatDuration(value: number | null, t: Messages): string {
|
||||||
if (value === null) return '-';
|
if (value === null) return '-';
|
||||||
const seconds = Math.floor(value / 1000);
|
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 (
|
|
||||||
<div className={`usage-quota ${tightest ? 'usage-quota-tight' : ''}`}>
|
|
||||||
<div>
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{remaining === null ? '-' : formatPct(remaining)}</strong>
|
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
aria-label={`${rowName} ${label} ${
|
|
||||||
remaining === null ? '-' : formatPct(remaining)
|
|
||||||
}`}
|
|
||||||
max={100}
|
|
||||||
value={remaining ?? 0}
|
|
||||||
/>
|
|
||||||
<small>{reset ? `${t.usage.reset} ${reset}` : t.usage.noReset}</small>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
|
|
||||||
const rate = usageBurnRate(row);
|
|
||||||
const level = usageSpeedLevel(rate);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`usage-speed usage-speed-${level}`}>
|
|
||||||
<span>{t.usage.speed}</span>
|
|
||||||
<strong>{formatUsageRate(rate)}</strong>
|
|
||||||
<small>{t.usage.speedLabel[level]}</small>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 <EmptyState>{t.usage.empty}</EmptyState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="usage-dashboard">
|
|
||||||
<div className="usage-summary">
|
|
||||||
<div>
|
|
||||||
<span>{focusLabel}</span>
|
|
||||||
<strong>{focusValue}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>{t.usage.watch}</span>
|
|
||||||
<strong>{watched}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="usage-matrix" role="table" aria-label={t.panels.usage}>
|
|
||||||
<div className="usage-matrix-head" role="row">
|
|
||||||
<span>{t.usage.usage}</span>
|
|
||||||
<span>{t.usage.quota.h5}</span>
|
|
||||||
<span>{t.usage.quota.d7}</span>
|
|
||||||
<span>{t.usage.speed}</span>
|
|
||||||
</div>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<div className="usage-group" key={group.key} role="rowgroup">
|
|
||||||
<div className="usage-group-label" role="row">
|
|
||||||
<span>{group.label}</span>
|
|
||||||
</div>
|
|
||||||
{group.rows.map((row) => {
|
|
||||||
const risk = usageRiskLevel(row);
|
|
||||||
const { account, plan } = usageNameParts(row);
|
|
||||||
return (
|
|
||||||
<section className={`usage-row usage-${risk}`} key={row.name}>
|
|
||||||
<div className="usage-account">
|
|
||||||
<strong>{account}</strong>
|
|
||||||
<div>
|
|
||||||
{usageActive(row) ? (
|
|
||||||
<span className="pill pill-info">{t.usage.inUse}</span>
|
|
||||||
) : null}
|
|
||||||
{plan ? <span className="mono-chip">{plan}</span> : null}
|
|
||||||
{usageLimited(row) || risk !== 'ok' ? (
|
|
||||||
<span className={`pill pill-${risk}`}>
|
|
||||||
{t.usage.risk[risk]}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UsageQuotaMeter
|
|
||||||
row={row}
|
|
||||||
rowName={account}
|
|
||||||
window="h5"
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<UsageQuotaMeter
|
|
||||||
row={row}
|
|
||||||
rowName={account}
|
|
||||||
window="d7"
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<UsageSpeed row={row} t={t} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function DashboardErrorCard({
|
function DashboardErrorCard({
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
|||||||
71
apps/dashboard/src/UsagePanel.test.ts
Normal file
71
apps/dashboard/src/UsagePanel.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
249
apps/dashboard/src/UsagePanel.tsx
Normal file
249
apps/dashboard/src/UsagePanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={`usage-quota ${tightest ? 'usage-quota-tight' : ''}`}>
|
||||||
|
<div>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{remaining === null ? '-' : formatPct(remaining)}</strong>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
aria-label={`${rowName} ${label} ${
|
||||||
|
remaining === null ? '-' : formatPct(remaining)
|
||||||
|
}`}
|
||||||
|
max={100}
|
||||||
|
value={remaining ?? 0}
|
||||||
|
/>
|
||||||
|
<small>{reset ? `${t.usage.reset} ${reset}` : t.usage.noReset}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
|
||||||
|
const rate = usageBurnRate(row);
|
||||||
|
const level = usageSpeedLevel(rate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`usage-speed usage-speed-${level}`}>
|
||||||
|
<span>{t.usage.speed}</span>
|
||||||
|
<strong>{formatUsageRate(rate)}</strong>
|
||||||
|
<small>{t.usage.speedLabel[level]}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <EmptyState>{t.usage.empty}</EmptyState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="usage-dashboard">
|
||||||
|
<div className="usage-summary">
|
||||||
|
<div>
|
||||||
|
<span>{focusLabel}</span>
|
||||||
|
<strong>{focusValue}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{t.usage.watch}</span>
|
||||||
|
<strong>{watched}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="usage-matrix" role="table" aria-label={t.panels.usage}>
|
||||||
|
<div className="usage-matrix-head" role="row">
|
||||||
|
<span>{t.usage.usage}</span>
|
||||||
|
<span>{t.usage.quota.h5}</span>
|
||||||
|
<span>{t.usage.quota.d7}</span>
|
||||||
|
<span>{t.usage.speed}</span>
|
||||||
|
</div>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div className="usage-group" key={group.key} role="rowgroup">
|
||||||
|
<div className="usage-group-label" role="row">
|
||||||
|
<span>{group.label}</span>
|
||||||
|
</div>
|
||||||
|
{group.rows.map((row) => {
|
||||||
|
const risk = usageRiskLevel(row);
|
||||||
|
const { account, plan } = usageNameParts(row);
|
||||||
|
return (
|
||||||
|
<section className={`usage-row usage-${risk}`} key={row.name}>
|
||||||
|
<div className="usage-account">
|
||||||
|
<strong>{account}</strong>
|
||||||
|
<div>
|
||||||
|
{usageActive(row) ? (
|
||||||
|
<span className="pill pill-info">{t.usage.inUse}</span>
|
||||||
|
) : null}
|
||||||
|
{plan ? <span className="mono-chip">{plan}</span> : null}
|
||||||
|
{usageLimited(row) || risk !== 'ok' ? (
|
||||||
|
<span className={`pill pill-${risk}`}>
|
||||||
|
{t.usage.risk[risk]}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UsageQuotaMeter
|
||||||
|
row={row}
|
||||||
|
rowName={account}
|
||||||
|
window="h5"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<UsageQuotaMeter
|
||||||
|
row={row}
|
||||||
|
rowName={account}
|
||||||
|
window="d7"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<UsageSpeed row={row} t={t} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user