@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 +6 -6
- package/src/aggregator.ts +279 -42
- package/src/client/App.tsx +125 -30
- package/src/client/api.ts +35 -3
- package/src/client/components/BehaviorChart.tsx +374 -0
- package/src/client/components/BehaviorModelsTable.tsx +465 -0
- package/src/client/components/BehaviorSummary.tsx +95 -0
- package/src/client/components/CostChart.tsx +5 -38
- package/src/client/components/CostSummary.tsx +8 -47
- package/src/client/components/Header.tsx +28 -4
- package/src/client/components/StatsGrid.tsx +10 -1
- package/src/client/types.ts +63 -0
- package/src/db.ts +420 -26
- package/src/index.ts +29 -3
- package/src/parser.ts +95 -7
- package/src/server.ts +30 -6
- package/src/sync-worker.ts +31 -0
- package/src/types.ts +113 -0
- package/src/user-metrics.ts +686 -0
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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "14.9.
|
|
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,
|
|
20
|
-
import type {
|
|
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
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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(
|
|
90
|
-
modelSeries: getModelTimeSeries(
|
|
91
|
-
modelPerformanceSeries: getModelPerformanceSeries(
|
|
92
|
-
costSeries: getCostTimeSeries(
|
|
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
|
+
}
|
package/src/client/App.tsx
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import {
|
|
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 {
|
|
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 [
|
|
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
|
|
45
|
+
const loadRecentLists = useCallback(async () => {
|
|
24
46
|
try {
|
|
25
|
-
const [
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
81
|
+
await Promise.all([loadActiveTabStats(), loadRecentLists()]);
|
|
39
82
|
} finally {
|
|
40
83
|
setSyncing(false);
|
|
41
84
|
}
|
|
42
85
|
};
|
|
43
86
|
|
|
44
87
|
useEffect(() => {
|
|
45
|
-
|
|
46
|
-
const interval = setInterval(
|
|
88
|
+
loadRecentLists();
|
|
89
|
+
const interval = setInterval(loadRecentLists, 30000);
|
|
47
90
|
return () => clearInterval(interval);
|
|
48
|
-
}, [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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 {
|
|
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
|
+
}
|