@lemantorus/opencode-analytics 1.0.0 → 1.0.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.
package/README.md CHANGED
@@ -1,10 +1,20 @@
1
1
  # OpenCode Analytics
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@lemantorus/opencode-analytics.svg)](https://www.npmjs.com/package/@lemantorus/opencode-analytics)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@lemantorus/opencode-analytics.svg)](https://www.npmjs.com/package/@lemantorus/opencode-analytics)
5
+
3
6
  > ⚠️ **Unofficial** - This is a community-built dashboard, not affiliated with or endorsed by [Anomaly](https://anoma.ly) (the creators of OpenCode).
4
7
 
5
8
  A beautiful, real-time analytics dashboard for OpenCode that visualizes your AI coding usage, token consumption, costs, and model performance.
6
9
 
7
- ![Dashboard Preview](https://via.placeholder.com/800x400/0a0a0a/00ff88?text=OpenCode+Analytics+Dashboard)
10
+ ![Dashboard Preview](./screenshots/desktop.png)
11
+
12
+ <details>
13
+ <summary>📱 Mobile View</summary>
14
+
15
+ ![Mobile Preview](./screenshots/mobile.png)
16
+
17
+ </details>
8
18
 
9
19
  ## Features
10
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemantorus/opencode-analytics",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Analytics dashboard for OpenCode - visualize your AI coding usage (unofficial)",
5
5
  "main": "server/index.js",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "node": ">=18"
33
33
  },
34
34
  "dependencies": {
35
+ "compression": "^1.8.1",
35
36
  "express": "^4.22.1",
36
37
  "open": "^10.1.0"
37
38
  }
@@ -0,0 +1,379 @@
1
+ const Aggregator = (function() {
2
+ function normalizeModelName(modelId) {
3
+ if (!modelId) return 'unknown';
4
+ return modelId;
5
+ }
6
+
7
+ function getShortModelName(modelId) {
8
+ if (!modelId) return 'unknown';
9
+ return modelId
10
+ .replace(/^zai-org\//, '')
11
+ .replace(/-maas$/, '')
12
+ .replace(/-free$/, '')
13
+ .replace(/-flashx$/, '-flash');
14
+ }
15
+
16
+ function isFreeModel(modelId) {
17
+ if (!modelId) return false;
18
+ return modelId.includes('-free') ||
19
+ modelId === 'big-pickle' ||
20
+ modelId.includes('nemotron');
21
+ }
22
+
23
+ function filterByRange(messages, startTs, endTs) {
24
+ if (!startTs && !endTs) return messages;
25
+ return messages.filter(m => {
26
+ if (!m.ts) return false;
27
+ if (startTs && m.ts < startTs) return false;
28
+ if (endTs && m.ts > endTs) return false;
29
+ return true;
30
+ });
31
+ }
32
+
33
+ function filterByDays(messages, days) {
34
+ if (!days || days === 'all') return messages;
35
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
36
+ return messages.filter(m => m.ts && m.ts >= cutoff);
37
+ }
38
+
39
+ function getPricing(modelId, pricing, checkAsFree) {
40
+ const base = normalizeModelName(modelId);
41
+ const short = getShortModelName(modelId);
42
+ const isFree = isFreeModel(modelId);
43
+
44
+ if (checkAsFree && isFree) {
45
+ return { input: 0, output: 0, cacheRead: 0, isFree: true };
46
+ }
47
+
48
+ if (pricing[base]) return pricing[base];
49
+ if (pricing[short]) return pricing[short];
50
+
51
+ return { input: 0, output: 0, cacheRead: 0, isFree };
52
+ }
53
+
54
+ function calcCost(msg, pricing, checkAsFree) {
55
+ const p = getPricing(msg.modelId, pricing, checkAsFree);
56
+ const inputCost = (msg.in || 0) * (p.input || 0);
57
+ const outputCost = ((msg.out || 0) + (msg.rs || 0)) * (p.output || 0);
58
+ const cacheCost = (msg.cr || 0) * (p.cacheRead || p.input || 0);
59
+ const cacheWriteCost = (msg.cw || 0) * (p.input || 0);
60
+ return inputCost + outputCost + cacheCost + cacheWriteCost;
61
+ }
62
+
63
+ function aggregateByPeriod(messages, period, pricing, checkAsFree) {
64
+ const groups = {};
65
+
66
+ for (const msg of messages) {
67
+ if (!msg.ts) continue;
68
+
69
+ const d = new Date(msg.ts);
70
+ let key;
71
+
72
+ if (period === 'day') {
73
+ key = d.toISOString().split('T')[0];
74
+ } else if (period === 'week') {
75
+ const year = d.getFullYear();
76
+ const weekNum = getWeekNumber(d);
77
+ key = `${year}-${weekNum.toString().padStart(2, '0')}`;
78
+ } else if (period === 'month') {
79
+ key = d.toISOString().slice(0, 7);
80
+ } else {
81
+ key = d.toISOString().split('T')[0];
82
+ }
83
+
84
+ if (!groups[key]) {
85
+ groups[key] = { time: key, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0, cost: 0, messageCount: 0 };
86
+ }
87
+
88
+ groups[key].inputTokens += msg.in || 0;
89
+ groups[key].outputTokens += (msg.out || 0) + (msg.rs || 0);
90
+ groups[key].cacheRead += msg.cr || 0;
91
+ groups[key].cacheWrite += msg.cw || 0;
92
+ groups[key].cost += calcCost(msg, pricing, checkAsFree);
93
+ groups[key].messageCount += 1;
94
+ }
95
+
96
+ return Object.values(groups).sort((a, b) => a.time.localeCompare(b.time));
97
+ }
98
+
99
+ function getWeekNumber(d) {
100
+ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
101
+ const dayNum = date.getUTCDay() || 7;
102
+ date.setUTCDate(date.getUTCDate() + 4 - dayNum);
103
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
104
+ return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
105
+ }
106
+
107
+ function aggregateByHour(messages) {
108
+ const groups = {};
109
+
110
+ for (const msg of messages) {
111
+ if (!msg.ts) continue;
112
+ const hour = new Date(msg.ts).getHours();
113
+
114
+ if (!groups[hour]) {
115
+ groups[hour] = { hour, messageCount: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 };
116
+ }
117
+
118
+ groups[hour].messageCount += 1;
119
+ groups[hour].inputTokens += msg.in || 0;
120
+ groups[hour].outputTokens += (msg.out || 0) + (msg.rs || 0);
121
+ groups[hour].cacheRead += msg.cr || 0;
122
+ groups[hour].cacheWrite += msg.cw || 0;
123
+ }
124
+
125
+ const result = [];
126
+ for (let i = 0; i < 24; i++) {
127
+ result.push(groups[i] || { hour: i, messageCount: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 });
128
+ }
129
+ return result;
130
+ }
131
+
132
+ function aggregateByModel(messages, pricing, checkAsFree) {
133
+ const groups = {};
134
+
135
+ for (const msg of messages) {
136
+ const modelId = msg.modelId || 'unknown';
137
+ const base = normalizeModelName(modelId);
138
+
139
+ if (!groups[base]) {
140
+ groups[base] = {
141
+ modelId,
142
+ baseModel: base,
143
+ isFree: isFreeModel(modelId),
144
+ messageCount: 0,
145
+ inputTokens: 0,
146
+ outputTokens: 0,
147
+ cacheRead: 0,
148
+ cacheWrite: 0,
149
+ cost: 0
150
+ };
151
+ }
152
+
153
+ groups[base].messageCount += 1;
154
+ groups[base].inputTokens += msg.in || 0;
155
+ groups[base].outputTokens += (msg.out || 0) + (msg.rs || 0);
156
+ groups[base].cacheRead += msg.cr || 0;
157
+ groups[base].cacheWrite += msg.cw || 0;
158
+ groups[base].cost += calcCost(msg, pricing, checkAsFree);
159
+ }
160
+
161
+ return Object.values(groups).sort((a, b) => b.inputTokens - a.inputTokens);
162
+ }
163
+
164
+ function getOverview(messages, pricing, checkAsFree) {
165
+ let messageCount = 0;
166
+ let totalInput = 0;
167
+ let totalOutput = 0;
168
+ let totalCacheRead = 0;
169
+ let totalCacheWrite = 0;
170
+ let totalCost = 0;
171
+ let firstMessage = null;
172
+ let lastMessage = null;
173
+
174
+ for (const msg of messages) {
175
+ messageCount += 1;
176
+ totalInput += msg.in || 0;
177
+ totalOutput += (msg.out || 0) + (msg.rs || 0);
178
+ totalCacheRead += msg.cr || 0;
179
+ totalCacheWrite += msg.cw || 0;
180
+ totalCost += calcCost(msg, pricing, checkAsFree);
181
+
182
+ if (msg.ts) {
183
+ if (!firstMessage || msg.ts < firstMessage) firstMessage = msg.ts;
184
+ if (!lastMessage || msg.ts > lastMessage) lastMessage = msg.ts;
185
+ }
186
+ }
187
+
188
+ return {
189
+ messageCount,
190
+ totalInput,
191
+ totalOutput,
192
+ totalCacheRead,
193
+ totalCacheWrite,
194
+ totalCost,
195
+ firstMessage,
196
+ lastMessage
197
+ };
198
+ }
199
+
200
+ function getHourlyTPS(messages) {
201
+ const now = Date.now();
202
+ const oneDayAgo = now - (24 * 60 * 60 * 1000);
203
+ const recentMessages = messages.filter(m => m.ts && m.ts >= oneDayAgo);
204
+
205
+ const groups = {};
206
+ for (const msg of recentMessages) {
207
+ const hour = new Date(msg.ts).getHours();
208
+ if (!groups[hour]) {
209
+ groups[hour] = { messages: [], minTs: msg.ts, maxTs: msg.ts };
210
+ }
211
+ groups[hour].messages.push(msg);
212
+ if (msg.ts < groups[hour].minTs) groups[hour].minTs = msg.ts;
213
+ if (msg.ts > groups[hour].maxTs) groups[hour].maxTs = msg.ts;
214
+ }
215
+
216
+ const result = [];
217
+ const currentHour = new Date().getHours();
218
+
219
+ for (let i = 0; i < 24; i++) {
220
+ const group = groups[i];
221
+ const isToday = Math.abs(currentHour - i) <= 12;
222
+
223
+ if (group && group.messages.length > 0) {
224
+ let totalOutput = 0;
225
+ for (const msg of group.messages) {
226
+ totalOutput += (msg.out || 0) + (msg.rs || 0);
227
+ }
228
+ const duration = (group.maxTs - group.minTs) / 1000;
229
+ const tps = duration > 0 ? totalOutput / duration : 0;
230
+
231
+ result.push({
232
+ hour: i,
233
+ messageCount: group.messages.length,
234
+ outputTokens: totalOutput,
235
+ outputTPS: tps,
236
+ isToday
237
+ });
238
+ } else {
239
+ result.push({ hour: i, messageCount: 0, outputTokens: 0, outputTPS: 0, isToday });
240
+ }
241
+ }
242
+
243
+ return result;
244
+ }
245
+
246
+ function getDailyTPSByModel(messages, days, modelFilter, pricing) {
247
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
248
+ const recent = messages.filter(m => m.ts && m.ts >= cutoff);
249
+
250
+ const modelData = {};
251
+ const allDates = new Set();
252
+
253
+ for (const msg of recent) {
254
+ if (!msg.modelId) continue;
255
+
256
+ const base = normalizeModelName(msg.modelId);
257
+ const date = new Date(msg.ts).toISOString().split('T')[0];
258
+ allDates.add(date);
259
+
260
+ const tStart = msg.tStart || msg.ts;
261
+ const tEnd = msg.tEnd;
262
+ const duration = (tEnd && tStart && tEnd > tStart) ? (tEnd - tStart) / 1000 : 0;
263
+
264
+ if (duration <= 0) continue;
265
+
266
+ const tps = ((msg.out || 0) + (msg.rs || 0)) / duration;
267
+ if (!isFinite(tps)) continue;
268
+
269
+ if (!modelData[base]) modelData[base] = {};
270
+ if (!modelData[base][date]) {
271
+ modelData[base][date] = { tpsSum: 0, count: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheRead: 0, cacheWrite: 0 };
272
+ }
273
+
274
+ modelData[base][date].tpsSum += tps;
275
+ modelData[base][date].count += 1;
276
+ modelData[base][date].inputTokens += msg.in || 0;
277
+ modelData[base][date].outputTokens += msg.out || 0;
278
+ modelData[base][date].reasoningTokens += msg.rs || 0;
279
+ modelData[base][date].cacheRead += msg.cr || 0;
280
+ modelData[base][date].cacheWrite += msg.cw || 0;
281
+ }
282
+
283
+ const sortedDates = Array.from(allDates).sort();
284
+ const models = [];
285
+
286
+ for (const [model, dates] of Object.entries(modelData)) {
287
+ if (modelFilter && !modelFilter.includes(model)) continue;
288
+
289
+ const data = sortedDates.map(date => {
290
+ const d = dates[date];
291
+ return d && d.count > 0
292
+ ? { tps: d.tpsSum / d.count, inputTokens: d.inputTokens, outputTokens: d.outputTokens, reasoningTokens: d.reasoningTokens, cacheRead: d.cacheRead, cacheWrite: d.cacheWrite }
293
+ : null;
294
+ });
295
+
296
+ models.push({ baseModel: model, data });
297
+ }
298
+
299
+ models.sort((a, b) => {
300
+ const totalA = a.data.reduce((s, v) => s + (v?.tps || 0), 0);
301
+ const totalB = b.data.reduce((s, v) => s + (v?.tps || 0), 0);
302
+ return totalB - totalA;
303
+ });
304
+
305
+ return { dates: sortedDates, models };
306
+ }
307
+
308
+ function getModelsTPS(messages, days) {
309
+ const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000);
310
+ const recent = messages.filter(m => m.ts && m.ts >= cutoff);
311
+
312
+ const modelData = {};
313
+
314
+ for (const msg of recent) {
315
+ const base = normalizeModelName(msg.modelId);
316
+
317
+ if (!modelData[base]) {
318
+ modelData[base] = {
319
+ modelId: msg.modelId,
320
+ baseModel: base,
321
+ isFree: isFreeModel(msg.modelId),
322
+ messageCount: 0,
323
+ inputTokens: 0,
324
+ outputTokens: 0,
325
+ minTs: msg.ts,
326
+ maxTs: msg.ts
327
+ };
328
+ }
329
+
330
+ const md = modelData[base];
331
+ md.messageCount += 1;
332
+ md.inputTokens += msg.in || 0;
333
+ md.outputTokens += (msg.out || 0) + (msg.rs || 0);
334
+ if (msg.ts < md.minTs) md.minTs = msg.ts;
335
+ if (msg.ts > md.maxTs) md.maxTs = msg.ts;
336
+ }
337
+
338
+ return Object.values(modelData).map(md => {
339
+ const duration = md.maxTs > md.minTs ? (md.maxTs - md.minTs) / 1000 : 1;
340
+ return {
341
+ modelId: md.modelId,
342
+ baseModel: md.baseModel,
343
+ isFree: md.isFree,
344
+ messageCount: md.messageCount,
345
+ outputTokens: md.outputTokens,
346
+ inputTokens: md.inputTokens,
347
+ outputTPS: md.outputTokens / duration,
348
+ inputTPS: md.inputTokens / duration,
349
+ durationSeconds: duration
350
+ };
351
+ }).sort((a, b) => b.outputTokens - a.outputTokens);
352
+ }
353
+
354
+ function getModelsList(messages) {
355
+ const models = new Set();
356
+ for (const msg of messages) {
357
+ if (msg.modelId) models.add(normalizeModelName(msg.modelId));
358
+ }
359
+ return Array.from(models).sort();
360
+ }
361
+
362
+ return {
363
+ normalizeModelName,
364
+ getShortModelName,
365
+ isFreeModel,
366
+ filterByRange,
367
+ filterByDays,
368
+ getPricing,
369
+ calcCost,
370
+ aggregateByPeriod,
371
+ aggregateByHour,
372
+ aggregateByModel,
373
+ getOverview,
374
+ getHourlyTPS,
375
+ getDailyTPSByModel,
376
+ getModelsTPS,
377
+ getModelsList
378
+ };
379
+ })();
package/public/app.js CHANGED
@@ -1,16 +1,25 @@
1
1
  const API_BASE = '/api';
2
+
3
+ let rawData = null;
4
+ let rawMessages = [];
5
+ let pricing = {};
6
+
2
7
  let checkAsFree = true;
3
8
  let currentRange = 30;
4
9
  let useCustomRange = false;
5
10
  let customStart = null;
6
11
  let customEnd = null;
7
12
  let charts = {};
8
- let modelsData = [];
13
+
9
14
  let tpsModelsList = [];
10
15
  let selectedTPSModels = [];
11
16
  let currentChartType = 'tokens';
12
17
  let currentHourlyType = 'messages';
13
18
 
19
+ let autoRefreshInterval = null;
20
+ let autoRefreshSeconds = 0;
21
+
22
+
14
23
  function formatNumber(num) {
15
24
  if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
16
25
  if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
@@ -59,7 +68,9 @@ const tooltipDefaults = {
59
68
  usePointStyle: true,
60
69
  animation: { duration: 150 },
61
70
  caretSize: 6,
62
- caretPadding: 8
71
+ caretPadding: 8,
72
+ position: 'nearest',
73
+ clamp: true
63
74
  };
64
75
 
65
76
  const chartDefaults = {
@@ -109,32 +120,56 @@ const chartDefaults = {
109
120
  },
110
121
  animation: {
111
122
  duration: 300
123
+ },
124
+ layout: {
125
+ padding: {
126
+ top: 4,
127
+ bottom: 4
128
+ }
112
129
  }
113
130
  };
114
131
 
115
- async function fetchAPI(endpoint) {
116
- const url = `${API_BASE}${endpoint}${endpoint.includes('?') ? '&' : '?'}checkAsFree=${checkAsFree}`;
117
- const res = await fetch(url);
132
+ async function fetchRawData() {
133
+ const res = await fetch(`${API_BASE}/stats/raw`);
118
134
  if (!res.ok) throw new Error(`API Error: ${res.status}`);
119
135
  return res.json();
120
136
  }
121
137
 
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}`);
138
+ async function loadRawData() {
139
+ rawData = await fetchRawData();
140
+ rawMessages = rawData.messages || [];
141
+ pricing = rawData.pricing || {};
142
+ }
143
+
144
+ function getFilteredMessages() {
145
+ if (useCustomRange && customStart && customEnd) {
146
+ const startTs = new Date(customStart).getTime();
147
+ const endTs = new Date(customEnd).getTime() + (24 * 60 * 60 * 1000 - 1);
148
+ return Aggregator.filterByRange(rawMessages, startTs, endTs);
149
+ }
126
150
 
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);
151
+ if (currentRange === 'all') {
152
+ return rawMessages;
153
+ }
154
+
155
+ return Aggregator.filterByDays(rawMessages, currentRange);
132
156
  }
133
157
 
134
- async function loadModelsChart() {
135
- const days = useCustomRange ? 365 : (currentRange === 'all' ? 365 : currentRange);
136
- modelsData = await fetchAPI(`/stats/models?days=${days}`);
158
+ function renderOverview() {
159
+ const messages = getFilteredMessages();
160
+ const overview = Aggregator.getOverview(messages, pricing, checkAsFree);
137
161
 
162
+ document.getElementById('totalMessages').textContent = formatNumber(overview.messageCount);
163
+ document.getElementById('totalInput').textContent = formatNumber(overview.totalInput);
164
+ document.getElementById('totalOutput').textContent = formatNumber(overview.totalOutput);
165
+ document.getElementById('totalCacheRead').textContent = formatNumber(overview.totalCacheRead);
166
+ document.getElementById('totalCacheWrite').textContent = formatNumber(overview.totalCacheWrite);
167
+ document.getElementById('totalCost').textContent = formatCurrency(overview.totalCost);
168
+ }
169
+
170
+ function renderModelsChart() {
171
+ const messages = getFilteredMessages();
172
+ const modelsData = Aggregator.aggregateByModel(messages, pricing, checkAsFree);
138
173
  const topModels = modelsData.slice(0, 8);
139
174
  const labels = topModels.map(m => m.baseModel);
140
175
 
@@ -252,18 +287,32 @@ async function loadModelsChart() {
252
287
  }
253
288
  });
254
289
 
255
- updateModelsTable();
290
+ renderModelsTable(modelsData);
256
291
  }
257
292
 
258
- async function loadDailyChart(showCost = false) {
259
- let data;
293
+ function renderModelsTable(modelsData) {
294
+ const tbody = document.querySelector('#modelsTable tbody');
295
+ tbody.innerHTML = '';
260
296
 
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
- }
297
+ modelsData.forEach(m => {
298
+ const row = document.createElement('tr');
299
+ row.innerHTML = `
300
+ <td><strong>${m.baseModel}</strong></td>
301
+ <td>${formatNumber(m.messageCount)}</td>
302
+ <td>${formatNumber(m.inputTokens)}</td>
303
+ <td>${formatNumber(m.outputTokens)}</td>
304
+ <td>${formatNumber(m.cacheRead)}</td>
305
+ <td>${formatNumber(m.cacheWrite)}</td>
306
+ <td>${formatCurrency(m.cost)}</td>
307
+ <td><span class="badge ${m.isFree ? 'badge-free' : 'badge-paid'}">${m.isFree ? 'FREE' : 'PAID'}</span></td>
308
+ `;
309
+ tbody.appendChild(row);
310
+ });
311
+ }
312
+
313
+ function renderDailyChart(showCost = false) {
314
+ const messages = getFilteredMessages();
315
+ const data = Aggregator.aggregateByPeriod(messages, 'day', pricing, checkAsFree);
267
316
 
268
317
  if (charts.daily) charts.daily.destroy();
269
318
 
@@ -375,6 +424,22 @@ async function loadDailyChart(showCost = false) {
375
424
  pointHoverBorderColor: '#00ff88',
376
425
  pointHoverBorderWidth: 2,
377
426
  borderWidth: 2
427
+ },
428
+ {
429
+ label: 'Cache Write',
430
+ data: data.map(d => d.cacheWrite),
431
+ borderColor: getColor(3),
432
+ backgroundColor: 'transparent',
433
+ tension: 0.3,
434
+ pointRadius: 2,
435
+ pointBackgroundColor: getColor(3),
436
+ pointBorderColor: '#0a0a0a',
437
+ pointBorderWidth: 1,
438
+ pointHoverRadius: 6,
439
+ pointHoverBackgroundColor: getColor(3),
440
+ pointHoverBorderColor: '#00ff88',
441
+ pointHoverBorderWidth: 2,
442
+ borderWidth: 2
378
443
  }
379
444
  ]
380
445
  },
@@ -403,6 +468,15 @@ async function loadDailyChart(showCost = false) {
403
468
  callbacks: {
404
469
  label: function(context) {
405
470
  return `${context.dataset.label}: ${formatNumber(context.raw)}`;
471
+ },
472
+ footer: function(tooltipItems) {
473
+ const idx = tooltipItems[0].dataIndex;
474
+ const d = data[idx];
475
+ const total = (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheRead || 0) + (d.cacheWrite || 0);
476
+ return [
477
+ `Messages: ${formatNumber(d.messageCount || 0)}`,
478
+ `Total: ${formatNumber(total)}`
479
+ ];
406
480
  }
407
481
  }
408
482
  }
@@ -422,12 +496,12 @@ async function loadDailyChart(showCost = false) {
422
496
  }
423
497
  }
424
498
 
425
- async function loadHourlyChart(mode = 'messages') {
499
+ function renderHourlyChart(mode = 'messages') {
426
500
  let data;
427
501
  if (mode === 'tps') {
428
- data = await fetchAPI('/stats/hourly-tps');
502
+ data = Aggregator.getHourlyTPS(rawMessages);
429
503
  } else {
430
- data = await fetchAPI('/stats/hourly');
504
+ data = Aggregator.aggregateByHour(rawMessages);
431
505
  }
432
506
 
433
507
  if (charts.hourly) charts.hourly.destroy();
@@ -504,8 +578,9 @@ async function loadHourlyChart(mode = 'messages') {
504
578
  }
505
579
  }
506
580
 
507
- async function loadWeeklyChart() {
508
- const data = await fetchAPI('/stats/weekly?weeks=12');
581
+ function renderWeeklyChart() {
582
+ const messages = getFilteredMessages();
583
+ const data = Aggregator.aggregateByPeriod(messages, 'week', pricing, checkAsFree);
509
584
 
510
585
  if (charts.weekly) charts.weekly.destroy();
511
586
 
@@ -513,7 +588,7 @@ async function loadWeeklyChart() {
513
588
  charts.weekly = new Chart(ctx, {
514
589
  type: 'bar',
515
590
  data: {
516
- labels: data.map(d => (d.week || d.time || '').split('-')[1] || d.week || d.time),
591
+ labels: data.map(d => (d.time || '').split('-')[1] || d.time),
517
592
  datasets: [{
518
593
  label: 'Cost',
519
594
  data: data.map(d => d.cost),
@@ -548,23 +623,48 @@ async function loadWeeklyChart() {
548
623
  });
549
624
  }
550
625
 
551
- async function loadTPSModelsList() {
552
- const modelsWithStats = await fetchAPI('/stats/models?days=30');
626
+ function updateTPSModelsList() {
627
+ const messages = Aggregator.filterByDays(rawMessages, 30);
628
+ const modelsData = Aggregator.aggregateByModel(messages, pricing, checkAsFree);
629
+ const tpsData = Aggregator.getModelsTPS(rawMessages, 30);
630
+ const tpsMap = {};
631
+ for (const m of tpsData) {
632
+ tpsMap[m.baseModel] = m.outputTPS || 0;
633
+ }
634
+
635
+ modelsData.sort((a, b) => (b.outputTokens || 0) - (a.outputTokens || 0));
636
+
637
+ const modelsInfo = {};
638
+ for (const m of modelsData) {
639
+ modelsInfo[m.baseModel] = {
640
+ input: m.inputTokens,
641
+ output: m.outputTokens,
642
+ total: m.inputTokens + m.outputTokens,
643
+ tps: tpsMap[m.baseModel] || 0
644
+ };
645
+ }
646
+
647
+ tpsModelsList = modelsData.slice(0, 10).map(m => m.baseModel);
553
648
 
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);
649
+ if (selectedTPSModels.length === 0) {
650
+ selectedTPSModels = tpsModelsList.slice(0, 5);
651
+ }
557
652
 
558
653
  const dropdown = document.getElementById('tpsModelDropdown');
559
654
  const optionsContainer = dropdown.querySelector('.dropdown-options');
560
655
  optionsContainer.innerHTML = '';
561
656
 
562
657
  for (const model of tpsModelsList) {
658
+ const info = modelsInfo[model];
659
+ const sortVal = formatNumber(info.output);
660
+ const sortTooltip = `In: ${formatNumber(info.input)} | Out: ${formatNumber(info.output)}`;
661
+
563
662
  const label = document.createElement('label');
564
663
  label.className = 'dropdown-option' + (selectedTPSModels.includes(model) ? ' selected' : '');
565
664
  label.innerHTML = `
566
665
  <input type="checkbox" value="${model}" ${selectedTPSModels.includes(model) ? 'checked' : ''}>
567
- ${model}
666
+ <span class="model-name">${model}</span>
667
+ <span class="model-tokens" title="${sortTooltip}">${sortVal}</span>
568
668
  `;
569
669
  optionsContainer.appendChild(label);
570
670
  }
@@ -616,30 +716,30 @@ function initTPSDropdown() {
616
716
  e.stopPropagation();
617
717
  });
618
718
 
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);
719
+ const existingApplyBtn = dropdown.querySelector('.dropdown-menu .btn-primary');
720
+ if (existingApplyBtn) existingApplyBtn.remove();
721
+
722
+ const applyBtn = document.createElement('button');
723
+ applyBtn.type = 'button';
724
+ applyBtn.className = 'btn btn-sm btn-primary';
725
+ applyBtn.textContent = 'Apply';
726
+ applyBtn.style.marginTop = '0.5rem';
727
+ applyBtn.style.width = '100%';
728
+ applyBtn.addEventListener('click', () => {
729
+ dropdown.classList.remove('open');
730
+ const checked = dropdown.querySelectorAll('input[type="checkbox"]:checked');
731
+ selectedTPSModels = Array.from(checked).map(c => c.value);
732
+ renderTPSChart();
733
+ });
734
+ dropdown.querySelector('.dropdown-menu').appendChild(applyBtn);
634
735
  }
635
736
 
636
- async function loadTPSChart() {
737
+ function renderTPSChart() {
637
738
  if (selectedTPSModels.length === 0) {
638
739
  selectedTPSModels = tpsModelsList.slice(0, 5);
639
740
  }
640
741
 
641
- const modelsParam = selectedTPSModels.join(',');
642
- const data = await fetchAPI('/stats/daily-tps-by-model?days=30&models=' + modelsParam);
742
+ const data = Aggregator.getDailyTPSByModel(rawMessages, 30, selectedTPSModels, pricing);
643
743
 
644
744
  if (charts.tps) charts.tps.destroy();
645
745
 
@@ -693,10 +793,14 @@ async function loadTPSChart() {
693
793
  const tps = extra?.tps?.toFixed(2) || '-';
694
794
  const input = extra?.inputTokens ? formatNumber(extra.inputTokens) : '0';
695
795
  const output = extra?.outputTokens ? formatNumber(extra.outputTokens) : '0';
796
+ const reasoning = extra?.reasoningTokens ? formatNumber(extra.reasoningTokens) : '0';
797
+ const cacheRead = extra?.cacheRead ? formatNumber(extra.cacheRead) : '0';
798
+ const cacheWrite = extra?.cacheWrite ? formatNumber(extra.cacheWrite) : '0';
696
799
  return [
697
800
  ctx.dataset.label,
698
801
  ` ├─ TPS: ${tps} tok/s`,
699
- ` └─ In: ${input} | Out: ${output}`
802
+ ` ├─ In: ${input} | Out: ${output} | Think: ${reasoning}`,
803
+ ` └─ Cache R: ${cacheRead} | W: ${cacheWrite}`
700
804
  ];
701
805
  }
702
806
  }
@@ -720,24 +824,75 @@ async function loadTPSChart() {
720
824
  });
721
825
  }
722
826
 
723
- function updateModelsTable() {
724
- const tbody = document.querySelector('#modelsTable tbody');
725
- tbody.innerHTML = '';
827
+ function renderAll() {
828
+ renderOverview();
829
+ renderModelsChart();
830
+ renderDailyChart(currentChartType === 'cost');
831
+ renderHourlyChart(currentHourlyType);
832
+ renderWeeklyChart();
833
+ updateTPSModelsList();
834
+ renderTPSChart();
835
+ }
836
+
837
+ function setAutoRefresh(seconds) {
838
+ autoRefreshSeconds = seconds;
726
839
 
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
- });
840
+ if (autoRefreshInterval) {
841
+ clearInterval(autoRefreshInterval);
842
+ autoRefreshInterval = null;
843
+ }
844
+
845
+ const indicator = document.getElementById('autoRefreshIndicator');
846
+ const btn = document.getElementById('autoRefreshBtn');
847
+
848
+ if (seconds <= 0) {
849
+ if (indicator) indicator.style.display = 'none';
850
+ if (btn) btn.classList.remove('active');
851
+ return;
852
+ }
853
+
854
+ if (indicator) indicator.style.display = 'inline';
855
+ if (btn) btn.classList.add('active');
856
+
857
+ autoRefreshInterval = setInterval(async () => {
858
+ try {
859
+ await loadRawData();
860
+ renderAll();
861
+ updateAutoRefreshIndicator();
862
+ } catch (err) {
863
+ console.error('Auto-refresh failed:', err);
864
+ }
865
+ }, seconds * 1000);
866
+
867
+ updateAutoRefreshIndicator();
868
+ }
869
+
870
+ function updateAutoRefreshIndicator() {
871
+ const indicator = document.getElementById('autoRefreshIndicator');
872
+ if (!indicator || autoRefreshSeconds <= 0) return;
873
+
874
+ const nextRefresh = new Date(Date.now() + autoRefreshSeconds * 1000);
875
+ indicator.textContent = `Auto: ${autoRefreshSeconds}s`;
876
+ }
877
+
878
+ async function manualRefresh() {
879
+ const btn = document.getElementById('refreshBtn');
880
+ if (btn) {
881
+ btn.textContent = '...';
882
+ btn.disabled = true;
883
+ }
884
+
885
+ try {
886
+ await loadRawData();
887
+ renderAll();
888
+ } catch (err) {
889
+ console.error('Refresh failed:', err);
890
+ } finally {
891
+ if (btn) {
892
+ btn.textContent = 'Refresh';
893
+ btn.disabled = false;
894
+ }
895
+ }
741
896
  }
742
897
 
743
898
  async function loadPricingModal() {
@@ -783,24 +938,17 @@ async function loadPricingModal() {
783
938
 
784
939
  btn.textContent = 'Saved';
785
940
  setTimeout(() => btn.textContent = 'Save', 1200);
786
- await loadAll();
941
+
942
+ await loadRawData();
943
+ renderAll();
787
944
  });
788
945
  });
789
946
  }
790
947
 
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
948
  document.addEventListener('DOMContentLoaded', async () => {
802
949
  try {
803
- await loadAll();
950
+ await loadRawData();
951
+ renderAll();
804
952
  initTPSDropdown();
805
953
  document.getElementById('loading').classList.add('hidden');
806
954
  } catch (err) {
@@ -808,17 +956,33 @@ document.addEventListener('DOMContentLoaded', async () => {
808
956
  document.querySelector('.loading span').textContent = 'Failed to load data. Is the server running?';
809
957
  }
810
958
 
811
- document.getElementById('checkAsFree').addEventListener('change', async function() {
959
+ document.getElementById('checkAsFree').addEventListener('change', function() {
812
960
  checkAsFree = this.checked;
813
- await loadAll();
961
+ renderAll();
814
962
  });
815
963
 
964
+ document.getElementById('refreshBtn').addEventListener('click', manualRefresh);
965
+
966
+ document.getElementById('autoRefreshBtn').addEventListener('click', function() {
967
+ const select = document.getElementById('autoRefreshSelect');
968
+ const seconds = parseInt(select.value) || 0;
969
+ setAutoRefresh(seconds);
970
+ });
971
+
972
+ document.getElementById('autoRefreshSelect').addEventListener('change', function() {
973
+ if (autoRefreshSeconds > 0) {
974
+ setAutoRefresh(parseInt(this.value) || 0);
975
+ }
976
+ });
977
+
978
+
816
979
  document.getElementById('refreshPricingBtn').addEventListener('click', async function() {
817
980
  this.textContent = '...';
818
981
  await fetch(`${API_BASE}/pricing/reset`, { method: 'POST' });
819
982
  this.textContent = 'Done';
820
983
  setTimeout(() => this.textContent = 'Actualize prices', 1500);
821
- await loadAll();
984
+ await loadRawData();
985
+ renderAll();
822
986
  });
823
987
 
824
988
  document.getElementById('editPricingBtn').addEventListener('click', () => {
@@ -837,30 +1001,31 @@ document.addEventListener('DOMContentLoaded', async () => {
837
1001
  });
838
1002
 
839
1003
  document.querySelectorAll('#mainTimeFilter .btn[data-range]').forEach(btn => {
840
- btn.addEventListener('click', async function() {
1004
+ btn.addEventListener('click', function() {
841
1005
  document.querySelectorAll('#mainTimeFilter .btn[data-range]').forEach(b => b.classList.remove('active'));
842
1006
  this.classList.add('active');
843
1007
  currentRange = this.dataset.range === 'all' ? 'all' : parseInt(this.dataset.range);
844
1008
  useCustomRange = false;
845
- await loadAll();
1009
+ renderAll();
846
1010
  });
847
1011
  });
848
1012
 
849
1013
  document.querySelectorAll('.chart-type-toggle .btn').forEach(btn => {
850
- btn.addEventListener('click', async function() {
1014
+ btn.addEventListener('click', function() {
851
1015
  document.querySelectorAll('.chart-type-toggle .btn').forEach(b => b.classList.remove('active'));
852
1016
  this.classList.add('active');
853
1017
 
854
1018
  if (this.dataset.chart) {
855
1019
  currentChartType = this.dataset.chart;
856
- await loadDailyChart(currentChartType === 'cost');
1020
+ renderDailyChart(currentChartType === 'cost');
857
1021
  } else if (this.dataset.hourly) {
858
- await loadHourlyChart(this.dataset.hourly);
1022
+ currentHourlyType = this.dataset.hourly;
1023
+ renderHourlyChart(currentHourlyType);
859
1024
  }
860
1025
  });
861
1026
  });
862
1027
 
863
- document.getElementById('applyDateRange').addEventListener('click', async function() {
1028
+ document.getElementById('applyDateRange').addEventListener('click', function() {
864
1029
  const start = document.getElementById('startDate').value;
865
1030
  const end = document.getElementById('endDate').value;
866
1031
 
@@ -869,7 +1034,7 @@ document.addEventListener('DOMContentLoaded', async () => {
869
1034
  customEnd = end;
870
1035
  useCustomRange = true;
871
1036
  document.querySelectorAll('#mainTimeFilter .btn[data-range]').forEach(b => b.classList.remove('active'));
872
- await loadAll();
1037
+ renderAll();
873
1038
  }
874
1039
  });
875
1040
 
package/public/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>OpenCode Analytics</title>
7
- <link rel="stylesheet" href="styles.css">
7
+ <link rel="stylesheet" href="styles.css?v=2">
8
8
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
9
9
  </head>
10
10
  <body>
@@ -18,6 +18,18 @@
18
18
  <input type="checkbox" id="checkAsFree" checked>
19
19
  <span>Check as Free</span>
20
20
  </label>
21
+ <div class="refresh-controls">
22
+ <button id="refreshBtn" class="btn btn-outline" title="Refresh data">Refresh</button>
23
+ <select id="autoRefreshSelect" class="btn btn-outline" style="padding-right: 1.5rem;">
24
+ <option value="0">Auto: Off</option>
25
+ <option value="30">30s</option>
26
+ <option value="60">1m</option>
27
+ <option value="120">2m</option>
28
+ <option value="300">5m</option>
29
+ </select>
30
+ <button id="autoRefreshBtn" class="btn btn-outline" title="Start auto-refresh">Start Auto</button>
31
+ <span id="autoRefreshIndicator" style="display:none; color: #00ff88; font-size: 0.75em; margin-left: 0.25rem;"></span>
32
+ </div>
21
33
  <button id="refreshPricingBtn" class="btn btn-outline" title="Reset all custom prices to server defaults. Your changes will be lost.">Actualize prices</button>
22
34
  <button id="editPricingBtn" class="btn btn-outline">Edit Pricing</button>
23
35
  </div>
@@ -52,6 +64,13 @@
52
64
  <div class="card-value" id="totalCacheRead">-</div>
53
65
  </div>
54
66
  </div>
67
+ <div class="card">
68
+ <div class="card-icon">💾</div>
69
+ <div class="card-content">
70
+ <div class="card-label">Cache Write</div>
71
+ <div class="card-value" id="totalCacheWrite">-</div>
72
+ </div>
73
+ </div>
55
74
  <div class="card highlight">
56
75
  <div class="card-icon">💰</div>
57
76
  <div class="card-content">
@@ -171,6 +190,7 @@
171
190
  <span>Loading analytics...</span>
172
191
  </div>
173
192
 
174
- <script src="app.js?v=10"></script>
193
+ <script src="aggregator.js?v=3"></script>
194
+ <script src="app.js?v=14"></script>
175
195
  </body>
176
196
  </html>
package/public/styles.css CHANGED
@@ -270,8 +270,12 @@ h1 {
270
270
  .time-filter-row {
271
271
  display: flex;
272
272
  align-items: center;
273
+ justify-content: center;
273
274
  gap: 1rem;
275
+ margin-top: 1.5rem;
274
276
  margin-bottom: 1.25rem;
277
+ padding-top: 1.5rem;
278
+ border-top: 1px solid var(--border);
275
279
  flex-wrap: wrap;
276
280
  }
277
281
 
@@ -688,6 +692,11 @@ tbody tr:hover {
688
692
  align-items: flex-start;
689
693
  }
690
694
 
695
+ .time-filter-row {
696
+ flex-direction: column;
697
+ align-items: center;
698
+ }
699
+
691
700
  .date-range-picker {
692
701
  margin-left: 0;
693
702
  margin-top: 0.5rem;
@@ -796,6 +805,7 @@ tbody tr:hover {
796
805
  .dropdown-option {
797
806
  display: flex;
798
807
  align-items: center;
808
+ justify-content: space-between;
799
809
  gap: 0.5rem;
800
810
  padding: 0.4rem 0.5rem;
801
811
  border-radius: 4px;
@@ -814,13 +824,119 @@ tbody tr:hover {
814
824
  width: 14px;
815
825
  height: 14px;
816
826
  cursor: pointer;
827
+ flex-shrink: 0;
828
+ }
829
+
830
+ .dropdown-option .model-name {
831
+ flex: 1;
832
+ overflow: hidden;
833
+ text-overflow: ellipsis;
834
+ white-space: nowrap;
835
+ }
836
+
837
+ .dropdown-option .model-tokens {
838
+ font-size: 0.6rem;
839
+ color: var(--text-muted);
840
+ flex-shrink: 0;
841
+ cursor: help;
842
+ }
843
+
844
+ .dropdown-option:hover .model-tokens {
845
+ color: var(--text-secondary);
846
+ }
847
+
848
+ .dropdown-option.selected {
849
+ color: var(--accent);
850
+ }
851
+
852
+ .dropdown-option .model-tokens {
853
+ font-size: 0.65rem;
854
+ color: var(--text-muted);
855
+ flex-shrink: 0;
856
+ cursor: help;
857
+ }
858
+
859
+ .dropdown-option:hover .model-tokens {
860
+ color: var(--text-secondary);
817
861
  }
818
862
 
819
863
  .dropdown-option.selected {
820
864
  color: var(--accent);
821
865
  }
822
866
 
823
- .dropdown-option.selected::before {
824
- content: '✓ ';
867
+ .dropdown-option.selected .model-tokens {
868
+ color: var(--accent);
869
+ }
870
+
871
+ .refresh-controls {
872
+ display: flex;
873
+ align-items: center;
874
+ gap: 0.25rem;
875
+ }
876
+
877
+ .refresh-controls select {
878
+ background: var(--bg-tertiary);
879
+ border: 1px solid var(--border-light);
880
+ border-radius: 4px;
881
+ color: var(--text-secondary);
882
+ font-family: inherit;
883
+ font-size: 0.7rem;
884
+ padding: 0.5rem 0.875rem;
885
+ cursor: pointer;
886
+ appearance: none;
887
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3E%3Cpath fill='%23888' d='M4 6L1 2h6z'/%3E%3C/svg%3E");
888
+ background-repeat: no-repeat;
889
+ background-position: right 0.5rem center;
890
+ padding-right: 1.5rem;
891
+ }
892
+
893
+ .refresh-controls select:hover {
894
+ border-color: var(--accent);
895
+ color: var(--accent);
896
+ }
897
+
898
+ .refresh-controls select:focus {
899
+ outline: none;
900
+ border-color: var(--accent);
901
+ }
902
+
903
+ #autoRefreshBtn.active {
904
+ background: var(--accent);
905
+ color: var(--bg-primary);
906
+ border-color: var(--accent);
907
+ }
908
+
909
+ #autoRefreshBtn.active:hover {
910
+ background: var(--accent-dim);
911
+ }
912
+
913
+
914
+
915
+ .dropdown-option {
916
+ display: flex;
917
+ align-items: center;
918
+ justify-content: space-between;
919
+ gap: 0.5rem;
920
+ }
921
+
922
+ .dropdown-option .model-name {
923
+ flex: 1;
924
+ overflow: hidden;
925
+ text-overflow: ellipsis;
926
+ white-space: nowrap;
927
+ }
928
+
929
+ .dropdown-option .model-tokens {
825
930
  font-size: 0.65rem;
931
+ color: var(--text-muted);
932
+ flex-shrink: 0;
933
+ cursor: help;
934
+ }
935
+
936
+ .dropdown-option:hover .model-tokens {
937
+ color: var(--text-secondary);
938
+ }
939
+
940
+ .dropdown-option.selected .model-tokens {
941
+ color: var(--accent);
826
942
  }
package/server/db.js CHANGED
@@ -522,6 +522,36 @@ function getModelsList() {
522
522
  .sort();
523
523
  }
524
524
 
525
+ function getRawMessages() {
526
+ const rows = query(`
527
+ SELECT
528
+ time_created as ts,
529
+ json_extract(data, '$.modelID') as modelId,
530
+ json_extract(data, '$.tokens.input') as tin,
531
+ json_extract(data, '$.tokens.output') as tout,
532
+ json_extract(data, '$.tokens.reasoning') as treas,
533
+ json_extract(data, '$.tokens.cache.read') as tcr,
534
+ json_extract(data, '$.tokens.cache.write') as tcw,
535
+ json_extract(data, '$.time.created') as tStart,
536
+ json_extract(data, '$.time.completed') as tEnd
537
+ FROM message
538
+ WHERE data LIKE '%"tokens":%'
539
+ ORDER BY time_created
540
+ `);
541
+
542
+ return rows.map(row => ({
543
+ ts: row.ts,
544
+ modelId: row.modelId,
545
+ in: parseInt(row.tin) || 0,
546
+ out: parseInt(row.tout) || 0,
547
+ rs: parseInt(row.treas) || 0,
548
+ cr: parseInt(row.tcr) || 0,
549
+ cw: parseInt(row.tcw) || 0,
550
+ tStart: row.tStart ? parseFloat(row.tStart) : null,
551
+ tEnd: row.tEnd ? parseFloat(row.tEnd) : null
552
+ }));
553
+ }
554
+
525
555
  module.exports = {
526
556
  normalizeModelName,
527
557
  getShortModelName,
@@ -537,5 +567,6 @@ module.exports = {
537
567
  getModelsTPSStats,
538
568
  getHourlyTPSStats,
539
569
  getDailyTPSByModel,
540
- getModelsList
570
+ getModelsList,
571
+ getRawMessages
541
572
  };
package/server/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const express = require('express');
2
+ const compression = require('compression');
2
3
  const path = require('path');
3
4
  const fs = require('fs');
4
5
  const db = require('./db');
@@ -277,6 +278,7 @@ function aggregateByTimeFrame(stats, timeKey, checkAsFree = true) {
277
278
  }
278
279
 
279
280
  const app = express();
281
+ app.use(compression());
280
282
  app.use(express.json());
281
283
  app.get('/', (req, res) => {
282
284
  res.sendFile(path.join(__dirname, '../public/index.html'));
@@ -483,6 +485,49 @@ app.delete('/api/pricing/:model', (req, res) => {
483
485
  }
484
486
  });
485
487
 
488
+ app.get('/api/stats/raw', (req, res) => {
489
+ const messages = db.getRawMessages();
490
+ const modelPrices = {};
491
+
492
+ for (const [name, p] of Object.entries(pricing.models || {})) {
493
+ modelPrices[name] = {
494
+ input: p.input || 0,
495
+ output: p.output || 0,
496
+ cacheRead: p.cacheRead || p.input || 0,
497
+ isFree: p.isFree || false
498
+ };
499
+ }
500
+
501
+ for (const [name, p] of Object.entries(userPrices.prices || {})) {
502
+ modelPrices[name] = {
503
+ input: p.input || 0,
504
+ output: p.output || 0,
505
+ cacheRead: p.cacheRead || 0,
506
+ isFree: false
507
+ };
508
+ }
509
+
510
+ let firstMessage = null;
511
+ let lastMessage = null;
512
+ for (const msg of messages) {
513
+ if (msg.ts) {
514
+ if (!firstMessage || msg.ts < firstMessage) firstMessage = msg.ts;
515
+ if (!lastMessage || msg.ts > lastMessage) lastMessage = msg.ts;
516
+ }
517
+ }
518
+
519
+ res.json({
520
+ messages,
521
+ pricing: modelPrices,
522
+ meta: {
523
+ firstMessage,
524
+ lastMessage,
525
+ count: messages.length,
526
+ lastUpdated: pricing.lastUpdated
527
+ }
528
+ });
529
+ });
530
+
486
531
  const PORT = process.env.PORT || 3456;
487
532
 
488
533
  app.listen(PORT, async () => {
@@ -1,5 +1,5 @@
1
1
  {
2
- "lastUpdated": "2026-03-17T10:05:06.696Z",
2
+ "lastUpdated": "2026-03-17T11:20:59.554Z",
3
3
  "source": "models.dev",
4
4
  "overrides": {
5
5
  "big-pickle": {