@oh-my-pi/omp-stats 14.9.3 → 14.9.7

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "14.9.3",
4
+ "version": "14.9.7",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -37,22 +37,22 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-ai": "14.9.3",
41
- "@oh-my-pi/pi-utils": "14.9.3",
40
+ "@oh-my-pi/pi-ai": "14.9.7",
41
+ "@oh-my-pi/pi-utils": "14.9.7",
42
42
  "@tailwindcss/node": "^4.2.4",
43
43
  "chart.js": "^4.5.1",
44
44
  "date-fns": "^4.1.0",
45
45
  "lucide-react": "^1.14.0",
46
46
  "react": "19.2.5",
47
47
  "react-chartjs-2": "^5.3.1",
48
- "react-dom": "19.2.5"
48
+ "react-dom": "19.2.5",
49
+ "tailwindcss": "^4.2.4"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@types/bun": "^1.3.13",
52
53
  "@types/react": "^19.2.14",
53
54
  "@types/react-dom": "^19.2.3",
54
- "postcss": "^8.5.14",
55
- "tailwindcss": "^4.2.4"
55
+ "postcss": "^8.5.14"
56
56
  },
57
57
  "engines": {
58
58
  "bun": ">=1.3.7"
package/src/aggregator.ts CHANGED
@@ -2,6 +2,9 @@ import * as fs from "node:fs";
2
2
  import {
3
3
  getRecentErrors as dbGetRecentErrors,
4
4
  getRecentRequests as dbGetRecentRequests,
5
+ getBehaviorByModel,
6
+ getBehaviorOverall,
7
+ getBehaviorTimeSeries,
5
8
  getCostTimeSeries,
6
9
  getFileOffset,
7
10
  getMessageById,
@@ -14,82 +17,306 @@ import {
14
17
  getTimeSeries,
15
18
  initDb,
16
19
  insertMessageStats,
20
+ insertUserMessageStats,
17
21
  setFileOffset,
22
+ updateUserMessageLinks,
18
23
  } from "./db";
19
- import { getSessionEntry, listAllSessionFiles, parseSessionFile } from "./parser";
20
- import type { DashboardStats, MessageStats, RequestDetails } from "./types";
24
+ import { getSessionEntry, listAllSessionFiles, type ParseSessionResult } from "./parser";
25
+ import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
26
+ // `with { type: "file" }` resolves to the worker's absolute path at runtime
27
+ // (dev) and survives bundling (the asset is copied alongside the build).
28
+ // tsgo doesn't recognize Bun's file-URL import attribute and would raise
29
+ // TS1192/TS5097 here; Bun honors it. Same suppression pattern lives in
30
+ // `tab-supervisor.ts` and `context-manager.ts`.
31
+ // @ts-expect-error -- Bun file-URL import (see comment above).
32
+ import syncWorkerUrl from "./sync-worker.ts" with { type: "file" };
33
+ import type { BehaviorDashboardStats, DashboardStats, MessageStats, RequestDetails } from "./types";
21
34
 
22
35
  /**
23
- * Sync a single session file to the database.
24
- * Only processes new entries since the last sync.
36
+ * Apply a freshly parsed result to the database. Runs entirely on the
37
+ * main thread so the single SQLite handle owns every write.
25
38
  */
26
- async function syncSessionFile(sessionFile: string): Promise<number> {
27
- // Get file stats
28
- let fileStats: fs.Stats;
29
- try {
30
- fileStats = await fs.promises.stat(sessionFile);
31
- } catch {
32
- return 0;
33
- }
39
+ function applyParseResult(sessionFile: string, lastModified: number, result: ParseSessionResult): number {
40
+ if (result.stats.length > 0) insertMessageStats(result.stats);
41
+ if (result.userStats.length > 0) insertUserMessageStats(result.userStats);
42
+ if (result.userLinks.length > 0) updateUserMessageLinks(result.userLinks);
43
+ setFileOffset(sessionFile, result.newOffset, lastModified);
44
+ return result.stats.length + result.userStats.length;
45
+ }
34
46
 
35
- const lastModified = fileStats.mtimeMs;
47
+ /**
48
+ * Progress event emitted after each session file is fully processed.
49
+ * `current` is the number of files completed (skipped + parsed),
50
+ * `total` is the size of the work set. `processed` is the running total
51
+ * of inserted rows.
52
+ */
53
+ export interface SyncProgress {
54
+ current: number;
55
+ total: number;
56
+ processed: number;
57
+ sessionFile: string;
58
+ }
36
59
 
37
- // Check if file has changed since last sync
38
- const stored = getFileOffset(sessionFile);
39
- if (stored && stored.lastModified >= lastModified) {
40
- return 0; // File hasn't changed
41
- }
60
+ export interface SyncOptions {
61
+ /** Called after each file completes. Synchronous; keep it cheap. */
62
+ onProgress?: (event: SyncProgress) => void;
63
+ /**
64
+ * Worker pool size. Defaults to a sensible value derived from the host
65
+ * (capped to avoid drowning a small machine in workers). Set to `1` to
66
+ * force serial parsing without spawning workers.
67
+ */
68
+ workers?: number;
69
+ }
42
70
 
43
- // Parse file from last offset
44
- const fromOffset = stored?.offset ?? 0;
45
- const { stats, newOffset } = await parseSessionFile(sessionFile, fromOffset);
71
+ function defaultWorkerCount(): number {
72
+ // `navigator.hardwareConcurrency` is the portable answer in Bun; fall
73
+ // back to a small fixed pool if it's somehow unavailable.
74
+ const hw = typeof navigator !== "undefined" ? (navigator.hardwareConcurrency ?? 0) : 0;
75
+ const raw = hw > 0 ? hw : 4;
76
+ // Cap at 8 - parse is JSON-bound, and SQLite writes serialize on main
77
+ // thread anyway, so more workers stop helping.
78
+ return Math.min(8, Math.max(2, Math.floor(raw)));
79
+ }
46
80
 
47
- if (stats.length > 0) {
48
- insertMessageStats(stats);
49
- }
81
+ interface WorkerHandle {
82
+ worker: Worker;
83
+ busy: boolean;
84
+ resolve: ((res: ParseSessionResult) => void) | null;
85
+ reject: ((err: Error) => void) | null;
86
+ }
50
87
 
51
- // Update offset tracker
52
- setFileOffset(sessionFile, newOffset, lastModified);
88
+ function spawnWorker(): WorkerHandle {
89
+ const worker = new Worker(syncWorkerUrl, { type: "module" });
90
+ const handle: WorkerHandle = { worker, busy: false, resolve: null, reject: null };
91
+ worker.onmessage = (event: MessageEvent<SyncWorkerResponse>) => {
92
+ const { resolve, reject } = handle;
93
+ handle.resolve = null;
94
+ handle.reject = null;
95
+ handle.busy = false;
96
+ if (!resolve || !reject) return;
97
+ if (event.data.ok) resolve(event.data.result);
98
+ else reject(new Error(event.data.error));
99
+ };
100
+ worker.onerror = (event: ErrorEvent) => {
101
+ const { reject } = handle;
102
+ handle.resolve = null;
103
+ handle.reject = null;
104
+ handle.busy = false;
105
+ reject?.(event.error instanceof Error ? event.error : new Error(event.message || "worker error"));
106
+ };
107
+ return handle;
108
+ }
53
109
 
54
- return stats.length;
110
+ function dispatch(handle: WorkerHandle, request: SyncWorkerRequest): Promise<ParseSessionResult> {
111
+ if (handle.busy) {
112
+ return Promise.reject(new Error("worker is busy - this is a bug in the dispatcher"));
113
+ }
114
+ const { promise, resolve, reject } = Promise.withResolvers<ParseSessionResult>();
115
+ handle.busy = true;
116
+ handle.resolve = resolve;
117
+ handle.reject = reject;
118
+ handle.worker.postMessage(request);
119
+ return promise;
55
120
  }
56
121
 
57
122
  /**
58
123
  * Sync all session files to the database.
59
- * Returns the number of new entries processed.
124
+ *
125
+ * Parsing fans out across a worker pool (one in-flight job per worker)
126
+ * while DB writes and offset bookkeeping stay on the calling thread so the
127
+ * single SQLite handle stays uncontended. `onProgress` fires once per
128
+ * completed file (skipped files included so the bar walks at a steady
129
+ * rate).
60
130
  */
61
- export async function syncAllSessions(): Promise<{ processed: number; files: number }> {
131
+ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: number; files: number }> {
62
132
  await initDb();
63
133
 
64
134
  const files = await listAllSessionFiles();
135
+ if (files.length === 0) return { processed: 0, files: 0 };
136
+
65
137
  let totalProcessed = 0;
66
138
  let filesProcessed = 0;
139
+ let completed = 0;
140
+ let cursor = 0;
141
+
142
+ const poolSize = Math.max(1, Math.min(files.length, opts?.workers ?? defaultWorkerCount()));
143
+ const handles: WorkerHandle[] = [];
144
+ for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
145
+
146
+ const report = (sessionFile: string) => {
147
+ completed++;
148
+ opts?.onProgress?.({
149
+ current: completed,
150
+ total: files.length,
151
+ processed: totalProcessed,
152
+ sessionFile,
153
+ });
154
+ };
67
155
 
68
- for (const file of files) {
69
- const count = await syncSessionFile(file);
70
- if (count > 0) {
71
- totalProcessed += count;
72
- filesProcessed++;
156
+ async function drain(handle: WorkerHandle): Promise<void> {
157
+ while (true) {
158
+ const idx = cursor++;
159
+ if (idx >= files.length) return;
160
+ const sessionFile = files[idx];
161
+
162
+ let fileStats: fs.Stats;
163
+ try {
164
+ fileStats = await fs.promises.stat(sessionFile);
165
+ } catch {
166
+ report(sessionFile);
167
+ continue;
168
+ }
169
+ const lastModified = fileStats.mtimeMs;
170
+ const stored = getFileOffset(sessionFile);
171
+ if (stored && stored.lastModified >= lastModified) {
172
+ report(sessionFile);
173
+ continue;
174
+ }
175
+
176
+ const fromOffset = stored?.offset ?? 0;
177
+ const result = await dispatch(handle, { sessionFile, fromOffset });
178
+ const inserted = applyParseResult(sessionFile, lastModified, result);
179
+ if (inserted > 0) {
180
+ totalProcessed += inserted;
181
+ filesProcessed++;
182
+ }
183
+ report(sessionFile);
73
184
  }
74
185
  }
75
186
 
187
+ try {
188
+ await Promise.all(handles.map(drain));
189
+ } finally {
190
+ for (const handle of handles) handle.worker.terminate();
191
+ }
192
+
76
193
  return { processed: totalProcessed, files: filesProcessed };
77
194
  }
78
195
 
196
+ const HOUR_MS = 60 * 60 * 1000;
197
+ const DAY_MS = 24 * HOUR_MS;
198
+
199
+ type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
200
+
201
+ interface TimeRangeConfig {
202
+ timeSeriesHours: number;
203
+ timeSeriesBucketMs: number;
204
+ modelSeriesDays: number;
205
+ modelPerformanceDays: number;
206
+ costSeriesDays: number;
207
+ cutoff: number | null;
208
+ }
209
+
210
+ const DEFAULT_TIME_RANGE: TimeRange = "24h";
211
+
212
+ const TIME_RANGE_TO_CONFIG: Record<TimeRange, Omit<TimeRangeConfig, "cutoff">> = {
213
+ "1h": {
214
+ timeSeriesHours: 1,
215
+ timeSeriesBucketMs: HOUR_MS,
216
+ modelSeriesDays: 1,
217
+ modelPerformanceDays: 1,
218
+ costSeriesDays: 1,
219
+ },
220
+ "24h": {
221
+ timeSeriesHours: 24,
222
+ timeSeriesBucketMs: HOUR_MS,
223
+ modelSeriesDays: 1,
224
+ modelPerformanceDays: 1,
225
+ costSeriesDays: 1,
226
+ },
227
+ "7d": {
228
+ timeSeriesHours: 24 * 7,
229
+ timeSeriesBucketMs: DAY_MS,
230
+ modelSeriesDays: 7,
231
+ modelPerformanceDays: 7,
232
+ costSeriesDays: 7,
233
+ },
234
+ "30d": {
235
+ timeSeriesHours: 24 * 30,
236
+ timeSeriesBucketMs: DAY_MS,
237
+ modelSeriesDays: 30,
238
+ modelPerformanceDays: 30,
239
+ costSeriesDays: 30,
240
+ },
241
+ "90d": {
242
+ timeSeriesHours: 24 * 90,
243
+ timeSeriesBucketMs: DAY_MS,
244
+ modelSeriesDays: 90,
245
+ modelPerformanceDays: 90,
246
+ costSeriesDays: 90,
247
+ },
248
+ all: {
249
+ timeSeriesHours: 24 * 3650,
250
+ timeSeriesBucketMs: DAY_MS,
251
+ modelSeriesDays: 3650,
252
+ modelPerformanceDays: 3650,
253
+ costSeriesDays: 3650,
254
+ },
255
+ };
256
+
257
+ function getTimeRangeConfig(range?: string | null): TimeRangeConfig {
258
+ const normalized = range?.trim().toLowerCase() ?? DEFAULT_TIME_RANGE;
259
+ const config = TIME_RANGE_TO_CONFIG[normalized as TimeRange];
260
+ if (config) {
261
+ const cutoff = normalized === "all" ? null : Date.now() - Math.max(1, config.timeSeriesHours * 60 * 60 * 1000);
262
+ return { ...config, cutoff };
263
+ }
264
+
265
+ const fallbackConfig = TIME_RANGE_TO_CONFIG[DEFAULT_TIME_RANGE];
266
+ return {
267
+ ...fallbackConfig,
268
+ cutoff: Date.now() - fallbackConfig.timeSeriesHours * 60 * 60 * 1000,
269
+ };
270
+ }
271
+
79
272
  /**
80
273
  * Get all dashboard stats.
81
274
  */
82
- export async function getDashboardStats(): Promise<DashboardStats> {
275
+ export async function getDashboardStats(range?: string | null): Promise<DashboardStats> {
83
276
  await initDb();
277
+ const { timeSeriesHours, timeSeriesBucketMs, modelSeriesDays, modelPerformanceDays, costSeriesDays, cutoff } =
278
+ getTimeRangeConfig(range);
84
279
 
85
280
  return {
86
- overall: getOverallStats(),
87
- byModel: getStatsByModel(),
88
- byFolder: getStatsByFolder(),
89
- timeSeries: getTimeSeries(24),
90
- modelSeries: getModelTimeSeries(14),
91
- modelPerformanceSeries: getModelPerformanceSeries(14),
92
- costSeries: getCostTimeSeries(90),
281
+ overall: getOverallStats(cutoff ?? undefined),
282
+ byModel: getStatsByModel(cutoff ?? undefined),
283
+ byFolder: getStatsByFolder(cutoff ?? undefined),
284
+ timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
285
+ modelSeries: getModelTimeSeries(modelSeriesDays, cutoff),
286
+ modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff),
287
+ costSeries: getCostTimeSeries(costSeriesDays, cutoff),
288
+ };
289
+ }
290
+
291
+ export async function getOverviewStats(range?: string | null): Promise<Pick<DashboardStats, "overall" | "timeSeries">> {
292
+ await initDb();
293
+ const { timeSeriesHours, timeSeriesBucketMs, cutoff } = getTimeRangeConfig(range);
294
+
295
+ return {
296
+ overall: getOverallStats(cutoff ?? undefined),
297
+ timeSeries: getTimeSeries(timeSeriesHours, cutoff, timeSeriesBucketMs),
298
+ };
299
+ }
300
+
301
+ export async function getModelDashboardStats(
302
+ range?: string | null,
303
+ ): Promise<Pick<DashboardStats, "byModel" | "modelSeries" | "modelPerformanceSeries">> {
304
+ await initDb();
305
+ const { modelSeriesDays, modelPerformanceDays, cutoff } = getTimeRangeConfig(range);
306
+
307
+ return {
308
+ byModel: getStatsByModel(cutoff ?? undefined),
309
+ modelSeries: getModelTimeSeries(modelSeriesDays, cutoff),
310
+ modelPerformanceSeries: getModelPerformanceSeries(modelPerformanceDays, cutoff),
311
+ };
312
+ }
313
+
314
+ export async function getCostDashboardStats(range?: string | null): Promise<Pick<DashboardStats, "costSeries">> {
315
+ await initDb();
316
+ const { costSeriesDays, cutoff } = getTimeRangeConfig(range);
317
+
318
+ return {
319
+ costSeries: getCostTimeSeries(costSeriesDays, cutoff),
93
320
  };
94
321
  }
95
322
  export async function getRecentRequests(limit?: number): Promise<MessageStats[]> {
@@ -128,3 +355,13 @@ export async function getTotalMessageCount(): Promise<number> {
128
355
  await initDb();
129
356
  return getMessageCount();
130
357
  }
358
+
359
+ export async function getBehaviorDashboardStats(range?: string | null): Promise<BehaviorDashboardStats> {
360
+ await initDb();
361
+ const { cutoff } = getTimeRangeConfig(range);
362
+ return {
363
+ overall: getBehaviorOverall(cutoff),
364
+ byModel: getBehaviorByModel(cutoff),
365
+ behaviorSeries: getBehaviorTimeSeries(cutoff),
366
+ };
367
+ }
@@ -1,5 +1,16 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
- import { getRecentErrors, getRecentRequests, getStats, sync } from "./api";
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";
3
14
  import { ChartsContainer } from "./components/ChartsContainer";
4
15
  import { CostChart } from "./components/CostChart";
5
16
  import { CostSummary } from "./components/CostSummary";
@@ -8,64 +19,102 @@ import { ModelsTable } from "./components/ModelsTable";
8
19
  import { RequestDetail } from "./components/RequestDetail";
9
20
  import { RequestList } from "./components/RequestList";
10
21
  import { StatsGrid } from "./components/StatsGrid";
11
- import type { DashboardStats, MessageStats } from "./types";
22
+ import type {
23
+ BehaviorDashboardStats,
24
+ CostDashboardStats,
25
+ MessageStats,
26
+ ModelDashboardStats,
27
+ OverviewStats,
28
+ TimeRange,
29
+ } from "./types";
12
30
 
13
- type Tab = "overview" | "requests" | "errors" | "models" | "costs";
31
+ type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior";
14
32
 
15
33
  export default function App() {
16
- const [stats, setStats] = useState<DashboardStats | null>(null);
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);
17
38
  const [recentRequests, setRecentRequests] = useState<MessageStats[]>([]);
18
39
  const [recentErrors, setRecentErrors] = useState<MessageStats[]>([]);
19
40
  const [selectedRequest, setSelectedRequest] = useState<number | null>(null);
20
41
  const [syncing, setSyncing] = useState(false);
21
42
  const [activeTab, setActiveTab] = useState<Tab>("overview");
43
+ const [timeRange, setTimeRange] = useState<TimeRange>("24h");
22
44
 
23
- const loadData = useCallback(async () => {
45
+ const loadRecentLists = useCallback(async () => {
24
46
  try {
25
- const [s, r, e] = await Promise.all([getStats(), getRecentRequests(50), getRecentErrors(50)]);
26
- setStats(s);
27
- setRecentRequests(r);
28
- setRecentErrors(e);
47
+ const [requests, errors] = await Promise.all([getRecentRequests(50), getRecentErrors(50)]);
48
+ setRecentRequests(requests);
49
+ setRecentErrors(errors);
29
50
  } catch (err) {
30
51
  console.error(err);
31
52
  }
32
53
  }, []);
33
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
+
34
77
  const handleSync = async () => {
35
78
  setSyncing(true);
36
79
  try {
37
80
  await sync();
38
- await loadData();
81
+ await Promise.all([loadActiveTabStats(), loadRecentLists()]);
39
82
  } finally {
40
83
  setSyncing(false);
41
84
  }
42
85
  };
43
86
 
44
87
  useEffect(() => {
45
- loadData();
46
- const interval = setInterval(loadData, 30000);
88
+ loadRecentLists();
89
+ const interval = setInterval(loadRecentLists, 30000);
47
90
  return () => clearInterval(interval);
48
- }, [loadData]);
49
-
50
- if (!stats) {
51
- return (
52
- <div className="min-h-screen flex items-center justify-center">
53
- <div className="flex items-center gap-3 text-[var(--text-muted)]">
54
- <div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
55
- <span className="text-sm">Loading analytics...</span>
56
- </div>
57
- </div>
58
- );
59
- }
91
+ }, [loadRecentLists]);
92
+
93
+ useEffect(() => {
94
+ loadActiveTabStats();
95
+ const interval = setInterval(loadActiveTabStats, 30000);
96
+ return () => clearInterval(interval);
97
+ }, [loadActiveTabStats]);
60
98
 
61
99
  return (
62
100
  <div className="min-h-screen">
63
101
  <div className="max-w-[1600px] mx-auto px-6 py-6">
64
- <Header activeTab={activeTab} onTabChange={setActiveTab} onSync={handleSync} syncing={syncing} />
102
+ <Header
103
+ activeTab={activeTab}
104
+ onTabChange={setActiveTab}
105
+ onSync={handleSync}
106
+ syncing={syncing}
107
+ timeRange={timeRange}
108
+ onTimeRangeChange={setTimeRange}
109
+ />
65
110
 
66
111
  {activeTab === "overview" && (
67
112
  <div className="space-y-6 animate-fade-in">
68
- <StatsGrid stats={stats.overall} />
113
+ {overviewStats ? (
114
+ <StatsGrid stats={overviewStats.overall} />
115
+ ) : (
116
+ <LoadingState label="Loading overview..." />
117
+ )}
69
118
 
70
119
  <div className="grid lg:grid-cols-2 gap-6">
71
120
  <RequestList
@@ -104,15 +153,50 @@ export default function App() {
104
153
 
105
154
  {activeTab === "models" && (
106
155
  <div className="space-y-6 animate-fade-in">
107
- <ChartsContainer modelSeries={stats.modelSeries} />
108
- <ModelsTable models={stats.byModel} performanceSeries={stats.modelPerformanceSeries} />
156
+ {modelStats ? (
157
+ <>
158
+ <ChartsContainer modelSeries={modelStats.modelSeries} />
159
+ <ModelsTable
160
+ models={modelStats.byModel}
161
+ performanceSeries={modelStats.modelPerformanceSeries}
162
+ />
163
+ </>
164
+ ) : (
165
+ <LoadingState label="Loading models..." />
166
+ )}
109
167
  </div>
110
168
  )}
111
169
 
112
170
  {activeTab === "costs" && (
113
171
  <div className="space-y-6 animate-fade-in">
114
- <CostSummary costSeries={stats.costSeries} />
115
- <CostChart costSeries={stats.costSeries} />
172
+ {costStats ? (
173
+ <>
174
+ <CostSummary costSeries={costStats.costSeries} />
175
+ <CostChart costSeries={costStats.costSeries} />
176
+ </>
177
+ ) : (
178
+ <LoadingState label="Loading costs..." />
179
+ )}
180
+ </div>
181
+ )}
182
+
183
+ {activeTab === "behavior" && (
184
+ <div className="space-y-6 animate-fade-in">
185
+ {behaviorStats ? (
186
+ <>
187
+ <BehaviorSummary
188
+ overall={behaviorStats.overall}
189
+ behaviorSeries={behaviorStats.behaviorSeries}
190
+ />
191
+ <BehaviorChart behaviorSeries={behaviorStats.behaviorSeries} />
192
+ <BehaviorModelsTable
193
+ models={behaviorStats.byModel}
194
+ behaviorSeries={behaviorStats.behaviorSeries}
195
+ />
196
+ </>
197
+ ) : (
198
+ <LoadingState label="Loading behavior..." />
199
+ )}
116
200
  </div>
117
201
  )}
118
202
 
@@ -123,3 +207,14 @@ export default function App() {
123
207
  </div>
124
208
  );
125
209
  }
210
+
211
+ function LoadingState({ label }: { label: string }) {
212
+ return (
213
+ <div className="min-h-[180px] flex items-center justify-center">
214
+ <div className="flex items-center gap-3 text-[var(--text-muted)]">
215
+ <div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
216
+ <span className="text-sm">{label}</span>
217
+ </div>
218
+ </div>
219
+ );
220
+ }
package/src/client/api.ts CHANGED
@@ -1,13 +1,39 @@
1
- import type { DashboardStats, MessageStats, RequestDetails } from "./types";
1
+ import type {
2
+ BehaviorDashboardStats,
3
+ CostDashboardStats,
4
+ DashboardStats,
5
+ MessageStats,
6
+ ModelDashboardStats,
7
+ OverviewStats,
8
+ RequestDetails,
9
+ } from "./types";
2
10
 
3
11
  const API_BASE = "/api";
4
12
 
5
- export async function getStats(): Promise<DashboardStats> {
6
- const res = await fetch(`${API_BASE}/stats`);
13
+ export async function getStats(range = "24h"): Promise<DashboardStats> {
14
+ const res = await fetch(`${API_BASE}/stats?range=${encodeURIComponent(range)}`);
7
15
  if (!res.ok) throw new Error("Failed to fetch stats");
8
16
  return res.json() as Promise<DashboardStats>;
9
17
  }
10
18
 
19
+ export async function getOverviewStats(range = "24h"): Promise<OverviewStats> {
20
+ const res = await fetch(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`);
21
+ if (!res.ok) throw new Error("Failed to fetch overview stats");
22
+ return res.json() as Promise<OverviewStats>;
23
+ }
24
+
25
+ export async function getModelDashboardStats(range = "24h"): Promise<ModelDashboardStats> {
26
+ const res = await fetch(`${API_BASE}/stats/model-dashboard?range=${encodeURIComponent(range)}`);
27
+ if (!res.ok) throw new Error("Failed to fetch model stats");
28
+ return res.json() as Promise<ModelDashboardStats>;
29
+ }
30
+
31
+ export async function getCostDashboardStats(range = "24h"): Promise<CostDashboardStats> {
32
+ const res = await fetch(`${API_BASE}/stats/costs?range=${encodeURIComponent(range)}`);
33
+ if (!res.ok) throw new Error("Failed to fetch cost stats");
34
+ return res.json() as Promise<CostDashboardStats>;
35
+ }
36
+
11
37
  export async function getRecentRequests(limit = 50): Promise<MessageStats[]> {
12
38
  const res = await fetch(`${API_BASE}/stats/recent?limit=${limit}`);
13
39
  if (!res.ok) throw new Error("Failed to fetch recent requests");
@@ -31,3 +57,9 @@ export async function sync(): Promise<any> {
31
57
  if (!res.ok) throw new Error("Failed to sync");
32
58
  return res.json();
33
59
  }
60
+
61
+ export async function getBehaviorDashboardStats(range = "24h"): Promise<BehaviorDashboardStats> {
62
+ const res = await fetch(`${API_BASE}/stats/behavior?range=${encodeURIComponent(range)}`);
63
+ if (!res.ok) throw new Error("Failed to fetch behavior stats");
64
+ return res.json() as Promise<BehaviorDashboardStats>;
65
+ }