@gajae-code/stats 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +82 -0
  2. package/build.ts +84 -0
  3. package/dist/client/index.css +1 -0
  4. package/dist/client/index.html +13 -0
  5. package/dist/client/index.js +257 -0
  6. package/dist/client/styles.css +1159 -0
  7. package/dist/types/aggregator.d.ts +65 -0
  8. package/dist/types/client/App.d.ts +1 -0
  9. package/dist/types/client/api.d.ts +10 -0
  10. package/dist/types/client/components/BehaviorChart.d.ts +6 -0
  11. package/dist/types/client/components/BehaviorModelsTable.d.ts +7 -0
  12. package/dist/types/client/components/BehaviorSummary.d.ts +7 -0
  13. package/dist/types/client/components/ChartsContainer.d.ts +7 -0
  14. package/dist/types/client/components/CostChart.d.ts +6 -0
  15. package/dist/types/client/components/CostSummary.d.ts +6 -0
  16. package/dist/types/client/components/Header.d.ts +12 -0
  17. package/dist/types/client/components/ModelsTable.d.ts +8 -0
  18. package/dist/types/client/components/RequestDetail.d.ts +6 -0
  19. package/dist/types/client/components/RequestList.d.ts +8 -0
  20. package/dist/types/client/components/StatsGrid.d.ts +6 -0
  21. package/dist/types/client/components/chart-shared.d.ts +187 -0
  22. package/dist/types/client/components/models-table-shared.d.ts +195 -0
  23. package/dist/types/client/components/range-meta.d.ts +21 -0
  24. package/dist/types/client/index.d.ts +1 -0
  25. package/dist/types/client/types.d.ts +62 -0
  26. package/dist/types/client/useSystemTheme.d.ts +2 -0
  27. package/dist/types/db.d.ts +93 -0
  28. package/dist/types/index.d.ts +5 -0
  29. package/dist/types/parser.d.ts +40 -0
  30. package/dist/types/server.d.ts +7 -0
  31. package/dist/types/shared-types.d.ts +192 -0
  32. package/dist/types/sync-worker.d.ts +31 -0
  33. package/dist/types/types.d.ts +120 -0
  34. package/dist/types/user-metrics.d.ts +72 -0
  35. package/package.json +91 -0
  36. package/src/aggregator.ts +454 -0
  37. package/src/client/App.tsx +221 -0
  38. package/src/client/api.ts +65 -0
  39. package/src/client/components/BehaviorChart.tsx +189 -0
  40. package/src/client/components/BehaviorModelsTable.tsx +342 -0
  41. package/src/client/components/BehaviorSummary.tsx +95 -0
  42. package/src/client/components/ChartsContainer.tsx +221 -0
  43. package/src/client/components/CostChart.tsx +171 -0
  44. package/src/client/components/CostSummary.tsx +53 -0
  45. package/src/client/components/Header.tsx +72 -0
  46. package/src/client/components/ModelsTable.tsx +265 -0
  47. package/src/client/components/RequestDetail.tsx +172 -0
  48. package/src/client/components/RequestList.tsx +73 -0
  49. package/src/client/components/StatsGrid.tsx +135 -0
  50. package/src/client/components/chart-shared.tsx +320 -0
  51. package/src/client/components/models-table-shared.tsx +275 -0
  52. package/src/client/components/range-meta.ts +72 -0
  53. package/src/client/css.d.ts +1 -0
  54. package/src/client/index.tsx +6 -0
  55. package/src/client/styles.css +306 -0
  56. package/src/client/types.ts +78 -0
  57. package/src/client/useSystemTheme.ts +31 -0
  58. package/src/db.ts +1100 -0
  59. package/src/embedded-client.generated.txt +7 -0
  60. package/src/index.ts +182 -0
  61. package/src/parser.ts +334 -0
  62. package/src/server.ts +325 -0
  63. package/src/shared-types.ts +204 -0
  64. package/src/sync-worker.ts +40 -0
  65. package/src/types.ts +125 -0
  66. package/src/user-metrics.ts +686 -0
  67. package/tailwind.config.js +40 -0
