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