icebear icebear0828 commited on
Commit
df56b50
·
unverified ·
1 Parent(s): 59408ab

feat: usage stats page with time-series token tracking (#147)

Browse files

* feat: usage stats page with time-series token tracking

- Background snapshot recording (piggybacks on quota refresh, ~5min interval)
- UsageStatsStore: JSON persistence, 7-day retention, delta computation
- GET /admin/usage-stats/summary — live cumulative totals
- GET /admin/usage-stats/history — time-series deltas with hourly/daily granularity
- Pure SVG line charts (input/output tokens + request count)
- Granularity (hourly/daily) and range (24h/3d/7d) selectors
- 13 backend tests (snapshot, delta, aggregation, retention, API routes)
- i18n: en + zh

* fix: usage stats review fixes — chart coords, refresh, cleanup

---------

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -15,6 +15,12 @@
15
  - 后台额度刷新周期性重试 banned 账号,成功即自动解封
16
  - 上游 401 token 吊销("token has been invalidated")自动标记过期并切换下一个账号
17
  - 之前 401 直接透传给客户端,不标记也不重试
 
 
 
 
 
 
18
  - Account Management 页面(`#/account-management`):批量删除、批量改状态(active/disabled)、导入导出
19
  - `POST /auth/accounts/batch-delete` 和 `POST /auth/accounts/batch-status` 批量端点
20
  - 状态摘要条可点击筛选,复用 AccountTable 选择/分页/Shift 多选
 
15
  - 后台额度刷新周期性重试 banned 账号,成功即自动解封
16
  - 上游 401 token 吊销("token has been invalidated")自动标记过期并切换下一个账号
17
  - 之前 401 直接透传给客户端,不标记也不重试
18
+ - Usage Stats 页面(`#/usage-stats`):累计 token 用量汇总 + 时间趋势图
19
+ - 后台每 5 分钟记录用量快照,保留 7 天历史
20
+ - `GET /admin/usage-stats/summary` 实时累计汇总
21
+ - `GET /admin/usage-stats/history?granularity=hourly|daily&hours=N` 时间序列增量
22
+ - 纯 SVG 折线图(input/output tokens + 请求数),无外部图表库
23
+ - 支持按小时/按天粒度,24h/3d/7d 时间范围切换
24
  - Account Management 页面(`#/account-management`):批量删除、批量改状态(active/disabled)、导入导出
25
  - `POST /auth/accounts/batch-delete` 和 `POST /auth/accounts/batch-status` 批量端点
26
  - 状态摘要条可点击筛选,复用 AccountTable 选择/分页/Shift 多选
shared/hooks/use-usage-stats.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Hooks for fetching usage stats data.
3
+ */
4
+
5
+ import { useState, useEffect, useCallback } from "preact/hooks";
6
+
7
+ export interface UsageSummary {
8
+ total_input_tokens: number;
9
+ total_output_tokens: number;
10
+ total_request_count: number;
11
+ total_accounts: number;
12
+ active_accounts: number;
13
+ }
14
+
15
+ export interface UsageDataPoint {
16
+ timestamp: string;
17
+ input_tokens: number;
18
+ output_tokens: number;
19
+ request_count: number;
20
+ }
21
+
22
+ export type Granularity = "raw" | "hourly" | "daily";
23
+
24
+ export function useUsageSummary(refreshIntervalMs = 30_000) {
25
+ const [summary, setSummary] = useState<UsageSummary | null>(null);
26
+ const [loading, setLoading] = useState(true);
27
+
28
+ const load = useCallback(async () => {
29
+ try {
30
+ const resp = await fetch("/admin/usage-stats/summary");
31
+ if (resp.ok) setSummary(await resp.json());
32
+ } catch { /* ignore */ }
33
+ setLoading(false);
34
+ }, []);
35
+
36
+ useEffect(() => {
37
+ load();
38
+ const id = setInterval(load, refreshIntervalMs);
39
+ return () => clearInterval(id);
40
+ }, [load, refreshIntervalMs]);
41
+
42
+ return { summary, loading };
43
+ }
44
+
45
+ export function useUsageHistory(granularity: Granularity, hours: number, refreshIntervalMs = 60_000) {
46
+ const [dataPoints, setDataPoints] = useState<UsageDataPoint[]>([]);
47
+ const [loading, setLoading] = useState(true);
48
+
49
+ const load = useCallback(async () => {
50
+ try {
51
+ const resp = await fetch(
52
+ `/admin/usage-stats/history?granularity=${granularity}&hours=${hours}`,
53
+ );
54
+ if (resp.ok) {
55
+ const body = await resp.json();
56
+ setDataPoints(body.data_points);
57
+ }
58
+ } catch { /* ignore */ }
59
+ setLoading(false);
60
+ }, [granularity, hours]);
61
+
62
+ useEffect(() => {
63
+ setLoading(true);
64
+ load();
65
+ const id = setInterval(load, refreshIntervalMs);
66
+ return () => clearInterval(id);
67
+ }, [load, refreshIntervalMs]);
68
+
69
+ return { dataPoints, loading };
70
+ }
shared/i18n/translations.ts CHANGED
@@ -210,6 +210,16 @@ export const translations = {
210
  deleteSuccess: "Deleted",
211
  statusChangeSuccess: "Updated",
212
  cancel: "Cancel",
 
 
 
 
 
 
 
 
 
 
213
  },
214
  zh: {
215
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
@@ -425,6 +435,16 @@ export const translations = {
425
  deleteSuccess: "已删除",
426
  statusChangeSuccess: "已更新",
427
  cancel: "取消",
 
 
 
 
 
 
 
 
 
 
428
  },
429
  } as const;
430
 
 
210
  deleteSuccess: "Deleted",
211
  statusChangeSuccess: "Updated",
212
  cancel: "Cancel",
213
+ usageStats: "Usage Stats",
214
+ totalInputTokens: "Input Tokens",
215
+ totalOutputTokens: "Output Tokens",
216
+ totalRequestCount: "Total Requests",
217
+ activeAccounts: "Active Accounts",
218
+ granularityHourly: "Hourly",
219
+ granularityDaily: "Daily",
220
+ last24h: "Last 24h",
221
+ last3d: "Last 3d",
222
+ last7d: "Last 7d",
223
  },
224
  zh: {
225
  serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
 
435
  deleteSuccess: "已删除",
436
  statusChangeSuccess: "已更新",
437
  cancel: "取消",
438
+ usageStats: "用量统计",
439
+ totalInputTokens: "输入 Token",
440
+ totalOutputTokens: "输出 Token",
441
+ totalRequestCount: "总请求数",
442
+ activeAccounts: "活跃账号",
443
+ granularityHourly: "按小时",
444
+ granularityDaily: "按天",
445
+ last24h: "最近 24h",
446
+ last3d: "最近 3 天",
447
+ last7d: "最近 7 天",
448
  },
449
  } as const;
450
 