@@ -0,0 +1,454 @@
1
+ import * as fs from "node:fs";
2
+ import { isCompiledBinary } from "@gajae-code/utils";
3
+ import {
4
+ getRecentErrors as dbGetRecentErrors,
5
+ getRecentRequests as dbGetRecentRequests,
6
+ getBehaviorByModel,
7
+ getBehaviorOverall,
8
+ getBehaviorTimeSeries,
9
+ getCostTimeSeries,
10
+ getFileOffset,
11
+ getMessageById,
12
+ getMessageCount,
13
+ getModelPerformanceSeries,
14
+ getModelTimeSeries,
15
+ getOverallStats,
16
+ getStatsByFolder,
17
+ getStatsByModel,
18
+ getTimeSeries,
19
+ initDb,
20
+ insertMessageStats,
21
+ insertUserMessageStats,
22
+ setFileOffset,
23
+ updateUserMessageLinks,
24
+ } from "./db";
25
+ import { getSessionEntry, listAllSessionFiles, type ParseSessionResult } from "./parser";
26
+ import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
27
+ // Worker entry. Bun's `--compile` bundler statically discovers the string
28
+ // literal in `new Worker("./packages/stats/src/sync-worker.ts", …)` below and
29
+ // emits the worker as an additional entrypoint (registered in
30
+ // `packages/coding-agent/scripts/build-binary.ts`). In dev runs we resolve
31
+ // the same source file through `import.meta.url`, so the literal only has to
32
+ // be valid relative to the `--root` directory (repo root). Importing the
33
+ // source as `with { type: "file" }` is NOT sufficient — that copies the file
34
+ // as a raw asset and does not bundle the worker's relative imports, so the
35
+ // worker would crash on first `import` (issue #1011, PR #1027).
36
+ import type { BehaviorDashboardStats, DashboardStats, MessageStats, RequestDetails } from "./types";
37
+
38
+ /**
39
+ * Apply a freshly parsed result to the database. Runs entirely on the
40
+ * main thread so the single SQLite handle owns every write.
41
+ */
42
+ function applyParseResult(sessionFile: string, lastModified: number, result: ParseSessionResult): number {
43
+ if (result.stats.length > 0) insertMessageStats(result.stats);
44
+ if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
45
+ if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
46
+ setFileOffset(sessionFile, result.newOffset, lastModified);
47
+ return result.stats.length + result.userStats.length;
48
+ }
49
+
50
+ /**
51
+ * Progress event emitted after each session file is fully processed.
52
+ * `current` is the number of files completed (skipped + parsed),
53
+ * `total` is the size of the work set. `processed` is the running total
54
+ * of inserted rows.
55
+ */
56
+ export interface SyncProgress {
57
+ current: number;
58
+ total: number;
59
+ processed: number;
60
+ sessionFile: string;
61
+ }
62
+
63
+ export interface SyncOptions {
64
+ /** Called after each file completes. Synchronous; keep it cheap. */
65
+ onProgress?: (event: SyncProgress) => void;
66
+ /**
67
+ * Worker pool size. Defaults to a sensible value derived from the host
68
+ * (capped to avoid drowning a small machine in workers). Set to `1` to
69
+ * force serial parsing without spawning workers.
70
+ */
71
+ workers?: number;
72
+ }
73
+
74
+ function defaultWorkerCount(): number {
75
+ // `navigator.hardwareConcurrency` is the portable answer in Bun; fall
76
+ // back to a small fixed pool if it's somehow unavailable.
77
+ const hw = typeof navigator !== "undefined" ? (navigator.hardwareConcurrency ?? 0) : 0;
78
+ const raw = hw > 0 ? hw : 4;
79
+ // Cap at 8 - parse is JSON-bound, and SQLite writes serialize on main
80
+ // thread anyway, so more workers stop helping.
81
+ return Math.min(8, Math.max(2, Math.floor(raw)));
82
+ }
83
+
84
+ interface WorkerHandle {
85
+ worker: Worker;
86
+ busy: boolean;
87
+ resolve: ((res: ParseSessionResult) => void) | null;
88
+ reject: ((err: Error) => void) | null;
89
+ }
90
+
91
+ /**
92
+ * Create a fresh sync worker. In a `--compile` binary the literal-string
93
+ * specifier is what Bun's static analyzer needs (the file is also listed as
94
+ * an additional `--compile` entrypoint in
95
+ * `packages/coding-agent/scripts/build-binary.ts`). In dev runs we resolve
96
+ * the source URL via `import.meta.url` so the worker survives `cwd` changes
97
+ * by callers.
98
+ */
99
+ function createSyncWorker(): Worker {
100
+ return isCompiledBinary()
101
+ ? new Worker("./packages/stats/src/sync-worker.ts", { type: "module" })
102
+ : new Worker(new URL("./sync-worker.ts", import.meta.url).href, { type: "module" });
103
+ }
104
+
105
+ function spawnWorker(): WorkerHandle {
106
+ const worker = createSyncWorker();
107
+ const handle: WorkerHandle = { worker, busy: false, resolve: null, reject: null };
108
+ worker.onmessage = (event: MessageEvent<SyncWorkerResponse>) => {
109
+ const { resolve, reject } = handle;
110
+ handle.resolve = null;
111
+ handle.reject = null;
112
+ handle.busy = false;
113
+ if (!resolve || !reject) return;
114
+ const data = event.data;
115
+ if (!data.ok) {
116
+ reject(new Error(data.error));
117
+ return;
118
+ }
119
+ if (data.kind === "pong") {
120
+ reject(new Error("sync worker: unexpected pong on parse channel"));
121
+ return;
122
+ }
123
+ resolve(data.result);
124
+ };
125
+ worker.onerror = (event: ErrorEvent) => {
126
+ const { reject } = handle;
127
+ handle.resolve = null;
128
+ handle.reject = null;
129
+ handle.busy = false;
130
+ reject?.(event.error instanceof Error ? event.error : new Error(event.message || "worker error"));
131
+ };
132
+ return handle;
133
+ }
134
+
135
+ function dispatch(handle: WorkerHandle, request: SyncWorkerRequest): Promise<ParseSessionResult> {
136
+ if (handle.busy) {
137
+ return Promise.reject(new Error("worker is busy - this is a bug in the dispatcher"));
138
+ }
139
+ const { promise, resolve, reject } = Promise.withResolvers<ParseSessionResult>();
140
+ handle.busy = true;
141
+ handle.resolve = resolve;
142
+ handle.reject = reject;
143
+ handle.worker.postMessage(request);
144
+ return promise;
145
+ }
146
+
147
+ /**
148
+ * Smoke test: spawns one sync worker, pings it, asserts the pong response,
149
+ * then terminates. Used by `gjc --smoke-test` so the install-method CI jobs
150
+ * catch the silent worker-load failure that hit compiled binaries in #1011
151
+ * and #1027 — neither `--version` nor `stats --summary` exercises the worker
152
+ * spawn path on a fresh install (no session files = early return), so a
153
+ * dedicated probe is the only reliable signal.
154
+ *
155
+ * Resolves with the worker's `import.meta.url` (caller-visible diagnostics);
156
+ * rejects on transport error, error response, or timeout.
157
+ */
158
+ export async function smokeTestSyncWorker({ timeoutMs = 30_000 }: { timeoutMs?: number } = {}): Promise<void> {
159
+ const worker = createSyncWorker();
160
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
161
+ const timer = setTimeout(() => reject(new Error(`sync worker did not pong within ${timeoutMs}ms`)), timeoutMs);
162
+ worker.onmessage = (event: MessageEvent<SyncWorkerResponse>) => {
163
+ const data = event.data;
164
+ if (!data.ok) {
165
+ reject(new Error(data.error));
166
+ return;
167
+ }
168
+ if (data.kind !== "pong") {
169
+ reject(new Error(`sync worker: expected pong, got ${JSON.stringify(data)}`));
170
+ return;
171
+ }
172
+ resolve();
173
+ };
174
+ worker.onerror = (event: ErrorEvent) => {
175
+ reject(event.error instanceof Error ? event.error : new Error(event.message || "worker error"));
176
+ };
177
+ try {
178
+ worker.postMessage({ kind: "ping" } satisfies SyncWorkerRequest);
179
+ await promise;
180
+ } finally {
181
+ clearTimeout(timer);
182
+ worker.terminate();
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Sync all session files to the database.
188
+ *
189
+ * Parsing fans out across a worker pool (one in-flight job per worker)
190
+ * while DB writes and offset bookkeeping stay on the calling thread so the
191
+ * single SQLite handle stays uncontended. `onProgress` fires once per
192
+ * completed file (skipped files included so the bar walks at a steady
193
+ * rate).
194
+ */
195
+ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
196
+ await initDb();
197
+
198
+ const files = await listAllSessionFiles();
199
+ if (files.length === 0) return { processed: 0, files: 0 };
200
+
201
+ let totalProcessed = 0;
202
+ let filesProcessed = 0;
203
+ let completed = 0;
204
+ let cursor = 0;
205
+
206
+ const poolSize = Math.max(1, Math.min(files.length, opts?.workers ?? defaultWorkerCount()));
207
+ const handles: WorkerHandle[] = [];
208
+ for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
209
+
210
+ const report = (sessionFile: string) => {
211
+ completed++;
212
+ opts?.onProgress?.({
213
+ current: completed,
214
+ total: files.length,
215
+ processed: totalProcessed,
216
+ sessionFile,
217
+ });
218
+ };
219
+
220
+ async function drain(handle: WorkerHandle): Promise<void> {
221
+ while (true) {
222
+ const idx = cursor++;
223
+ if (idx >= files.length) return;
224
+ const sessionFile = files[idx];
225
+
226
+ let fileStats: fs.Stats;
227
+ try {
228
+ fileStats = await fs.promises.stat(sessionFile);
229
+ } catch {
230
+ report(sessionFile);
231
+ continue;
232
+ }
233
+ const lastModified = fileStats.mtimeMs;
234
+ const stored = getFileOffset(sessionFile);
235
+ if (stored && stored.lastModified >= lastModified) {
236
+ report(sessionFile);
237
+ continue;
238
+ }
239
+
240
+ const fromOffset = stored?.offset ?? 0;
241
+ const result = await dispatch(handle, { sessionFile, fromOffset });
242
+ const inserted = applyParseResult(sessionFile, lastModified, result);
243
+ if (inserted > 0) {
244
+ totalProcessed += inserted;
245
+ filesProcessed++;
246
+ }
247
+ report(sessionFile);
248
+ }
249
+ }
250
+
251
+ try {
252
+ await Promise.all(handles.map(drain));
253
+ } finally {
254
+ for (const handle of handles) handle.worker.terminate();
255
+ }
256
+
257
+ return { processed: totalProcessed, files: filesProcessed };
258
+ }
259
+
260
+ const HOUR_MS = 60 * 60 * 1000;
261
+ const DAY_MS = 24 * HOUR_MS;
262
+
263
+ type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
264
+
265
+ interface TimeRangeConfig {
266
+ timeSeriesHours: number;
267
+ timeSeriesBucketMs: number;
268
+ modelSeriesDays: number;
269
+ modelSeriesBucketMs: number;
270
+ modelPerformanceDays: number;
271
+ modelPerformanceBucketMs: number;
272
+ costSeriesDays: number;
273
+ cutoff: number | null;
274
+ }
275
+
276
+ const DEFAULT_TIME_RANGE: TimeRange = "24h";
277
+
278
+ const TIME_RANGE_TO_CONFIG: Record<TimeRange, Omit<TimeRangeConfig, "cutoff">> = {
279
+ "1h": {
280
+ timeSeriesHours: 1,
281
+ timeSeriesBucketMs: HOUR_MS,
282
+ modelSeriesDays: 1,
283
+ modelSeriesBucketMs: HOUR_MS,
284
+ modelPerformanceDays: 1,
285
+ modelPerformanceBucketMs: HOUR_MS,
286
+ costSeriesDays: 1,
287
+ },
288
+ "24h": {
289
+ timeSeriesHours: 24,
290
+ timeSeriesBucketMs: HOUR_MS,
291
+ modelSeriesDays: 1,
292
+ modelSeriesBucketMs: HOUR_MS,
293
+ modelPerformanceDays: 1,
294
+ modelPerformanceBucketMs: HOUR_MS,
295
+ costSeriesDays: 1,
296
+ },
297
+ "7d": {
298
+ timeSeriesHours: 24 * 7,
299
+ timeSeriesBucketMs: DAY_MS,
300
+ modelSeriesDays: 7,
301
+ modelSeriesBucketMs: DAY_MS,
302
+ modelPerformanceDays: 7,
303
+ modelPerformanceBucketMs: DAY_MS,
304
+ costSeriesDays: 7,
305
+ },
306
+ "30d": {
307
+ timeSeriesHours: 24 * 30,
308
+ timeSeriesBucketMs: DAY_MS,
309
+ modelSeriesDays: 30,
310
+ modelSeriesBucketMs: DAY_MS,
311
+ modelPerformanceDays: 30,
312
+ modelPerformanceBucketMs: DAY_MS,
313
+ costSeriesDays: 30,
314
+ },
315
+ "90d": {
316
+ timeSeriesHours: 24 * 90,
317
+ timeSeriesBucketMs: DAY_MS,
318
+ modelSeriesDays: 90,
319
+ modelSeriesBucketMs: DAY_MS,
320
+ modelPerformanceDays: 90,
321
+ modelPerformanceBucketMs: DAY_MS,
322
+ costSeriesDays: 90,
323
+ },
324
+ all: {
325
+ timeSeriesHours: 24 * 3650,
326
+ timeSeriesBucketMs: DAY_MS,
327
+ modelSeriesDays: 3650,
328
+ modelSeriesBucketMs: DAY_MS,
329
+ modelPerformanceDays: 3650,
330
+ modelPerformanceBucketMs: DAY_MS,
331
+ costSeriesDays: 3650,
332
+ },
333
+ };
334
+
335
+ function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
336
+ const normalized = range?.trim().toLowerCase() ?? DEFAULT_TIME_RANGE;
337
+ const config = TIME_RANGE_TO_CONFIG[normalized as TimeRange];
338
+ if (config) {
339
+ const cutoff = normalized === "all" ? null : Date.now() - Math.max(1, config.timeSeriesHours * 60 * 60 * 1000);
340
+ return { ...config, cutoff };
341
+ }
342
+
343
+ const fallbackConfig = TIME_RANGE_TO_CONFIG[DEFAULT_TIME_RANGE];
344
+ return {
345
+ ...fallbackConfig,
346
+ cutoff: Date.now() - fallbackConfig.timeSeriesHours * 60 * 60 * 1000,
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Get all dashboard stats.
352
+ */
353
+ export async function getDashboardStats(range?: string | null): Promise<DashboardStats> {
354
+ await initDb();
355
+ const {
356
+ timeSeriesHours,
357
+ timeSeriesBucketMs,
358
+ modelSeriesDays,
359
+ modelSeriesBucketMs,
360
+ modelPerformanceDays,
361
+ modelPerformanceBucketMs,
362
+ costSeriesDays,
363
+ cutoff,
364
+ } = getTimeRangeConfig(range);
365
+
366
+ return {
367
+ overall: getOverallStats(cutoff ?? undefined),
368
+ byModel: getStatsByModel(cutoff ?? undefined),
369
+ byFolder: getStatsByFolder(cutoff ?? undefined),
370
+ timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
371
+ modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
372
+ modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
373
+ costSeries: getCostTimeSeries(costSeriesDays, cutoff),
374
+ };
375
+ }
376
+
377
+ export async function getOverviewStats(range?: string | null): Promise<Pick<DashboardStats, "overall" | "timeSeries">> {
378
+ await initDb();
379
+ const { timeSeriesHours, timeSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
380
+
381
+ return {
382
+ overall: getOverallStats(cutoff ?? undefined),
383
+ timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
384
+ };
385
+ }
386
+
387
+ export async function getModelDashboardStats(
388
+ range?: string | null,
389
+ ): Promise<Pick<DashboardStats, "byModel" | "modelSeries" | "modelPerformanceSeries">> {
390
+ await initDb();
391
+ const { modelSeriesDays, modelSeriesBucketMs, modelPerformanceDays, modelPerformanceBucketMs, cutoff } =
392
+ getTimeRangeConfig(range);
393
+
394
+ return {
395
+ byModel: getStatsByModel(cutoff ?? undefined),
396
+ modelSeries: getModelTimeSeries(modelSeriesDays, cutoff, modelSeriesBucketMs),
397
+ modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff, modelPerformanceBucketMs),
398
+ };
399
+ }
400
+
401
+ export async function getCostDashboardStats(range?: string | null): Promise<Pick<DashboardStats, "costSeries">> {
402
+ await initDb();
403
+ const { costSeriesDays, cutoff } = getTimeRangeConfig(range);
404
+
405
+ return {
406
+ costSeries: getCostTimeSeries(costSeriesDays, cutoff),
407
+ };
408
+ }
409
+ export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
410
+ await initDb();
411
+ return dbGetRecentRequests(limit);
412
+ }
413
+
414
+ export async function getRecentErrors(limit?: number): Promise<MessageStats[]> {
415
+ await initDb();
416
+ return dbGetRecentErrors(limit);
417
+ }
418
+
419
+ export async function getRequestDetails(id: number): Promise<RequestDetails | null> {
420
+ await initDb();
421
+ const msg = getMessageById(id);
422
+ if (!msg) return null;
423
+
424
+ const entry = await getSessionEntry(msg.sessionFile, msg.entryId);
425
+ if (!entry || entry.type !== "message") return null;
426
+
427
+ // TODO: Get parent/context messages?
428
+ // For now we return the single entry which contains the assistant response.
429
+ // The user prompt is likely the parent.
430
+
431
+ return {
432
+ ...msg,
433
+ messages: [entry],
434
+ output: (entry as any).message,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Get the current message count in the database.
440
+ */
441
+ export async function getTotalMessageCount(): Promise<number> {
442
+ await initDb();
443
+ return getMessageCount();
444
+ }
445
+
446
+ export async function getBehaviorDashboardStats(range?: string | null): Promise<BehaviorDashboardStats> {
447
+ await initDb();
448
+ const { cutoff } = getTimeRangeConfig(range);
449
+ return {
450
+ overall: getBehaviorOverall(cutoff),
451
+ byModel: getBehaviorByModel(cutoff),
452
+ behaviorSeries: getBehaviorTimeSeries(cutoff),
453
+ };
454
+ }
@@ -0,0 +1,221 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import {
3
+ getBehaviorDashboardStats,
4
+ getCostDashboardStats,
5
+ getModelDashboardStats,
6
+ getOverviewStats,
7
+ getRecentErrors,
8
+ getRecentRequests,
9
+ sync,
10
+ } from "./api";
11
+ import { BehaviorChart } from "./components/BehaviorChart";
12
+ import { BehaviorModelsTable } from "./components/BehaviorModelsTable";
13
+ import { BehaviorSummary } from "./components/BehaviorSummary";
14
+ import { ChartsContainer } from "./components/ChartsContainer";
15
+ import { CostChart } from "./components/CostChart";
16
+ import { CostSummary } from "./components/CostSummary";
17
+ import { Header } from "./components/Header";
18
+ import { ModelsTable } from "./components/ModelsTable";
19
+ import { RequestDetail } from "./components/RequestDetail";
20
+ import { RequestList } from "./components/RequestList";
21
+ import { StatsGrid } from "./components/StatsGrid";
22
+ import type {
23
+ BehaviorDashboardStats,
24
+ CostDashboardStats,
25
+ MessageStats,
26
+ ModelDashboardStats,
27
+ OverviewStats,
28
+ TimeRange,
29
+ } from "./types";
30
+
31
+ type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior";
32
+
33
+ export default function App() {
34
+ const [overviewStats, setOverviewStats] = useState<OverviewStats | null>(null);
35
+ const [modelStats, setModelStats] = useState<ModelDashboardStats | null>(null);
36
+ const [costStats, setCostStats] = useState<CostDashboardStats | null>(null);
37
+ const [behaviorStats, setBehaviorStats] = useState<BehaviorDashboardStats | null>(null);
38
+ const [recentRequests, setRecentRequests] = useState<MessageStats[]>([]);
39
+ const [recentErrors, setRecentErrors] = useState<MessageStats[]>([]);
40
+ const [selectedRequest, setSelectedRequest] = useState<number | null>(null);
41
+ const [syncing, setSyncing] = useState(false);
42
+ const [activeTab, setActiveTab] = useState<Tab>("overview");
43
+ const [timeRange, setTimeRange] = useState<TimeRange>("24h");
44
+
45
+ const loadRecentLists = useCallback(async () => {
46
+ try {
47
+ const [requests, errors] = await Promise.all([getRecentRequests(50), getRecentErrors(50)]);
48
+ setRecentRequests(requests);
49
+ setRecentErrors(errors);
50
+ } catch (err) {
51
+ console.error(err);
52
+ }
53
+ }, []);
54
+
55
+ const loadActiveTabStats = useCallback(async () => {
56
+ try {
57
+ if (activeTab === "models") {
58
+ setModelStats(await getModelDashboardStats(timeRange));
59
+ return;
60
+ }
61
+ if (activeTab === "costs") {
62
+ setCostStats(await getCostDashboardStats(timeRange));
63
+ return;
64
+ }
65
+ if (activeTab === "behavior") {
66
+ setBehaviorStats(await getBehaviorDashboardStats(timeRange));
67
+ return;
68
+ }
69
+ if (activeTab === "overview") {
70
+ setOverviewStats(await getOverviewStats(timeRange));
71
+ }
72
+ } catch (err) {
73
+ console.error(err);
74
+ }
75
+ }, [activeTab, timeRange]);
76
+
77
+ const handleSync = async () => {
78
+ setSyncing(true);
79
+ try {
80
+ await sync();
81
+ await Promise.all([loadActiveTabStats(), loadRecentLists()]);
82
+ } finally {
83
+ setSyncing(false);
84
+ }
85
+ };
86
+
87
+ useEffect(() => {
88
+ loadRecentLists();
89
+ const interval = setInterval(loadRecentLists, 30000);
90
+ return () => clearInterval(interval);
91
+ }, [loadRecentLists]);
92
+
93
+ useEffect(() => {
94
+ loadActiveTabStats();
95
+ const interval = setInterval(loadActiveTabStats, 30000);
96
+ return () => clearInterval(interval);
97
+ }, [loadActiveTabStats]);
98
+
99
+ return (
100
+ <div className="min-h-screen">
101
+ <div className="max-w-[1600px] mx-auto px-6 py-6">
102
+ <Header
103
+ activeTab={activeTab}
104
+ onTabChange={setActiveTab}
105
+ onSync={handleSync}
106
+ syncing={syncing}
107
+ timeRange={timeRange}
108
+ onTimeRangeChange={setTimeRange}
109
+ />
110
+
111
+ {activeTab === "overview" && (
112
+ <div className="space-y-6 animate-fade-in">
113
+ {overviewStats ? (
114
+ <StatsGrid stats={overviewStats.overall} />
115
+ ) : (
116
+ <LoadingState label="Loading overview..." />
117
+ )}
118
+
119
+ <div className="grid lg:grid-cols-2 gap-6">
120
+ <RequestList
121
+ title="Recent Requests"
122
+ requests={recentRequests.slice(0, 10)}
123
+ onSelect={r => r.id && setSelectedRequest(r.id)}
124
+ />
125
+ <RequestList
126
+ title="Recent Errors"
127
+ requests={recentErrors.slice(0, 10)}
128
+ onSelect={r => r.id && setSelectedRequest(r.id)}
129
+ />
130
+ </div>
131
+ </div>
132
+ )}
133
+
134
+ {activeTab === "requests" && (
135
+ <div className="h-[calc(100vh-140px)] animate-fade-in">
136
+ <RequestList
137
+ title="All Recent Requests"
138
+ requests={recentRequests}
139
+ onSelect={r => r.id && setSelectedRequest(r.id)}
140
+ />
141
+ </div>
142
+ )}
143
+
144
+ {activeTab === "errors" && (
145
+ <div className="h-[calc(100vh-140px)] animate-fade-in">
146
+ <RequestList
147
+ title="Failed Requests"
148
+ requests={recentErrors}
149
+ onSelect={r => r.id && setSelectedRequest(r.id)}
150
+ />
151
+ </div>
152
+ )}
153
+
154
+ {activeTab === "models" && (
155
+ <div className="space-y-6 animate-fade-in">
156
+ {modelStats ? (
157
+ <>
158
+ <ChartsContainer modelSeries={modelStats.modelSeries} timeRange={timeRange} />
159
+ <ModelsTable
160
+ models={modelStats.byModel}
161
+ performanceSeries={modelStats.modelPerformanceSeries}
162
+ timeRange={timeRange}
163
+ />
164
+ </>
165
+ ) : (
166
+ <LoadingState label="Loading models..." />
167
+ )}
168
+ </div>
169
+ )}
170
+
171
+ {activeTab === "costs" && (
172
+ <div className="space-y-6 animate-fade-in">
173
+ {costStats ? (
174
+ <>
175
+ <CostSummary costSeries={costStats.costSeries} />
176
+ <CostChart costSeries={costStats.costSeries} />
177
+ </>
178
+ ) : (
179
+ <LoadingState label="Loading costs..." />
180
+ )}
181
+ </div>
182
+ )}
183
+
184
+ {activeTab === "behavior" && (
185
+ <div className="space-y-6 animate-fade-in">
186
+ {behaviorStats ? (
187
+ <>
188
+ <BehaviorSummary
189
+ overall={behaviorStats.overall}
190
+ behaviorSeries={behaviorStats.behaviorSeries}
191
+ />
192
+ <BehaviorChart behaviorSeries={behaviorStats.behaviorSeries} />
193
+ <BehaviorModelsTable
194
+ models={behaviorStats.byModel}
195
+ behaviorSeries={behaviorStats.behaviorSeries}
196
+ />
197
+ </>
198
+ ) : (
199
+ <LoadingState label="Loading behavior..." />
200
+ )}
201
+ </div>
202
+ )}
203
+
204
+ {selectedRequest !== null && (
205
+ <RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
206
+ )}
207
+ </div>
208
+ </div>
209
+ );
210
+ }
211
+
212
+ function LoadingState({ label }: { label: string }) {
213
+ return (
214
+ <div className="min-h-[180px] flex items-center justify-center">
215
+ <div className="flex items-center gap-3 text-[var(--text-muted)]">
216
+ <div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
217
+ <span className="text-sm">{label}</span>
218
+ </div>
219
+ </div>
220
+ );
221
+ }