opencode-tokenwatch 0.3.2 → 0.4.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/LICENSE +21 -21
- package/README.en.md +79 -107
- package/README.md +79 -107
- package/dist/commands.js +208 -0
- package/dist/generate-usage-html.js +801 -725
- package/dist/queries.js +101 -101
- package/dist/sidebar.jsx +138 -138
- package/package.json +63 -63
|
@@ -48,32 +48,32 @@ function renderKpiCards(data) {
|
|
|
48
48
|
const errorRatePct = errors ? (errors.errorRate * 100).toFixed(1) + '%' : '—';
|
|
49
49
|
const errorColor = errors && errors.errorRate >= 0.05 ? 'var(--output)'
|
|
50
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>
|
|
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
77
|
</div>`;
|
|
78
78
|
}
|
|
79
79
|
function renderModelChartInit(data) {
|
|
@@ -86,139 +86,139 @@ function renderModelChartInit(data) {
|
|
|
86
86
|
const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
|
|
87
87
|
return perf?.avgTPS ?? null;
|
|
88
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();
|
|
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
222
|
}`;
|
|
223
223
|
}
|
|
224
224
|
function renderScatterChartInit(data) {
|
|
@@ -246,100 +246,104 @@ function renderScatterChartInit(data) {
|
|
|
246
246
|
});
|
|
247
247
|
const hitRates = sorted.map(p => p.cacheHitRate ?? 0);
|
|
248
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
|
-
}
|
|
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
335
|
`;
|
|
336
336
|
}
|
|
337
337
|
function providerBorderColor(provider) {
|
|
338
338
|
const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800", };
|
|
339
339
|
return colors[provider] || "#2A2A35";
|
|
340
340
|
}
|
|
341
|
+
/** 渲染 Provider 卡片,按 token 总量降序,最多显示 10 个 */
|
|
341
342
|
function renderProviderCards(data) {
|
|
342
|
-
|
|
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 => {
|
|
343
347
|
const modelCount = data.models.filter(m => m.provider === p.provider).length;
|
|
344
348
|
const perfItems = data.perfSummary.filter(ps => ps.providerID === p.provider);
|
|
345
349
|
let ttftSum = 0, ttftReqs = 0;
|
|
@@ -358,234 +362,250 @@ function renderProviderCards(data) {
|
|
|
358
362
|
}
|
|
359
363
|
}
|
|
360
364
|
const avgTps = tpsReqs > 0 ? tpsSum / tpsReqs : null;
|
|
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
|
+
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>
|
|
369
373
|
</div>`;
|
|
370
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;
|
|
371
379
|
}
|
|
372
|
-
|
|
373
|
-
|
|
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 => {
|
|
374
389
|
const hitRate = cacheHitRate(m.inputTokens, m.cacheRead);
|
|
375
|
-
const hitRatePct = fmtPercent(hitRate);
|
|
376
390
|
const hitColor = hitRate >= 0.85 ? 'var(--cache)' : hitRate >= 0.70 ? 'var(--tps)' : 'var(--output)';
|
|
377
391
|
const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
|
|
378
392
|
const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + 'ms' : '—';
|
|
379
393
|
const p95ttft = perf?.p95TTFT != null ? perf.p95TTFT.toFixed(0) + 'ms' : '—';
|
|
380
394
|
const tps = perf?.avgTPS != null ? perf.avgTPS.toFixed(1) : '—';
|
|
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">${
|
|
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>
|
|
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>
|
|
394
408
|
</tr>`;
|
|
395
409
|
}).join("\n");
|
|
396
|
-
|
|
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>
|
|
414
|
-
</table>`;
|
|
415
|
-
}
|
|
416
|
-
/** 渲染 P50/P95/P99 延迟分位数表格 */
|
|
417
|
-
function renderPerfPercentileTable(data) {
|
|
418
|
-
// 过滤掉无效记录:无请求或 token 全为 0 的模型
|
|
410
|
+
// ─── Tab 2: Latency Percentiles ───────────────────────────────────────────
|
|
419
411
|
const validPerf = data.perfSummary.filter(p => p.requestCount > 0 &&
|
|
420
412
|
(p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
|
|
421
|
-
if (validPerf.length === 0)
|
|
422
|
-
return '';
|
|
423
413
|
const fmtMs = (v) => v != null ? v.toFixed(0) + 'ms' : '—';
|
|
424
|
-
const
|
|
414
|
+
const perfRows = validPerf.map(p => {
|
|
425
415
|
const hitColor = p.cacheHitRate != null && p.cacheHitRate >= 85 ? 'var(--cache)'
|
|
426
416
|
: p.cacheHitRate != null && p.cacheHitRate >= 70 ? 'var(--tps)' : 'var(--output)';
|
|
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>
|
|
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>
|
|
439
429
|
</tr>`;
|
|
440
430
|
}).join("\n");
|
|
441
|
-
|
|
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>
|
|
462
|
-
</section>`;
|
|
463
|
-
}
|
|
464
|
-
/** 渲染失败请求统计区域 */
|
|
465
|
-
function renderErrorStatsSection(data) {
|
|
431
|
+
// ─── Tab 3: Failed Requests ───────────────────────────────────────────────
|
|
466
432
|
const errors = data.errors;
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
<
|
|
489
|
-
|
|
490
|
-
</
|
|
491
|
-
|
|
492
|
-
<
|
|
493
|
-
<
|
|
494
|
-
<
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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>`;
|
|
502
522
|
}
|
|
503
523
|
function renderDailyTrendInit(data) {
|
|
504
524
|
const days = data.daily.slice().reverse().map(d => d.day);
|
|
505
525
|
const tokens = data.daily.slice().reverse().map(d => d.totalTokens);
|
|
506
526
|
const costs = data.daily.slice().reverse().map(d => d.totalCost);
|
|
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();
|
|
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();
|
|
589
609
|
}`;
|
|
590
610
|
}
|
|
591
611
|
function renderHeatmapInit(data) {
|
|
@@ -593,54 +613,54 @@ function renderHeatmapInit(data) {
|
|
|
593
613
|
const heatData = days.map(d => [d.day, Math.log10(d.totalTokens + 1)]);
|
|
594
614
|
const minDate = days.length > 0 ? days[0].day : '';
|
|
595
615
|
const maxDate = days.length > 0 ? days[days.length - 1].day : '';
|
|
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();
|
|
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();
|
|
644
664
|
}`;
|
|
645
665
|
}
|
|
646
666
|
export function generateUsageHtml(data) {
|
|
@@ -650,237 +670,293 @@ export function generateUsageHtml(data) {
|
|
|
650
670
|
const modelChartJs = modelChartVisible ? renderModelChartInit(data) : "";
|
|
651
671
|
const scatterChartJs = renderScatterChartInit(data);
|
|
652
672
|
const providerStr = renderProviderCards(data);
|
|
653
|
-
const
|
|
654
|
-
const perfPercentileStr = renderPerfPercentileTable(data);
|
|
655
|
-
const errorStatsStr = renderErrorStatsSection(data);
|
|
673
|
+
const modelAnalyticsStr = renderModelAnalyticsSection(data);
|
|
656
674
|
const dailyChartJs = renderDailyTrendInit(data);
|
|
657
675
|
const heatmapJs = renderHeatmapInit(data);
|
|
658
676
|
const hasPerf = data.perfSummary.some(p => p.requestCount > 0 &&
|
|
659
677
|
(p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
|
|
660
678
|
const jsonData = JSON.stringify(data);
|
|
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
|
-
|
|
742
|
-
.data-table
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
.data-table
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
.
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
<div class="
|
|
798
|
-
<
|
|
799
|
-
|
|
800
|
-
</div>
|
|
801
|
-
|
|
802
|
-
${
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
<div
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
</div>
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
<
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
document.
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
if (
|
|
880
|
-
|
|
881
|
-
if (
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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>
|
|
885
961
|
</html>`;
|
|
886
962
|
}
|