opencode-tokenwatch 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +32 -38
- package/README.md +33 -39
- package/dist/host/command-actions.d.ts +20 -0
- package/dist/host/runtime.d.ts +7 -0
- package/dist/host/types.d.ts +148 -0
- package/dist/host/v1/adapter.d.ts +9 -0
- package/dist/host/v1/commands.d.ts +9 -0
- package/dist/{queries.d.ts → host/v1/data-source.d.ts} +1 -6
- package/dist/host/v2/adapter.d.ts +63 -0
- package/dist/host/v2/commands.d.ts +8 -0
- package/dist/host/v2/data-source.d.ts +26 -0
- package/dist/kernel/config.d.ts +33 -0
- package/dist/{formatter.d.ts → kernel/format.d.ts} +9 -10
- package/dist/kernel/model.d.ts +19 -0
- package/dist/kernel/perf-aggregate.d.ts +62 -0
- package/dist/{perf-tracker.d.ts → kernel/perf.d.ts} +13 -7
- package/dist/{generate-usage-html.d.ts → kernel/report-html.d.ts} +1 -1
- package/dist/kernel/report.d.ts +19 -0
- package/dist/{stats-store.d.ts → kernel/store.d.ts} +5 -5
- package/dist/server.d.ts +15 -0
- package/dist/server.js +15 -0
- package/dist/tui.d.ts +11 -12
- package/dist/tui.js +4802 -0
- package/dist/ui/sidebar.d.ts +14 -0
- package/index.ts +11 -0
- package/package.json +39 -12
- package/tui.ts +9 -0
- package/dist/commands.d.ts +0 -2
- package/dist/commands.js +0 -208
- package/dist/commands.jsx +0 -381
- package/dist/formatter.js +0 -289
- package/dist/generate-usage-html.js +0 -962
- package/dist/i18n.js +0 -173
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -7
- package/dist/perf-tracker.js +0 -299
- package/dist/queries.js +0 -393
- package/dist/sidebar.d.ts +0 -22
- package/dist/sidebar.jsx +0 -604
- package/dist/stats-store.js +0 -258
- package/dist/tui.jsx +0 -178
- /package/dist/{i18n.d.ts → kernel/i18n.d.ts} +0 -0
|
@@ -1,962 +0,0 @@
|
|
|
1
|
-
function fmtTokens(n) {
|
|
2
|
-
if (n >= 1_000_000_000)
|
|
3
|
-
return (n / 1_000_000_000).toFixed(1) + "B";
|
|
4
|
-
if (n >= 1_000_000)
|
|
5
|
-
return (n / 1_000_000).toFixed(1) + "M";
|
|
6
|
-
if (n >= 1_000)
|
|
7
|
-
return (n / 1_000).toFixed(1) + "K";
|
|
8
|
-
return String(n);
|
|
9
|
-
}
|
|
10
|
-
function fmtCost(n) {
|
|
11
|
-
if (n === 0)
|
|
12
|
-
return "$0.00";
|
|
13
|
-
if (n < 0.01)
|
|
14
|
-
return "$" + n.toFixed(6);
|
|
15
|
-
return "$" + n.toFixed(2);
|
|
16
|
-
}
|
|
17
|
-
function fmtPercent(n) {
|
|
18
|
-
return (n * 100).toFixed(1) + "%";
|
|
19
|
-
}
|
|
20
|
-
function cacheHitRate(input, cacheRead) {
|
|
21
|
-
if (input + cacheRead === 0)
|
|
22
|
-
return 0;
|
|
23
|
-
return cacheRead / (input + cacheRead);
|
|
24
|
-
}
|
|
25
|
-
function sortModelsByUsage(models) {
|
|
26
|
-
// 过滤掉 totalTokens=0 的无效模型条目(如会话失败导致全为 0 的记录)
|
|
27
|
-
return [...models].filter(m => m.totalTokens > 0).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
28
|
-
}
|
|
29
|
-
function renderMeta(data) {
|
|
30
|
-
const m = data.meta;
|
|
31
|
-
return `TokenWatch Usage Report · ${m.dateRange.start} → ${m.dateRange.end} · generated ${m.generatedAt}`;
|
|
32
|
-
}
|
|
33
|
-
function renderKpiCards(data) {
|
|
34
|
-
const s = data.summary;
|
|
35
|
-
const hitRate = cacheHitRate(s.inputTokens, s.cacheRead);
|
|
36
|
-
const hitRatePct = fmtPercent(hitRate);
|
|
37
|
-
let tpsSum = 0, tpsReqs = 0;
|
|
38
|
-
for (const p of data.perfSummary) {
|
|
39
|
-
if (p.avgTPS != null && p.avgTPS > 0) {
|
|
40
|
-
tpsSum += p.avgTPS * p.requestCount;
|
|
41
|
-
tpsReqs += p.requestCount;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
const avgTpsRaw = tpsReqs > 0 ? tpsSum / tpsReqs : 0;
|
|
45
|
-
const avgTps = tpsReqs > 0 ? avgTpsRaw.toFixed(1) : '—';
|
|
46
|
-
const isHighCache = hitRate >= 0.5;
|
|
47
|
-
const errors = data.errors;
|
|
48
|
-
const errorRatePct = errors ? (errors.errorRate * 100).toFixed(1) + '%' : '—';
|
|
49
|
-
const errorColor = errors && errors.errorRate >= 0.05 ? 'var(--output)'
|
|
50
|
-
: errors && errors.errorRate > 0 ? 'var(--tps)' : 'var(--cache)';
|
|
51
|
-
return `
|
|
52
|
-
<div class="kpi-row">
|
|
53
|
-
<div class="kpi-card">
|
|
54
|
-
<div class="kpi-label">Total Tokens</div>
|
|
55
|
-
<div class="kpi-value">${fmtTokens(s.totalTokens)}</div>
|
|
56
|
-
</div>
|
|
57
|
-
<div class="kpi-card${isHighCache ? ' kpi-glow' : ''}">
|
|
58
|
-
<div class="kpi-label">Cache Hit Rate</div>
|
|
59
|
-
<div class="kpi-value" style="color:var(--cache)">${hitRatePct}</div>
|
|
60
|
-
</div>
|
|
61
|
-
<div class="kpi-card">
|
|
62
|
-
<div class="kpi-label">Avg TPS</div>
|
|
63
|
-
<div class="kpi-value" style="color:var(--tps)">${avgTps}</div>
|
|
64
|
-
</div>
|
|
65
|
-
<div class="kpi-card">
|
|
66
|
-
<div class="kpi-label">Requests</div>
|
|
67
|
-
<div class="kpi-value">${s.requestCount}</div>
|
|
68
|
-
</div>
|
|
69
|
-
<div class="kpi-card">
|
|
70
|
-
<div class="kpi-label">Total Cost</div>
|
|
71
|
-
<div class="kpi-value" style="color:var(--tps)">${fmtCost(s.totalCost)}</div>
|
|
72
|
-
</div>
|
|
73
|
-
<div class="kpi-card">
|
|
74
|
-
<div class="kpi-label">Error Rate</div>
|
|
75
|
-
<div class="kpi-value" style="color:${errorColor}">${errorRatePct}</div>
|
|
76
|
-
</div>
|
|
77
|
-
</div>`;
|
|
78
|
-
}
|
|
79
|
-
function renderModelChartInit(data) {
|
|
80
|
-
const models = sortModelsByUsage(data.models).filter(m => m.totalTokens >= 1_000_000);
|
|
81
|
-
const names = models.map(m => m.model);
|
|
82
|
-
const inputData = models.map(m => m.inputTokens);
|
|
83
|
-
const outputData = models.map(m => m.outputTokens);
|
|
84
|
-
const cacheData = models.map(m => m.cacheRead);
|
|
85
|
-
const tpsData = models.map(m => {
|
|
86
|
-
const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
|
|
87
|
-
return perf?.avgTPS ?? null;
|
|
88
|
-
});
|
|
89
|
-
return `var modelNames = ${JSON.stringify(names)};
|
|
90
|
-
var modelInput = ${JSON.stringify(inputData)};
|
|
91
|
-
var modelOutput = ${JSON.stringify(outputData)};
|
|
92
|
-
var modelCache = ${JSON.stringify(cacheData)};
|
|
93
|
-
var modelTps = ${JSON.stringify(tpsData)};
|
|
94
|
-
|
|
95
|
-
function initModelChart() {
|
|
96
|
-
var el = document.getElementById('model-chart');
|
|
97
|
-
if (!el) return;
|
|
98
|
-
var chart = echarts.init(el);
|
|
99
|
-
window.modelChart = chart;
|
|
100
|
-
renderModelChart(chart);
|
|
101
|
-
return chart;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function renderModelChart(chart) {
|
|
105
|
-
var totals = modelInput.map(function(v, i) { return v + modelOutput[i] + modelCache[i]; });
|
|
106
|
-
var option = {
|
|
107
|
-
tooltip: {
|
|
108
|
-
trigger: 'axis',
|
|
109
|
-
axisPointer: { type: 'shadow' },
|
|
110
|
-
formatter: function(params) {
|
|
111
|
-
var html = '<b>' + params[0].axisValue + '</b><br/>';
|
|
112
|
-
var total = 0;
|
|
113
|
-
params.forEach(function(p) {
|
|
114
|
-
if (p.seriesName !== 'TPS') {
|
|
115
|
-
html += p.marker + ' ' + p.seriesName + ': ' + fmt(p.value) + '<br/>';
|
|
116
|
-
total += p.value;
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
html += 'Total: ' + fmt(total) + '<br/>';
|
|
120
|
-
var tpsParam = params.find(function(p) { return p.seriesName === 'TPS'; });
|
|
121
|
-
if (tpsParam && tpsParam.value != null) {
|
|
122
|
-
html += tpsParam.marker + ' TPS: ' + tpsParam.value.toFixed(1) + '<br/>';
|
|
123
|
-
}
|
|
124
|
-
return html;
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
legend: {
|
|
128
|
-
data: ['Input', 'Output', 'Cache', 'TPS'],
|
|
129
|
-
textStyle: { color: '#B0B0C0' },
|
|
130
|
-
top: 5
|
|
131
|
-
},
|
|
132
|
-
grid: { left: 60, right: 60, bottom: 100, top: 50 },
|
|
133
|
-
xAxis: {
|
|
134
|
-
type: 'category',
|
|
135
|
-
data: modelNames,
|
|
136
|
-
axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
|
|
137
|
-
axisLine: { lineStyle: { color: '#2A2A35' } }
|
|
138
|
-
},
|
|
139
|
-
yAxis: [
|
|
140
|
-
{
|
|
141
|
-
type: 'value',
|
|
142
|
-
name: 'Tokens',
|
|
143
|
-
nameTextStyle: { color: '#B0B0C0' },
|
|
144
|
-
axisLabel: {
|
|
145
|
-
color: '#B0B0C0',
|
|
146
|
-
formatter: fmt
|
|
147
|
-
},
|
|
148
|
-
splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
|
|
149
|
-
},
|
|
150
|
-
{
|
|
151
|
-
type: 'value',
|
|
152
|
-
name: 'TPS',
|
|
153
|
-
nameTextStyle: { color: '#FFB800' },
|
|
154
|
-
axisLabel: { color: '#FFB800', formatter: function(v) { return v.toFixed(1); } },
|
|
155
|
-
splitLine: { show: false }
|
|
156
|
-
}
|
|
157
|
-
],
|
|
158
|
-
series: [
|
|
159
|
-
{
|
|
160
|
-
name: 'Input',
|
|
161
|
-
type: 'bar',
|
|
162
|
-
stack: 'tokens',
|
|
163
|
-
data: modelInput,
|
|
164
|
-
itemStyle: { color: '#00D1FF' },
|
|
165
|
-
barMaxWidth: 40
|
|
166
|
-
},
|
|
167
|
-
{
|
|
168
|
-
name: 'Cache',
|
|
169
|
-
type: 'bar',
|
|
170
|
-
stack: 'tokens',
|
|
171
|
-
data: modelCache,
|
|
172
|
-
itemStyle: { color: '#00F593' },
|
|
173
|
-
barMaxWidth: 40,
|
|
174
|
-
label: {
|
|
175
|
-
show: true,
|
|
176
|
-
position: 'inside',
|
|
177
|
-
formatter: function(p) {
|
|
178
|
-
var cache = modelCache[p.dataIndex];
|
|
179
|
-
var input = modelInput[p.dataIndex];
|
|
180
|
-
if (cache === 0) return '';
|
|
181
|
-
return (cache / (input + cache) * 100).toFixed(0) + '%';
|
|
182
|
-
},
|
|
183
|
-
color: '#fff', fontSize: 10, fontWeight: 'bold'
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
{
|
|
187
|
-
name: 'Output',
|
|
188
|
-
type: 'bar',
|
|
189
|
-
stack: 'tokens',
|
|
190
|
-
data: modelOutput,
|
|
191
|
-
itemStyle: { color: '#B545FF' },
|
|
192
|
-
barMaxWidth: 40,
|
|
193
|
-
label: {
|
|
194
|
-
show: true,
|
|
195
|
-
position: 'top',
|
|
196
|
-
formatter: function(p) {
|
|
197
|
-
var total = modelInput[p.dataIndex] + modelOutput[p.dataIndex] + modelCache[p.dataIndex];
|
|
198
|
-
return total > 0 ? fmt(total) : '';
|
|
199
|
-
},
|
|
200
|
-
color: '#fff', fontSize: 10, fontWeight: 'bold'
|
|
201
|
-
}
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
name: 'TPS',
|
|
205
|
-
type: 'scatter',
|
|
206
|
-
yAxisIndex: 1,
|
|
207
|
-
data: modelTps,
|
|
208
|
-
symbol: 'diamond',
|
|
209
|
-
symbolSize: function(val) { return val != null && val > 0 ? 13 : 0; },
|
|
210
|
-
itemStyle: { color: '#FFB800' },
|
|
211
|
-
label: {
|
|
212
|
-
show: true,
|
|
213
|
-
position: 'right',
|
|
214
|
-
formatter: function(p) { return p.value != null && p.value > 0 ? p.value.toFixed(1) : ''; },
|
|
215
|
-
color: '#FFB800', fontSize: 10
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
]
|
|
219
|
-
};
|
|
220
|
-
chart.setOption(option);
|
|
221
|
-
chart.resize();
|
|
222
|
-
}`;
|
|
223
|
-
}
|
|
224
|
-
function renderScatterChartInit(data) {
|
|
225
|
-
// 过滤掉全零无效条目:无请求或无任何 token 的模型
|
|
226
|
-
const validPerf = data.perfSummary.filter(p => p.requestCount > 0 &&
|
|
227
|
-
(p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
|
|
228
|
-
if (validPerf.length === 0)
|
|
229
|
-
return "";
|
|
230
|
-
// 按 TPS 降序排列(null TPS 放末尾),让最快的模型显示在最上方
|
|
231
|
-
const sorted = [...validPerf].sort((a, b) => {
|
|
232
|
-
if (a.avgTPS == null && b.avgTPS == null)
|
|
233
|
-
return 0;
|
|
234
|
-
if (a.avgTPS == null)
|
|
235
|
-
return 1;
|
|
236
|
-
if (b.avgTPS == null)
|
|
237
|
-
return -1;
|
|
238
|
-
return b.avgTPS - a.avgTPS;
|
|
239
|
-
});
|
|
240
|
-
const names = sorted.map(p => p.model);
|
|
241
|
-
const tpsValues = sorted.map(p => p.avgTPS ?? 0);
|
|
242
|
-
const ttftValues = sorted.map(p => p.avgTTFT ?? 0);
|
|
243
|
-
const costValues = sorted.map(p => {
|
|
244
|
-
const billable = p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite;
|
|
245
|
-
return billable > 0 ? (p.totalCost / billable) * 1000 : 0;
|
|
246
|
-
});
|
|
247
|
-
const hitRates = sorted.map(p => p.cacheHitRate ?? 0);
|
|
248
|
-
const reqCounts = sorted.map(p => p.requestCount);
|
|
249
|
-
return `
|
|
250
|
-
var effNames = ${JSON.stringify(names)};
|
|
251
|
-
var effTps = ${JSON.stringify(tpsValues)};
|
|
252
|
-
var effTtft = ${JSON.stringify(ttftValues)};
|
|
253
|
-
var effCost = ${JSON.stringify(costValues)};
|
|
254
|
-
var effHit = ${JSON.stringify(hitRates)};
|
|
255
|
-
var effReq = ${JSON.stringify(reqCounts)};
|
|
256
|
-
|
|
257
|
-
function initScatterChart() {
|
|
258
|
-
var el = document.getElementById('scatter-chart');
|
|
259
|
-
if (!el) return;
|
|
260
|
-
var chart = echarts.init(el);
|
|
261
|
-
window.scatterChart = chart;
|
|
262
|
-
|
|
263
|
-
// TPS 越高越绿,越低越紫,无数据为灰
|
|
264
|
-
var maxTps = Math.max.apply(null, effTps.filter(function(v){ return v > 0; })) || 1;
|
|
265
|
-
var barColors = effTps.map(function(v) {
|
|
266
|
-
if (v <= 0) return '#444455';
|
|
267
|
-
var r = v / maxTps;
|
|
268
|
-
if (r >= 0.8) return '#00F593';
|
|
269
|
-
if (r >= 0.5) return '#FFB800';
|
|
270
|
-
return '#B545FF';
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
var option = {
|
|
274
|
-
tooltip: {
|
|
275
|
-
trigger: 'axis',
|
|
276
|
-
axisPointer: { type: 'none' },
|
|
277
|
-
formatter: function(params) {
|
|
278
|
-
var i = params[0].dataIndex;
|
|
279
|
-
var tps = effTps[i] > 0 ? effTps[i].toFixed(1) + ' tok/s' : '\u2014';
|
|
280
|
-
var ttft = effTtft[i] > 0 ? effTtft[i].toFixed(0) + ' ms' : '\u2014';
|
|
281
|
-
var cost = effCost[i] > 0 ? '$' + effCost[i].toFixed(4) + '/1K' : '\u2014';
|
|
282
|
-
var hit = effHit[i] > 0 ? effHit[i].toFixed(1) + '%' : '\u2014';
|
|
283
|
-
return '<b>' + effNames[i] + '</b><br/>' +
|
|
284
|
-
'\u25B6 TPS: ' + tps + '<br/>' +
|
|
285
|
-
'\u23F1 TTFT: ' + ttft + '<br/>' +
|
|
286
|
-
'\uD83D\uDCB0 Cost/1K: ' + cost + '<br/>' +
|
|
287
|
-
'\uD83D\uDCBE Cache Hit: ' + hit + '<br/>' +
|
|
288
|
-
'Requests: ' + effReq[i];
|
|
289
|
-
}
|
|
290
|
-
},
|
|
291
|
-
grid: { left: 20, right: 280, bottom: 30, top: 20, containLabel: true },
|
|
292
|
-
xAxis: {
|
|
293
|
-
type: 'value',
|
|
294
|
-
name: 'Avg TPS (tokens / sec)',
|
|
295
|
-
nameTextStyle: { color: '#B0B0C0', fontSize: 11 },
|
|
296
|
-
axisLabel: { color: '#B0B0C0', formatter: function(v) { return v > 0 ? v.toFixed(0) : '0'; } },
|
|
297
|
-
splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
|
|
298
|
-
},
|
|
299
|
-
yAxis: {
|
|
300
|
-
type: 'category',
|
|
301
|
-
data: effNames,
|
|
302
|
-
inverse: true,
|
|
303
|
-
axisLabel: {
|
|
304
|
-
color: '#E0E0F0',
|
|
305
|
-
fontSize: 11,
|
|
306
|
-
formatter: function(v) { return v.length > 50 ? v.slice(0, 48) + '\u2026' : v; }
|
|
307
|
-
},
|
|
308
|
-
axisLine: { show: false },
|
|
309
|
-
axisTick: { show: false }
|
|
310
|
-
},
|
|
311
|
-
series: [{
|
|
312
|
-
type: 'bar',
|
|
313
|
-
data: effTps.map(function(v, i) {
|
|
314
|
-
return { value: v > 0 ? v : 0.001, itemStyle: { color: barColors[i], borderRadius: [0, 4, 4, 0] } };
|
|
315
|
-
}),
|
|
316
|
-
barMaxWidth: 22,
|
|
317
|
-
label: {
|
|
318
|
-
show: true,
|
|
319
|
-
position: 'right',
|
|
320
|
-
color: '#E0E0F0',
|
|
321
|
-
fontSize: 10,
|
|
322
|
-
formatter: function(p) {
|
|
323
|
-
var i = p.dataIndex;
|
|
324
|
-
var parts = [effTps[i] > 0 ? effTps[i].toFixed(1) + ' t/s' : '\u2014'];
|
|
325
|
-
if (effTtft[i] > 0) parts.push('TTFT ' + effTtft[i].toFixed(0) + 'ms');
|
|
326
|
-
if (effCost[i] > 0) parts.push('\u0024' + effCost[i].toFixed(4) + '/1K');
|
|
327
|
-
return parts.join(' ');
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}]
|
|
331
|
-
};
|
|
332
|
-
chart.setOption(option);
|
|
333
|
-
chart.resize();
|
|
334
|
-
}
|
|
335
|
-
`;
|
|
336
|
-
}
|
|
337
|
-
function providerBorderColor(provider) {
|
|
338
|
-
const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800", };
|
|
339
|
-
return colors[provider] || "#2A2A35";
|
|
340
|
-
}
|
|
341
|
-
/** 渲染 Provider 卡片,按 token 总量降序,最多显示 10 个 */
|
|
342
|
-
function renderProviderCards(data) {
|
|
343
|
-
const sorted = [...data.providers].sort((a, b) => b.totalTokens - a.totalTokens);
|
|
344
|
-
const top = sorted.slice(0, 10);
|
|
345
|
-
const remaining = sorted.length - 10;
|
|
346
|
-
const cards = top.map(p => {
|
|
347
|
-
const modelCount = data.models.filter(m => m.provider === p.provider).length;
|
|
348
|
-
const perfItems = data.perfSummary.filter(ps => ps.providerID === p.provider);
|
|
349
|
-
let ttftSum = 0, ttftReqs = 0;
|
|
350
|
-
for (const x of perfItems) {
|
|
351
|
-
if (x.avgTTFT != null && x.avgTTFT > 0) {
|
|
352
|
-
ttftSum += x.avgTTFT * x.requestCount;
|
|
353
|
-
ttftReqs += x.requestCount;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
const avgTtft = ttftReqs > 0 ? ttftSum / ttftReqs : null;
|
|
357
|
-
let tpsSum = 0, tpsReqs = 0;
|
|
358
|
-
for (const x of perfItems) {
|
|
359
|
-
if (x.avgTPS != null && x.avgTPS > 0) {
|
|
360
|
-
tpsSum += x.avgTPS * x.requestCount;
|
|
361
|
-
tpsReqs += x.requestCount;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
const avgTps = tpsReqs > 0 ? tpsSum / tpsReqs : null;
|
|
365
|
-
return `
|
|
366
|
-
<div class="provider-card" style="border-color:${providerBorderColor(p.provider)}">
|
|
367
|
-
<div class="provider-name">${p.provider}</div>
|
|
368
|
-
<div class="provider-stat"><span class="stat-label">Tokens</span><span>${fmtTokens(p.totalTokens)}</span></div>
|
|
369
|
-
<div class="provider-stat"><span class="stat-label">Cost</span><span>${fmtCost(p.totalCost)}</span></div>
|
|
370
|
-
<div class="provider-stat"><span class="stat-label">Avg TTFT</span><span>${avgTtft != null ? avgTtft.toFixed(0) + 'ms' : '—'}</span></div>
|
|
371
|
-
<div class="provider-stat"><span class="stat-label">Avg TPS</span><span>${avgTps != null ? avgTps.toFixed(1) : '—'}</span></div>
|
|
372
|
-
<div class="provider-stat"><span class="stat-label">Models</span><span>${modelCount}</span></div>
|
|
373
|
-
</div>`;
|
|
374
|
-
}).join("\n");
|
|
375
|
-
const moreHint = remaining > 0
|
|
376
|
-
? `<div class="provider-more">+ ${remaining} more provider${remaining > 1 ? 's' : ''} not shown</div>`
|
|
377
|
-
: '';
|
|
378
|
-
return cards + moreHint;
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* 渲染 Model Analytics 区块:三个表格合并为 Tab 切换展示,各自带分页控件。
|
|
382
|
-
* Tab 1: Usage Breakdown — Token/成本用量分解
|
|
383
|
-
* Tab 2: Latency Percentiles — TTFT/E2E 分位数
|
|
384
|
-
* Tab 3: Failed Requests — 失败请求明细(无数据时不显示此 Tab)
|
|
385
|
-
*/
|
|
386
|
-
function renderModelAnalyticsSection(data) {
|
|
387
|
-
// ─── Tab 1: Usage Breakdown ───────────────────────────────────────────────
|
|
388
|
-
const usageRows = sortModelsByUsage(data.models).map(m => {
|
|
389
|
-
const hitRate = cacheHitRate(m.inputTokens, m.cacheRead);
|
|
390
|
-
const hitColor = hitRate >= 0.85 ? 'var(--cache)' : hitRate >= 0.70 ? 'var(--tps)' : 'var(--output)';
|
|
391
|
-
const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
|
|
392
|
-
const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + 'ms' : '—';
|
|
393
|
-
const p95ttft = perf?.p95TTFT != null ? perf.p95TTFT.toFixed(0) + 'ms' : '—';
|
|
394
|
-
const tps = perf?.avgTPS != null ? perf.avgTPS.toFixed(1) : '—';
|
|
395
|
-
return `<tr>
|
|
396
|
-
<td>${m.model}</td>
|
|
397
|
-
<td>${m.provider}</td>
|
|
398
|
-
<td>${m.requests}</td>
|
|
399
|
-
<td>${fmtTokens(m.totalTokens)}</td>
|
|
400
|
-
<td>${fmtTokens(m.inputTokens)}</td>
|
|
401
|
-
<td>${fmtTokens(m.outputTokens)}</td>
|
|
402
|
-
<td>${fmtTokens(m.cacheRead)}</td>
|
|
403
|
-
<td style="color:${hitColor};font-weight:600">${fmtPercent(hitRate)}</td>
|
|
404
|
-
<td>${ttft}</td>
|
|
405
|
-
<td style="color:var(--tps);font-size:0.85em">${p95ttft}</td>
|
|
406
|
-
<td>${tps}</td>
|
|
407
|
-
<td>${fmtCost(m.totalCost)}</td>
|
|
408
|
-
</tr>`;
|
|
409
|
-
}).join("\n");
|
|
410
|
-
// ─── Tab 2: Latency Percentiles ───────────────────────────────────────────
|
|
411
|
-
const validPerf = data.perfSummary.filter(p => p.requestCount > 0 &&
|
|
412
|
-
(p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
|
|
413
|
-
const fmtMs = (v) => v != null ? v.toFixed(0) + 'ms' : '—';
|
|
414
|
-
const perfRows = validPerf.map(p => {
|
|
415
|
-
const hitColor = p.cacheHitRate != null && p.cacheHitRate >= 85 ? 'var(--cache)'
|
|
416
|
-
: p.cacheHitRate != null && p.cacheHitRate >= 70 ? 'var(--tps)' : 'var(--output)';
|
|
417
|
-
return `<tr>
|
|
418
|
-
<td>${p.model}</td>
|
|
419
|
-
<td>${p.requestCount}</td>
|
|
420
|
-
<td>${fmtMs(p.avgTTFT)}</td>
|
|
421
|
-
<td>${fmtMs(p.p50TTFT)}</td>
|
|
422
|
-
<td>${fmtMs(p.p95TTFT)}</td>
|
|
423
|
-
<td>${fmtMs(p.p99TTFT)}</td>
|
|
424
|
-
<td>${fmtMs(p.avgLatency)}</td>
|
|
425
|
-
<td>${fmtMs(p.p50Latency)}</td>
|
|
426
|
-
<td>${fmtMs(p.p95Latency)}</td>
|
|
427
|
-
<td>${fmtMs(p.p99Latency)}</td>
|
|
428
|
-
<td style="color:${hitColor};font-weight:600">${p.cacheHitRate != null ? p.cacheHitRate.toFixed(1) + '%' : '—'}</td>
|
|
429
|
-
</tr>`;
|
|
430
|
-
}).join("\n");
|
|
431
|
-
// ─── Tab 3: Failed Requests ───────────────────────────────────────────────
|
|
432
|
-
const errors = data.errors;
|
|
433
|
-
const hasErrors = !!(errors && errors.failedCount > 0);
|
|
434
|
-
let errorTabBtn = '';
|
|
435
|
-
let errorTabContent = '';
|
|
436
|
-
if (hasErrors) {
|
|
437
|
-
const errorRatePct = (errors.errorRate * 100).toFixed(2) + '%';
|
|
438
|
-
const rateColor = errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)';
|
|
439
|
-
const cellColor = errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)';
|
|
440
|
-
const errorRows = errors.byModel
|
|
441
|
-
.filter(m => m.failed > 0)
|
|
442
|
-
.map(m => {
|
|
443
|
-
const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + '%' : '—';
|
|
444
|
-
return `<tr>
|
|
445
|
-
<td>${m.provider}</td>
|
|
446
|
-
<td>${m.model}</td>
|
|
447
|
-
<td>${m.total}</td>
|
|
448
|
-
<td style="color:var(--output)">${m.failed}</td>
|
|
449
|
-
<td style="color:var(--tps)">${m.total - m.failed}</td>
|
|
450
|
-
<td style="color:${cellColor}">${modelRate}</td>
|
|
451
|
-
</tr>`;
|
|
452
|
-
}).join('\n');
|
|
453
|
-
errorTabBtn = `
|
|
454
|
-
<button class="tab-btn" data-mtab="errors" onclick="switchModelTab('errors')">
|
|
455
|
-
Failed Requests <span style="color:var(--output);margin-left:4px;font-size:0.85em">(${errors.failedCount})</span>
|
|
456
|
-
</button>`;
|
|
457
|
-
errorTabContent = `
|
|
458
|
-
<div id="model-tab-errors" class="tab-content">
|
|
459
|
-
<p style="font-size:12px;color:${rateColor};padding:8px 0 6px">
|
|
460
|
-
Overall error rate: <strong>${errorRatePct}</strong> —
|
|
461
|
-
${errors.failedCount} failed / ${errors.successCount + errors.failedCount} total
|
|
462
|
-
</p>
|
|
463
|
-
<table id="errors-table" class="data-table">
|
|
464
|
-
<thead><tr>
|
|
465
|
-
<th>Provider</th><th>Model</th><th>Total</th>
|
|
466
|
-
<th>Failed</th><th>Success</th><th>Error Rate</th>
|
|
467
|
-
</tr></thead>
|
|
468
|
-
<tbody>${errorRows}</tbody>
|
|
469
|
-
</table>
|
|
470
|
-
<div class="pagination-ctrl" id="errors-table-ctrl">
|
|
471
|
-
<button class="page-btn" id="errors-table-prev">← Prev</button>
|
|
472
|
-
<span class="page-info" id="errors-table-info"></span>
|
|
473
|
-
<button class="page-btn" id="errors-table-next">Next →</button>
|
|
474
|
-
</div>
|
|
475
|
-
</div>`;
|
|
476
|
-
}
|
|
477
|
-
return `
|
|
478
|
-
<div class="section">
|
|
479
|
-
<div class="section-title">Model Analytics</div>
|
|
480
|
-
<div class="tab-bar">
|
|
481
|
-
<button class="tab-btn active" data-mtab="usage" onclick="switchModelTab('usage')">Usage Breakdown</button>
|
|
482
|
-
<button class="tab-btn" data-mtab="perf" onclick="switchModelTab('perf')">Latency Percentiles</button>
|
|
483
|
-
${errorTabBtn}
|
|
484
|
-
</div>
|
|
485
|
-
|
|
486
|
-
<div id="model-tab-usage" class="tab-content active">
|
|
487
|
-
<table id="usage-table" class="data-table">
|
|
488
|
-
<thead><tr>
|
|
489
|
-
<th>Model</th><th>Provider</th><th>Req</th><th>Total</th>
|
|
490
|
-
<th>Input</th><th>Output</th><th>Cache</th><th>Hit Rate</th>
|
|
491
|
-
<th>Avg TTFT</th><th>P95 TTFT</th><th>TPS</th><th>Cost</th>
|
|
492
|
-
</tr></thead>
|
|
493
|
-
<tbody>${usageRows}</tbody>
|
|
494
|
-
</table>
|
|
495
|
-
<div class="pagination-ctrl" id="usage-table-ctrl">
|
|
496
|
-
<button class="page-btn" id="usage-table-prev">← Prev</button>
|
|
497
|
-
<span class="page-info" id="usage-table-info"></span>
|
|
498
|
-
<button class="page-btn" id="usage-table-next">Next →</button>
|
|
499
|
-
</div>
|
|
500
|
-
</div>
|
|
501
|
-
|
|
502
|
-
<div id="model-tab-perf" class="tab-content">
|
|
503
|
-
${validPerf.length > 0 ? `
|
|
504
|
-
<table id="perf-table" class="data-table">
|
|
505
|
-
<thead><tr>
|
|
506
|
-
<th>Model</th><th>Req</th>
|
|
507
|
-
<th>Avg TTFT</th><th>P50 TTFT</th><th>P95 TTFT</th><th>P99 TTFT</th>
|
|
508
|
-
<th>Avg E2E</th><th>P50 E2E</th><th>P95 E2E</th><th>P99 E2E</th>
|
|
509
|
-
<th>Cache Hit</th>
|
|
510
|
-
</tr></thead>
|
|
511
|
-
<tbody>${perfRows}</tbody>
|
|
512
|
-
</table>
|
|
513
|
-
<div class="pagination-ctrl" id="perf-table-ctrl">
|
|
514
|
-
<button class="page-btn" id="perf-table-prev">← Prev</button>
|
|
515
|
-
<span class="page-info" id="perf-table-info"></span>
|
|
516
|
-
<button class="page-btn" id="perf-table-next">Next →</button>
|
|
517
|
-
</div>` : '<div class="empty-state">No performance data available for this period.</div>'}
|
|
518
|
-
</div>
|
|
519
|
-
|
|
520
|
-
${errorTabContent}
|
|
521
|
-
</div>`;
|
|
522
|
-
}
|
|
523
|
-
function renderDailyTrendInit(data) {
|
|
524
|
-
const days = data.daily.slice().reverse().map(d => d.day);
|
|
525
|
-
const tokens = data.daily.slice().reverse().map(d => d.totalTokens);
|
|
526
|
-
const costs = data.daily.slice().reverse().map(d => d.totalCost);
|
|
527
|
-
return `
|
|
528
|
-
var dailyDays = ${JSON.stringify(days)};
|
|
529
|
-
var dailyTokens = ${JSON.stringify(tokens)};
|
|
530
|
-
var dailyCosts = ${JSON.stringify(costs)};
|
|
531
|
-
|
|
532
|
-
function initDailyChart() {
|
|
533
|
-
var el = document.getElementById('daily-chart');
|
|
534
|
-
if (!el) return;
|
|
535
|
-
var chart = echarts.init(el);
|
|
536
|
-
window.dailyChart = chart;
|
|
537
|
-
var option = {
|
|
538
|
-
tooltip: {
|
|
539
|
-
trigger: 'axis',
|
|
540
|
-
formatter: function(params) {
|
|
541
|
-
var html = '<b>' + params[0].axisValue + '</b><br/>';
|
|
542
|
-
params.forEach(function(p) {
|
|
543
|
-
html += p.marker + ' ' + p.seriesName + ': ' + (p.seriesName === 'Cost' ? '$' + p.value.toFixed(4) : fmt(p.value)) + '<br/>';
|
|
544
|
-
});
|
|
545
|
-
return html;
|
|
546
|
-
}
|
|
547
|
-
},
|
|
548
|
-
legend: {
|
|
549
|
-
data: ['Tokens', 'Cost'],
|
|
550
|
-
textStyle: { color: '#B0B0C0' },
|
|
551
|
-
top: 5
|
|
552
|
-
},
|
|
553
|
-
grid: { left: 60, right: 60, bottom: 80, top: 40 },
|
|
554
|
-
xAxis: {
|
|
555
|
-
type: 'category',
|
|
556
|
-
data: dailyDays,
|
|
557
|
-
axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
|
|
558
|
-
axisLine: { lineStyle: { color: '#2A2A35' } }
|
|
559
|
-
},
|
|
560
|
-
yAxis: [
|
|
561
|
-
{
|
|
562
|
-
type: 'value',
|
|
563
|
-
name: 'Tokens',
|
|
564
|
-
nameTextStyle: { color: '#B0B0C0' },
|
|
565
|
-
axisLabel: { color: '#B0B0C0', formatter: fmt },
|
|
566
|
-
splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
|
|
567
|
-
},
|
|
568
|
-
{
|
|
569
|
-
type: 'value',
|
|
570
|
-
name: 'Cost',
|
|
571
|
-
nameTextStyle: { color: '#FFB800' },
|
|
572
|
-
axisLabel: { color: '#FFB800', formatter: function(v) { return '$' + v.toFixed(4); } },
|
|
573
|
-
splitLine: { show: false }
|
|
574
|
-
}
|
|
575
|
-
],
|
|
576
|
-
dataZoom: [{
|
|
577
|
-
type: 'slider',
|
|
578
|
-
bottom: 5,
|
|
579
|
-
height: 20,
|
|
580
|
-
borderColor: '#2A2A35',
|
|
581
|
-
fillerColor: 'rgba(0,213,255,0.1)',
|
|
582
|
-
handleStyle: { color: '#00D1FF' },
|
|
583
|
-
textStyle: { color: '#B0B0C0' }
|
|
584
|
-
}],
|
|
585
|
-
series: [
|
|
586
|
-
{
|
|
587
|
-
name: 'Tokens',
|
|
588
|
-
type: 'line',
|
|
589
|
-
data: dailyTokens,
|
|
590
|
-
smooth: true,
|
|
591
|
-
symbol: 'none',
|
|
592
|
-
lineStyle: { color: '#00D1FF', width: 2 },
|
|
593
|
-
areaStyle: { color: 'rgba(0,209,255,0.15)' }
|
|
594
|
-
},
|
|
595
|
-
{
|
|
596
|
-
name: 'Cost',
|
|
597
|
-
type: 'line',
|
|
598
|
-
yAxisIndex: 1,
|
|
599
|
-
data: dailyCosts,
|
|
600
|
-
smooth: true,
|
|
601
|
-
symbol: 'none',
|
|
602
|
-
lineStyle: { color: '#FFB800', width: 2 },
|
|
603
|
-
areaStyle: { color: 'rgba(255,184,0,0.1)' }
|
|
604
|
-
}
|
|
605
|
-
]
|
|
606
|
-
};
|
|
607
|
-
chart.setOption(option);
|
|
608
|
-
chart.resize();
|
|
609
|
-
}`;
|
|
610
|
-
}
|
|
611
|
-
function renderHeatmapInit(data) {
|
|
612
|
-
const days = data.daily.slice().reverse();
|
|
613
|
-
const heatData = days.map(d => [d.day, Math.log10(d.totalTokens + 1)]);
|
|
614
|
-
const minDate = days.length > 0 ? days[0].day : '';
|
|
615
|
-
const maxDate = days.length > 0 ? days[days.length - 1].day : '';
|
|
616
|
-
return `
|
|
617
|
-
var heatData = ${JSON.stringify(heatData)};
|
|
618
|
-
|
|
619
|
-
function initHeatmapChart() {
|
|
620
|
-
var el = document.getElementById('heatmap-chart');
|
|
621
|
-
if (!el) return;
|
|
622
|
-
var chart = echarts.init(el);
|
|
623
|
-
window.heatmapChart = chart;
|
|
624
|
-
var option = {
|
|
625
|
-
tooltip: {
|
|
626
|
-
formatter: function(params) {
|
|
627
|
-
var val = params.value;
|
|
628
|
-
var rawTokens = Math.pow(10, val[1]) - 1;
|
|
629
|
-
return '<b>' + val[0] + '</b><br/>Tokens: ' + fmt(Math.round(rawTokens));
|
|
630
|
-
}
|
|
631
|
-
},
|
|
632
|
-
visualMap: {
|
|
633
|
-
min: 0,
|
|
634
|
-
max: Math.max.apply(null, heatData.map(function(d) { return d[1]; })) || 5,
|
|
635
|
-
calculable: true,
|
|
636
|
-
orient: 'horizontal',
|
|
637
|
-
left: 'center',
|
|
638
|
-
bottom: 10,
|
|
639
|
-
textStyle: { color: '#B0B0C0' },
|
|
640
|
-
inRange: {
|
|
641
|
-
color: ['#0C0C0E', '#1a3a2a', '#00F593', '#00D1FF', '#B545FF']
|
|
642
|
-
}
|
|
643
|
-
},
|
|
644
|
-
calendar: {
|
|
645
|
-
left: 30,
|
|
646
|
-
right: 30,
|
|
647
|
-
top: 20,
|
|
648
|
-
bottom: 60,
|
|
649
|
-
range: ['${minDate}', '${maxDate}'],
|
|
650
|
-
splitLine: { lineStyle: { color: '#2A2A35' } },
|
|
651
|
-
dayLabel: { color: '#B0B0C0' },
|
|
652
|
-
monthLabel: { color: '#B0B0C0' },
|
|
653
|
-
yearLabel: { color: '#B0B0C0' },
|
|
654
|
-
itemStyle: { color: '#16161A', borderColor: '#0C0C0E', borderWidth: 2 }
|
|
655
|
-
},
|
|
656
|
-
series: [{
|
|
657
|
-
type: 'heatmap',
|
|
658
|
-
coordinateSystem: 'calendar',
|
|
659
|
-
data: heatData
|
|
660
|
-
}]
|
|
661
|
-
};
|
|
662
|
-
chart.setOption(option);
|
|
663
|
-
chart.resize();
|
|
664
|
-
}`;
|
|
665
|
-
}
|
|
666
|
-
export function generateUsageHtml(data) {
|
|
667
|
-
const metaStr = renderMeta(data);
|
|
668
|
-
const kpiStr = renderKpiCards(data);
|
|
669
|
-
const modelChartVisible = data.models.filter(m => m.totalTokens >= 1_000_000).length > 0;
|
|
670
|
-
const modelChartJs = modelChartVisible ? renderModelChartInit(data) : "";
|
|
671
|
-
const scatterChartJs = renderScatterChartInit(data);
|
|
672
|
-
const providerStr = renderProviderCards(data);
|
|
673
|
-
const modelAnalyticsStr = renderModelAnalyticsSection(data);
|
|
674
|
-
const dailyChartJs = renderDailyTrendInit(data);
|
|
675
|
-
const heatmapJs = renderHeatmapInit(data);
|
|
676
|
-
const hasPerf = data.perfSummary.some(p => p.requestCount > 0 &&
|
|
677
|
-
(p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
|
|
678
|
-
const jsonData = JSON.stringify(data);
|
|
679
|
-
return `<!DOCTYPE html>
|
|
680
|
-
<html lang="en">
|
|
681
|
-
<head>
|
|
682
|
-
<meta charset="UTF-8">
|
|
683
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
684
|
-
<title>TokenWatch Usage Report</title>
|
|
685
|
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
686
|
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
687
|
-
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
|
688
|
-
<style>
|
|
689
|
-
:root {
|
|
690
|
-
--bg: #0C0C0E;
|
|
691
|
-
--card: #16161A;
|
|
692
|
-
--border: #2A2A35;
|
|
693
|
-
--text: #E0E0F0;
|
|
694
|
-
--text-dim: #B0B0C0;
|
|
695
|
-
--cache: #00F593;
|
|
696
|
-
--input: #00D1FF;
|
|
697
|
-
--output: #B545FF;
|
|
698
|
-
--tps: #FFB800;
|
|
699
|
-
--radius: 8px;
|
|
700
|
-
}
|
|
701
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
702
|
-
body {
|
|
703
|
-
background: var(--bg);
|
|
704
|
-
color: var(--text);
|
|
705
|
-
font-family: 'Inter', -apple-system, sans-serif;
|
|
706
|
-
font-size: 14px;
|
|
707
|
-
line-height: 1.5;
|
|
708
|
-
min-height: 100vh;
|
|
709
|
-
}
|
|
710
|
-
.container { max-width: 1400px; margin: 0 auto; padding: 24px 20px; }
|
|
711
|
-
.header {
|
|
712
|
-
display: flex; justify-content: space-between; align-items: center;
|
|
713
|
-
padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px;
|
|
714
|
-
}
|
|
715
|
-
.header h1 { font-size: 22px; font-weight: 600; color: var(--text); }
|
|
716
|
-
.header h1 span { color: var(--input); }
|
|
717
|
-
.header .meta { font-size: 12px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
|
|
718
|
-
|
|
719
|
-
.kpi-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 24px; }
|
|
720
|
-
.kpi-card {
|
|
721
|
-
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
722
|
-
padding: 16px; text-align: center;
|
|
723
|
-
}
|
|
724
|
-
.kpi-card.kpi-glow { box-shadow: 0 0 20px rgba(0,245,147,0.15); border-color: var(--cache); }
|
|
725
|
-
.kpi-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
|
|
726
|
-
.kpi-value { font-size: 26px; font-weight: 700; font-family: 'JetBrains Mono', monospace; color: var(--text); }
|
|
727
|
-
|
|
728
|
-
.section { margin-bottom: 28px; }
|
|
729
|
-
.section-title {
|
|
730
|
-
font-size: 16px; font-weight: 600; margin-bottom: 12px;
|
|
731
|
-
padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
|
732
|
-
}
|
|
733
|
-
.chart-box {
|
|
734
|
-
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
735
|
-
padding: 12px; height: 400px;
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
.tab-bar { display: flex; gap: 4px; margin-bottom: 12px; }
|
|
739
|
-
.tab-btn {
|
|
740
|
-
background: var(--card); border: 1px solid var(--border); color: var(--text-dim);
|
|
741
|
-
padding: 6px 18px; border-radius: 4px 4px 0 0; cursor: pointer; font-size: 13px; font-family: 'Inter', sans-serif;
|
|
742
|
-
}
|
|
743
|
-
.tab-btn:hover { border-color: var(--input); color: var(--text); }
|
|
744
|
-
.tab-btn.active {
|
|
745
|
-
background: var(--border); color: var(--text); border-bottom-color: var(--border);
|
|
746
|
-
}
|
|
747
|
-
.tab-content { display: none; }
|
|
748
|
-
.tab-content.active { display: block; }
|
|
749
|
-
|
|
750
|
-
.provider-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
|
751
|
-
.provider-card {
|
|
752
|
-
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
753
|
-
padding: 14px;
|
|
754
|
-
}
|
|
755
|
-
.provider-name { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--input); }
|
|
756
|
-
.provider-stat { display: flex; justify-content: space-between; font-size: 12px; padding: 2px 0; }
|
|
757
|
-
.provider-stat .stat-label { color: var(--text-dim); }
|
|
758
|
-
.provider-more { color: var(--text-dim); font-size: 11px; padding: 10px 4px 0; grid-column: 1 / -1; }
|
|
759
|
-
|
|
760
|
-
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
761
|
-
.data-table th {
|
|
762
|
-
background: var(--card); color: var(--text-dim); padding: 8px 10px;
|
|
763
|
-
text-align: right; border-bottom: 1px solid var(--border); font-weight: 500;
|
|
764
|
-
white-space: nowrap;
|
|
765
|
-
}
|
|
766
|
-
.data-table th:first-child { text-align: left; }
|
|
767
|
-
.data-table td {
|
|
768
|
-
padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--border);
|
|
769
|
-
font-family: 'JetBrains Mono', monospace;
|
|
770
|
-
}
|
|
771
|
-
.data-table td:first-child {
|
|
772
|
-
text-align: left; color: var(--text); font-family: 'Inter', sans-serif;
|
|
773
|
-
}
|
|
774
|
-
.data-table tbody tr:hover { background: rgba(42,42,53,0.4); }
|
|
775
|
-
|
|
776
|
-
.pagination-ctrl {
|
|
777
|
-
display: none; align-items: center; gap: 14px; justify-content: center;
|
|
778
|
-
padding: 14px 0 4px;
|
|
779
|
-
}
|
|
780
|
-
.page-btn {
|
|
781
|
-
background: var(--card); border: 1px solid var(--border); color: var(--text);
|
|
782
|
-
padding: 5px 16px; border-radius: 4px; cursor: pointer;
|
|
783
|
-
font-size: 12px; font-family: 'Inter', sans-serif; transition: border-color 0.15s, color 0.15s;
|
|
784
|
-
}
|
|
785
|
-
.page-btn:hover:not(:disabled) { border-color: var(--input); color: var(--input); }
|
|
786
|
-
.page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
787
|
-
.page-info {
|
|
788
|
-
color: var(--text-dim); font-size: 12px;
|
|
789
|
-
font-family: 'JetBrains Mono', monospace; min-width: 110px; text-align: center;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
.empty-state {
|
|
793
|
-
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
|
794
|
-
padding: 40px; text-align: center; color: var(--text-dim);
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
.footer {
|
|
798
|
-
margin-top: 40px; padding: 16px 0; border-top: 1px solid var(--border);
|
|
799
|
-
text-align: center; font-size: 11px; color: var(--text-dim);
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
@media (max-width: 768px) {
|
|
803
|
-
.kpi-row { grid-template-columns: repeat(2, 1fr); }
|
|
804
|
-
.provider-row { grid-template-columns: 1fr; }
|
|
805
|
-
.container { padding: 12px 10px; }
|
|
806
|
-
.header { flex-direction: column; gap: 6px; align-items: flex-start; }
|
|
807
|
-
.chart-box { height: 300px; }
|
|
808
|
-
.data-table { font-size: 11px; }
|
|
809
|
-
.data-table th, .data-table td { padding: 4px 6px; }
|
|
810
|
-
}
|
|
811
|
-
</style>
|
|
812
|
-
</head>
|
|
813
|
-
<body>
|
|
814
|
-
<div class="container">
|
|
815
|
-
<div class="header">
|
|
816
|
-
<h1><span>TokenWatch</span> Usage Report</h1>
|
|
817
|
-
<div class="meta">${metaStr}</div>
|
|
818
|
-
</div>
|
|
819
|
-
|
|
820
|
-
${kpiStr}
|
|
821
|
-
|
|
822
|
-
<div class="section">
|
|
823
|
-
<div class="section-title">Model Comparison Matrix</div>
|
|
824
|
-
${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with ≥1M tokens in this period.</div>'}
|
|
825
|
-
</div>
|
|
826
|
-
|
|
827
|
-
<div class="section">
|
|
828
|
-
<div class="section-title">Provider Summary</div>
|
|
829
|
-
<div class="provider-row">${providerStr}</div>
|
|
830
|
-
</div>
|
|
831
|
-
|
|
832
|
-
${modelAnalyticsStr}
|
|
833
|
-
|
|
834
|
-
<div class="section">
|
|
835
|
-
<div class="section-title">Efficiency vs Cost</div>
|
|
836
|
-
${hasPerf ? '<div class="chart-box" id="scatter-chart"></div>' : '<div class="empty-state">No performance data available for this period.</div>'}
|
|
837
|
-
</div>
|
|
838
|
-
|
|
839
|
-
<div class="section">
|
|
840
|
-
<div class="section-title">Usage Timeline</div>
|
|
841
|
-
<div class="tab-bar">
|
|
842
|
-
<button class="tab-btn active" data-tab="daily" onclick="switchTab('daily')">Daily Trend</button>
|
|
843
|
-
<button class="tab-btn" data-tab="heatmap" onclick="switchTab('heatmap')">Heatmap</button>
|
|
844
|
-
</div>
|
|
845
|
-
<div id="tab-daily" class="tab-content active">
|
|
846
|
-
<div class="chart-box" id="daily-chart"></div>
|
|
847
|
-
</div>
|
|
848
|
-
<div id="tab-heatmap" class="tab-content">
|
|
849
|
-
<div class="chart-box" id="heatmap-chart"></div>
|
|
850
|
-
</div>
|
|
851
|
-
</div>
|
|
852
|
-
|
|
853
|
-
<div class="footer">
|
|
854
|
-
Generated by TokenWatch · Data: SQLite + JSONL · Export: <a href="javascript:void(0)" onclick="downloadJSON()" style="color:var(--input);text-decoration:none">JSON</a> <span style="color:var(--text-dim)">(saves to browser download folder)</span>
|
|
855
|
-
</div>
|
|
856
|
-
</div>
|
|
857
|
-
|
|
858
|
-
<script id="report-data" type="application/json">${jsonData}</script>
|
|
859
|
-
|
|
860
|
-
<script>
|
|
861
|
-
var fmt = function(v) {
|
|
862
|
-
if (v == null) return '\u2014';
|
|
863
|
-
if (v >= 1000000000) return (v/1000000000).toFixed(1)+'B';
|
|
864
|
-
if (v >= 1000000) return (v/1000000).toFixed(1)+'M';
|
|
865
|
-
if (v >= 1000) return (v/1000).toFixed(1)+'K';
|
|
866
|
-
return String(v);
|
|
867
|
-
};
|
|
868
|
-
|
|
869
|
-
// Usage Timeline tab switcher
|
|
870
|
-
window.switchTab = function(name) {
|
|
871
|
-
document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
|
|
872
|
-
document.querySelectorAll('.tab-btn[data-tab]').forEach(function(el) { el.classList.remove('active'); });
|
|
873
|
-
document.getElementById('tab-' + name).classList.add('active');
|
|
874
|
-
document.querySelector('[data-tab="' + name + '"]').classList.add('active');
|
|
875
|
-
setTimeout(function() {
|
|
876
|
-
if (name === 'daily' && window.dailyChart) window.dailyChart.resize();
|
|
877
|
-
if (name === 'heatmap' && window.heatmapChart) window.heatmapChart.resize();
|
|
878
|
-
}, 50);
|
|
879
|
-
};
|
|
880
|
-
|
|
881
|
-
// Model Analytics tab switcher
|
|
882
|
-
window.switchModelTab = function(name) {
|
|
883
|
-
document.querySelectorAll('[data-mtab]').forEach(function(el) { el.classList.remove('active'); });
|
|
884
|
-
['model-tab-usage', 'model-tab-perf', 'model-tab-errors'].forEach(function(id) {
|
|
885
|
-
var el = document.getElementById(id);
|
|
886
|
-
if (el) el.classList.remove('active');
|
|
887
|
-
});
|
|
888
|
-
var activeTab = document.getElementById('model-tab-' + name);
|
|
889
|
-
if (activeTab) activeTab.classList.add('active');
|
|
890
|
-
var activeBtn = document.querySelector('[data-mtab="' + name + '"]');
|
|
891
|
-
if (activeBtn) activeBtn.classList.add('active');
|
|
892
|
-
};
|
|
893
|
-
|
|
894
|
-
// Generic paginator: hide/show tbody rows, show prev/next controls
|
|
895
|
-
function initPaginator(tableId, pageSize) {
|
|
896
|
-
var tbody = document.querySelector('#' + tableId + ' tbody');
|
|
897
|
-
if (!tbody) return;
|
|
898
|
-
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
899
|
-
if (rows.length <= pageSize) return; // 行数不超过一页时无需分页
|
|
900
|
-
var totalPages = Math.ceil(rows.length / pageSize);
|
|
901
|
-
var cur = 1;
|
|
902
|
-
|
|
903
|
-
function render() {
|
|
904
|
-
rows.forEach(function(r, i) {
|
|
905
|
-
r.style.display = (i >= (cur - 1) * pageSize && i < cur * pageSize) ? '' : 'none';
|
|
906
|
-
});
|
|
907
|
-
var info = document.getElementById(tableId + '-info');
|
|
908
|
-
if (info) info.textContent = 'Page ' + cur + ' / ' + totalPages + ' (' + rows.length + ' rows)';
|
|
909
|
-
var prevEl = document.getElementById(tableId + '-prev');
|
|
910
|
-
var nextEl = document.getElementById(tableId + '-next');
|
|
911
|
-
if (prevEl) prevEl.disabled = cur === 1;
|
|
912
|
-
if (nextEl) nextEl.disabled = cur === totalPages;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
var prevEl = document.getElementById(tableId + '-prev');
|
|
916
|
-
var nextEl = document.getElementById(tableId + '-next');
|
|
917
|
-
if (prevEl) prevEl.addEventListener('click', function() { if (cur > 1) { cur--; render(); } });
|
|
918
|
-
if (nextEl) nextEl.addEventListener('click', function() { if (cur < totalPages) { cur++; render(); } });
|
|
919
|
-
|
|
920
|
-
var ctrl = document.getElementById(tableId + '-ctrl');
|
|
921
|
-
if (ctrl) ctrl.style.display = 'flex';
|
|
922
|
-
render();
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
${modelChartJs}
|
|
926
|
-
${scatterChartJs}
|
|
927
|
-
${dailyChartJs}
|
|
928
|
-
${heatmapJs}
|
|
929
|
-
|
|
930
|
-
window.downloadJSON = function() {
|
|
931
|
-
var d = document.getElementById('report-data');
|
|
932
|
-
if (!d) return;
|
|
933
|
-
var b = new Blob([d.textContent], { type: 'application/json' });
|
|
934
|
-
var a = document.createElement('a');
|
|
935
|
-
a.href = URL.createObjectURL(b);
|
|
936
|
-
a.download = 'tokenwatch-data.json';
|
|
937
|
-
document.body.appendChild(a);
|
|
938
|
-
a.click();
|
|
939
|
-
document.body.removeChild(a);
|
|
940
|
-
setTimeout(function() { URL.revokeObjectURL(a.href); }, 100);
|
|
941
|
-
};
|
|
942
|
-
|
|
943
|
-
document.addEventListener('DOMContentLoaded', function() {
|
|
944
|
-
${modelChartVisible ? 'initModelChart();' : ''}
|
|
945
|
-
${hasPerf ? 'initScatterChart();' : ''}
|
|
946
|
-
initDailyChart();
|
|
947
|
-
initHeatmapChart();
|
|
948
|
-
initPaginator('usage-table', 10);
|
|
949
|
-
initPaginator('perf-table', 10);
|
|
950
|
-
initPaginator('errors-table', 10);
|
|
951
|
-
});
|
|
952
|
-
|
|
953
|
-
window.addEventListener('resize', function() {
|
|
954
|
-
${modelChartVisible ? 'if (window.modelChart) window.modelChart.resize();' : ''}
|
|
955
|
-
if (window.scatterChart) window.scatterChart.resize();
|
|
956
|
-
if (window.dailyChart) window.dailyChart.resize();
|
|
957
|
-
if (window.heatmapChart) window.heatmapChart.resize();
|
|
958
|
-
});
|
|
959
|
-
</script>
|
|
960
|
-
</body>
|
|
961
|
-
</html>`;
|
|
962
|
-
}
|