src/auth/__tests__/usage-stats.test.ts ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for UsageStatsStore — snapshot recording, delta computation, aggregation.
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from "vitest";
6
+
7
+ vi.mock("../../paths.js", () => ({
8
+ getDataDir: vi.fn(() => "/tmp/test-data"),
9
+ }));
10
+
11
+ import { UsageStatsStore, type UsageStatsPersistence, type UsageSnapshot } from "../usage-stats.js";
12
+ import type { AccountPool } from "../account-pool.js";
13
+
14
+ function createMockPersistence(initial: UsageSnapshot[] = []): UsageStatsPersistence & { saved: UsageSnapshot[] } {
15
+ const store = {
16
+ saved: initial,
17
+ load: () => ({ version: 1 as const, snapshots: [...initial] }),
18
+ save: vi.fn((data: { version: 1; snapshots: UsageSnapshot[] }) => {
19
+ store.saved = data.snapshots;
20
+ }),
21
+ };
22
+ return store;
23
+ }
24
+
25
+ function createMockPool(entries: Array<{
26
+ status: string;
27
+ input_tokens: number;
28
+ output_tokens: number;
29
+ request_count: number;
30
+ }>): AccountPool {
31
+ return {
32
+ getAllEntries: () =>
33
+ entries.map((e, i) => ({
34
+ id: `entry-${i}`,
35
+ status: e.status,
36
+ usage: {
37
+ input_tokens: e.input_tokens,
38
+ output_tokens: e.output_tokens,
39
+ request_count: e.request_count,
40
+ },
41
+ })),
42
+ } as unknown as AccountPool;
43
+ }
44
+
45
+ describe("UsageStatsStore", () => {
46
+ let persistence: ReturnType<typeof createMockPersistence>;
47
+ let store: UsageStatsStore;
48
+
49
+ beforeEach(() => {
50
+ persistence = createMockPersistence();
51
+ store = new UsageStatsStore(persistence);
52
+ });
53
+
54
+ describe("recordSnapshot", () => {
55
+ it("records cumulative totals from all accounts", () => {
56
+ const pool = createMockPool([
57
+ { status: "active", input_tokens: 1000, output_tokens: 200, request_count: 5 },
58
+ { status: "active", input_tokens: 500, output_tokens: 100, request_count: 3 },
59
+ { status: "expired", input_tokens: 300, output_tokens: 50, request_count: 2 },
60
+ ]);
61
+
62
+ store.recordSnapshot(pool);
63
+
64
+ expect(store.snapshotCount).toBe(1);
65
+ expect(persistence.save).toHaveBeenCalledTimes(1);
66
+
67
+ const saved = persistence.saved;
68
+ expect(saved).toHaveLength(1);
69
+ expect(saved[0].totals).toEqual({
70
+ input_tokens: 1800,
71
+ output_tokens: 350,
72
+ request_count: 10,
73
+ active_accounts: 2,
74
+ });
75
+ });
76
+
77
+ it("handles empty pool", () => {
78
+ const pool = createMockPool([]);
79
+ store.recordSnapshot(pool);
80
+
81
+ expect(store.snapshotCount).toBe(1);
82
+ expect(persistence.saved[0].totals).toEqual({
83
+ input_tokens: 0,
84
+ output_tokens: 0,
85
+ request_count: 0,
86
+ active_accounts: 0,
87
+ });
88
+ });
89
+ });
90
+
91
+ describe("getSummary", () => {
92
+ it("returns live totals from pool", () => {
93
+ const pool = createMockPool([
94
+ { status: "active", input_tokens: 1000, output_tokens: 200, request_count: 5 },
95
+ { status: "disabled", input_tokens: 500, output_tokens: 100, request_count: 3 },
96
+ ]);
97
+
98
+ const summary = store.getSummary(pool);
99
+ expect(summary).toEqual({
100
+ total_input_tokens: 1500,
101
+ total_output_tokens: 300,
102
+ total_request_count: 8,
103
+ total_accounts: 2,
104
+ active_accounts: 1,
105
+ });
106
+ });
107
+ });
108
+
109
+ describe("getHistory", () => {
110
+ it("returns empty for less than 2 snapshots", () => {
111
+ expect(store.getHistory(24, "hourly")).toEqual([]);
112
+ });
113
+
114
+ it("computes deltas between consecutive snapshots", () => {
115
+ const now = Date.now();
116
+ const snapshots: UsageSnapshot[] = [
117
+ {
118
+ timestamp: new Date(now - 3600_000).toISOString(),
119
+ totals: { input_tokens: 100, output_tokens: 20, request_count: 2, active_accounts: 1 },
120
+ },
121
+ {
122
+ timestamp: new Date(now - 1800_000).toISOString(),
123
+ totals: { input_tokens: 300, output_tokens: 50, request_count: 5, active_accounts: 1 },
124
+ },
125
+ {
126
+ timestamp: new Date(now).toISOString(),
127
+ totals: { input_tokens: 600, output_tokens: 100, request_count: 10, active_accounts: 1 },
128
+ },
129
+ ];
130
+
131
+ persistence = createMockPersistence(snapshots);
132
+ store = new UsageStatsStore(persistence);
133
+
134
+ const raw = store.getHistory(2, "raw");
135
+ expect(raw).toHaveLength(2);
136
+ expect(raw[0].input_tokens).toBe(200);
137
+ expect(raw[0].output_tokens).toBe(30);
138
+ expect(raw[0].request_count).toBe(3);
139
+ expect(raw[1].input_tokens).toBe(300);
140
+ expect(raw[1].output_tokens).toBe(50);
141
+ expect(raw[1].request_count).toBe(5);
142
+ });
143
+
144
+ it("clamps negative deltas to zero (account removal)", () => {
145
+ const now = Date.now();
146
+ const snapshots: UsageSnapshot[] = [
147
+ {
148
+ timestamp: new Date(now - 3600_000).toISOString(),
149
+ totals: { input_tokens: 1000, output_tokens: 200, request_count: 10, active_accounts: 2 },
150
+ },
151
+ {
152
+ timestamp: new Date(now).toISOString(),
153
+ totals: { input_tokens: 500, output_tokens: 100, request_count: 5, active_accounts: 1 },
154
+ },
155
+ ];
156
+
157
+ persistence = createMockPersistence(snapshots);
158
+ store = new UsageStatsStore(persistence);
159
+
160
+ const raw = store.getHistory(2, "raw");
161
+ expect(raw).toHaveLength(1);
162
+ expect(raw[0].input_tokens).toBe(0);
163
+ expect(raw[0].output_tokens).toBe(0);
164
+ expect(raw[0].request_count).toBe(0);
165
+ });
166
+
167
+ it("aggregates into hourly buckets", () => {
168
+ const now = Date.now();
169
+ const hourStart = Math.floor(now / 3600_000) * 3600_000;
170
+
171
+ const snapshots: UsageSnapshot[] = [
172
+ {
173
+ timestamp: new Date(hourStart - 1800_000).toISOString(),
174
+ totals: { input_tokens: 0, output_tokens: 0, request_count: 0, active_accounts: 1 },
175
+ },
176
+ {
177
+ timestamp: new Date(hourStart - 900_000).toISOString(),
178
+ totals: { input_tokens: 100, output_tokens: 10, request_count: 1, active_accounts: 1 },
179
+ },
180
+ {
181
+ timestamp: new Date(hourStart + 100_000).toISOString(),
182
+ totals: { input_tokens: 300, output_tokens: 30, request_count: 3, active_accounts: 1 },
183
+ },
184
+ {
185
+ timestamp: new Date(hourStart + 200_000).toISOString(),
186
+ totals: { input_tokens: 500, output_tokens: 50, request_count: 5, active_accounts: 1 },
187
+ },
188
+ ];
189
+
190
+ persistence = createMockPersistence(snapshots);
191
+ store = new UsageStatsStore(persistence);
192
+
193
+ const hourly = store.getHistory(2, "hourly");
194
+ // Two buckets: one before hourStart, one at/after hourStart
195
+ expect(hourly).toHaveLength(2);
196
+
197
+ // Previous hour bucket: delta 0→100 = 100
198
+ expect(hourly[0].input_tokens).toBe(100);
199
+ // Current hour bucket: delta 100→300 + 300→500 = 200 + 200 = 400
200
+ expect(hourly[1].input_tokens).toBe(400);
201
+ });
202
+
203
+ it("filters by time range", () => {
204
+ const now = Date.now();
205
+ const snapshots: UsageSnapshot[] = [
206
+ {
207
+ timestamp: new Date(now - 48 * 3600_000).toISOString(), // 48h ago
208
+ totals: { input_tokens: 100, output_tokens: 10, request_count: 1, active_accounts: 1 },
209
+ },
210
+ {
211
+ timestamp: new Date(now - 12 * 3600_000).toISOString(), // 12h ago
212
+ totals: { input_tokens: 500, output_tokens: 50, request_count: 5, active_accounts: 1 },
213
+ },
214
+ {
215
+ timestamp: new Date(now).toISOString(),
216
+ totals: { input_tokens: 1000, output_tokens: 100, request_count: 10, active_accounts: 1 },
217
+ },
218
+ ];
219
+
220
+ persistence = createMockPersistence(snapshots);
221
+ store = new UsageStatsStore(persistence);
222
+
223
+ // Only last 24h → only the last two snapshots qualify → 1 delta
224
+ const raw = store.getHistory(24, "raw");
225
+ expect(raw).toHaveLength(1);
226
+ expect(raw[0].input_tokens).toBe(500);
227
+ });
228
+ });
229
+
230
+ describe("retention", () => {
231
+ it("prunes snapshots older than 7 days on record", () => {
232
+ const now = Date.now();
233
+ const old: UsageSnapshot[] = [
234
+ {
235
+ timestamp: new Date(now - 8 * 24 * 3600_000).toISOString(), // 8 days ago
236
+ totals: { input_tokens: 100, output_tokens: 10, request_count: 1, active_accounts: 1 },
237
+ },
238
+ {
239
+ timestamp: new Date(now - 1 * 3600_000).toISOString(), // 1h ago
240
+ totals: { input_tokens: 500, output_tokens: 50, request_count: 5, active_accounts: 1 },
241
+ },
242
+ ];
243
+
244
+ persistence = createMockPersistence(old);
245
+ store = new UsageStatsStore(persistence);
246
+
247
+ const pool = createMockPool([
248
+ { status: "active", input_tokens: 1000, output_tokens: 100, request_count: 10 },
249
+ ]);
250
+ store.recordSnapshot(pool);
251
+
252
+ // Old snapshot pruned, recent + new remain
253
+ expect(store.snapshotCount).toBe(2);
254
+ });
255
+ });
256
+ });
src/auth/usage-refresher.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
  import type { AccountPool } from "./account-pool.js";
