diff --git a/docs/images/control-ui-analytics.png b/docs/images/control-ui-analytics.png new file mode 100644 index 000000000..3026f2b67 Binary files /dev/null and b/docs/images/control-ui-analytics.png differ diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index e8a350788..0e9c1bd9a 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index a53774c81..786d5d470 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -115,6 +115,7 @@ export type AppViewState = { analyticsLoading: boolean; analyticsError: string | null; analyticsData: unknown | null; + analyticsQuota: unknown | null; analyticsDays: number; cronLoading: boolean; cronJobs: CronJob[]; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index f07066076..fdc596dfa 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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; diff --git a/ui/src/ui/controllers/analytics.test.ts b/ui/src/ui/controllers/analytics.test.ts index e10387651..9bbc690ec 100644 --- a/ui/src/ui/controllers/analytics.test.ts +++ b/ui/src/ui/controllers/analytics.test.ts @@ -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); + }); }); diff --git a/ui/src/ui/controllers/analytics.ts b/ui/src/ui/controllers/analytics.ts index a4489a6a3..7fda282fd 100644 --- a/ui/src/ui/controllers/analytics.ts +++ b/ui/src/ui/controllers/analytics.ts @@ -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 { 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, + state.client.request("usage.status", {}) as Promise, + ]); + + 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"; diff --git a/ui/src/ui/views/analytics.test.ts b/ui/src/ui/views/analytics.test.ts index 0305a4d05..268bc088b 100644 --- a/ui/src/ui/views/analytics.test.ts +++ b/ui/src/ui/views/analytics.test.ts @@ -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(); }); }); diff --git a/ui/src/ui/views/analytics.ts b/ui/src/ui/views/analytics.ts index fb2d3ad77..a55ab6abb 100644 --- a/ui/src/ui/views/analytics.ts +++ b/ui/src/ui/views/analytics.ts @@ -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`
No data for this period
`; @@ -54,7 +93,7 @@ function renderBarChart(daily: CostUsageDailyEntry[], metric: "totalTokens" | "t return html`
- ${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` +
+
+
+ `; +} + +function renderProviderQuota(provider: ProviderQuota) { + if (provider.error) { + return html` +
+
${provider.displayName}
+
${provider.error}
+
+ `; + } + + if (!provider.windows.length) { + return nothing; + } + + return html` +
+
+
${provider.displayName}
+ ${provider.plan ? html`
${provider.plan}
` : nothing} +
+
+ ${provider.windows.map( + (w) => html` +
+
+ ${w.label} + ${formatPercent(w.usedPercent)} used +
+ ${renderQuotaBar(w.usedPercent)} + ${w.resetAt ? html`
Resets in ${formatResetTime(w.resetAt)}
` : nothing} +
+ `, + )} +
+
+ `; +} + 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` -
+ ${hasQuota || hasQuotaError + ? html` +
+
Plan Quota
+
Current usage against your plan limits.
+
+ ${quota!.map((p) => renderProviderQuota(p))} +
+
+ ` + : quotaEmpty + ? html` +
+
Plan Quota
+
Current usage against your plan limits.
+
+ No plan quota data available. To see usage limits for Claude Max or other plans, + configure OAuth authentication with the required scopes. +
+
+ ` + : nothing} + +
Usage Summary
Token consumption and estimated costs.