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:
parent
6abd372a48
commit
93614c4799
BIN
docs/images/control-ui-analytics.png
Normal file
BIN
docs/images/control-ui-analytics.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@ -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;
|
||||
|
||||
@ -115,6 +115,7 @@ export type AppViewState = {
|
||||
analyticsLoading: boolean;
|
||||
analyticsError: string | null;
|
||||
analyticsData: unknown | null;
|
||||
analyticsQuota: unknown | null;
|
||||
analyticsDays: number;
|
||||
cronLoading: boolean;
|
||||
cronJobs: CronJob[];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user