@lemantorus/opencode-analytics 1.0.0 → 1.0.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 +11 -1
- package/package.json +2 -1
- package/public/aggregator.js +379 -0
- package/public/app.js +261 -96
- package/public/index.html +22 -2
- package/public/styles.css +118 -2
- package/server/db.js +32 -1
- package/server/index.js +45 -0
- package/server/pricing.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
# OpenCode Analytics
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@lemantorus/opencode-analytics)
|
|
4
|
+
[](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
|
-

|
|
11
|
+
|
|
12
|
+
<details>
|
|
13
|
+
<summary>📱 Mobile View</summary>
|
|
14
|
+
|
|
15
|
+

|
|
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.
|
|
3
|
+
"version": "1.0.1",
|
|
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
|
-
|
|
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
|
|
116
|
-
const
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
151
|
+
if (currentRange === 'all') {
|
|
152
|
+
return rawMessages;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return Aggregator.filterByDays(rawMessages, currentRange);
|
|
132
156
|
}
|
|
133
157
|
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
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
|
-
|
|
290
|
+
renderModelsTable(modelsData);
|
|
256
291
|
}
|
|
257
292
|
|
|
258
|
-
|
|
259
|
-
|
|
293
|
+
function renderModelsTable(modelsData) {
|
|
294
|
+
const tbody = document.querySelector('#modelsTable tbody');
|
|
295
|
+
tbody.innerHTML = '';
|
|
260
296
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
499
|
+
function renderHourlyChart(mode = 'messages') {
|
|
426
500
|
let data;
|
|
427
501
|
if (mode === 'tps') {
|
|
428
|
-
data =
|
|
502
|
+
data = Aggregator.getHourlyTPS(rawMessages);
|
|
429
503
|
} else {
|
|
430
|
-
data =
|
|
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
|
-
|
|
508
|
-
const
|
|
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.
|
|
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
|
-
|
|
552
|
-
const
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
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
|
-
|
|
737
|
+
function renderTPSChart() {
|
|
637
738
|
if (selectedTPSModels.length === 0) {
|
|
638
739
|
selectedTPSModels = tpsModelsList.slice(0, 5);
|
|
639
740
|
}
|
|
640
741
|
|
|
641
|
-
const
|
|
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
|
-
`
|
|
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
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
|
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',
|
|
959
|
+
document.getElementById('checkAsFree').addEventListener('change', function() {
|
|
812
960
|
checkAsFree = this.checked;
|
|
813
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
1009
|
+
renderAll();
|
|
846
1010
|
});
|
|
847
1011
|
});
|
|
848
1012
|
|
|
849
1013
|
document.querySelectorAll('.chart-type-toggle .btn').forEach(btn => {
|
|
850
|
-
btn.addEventListener('click',
|
|
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
|
-
|
|
1020
|
+
renderDailyChart(currentChartType === 'cost');
|
|
857
1021
|
} else if (this.dataset.hourly) {
|
|
858
|
-
|
|
1022
|
+
currentHourlyType = this.dataset.hourly;
|
|
1023
|
+
renderHourlyChart(currentHourlyType);
|
|
859
1024
|
}
|
|
860
1025
|
});
|
|
861
1026
|
});
|
|
862
1027
|
|
|
863
|
-
document.getElementById('applyDateRange').addEventListener('click',
|
|
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
|
-
|
|
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="
|
|
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
|
|
824
|
-
|
|
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 () => {
|
package/server/pricing.json
CHANGED