claude-code-watch 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/css/app.css +500 -0
- package/public/index.html +39 -2512
- package/public/js/app.js +500 -0
- package/public/js/shared.js +245 -0
- package/public/js/stream.js +1076 -0
- package/public/js/token.js +458 -0
- package/src/scanner/scanner.js +18 -9
- package/src/server/server.js +87 -14
- package/src/watcher/watcher.js +103 -65
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// token.js — Token Statistics page
|
|
3
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
// ── Token State ──
|
|
6
|
+
let tokenStatsData = { totals: { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, days: 0 }, modelTotals: {}, daily: {}, hourly: [] };
|
|
7
|
+
let tokenStatsRendered = false;
|
|
8
|
+
let tsDetailTab = 'daily';
|
|
9
|
+
|
|
10
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
11
|
+
// WebSocket handler
|
|
12
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
13
|
+
|
|
14
|
+
function handleTokenStats(payload) {
|
|
15
|
+
tokenStatsData = payload;
|
|
16
|
+
// Auto-render if currently on tokens tab (handles both late data arrival
|
|
17
|
+
// after initial tab switch and live updates while viewing the tab)
|
|
18
|
+
if (currentTab === 'tokens' && payload.totals.messages > 0) {
|
|
19
|
+
tokenStatsRendered = true;
|
|
20
|
+
renderTokenPage();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
25
|
+
// Tab switching & refresh
|
|
26
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
function tsSwitchDetail(n) {
|
|
29
|
+
tsDetailTab = n;
|
|
30
|
+
document.querySelectorAll('.tp-tab').forEach(t => t.classList.remove('active'));
|
|
31
|
+
document.querySelectorAll('.tp-tc').forEach(t => t.classList.remove('active'));
|
|
32
|
+
document.querySelector(`.tp-tab[data-tab="${n}"]`)?.classList.add('active');
|
|
33
|
+
document.getElementById('tp-tc-' + n)?.classList.add('active');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function refreshTokenStats() {
|
|
37
|
+
const btn = document.getElementById('btn-refresh-tokens');
|
|
38
|
+
const info = document.getElementById('tp-refresh-info');
|
|
39
|
+
if (btn) btn.disabled = true;
|
|
40
|
+
if (info) info.textContent = '正在刷新...';
|
|
41
|
+
fetch('/api/token-stats')
|
|
42
|
+
.then(r => r.json())
|
|
43
|
+
.then(data => {
|
|
44
|
+
tokenStatsData = data;
|
|
45
|
+
tokenStatsRendered = true;
|
|
46
|
+
renderTokenPage();
|
|
47
|
+
if (info) {
|
|
48
|
+
const now = new Date();
|
|
49
|
+
info.textContent = '上次刷新: ' + now.toLocaleTimeString();
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
.catch(err => {
|
|
53
|
+
if (info) info.textContent = '刷新失败: ' + err.message;
|
|
54
|
+
})
|
|
55
|
+
.finally(() => {
|
|
56
|
+
if (btn) btn.disabled = false;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
61
|
+
// Chart builders
|
|
62
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
63
|
+
|
|
64
|
+
// ── Heatmap: 52-week × 7-day GitHub-style grid ──
|
|
65
|
+
function buildHeatmap(daily) {
|
|
66
|
+
const today = new Date();
|
|
67
|
+
const dailyTotalsMap = {};
|
|
68
|
+
for (const [k, d] of Object.entries(daily)) {
|
|
69
|
+
dailyTotalsMap[k] = d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const startSunday = new Date(today);
|
|
73
|
+
startSunday.setDate(startSunday.getDate() - startSunday.getDay() - 52 * 7);
|
|
74
|
+
const startStr = fmtDateISO(startSunday);
|
|
75
|
+
|
|
76
|
+
let maxVal = 0;
|
|
77
|
+
for (const [k, v] of Object.entries(dailyTotalsMap)) {
|
|
78
|
+
if (k >= startStr && v > maxVal) maxVal = v;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const weeks = [];
|
|
82
|
+
const monthLabels = [];
|
|
83
|
+
let lastMonth = -1;
|
|
84
|
+
let currentSunday = new Date(startSunday);
|
|
85
|
+
|
|
86
|
+
for (let w = 0; w < 53; w++) {
|
|
87
|
+
const weekData = [];
|
|
88
|
+
for (let dow = 0; dow < 7; dow++) {
|
|
89
|
+
const d = new Date(currentSunday);
|
|
90
|
+
d.setDate(d.getDate() + dow);
|
|
91
|
+
const ds = fmtDateISO(d);
|
|
92
|
+
const val = dailyTotalsMap[ds] || 0;
|
|
93
|
+
weekData.push({ date: ds, val, future: d > today });
|
|
94
|
+
if (dow === 0) {
|
|
95
|
+
const m = d.getMonth();
|
|
96
|
+
if (m !== lastMonth) { monthLabels.push({ month: m, week: w }); lastMonth = m; }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
weeks.push(weekData);
|
|
100
|
+
currentSunday.setDate(currentSunday.getDate() + 7);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cellColor(val, future) {
|
|
104
|
+
if (future) return 'var(--bg3)';
|
|
105
|
+
if (val === 0) return '#0d423d';
|
|
106
|
+
const pct = maxVal > 0 ? val / maxVal : 0;
|
|
107
|
+
if (pct < 0.25) return '#0e6b5a';
|
|
108
|
+
if (pct < 0.5) return '#12b886';
|
|
109
|
+
if (pct < 0.75) return '#34d399';
|
|
110
|
+
return '#6ee7b7';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
114
|
+
const monthsParts = ['<div class="tp-hm-months">'];
|
|
115
|
+
let prevWeek = 0;
|
|
116
|
+
for (const ml of monthLabels) {
|
|
117
|
+
const offset = ml.week - prevWeek;
|
|
118
|
+
if (offset > 0) monthsParts.push(`<span style="width:${offset * 14}px"></span>`);
|
|
119
|
+
monthsParts.push(`<span style="width:14px">${monthNames[ml.month]}</span>`);
|
|
120
|
+
prevWeek = ml.week + 1;
|
|
121
|
+
}
|
|
122
|
+
monthsParts.push('</div>');
|
|
123
|
+
const monthsHTML = monthsParts.join('');
|
|
124
|
+
|
|
125
|
+
const dayLabels = ['','Mon','','Wed','','Fri',''];
|
|
126
|
+
const gridParts = [];
|
|
127
|
+
for (let dow = 0; dow < 7; dow++) {
|
|
128
|
+
gridParts.push(`<div class="tp-hm-row"><span class="tp-hm-day-label">${dayLabels[dow]}</span>`);
|
|
129
|
+
for (let w = 0; w < weeks.length; w++) {
|
|
130
|
+
const cell = weeks[w][dow];
|
|
131
|
+
const bg = cellColor(cell.val, cell.future);
|
|
132
|
+
const tip = `${cell.date} · ${fmtTS(cell.val)} tokens`;
|
|
133
|
+
gridParts.push(`<span class="tp-hm-cell" style="background:${bg}" title="${tip}"></span>`);
|
|
134
|
+
}
|
|
135
|
+
gridParts.push('</div>');
|
|
136
|
+
}
|
|
137
|
+
const gridHTML = gridParts.join('');
|
|
138
|
+
|
|
139
|
+
const legendHTML = '<div class="tp-hm-legend"><span>少 Less</span>'
|
|
140
|
+
+ '<span class="tp-hm-legend-cell" style="background:#0d423d"></span>'
|
|
141
|
+
+ '<span class="tp-hm-legend-cell" style="background:#0e6b5a"></span>'
|
|
142
|
+
+ '<span class="tp-hm-legend-cell" style="background:#12b886"></span>'
|
|
143
|
+
+ '<span class="tp-hm-legend-cell" style="background:#34d399"></span>'
|
|
144
|
+
+ '<span class="tp-hm-legend-cell" style="background:#6ee7b7"></span>'
|
|
145
|
+
+ '<span>多 More</span></div>';
|
|
146
|
+
|
|
147
|
+
return `<div class="tp-heatmap"><div class="tp-heatmap-inner">${monthsHTML}${gridHTML}</div>${legendHTML}</div>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Trend: bar chart for last 30 days ──
|
|
151
|
+
function buildTrend(daily) {
|
|
152
|
+
const keys = Object.keys(daily).sort();
|
|
153
|
+
const recentKeys = keys.slice(-30);
|
|
154
|
+
if (recentKeys.length === 0) return '<div style="color:var(--dim);padding:8px">暂无趋势数据</div>';
|
|
155
|
+
|
|
156
|
+
const values = recentKeys.map(k => {
|
|
157
|
+
const d = daily[k];
|
|
158
|
+
return d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
159
|
+
});
|
|
160
|
+
const maxVal = Math.max(...values);
|
|
161
|
+
|
|
162
|
+
let barsHTML = '';
|
|
163
|
+
for (let i = 0; i < recentKeys.length; i++) {
|
|
164
|
+
const k = recentKeys[i];
|
|
165
|
+
const v = values[i];
|
|
166
|
+
const pct = maxVal > 0 ? (v / maxVal * 100) : 0;
|
|
167
|
+
const label = k.slice(5);
|
|
168
|
+
const tip = `${k}: ${fmtTS(v)}`;
|
|
169
|
+
const color = pct < 30 ? '#0e6b5a' : pct < 60 ? '#12b886' : pct < 80 ? '#34d399' : '#6ee7b7';
|
|
170
|
+
barsHTML += `<div class="tp-trend-bar-wrap"><div class="tp-trend-bar" style="height:${Math.max(pct, 3)}%;background:${color}" data-tip="${esc(tip)}"></div><span class="tp-trend-label">${esc(label)}</span></div>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const gridLines = `<div class="tp-trend-grid-lines">
|
|
174
|
+
<span style="font-size:9px;color:var(--dim);align-self:flex-start">${fmtTS(maxVal)}</span>
|
|
175
|
+
<div class="tp-trend-grid-line"></div>
|
|
176
|
+
<div class="tp-trend-grid-line"></div>
|
|
177
|
+
<span style="font-size:9px;color:var(--dim);align-self:center">${fmtTS(maxVal * 0.5)}</span>
|
|
178
|
+
<div class="tp-trend-grid-line"></div>
|
|
179
|
+
<span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>
|
|
180
|
+
</div>`;
|
|
181
|
+
|
|
182
|
+
return `<div class="tp-trend-bars">${gridLines}${barsHTML}</div>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Model ranking sidebar ──
|
|
186
|
+
function buildModelRank(mt, totalAll) {
|
|
187
|
+
const sorted = Object.entries(mt).sort((a, b) => {
|
|
188
|
+
const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
|
|
189
|
+
const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
|
|
190
|
+
return sB - sA;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
let html = '<div class="tp-rank-title">🏆 模型排名 Model Ranking</div>';
|
|
194
|
+
for (let i = 0; i < Math.min(sorted.length, 5); i++) {
|
|
195
|
+
const [name, m] = sorted[i];
|
|
196
|
+
const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
|
|
197
|
+
const pct = totalAll > 0 ? (mTotal / totalAll * 100).toFixed(1) : '0';
|
|
198
|
+
const c = modelColor(name);
|
|
199
|
+
html += `<div class="tp-rank-item">
|
|
200
|
+
<span class="tp-rank-num">${i + 1}</span>
|
|
201
|
+
<span class="tp-rank-dot" style="background:${c}"></span>
|
|
202
|
+
<span class="tp-rank-name">${esc(name)}</span>
|
|
203
|
+
<span class="tp-rank-pct">${pct}%</span>
|
|
204
|
+
</div>`;
|
|
205
|
+
}
|
|
206
|
+
return html;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Bar chart for weekly/monthly token consumption ──
|
|
210
|
+
|
|
211
|
+
function buildStackedChart(dailyKeys, daily, type) {
|
|
212
|
+
const title = type === 'weekly' ? '📊 周 Token 消耗 Weekly Token Consumption' : '📊 月 Token 消耗 Monthly Token Consumption';
|
|
213
|
+
let periods;
|
|
214
|
+
if (type === 'weekly') {
|
|
215
|
+
periods = aggregateWeekly(dailyKeys, daily);
|
|
216
|
+
} else {
|
|
217
|
+
periods = aggregateMonthly(dailyKeys, daily);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const periodKeys = Object.keys(periods).sort();
|
|
221
|
+
if (periodKeys.length === 0) return `<div class="tp-chart-title">${title}</div><div style="color:var(--dim);padding:8px">暂无数据</div>`;
|
|
222
|
+
|
|
223
|
+
const totals = periodKeys.map(k => {
|
|
224
|
+
const p = periods[k];
|
|
225
|
+
return p.input + p.output + p.cacheCreation + p.cacheRead;
|
|
226
|
+
});
|
|
227
|
+
const maxTotal = Math.max(...totals);
|
|
228
|
+
|
|
229
|
+
let barsHTML = '';
|
|
230
|
+
for (let i = 0; i < periodKeys.length; i++) {
|
|
231
|
+
const k = periodKeys[i];
|
|
232
|
+
const total = totals[i];
|
|
233
|
+
const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
|
|
234
|
+
const label = type === 'weekly' ? k.slice(5) : k.slice(2);
|
|
235
|
+
const tip = `${k}: ${fmtTS(total)} tokens`;
|
|
236
|
+
barsHTML += `<div class="tp-stack-wrap"><div class="tp-stack-bar-area"><div class="tp-stack-bar-group" data-tip="${esc(tip)}"><div class="tp-stack-seg" style="height:${Math.max(pct, 1)}%;background:#58a6ff"></div></div></div><span class="tp-stack-label">${esc(label)}</span></div>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let gridHTML = '<div class="tp-stack-grid">';
|
|
240
|
+
gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-start">${fmtTS(maxTotal)}</span>`;
|
|
241
|
+
gridHTML += '<div class="tp-stack-grid-line"></div><div class="tp-stack-grid-line"></div>';
|
|
242
|
+
gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:center">${fmtTS(Math.round(maxTotal * 0.5))}</span>`;
|
|
243
|
+
gridHTML += '<div class="tp-stack-grid-line"></div>';
|
|
244
|
+
gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>`;
|
|
245
|
+
gridHTML += '</div>';
|
|
246
|
+
|
|
247
|
+
return `<div class="tp-chart-title">${title}</div><div class="tp-stack-bars">${gridHTML}${barsHTML}</div>`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Model proportion doughnut chart ──
|
|
251
|
+
function buildModelPie(mt, totalAll) {
|
|
252
|
+
const title = '🤖 模型 Token 占比 Model Token Proportion';
|
|
253
|
+
if (totalAll === 0) return `<div class="tp-chart-title">${title}</div><div style="color:var(--dim);padding:8px">暂无数据</div>`;
|
|
254
|
+
|
|
255
|
+
const sorted = Object.entries(mt).sort((a, b) => {
|
|
256
|
+
const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
|
|
257
|
+
const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
|
|
258
|
+
return sB - sA;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
let gradParts = '';
|
|
262
|
+
let legendHTML = '';
|
|
263
|
+
let currentDeg = 0;
|
|
264
|
+
|
|
265
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
266
|
+
const [name, m] = sorted[i];
|
|
267
|
+
const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
|
|
268
|
+
const pct = totalAll > 0 ? (mTotal / totalAll * 100) : 0;
|
|
269
|
+
const nextDeg = currentDeg + pct * 3.6;
|
|
270
|
+
const c = modelColor(name);
|
|
271
|
+
gradParts += `${c} ${currentDeg.toFixed(2)}deg ${nextDeg.toFixed(2)}deg`;
|
|
272
|
+
if (i < sorted.length - 1) gradParts += ', ';
|
|
273
|
+
currentDeg = nextDeg;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (currentDeg < 360) {
|
|
277
|
+
gradParts += `, var(--bg3) ${currentDeg.toFixed(2)}deg 360deg`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
281
|
+
const [name, m] = sorted[i];
|
|
282
|
+
const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
|
|
283
|
+
const pct = totalAll > 0 ? (mTotal / totalAll * 100).toFixed(1) : '0';
|
|
284
|
+
const c = modelColor(name);
|
|
285
|
+
legendHTML += `<div class="tp-pie-legend-item"><span class="tp-pie-dot" style="background:${c}"></span><span class="tp-pie-name">${esc(name)}</span><span class="tp-pie-pct">${pct}%</span></div>`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const modelsCount = sorted.length;
|
|
289
|
+
|
|
290
|
+
return `<div class="tp-chart-title">${title}</div>
|
|
291
|
+
<div class="tp-pie-wrap">
|
|
292
|
+
<div class="tp-pie-ring" style="background:conic-gradient(${gradParts})">
|
|
293
|
+
<div class="tp-pie-hole">
|
|
294
|
+
<div class="tp-pie-hole-v">${modelsCount}</div>
|
|
295
|
+
<div class="tp-pie-hole-l">模型 models</div>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
<div class="tp-pie-legend">${legendHTML}</div>
|
|
299
|
+
</div>`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Hourly distribution chart ──
|
|
303
|
+
function buildHourlyChart(hourly) {
|
|
304
|
+
const title = '⏰ 活跃时段分布 Active Time Distribution';
|
|
305
|
+
if (!hourly || hourly.length === 0 || hourly.every(v => v === 0)) return `<div class="tp-chart-title">${title}</div><div style="color:var(--dim);padding:8px">暂无数据</div>`;
|
|
306
|
+
|
|
307
|
+
const maxCalls = Math.max(...hourly);
|
|
308
|
+
|
|
309
|
+
let barsHTML = '';
|
|
310
|
+
for (let h = 0; h < 24; h++) {
|
|
311
|
+
const calls = hourly[h] || 0;
|
|
312
|
+
const pct = maxCalls > 0 ? (calls / maxCalls * 100) : 0;
|
|
313
|
+
const color = pct < 30 ? 'rgba(16,185,129,0.25)' : pct < 60 ? 'rgba(16,185,129,0.5)' : pct < 80 ? 'rgba(16,185,129,0.75)' : 'rgb(16,185,129)';
|
|
314
|
+
const borderColor = 'rgb(16,185,129)';
|
|
315
|
+
const tip = `${h}:00 · ${calls.toLocaleString()} 次调用 calls`;
|
|
316
|
+
barsHTML += `<div class="tp-hourly-bar-wrap"><div class="tp-hourly-bar-area"><div class="tp-hourly-bar" style="height:${Math.max(pct, 2)}%;background:${color};border:1px solid ${borderColor}" data-tip="${esc(tip)}"></div></div><span class="tp-hourly-label">${h}</span></div>`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let gridHTML = '<div class="tp-hourly-grid">';
|
|
320
|
+
gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-start">${maxCalls.toLocaleString()}</span>`;
|
|
321
|
+
gridHTML += '<div style="border-top:1px dashed var(--border);opacity:0.3"></div>';
|
|
322
|
+
gridHTML += `<span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>`;
|
|
323
|
+
gridHTML += '</div>';
|
|
324
|
+
|
|
325
|
+
const totalCalls = hourly.reduce((a, b) => a + b, 0);
|
|
326
|
+
const peakHour = hourly.indexOf(maxCalls);
|
|
327
|
+
|
|
328
|
+
return `<div class="tp-chart-title">${title}</div>
|
|
329
|
+
<div style="display:flex;gap:12px;margin-bottom:6px;font-size:10px;color:var(--dim)">
|
|
330
|
+
<span>总调用 Total calls: <b style="color:var(--white)">${totalCalls.toLocaleString()}</b></span>
|
|
331
|
+
<span>峰值 Peak: <b style="color:var(--white)">${peakHour}:00</b> (${maxCalls.toLocaleString()} calls)</span>
|
|
332
|
+
</div>
|
|
333
|
+
<div class="tp-hourly-bars">${gridHTML}${barsHTML}</div>`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
337
|
+
// Detail table
|
|
338
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
339
|
+
|
|
340
|
+
function renderPeriodTable(keys, data, type) {
|
|
341
|
+
const sorted = keys.sort((a, b) => b.localeCompare(a));
|
|
342
|
+
const rows = new Array(sorted.length);
|
|
343
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
344
|
+
const k = sorted[i];
|
|
345
|
+
const d = data[k];
|
|
346
|
+
const total = d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
347
|
+
const label = type === 'daily' ? k : k + '<br><small style="color:var(--dim)">' + esc(d.dateRange || '') + '</small>';
|
|
348
|
+
const modelsHtml = Object.entries(d.models).sort((a, b) => {
|
|
349
|
+
const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
|
|
350
|
+
const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
|
|
351
|
+
return sB - sA;
|
|
352
|
+
}).slice(0, 4).map(([mn, m]) => {
|
|
353
|
+
const mT = m.input + m.output + m.cacheCreation + m.cacheRead;
|
|
354
|
+
const c = modelColor(mn);
|
|
355
|
+
return `<span class="tp-mtag" style="background:${c}20;border-color:${c};color:${c}">${esc(mn)}: ${fmtTS(mT)}</span>`;
|
|
356
|
+
}).join(' ');
|
|
357
|
+
rows[i] = `<tr><td>${label}</td><td><b>${fmtTS(total)}</b></td><td>${fmtTS(d.input)}</td><td>${fmtTS(d.output)}</td><td>${fmtTS(d.cacheRead)}</td><td>${fmtTS(d.cacheCreation)}</td><td>${d.messages.toLocaleString()}</td><td class="tp-mbreak">${modelsHtml}</td></tr>`;
|
|
358
|
+
}
|
|
359
|
+
return '<table class="tp-table"><thead><tr><th>日期 Date</th><th>总计 Total</th><th>输入 Input</th><th>输出 Output</th><th>缓存读取 Cache Read</th><th>缓存创建 Cache Create</th><th>消息数 Messages</th><th>模型 Models</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
363
|
+
// Render entire token page
|
|
364
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
365
|
+
|
|
366
|
+
function renderTokenPage() {
|
|
367
|
+
_modelColorIdx = 0;
|
|
368
|
+
const t = tokenStatsData.totals;
|
|
369
|
+
const mt = tokenStatsData.modelTotals;
|
|
370
|
+
const daily = tokenStatsData.daily;
|
|
371
|
+
const totalAll = t.input + t.output + t.cacheCreation + t.cacheRead;
|
|
372
|
+
|
|
373
|
+
if (totalAll === 0) {
|
|
374
|
+
document.getElementById('tp-total-card').innerHTML = '<div style="color:var(--dim);padding:8px">暂无历史 Token 数据</div>';
|
|
375
|
+
document.getElementById('tp-stats-grid').innerHTML = '';
|
|
376
|
+
document.getElementById('tp-model-rank').innerHTML = '';
|
|
377
|
+
document.getElementById('tp-trend-card').innerHTML = '';
|
|
378
|
+
document.getElementById('tp-heatmap-card').innerHTML = '';
|
|
379
|
+
document.getElementById('tp-weekly-chart').innerHTML = '';
|
|
380
|
+
document.getElementById('tp-monthly-chart').innerHTML = '';
|
|
381
|
+
document.getElementById('tp-model-pie').innerHTML = '';
|
|
382
|
+
document.getElementById('tp-hourly-chart').innerHTML = '';
|
|
383
|
+
document.getElementById('tp-detail-tabs').innerHTML = '';
|
|
384
|
+
document.getElementById('tp-daily-table').innerHTML = '';
|
|
385
|
+
document.getElementById('tp-weekly-table').innerHTML = '';
|
|
386
|
+
document.getElementById('tp-monthly-table').innerHTML = '';
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const inputPct = totalAll > 0 ? (t.input / totalAll * 100).toFixed(1) : '0';
|
|
391
|
+
const outputPct = totalAll > 0 ? (t.output / totalAll * 100).toFixed(1) : '0';
|
|
392
|
+
const crPct = totalAll > 0 ? (t.cacheRead / totalAll * 100).toFixed(1) : '0';
|
|
393
|
+
const ccPct = totalAll > 0 ? (t.cacheCreation / totalAll * 100).toFixed(1) : '0';
|
|
394
|
+
const dailyAvg = t.days > 0 ? Math.round(totalAll / t.days).toLocaleString() : '—';
|
|
395
|
+
|
|
396
|
+
// 1. Total tokens card
|
|
397
|
+
document.getElementById('tp-total-card').innerHTML = `
|
|
398
|
+
<div class="tp-total-label">总用量 TOTAL TOKENS</div>
|
|
399
|
+
<div class="tp-total-value">${fmtTS(totalAll)}</div>
|
|
400
|
+
<div class="tp-footer-stats">
|
|
401
|
+
<span>开始 Started <span class="tp-fv">${Object.keys(daily).sort()[0] || '—'}</span></span>
|
|
402
|
+
<span>活跃 Active <span class="tp-fv">${t.days} 天 DAY</span></span>
|
|
403
|
+
<span>模型 Models <span class="tp-fv">${Object.keys(mt).length}</span></span>
|
|
404
|
+
</div>`;
|
|
405
|
+
|
|
406
|
+
// 2. Stats grid
|
|
407
|
+
const stats = [
|
|
408
|
+
{ l: '输入 Input', v: fmtTS(t.input), s: inputPct + '%' },
|
|
409
|
+
{ l: '输出 Output', v: fmtTS(t.output), s: outputPct + '%' },
|
|
410
|
+
{ l: '缓存读取 Cache Read', v: fmtTS(t.cacheRead), s: crPct + '%' },
|
|
411
|
+
{ l: '缓存创建 Cache Create', v: fmtTS(t.cacheCreation), s: ccPct + '%' },
|
|
412
|
+
{ l: '消息 Messages', v: fmtTS(t.messages), s: t.messages.toLocaleString() },
|
|
413
|
+
{ l: '日平均 Daily Avg', v: dailyAvg, s: 'tokens/天' },
|
|
414
|
+
];
|
|
415
|
+
document.getElementById('tp-stats-grid').innerHTML = `<div class="tp-stat-grid">${stats.map(s => `<div class="tp-stat"><div class="tp-s-l">${s.l}</div><div class="tp-s-v">${s.v}</div><div style="font-size:9px;color:var(--dim)">${s.s}</div></div>`).join('')}</div>`;
|
|
416
|
+
|
|
417
|
+
// 3. Model ranking
|
|
418
|
+
document.getElementById('tp-model-rank').innerHTML = buildModelRank(mt, totalAll);
|
|
419
|
+
|
|
420
|
+
// 4. Usage Trend
|
|
421
|
+
const dailyKeys = Object.keys(daily);
|
|
422
|
+
document.getElementById('tp-trend-card').innerHTML = `<div class="tp-h3">📊 使用趋势 Usage Trend</div>${buildTrend(daily)}`;
|
|
423
|
+
|
|
424
|
+
// 5. Activity Heatmap
|
|
425
|
+
const tzOffset = -(new Date().getTimezoneOffset() / 60);
|
|
426
|
+
document.getElementById('tp-heatmap-card').innerHTML = `<div class="tp-h3">🗓 活跃热力图 Activity Heatmap</div><span style="font-size:10px;color:var(--dim);float:right">UTC+${tzOffset.toFixed(0)}</span>${buildHeatmap(daily)}`;
|
|
427
|
+
|
|
428
|
+
// 6. Charts: Weekly, Monthly, Model Pie, Hourly
|
|
429
|
+
document.getElementById('tp-weekly-chart').innerHTML = buildStackedChart(dailyKeys, daily, 'weekly');
|
|
430
|
+
document.getElementById('tp-monthly-chart').innerHTML = buildStackedChart(dailyKeys, daily, 'monthly');
|
|
431
|
+
document.getElementById('tp-model-pie').innerHTML = buildModelPie(mt, totalAll);
|
|
432
|
+
document.getElementById('tp-hourly-chart').innerHTML = buildHourlyChart(tokenStatsData.hourly || []);
|
|
433
|
+
|
|
434
|
+
// 7. Detail tabs
|
|
435
|
+
const weeklyCount = weeklyKeysFromDaily(dailyKeys).length;
|
|
436
|
+
const monthlyCount = monthlyKeysFromDaily(dailyKeys).length;
|
|
437
|
+
document.getElementById('tp-detail-tabs').innerHTML = `<div class="tp-tab ${tsDetailTab === 'daily' ? 'active' : ''}" data-tab="daily" onclick="tsSwitchDetail('daily')">每日明细 Daily Breakdown (${dailyKeys.length})</div><div class="tp-tab ${tsDetailTab === 'weekly' ? 'active' : ''}" data-tab="weekly" onclick="tsSwitchDetail('weekly')">每周 Weekly (${weeklyCount})</div><div class="tp-tab ${tsDetailTab === 'monthly' ? 'active' : ''}" data-tab="monthly" onclick="tsSwitchDetail('monthly')">每月 Monthly (${monthlyCount})</div>`;
|
|
438
|
+
document.querySelectorAll('.tp-tc').forEach(tc => tc.classList.remove('active'));
|
|
439
|
+
document.getElementById('tp-tc-' + tsDetailTab)?.classList.add('active');
|
|
440
|
+
|
|
441
|
+
document.getElementById('tp-daily-table').innerHTML = renderPeriodTable(dailyKeys, daily, 'daily');
|
|
442
|
+
const weekly = aggregateWeekly(dailyKeys, daily);
|
|
443
|
+
document.getElementById('tp-weekly-table').innerHTML = renderPeriodTable(Object.keys(weekly), weekly, 'weekly');
|
|
444
|
+
const monthly = aggregateMonthly(dailyKeys, daily);
|
|
445
|
+
document.getElementById('tp-monthly-table').innerHTML = renderPeriodTable(Object.keys(monthly), monthly, 'monthly');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function weeklyKeysFromDaily(keys) {
|
|
449
|
+
const weeks = new Set();
|
|
450
|
+
for (const k of keys) { const d = new Date(k); const wk = getWeekKey(d); weeks.add(wk); }
|
|
451
|
+
return [...weeks];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function monthlyKeysFromDaily(keys) {
|
|
455
|
+
const months = new Set();
|
|
456
|
+
for (const k of keys) { months.add(k.slice(0, 7)); }
|
|
457
|
+
return [...months];
|
|
458
|
+
}
|
package/src/scanner/scanner.js
CHANGED
|
@@ -45,6 +45,8 @@ function getClaudeDir() {
|
|
|
45
45
|
async function fullScanTokenUsage(progressCallback) {
|
|
46
46
|
const claudeDir = getClaudeDir();
|
|
47
47
|
const dailyStats = new Map();
|
|
48
|
+
// Hourly aggregation: 24-hour distribution of API calls (local timezone)
|
|
49
|
+
const hourlyStats = new Array(24).fill(0);
|
|
48
50
|
|
|
49
51
|
// Collect all JSONL files (main + subagent)
|
|
50
52
|
const jsonlFiles = [];
|
|
@@ -56,18 +58,21 @@ async function fullScanTokenUsage(progressCallback) {
|
|
|
56
58
|
|
|
57
59
|
if (progressCallback) progressCallback(0, jsonlFiles.length);
|
|
58
60
|
|
|
59
|
-
// Process
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// Process files in concurrent batches for faster startup
|
|
62
|
+
const SCAN_BATCH_SIZE = 8;
|
|
63
|
+
for (let i = 0; i < jsonlFiles.length; i += SCAN_BATCH_SIZE) {
|
|
64
|
+
const batch = jsonlFiles.slice(i, i + SCAN_BATCH_SIZE);
|
|
65
|
+
await Promise.all(batch.map(f => scanOneFile(f, dailyStats, hourlyStats)));
|
|
66
|
+
const done = Math.min(i + SCAN_BATCH_SIZE, jsonlFiles.length);
|
|
67
|
+
if (progressCallback && (done % 50 < SCAN_BATCH_SIZE || done === jsonlFiles.length)) {
|
|
68
|
+
progressCallback(done, jsonlFiles.length);
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
return dailyStats;
|
|
72
|
+
return { dailyStats, hourlyStats };
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
async function scanOneFile(filePath, dailyStats) {
|
|
75
|
+
async function scanOneFile(filePath, dailyStats, hourlyStats) {
|
|
71
76
|
let input, rl;
|
|
72
77
|
try {
|
|
73
78
|
input = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
@@ -92,16 +97,17 @@ async function scanOneFile(filePath, dailyStats) {
|
|
|
92
97
|
const msg = raw.message;
|
|
93
98
|
if (!msg) continue;
|
|
94
99
|
|
|
95
|
-
// Extract timestamp — required for date-based aggregation
|
|
100
|
+
// Extract timestamp — required for date-based and hour-based aggregation
|
|
96
101
|
let ts;
|
|
97
102
|
if (raw.timestamp) {
|
|
98
103
|
ts = new Date(raw.timestamp);
|
|
99
104
|
}
|
|
100
105
|
if (!raw.timestamp || isNaN(ts.getTime())) {
|
|
101
|
-
// Skip lines without valid timestamps — can't determine which day they belong to
|
|
106
|
+
// Skip lines without valid timestamps — can't determine which day/hour they belong to
|
|
102
107
|
continue;
|
|
103
108
|
}
|
|
104
109
|
const dateStr = ts.getFullYear() + '-' + String(ts.getMonth() + 1).padStart(2, '0') + '-' + String(ts.getDate()).padStart(2, '0');
|
|
110
|
+
const hour = ts.getHours();
|
|
105
111
|
|
|
106
112
|
// Extract usage
|
|
107
113
|
const usage = msg.usage;
|
|
@@ -127,6 +133,9 @@ async function scanOneFile(filePath, dailyStats) {
|
|
|
127
133
|
day.cacheCreation += cacheCreationTokens;
|
|
128
134
|
day.cacheRead += cacheReadTokens;
|
|
129
135
|
|
|
136
|
+
// Hourly distribution (API calls count)
|
|
137
|
+
hourlyStats[hour]++;
|
|
138
|
+
|
|
130
139
|
// Per-model breakdown
|
|
131
140
|
const model = (hasModel && msg.model && msg.model !== '<synthetic>') ? msg.model : '';
|
|
132
141
|
if (model) {
|