@lemantorus/opencode-analytics 1.0.0

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/public/app.js ADDED
@@ -0,0 +1,880 @@
1
+ const API_BASE = '/api';
2
+ let checkAsFree = true;
3
+ let currentRange = 30;
4
+ let useCustomRange = false;
5
+ let customStart = null;
6
+ let customEnd = null;
7
+ let charts = {};
8
+ let modelsData = [];
9
+ let tpsModelsList = [];
10
+ let selectedTPSModels = [];
11
+ let currentChartType = 'tokens';
12
+ let currentHourlyType = 'messages';
13
+
14
+ function formatNumber(num) {
15
+ if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
16
+ if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
17
+ if (num >= 1e3) return (num / 1e3).toFixed(1) + 'K';
18
+ return num.toLocaleString();
19
+ }
20
+
21
+ function formatCurrency(num) {
22
+ if (num >= 100) return '$' + num.toFixed(0);
23
+ if (num >= 10) return '$' + num.toFixed(1);
24
+ if (num >= 1) return '$' + num.toFixed(2);
25
+ if (num >= 0.01) return '$' + num.toFixed(3);
26
+ return '$' + num.toFixed(4);
27
+ }
28
+
29
+ function formatDate(ts) {
30
+ if (!ts) return '-';
31
+ const d = new Date(ts);
32
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
33
+ }
34
+
35
+ const chartColors = [
36
+ '#00ff88', '#00d4aa', '#00aaff', '#aa88ff', '#ff8844',
37
+ '#ff44aa', '#88ff00', '#ffcc00', '#ff4444', '#44ffff',
38
+ '#ff8800', '#00ffcc', '#cc44ff', '#88ff44', '#ff6688'
39
+ ];
40
+
41
+ function getColor(index) {
42
+ return chartColors[index % chartColors.length];
43
+ }
44
+
45
+ const tooltipDefaults = {
46
+ backgroundColor: 'rgba(10, 10, 10, 0.95)',
47
+ titleColor: '#00ff88',
48
+ bodyColor: '#e0e0e0',
49
+ borderColor: '#00ff88',
50
+ borderWidth: 1,
51
+ padding: 12,
52
+ cornerRadius: 6,
53
+ titleFont: { size: 12, weight: '600', family: 'JetBrains Mono, monospace' },
54
+ bodyFont: { size: 11, family: 'JetBrains Mono, monospace' },
55
+ displayColors: true,
56
+ boxWidth: 10,
57
+ boxHeight: 10,
58
+ boxPadding: 4,
59
+ usePointStyle: true,
60
+ animation: { duration: 150 },
61
+ caretSize: 6,
62
+ caretPadding: 8
63
+ };
64
+
65
+ const chartDefaults = {
66
+ responsive: true,
67
+ maintainAspectRatio: false,
68
+ plugins: {
69
+ legend: {
70
+ position: 'bottom',
71
+ labels: {
72
+ color: '#888888',
73
+ boxWidth: 12,
74
+ padding: 16,
75
+ font: { size: 10, family: 'JetBrains Mono, monospace' },
76
+ usePointStyle: true,
77
+ pointStyle: 'rectRounded'
78
+ }
79
+ },
80
+ tooltip: {
81
+ ...tooltipDefaults,
82
+ callbacks: {
83
+ label: function(context) {
84
+ const value = context.raw;
85
+ const label = context.dataset.label || '';
86
+ if (label.toLowerCase().includes('cost')) {
87
+ return `${label}: ${formatCurrency(value)}`;
88
+ }
89
+ return `${label}: ${formatNumber(value)}`;
90
+ }
91
+ }
92
+ }
93
+ },
94
+ scales: {
95
+ x: {
96
+ ticks: {
97
+ color: '#555555',
98
+ font: { size: 9, family: 'JetBrains Mono, monospace' }
99
+ },
100
+ grid: { color: '#1a1a1a', drawBorder: false }
101
+ },
102
+ y: {
103
+ ticks: {
104
+ color: '#555555',
105
+ font: { size: 9, family: 'JetBrains Mono, monospace' }
106
+ },
107
+ grid: { color: '#1a1a1a', drawBorder: false }
108
+ }
109
+ },
110
+ animation: {
111
+ duration: 300
112
+ }
113
+ };
114
+
115
+ async function fetchAPI(endpoint) {
116
+ const url = `${API_BASE}${endpoint}${endpoint.includes('?') ? '&' : '?'}checkAsFree=${checkAsFree}`;
117
+ const res = await fetch(url);
118
+ if (!res.ok) throw new Error(`API Error: ${res.status}`);
119
+ return res.json();
120
+ }
121
+
122
+ async function loadOverview() {
123
+ const days = useCustomRange ? null : (currentRange === 'all' ? null : currentRange);
124
+ const queryParam = days ? `?days=${days}` : '';
125
+ const data = await fetchAPI(`/stats/overview${queryParam}`);
126
+
127
+ document.getElementById('totalMessages').textContent = formatNumber(data.messageCount);
128
+ document.getElementById('totalInput').textContent = formatNumber(data.totalInput);
129
+ document.getElementById('totalOutput').textContent = formatNumber(data.totalOutput);
130
+ document.getElementById('totalCacheRead').textContent = formatNumber(data.totalCacheRead);
131
+ document.getElementById('totalCost').textContent = formatCurrency(data.totalCost);
132
+ }
133
+
134
+ async function loadModelsChart() {
135
+ const days = useCustomRange ? 365 : (currentRange === 'all' ? 365 : currentRange);
136
+ modelsData = await fetchAPI(`/stats/models?days=${days}`);
137
+
138
+ const topModels = modelsData.slice(0, 8);
139
+ const labels = topModels.map(m => m.baseModel);
140
+
141
+ if (charts.models) charts.models.destroy();
142
+
143
+ const ctx = document.getElementById('modelsChart').getContext('2d');
144
+ charts.models = new Chart(ctx, {
145
+ type: 'bar',
146
+ data: {
147
+ labels,
148
+ datasets: [
149
+ {
150
+ label: 'Input',
151
+ data: topModels.map(m => m.inputTokens),
152
+ backgroundColor: getColor(0),
153
+ borderRadius: 3,
154
+ barPercentage: 0.7
155
+ },
156
+ {
157
+ label: 'Output',
158
+ data: topModels.map(m => m.outputTokens),
159
+ backgroundColor: getColor(1),
160
+ borderRadius: 3,
161
+ barPercentage: 0.7
162
+ },
163
+ {
164
+ label: 'Cache Read',
165
+ data: topModels.map(m => m.cacheRead),
166
+ backgroundColor: getColor(2),
167
+ borderRadius: 3,
168
+ barPercentage: 0.7
169
+ },
170
+ {
171
+ label: 'Cache Write',
172
+ data: topModels.map(m => m.cacheWrite),
173
+ backgroundColor: getColor(3),
174
+ borderRadius: 3,
175
+ barPercentage: 0.7
176
+ }
177
+ ]
178
+ },
179
+ options: {
180
+ ...chartDefaults,
181
+ interaction: {
182
+ mode: 'index',
183
+ intersect: false
184
+ },
185
+ plugins: {
186
+ ...chartDefaults.plugins,
187
+ tooltip: {
188
+ ...tooltipDefaults,
189
+ mode: 'index',
190
+ intersect: false,
191
+ callbacks: {
192
+ label: function(context) {
193
+ const value = context.raw;
194
+ return `${context.dataset.label}: ${formatNumber(value)}`;
195
+ }
196
+ }
197
+ }
198
+ },
199
+ scales: {
200
+ x: { ...chartDefaults.scales.x, stacked: true },
201
+ y: {
202
+ ...chartDefaults.scales.y,
203
+ stacked: true,
204
+ ticks: {
205
+ ...chartDefaults.scales.y.ticks,
206
+ callback: v => formatNumber(v)
207
+ }
208
+ }
209
+ }
210
+ }
211
+ });
212
+
213
+ if (charts.cost) charts.cost.destroy();
214
+
215
+ const ctxCost = document.getElementById('costChart').getContext('2d');
216
+ charts.cost = new Chart(ctxCost, {
217
+ type: 'doughnut',
218
+ data: {
219
+ labels: topModels.map(m => m.baseModel),
220
+ datasets: [{
221
+ data: topModels.map(m => m.cost),
222
+ backgroundColor: topModels.map((_, i) => getColor(i)),
223
+ borderWidth: 2,
224
+ borderColor: '#0d0d0d',
225
+ hoverOffset: 8,
226
+ hoverBorderWidth: 2,
227
+ hoverBorderColor: '#00ff88'
228
+ }]
229
+ },
230
+ options: {
231
+ responsive: true,
232
+ maintainAspectRatio: false,
233
+ cutout: '65%',
234
+ plugins: {
235
+ legend: {
236
+ position: 'right',
237
+ labels: {
238
+ color: '#888888',
239
+ boxWidth: 10,
240
+ padding: 8,
241
+ font: { size: 9, family: 'JetBrains Mono, monospace' },
242
+ usePointStyle: true
243
+ }
244
+ },
245
+ tooltip: {
246
+ ...tooltipDefaults,
247
+ callbacks: {
248
+ label: (ctx) => `${ctx.label}: ${formatCurrency(ctx.raw)}`
249
+ }
250
+ }
251
+ }
252
+ }
253
+ });
254
+
255
+ updateModelsTable();
256
+ }
257
+
258
+ async function loadDailyChart(showCost = false) {
259
+ let data;
260
+
261
+ if (useCustomRange && customStart && customEnd) {
262
+ data = await fetchAPI(`/stats/daily/range?start=${customStart}&end=${customEnd}`);
263
+ } else {
264
+ const days = currentRange === 'all' ? 365 : currentRange;
265
+ data = await fetchAPI(`/stats/daily?days=${days}`);
266
+ }
267
+
268
+ if (charts.daily) charts.daily.destroy();
269
+
270
+ const ctx = document.getElementById('dailyChart').getContext('2d');
271
+
272
+ if (showCost) {
273
+ charts.daily = new Chart(ctx, {
274
+ type: 'line',
275
+ data: {
276
+ labels: data.map(d => d.time),
277
+ datasets: [{
278
+ label: 'Cost',
279
+ data: data.map(d => d.cost),
280
+ borderColor: getColor(0),
281
+ backgroundColor: 'rgba(0, 255, 136, 0.1)',
282
+ fill: true,
283
+ tension: 0.3,
284
+ pointRadius: 2,
285
+ pointBackgroundColor: getColor(0),
286
+ pointBorderColor: '#0a0a0a',
287
+ pointBorderWidth: 1,
288
+ pointHoverRadius: 6,
289
+ pointHoverBackgroundColor: getColor(0),
290
+ pointHoverBorderColor: '#00ff88',
291
+ pointHoverBorderWidth: 2,
292
+ borderWidth: 2
293
+ }]
294
+ },
295
+ options: {
296
+ ...chartDefaults,
297
+ interaction: {
298
+ mode: 'index',
299
+ intersect: false
300
+ },
301
+ plugins: {
302
+ legend: { display: false },
303
+ tooltip: {
304
+ ...tooltipDefaults,
305
+ mode: 'index',
306
+ intersect: false,
307
+ callbacks: {
308
+ title: (items) => items[0].label,
309
+ label: (ctx) => `Cost: ${formatCurrency(ctx.raw)}`
310
+ }
311
+ }
312
+ },
313
+ scales: {
314
+ x: {
315
+ ...chartDefaults.scales.x,
316
+ ticks: { ...chartDefaults.scales.x.ticks, maxTicksLimit: 10 }
317
+ },
318
+ y: {
319
+ ...chartDefaults.scales.y,
320
+ ticks: { ...chartDefaults.scales.y.ticks, callback: v => formatCurrency(v) }
321
+ }
322
+ }
323
+ }
324
+ });
325
+ } else {
326
+ charts.daily = new Chart(ctx, {
327
+ type: 'line',
328
+ data: {
329
+ labels: data.map(d => d.time),
330
+ datasets: [
331
+ {
332
+ label: 'Input',
333
+ data: data.map(d => d.inputTokens),
334
+ borderColor: getColor(0),
335
+ backgroundColor: 'transparent',
336
+ tension: 0.3,
337
+ pointRadius: 2,
338
+ pointBackgroundColor: getColor(0),
339
+ pointBorderColor: '#0a0a0a',
340
+ pointBorderWidth: 1,
341
+ pointHoverRadius: 6,
342
+ pointHoverBackgroundColor: getColor(0),
343
+ pointHoverBorderColor: '#00ff88',
344
+ pointHoverBorderWidth: 2,
345
+ borderWidth: 2
346
+ },
347
+ {
348
+ label: 'Output',
349
+ data: data.map(d => d.outputTokens),
350
+ borderColor: getColor(1),
351
+ backgroundColor: 'transparent',
352
+ tension: 0.3,
353
+ pointRadius: 2,
354
+ pointBackgroundColor: getColor(1),
355
+ pointBorderColor: '#0a0a0a',
356
+ pointBorderWidth: 1,
357
+ pointHoverRadius: 6,
358
+ pointHoverBackgroundColor: getColor(1),
359
+ pointHoverBorderColor: '#00ff88',
360
+ pointHoverBorderWidth: 2,
361
+ borderWidth: 2
362
+ },
363
+ {
364
+ label: 'Cache Read',
365
+ data: data.map(d => d.cacheRead),
366
+ borderColor: getColor(2),
367
+ backgroundColor: 'transparent',
368
+ tension: 0.3,
369
+ pointRadius: 2,
370
+ pointBackgroundColor: getColor(2),
371
+ pointBorderColor: '#0a0a0a',
372
+ pointBorderWidth: 1,
373
+ pointHoverRadius: 6,
374
+ pointHoverBackgroundColor: getColor(2),
375
+ pointHoverBorderColor: '#00ff88',
376
+ pointHoverBorderWidth: 2,
377
+ borderWidth: 2
378
+ }
379
+ ]
380
+ },
381
+ options: {
382
+ ...chartDefaults,
383
+ interaction: {
384
+ mode: 'index',
385
+ intersect: false
386
+ },
387
+ plugins: {
388
+ legend: {
389
+ position: 'top',
390
+ align: 'end',
391
+ labels: {
392
+ color: '#888888',
393
+ boxWidth: 10,
394
+ padding: 12,
395
+ font: { size: 9, family: 'JetBrains Mono, monospace' },
396
+ usePointStyle: true
397
+ }
398
+ },
399
+ tooltip: {
400
+ ...tooltipDefaults,
401
+ mode: 'index',
402
+ intersect: false,
403
+ callbacks: {
404
+ label: function(context) {
405
+ return `${context.dataset.label}: ${formatNumber(context.raw)}`;
406
+ }
407
+ }
408
+ }
409
+ },
410
+ scales: {
411
+ x: {
412
+ ...chartDefaults.scales.x,
413
+ ticks: { ...chartDefaults.scales.x.ticks, maxTicksLimit: 10 }
414
+ },
415
+ y: {
416
+ ...chartDefaults.scales.y,
417
+ ticks: { ...chartDefaults.scales.y.ticks, callback: v => formatNumber(v) }
418
+ }
419
+ }
420
+ }
421
+ });
422
+ }
423
+ }
424
+
425
+ async function loadHourlyChart(mode = 'messages') {
426
+ let data;
427
+ if (mode === 'tps') {
428
+ data = await fetchAPI('/stats/hourly-tps');
429
+ } else {
430
+ data = await fetchAPI('/stats/hourly');
431
+ }
432
+
433
+ if (charts.hourly) charts.hourly.destroy();
434
+
435
+ const ctx = document.getElementById('hourlyChart').getContext('2d');
436
+
437
+ if (mode === 'tps') {
438
+ charts.hourly = new Chart(ctx, {
439
+ type: 'bar',
440
+ data: {
441
+ labels: data.map(d => `${d.hour.toString().padStart(2, '0')}`),
442
+ datasets: [{
443
+ label: 'Output TPS',
444
+ data: data.map(d => d.outputTPS),
445
+ backgroundColor: data.map((d, i) => d.isToday ? getColor(0) : getColor(0) + '40'),
446
+ borderRadius: 2,
447
+ barPercentage: 0.8
448
+ }]
449
+ },
450
+ options: {
451
+ ...chartDefaults,
452
+ plugins: {
453
+ legend: { display: false },
454
+ tooltip: {
455
+ ...tooltipDefaults,
456
+ callbacks: {
457
+ title: (items) => `Hour ${items[0].label}:00`,
458
+ label: (ctx) => `Output TPS: ${ctx.raw.toFixed(2)} tok/s`
459
+ }
460
+ }
461
+ },
462
+ scales: {
463
+ ...chartDefaults.scales,
464
+ y: {
465
+ ...chartDefaults.scales.y,
466
+ ticks: {
467
+ ...chartDefaults.scales.y.ticks,
468
+ callback: v => v.toFixed(1)
469
+ }
470
+ }
471
+ }
472
+ }
473
+ });
474
+ } else {
475
+ charts.hourly = new Chart(ctx, {
476
+ type: 'bar',
477
+ data: {
478
+ labels: data.map(d => `${d.hour.toString().padStart(2, '0')}`),
479
+ datasets: [{
480
+ label: 'Messages',
481
+ data: data.map(d => d.messageCount),
482
+ backgroundColor: data.map((_, i) => {
483
+ const hour = data[i].hour;
484
+ return (hour >= 9 && hour <= 18) ? getColor(0) : getColor(0) + '40';
485
+ }),
486
+ borderRadius: 2,
487
+ barPercentage: 0.8
488
+ }]
489
+ },
490
+ options: {
491
+ ...chartDefaults,
492
+ plugins: {
493
+ legend: { display: false },
494
+ tooltip: {
495
+ ...tooltipDefaults,
496
+ callbacks: {
497
+ title: (items) => `Hour ${items[0].label}:00`,
498
+ label: (ctx) => `Messages: ${formatNumber(ctx.raw)}`
499
+ }
500
+ }
501
+ }
502
+ }
503
+ });
504
+ }
505
+ }
506
+
507
+ async function loadWeeklyChart() {
508
+ const data = await fetchAPI('/stats/weekly?weeks=12');
509
+
510
+ if (charts.weekly) charts.weekly.destroy();
511
+
512
+ const ctx = document.getElementById('weeklyChart').getContext('2d');
513
+ charts.weekly = new Chart(ctx, {
514
+ type: 'bar',
515
+ data: {
516
+ labels: data.map(d => (d.week || d.time || '').split('-')[1] || d.week || d.time),
517
+ datasets: [{
518
+ label: 'Cost',
519
+ data: data.map(d => d.cost),
520
+ backgroundColor: getColor(4),
521
+ borderRadius: 3,
522
+ barPercentage: 0.6,
523
+ hoverBackgroundColor: getColor(0)
524
+ }]
525
+ },
526
+ options: {
527
+ ...chartDefaults,
528
+ plugins: {
529
+ legend: { display: false },
530
+ tooltip: {
531
+ ...tooltipDefaults,
532
+ callbacks: {
533
+ label: (ctx) => `Cost: ${formatCurrency(ctx.raw)}`
534
+ }
535
+ }
536
+ },
537
+ scales: {
538
+ ...chartDefaults.scales,
539
+ y: {
540
+ ...chartDefaults.scales.y,
541
+ ticks: {
542
+ ...chartDefaults.scales.y.ticks,
543
+ callback: v => formatCurrency(v)
544
+ }
545
+ }
546
+ }
547
+ }
548
+ });
549
+ }
550
+
551
+ async function loadTPSModelsList() {
552
+ const modelsWithStats = await fetchAPI('/stats/models?days=30');
553
+
554
+ modelsWithStats.sort((a, b) => (b.outputTokens || 0) - (a.outputTokens || 0));
555
+ tpsModelsList = modelsWithStats.slice(0, 10).map(m => m.baseModel);
556
+ selectedTPSModels = tpsModelsList.slice(0, 5);
557
+
558
+ const dropdown = document.getElementById('tpsModelDropdown');
559
+ const optionsContainer = dropdown.querySelector('.dropdown-options');
560
+ optionsContainer.innerHTML = '';
561
+
562
+ for (const model of tpsModelsList) {
563
+ const label = document.createElement('label');
564
+ label.className = 'dropdown-option' + (selectedTPSModels.includes(model) ? ' selected' : '');
565
+ label.innerHTML = `
566
+ <input type="checkbox" value="${model}" ${selectedTPSModels.includes(model) ? 'checked' : ''}>
567
+ ${model}
568
+ `;
569
+ optionsContainer.appendChild(label);
570
+ }
571
+
572
+ updateDropdownButton();
573
+ }
574
+
575
+ function updateDropdownButton() {
576
+ const dropdown = document.getElementById('tpsModelDropdown');
577
+ const btn = dropdown.querySelector('.dropdown-toggle');
578
+ const checked = dropdown.querySelectorAll('input[type="checkbox"]:checked');
579
+
580
+ if (checked.length === 0) {
581
+ btn.textContent = 'Select models ▼';
582
+ } else if (checked.length <= 2) {
583
+ const labels = Array.from(checked).map(c => c.value);
584
+ btn.textContent = labels.join(', ') + ' ▼';
585
+ } else {
586
+ btn.textContent = `${checked.length} models selected ▼`;
587
+ }
588
+ }
589
+
590
+ function initTPSDropdown() {
591
+ const dropdown = document.getElementById('tpsModelDropdown');
592
+ const btn = dropdown.querySelector('.dropdown-toggle');
593
+
594
+ btn.addEventListener('click', (e) => {
595
+ e.stopPropagation();
596
+ dropdown.classList.toggle('open');
597
+ });
598
+
599
+ dropdown.querySelector('.dropdown-options').addEventListener('change', (e) => {
600
+ if (e.target.type === 'checkbox') {
601
+ const label = e.target.closest('.dropdown-option');
602
+ if (e.target.checked) {
603
+ label.classList.add('selected');
604
+ } else {
605
+ label.classList.remove('selected');
606
+ }
607
+ updateDropdownButton();
608
+ }
609
+ });
610
+
611
+ document.addEventListener('click', () => {
612
+ dropdown.classList.remove('open');
613
+ });
614
+
615
+ dropdown.querySelector('.dropdown-menu').addEventListener('click', (e) => {
616
+ e.stopPropagation();
617
+ });
618
+
619
+ setTimeout(() => {
620
+ const applyBtn = document.createElement('button');
621
+ applyBtn.type = 'button';
622
+ applyBtn.className = 'btn btn-sm btn-primary';
623
+ applyBtn.textContent = 'Apply';
624
+ applyBtn.style.marginTop = '0.5rem';
625
+ applyBtn.style.width = '100%';
626
+ applyBtn.addEventListener('click', async () => {
627
+ dropdown.classList.remove('open');
628
+ const checked = dropdown.querySelectorAll('input[type="checkbox"]:checked');
629
+ selectedTPSModels = Array.from(checked).map(c => c.value);
630
+ await loadTPSChart();
631
+ });
632
+ dropdown.querySelector('.dropdown-menu').appendChild(applyBtn);
633
+ }, 0);
634
+ }
635
+
636
+ async function loadTPSChart() {
637
+ if (selectedTPSModels.length === 0) {
638
+ selectedTPSModels = tpsModelsList.slice(0, 5);
639
+ }
640
+
641
+ const modelsParam = selectedTPSModels.join(',');
642
+ const data = await fetchAPI('/stats/daily-tps-by-model?days=30&models=' + modelsParam);
643
+
644
+ if (charts.tps) charts.tps.destroy();
645
+
646
+ const ctx = document.getElementById('tpsChart').getContext('2d');
647
+
648
+ const datasets = data.models.map((model, index) => ({
649
+ label: model.baseModel,
650
+ data: model.data.map(d => d?.tps ?? null),
651
+ borderColor: getColor(index),
652
+ backgroundColor: getColor(index) + '20',
653
+ fill: true,
654
+ tension: 0.3,
655
+ pointRadius: 2,
656
+ pointBackgroundColor: getColor(index),
657
+ pointBorderColor: '#0a0a0a',
658
+ pointBorderWidth: 1,
659
+ pointHoverRadius: 6,
660
+ pointHoverBackgroundColor: getColor(index),
661
+ pointHoverBorderColor: '#00ff88',
662
+ pointHoverBorderWidth: 2,
663
+ borderWidth: 2,
664
+ extraData: model.data
665
+ }));
666
+
667
+ charts.tps = new Chart(ctx, {
668
+ type: 'line',
669
+ data: {
670
+ labels: data.dates,
671
+ datasets: datasets
672
+ },
673
+ options: {
674
+ ...chartDefaults,
675
+ interaction: {
676
+ mode: 'index',
677
+ intersect: false
678
+ },
679
+ plugins: {
680
+ ...chartDefaults.plugins,
681
+ tooltip: {
682
+ ...tooltipDefaults,
683
+ mode: 'index',
684
+ intersect: false,
685
+ callbacks: {
686
+ title: () => '',
687
+ beforeBody: (items) => {
688
+ if (!items.length) return [];
689
+ return [items[0].label];
690
+ },
691
+ label: (ctx) => {
692
+ const extra = ctx.dataset.extraData[ctx.dataIndex];
693
+ const tps = extra?.tps?.toFixed(2) || '-';
694
+ const input = extra?.inputTokens ? formatNumber(extra.inputTokens) : '0';
695
+ const output = extra?.outputTokens ? formatNumber(extra.outputTokens) : '0';
696
+ return [
697
+ ctx.dataset.label,
698
+ ` ├─ TPS: ${tps} tok/s`,
699
+ ` └─ In: ${input} | Out: ${output}`
700
+ ];
701
+ }
702
+ }
703
+ }
704
+ },
705
+ scales: {
706
+ x: {
707
+ ...chartDefaults.scales.x,
708
+ ticks: { ...chartDefaults.scales.x.ticks, maxTicksLimit: 10 }
709
+ },
710
+ y: {
711
+ ...chartDefaults.scales.y,
712
+ ticks: {
713
+ ...chartDefaults.scales.y.ticks,
714
+ callback: v => v.toFixed(1) + ' tok/s'
715
+ }
716
+ }
717
+ },
718
+ spanGaps: true
719
+ }
720
+ });
721
+ }
722
+
723
+ function updateModelsTable() {
724
+ const tbody = document.querySelector('#modelsTable tbody');
725
+ tbody.innerHTML = '';
726
+
727
+ modelsData.forEach(m => {
728
+ const row = document.createElement('tr');
729
+ row.innerHTML = `
730
+ <td><strong>${m.baseModel}</strong></td>
731
+ <td>${formatNumber(m.messageCount)}</td>
732
+ <td>${formatNumber(m.inputTokens)}</td>
733
+ <td>${formatNumber(m.outputTokens)}</td>
734
+ <td>${formatNumber(m.cacheRead)}</td>
735
+ <td>${formatNumber(m.cacheWrite)}</td>
736
+ <td>${formatCurrency(m.cost)}</td>
737
+ <td><span class="badge ${m.isFree ? 'badge-free' : 'badge-paid'}">${m.isFree ? 'FREE' : 'PAID'}</span></td>
738
+ `;
739
+ tbody.appendChild(row);
740
+ });
741
+ }
742
+
743
+ async function loadPricingModal() {
744
+ const models = await fetch(`${API_BASE}/pricing/models`).then(r => r.json());
745
+ const list = document.getElementById('pricingList');
746
+ list.innerHTML = '';
747
+
748
+ for (const model of models) {
749
+ const prices = model.pricing || { input: 0, output: 0, cacheRead: 0 };
750
+ const inputPerM = (prices.input || 0) * 1000000;
751
+ const outputPerM = (prices.output || 0) * 1000000;
752
+ const cachePerM = (prices.cacheRead || 0) * 1000000;
753
+
754
+ const item = document.createElement('div');
755
+ item.className = 'pricing-item';
756
+ item.innerHTML = `
757
+ <label>${model.name} <span style="color:#555;font-size:0.7em;">(${formatNumber(model.messageCount)} msgs)</span></label>
758
+ <input type="number" step="0.01" placeholder="Input" value="${inputPerM.toFixed(4)}" data-model="${model.name}" data-field="input">
759
+ <input type="number" step="0.01" placeholder="Output" value="${outputPerM.toFixed(4)}" data-model="${model.name}" data-field="output">
760
+ <input type="number" step="0.01" placeholder="Cache" value="${cachePerM.toFixed(4)}" data-model="${model.name}" data-field="cacheRead">
761
+ <button class="btn btn-sm btn-primary save-btn" data-model="${model.name}">Save</button>
762
+ `;
763
+ list.appendChild(item);
764
+ }
765
+
766
+ list.querySelectorAll('.save-btn').forEach(btn => {
767
+ btn.addEventListener('click', async () => {
768
+ const model = btn.dataset.model;
769
+ const inputs = list.querySelectorAll(`[data-model="${model}"]`);
770
+ const data = {};
771
+ inputs.forEach(input => {
772
+ if (input.dataset.field) {
773
+ data[input.dataset.field] = parseFloat(input.value) || 0;
774
+ }
775
+ });
776
+
777
+ btn.textContent = '...';
778
+ await fetch(`${API_BASE}/pricing`, {
779
+ method: 'PUT',
780
+ headers: { 'Content-Type': 'application/json' },
781
+ body: JSON.stringify({ model, ...data })
782
+ });
783
+
784
+ btn.textContent = 'Saved';
785
+ setTimeout(() => btn.textContent = 'Save', 1200);
786
+ await loadAll();
787
+ });
788
+ });
789
+ }
790
+
791
+ async function loadAll() {
792
+ await loadOverview();
793
+ await loadModelsChart();
794
+ await loadDailyChart(currentChartType === 'cost');
795
+ await loadHourlyChart();
796
+ await loadWeeklyChart();
797
+ await loadTPSModelsList();
798
+ await loadTPSChart();
799
+ }
800
+
801
+ document.addEventListener('DOMContentLoaded', async () => {
802
+ try {
803
+ await loadAll();
804
+ initTPSDropdown();
805
+ document.getElementById('loading').classList.add('hidden');
806
+ } catch (err) {
807
+ console.error('Failed to load:', err);
808
+ document.querySelector('.loading span').textContent = 'Failed to load data. Is the server running?';
809
+ }
810
+
811
+ document.getElementById('checkAsFree').addEventListener('change', async function() {
812
+ checkAsFree = this.checked;
813
+ await loadAll();
814
+ });
815
+
816
+ document.getElementById('refreshPricingBtn').addEventListener('click', async function() {
817
+ this.textContent = '...';
818
+ await fetch(`${API_BASE}/pricing/reset`, { method: 'POST' });
819
+ this.textContent = 'Done';
820
+ setTimeout(() => this.textContent = 'Actualize prices', 1500);
821
+ await loadAll();
822
+ });
823
+
824
+ document.getElementById('editPricingBtn').addEventListener('click', () => {
825
+ loadPricingModal();
826
+ document.getElementById('pricingModal').classList.add('show');
827
+ });
828
+
829
+ document.querySelector('.close-btn').addEventListener('click', () => {
830
+ document.getElementById('pricingModal').classList.remove('show');
831
+ });
832
+
833
+ document.getElementById('pricingModal').addEventListener('click', (e) => {
834
+ if (e.target.id === 'pricingModal') {
835
+ e.target.classList.remove('show');
836
+ }
837
+ });
838
+
839
+ document.querySelectorAll('#mainTimeFilter .btn[data-range]').forEach(btn => {
840
+ btn.addEventListener('click', async function() {
841
+ document.querySelectorAll('#mainTimeFilter .btn[data-range]').forEach(b => b.classList.remove('active'));
842
+ this.classList.add('active');
843
+ currentRange = this.dataset.range === 'all' ? 'all' : parseInt(this.dataset.range);
844
+ useCustomRange = false;
845
+ await loadAll();
846
+ });
847
+ });
848
+
849
+ document.querySelectorAll('.chart-type-toggle .btn').forEach(btn => {
850
+ btn.addEventListener('click', async function() {
851
+ document.querySelectorAll('.chart-type-toggle .btn').forEach(b => b.classList.remove('active'));
852
+ this.classList.add('active');
853
+
854
+ if (this.dataset.chart) {
855
+ currentChartType = this.dataset.chart;
856
+ await loadDailyChart(currentChartType === 'cost');
857
+ } else if (this.dataset.hourly) {
858
+ await loadHourlyChart(this.dataset.hourly);
859
+ }
860
+ });
861
+ });
862
+
863
+ document.getElementById('applyDateRange').addEventListener('click', async function() {
864
+ const start = document.getElementById('startDate').value;
865
+ const end = document.getElementById('endDate').value;
866
+
867
+ if (start && end) {
868
+ customStart = start;
869
+ customEnd = end;
870
+ useCustomRange = true;
871
+ document.querySelectorAll('#mainTimeFilter .btn[data-range]').forEach(b => b.classList.remove('active'));
872
+ await loadAll();
873
+ }
874
+ });
875
+
876
+ const today = new Date().toISOString().split('T')[0];
877
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
878
+ document.getElementById('endDate').value = today;
879
+ document.getElementById('startDate').value = thirtyDaysAgo;
880
+ });