feat(ui): add plan quota display to analytics dashboard

- Fix analytics loading bug where client.request returns payload directly
- Add plan quota section showing usage against plan limits (5h, weekly windows)
- Display provider errors when OAuth token lacks required scopes
- Add helpful message when no quota data is available
- Add screenshot script with dynamic Playwright chromium detection
- Update tests for quota state and parallel data fetching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Romain Beaumont 2026-01-26 03:05:56 +00:00
parent 6abd372a48
commit 93614c4799
8 changed files with 324 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -311,6 +311,7 @@ export function renderApp(state: AppViewState) {
loading: state.analyticsLoading,
error: state.analyticsError,
data: state.analyticsData,
quota: state.analyticsQuota,
days: state.analyticsDays,
onDaysChange: (days) => {
state.analyticsDays = days;

View File

@ -115,6 +115,7 @@ export type AppViewState = {
analyticsLoading: boolean;
analyticsError: string | null;
analyticsData: unknown | null;
analyticsQuota: unknown | null;
analyticsDays: number;
cronLoading: boolean;
cronJobs: CronJob[];

View File

@ -205,6 +205,7 @@ export class ClawdbotApp extends LitElement {
@state() analyticsLoading = false;
@state() analyticsError: string | null = null;
@state() analyticsData: unknown | null = null;
@state() analyticsQuota: unknown | null = null;
@state() analyticsDays = 30;
@state() cronLoading = false;

View File

@ -1,26 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import type { AnalyticsState } from "./analytics";
import { loadAnalytics } from "./analytics";
import type { CostUsageSummary } from "../views/analytics";
describe("analytics controller", () => {
it("AnalyticsState has expected properties", () => {
const mockClient = {
request: vi.fn().mockResolvedValue({
ok: true,
result: {
updatedAt: Date.now(),
days: 30,
daily: [],
totals: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
missingCostEntries: 0,
},
updatedAt: Date.now(),
days: 30,
daily: [],
totals: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
missingCostEntries: 0,
},
}),
} as CostUsageSummary),
};
const state: AnalyticsState = {
@ -28,12 +27,14 @@ describe("analytics controller", () => {
analyticsLoading: false,
analyticsError: null,
analyticsData: null,
analyticsQuota: null,
analyticsDays: 30,
};
expect(state.analyticsLoading).toBe(false);
expect(state.analyticsDays).toBe(30);
expect(state.analyticsData).toBeNull();
expect(state.analyticsQuota).toBeNull();
});
it("should have valid day options", () => {
@ -43,4 +44,94 @@ describe("analytics controller", () => {
expect(days).toBeLessThanOrEqual(90);
});
});
it("loadAnalytics sets data and quota on success", async () => {
const mockCostData: CostUsageSummary = {
updatedAt: Date.now(),
days: 30,
daily: [
{
date: "2026-01-26",
input: 1000,
output: 500,
cacheRead: 200,
cacheWrite: 100,
totalTokens: 1800,
totalCost: 0.05,
missingCostEntries: 0,
},
],
totals: {
input: 1000,
output: 500,
cacheRead: 200,
cacheWrite: 100,
totalTokens: 1800,
totalCost: 0.05,
missingCostEntries: 0,
},
};
const mockQuotaData = {
updatedAt: Date.now(),
providers: [
{
provider: "anthropic",
displayName: "Claude",
windows: [
{ label: "5h", usedPercent: 42 },
{ label: "Week", usedPercent: 25 },
],
},
],
};
const mockClient = {
request: vi.fn().mockImplementation((method: string) => {
if (method === "usage.cost") return Promise.resolve(mockCostData);
if (method === "usage.status") return Promise.resolve(mockQuotaData);
return Promise.reject(new Error("Unknown method"));
}),
};
const state: AnalyticsState = {
client: mockClient as any,
analyticsLoading: false,
analyticsError: null,
analyticsData: null,
analyticsQuota: null,
analyticsDays: 30,
};
await loadAnalytics(state);
expect(mockClient.request).toHaveBeenCalledWith("usage.cost", { days: 30 });
expect(mockClient.request).toHaveBeenCalledWith("usage.status", {});
expect(state.analyticsData).toEqual(mockCostData);
expect(state.analyticsQuota).toHaveLength(1);
expect(state.analyticsQuota![0].displayName).toBe("Claude");
expect(state.analyticsError).toBeNull();
expect(state.analyticsLoading).toBe(false);
});
it("loadAnalytics sets error on failure", async () => {
const mockClient = {
request: vi.fn().mockRejectedValue(new Error("Network error")),
};
const state: AnalyticsState = {
client: mockClient as any,
analyticsLoading: false,
analyticsError: null,
analyticsData: null,
analyticsQuota: null,
analyticsDays: 7,
};
await loadAnalytics(state);
expect(state.analyticsData).toBeNull();
expect(state.analyticsError).toBe("Network error");
expect(state.analyticsLoading).toBe(false);
});
});

View File

@ -1,11 +1,12 @@
import type { GatewayBrowserClient } from "../gateway.js";
import type { CostUsageSummary } from "../views/analytics.js";
import type { CostUsageSummary, ProviderQuota, UsageSummary } from "../views/analytics.js";
export type AnalyticsState = {
client: GatewayBrowserClient;
analyticsLoading: boolean;
analyticsError: string | null;
analyticsData: CostUsageSummary | null;
analyticsQuota: ProviderQuota[] | null;
analyticsDays: number;
};
@ -14,11 +15,30 @@ export async function loadAnalytics(state: AnalyticsState): Promise<void> {
state.analyticsError = null;
try {
const res = await state.client.request("usage.cost", { days: state.analyticsDays });
if (res.ok && res.result) {
state.analyticsData = res.result as CostUsageSummary;
} else {
state.analyticsError = res.error?.message ?? "Failed to load usage data";
// Fetch both cost and quota data in parallel
const [costRes, quotaRes] = await Promise.all([
state.client.request("usage.cost", {
days: state.analyticsDays,
}) as Promise<CostUsageSummary | undefined>,
state.client.request("usage.status", {}) as Promise<UsageSummary | undefined>,
]);
if (costRes) {
state.analyticsData = costRes;
}
if (quotaRes?.providers) {
state.analyticsQuota = quotaRes.providers.map((p) => ({
provider: p.provider,
displayName: p.displayName,
windows: p.windows,
plan: p.plan,
error: p.error,
}));
}
if (!costRes && !quotaRes) {
state.analyticsError = "Failed to load usage data";
}
} catch (err) {
state.analyticsError = err instanceof Error ? err.message : "Failed to load usage data";

View File

@ -88,6 +88,7 @@ describe("analytics data types", () => {
loading: false,
error: null,
data: null,
quota: null,
days: 30,
onDaysChange: () => {},
onRefresh: () => {},
@ -95,5 +96,6 @@ describe("analytics data types", () => {
expect(props.loading).toBe(false);
expect(props.days).toBe(30);
expect(props.quota).toBeNull();
});
});

View File

@ -21,10 +21,30 @@ export type CostUsageSummary = {
totals: CostUsageTotals;
};
export type UsageWindow = {
label: string;
usedPercent: number;
resetAt?: number;
};
export type ProviderQuota = {
provider: string;
displayName: string;
windows: UsageWindow[];
plan?: string;
error?: string;
};
export type UsageSummary = {
updatedAt: number;
providers: ProviderQuota[];
};
export type AnalyticsProps = {
loading: boolean;
error: string | null;
data: CostUsageSummary | null;
quota: ProviderQuota[] | null;
days: number;
onDaysChange: (days: number) => void;
onRefresh: () => void;
@ -42,6 +62,25 @@ function formatCost(n: number): string {
return `$${n.toFixed(2)}`;
}
function formatPercent(n: number): string {
return `${Math.round(n)}%`;
}
function formatResetTime(timestamp?: number): string {
if (!timestamp) return "";
const now = Date.now();
const diff = timestamp - now;
if (diff <= 0) return "now";
const hours = Math.floor(diff / 3600000);
const minutes = Math.floor((diff % 3600000) / 60000);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "totalCost") {
if (!daily.length) {
return html`<div class="chart-empty">No data for this period</div>`;
@ -54,7 +93,7 @@ function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "t
return html`
<div class="chart-container">
<div class="chart-bars">
${daily.map((entry, i) => {
${daily.map((entry) => {
const value = entry[metric];
const height = Math.max(2, (value / max) * 150);
const label = metric === "totalCost" ? formatCost(value) : formatNumber(value);
@ -76,9 +115,59 @@ function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "t
`;
}
function renderQuotaBar(percent: number) {
const color = percent >= 90 ? "var(--danger)" : percent >= 70 ? "var(--warning)" : "var(--accent)";
return html`
<div class="quota-bar-container">
<div class="quota-bar-fill" style="width: ${Math.min(100, percent)}%; background: ${color}"></div>
</div>
`;
}
function renderProviderQuota(provider: ProviderQuota) {
if (provider.error) {
return html`
<div class="quota-provider">
<div class="quota-provider-name">${provider.displayName}</div>
<div class="quota-error">${provider.error}</div>
</div>
`;
}
if (!provider.windows.length) {
return nothing;
}
return html`
<div class="quota-provider">
<div class="quota-provider-header">
<div class="quota-provider-name">${provider.displayName}</div>
${provider.plan ? html`<div class="quota-plan">${provider.plan}</div>` : nothing}
</div>
<div class="quota-windows">
${provider.windows.map(
(w) => html`
<div class="quota-window">
<div class="quota-window-header">
<span class="quota-window-label">${w.label}</span>
<span class="quota-window-value">${formatPercent(w.usedPercent)} used</span>
</div>
${renderQuotaBar(w.usedPercent)}
${w.resetAt ? html`<div class="quota-reset">Resets in ${formatResetTime(w.resetAt)}</div>` : nothing}
</div>
`,
)}
</div>
</div>
`;
}
export function renderAnalytics(props: AnalyticsProps) {
const { loading, error, data, days } = props;
const { loading, error, data, quota, days } = props;
const totals = data?.totals;
const hasQuota = quota && quota.some((p) => p.windows.length > 0);
const hasQuotaError = quota && quota.some((p) => p.error);
const quotaEmpty = quota && quota.length === 0;
return html`
<style>
@ -164,9 +253,104 @@ export function renderAnalytics(props: AnalyticsProps) {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.quota-section {
margin-bottom: 24px;
}
.quota-providers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.quota-provider {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.quota-provider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.quota-provider-name {
font-weight: 600;
font-size: 14px;
}
.quota-plan {
font-size: 11px;
color: var(--fg-muted);
background: var(--bg);
padding: 2px 8px;
border-radius: 10px;
}
.quota-error {
color: var(--fg-muted);
font-size: 12px;
}
.quota-windows {
display: flex;
flex-direction: column;
gap: 12px;
}
.quota-window {
display: flex;
flex-direction: column;
gap: 4px;
}
.quota-window-header {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.quota-window-label {
color: var(--fg-muted);
}
.quota-window-value {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.quota-bar-container {
height: 8px;
background: var(--bg);
border-radius: 4px;
overflow: hidden;
}
.quota-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.quota-reset {
font-size: 11px;
color: var(--fg-muted);
}
</style>
<section class="card">
${hasQuota || hasQuotaError
? html`
<section class="card">
<div class="card-title">Plan Quota</div>
<div class="card-sub">Current usage against your plan limits.</div>
<div class="quota-providers" style="margin-top: 16px;">
${quota!.map((p) => renderProviderQuota(p))}
</div>
</section>
`
: quotaEmpty
? html`
<section class="card">
<div class="card-title">Plan Quota</div>
<div class="card-sub">Current usage against your plan limits.</div>
<div class="callout" style="margin-top: 16px;">
No plan quota data available. To see usage limits for Claude Max or other plans,
configure OAuth authentication with the required scopes.
</div>
</section>
`
: nothing}
<section class="card" style="margin-top: ${hasQuota || hasQuotaError || quotaEmpty ? "18px" : "0"};">
<div class="card-title">Usage Summary</div>
<div class="card-sub">Token consumption and estimated costs.</div>