22
  import type { CookieJar } from "../proxy/cookie-jar.js";
23
  import type { ProxyPool } from "../proxy/proxy-pool.js";
 
24
 
25
  /** Check if a CodexApiError indicates the account is banned/suspended (non-CF 403). */
26
  function isBanError(err: unknown): boolean {
@@ -44,6 +45,7 @@ let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
44
  let _accountPool: AccountPool | null = null;
45
  let _cookieJar: CookieJar | null = null;
46
  let _proxyPool: ProxyPool | null = null;
 
47
 
48
  async function fetchQuotaForAllAccounts(
49
  pool: AccountPool,
@@ -143,6 +145,15 @@ async function fetchQuotaForAllAccounts(
143
  }
144
 
145
  console.log(`[QuotaRefresh] Done: ${succeeded}/${entries.length} succeeded`);
 
 
 
 
 
 
 
 
 
146
  }
147
 
148
  function scheduleNext(
@@ -167,10 +178,12 @@ export function startQuotaRefresh(
167
  accountPool: AccountPool,
168
  cookieJar: CookieJar,
169
  proxyPool?: ProxyPool,
 
170
  ): void {
171
  _accountPool = accountPool;
172
  _cookieJar = cookieJar;
173
  _proxyPool = proxyPool ?? null;
 
174
 
175
  _refreshTimer = setTimeout(async () => {
176
  try {
 
21
  import type { AccountPool } from "./account-pool.js";
22
  import type { CookieJar } from "../proxy/cookie-jar.js";
23
  import type { ProxyPool } from "../proxy/proxy-pool.js";
24
+ import type { UsageStatsStore } from "./usage-stats.js";
25
 
26
  /** Check if a CodexApiError indicates the account is banned/suspended (non-CF 403). */
27
  function isBanError(err: unknown): boolean {
 
45
  let _accountPool: AccountPool | null = null;
46
  let _cookieJar: CookieJar | null = null;
47
  let _proxyPool: ProxyPool | null = null;
48
+ let _usageStats: UsageStatsStore | null = null;
49
 
50
  async function fetchQuotaForAllAccounts(
51
  pool: AccountPool,
 
145
  }
146
 
147
  console.log(`[QuotaRefresh] Done: ${succeeded}/${entries.length} succeeded`);
148
+
149
+ // Record usage snapshot for time-series history
150
+ if (_usageStats) {
151
+ try {
152
+ _usageStats.recordSnapshot(pool);
153
+ } catch (err) {
154
+ console.warn("[QuotaRefresh] Failed to record usage snapshot:", err instanceof Error ? err.message : err);
155
+ }
156
+ }
157
  }
158
 
159
  function scheduleNext(
 
178
  accountPool: AccountPool,
179
  cookieJar: CookieJar,
180
  proxyPool?: ProxyPool,
181
+ usageStats?: UsageStatsStore,
182
  ): void {
183
  _accountPool = accountPool;
184
  _cookieJar = cookieJar;
185
  _proxyPool = proxyPool ?? null;
186
+ _usageStats = usageStats ?? null;
187
 
188
  _refreshTimer = setTimeout(async () => {
189
  try {
src/auth/usage-stats.ts ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Usage Stats — time-series snapshot recording and aggregation.
3
+ *
4
+ * Records periodic snapshots of cumulative token usage across all accounts.
5
+ * Snapshots are persisted to data/usage-history.json and pruned to 7 days.
6
+ * Aggregation (delta computation, bucketing) happens on read.
7
+ */
8
+
9
+ import {
10
+ readFileSync,
11
+ writeFileSync,
12
+ renameSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ } from "fs";
16
+ import { resolve, dirname } from "path";
17
+ import { getDataDir } from "../paths.js";
18
+ import type { AccountPool } from "./account-pool.js";
19
+
20
+ // ── Types ──────────────────────────────────────────────────────────
21
+
22
+ export interface UsageSnapshot {
23
+ timestamp: string; // ISO 8601
24
+ totals: {
25
+ input_tokens: number;
26
+ output_tokens: number;
27
+ request_count: number;
28
+ active_accounts: number;
29
+ };
30
+ }
31
+
32
+ interface UsageHistoryFile {
33
+ version: 1;
34
+ snapshots: UsageSnapshot[];
35
+ }
36
+
37
+ export interface UsageDataPoint {
38
+ timestamp: string;
39
+ input_tokens: number;
40
+ output_tokens: number;
41
+ request_count: number;
42
+ }
43
+
44
+ export interface UsageSummary {
45
+ total_input_tokens: number;
46
+ total_output_tokens: number;
47
+ total_request_count: number;
48
+ total_accounts: number;
49
+ active_accounts: number;
50
+ }
51
+
52
+ // ── Constants ──────────────────────────────────────────────────────
53
+
54
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
55
+ const HISTORY_FILE = "usage-history.json";
56
+
57
+ // ── Persistence interface (injectable for testing) ─────────────────
58
+
59
+ export interface UsageStatsPersistence {
60
+ load(): UsageHistoryFile;
61
+ save(data: UsageHistoryFile): void;
62
+ }
63
+
64
+ export function createFsUsageStatsPersistence(): UsageStatsPersistence {
65
+ function getFilePath(): string {
66
+ return resolve(getDataDir(), HISTORY_FILE);
67
+ }
68
+
69
+ return {
70
+ load(): UsageHistoryFile {
71
+ try {
72
+ const filePath = getFilePath();
73
+ if (!existsSync(filePath)) return { version: 1, snapshots: [] };
74
+ const raw = readFileSync(filePath, "utf-8");
75
+ const data = JSON.parse(raw) as UsageHistoryFile;
76
+ if (!Array.isArray(data.snapshots)) return { version: 1, snapshots: [] };
77
+ return data;
78
+ } catch {
79
+ return { version: 1, snapshots: [] };
80
+ }
81
+ },
82
+
83
+ save(data: UsageHistoryFile): void {
84
+ try {
85
+ const filePath = getFilePath();
86
+ const dir = dirname(filePath);
87
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
88
+ const tmpFile = filePath + ".tmp";
89
+ writeFileSync(tmpFile, JSON.stringify(data), "utf-8");
90
+ renameSync(tmpFile, filePath);
91
+ } catch (err) {
92
+ console.error("[UsageStats] Failed to persist:", err instanceof Error ? err.message : err);
93
+ }
94
+ },
95
+ };
96
+ }
97
+
98
+ // ── Store ──────────────────────────────────────────────────────────
99
+
100
+ export class UsageStatsStore {
101
+ private persistence: UsageStatsPersistence;
102
+ private snapshots: UsageSnapshot[];
103
+
104
+ constructor(persistence?: UsageStatsPersistence) {
105
+ this.persistence = persistence ?? createFsUsageStatsPersistence();
106
+ this.snapshots = this.persistence.load().snapshots;
107
+ }
108
+
109
+ /** Take a snapshot of current cumulative usage across all accounts. */
110
+ recordSnapshot(pool: AccountPool): void {
111
+ const entries = pool.getAllEntries();
112
+ const now = new Date().toISOString();
113
+
114
+ let input_tokens = 0;
115
+ let output_tokens = 0;
116
+ let request_count = 0;
117
+ let active_accounts = 0;
118
+
119
+ for (const entry of entries) {
120
+ input_tokens += entry.usage.input_tokens;
121
+ output_tokens += entry.usage.output_tokens;
122
+ request_count += entry.usage.request_count;
123
+ if (entry.status === "active") active_accounts++;
124
+ }
125
+
126
+ this.snapshots.push({
127
+ timestamp: now,
128
+ totals: { input_tokens, output_tokens, request_count, active_accounts },
129
+ });
130
+
131
+ // Prune old snapshots
132
+ const cutoff = Date.now() - MAX_AGE_MS;
133
+ this.snapshots = this.snapshots.filter(
134
+ (s) => new Date(s.timestamp).getTime() >= cutoff,
135
+ );
136
+
137
+ this.persistence.save({ version: 1, snapshots: this.snapshots });
138
+ }
139
+
140
+ /** Get current cumulative summary from live pool data. */
141
+ getSummary(pool: AccountPool): UsageSummary {
142
+ const entries = pool.getAllEntries();
143
+ let total_input_tokens = 0;
144
+ let total_output_tokens = 0;
145
+ let total_request_count = 0;
146
+ let active_accounts = 0;
147
+
148
+ for (const entry of entries) {
149
+ total_input_tokens += entry.usage.input_tokens;
150
+ total_output_tokens += entry.usage.output_tokens;
151
+ total_request_count += entry.usage.request_count;
152
+ if (entry.status === "active") active_accounts++;
153
+ }
154
+
155
+ return {
156
+ total_input_tokens,
157
+ total_output_tokens,
158
+ total_request_count,
159
+ total_accounts: entries.length,
160
+ active_accounts,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Get usage history as delta data points, aggregated by granularity.
166
+ * @param hours - how many hours of history to return
167
+ * @param granularity - "raw" | "hourly" | "daily"
168
+ */
169
+ getHistory(
170
+ hours: number,
171
+ granularity: "raw" | "hourly" | "daily",
172
+ ): UsageDataPoint[] {
173
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
174
+ const filtered = this.snapshots.filter(
175
+ (s) => new Date(s.timestamp).getTime() >= cutoff,
176
+ );
177
+
178
+ if (filtered.length < 2) return [];
179
+
180
+ // Compute deltas between consecutive snapshots
181
+ const deltas: UsageDataPoint[] = [];
182
+ for (let i = 1; i < filtered.length; i++) {
183
+ const prev = filtered[i - 1].totals;
184
+ const curr = filtered[i].totals;
185
+ deltas.push({
186
+ timestamp: filtered[i].timestamp,
187
+ input_tokens: Math.max(0, curr.input_tokens - prev.input_tokens),
188
+ output_tokens: Math.max(0, curr.output_tokens - prev.output_tokens),
189
+ request_count: Math.max(0, curr.request_count - prev.request_count),
190
+ });
191
+ }
192
+
193
+ if (granularity === "raw") return deltas;
194
+
195
+ // Bucket into time intervals
196
+ const bucketMs = granularity === "hourly" ? 3600_000 : 86400_000;
197
+ return bucketize(deltas, bucketMs);
198
+ }
199
+
200
+ /** Get raw snapshot count (for testing). */
201
+ get snapshotCount(): number {
202
+ return this.snapshots.length;
203
+ }
204
+ }
205
+
206
+ function bucketize(deltas: UsageDataPoint[], bucketMs: number): UsageDataPoint[] {
207
+ if (deltas.length === 0) return [];
208
+
209
+ const buckets = new Map<number, UsageDataPoint>();
210
+
211
+ for (const d of deltas) {
212
+ const t = new Date(d.timestamp).getTime();
213
+ const bucketKey = Math.floor(t / bucketMs) * bucketMs;
214
+
215
+ const existing = buckets.get(bucketKey);
216
+ if (existing) {
217
+ existing.input_tokens += d.input_tokens;
218
+ existing.output_tokens += d.output_tokens;
219
+ existing.request_count += d.request_count;
220
+ } else {
221
+ buckets.set(bucketKey, {
222
+ timestamp: new Date(bucketKey).toISOString(),
223
+ input_tokens: d.input_tokens,
224
+ output_tokens: d.output_tokens,
225
+ request_count: d.request_count,
226
+ });
227
+ }
228
+ }
229
+
230
+ return [...buckets.values()].sort(
231
+ (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
232
+ );
233
+ }
src/index.ts CHANGED
@@ -25,6 +25,7 @@ import { initTransport } from "./tls/transport.js";
25
  import { loadStaticModels } from "./models/model-store.js";
26
  import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
27
  import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js";
 
28
 
29
  export interface ServerHandle {
30
  close: () => Promise<void>;
@@ -77,7 +78,8 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
77
  const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool);
78
  const responsesRoutes = createResponsesRoutes(accountPool, cookieJar, proxyPool);
79
  const proxyRoutes = createProxyRoutes(proxyPool, accountPool);
80
- const webRoutes = createWebRoutes(accountPool);
 
81
 
82
  app.route("/", authRoutes);
83
  app.route("/", accountRoutes);
@@ -128,7 +130,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
128
  startModelRefresh(accountPool, cookieJar, proxyPool);
129
 
130
  // Start background quota refresh
131
- startQuotaRefresh(accountPool, cookieJar, proxyPool);
132
 
133
  // Start proxy health check timer (if proxies exist)
134
  proxyPool.startHealthCheckTimer();
 
25
  import { loadStaticModels } from "./models/model-store.js";
26
  import { startModelRefresh, stopModelRefresh } from "./models/model-fetcher.js";
27
  import { startQuotaRefresh, stopQuotaRefresh } from "./auth/usage-refresher.js";
28
+ import { UsageStatsStore } from "./auth/usage-stats.js";
29
 
30
  export interface ServerHandle {
31
  close: () => Promise<void>;
 
78
  const geminiRoutes = createGeminiRoutes(accountPool, cookieJar, proxyPool);
79
  const responsesRoutes = createResponsesRoutes(accountPool, cookieJar, proxyPool);
80
  const proxyRoutes = createProxyRoutes(proxyPool, accountPool);
81
+ const usageStats = new UsageStatsStore();
82
+ const webRoutes = createWebRoutes(accountPool, usageStats);
83
 
84
  app.route("/", authRoutes);
85
  app.route("/", accountRoutes);
 
130
  startModelRefresh(accountPool, cookieJar, proxyPool);
131
 
132
  // Start background quota refresh
133
+ startQuotaRefresh(accountPool, cookieJar, proxyPool, usageStats);
134
 
135
  // Start proxy health check timer (if proxies exist)
136
  proxyPool.startHealthCheckTimer();
src/routes/__tests__/usage-stats.test.ts ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for usage stats API routes.
3
+ */
4
+
5
+ import { describe, it, expect, vi } from "vitest";
6
+
7
+ vi.mock("../../paths.js", () => ({
8
+ getDataDir: vi.fn(() => "/tmp/test-data"),
9
+ }));
10
+
11
+ import { Hono } from "hono";
12
+ import { UsageStatsStore, type UsageStatsPersistence, type UsageSnapshot } from "../../auth/usage-stats.js";
13
+ import { createUsageStatsRoutes } from "../admin/usage-stats.js";
14
+ import type { AccountPool } from "../../auth/account-pool.js";
15
+
16
+ function createMockPool(totals: { input_tokens: number; output_tokens: number; request_count: number }): AccountPool {
17
+ return {
18
+ getAllEntries: () => [
19
+ {
20
+ id: "e1",
21
+ status: "active",
22
+ usage: totals,
23
+ },
24
+ ],
25
+ } as unknown as AccountPool;
26
+ }
27
+
28
+ function createStore(snapshots: UsageSnapshot[] = []): UsageStatsStore {
29
+ const persistence: UsageStatsPersistence = {
30
+ load: () => ({ version: 1, snapshots: [...snapshots] }),
31
+ save: vi.fn(),
32
+ };
33
+ return new UsageStatsStore(persistence);
34
+ }
35
+
36
+ describe("usage stats routes", () => {
37
+ describe("GET /admin/usage-stats/summary", () => {
38
+ it("returns cumulative totals", async () => {
39
+ const pool = createMockPool({ input_tokens: 5000, output_tokens: 1000, request_count: 20 });
40
+ const store = createStore();
41
+ const app = new Hono();
42
+ app.route("/", createUsageStatsRoutes(pool, store));
43
+
44
+ const res = await app.request("/admin/usage-stats/summary");
45
+ expect(res.status).toBe(200);
46
+
47
+ const body = await res.json();
48
+ expect(body.total_input_tokens).toBe(5000);
49
+ expect(body.total_output_tokens).toBe(1000);
50
+ expect(body.total_request_count).toBe(20);
51
+ expect(body.total_accounts).toBe(1);
52
+ expect(body.active_accounts).toBe(1);
53
+ });
54
+ });
55
+
56
+ describe("GET /admin/usage-stats/history", () => {
57
+ it("returns empty data_points when no history", async () => {
58
+ const pool = createMockPool({ input_tokens: 0, output_tokens: 0, request_count: 0 });
59
+ const store = createStore();
60
+ const app = new Hono();
61
+ app.route("/", createUsageStatsRoutes(pool, store));
62
+
63
+ const res = await app.request("/admin/usage-stats/history");
64
+ expect(res.status).toBe(200);
65
+
66
+ const body = await res.json();
67
+ expect(body.granularity).toBe("hourly");
68
+ expect(body.data_points).toEqual([]);
69
+ });
70
+
71
+ it("returns delta data points with raw granularity", async () => {
72
+ const now = Date.now();
73
+ const snapshots: UsageSnapshot[] = [
74
+ {
75
+ timestamp: new Date(now - 3600_000).toISOString(),
76
+ totals: { input_tokens: 100, output_tokens: 10, request_count: 1, active_accounts: 1 },
77
+ },
78
+ {
79
+ timestamp: new Date(now).toISOString(),
80
+ totals: { input_tokens: 500, output_tokens: 50, request_count: 5, active_accounts: 1 },
81
+ },
82
+ ];
83
+
84
+ const pool = createMockPool({ input_tokens: 500, output_tokens: 50, request_count: 5 });
85
+ const store = createStore(snapshots);
86
+ const app = new Hono();
87
+ app.route("/", createUsageStatsRoutes(pool, store));
88
+
89
+ const res = await app.request("/admin/usage-stats/history?granularity=raw&hours=2");
90
+ expect(res.status).toBe(200);
91
+
92
+ const body = await res.json();
93
+ expect(body.granularity).toBe("raw");
94
+ expect(body.data_points).toHaveLength(1);
95
+ expect(body.data_points[0].input_tokens).toBe(400);
96
+ });
97
+
98
+ it("rejects invalid granularity", async () => {
99
+ const pool = createMockPool({ input_tokens: 0, output_tokens: 0, request_count: 0 });
100
+ const store = createStore();
101
+ const app = new Hono();
102
+ app.route("/", createUsageStatsRoutes(pool, store));
103
+
104
+ const res = await app.request("/admin/usage-stats/history?granularity=yearly");
105
+ expect(res.status).toBe(400);
106
+ });
107
+ });
108
+ });
src/routes/admin/usage-stats.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Usage stats API routes.
3
+ *
4
+ * GET /admin/usage-stats/summary — current cumulative totals
5
+ * GET /admin/usage-stats/history — time-series delta data points
6
+ */
7
+
8
+ import { Hono } from "hono";
9
+ import type { AccountPool } from "../../auth/account-pool.js";
10
+ import type { UsageStatsStore } from "../../auth/usage-stats.js";
11
+
12
+ export function createUsageStatsRoutes(
13
+ pool: AccountPool,
14
+ statsStore: UsageStatsStore,
15
+ ): Hono {
16
+ const app = new Hono();
17
+
18
+ app.get("/admin/usage-stats/summary", (c) => {
19
+ return c.json(statsStore.getSummary(pool));
20
+ });
21
+
22
+ app.get("/admin/usage-stats/history", (c) => {
23
+ const granularity = c.req.query("granularity") ?? "hourly";
24
+ if (granularity !== "raw" && granularity !== "hourly" && granularity !== "daily") {
25
+ c.status(400);
26
+ return c.json({ error: "Invalid granularity. Must be raw, hourly, or daily." });
27
+ }
28
+
29
+ const hoursStr = c.req.query("hours") ?? "24";
30
+ const hours = Math.min(Math.max(1, parseInt(hoursStr, 10) || 24), 168);
31
+
32
+ const data_points = statsStore.getHistory(hours, granularity);
33
+
34
+ return c.json({
35
+ granularity,
36
+ hours,
37
+ data_points,
38
+ });
39
+ });
40
+
41
+ return app;
42
+ }
src/routes/web.ts CHANGED
@@ -8,8 +8,10 @@ import { createHealthRoutes } from "./admin/health.js";
8
  import { createUpdateRoutes } from "./admin/update.js";
9
  import { createConnectionRoutes } from "./admin/connection.js";
10
  import { createSettingsRoutes } from "./admin/settings.js";
 
 
11
 
12
- export function createWebRoutes(accountPool: AccountPool): Hono {
13
  const app = new Hono();
14
 
15
  const publicDir = getPublicDir();
@@ -68,6 +70,7 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
68
  app.route("/", createUpdateRoutes());
69
  app.route("/", createConnectionRoutes(accountPool));
70
  app.route("/", createSettingsRoutes());
 
71
 
72
  return app;
73
  }
 
8
  import { createUpdateRoutes } from "./admin/update.js";
9
  import { createConnectionRoutes } from "./admin/connection.js";
10
  import { createSettingsRoutes } from "./admin/settings.js";
11
+ import { createUsageStatsRoutes } from "./admin/usage-stats.js";
12
+ import type { UsageStatsStore } from "../auth/usage-stats.js";
13
 
14
+ export function createWebRoutes(accountPool: AccountPool, usageStats: UsageStatsStore): Hono {
15
  const app = new Hono();
16
 
17
  const publicDir = getPublicDir();
 
70
  app.route("/", createUpdateRoutes());
71
  app.route("/", createConnectionRoutes(accountPool));
72
  app.route("/", createSettingsRoutes());
73
+ app.route("/", createUsageStatsRoutes(accountPool, usageStats));
74
 
75
  return app;
76
  }
web/src/App.tsx CHANGED
@@ -16,6 +16,7 @@ import { TestConnection } from "./components/TestConnection";
16
  import { Footer } from "./components/Footer";
17
  import { ProxySettings } from "./pages/ProxySettings";
18
  import { AccountManagement } from "./pages/AccountManagement";
 
19
  import { useAccounts } from "../../shared/hooks/use-accounts";
20
  import { useProxies } from "../../shared/hooks/use-proxies";
21
  import { useStatus } from "../../shared/hooks/use-status";
@@ -185,15 +186,22 @@ function useHash(): string {
185
  return hash;
186
  }
187
 
 
 
 
 
 
 
 
 
 
188
  export function App() {
189
  const hash = useHash();
190
- const isProxySettings = hash === "#/proxy-settings";
191
- const isAccountManagement = hash === "#/account-management";
192
 
193
  return (
194
  <I18nProvider>
195
  <ThemeProvider>
196
- {isProxySettings ? <ProxySettingsPage /> : isAccountManagement ? <AccountManagement /> : <Dashboard />}
197
  </ThemeProvider>
198
  </I18nProvider>
199
  );
 
16
  import { Footer } from "./components/Footer";
17
  import { ProxySettings } from "./pages/ProxySettings";
18
  import { AccountManagement } from "./pages/AccountManagement";
19
+ import { UsageStats } from "./pages/UsageStats";
20
  import { useAccounts } from "../../shared/hooks/use-accounts";
21
  import { useProxies } from "../../shared/hooks/use-proxies";
22
  import { useStatus } from "../../shared/hooks/use-status";
 
186
  return hash;
187
  }
188
 
189
+ function PageRouter({ hash }: { hash: string }) {
190
+ switch (hash) {
191
+ case "#/proxy-settings": return <ProxySettingsPage />;
192
+ case "#/account-management": return <AccountManagement />;
193
+ case "#/usage-stats": return <UsageStats />;
194
+ default: return <Dashboard />;
195
+ }
196
+ }
197
+
198
  export function App() {
199
  const hash = useHash();
 
 
200
 
201
  return (
202
  <I18nProvider>
203
  <ThemeProvider>
204
+ <PageRouter hash={hash} />
205
  </ThemeProvider>
206
  </I18nProvider>
207
  );
web/src/components/AccountList.tsx CHANGED
@@ -76,6 +76,12 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
76
  >
77
  {t("manageAccounts")} &rarr;
78
  </a>
 
 
 
 
 
 
79
  {onExport && onImport && (
80
  <AccountImportExport onExport={onExport} onImport={onImport} selectedIds={selectedIds} />
81
  )}
 
76
  >
77
  {t("manageAccounts")} &rarr;
78
  </a>
79
+ <a
80
+ href="#/usage-stats"
81
+ class="text-[0.75rem] text-primary hover:text-primary/80 font-medium transition-colors"
82
+ >
83
+ {t("usageStats")} &rarr;
84
+ </a>
85
  {onExport && onImport && (
86
  <AccountImportExport onExport={onExport} onImport={onImport} selectedIds={selectedIds} />
87
  )}
web/src/components/UsageChart.tsx ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pure SVG line chart for token usage trends.
3
+ * No external chart library — renders <polyline> with axis labels.
4
+ */
5
+
6
+ import { useMemo } from "preact/hooks";
7
+ import type { UsageDataPoint } from "../../../shared/hooks/use-usage-stats";
8
+
9
+ interface UsageChartProps {
10
+ data: UsageDataPoint[];
11
+ height?: number;
12
+ }
13
+
14
+ const PADDING = { top: 20, right: 20, bottom: 40, left: 65 };
15
+
16
+ export function formatNumber(n: number): string {
17
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
18
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
19
+ return String(n);
20
+ }
21
+
22
+ function formatTime(iso: string): string {
23
+ const d = new Date(iso);
24
+ return `${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
25
+ }
26
+
27
+ export function UsageChart({ data, height = 260 }: UsageChartProps) {
28
+ const width = 720; // SVG viewBox width, responsive via CSS
29
+
30
+ const reqHeight = Math.round(height * 0.6);
31
+
32
+ const { inputPoints, outputPoints, requestPoints, xLabels, yTokenLabels, yReqLabels } = useMemo(() => {
33
+ if (data.length === 0) {
34
+ return { inputPoints: "", outputPoints: "", requestPoints: "", xLabels: [], yTokenLabels: [], yReqLabels: [] };
35
+ }
36
+
37
+ const chartW = width - PADDING.left - PADDING.right;
38
+ const chartH = height - PADDING.top - PADDING.bottom;
39
+ const reqChartH = reqHeight - PADDING.top - PADDING.bottom;
40
+
41
+ const maxInput = Math.max(...data.map((d) => d.input_tokens));
42
+ const maxOutput = Math.max(...data.map((d) => d.output_tokens));
43
+ const yMaxT = Math.max(maxInput, maxOutput, 1);
44
+ const yMaxR = Math.max(...data.map((d) => d.request_count), 1);
45
+
46
+ const toX = (i: number) => PADDING.left + (i / Math.max(data.length - 1, 1)) * chartW;
47
+ const toYTokens = (v: number) => PADDING.top + chartH - (v / yMaxT) * chartH;
48
+ const toYReqs = (v: number) => PADDING.top + reqChartH - (v / yMaxR) * reqChartH;
49
+
50
+ const inp = data.map((d, i) => `${toX(i)},${toYTokens(d.input_tokens)}`).join(" ");
51
+ const out = data.map((d, i) => `${toX(i)},${toYTokens(d.output_tokens)}`).join(" ");
52
+ const req = data.map((d, i) => `${toX(i)},${toYReqs(d.request_count)}`).join(" ");
53
+
54
+ // X axis labels (up to 6)
55
+ const step = Math.max(1, Math.floor(data.length / 5));
56
+ const xl = [];
57
+ for (let i = 0; i < data.length; i += step) {
58
+ xl.push({ x: toX(i), label: formatTime(data[i].timestamp) });
59
+ }
60
+
61
+ // Y axis labels (5 ticks)
62
+ const yTL = [];
63
+ const yRL = [];
64
+ for (let i = 0; i <= 4; i++) {
65
+ const frac = i / 4;
66
+ yTL.push({ y: PADDING.top + chartH - frac * chartH, label: formatNumber(Math.round(yMaxT * frac)) });
67
+ yRL.push({ y: PADDING.top + reqChartH - frac * reqChartH, label: formatNumber(Math.round(yMaxR * frac)) });
68
+ }
69
+
70
+ return { inputPoints: inp, outputPoints: out, requestPoints: req, xLabels: xl, yTokenLabels: yTL, yReqLabels: yRL };
71
+ }, [data, height, reqHeight]);
72
+
73
+ if (data.length === 0) {
74
+ return (
75
+ <div class="text-center py-12 text-slate-400 dark:text-text-dim text-sm">
76
+ No usage data yet
77
+ </div>
78
+ );
79
+ }
80
+
81
+ return (
82
+ <div class="space-y-6">
83
+ {/* Token chart */}
84
+ <div>
85
+ <div class="flex items-center gap-4 mb-2 text-xs text-slate-500 dark:text-text-dim">
86
+ <span class="flex items-center gap-1">
87
+ <span class="inline-block w-3 h-0.5 bg-blue-500 rounded" /> Input Tokens
88
+ </span>
89
+ <span class="flex items-center gap-1">
90
+ <span class="inline-block w-3 h-0.5 bg-emerald-500 rounded" /> Output Tokens
91
+ </span>
92
+ </div>
93
+ <svg
94
+ viewBox={`0 0 ${width} ${height}`}
95
+ class="w-full"
96
+ style={{ maxHeight: `${height}px` }}
97
+ >
98
+ {/* Grid lines */}
99
+ {yTokenLabels.map((tick) => (
100
+ <line
101
+ key={`grid-${tick.y}`}
102
+ x1={PADDING.left}
103
+ y1={tick.y}
104
+ x2={width - PADDING.right}
105
+ y2={tick.y}
106
+ stroke="currentColor"
107
+ class="text-gray-200 dark:text-border-dark"
108
+ stroke-width="0.5"
109
+ />
110
+ ))}
111
+
112
+ {/* Y axis labels */}
113
+ {yTokenLabels.map((tick) => (
114
+ <text
115
+ key={`yl-${tick.y}`}
116
+ x={PADDING.left - 8}
117
+ y={tick.y + 3}
118
+ text-anchor="end"
119
+ class="fill-slate-400 dark:fill-text-dim"
120
+ font-size="10"
121
+ >
122
+ {tick.label}
123
+ </text>
124
+ ))}
125
+
126
+ {/* X axis labels */}
127
+ {xLabels.map((tick) => (
128
+ <text
129
+ key={`xl-${tick.x}`}
130
+ x={tick.x}
131
+ y={height - 8}
132
+ text-anchor="middle"
133
+ class="fill-slate-400 dark:fill-text-dim"
134
+ font-size="9"
135
+ >
136
+ {tick.label}
137
+ </text>
138
+ ))}
139
+
140
+ {/* Lines */}
141
+ <polyline
142
+ points={inputPoints}
143
+ fill="none"
144
+ stroke="#3b82f6"
145
+ stroke-width="2"
146
+ stroke-linejoin="round"
147
+ />
148
+ <polyline
149
+ points={outputPoints}
150
+ fill="none"
151
+ stroke="#10b981"
152
+ stroke-width="2"
153
+ stroke-linejoin="round"
154
+ />
155
+ </svg>
156
+ </div>
157
+
158
+ {/* Request count chart */}
159
+ <div>
160
+ <div class="flex items-center gap-4 mb-2 text-xs text-slate-500 dark:text-text-dim">
161
+ <span class="flex items-center gap-1">
162
+ <span class="inline-block w-3 h-0.5 bg-amber-500 rounded" /> Requests
163
+ </span>
164
+ </div>
165
+ <svg
166
+ viewBox={`0 0 ${width} ${reqHeight}`}
167
+ class="w-full"
168
+ style={{ maxHeight: `${reqHeight}px` }}
169
+ >
170
+ {/* Grid lines */}
171
+ {yReqLabels.map((tick) => (
172
+ <line
173
+ key={`rgrid-${tick.y}`}
174
+ x1={PADDING.left}
175
+ y1={tick.y}
176
+ x2={width - PADDING.right}
177
+ y2={tick.y}
178
+ stroke="currentColor"
179
+ class="text-gray-200 dark:text-border-dark"
180
+ stroke-width="0.5"
181
+ />
182
+ ))}
183
+
184
+ {yReqLabels.map((tick) => (
185
+ <text
186
+ key={`ryl-${tick.y}`}
187
+ x={PADDING.left - 8}
188
+ y={tick.y + 3}
189
+ text-anchor="end"
190
+ class="fill-slate-400 dark:fill-text-dim"
191
+ font-size="10"
192
+ >
193
+ {tick.label}
194
+ </text>
195
+ ))}
196
+
197
+ {xLabels.map((tick) => (
198
+ <text
199
+ key={`rxl-${tick.x}`}
200
+ x={tick.x}
201
+ y={reqHeight - 8}
202
+ text-anchor="middle"
203
+ class="fill-slate-400 dark:fill-text-dim"
204
+ font-size="9"
205
+ >
206
+ {tick.label}
207
+ </text>
208
+ ))}
209
+
210
+ <polyline
211
+ points={requestPoints}
212
+ fill="none"
213
+ stroke="#f59e0b"
214
+ stroke-width="2"
215
+ stroke-linejoin="round"
216
+ />
217
+ </svg>
218
+ </div>
219
+ </div>
220
+ );
221
+ }
web/src/pages/UsageStats.tsx ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "preact/hooks";
2
+ import { useT } from "../../../shared/i18n/context";
3
+ import { useUsageSummary, useUsageHistory, type Granularity } from "../../../shared/hooks/use-usage-stats";
4
+ import { UsageChart, formatNumber } from "../components/UsageChart";
5
+ import type { TranslationKey } from "../../../shared/i18n/translations";
6
+
7
+ const granularityOptions: Array<{ value: Granularity; label: TranslationKey }> = [
8
+ { value: "hourly", label: "granularityHourly" },
9
+ { value: "daily", label: "granularityDaily" },
10
+ ];
11
+
12
+ const rangeOptions: Array<{ hours: number; label: TranslationKey }> = [
13
+ { hours: 24, label: "last24h" },
14
+ { hours: 72, label: "last3d" },
15
+ { hours: 168, label: "last7d" },
16
+ ];
17
+
18
+ export function UsageStats() {
19
+ const t = useT();
20
+ const { summary, loading: summaryLoading } = useUsageSummary();
21
+ const [granularity, setGranularity] = useState<Granularity>("hourly");
22
+ const [hours, setHours] = useState(24);
23
+ const { dataPoints, loading: historyLoading } = useUsageHistory(granularity, hours);
24
+
25
+ return (
26
+ <div class="min-h-screen bg-slate-50 dark:bg-bg-dark flex flex-col">
27
+ {/* Header */}
28
+ <header class="sticky top-0 z-50 bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark px-4 py-3">
29
+ <div class="max-w-[1100px] mx-auto flex items-center gap-3">
30
+ <a
31
+ href="#/"
32
+ class="text-sm text-slate-500 dark:text-text-dim hover:text-primary transition-colors"
33
+ >
34
+ &larr; {t("backToDashboard")}
35
+ </a>
36
+ <h1 class="text-base font-semibold text-slate-800 dark:text-text-main">
37
+ {t("usageStats")}
38
+ </h1>
39
+ </div>
40
+ </header>
41
+
42
+ <main class="flex-grow px-4 md:px-8 py-6 max-w-[1100px] mx-auto w-full">
43
+ {/* Summary cards */}
44
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
45
+ <SummaryCard
46
+ label={t("totalInputTokens")}
47
+ value={summaryLoading ? "—" : formatNumber(summary?.total_input_tokens ?? 0)}
48
+ />
49
+ <SummaryCard
50
+ label={t("totalOutputTokens")}
51
+ value={summaryLoading ? "—" : formatNumber(summary?.total_output_tokens ?? 0)}
52
+ />
53
+ <SummaryCard
54
+ label={t("totalRequestCount")}
55
+ value={summaryLoading ? "—" : formatNumber(summary?.total_request_count ?? 0)}
56
+ />
57
+ <SummaryCard
58
+ label={t("activeAccounts")}
59
+ value={summaryLoading ? "—" : `${summary?.active_accounts ?? 0} / ${summary?.total_accounts ?? 0}`}
60
+ />
61
+ </div>
62
+
63
+ {/* Controls */}
64
+ <div class="flex flex-wrap gap-2 mb-4">
65
+ {granularityOptions.map(({ value, label }) => (
66
+ <button
67
+ key={value}
68
+ onClick={() => setGranularity(value)}
69
+ class={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
70
+ granularity === value
71
+ ? "bg-primary text-white border-primary"
72
+ : "bg-white dark:bg-card-dark border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:border-primary/50"
73
+ }`}
74
+ >
75
+ {t(label)}
76
+ </button>
77
+ ))}
78
+ <div class="w-px h-5 bg-gray-200 dark:bg-border-dark self-center" />
79
+ {rangeOptions.map(({ hours: h, label }) => (
80
+ <button
81
+ key={h}
82
+ onClick={() => setHours(h)}
83
+ class={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
84
+ hours === h
85
+ ? "bg-primary text-white border-primary"
86
+ : "bg-white dark:bg-card-dark border-gray-200 dark:border-border-dark text-slate-600 dark:text-text-dim hover:border-primary/50"
87
+ }`}
88
+ >
89
+ {t(label)}
90
+ </button>
91
+ ))}
92
+ </div>
93
+
94
+ {/* Chart */}
95
+ <div class="bg-white dark:bg-card-dark rounded-xl border border-gray-200 dark:border-border-dark p-4">
96
+ {historyLoading ? (
97
+ <div class="text-center py-12 text-slate-400 dark:text-text-dim text-sm">Loading...</div>
98
+ ) : (
99
+ <UsageChart data={dataPoints} />
100
+ )}
101
+ </div>
102
+ </main>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ function SummaryCard({ label, value }: { label: string; value: string }) {
108
+ return (
109
+ <div class="bg-white dark:bg-card-dark rounded-xl border border-gray-200 dark:border-border-dark p-4">
110
+ <div class="text-xs text-slate-500 dark:text-text-dim mb-1">{label}</div>
111
+ <div class="text-lg font-semibold text-slate-800 dark:text-text-main">{value}</div>
112
+ </div>
113
+ );
114
+ }