@f5-sales-demo/xcsh-stats 19.51.2

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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Client-side type definitions.
3
+ * Duplicated from ../types.ts to avoid pulling in server dependencies.
4
+ */
5
+
6
+ export interface Usage {
7
+ input: number;
8
+ output: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ totalTokens: number;
12
+ premiumRequests?: number;
13
+ cost: {
14
+ input: number;
15
+ output: number;
16
+ cacheRead: number;
17
+ cacheWrite: number;
18
+ total: number;
19
+ };
20
+ }
21
+
22
+ export interface MessageStats {
23
+ id?: number;
24
+ sessionFile: string;
25
+ entryId: string;
26
+ folder: string;
27
+ model: string;
28
+ provider: string;
29
+ api: string;
30
+ timestamp: number;
31
+ duration: number | null;
32
+ ttft: number | null;
33
+ stopReason: string;
34
+ errorMessage: string | null;
35
+ usage: Usage;
36
+ }
37
+
38
+ export interface RequestDetails extends MessageStats {
39
+ messages: unknown[];
40
+ output: unknown;
41
+ }
42
+
43
+ export interface AggregatedStats {
44
+ totalRequests: number;
45
+ successfulRequests: number;
46
+ failedRequests: number;
47
+ errorRate: number;
48
+ totalInputTokens: number;
49
+ totalOutputTokens: number;
50
+ totalCacheReadTokens: number;
51
+ totalCacheWriteTokens: number;
52
+ cacheRate: number;
53
+ totalCost: number;
54
+ totalPremiumRequests: number;
55
+ avgDuration: number | null;
56
+ avgTtft: number | null;
57
+ avgTokensPerSecond: number | null;
58
+ firstTimestamp: number;
59
+ lastTimestamp: number;
60
+ }
61
+
62
+ export interface ModelStats extends AggregatedStats {
63
+ model: string;
64
+ provider: string;
65
+ }
66
+
67
+ export interface FolderStats extends AggregatedStats {
68
+ folder: string;
69
+ }
70
+
71
+ export interface TimeSeriesPoint {
72
+ timestamp: number;
73
+ requests: number;
74
+ errors: number;
75
+ tokens: number;
76
+ cost: number;
77
+ }
78
+
79
+ export interface ModelTimeSeriesPoint {
80
+ timestamp: number;
81
+ model: string;
82
+ provider: string;
83
+ requests: number;
84
+ }
85
+
86
+ export interface ModelPerformancePoint {
87
+ timestamp: number;
88
+ model: string;
89
+ provider: string;
90
+ requests: number;
91
+ avgTtft: number | null;
92
+ avgTokensPerSecond: number | null;
93
+ }
94
+
95
+ export interface DashboardStats {
96
+ overall: AggregatedStats;
97
+ byModel: ModelStats[];
98
+ byFolder: FolderStats[];
99
+ timeSeries: TimeSeriesPoint[];
100
+ modelSeries: ModelTimeSeriesPoint[];
101
+ modelPerformanceSeries: ModelPerformancePoint[];
102
+ }
@@ -0,0 +1,31 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export type SystemTheme = "light" | "dark";
4
+
5
+ const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
6
+
7
+ function getSystemTheme(): SystemTheme {
8
+ if (typeof window === "undefined") {
9
+ return "light";
10
+ }
11
+
12
+ return window.matchMedia(DARK_SCHEME_QUERY).matches ? "dark" : "light";
13
+ }
14
+
15
+ export function useSystemTheme(): SystemTheme {
16
+ const [theme, setTheme] = useState<SystemTheme>(() => getSystemTheme());
17
+
18
+ useEffect(() => {
19
+ const media = window.matchMedia(DARK_SCHEME_QUERY);
20
+ const applyTheme = () => {
21
+ setTheme(media.matches ? "dark" : "light");
22
+ };
23
+
24
+ applyTheme();
25
+
26
+ media.addEventListener("change", applyTheme);
27
+ return () => media.removeEventListener("change", applyTheme);
28
+ }, []);
29
+
30
+ return theme;
31
+ }
package/src/db.ts ADDED
@@ -0,0 +1,482 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as fs from "node:fs/promises";
3
+ import { getConfigRootDir, getStatsDbPath } from "@f5xc-salesdemos/pi-utils";
4
+ import type {
5
+ AggregatedStats,
6
+ FolderStats,
7
+ MessageStats,
8
+ ModelPerformancePoint,
9
+ ModelStats,
10
+ ModelTimeSeriesPoint,
11
+ TimeSeriesPoint,
12
+ } from "./types";
13
+
14
+ const DB_PATH = getStatsDbPath();
15
+
16
+ let db: Database | null = null;
17
+
18
+ /**
19
+ * Initialize the database and create tables.
20
+ */
21
+ export async function initDb(): Promise<Database> {
22
+ if (db) return db;
23
+
24
+ // Ensure directory exists
25
+ await fs.mkdir(getConfigRootDir(), { recursive: true });
26
+
27
+ db = new Database(DB_PATH);
28
+ db.exec("PRAGMA journal_mode = WAL");
29
+
30
+ // Create tables
31
+ db.exec(`
32
+ CREATE TABLE IF NOT EXISTS messages (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ session_file TEXT NOT NULL,
35
+ entry_id TEXT NOT NULL,
36
+ folder TEXT NOT NULL,
37
+ model TEXT NOT NULL,
38
+ provider TEXT NOT NULL,
39
+ api TEXT NOT NULL,
40
+ timestamp INTEGER NOT NULL,
41
+ duration INTEGER,
42
+ ttft INTEGER,
43
+ stop_reason TEXT NOT NULL,
44
+ error_message TEXT,
45
+ input_tokens INTEGER NOT NULL,
46
+ output_tokens INTEGER NOT NULL,
47
+ cache_read_tokens INTEGER NOT NULL,
48
+ cache_write_tokens INTEGER NOT NULL,
49
+ total_tokens INTEGER NOT NULL,
50
+ premium_requests REAL NOT NULL,
51
+ cost_input REAL NOT NULL,
52
+ cost_output REAL NOT NULL,
53
+ cost_cache_read REAL NOT NULL,
54
+ cost_cache_write REAL NOT NULL,
55
+ cost_total REAL NOT NULL,
56
+ UNIQUE(session_file, entry_id)
57
+ );
58
+
59
+ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
60
+ CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
61
+ CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder);
62
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_file);
63
+
64
+ CREATE TABLE IF NOT EXISTS file_offsets (
65
+ session_file TEXT PRIMARY KEY,
66
+ offset INTEGER NOT NULL,
67
+ last_modified INTEGER NOT NULL
68
+ );
69
+ `);
70
+
71
+ const messageColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
72
+ if (!messageColumns.some(column => column.name === "premium_requests")) {
73
+ db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
74
+ }
75
+ db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
76
+ return db;
77
+ }
78
+
79
+ /**
80
+ * Get the stored offset for a session file.
81
+ */
82
+ export function getFileOffset(sessionFile: string): { offset: number; lastModified: number } | null {
83
+ if (!db) return null;
84
+
85
+ const stmt = db.prepare("SELECT offset, last_modified FROM file_offsets WHERE session_file = ?");
86
+ const row = stmt.get(sessionFile) as { offset: number; last_modified: number } | undefined;
87
+
88
+ return row ? { offset: row.offset, lastModified: row.last_modified } : null;
89
+ }
90
+
91
+ /**
92
+ * Update the stored offset for a session file.
93
+ */
94
+ export function setFileOffset(sessionFile: string, offset: number, lastModified: number): void {
95
+ if (!db) return;
96
+
97
+ const stmt = db.prepare(`
98
+ INSERT OR REPLACE INTO file_offsets (session_file, offset, last_modified)
99
+ VALUES (?, ?, ?)
100
+ `);
101
+ stmt.run(sessionFile, offset, lastModified);
102
+ }
103
+
104
+ /**
105
+ * Insert message stats into the database.
106
+ */
107
+ export function insertMessageStats(stats: MessageStats[]): number {
108
+ if (!db || stats.length === 0) return 0;
109
+
110
+ const stmt = db.prepare(`
111
+ INSERT OR IGNORE INTO messages (
112
+ session_file, entry_id, folder, model, provider, api, timestamp,
113
+ duration, ttft, stop_reason, error_message,
114
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
115
+ cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
116
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
117
+ `);
118
+
119
+ let inserted = 0;
120
+ const insert = db.transaction(() => {
121
+ for (const s of stats) {
122
+ const result = stmt.run(
123
+ s.sessionFile,
124
+ s.entryId,
125
+ s.folder,
126
+ s.model,
127
+ s.provider,
128
+ s.api,
129
+ s.timestamp,
130
+ s.duration,
131
+ s.ttft,
132
+ s.stopReason,
133
+ s.errorMessage,
134
+ s.usage.input,
135
+ s.usage.output,
136
+ s.usage.cacheRead,
137
+ s.usage.cacheWrite,
138
+ s.usage.totalTokens,
139
+ s.usage.premiumRequests ?? 0,
140
+ s.usage.cost.input,
141
+ s.usage.cost.output,
142
+ s.usage.cost.cacheRead,
143
+ s.usage.cost.cacheWrite,
144
+ s.usage.cost.total,
145
+ );
146
+ if (result.changes > 0) inserted++;
147
+ }
148
+ });
149
+
150
+ insert();
151
+ return inserted;
152
+ }
153
+
154
+ /**
155
+ * Build aggregated stats from query results.
156
+ */
157
+ function buildAggregatedStats(rows: any[]): AggregatedStats {
158
+ if (rows.length === 0) {
159
+ return {
160
+ totalRequests: 0,
161
+ successfulRequests: 0,
162
+ failedRequests: 0,
163
+ errorRate: 0,
164
+ totalInputTokens: 0,
165
+ totalOutputTokens: 0,
166
+ totalCacheReadTokens: 0,
167
+ totalCacheWriteTokens: 0,
168
+ cacheRate: 0,
169
+ totalCost: 0,
170
+ totalPremiumRequests: 0,
171
+ avgDuration: null,
172
+ avgTtft: null,
173
+ avgTokensPerSecond: null,
174
+ firstTimestamp: 0,
175
+ lastTimestamp: 0,
176
+ };
177
+ }
178
+
179
+ const row = rows[0];
180
+ const totalRequests = row.total_requests || 0;
181
+ const failedRequests = row.failed_requests || 0;
182
+ const successfulRequests = totalRequests - failedRequests;
183
+ const totalInputTokens = row.total_input_tokens || 0;
184
+ const totalCacheReadTokens = row.total_cache_read_tokens || 0;
185
+ const totalPremiumRequests = row.total_premium_requests || 0;
186
+
187
+ return {
188
+ totalRequests,
189
+ successfulRequests,
190
+ failedRequests,
191
+ errorRate: totalRequests > 0 ? failedRequests / totalRequests : 0,
192
+ totalInputTokens,
193
+ totalOutputTokens: row.total_output_tokens || 0,
194
+ totalCacheReadTokens,
195
+ totalCacheWriteTokens: row.total_cache_write_tokens || 0,
196
+ cacheRate:
197
+ totalInputTokens + totalCacheReadTokens > 0
198
+ ? totalCacheReadTokens / (totalInputTokens + totalCacheReadTokens)
199
+ : 0,
200
+ totalCost: row.total_cost || 0,
201
+ totalPremiumRequests,
202
+ avgDuration: row.avg_duration,
203
+ avgTtft: row.avg_ttft,
204
+ avgTokensPerSecond: row.avg_tokens_per_second,
205
+ firstTimestamp: row.first_timestamp || 0,
206
+ lastTimestamp: row.last_timestamp || 0,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Get overall aggregated stats.
212
+ */
213
+ export function getOverallStats(): AggregatedStats {
214
+ if (!db) return buildAggregatedStats([]);
215
+
216
+ const stmt = db.prepare(`
217
+ SELECT
218
+ COUNT(*) as total_requests,
219
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
220
+ SUM(input_tokens) as total_input_tokens,
221
+ SUM(output_tokens) as total_output_tokens,
222
+ SUM(cache_read_tokens) as total_cache_read_tokens,
223
+ SUM(cache_write_tokens) as total_cache_write_tokens,
224
+ SUM(premium_requests) as total_premium_requests,
225
+ SUM(cost_total) as total_cost,
226
+ AVG(duration) as avg_duration,
227
+ AVG(ttft) as avg_ttft,
228
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
229
+ MIN(timestamp) as first_timestamp,
230
+ MAX(timestamp) as last_timestamp
231
+ FROM messages
232
+ `);
233
+
234
+ const rows = stmt.all();
235
+ return buildAggregatedStats(rows);
236
+ }
237
+
238
+ /**
239
+ * Get stats grouped by model.
240
+ */
241
+ export function getStatsByModel(): ModelStats[] {
242
+ if (!db) return [];
243
+
244
+ const stmt = db.prepare(`
245
+ SELECT
246
+ model,
247
+ provider,
248
+ COUNT(*) as total_requests,
249
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
250
+ SUM(input_tokens) as total_input_tokens,
251
+ SUM(output_tokens) as total_output_tokens,
252
+ SUM(cache_read_tokens) as total_cache_read_tokens,
253
+ SUM(cache_write_tokens) as total_cache_write_tokens,
254
+ SUM(premium_requests) as total_premium_requests,
255
+ SUM(cost_total) as total_cost,
256
+ AVG(duration) as avg_duration,
257
+ AVG(ttft) as avg_ttft,
258
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
259
+ MIN(timestamp) as first_timestamp,
260
+ MAX(timestamp) as last_timestamp
261
+ FROM messages
262
+ GROUP BY model, provider
263
+ ORDER BY total_requests DESC
264
+ `);
265
+
266
+ const rows = stmt.all() as any[];
267
+ return rows.map(row => ({
268
+ model: row.model,
269
+ provider: row.provider,
270
+ ...buildAggregatedStats([row]),
271
+ }));
272
+ }
273
+
274
+ /**
275
+ * Get stats grouped by folder.
276
+ */
277
+ export function getStatsByFolder(): FolderStats[] {
278
+ if (!db) return [];
279
+
280
+ const stmt = db.prepare(`
281
+ SELECT
282
+ folder,
283
+ COUNT(*) as total_requests,
284
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as failed_requests,
285
+ SUM(input_tokens) as total_input_tokens,
286
+ SUM(output_tokens) as total_output_tokens,
287
+ SUM(cache_read_tokens) as total_cache_read_tokens,
288
+ SUM(cache_write_tokens) as total_cache_write_tokens,
289
+ SUM(premium_requests) as total_premium_requests,
290
+ SUM(cost_total) as total_cost,
291
+ AVG(duration) as avg_duration,
292
+ AVG(ttft) as avg_ttft,
293
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second,
294
+ MIN(timestamp) as first_timestamp,
295
+ MAX(timestamp) as last_timestamp
296
+ FROM messages
297
+ GROUP BY folder
298
+ ORDER BY total_requests DESC
299
+ `);
300
+
301
+ const rows = stmt.all() as any[];
302
+ return rows.map(row => ({
303
+ folder: row.folder,
304
+ ...buildAggregatedStats([row]),
305
+ }));
306
+ }
307
+
308
+ /**
309
+ * Get hourly time series data.
310
+ */
311
+ export function getTimeSeries(hours = 24): TimeSeriesPoint[] {
312
+ if (!db) return [];
313
+
314
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
315
+
316
+ const stmt = db.prepare(`
317
+ SELECT
318
+ (timestamp / 3600000) * 3600000 as bucket,
319
+ COUNT(*) as requests,
320
+ SUM(CASE WHEN stop_reason = 'error' THEN 1 ELSE 0 END) as errors,
321
+ SUM(total_tokens) as tokens,
322
+ SUM(cost_total) as cost
323
+ FROM messages
324
+ WHERE timestamp >= ?
325
+ GROUP BY bucket
326
+ ORDER BY bucket ASC
327
+ `);
328
+
329
+ const rows = stmt.all(cutoff) as any[];
330
+ return rows.map(row => ({
331
+ timestamp: row.bucket,
332
+ requests: row.requests,
333
+ errors: row.errors,
334
+ tokens: row.tokens,
335
+ cost: row.cost,
336
+ }));
337
+ }
338
+
339
+ /**
340
+ * Get daily performance time series data for the last N days.
341
+ */
342
+ /**
343
+ * Get daily model usage time series data for the last N days.
344
+ */
345
+ export function getModelTimeSeries(days = 14): ModelTimeSeriesPoint[] {
346
+ if (!db) return [];
347
+
348
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
349
+
350
+ const stmt = db.prepare(`
351
+ SELECT
352
+ (timestamp / 86400000) * 86400000 as bucket,
353
+ model,
354
+ provider,
355
+ COUNT(*) as requests
356
+ FROM messages
357
+ WHERE timestamp >= ?
358
+ GROUP BY bucket, model, provider
359
+ ORDER BY bucket ASC
360
+ `);
361
+
362
+ const rows = stmt.all(cutoff) as any[];
363
+ return rows.map(row => ({
364
+ timestamp: row.bucket,
365
+ model: row.model,
366
+ provider: row.provider,
367
+ requests: row.requests,
368
+ }));
369
+ }
370
+
371
+ /**
372
+ * Get daily model performance time series data for the last N days.
373
+ */
374
+ export function getModelPerformanceSeries(days = 14): ModelPerformancePoint[] {
375
+ if (!db) return [];
376
+
377
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
378
+
379
+ const stmt = db.prepare(`
380
+ SELECT
381
+ (timestamp / 86400000) * 86400000 as bucket,
382
+ model,
383
+ provider,
384
+ COUNT(*) as requests,
385
+ AVG(ttft) as avg_ttft,
386
+ AVG(CASE WHEN duration > 0 THEN output_tokens * 1000.0 / duration ELSE NULL END) as avg_tokens_per_second
387
+ FROM messages
388
+ WHERE timestamp >= ?
389
+ GROUP BY bucket, model, provider
390
+ ORDER BY bucket ASC
391
+ `);
392
+
393
+ const rows = stmt.all(cutoff) as any[];
394
+ return rows.map(row => ({
395
+ timestamp: row.bucket,
396
+ model: row.model,
397
+ provider: row.provider,
398
+ requests: row.requests,
399
+ avgTtft: row.avg_ttft,
400
+ avgTokensPerSecond: row.avg_tokens_per_second,
401
+ }));
402
+ }
403
+
404
+ /**
405
+ * Get total message count.
406
+ */
407
+ export function getMessageCount(): number {
408
+ if (!db) return 0;
409
+ const stmt = db.prepare("SELECT COUNT(*) as count FROM messages");
410
+ const row = stmt.get() as { count: number };
411
+ return row.count;
412
+ }
413
+
414
+ /**
415
+ * Close the database connection.
416
+ */
417
+ export function closeDb(): void {
418
+ if (db) {
419
+ db.close();
420
+ db = null;
421
+ }
422
+ }
423
+
424
+ function rowToMessageStats(row: any): MessageStats {
425
+ return {
426
+ id: row.id,
427
+ sessionFile: row.session_file,
428
+ entryId: row.entry_id,
429
+ folder: row.folder,
430
+ model: row.model,
431
+ provider: row.provider,
432
+ api: row.api,
433
+ timestamp: row.timestamp,
434
+ duration: row.duration,
435
+ ttft: row.ttft,
436
+ stopReason: row.stop_reason as any,
437
+ errorMessage: row.error_message,
438
+ usage: {
439
+ input: row.input_tokens,
440
+ output: row.output_tokens,
441
+ cacheRead: row.cache_read_tokens,
442
+ cacheWrite: row.cache_write_tokens,
443
+ totalTokens: row.total_tokens,
444
+ premiumRequests: row.premium_requests ?? 0,
445
+ cost: {
446
+ input: row.cost_input,
447
+ output: row.cost_output,
448
+ cacheRead: row.cost_cache_read,
449
+ cacheWrite: row.cost_cache_write,
450
+ total: row.cost_total,
451
+ },
452
+ },
453
+ };
454
+ }
455
+
456
+ export function getRecentRequests(limit = 100): MessageStats[] {
457
+ if (!db) return [];
458
+ const stmt = db.prepare(`
459
+ SELECT * FROM messages
460
+ ORDER BY timestamp DESC
461
+ LIMIT ?
462
+ `);
463
+ return (stmt.all(limit) as any[]).map(rowToMessageStats);
464
+ }
465
+
466
+ export function getRecentErrors(limit = 100): MessageStats[] {
467
+ if (!db) return [];
468
+ const stmt = db.prepare(`
469
+ SELECT * FROM messages
470
+ WHERE stop_reason = 'error'
471
+ ORDER BY timestamp DESC
472
+ LIMIT ?
473
+ `);
474
+ return (stmt.all(limit) as any[]).map(rowToMessageStats);
475
+ }
476
+
477
+ export function getMessageById(id: number): MessageStats | null {
478
+ if (!db) return null;
479
+ const stmt = db.prepare("SELECT * FROM messages WHERE id = ?");
480
+ const row = stmt.get(id);
481
+ return row ? rowToMessageStats(row) : null;
482
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Embedded stats dashboard bundle for compiled binaries.
3
+ *
4
+ * This file is generated by `bun --cwd=packages/stats scripts/generate-client-bundle.ts --generate` during
5
+ * binary builds. The checked-in value is intentionally empty.
6
+ */
7
+ export const EMBEDDED_CLIENT_ARCHIVE_TAR_GZ_BASE64 = "";