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.
@@ -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
- return data.providers.map(p => {
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
- function renderDataTable(data) {
373
- const rows = sortModelsByUsage(data.models).map(m => {
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">${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>
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
- 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>
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 rows = validPerf.map(p => {
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
- 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>
462
- </section>`;
463
- }
464
- /** 渲染失败请求统计区域 */
465
- function renderErrorStatsSection(data) {
431
+ // ─── Tab 3: Failed Requests ───────────────────────────────────────────────
466
432
  const errors = data.errors;
467
- if (!errors || errors.failedCount === 0)
468
- return '';
469
- const errorRatePct = (errors.errorRate * 100).toFixed(2) + '%';
470
- const rateColor = errors.errorRate >= 0.05 ? 'var(--output)'
471
- : errors.errorRate > 0 ? 'var(--tps)' : 'var(--cache)';
472
- const rows = errors.byModel
473
- .filter(m => m.failed > 0)
474
- .map(m => {
475
- const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + '%' : '—';
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>
483
- </tr>`;
484
- }).join('\n');
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>
501
- </section>`;
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> &mdash;
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 tableStr = renderDataTable(data);
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
- .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 &middot; Data: SQLite + JSONL &middot; 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>
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 &ge;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 &middot; Data: SQLite + JSONL &middot; 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
  }