@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.
- package/README.md +82 -0
- package/build.ts +84 -0
- package/dist/client/index.css +1 -0
- package/dist/client/index.html +13 -0
- package/dist/client/index.js +257 -0
- package/dist/client/styles.css +1159 -0
- package/dist/types/aggregator.d.ts +65 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/api.d.ts +10 -0
- package/dist/types/client/components/BehaviorChart.d.ts +6 -0
- package/dist/types/client/components/BehaviorModelsTable.d.ts +7 -0
- package/dist/types/client/components/BehaviorSummary.d.ts +7 -0
- package/dist/types/client/components/ChartsContainer.d.ts +7 -0
- package/dist/types/client/components/CostChart.d.ts +6 -0
- package/dist/types/client/components/CostSummary.d.ts +6 -0
- package/dist/types/client/components/Header.d.ts +12 -0
- package/dist/types/client/components/ModelsTable.d.ts +8 -0
- package/dist/types/client/components/RequestDetail.d.ts +6 -0
- package/dist/types/client/components/RequestList.d.ts +8 -0
- package/dist/types/client/components/StatsGrid.d.ts +6 -0
- package/dist/types/client/components/chart-shared.d.ts +187 -0
- package/dist/types/client/components/models-table-shared.d.ts +195 -0
- package/dist/types/client/components/range-meta.d.ts +21 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/types.d.ts +62 -0
- package/dist/types/client/useSystemTheme.d.ts +2 -0
- package/dist/types/db.d.ts +93 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/parser.d.ts +40 -0
- package/dist/types/server.d.ts +7 -0
- package/dist/types/shared-types.d.ts +192 -0
- package/dist/types/sync-worker.d.ts +31 -0
- package/dist/types/types.d.ts +120 -0
- package/dist/types/user-metrics.d.ts +72 -0
- package/package.json +91 -0
- package/src/aggregator.ts +454 -0
- package/src/client/App.tsx +221 -0
- package/src/client/api.ts +65 -0
- package/src/client/components/BehaviorChart.tsx +189 -0
- package/src/client/components/BehaviorModelsTable.tsx +342 -0
- package/src/client/components/BehaviorSummary.tsx +95 -0
- package/src/client/components/ChartsContainer.tsx +221 -0
- package/src/client/components/CostChart.tsx +171 -0
- package/src/client/components/CostSummary.tsx +53 -0
- package/src/client/components/Header.tsx +72 -0
- package/src/client/components/ModelsTable.tsx +265 -0
- package/src/client/components/RequestDetail.tsx +172 -0
- package/src/client/components/RequestList.tsx +73 -0
- package/src/client/components/StatsGrid.tsx +135 -0
- package/src/client/components/chart-shared.tsx +320 -0
- package/src/client/components/models-table-shared.tsx +275 -0
- package/src/client/components/range-meta.ts +72 -0
- package/src/client/css.d.ts +1 -0
- package/src/client/index.tsx +6 -0
- package/src/client/styles.css +306 -0
- package/src/client/types.ts +78 -0
- package/src/client/useSystemTheme.ts +31 -0
- package/src/db.ts +1100 -0
- package/src/embedded-client.generated.txt +7 -0
- package/src/index.ts +182 -0
- package/src/parser.ts +334 -0
- package/src/server.ts +325 -0
- package/src/shared-types.ts +204 -0
- package/src/sync-worker.ts +40 -0
- package/src/types.ts +125 -0
- package/src/user-metrics.ts +686 -0
- 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
|
+
}
|