claude-code-watch 0.1.4 → 0.1.5
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/index.html +416 -116
- package/src/scanner/scanner.js +146 -0
- package/src/server/server.js +126 -1
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -209,31 +209,64 @@ body {
|
|
|
209
209
|
.tree-row.selected>.tree-actions { display: flex; }
|
|
210
210
|
|
|
211
211
|
/* ── Tokens page ── */
|
|
212
|
-
#tokens-page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding:
|
|
213
|
-
.
|
|
214
|
-
.
|
|
215
|
-
.
|
|
216
|
-
.
|
|
217
|
-
.
|
|
218
|
-
.
|
|
219
|
-
.
|
|
220
|
-
.
|
|
221
|
-
.
|
|
222
|
-
.
|
|
223
|
-
.
|
|
224
|
-
.
|
|
225
|
-
.
|
|
226
|
-
.
|
|
227
|
-
.
|
|
228
|
-
.
|
|
229
|
-
.
|
|
230
|
-
.
|
|
231
|
-
.
|
|
232
|
-
.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
.
|
|
236
|
-
.
|
|
212
|
+
#tokens-page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 20px 24px; gap: 16px; background: var(--bg); }
|
|
213
|
+
.tp-top { display: flex; gap: 16px; }
|
|
214
|
+
.tp-left { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; }
|
|
215
|
+
.tp-right { flex: 1; display: flex; flex-direction: column; gap: 12px; }
|
|
216
|
+
.tp-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
|
|
217
|
+
.tp-total-label { font-size: 11px; color: var(--dim); text-transform: uppercase; margin-bottom: 2px; }
|
|
218
|
+
.tp-total-value { font-size: 22px; font-weight: 700; color: var(--white); font-family: monospace; }
|
|
219
|
+
.tp-total-sub { font-size: 10px; color: var(--dim); margin-top: 2px; }
|
|
220
|
+
.tp-stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
|
221
|
+
.tp-stat { padding: 8px 10px; background: var(--bg3); border-radius: 6px; }
|
|
222
|
+
.tp-stat .tp-s-l { font-size: 10px; color: var(--dim); }
|
|
223
|
+
.tp-stat .tp-s-v { font-size: 13px; font-weight: 600; color: var(--white); font-family: monospace; }
|
|
224
|
+
.tp-rank-title { font-size: 12px; color: var(--dim); font-weight: 600; margin-bottom: 8px; }
|
|
225
|
+
.tp-rank-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); }
|
|
226
|
+
.tp-rank-num { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: var(--bg3); font-size: 10px; font-weight: 600; color: var(--dim); flex-shrink: 0; }
|
|
227
|
+
.tp-rank-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
228
|
+
.tp-rank-name { font-size: 12px; color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
229
|
+
.tp-rank-pct { font-size: 12px; font-weight: 600; color: var(--white); font-family: monospace; flex-shrink: 0; }
|
|
230
|
+
.tp-footer-stats { display: flex; justify-content: space-between; padding-top: 8px; border-top: 1px solid var(--border); font-size: 11px; color: var(--dim); }
|
|
231
|
+
.tp-footer-stats .tp-fv { color: var(--text); font-family: monospace; }
|
|
232
|
+
.tp-h3 { font-size: 12px; color: var(--dim); font-weight: 600; text-transform: uppercase; margin-bottom: 8px; }
|
|
233
|
+
|
|
234
|
+
/* ── Heatmap ── */
|
|
235
|
+
.tp-heatmap { overflow-x: auto; }
|
|
236
|
+
.tp-heatmap-inner { display: inline-flex; flex-direction: column; gap: 2px; }
|
|
237
|
+
.tp-hm-months { display: flex; gap: 0; font-size: 10px; color: var(--dim); margin-bottom: 2px; padding-left: 28px; }
|
|
238
|
+
.tp-hm-row { display: flex; align-items: center; gap: 2px; }
|
|
239
|
+
.tp-hm-day-label { width: 24px; font-size: 10px; color: var(--dim); text-align: right; flex-shrink: 0; }
|
|
240
|
+
.tp-hm-cell { width: 12px; height: 12px; border-radius: 2px; transition: transform 0.15s; cursor: pointer; position: relative; }
|
|
241
|
+
.tp-hm-cell:hover { transform: scale(1.6); z-index: 10; }
|
|
242
|
+
.tp-hm-cell[title]:hover::after { content: attr(title); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; }
|
|
243
|
+
.tp-hm-legend { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--dim); margin-top: 6px; justify-content: flex-end; }
|
|
244
|
+
.tp-hm-legend-cell { width: 12px; height: 12px; border-radius: 2px; }
|
|
245
|
+
|
|
246
|
+
/* ── Trend bars ── */
|
|
247
|
+
.tp-trend-bars { display: flex; align-items: flex-end; gap: 3px; height: 140px; position: relative; padding-bottom: 20px; }
|
|
248
|
+
.tp-trend-bar-wrap { flex: 1; display: flex; flex-direction: column; position: relative; min-width: 0; }
|
|
249
|
+
.tp-trend-bar { position: relative; border-radius: 3px 3px 0 0; transition: all 0.15s; cursor: pointer; min-height: 4px; }
|
|
250
|
+
.tp-trend-bar:hover { filter: brightness(1.3); }
|
|
251
|
+
.tp-trend-bar:hover::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; }
|
|
252
|
+
.tp-trend-label { font-size: 9px; color: var(--dim); text-align: center; position: absolute; bottom: 0; width: 100%; white-space: nowrap; overflow: hidden; }
|
|
253
|
+
.tp-trend-grid-lines { position: absolute; inset: 0 0 20px 0; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; }
|
|
254
|
+
.tp-trend-grid-line { width: 100%; border-top: 1px dashed var(--border); opacity: 0.4; }
|
|
255
|
+
|
|
256
|
+
/* ── Detail table ── */
|
|
257
|
+
.tp-tabs { display: flex; gap: 2px; }
|
|
258
|
+
.tp-tab { padding: 5px 12px; cursor: pointer; border-radius: 6px 6px 0 0; background: var(--bg3); color: var(--dim); border: 1px solid var(--border); border-bottom: none; font-size: 12px; font-weight: 500; }
|
|
259
|
+
.tp-tab.active { background: var(--bg2); color: var(--white); font-weight: 600; }
|
|
260
|
+
.tp-tc { display: none; }
|
|
261
|
+
.tp-tc.active { display: block; }
|
|
262
|
+
.tp-st { max-height: 480px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; }
|
|
263
|
+
.tp-table { width: 100%; border-collapse: collapse; font-size: 11px; background: var(--bg2); }
|
|
264
|
+
.tp-table th { background: var(--bg3); color: var(--white); padding: 7px 10px; text-align: left; font-weight: 600; white-space: nowrap; }
|
|
265
|
+
.tp-table td { padding: 6px 10px; border-top: 1px solid var(--border); white-space: nowrap; font-family: monospace; color: var(--text); }
|
|
266
|
+
.tp-table tr:hover td { background: var(--bg3); }
|
|
267
|
+
.tp-table tfoot td { font-weight: 700; background: var(--bg3); }
|
|
268
|
+
.tp-mtag { display: inline-block; padding: 1px 6px; border-radius: 3px; border: 1px solid; font-size: 10px; margin: 1px; font-weight: 500; }
|
|
269
|
+
.tp-mbreak { white-space: normal; min-width: 200px; }
|
|
237
270
|
|
|
238
271
|
/* ── Stream panel ── */
|
|
239
272
|
#stream-panel-wrap {
|
|
@@ -455,18 +488,24 @@ body {
|
|
|
455
488
|
</div>
|
|
456
489
|
|
|
457
490
|
<div id="tokens-page" style="display:none">
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
<div class="
|
|
491
|
+
<!-- Top row: left sidebar + right content -->
|
|
492
|
+
<div class="tp-top">
|
|
493
|
+
<div class="tp-left">
|
|
494
|
+
<div class="tp-box" id="tp-total-card"></div>
|
|
495
|
+
<div class="tp-box" id="tp-stats-grid"></div>
|
|
496
|
+
<div class="tp-box" id="tp-model-rank"></div>
|
|
497
|
+
</div>
|
|
498
|
+
<div class="tp-right">
|
|
499
|
+
<div class="tp-box" id="tp-trend-card"></div>
|
|
500
|
+
<div class="tp-box" id="tp-heatmap-card"></div>
|
|
501
|
+
</div>
|
|
461
502
|
</div>
|
|
462
|
-
|
|
463
|
-
<div
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
<
|
|
467
|
-
|
|
468
|
-
<tbody id="token-table-body"></tbody>
|
|
469
|
-
</table>
|
|
503
|
+
<!-- Detail table -->
|
|
504
|
+
<div class="tp-box">
|
|
505
|
+
<div class="tp-tabs" id="tp-detail-tabs"></div>
|
|
506
|
+
<div id="tp-tc-daily" class="tp-tc"><div class="tp-st" id="tp-daily-table"></div></div>
|
|
507
|
+
<div id="tp-tc-weekly" class="tp-tc"><div class="tp-st" id="tp-weekly-table"></div></div>
|
|
508
|
+
<div id="tp-tc-monthly" class="tp-tc"><div class="tp-st" id="tp-monthly-table"></div></div>
|
|
470
509
|
</div>
|
|
471
510
|
</div>
|
|
472
511
|
|
|
@@ -563,6 +602,7 @@ let showTokenCount = true;
|
|
|
563
602
|
let autoDiscovery = true;
|
|
564
603
|
let appVersion = '';
|
|
565
604
|
let currentTab = 'stream';
|
|
605
|
+
let tokenStatsData = { totals: { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, days: 0 }, modelTotals: {}, daily: {} };
|
|
566
606
|
|
|
567
607
|
const HIDDEN_KEY = 'claude-watch-hidden';
|
|
568
608
|
function loadHiddenSessions() {
|
|
@@ -723,7 +763,8 @@ function handleMessage(msg) {
|
|
|
723
763
|
case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
|
|
724
764
|
case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
|
|
725
765
|
case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
|
|
726
|
-
case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons();
|
|
766
|
+
case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); break;
|
|
767
|
+
case 'tokenStats': tokenStatsData = msg.payload; if (currentTab === 'tokens') renderTokenPage(); break;
|
|
727
768
|
case 'config':
|
|
728
769
|
if (msg.payload.version) appVersion = msg.payload.version;
|
|
729
770
|
if (msg.payload.collapseAfter > 0 && !collapseTimer) {
|
|
@@ -1679,104 +1720,363 @@ function toggleTokenDisplay() {
|
|
|
1679
1720
|
}
|
|
1680
1721
|
|
|
1681
1722
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
1682
|
-
// Tab switching & Token page
|
|
1723
|
+
// Tab switching & Token stats page
|
|
1724
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1725
|
+
// Token Statistics (completely independent from stream/context)
|
|
1683
1726
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
1684
1727
|
|
|
1728
|
+
const MODEL_COLORS = {
|
|
1729
|
+
'claude-opus-4-7': '#e74c3c', 'claude-opus-4-6': '#c0392b', 'claude-opus-4-8': '#e67e22',
|
|
1730
|
+
'claude-sonnet-4-6': '#3498db', 'claude-sonnet-4-5': '#2980b9',
|
|
1731
|
+
'claude-haiku-4-5': '#5dade2', 'claude-haiku-4': '#1abc9c',
|
|
1732
|
+
'glm-5.1': '#2980b9', 'glm-5': '#3498db', 'glm-4.7': '#5dade2',
|
|
1733
|
+
'qwen3.7-max': '#55efc4', 'qwen3.6-plus': '#2ecc71', 'qwen3.5-plus': '#27ae60',
|
|
1734
|
+
'qwen3-max': '#1abc9c',
|
|
1735
|
+
'deepseek-v4-pro': '#9b59b6',
|
|
1736
|
+
'kimi-k2.5': '#f39c12', 'kimi-k2.6': '#d35400', 'kimi-k2-thinking': '#d4a017',
|
|
1737
|
+
'MiniMax-M2.5': '#1abc9c',
|
|
1738
|
+
};
|
|
1739
|
+
let _modelColorIdx = 0;
|
|
1740
|
+
function modelColor(name) {
|
|
1741
|
+
if (MODEL_COLORS[name]) return MODEL_COLORS[name];
|
|
1742
|
+
const fallback = ['#e74c3c','#3498db','#2ecc71','#9b59b6','#f39c12','#1abc9c','#e67e22','#c0392b','#5dade2','#d35400','#55efc4','#d4a017'];
|
|
1743
|
+
return fallback[_modelColorIdx++ % fallback.length];
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
let tsDetailTab = 'daily';
|
|
1747
|
+
|
|
1685
1748
|
function switchTab(tab) {
|
|
1686
1749
|
currentTab = tab;
|
|
1687
1750
|
document.getElementById('main').style.display = tab === 'stream' ? 'flex' : 'none';
|
|
1688
1751
|
document.getElementById('tokens-page').style.display = tab === 'tokens' ? 'flex' : 'none';
|
|
1689
1752
|
document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
|
|
1690
1753
|
document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
|
|
1691
|
-
// footer 只在 stream 模式下有意义
|
|
1692
1754
|
document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
|
|
1693
1755
|
if (tab === 'tokens') renderTokenPage();
|
|
1694
1756
|
}
|
|
1695
1757
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
document.
|
|
1700
|
-
document.
|
|
1758
|
+
function tsSwitchDetail(n) {
|
|
1759
|
+
tsDetailTab = n;
|
|
1760
|
+
document.querySelectorAll('.tp-tab').forEach(t => t.classList.remove('active'));
|
|
1761
|
+
document.querySelectorAll('.tp-tc').forEach(t => t.classList.remove('active'));
|
|
1762
|
+
document.querySelector(`.tp-tab[data-tab="${n}"]`)?.classList.add('active');
|
|
1763
|
+
document.getElementById('tp-tc-' + n)?.classList.add('active');
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function fmtTS(n) {
|
|
1767
|
+
if (!n) return '0';
|
|
1768
|
+
return n.toLocaleString();
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function fmtDateISO(d) {
|
|
1772
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
1701
1773
|
}
|
|
1702
1774
|
|
|
1775
|
+
// ── Heatmap: 52-week × 7-day GitHub-style grid ──
|
|
1776
|
+
function buildHeatmap(daily) {
|
|
1777
|
+
const today = new Date();
|
|
1778
|
+
const dailyTotalsMap = {};
|
|
1779
|
+
for (const [k, d] of Object.entries(daily)) {
|
|
1780
|
+
dailyTotalsMap[k] = d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// Start 52 weeks ago from Sunday
|
|
1784
|
+
const startSunday = new Date(today);
|
|
1785
|
+
startSunday.setDate(startSunday.getDate() - startSunday.getDay() - 52 * 7);
|
|
1786
|
+
const startStr = fmtDateISO(startSunday);
|
|
1787
|
+
|
|
1788
|
+
// Compute maxVal only from dates within the heatmap window
|
|
1789
|
+
let maxVal = 0;
|
|
1790
|
+
for (const [k, v] of Object.entries(dailyTotalsMap)) {
|
|
1791
|
+
if (k >= startStr && v > maxVal) maxVal = v;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
const weeks = [];
|
|
1795
|
+
const monthLabels = [];
|
|
1796
|
+
let lastMonth = -1;
|
|
1797
|
+
let currentSunday = new Date(startSunday);
|
|
1798
|
+
|
|
1799
|
+
for (let w = 0; w < 53; w++) {
|
|
1800
|
+
const weekData = [];
|
|
1801
|
+
for (let dow = 0; dow < 7; dow++) {
|
|
1802
|
+
const d = new Date(currentSunday);
|
|
1803
|
+
d.setDate(d.getDate() + dow);
|
|
1804
|
+
const ds = fmtDateISO(d);
|
|
1805
|
+
const val = dailyTotalsMap[ds] || 0;
|
|
1806
|
+
weekData.push({ date: ds, val, future: d > today });
|
|
1807
|
+
if (dow === 0) {
|
|
1808
|
+
const m = d.getMonth();
|
|
1809
|
+
if (m !== lastMonth) { monthLabels.push({ month: m, week: w }); lastMonth = m; }
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
weeks.push(weekData);
|
|
1813
|
+
currentSunday.setDate(currentSunday.getDate() + 7);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function cellColor(val, future) {
|
|
1817
|
+
if (future) return 'var(--bg3)';
|
|
1818
|
+
if (val === 0) return '#0d423d';
|
|
1819
|
+
const pct = maxVal > 0 ? val / maxVal : 0;
|
|
1820
|
+
if (pct < 0.25) return '#0e6b5a';
|
|
1821
|
+
if (pct < 0.5) return '#12b886';
|
|
1822
|
+
if (pct < 0.75) return '#34d399';
|
|
1823
|
+
return '#6ee7b7';
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
1827
|
+
let monthsHTML = '<div class="tp-hm-months">';
|
|
1828
|
+
let prevWeek = 0;
|
|
1829
|
+
for (const ml of monthLabels) {
|
|
1830
|
+
const offset = ml.week - prevWeek;
|
|
1831
|
+
if (offset > 0) monthsHTML += `<span style="width:${offset * 14}px"></span>`;
|
|
1832
|
+
monthsHTML += `<span style="width:14px">${monthNames[ml.month]}</span>`;
|
|
1833
|
+
prevWeek = ml.week + 1;
|
|
1834
|
+
}
|
|
1835
|
+
monthsHTML += '</div>';
|
|
1836
|
+
|
|
1837
|
+
const dayLabels = ['','Mon','','Wed','','Fri',''];
|
|
1838
|
+
let gridHTML = '';
|
|
1839
|
+
for (let dow = 0; dow < 7; dow++) {
|
|
1840
|
+
gridHTML += `<div class="tp-hm-row"><span class="tp-hm-day-label">${dayLabels[dow]}</span>`;
|
|
1841
|
+
for (let w = 0; w < weeks.length; w++) {
|
|
1842
|
+
const cell = weeks[w][dow];
|
|
1843
|
+
const bg = cellColor(cell.val, cell.future);
|
|
1844
|
+
const tip = `${cell.date} · ${fmtTS(cell.val)} tokens`;
|
|
1845
|
+
gridHTML += `<span class="tp-hm-cell" style="background:${bg}" title="${tip}"></span>`;
|
|
1846
|
+
}
|
|
1847
|
+
gridHTML += '</div>';
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
let legendHTML = '<div class="tp-hm-legend"><span>Less</span>';
|
|
1851
|
+
legendHTML += '<span class="tp-hm-legend-cell" style="background:#0d423d"></span>';
|
|
1852
|
+
legendHTML += '<span class="tp-hm-legend-cell" style="background:#0e6b5a"></span>';
|
|
1853
|
+
legendHTML += '<span class="tp-hm-legend-cell" style="background:#12b886"></span>';
|
|
1854
|
+
legendHTML += '<span class="tp-hm-legend-cell" style="background:#34d399"></span>';
|
|
1855
|
+
legendHTML += '<span class="tp-hm-legend-cell" style="background:#6ee7b7"></span>';
|
|
1856
|
+
legendHTML += '<span>More</span></div>';
|
|
1857
|
+
|
|
1858
|
+
return `<div class="tp-heatmap"><div class="tp-heatmap-inner">${monthsHTML}${gridHTML}</div>${legendHTML}</div>`;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// ── Trend: bar chart for last 30 days ──
|
|
1862
|
+
function buildTrend(daily) {
|
|
1863
|
+
const keys = Object.keys(daily).sort();
|
|
1864
|
+
const recentKeys = keys.slice(-30);
|
|
1865
|
+
if (recentKeys.length === 0) return '<div style="color:var(--dim);padding:8px">暂无趋势数据</div>';
|
|
1866
|
+
|
|
1867
|
+
const values = recentKeys.map(k => {
|
|
1868
|
+
const d = daily[k];
|
|
1869
|
+
return d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
1870
|
+
});
|
|
1871
|
+
const maxVal = Math.max(...values);
|
|
1872
|
+
|
|
1873
|
+
let barsHTML = '';
|
|
1874
|
+
for (let i = 0; i < recentKeys.length; i++) {
|
|
1875
|
+
const k = recentKeys[i];
|
|
1876
|
+
const v = values[i];
|
|
1877
|
+
const pct = maxVal > 0 ? (v / maxVal * 100) : 0;
|
|
1878
|
+
const label = k.slice(5);
|
|
1879
|
+
const tip = `${k}: ${fmtTS(v)}`;
|
|
1880
|
+
const color = pct < 30 ? '#0e6b5a' : pct < 60 ? '#12b886' : pct < 80 ? '#34d399' : '#6ee7b7';
|
|
1881
|
+
barsHTML += `<div class="tp-trend-bar-wrap"><div class="tp-trend-bar" style="height:${Math.max(pct, 3)}%;background:${color}" data-tip="${tip}"></div><span class="tp-trend-label">${label}</span></div>`;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
const gridLines = `<div class="tp-trend-grid-lines">
|
|
1885
|
+
<span style="font-size:9px;color:var(--dim);align-self:flex-start">${fmtTS(maxVal)}</span>
|
|
1886
|
+
<div class="tp-trend-grid-line"></div>
|
|
1887
|
+
<div class="tp-trend-grid-line"></div>
|
|
1888
|
+
<span style="font-size:9px;color:var(--dim);align-self:center">${fmtTS(maxVal * 0.5)}</span>
|
|
1889
|
+
<div class="tp-trend-grid-line"></div>
|
|
1890
|
+
<span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>
|
|
1891
|
+
</div>`;
|
|
1892
|
+
|
|
1893
|
+
return `<div class="tp-trend-bars">${gridLines}${barsHTML}</div>`;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// ── Model ranking sidebar ──
|
|
1897
|
+
function buildModelRank(mt, totalAll) {
|
|
1898
|
+
const sorted = Object.entries(mt).sort((a, b) => {
|
|
1899
|
+
const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
|
|
1900
|
+
const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
|
|
1901
|
+
return sB - sA;
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
let html = '<div class="tp-rank-title">🏆 Model Ranking</div>';
|
|
1905
|
+
for (let i = 0; i < Math.min(sorted.length, 5); i++) {
|
|
1906
|
+
const [name, m] = sorted[i];
|
|
1907
|
+
const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
|
|
1908
|
+
const pct = totalAll > 0 ? (mTotal / totalAll * 100).toFixed(1) : '0';
|
|
1909
|
+
const c = modelColor(name);
|
|
1910
|
+
html += `<div class="tp-rank-item">
|
|
1911
|
+
<span class="tp-rank-num">${i + 1}</span>
|
|
1912
|
+
<span class="tp-rank-dot" style="background:${c}"></span>
|
|
1913
|
+
<span class="tp-rank-name">${esc(name)}</span>
|
|
1914
|
+
<span class="tp-rank-pct">${pct}%</span>
|
|
1915
|
+
</div>`;
|
|
1916
|
+
}
|
|
1917
|
+
return html;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// ── Render entire token page ──
|
|
1703
1921
|
function renderTokenPage() {
|
|
1704
|
-
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1922
|
+
const t = tokenStatsData.totals;
|
|
1923
|
+
const mt = tokenStatsData.modelTotals;
|
|
1924
|
+
const daily = tokenStatsData.daily;
|
|
1925
|
+
const totalAll = t.input + t.output + t.cacheCreation + t.cacheRead;
|
|
1926
|
+
|
|
1927
|
+
if (totalAll === 0) {
|
|
1928
|
+
document.getElementById('tp-total-card').innerHTML = '<div style="color:var(--dim);padding:8px">暂无历史 Token 数据</div>';
|
|
1929
|
+
document.getElementById('tp-stats-grid').innerHTML = '';
|
|
1930
|
+
document.getElementById('tp-model-rank').innerHTML = '';
|
|
1931
|
+
document.getElementById('tp-trend-card').innerHTML = '';
|
|
1932
|
+
document.getElementById('tp-heatmap-card').innerHTML = '';
|
|
1933
|
+
document.getElementById('tp-detail-tabs').innerHTML = '';
|
|
1934
|
+
document.getElementById('tp-daily-table').innerHTML = '';
|
|
1935
|
+
document.getElementById('tp-weekly-table').innerHTML = '';
|
|
1936
|
+
document.getElementById('tp-monthly-table').innerHTML = '';
|
|
1710
1937
|
return;
|
|
1711
1938
|
}
|
|
1712
1939
|
|
|
1713
|
-
|
|
1714
|
-
const
|
|
1715
|
-
const
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
const agentCardsEl = document.getElementById('token-agent-cards');
|
|
1728
|
-
// Sort by lastActivity descending (active first)
|
|
1729
|
-
const sorted = entries.sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0));
|
|
1730
|
-
agentCardsEl.innerHTML = sorted.map(([key, ctx]) => {
|
|
1731
|
-
const [sid, agentId] = key.split(':');
|
|
1732
|
-
const isMain = agentId === 'main' || !agentId.includes('-');
|
|
1733
|
-
const icon = isMain ? '🗣' : '🤖';
|
|
1734
|
-
const agentName = isMain ? 'Main' : agentId;
|
|
1735
|
-
const active = ctx.lastActivity && (Date.now() - ctx.lastActivity < 180000);
|
|
1736
|
-
const activeDot = active ? '<span class="token-active-dot">🟢</span>' : '<span class="token-active-dot">⚪</span>';
|
|
1737
|
-
|
|
1738
|
-
const pct = ctx.contextWindow > 0 ? Math.round(ctx.inputTokens / ctx.contextWindow * 100) : 0;
|
|
1739
|
-
const pctCls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
|
|
1740
|
-
const barCls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
|
|
1741
|
-
const barWidth = Math.min(pct, 100);
|
|
1742
|
-
|
|
1743
|
-
const ioRatio = ctx.outputTokens > 0 ? (ctx.inputTokens / ctx.outputTokens).toFixed(1) + ' : 1' : '—';
|
|
1744
|
-
|
|
1745
|
-
return `<div class="token-card">
|
|
1746
|
-
<div class="token-card-title">${icon} ${esc(agentName)} ${ctx.model ? '· ' + esc(ctx.model) : ''} ${activeDot}</div>
|
|
1747
|
-
<div class="token-usage-line">
|
|
1748
|
-
<span class="token-pct ${pctCls}">${pct}%</span>
|
|
1749
|
-
<span class="token-ctx-info">${fmtTok(ctx.inputTokens)} / ${fmtTok(ctx.contextWindow)}</span>
|
|
1750
|
-
</div>
|
|
1751
|
-
<div class="token-bar"><div class="token-bar-fill ${barCls}" style="width:${barWidth}%"></div></div>
|
|
1752
|
-
<div class="token-detail-row">
|
|
1753
|
-
<span class="token-kv">Output: <b>${fmtTok(ctx.outputTokens)}</b></span>
|
|
1754
|
-
<span class="token-kv">Cache+: <b>${fmtTok(ctx.cacheCreation)}</b></span>
|
|
1755
|
-
<span class="token-kv">Cache Read: <b>${fmtTok(ctx.cacheRead)}</b></span>
|
|
1756
|
-
<span class="token-kv">I/O: <b>${ioRatio}</b></span>
|
|
1757
|
-
</div>
|
|
1940
|
+
const inputPct = totalAll > 0 ? (t.input / totalAll * 100).toFixed(1) : '0';
|
|
1941
|
+
const outputPct = totalAll > 0 ? (t.output / totalAll * 100).toFixed(1) : '0';
|
|
1942
|
+
const crPct = totalAll > 0 ? (t.cacheRead / totalAll * 100).toFixed(1) : '0';
|
|
1943
|
+
const ccPct = totalAll > 0 ? (t.cacheCreation / totalAll * 100).toFixed(1) : '0';
|
|
1944
|
+
const dailyAvg = t.days > 0 ? Math.round(totalAll / t.days).toLocaleString() : '—';
|
|
1945
|
+
|
|
1946
|
+
// 1. Total tokens card
|
|
1947
|
+
document.getElementById('tp-total-card').innerHTML = `
|
|
1948
|
+
<div class="tp-total-label">TOTAL TOKENS</div>
|
|
1949
|
+
<div class="tp-total-value">${fmtTS(totalAll)}</div>
|
|
1950
|
+
<div class="tp-footer-stats">
|
|
1951
|
+
<span>Started <span class="tp-fv">${Object.keys(daily).sort()[0] || '—'}</span></span>
|
|
1952
|
+
<span>Active <span class="tp-fv">${t.days} DAY</span></span>
|
|
1953
|
+
<span>Models <span class="tp-fv">${Object.keys(mt).length}</span></span>
|
|
1758
1954
|
</div>`;
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1955
|
+
|
|
1956
|
+
// 2. Stats grid
|
|
1957
|
+
const stats = [
|
|
1958
|
+
{ l: 'Input', v: fmtTS(t.input), s: inputPct + '%' },
|
|
1959
|
+
{ l: 'Output', v: fmtTS(t.output), s: outputPct + '%' },
|
|
1960
|
+
{ l: 'Cache Read', v: fmtTS(t.cacheRead), s: crPct + '%' },
|
|
1961
|
+
{ l: 'Cache Create', v: fmtTS(t.cacheCreation), s: ccPct + '%' },
|
|
1962
|
+
{ l: 'Messages', v: fmtTS(t.messages), s: t.messages.toLocaleString() },
|
|
1963
|
+
{ l: 'Daily Avg', v: dailyAvg, s: 'tokens/day' },
|
|
1964
|
+
];
|
|
1965
|
+
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>`;
|
|
1966
|
+
|
|
1967
|
+
// 3. Model ranking
|
|
1968
|
+
document.getElementById('tp-model-rank').innerHTML = buildModelRank(mt, totalAll);
|
|
1969
|
+
|
|
1970
|
+
// 4. Usage Trend
|
|
1971
|
+
document.getElementById('tp-trend-card').innerHTML = `<div class="tp-h3">📊 Usage Trend</div>${buildTrend(daily)}`;
|
|
1972
|
+
|
|
1973
|
+
// 5. Activity Heatmap
|
|
1974
|
+
const tzOffset = -(new Date().getTimezoneOffset() / 60);
|
|
1975
|
+
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)}`;
|
|
1976
|
+
|
|
1977
|
+
// 6. Detail tabs
|
|
1978
|
+
const dailyKeys = Object.keys(daily);
|
|
1979
|
+
const weeklyCount = weeklyKeysFromDaily(dailyKeys).length;
|
|
1980
|
+
const monthlyCount = monthlyKeysFromDaily(dailyKeys).length;
|
|
1981
|
+
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>`;
|
|
1982
|
+
document.querySelectorAll('.tp-tc').forEach(tc => tc.classList.remove('active'));
|
|
1983
|
+
document.getElementById('tp-tc-' + tsDetailTab)?.classList.add('active');
|
|
1984
|
+
|
|
1985
|
+
document.getElementById('tp-daily-table').innerHTML = renderPeriodTable(dailyKeys, daily, 'daily');
|
|
1986
|
+
const weekly = aggregateWeekly(dailyKeys, daily);
|
|
1987
|
+
document.getElementById('tp-weekly-table').innerHTML = renderPeriodTable(Object.keys(weekly), weekly, 'weekly');
|
|
1988
|
+
const monthly = aggregateMonthly(dailyKeys, daily);
|
|
1989
|
+
document.getElementById('tp-monthly-table').innerHTML = renderPeriodTable(Object.keys(monthly), monthly, 'monthly');
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function weeklyKeysFromDaily(keys) {
|
|
1993
|
+
const weeks = new Set();
|
|
1994
|
+
for (const k of keys) { const d = new Date(k); const wk = getWeekKey(d); weeks.add(wk); }
|
|
1995
|
+
return [...weeks];
|
|
1996
|
+
}
|
|
1997
|
+
function monthlyKeysFromDaily(keys) {
|
|
1998
|
+
const months = new Set();
|
|
1999
|
+
for (const k of keys) { months.add(k.slice(0, 7)); }
|
|
2000
|
+
return [...months];
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
function getWeekKey(d) {
|
|
2004
|
+
const dayNum = d.getDay() || 7; // Sunday (0) becomes 7 for ISO week calculation
|
|
2005
|
+
const thursday = new Date(d);
|
|
2006
|
+
thursday.setDate(d.getDate() + 4 - dayNum); // ISO Thursday offset
|
|
2007
|
+
const year = thursday.getFullYear();
|
|
2008
|
+
const jan1 = new Date(year, 0, 1);
|
|
2009
|
+
const wk = Math.ceil(((thursday - jan1) / 86400000 + jan1.getDay() + 1) / 7);
|
|
2010
|
+
return year + '-W' + String(wk).padStart(2, '0');
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function aggregateWeekly(dailyKeys, daily) {
|
|
2014
|
+
const result = {};
|
|
2015
|
+
for (const k of dailyKeys) {
|
|
2016
|
+
const d = new Date(k);
|
|
2017
|
+
const wk = getWeekKey(d);
|
|
2018
|
+
if (!result[wk]) result[wk] = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {}, dateRange: k };
|
|
2019
|
+
else result[wk].dateRange += ' ~ ' + k;
|
|
2020
|
+
const day = daily[k];
|
|
2021
|
+
result[wk].messages += day.messages;
|
|
2022
|
+
result[wk].input += day.input;
|
|
2023
|
+
result[wk].output += day.output;
|
|
2024
|
+
result[wk].cacheCreation += day.cacheCreation;
|
|
2025
|
+
result[wk].cacheRead += day.cacheRead;
|
|
2026
|
+
for (const [mn, m] of Object.entries(day.models)) {
|
|
2027
|
+
if (!result[wk].models[mn]) result[wk].models[mn] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
2028
|
+
result[wk].models[mn].input += m.input;
|
|
2029
|
+
result[wk].models[mn].output += m.output;
|
|
2030
|
+
result[wk].models[mn].cacheCreation += m.cacheCreation;
|
|
2031
|
+
result[wk].models[mn].cacheRead += m.cacheRead;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
return result;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function aggregateMonthly(dailyKeys, daily) {
|
|
2038
|
+
const result = {};
|
|
2039
|
+
for (const k of dailyKeys) {
|
|
2040
|
+
const mk = k.slice(0, 7);
|
|
2041
|
+
if (!result[mk]) result[mk] = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {}, dateRange: k };
|
|
2042
|
+
else result[mk].dateRange += ' ~ ' + k.slice(5);
|
|
2043
|
+
const day = daily[k];
|
|
2044
|
+
result[mk].messages += day.messages;
|
|
2045
|
+
result[mk].input += day.input;
|
|
2046
|
+
result[mk].output += day.output;
|
|
2047
|
+
result[mk].cacheCreation += day.cacheCreation;
|
|
2048
|
+
result[mk].cacheRead += day.cacheRead;
|
|
2049
|
+
for (const [mn, m] of Object.entries(day.models)) {
|
|
2050
|
+
if (!result[mk].models[mn]) result[mk].models[mn] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
2051
|
+
result[mk].models[mn].input += m.input;
|
|
2052
|
+
result[mk].models[mn].output += m.output;
|
|
2053
|
+
result[mk].models[mn].cacheCreation += m.cacheCreation;
|
|
2054
|
+
result[mk].models[mn].cacheRead += m.cacheRead;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
return result;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function renderPeriodTable(keys, data, type) {
|
|
2061
|
+
const sorted = keys.sort((a, b) => b.localeCompare(a));
|
|
2062
|
+
let html = '<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>';
|
|
2063
|
+
for (const k of sorted) {
|
|
2064
|
+
const d = data[k];
|
|
2065
|
+
const total = d.input + d.output + d.cacheCreation + d.cacheRead;
|
|
2066
|
+
const label = type === 'daily' ? k : k + '<br><small style="color:var(--dim)">' + esc(d.dateRange || '') + '</small>';
|
|
2067
|
+
const modelsHtml = Object.entries(d.models).sort((a, b) => {
|
|
2068
|
+
const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
|
|
2069
|
+
const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
|
|
2070
|
+
return sB - sA;
|
|
2071
|
+
}).slice(0, 4).map(([mn, m]) => {
|
|
2072
|
+
const mT = m.input + m.output + m.cacheCreation + m.cacheRead;
|
|
2073
|
+
const c = modelColor(mn);
|
|
2074
|
+
return `<span class="tp-mtag" style="background:${c}20;border-color:${c};color:${c}">${esc(mn)}: ${fmtTS(mT)}</span>`;
|
|
2075
|
+
}).join(' ');
|
|
2076
|
+
html += `<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>`;
|
|
2077
|
+
}
|
|
2078
|
+
html += '</tbody></table>';
|
|
2079
|
+
return html;
|
|
1780
2080
|
}
|
|
1781
2081
|
function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
|
|
1782
2082
|
function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var fs = require('fs');
|
|
4
|
+
var path = require('path');
|
|
5
|
+
var os = require('os');
|
|
6
|
+
var readline = require('readline');
|
|
7
|
+
|
|
8
|
+
// ── Walk directory recursively ──
|
|
9
|
+
async function walkDir(dir, callback) {
|
|
10
|
+
try {
|
|
11
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const fullPath = path.join(dir, entry.name);
|
|
14
|
+
if (entry.isDirectory()) {
|
|
15
|
+
await walkDir(fullPath, callback);
|
|
16
|
+
} else {
|
|
17
|
+
try {
|
|
18
|
+
const stats = await fs.promises.stat(fullPath);
|
|
19
|
+
callback(fullPath, stats);
|
|
20
|
+
} catch { /* skip */ }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
|
|
25
|
+
console.error('[scanner] walkDir error on ' + dir + ': ' + err.message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isJsonlFile(filePath, stats) {
|
|
31
|
+
if (!stats.isFile()) return false;
|
|
32
|
+
return path.extname(filePath) === '.jsonl';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Get Claude projects directory ──
|
|
36
|
+
function getClaudeDir() {
|
|
37
|
+
return path.join(os.homedir(), '.claude', 'projects');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Full-scan all JSONL files under ~/.claude/projects,
|
|
42
|
+
* extract token usage data, aggregate by date.
|
|
43
|
+
* Returns a Map: "YYYY-MM-DD" → { messages, input, output, cacheCreation, cacheRead, models: { modelName: { ... } } }
|
|
44
|
+
*/
|
|
45
|
+
async function fullScanTokenUsage(progressCallback) {
|
|
46
|
+
const claudeDir = getClaudeDir();
|
|
47
|
+
const dailyStats = new Map();
|
|
48
|
+
|
|
49
|
+
// Collect all JSONL files (main + subagent)
|
|
50
|
+
const jsonlFiles = [];
|
|
51
|
+
await walkDir(claudeDir, (filePath, stats) => {
|
|
52
|
+
if (isJsonlFile(filePath, stats)) {
|
|
53
|
+
jsonlFiles.push(filePath);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (progressCallback) progressCallback(0, jsonlFiles.length);
|
|
58
|
+
|
|
59
|
+
// Process each file
|
|
60
|
+
for (let i = 0; i < jsonlFiles.length; i++) {
|
|
61
|
+
await scanOneFile(jsonlFiles[i], dailyStats);
|
|
62
|
+
if (progressCallback && (i % 50 === 0 || i === jsonlFiles.length - 1)) {
|
|
63
|
+
progressCallback(i + 1, jsonlFiles.length);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return dailyStats;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function scanOneFile(filePath, dailyStats) {
|
|
71
|
+
let input, rl;
|
|
72
|
+
try {
|
|
73
|
+
input = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
74
|
+
rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for await (const line of rl) {
|
|
80
|
+
// Fast pre-filter: only parse lines containing "usage"
|
|
81
|
+
if (!line.includes('"usage"')) continue;
|
|
82
|
+
// Also need model for per-model breakdown
|
|
83
|
+
const hasModel = line.includes('"model"');
|
|
84
|
+
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(line);
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const msg = raw.message;
|
|
93
|
+
if (!msg) continue;
|
|
94
|
+
|
|
95
|
+
// Extract timestamp — required for date-based aggregation
|
|
96
|
+
let ts;
|
|
97
|
+
if (raw.timestamp) {
|
|
98
|
+
ts = new Date(raw.timestamp);
|
|
99
|
+
}
|
|
100
|
+
if (!raw.timestamp || isNaN(ts.getTime())) {
|
|
101
|
+
// Skip lines without valid timestamps — can't determine which day they belong to
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const dateStr = ts.getFullYear() + '-' + String(ts.getMonth() + 1).padStart(2, '0') + '-' + String(ts.getDate()).padStart(2, '0');
|
|
105
|
+
|
|
106
|
+
// Extract usage
|
|
107
|
+
const usage = msg.usage;
|
|
108
|
+
if (!usage) continue;
|
|
109
|
+
|
|
110
|
+
const inputTokens = usage.input_tokens || 0;
|
|
111
|
+
const outputTokens = usage.output_tokens || 0;
|
|
112
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
113
|
+
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
114
|
+
|
|
115
|
+
if (!inputTokens && !outputTokens && !cacheCreationTokens && !cacheReadTokens) continue;
|
|
116
|
+
|
|
117
|
+
// Get or create day entry
|
|
118
|
+
let day = dailyStats.get(dateStr);
|
|
119
|
+
if (!day) {
|
|
120
|
+
day = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {} };
|
|
121
|
+
dailyStats.set(dateStr, day);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
day.messages++;
|
|
125
|
+
day.input += inputTokens;
|
|
126
|
+
day.output += outputTokens;
|
|
127
|
+
day.cacheCreation += cacheCreationTokens;
|
|
128
|
+
day.cacheRead += cacheReadTokens;
|
|
129
|
+
|
|
130
|
+
// Per-model breakdown
|
|
131
|
+
const model = (hasModel && msg.model && msg.model !== '<synthetic>') ? msg.model : '';
|
|
132
|
+
if (model) {
|
|
133
|
+
let m = day.models[model];
|
|
134
|
+
if (!m) {
|
|
135
|
+
m = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
136
|
+
day.models[model] = m;
|
|
137
|
+
}
|
|
138
|
+
m.input += inputTokens;
|
|
139
|
+
m.output += outputTokens;
|
|
140
|
+
m.cacheCreation += cacheCreationTokens;
|
|
141
|
+
m.cacheRead += cacheReadTokens;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { fullScanTokenUsage, getClaudeDir };
|
package/src/server/server.js
CHANGED
|
@@ -9,6 +9,7 @@ var readline = require('readline');
|
|
|
9
9
|
var { WebSocketServer } = require('ws');
|
|
10
10
|
var { Watcher, listSessions, listActiveSessions } = require('../watcher/watcher');
|
|
11
11
|
var { setDebugAll, contextWindowFor } = require('../parser/parser');
|
|
12
|
+
var { fullScanTokenUsage } = require('../scanner/scanner');
|
|
12
13
|
|
|
13
14
|
var PACKAGE_VERSION = require('../../package.json').version;
|
|
14
15
|
|
|
@@ -37,6 +38,11 @@ class DashboardServer {
|
|
|
37
38
|
this._contextCleanupTimer = null;
|
|
38
39
|
this._pendingItems = [];
|
|
39
40
|
this._flushTimer = null;
|
|
41
|
+
this._tokenStatsDirty = false;
|
|
42
|
+
|
|
43
|
+
// Time-series token stats: daily aggregation (never cleaned up)
|
|
44
|
+
// Key: "YYYY-MM-DD", value: { messages, input, output, cacheCreation, cacheRead, models: { modelName: { input, output, cacheCreation, cacheRead } } }
|
|
45
|
+
this.dailyStats = new Map();
|
|
40
46
|
|
|
41
47
|
this.server = null;
|
|
42
48
|
this.wss = null;
|
|
@@ -67,6 +73,12 @@ class DashboardServer {
|
|
|
67
73
|
return Date.now();
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
_getDateKey(ts) {
|
|
77
|
+
let d = new Date(ts);
|
|
78
|
+
if (isNaN(d.getTime())) d = new Date();
|
|
79
|
+
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
updateContext(item) {
|
|
71
83
|
const key = this.getCtxKey(item.sessionID, item.agentID);
|
|
72
84
|
let ctx = this.contextMap.get(key);
|
|
@@ -85,6 +97,36 @@ class DashboardServer {
|
|
|
85
97
|
ctx.contextWindow = contextWindowFor(item.model);
|
|
86
98
|
}
|
|
87
99
|
ctx.lastActivity = Math.max(ctx.lastActivity || 0, this.itemTime(item));
|
|
100
|
+
|
|
101
|
+
// ── Time-series aggregation for token stats ──
|
|
102
|
+
// All 4 token fields are summed (incremental for billing/consumption perspective)
|
|
103
|
+
const hasTokens = item.inputTokens || item.outputTokens || item.cacheCreationTokens || item.cacheReadTokens;
|
|
104
|
+
if (hasTokens) {
|
|
105
|
+
const dateKey = this._getDateKey(this.itemTime(item));
|
|
106
|
+
let day = this.dailyStats.get(dateKey);
|
|
107
|
+
if (!day) {
|
|
108
|
+
day = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {} };
|
|
109
|
+
this.dailyStats.set(dateKey, day);
|
|
110
|
+
}
|
|
111
|
+
day.messages++;
|
|
112
|
+
if (item.inputTokens) day.input += item.inputTokens;
|
|
113
|
+
if (item.outputTokens) day.output += item.outputTokens;
|
|
114
|
+
if (item.cacheCreationTokens) day.cacheCreation += item.cacheCreationTokens;
|
|
115
|
+
if (item.cacheReadTokens) day.cacheRead += item.cacheReadTokens;
|
|
116
|
+
|
|
117
|
+
// Per-model breakdown within this day
|
|
118
|
+
if (item.model) {
|
|
119
|
+
let m = day.models[item.model];
|
|
120
|
+
if (!m) {
|
|
121
|
+
m = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
122
|
+
day.models[item.model] = m;
|
|
123
|
+
}
|
|
124
|
+
if (item.inputTokens) m.input += item.inputTokens;
|
|
125
|
+
if (item.outputTokens) m.output += item.outputTokens;
|
|
126
|
+
if (item.cacheCreationTokens) m.cacheCreation += item.cacheCreationTokens;
|
|
127
|
+
if (item.cacheReadTokens) m.cacheRead += item.cacheReadTokens;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
88
130
|
}
|
|
89
131
|
|
|
90
132
|
cleanupContextMap() {
|
|
@@ -112,6 +154,47 @@ class DashboardServer {
|
|
|
112
154
|
return result;
|
|
113
155
|
}
|
|
114
156
|
|
|
157
|
+
getTokenStatsSnapshot() {
|
|
158
|
+
// Convert dailyStats Map to plain object, sorted by date descending
|
|
159
|
+
const daily = {};
|
|
160
|
+
const sortedKeys = [...this.dailyStats.keys()].sort().reverse();
|
|
161
|
+
for (const k of sortedKeys) {
|
|
162
|
+
const d = this.dailyStats.get(k);
|
|
163
|
+
daily[k] = {
|
|
164
|
+
messages: d.messages,
|
|
165
|
+
input: d.input,
|
|
166
|
+
output: d.output,
|
|
167
|
+
cacheCreation: d.cacheCreation,
|
|
168
|
+
cacheRead: d.cacheRead,
|
|
169
|
+
models: d.models,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Compute global totals
|
|
174
|
+
let totalMessages = 0, totalInput = 0, totalOutput = 0, totalCacheCreation = 0, totalCacheRead = 0;
|
|
175
|
+
const modelTotals = {};
|
|
176
|
+
for (const [, d] of this.dailyStats) {
|
|
177
|
+
totalMessages += d.messages;
|
|
178
|
+
totalInput += d.input;
|
|
179
|
+
totalOutput += d.output;
|
|
180
|
+
totalCacheCreation += d.cacheCreation;
|
|
181
|
+
totalCacheRead += d.cacheRead;
|
|
182
|
+
for (const [modelName, m] of Object.entries(d.models)) {
|
|
183
|
+
if (!modelTotals[modelName]) modelTotals[modelName] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
|
|
184
|
+
modelTotals[modelName].input += m.input;
|
|
185
|
+
modelTotals[modelName].output += m.output;
|
|
186
|
+
modelTotals[modelName].cacheCreation += m.cacheCreation;
|
|
187
|
+
modelTotals[modelName].cacheRead += m.cacheRead;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
totals: { messages: totalMessages, input: totalInput, output: totalOutput, cacheCreation: totalCacheCreation, cacheRead: totalCacheRead, days: this.dailyStats.size },
|
|
193
|
+
modelTotals,
|
|
194
|
+
daily,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
115
198
|
broadcast(type, payload) {
|
|
116
199
|
const msg = JSON.stringify({ type, payload });
|
|
117
200
|
const toRemove = [];
|
|
@@ -207,6 +290,11 @@ class DashboardServer {
|
|
|
207
290
|
return;
|
|
208
291
|
}
|
|
209
292
|
|
|
293
|
+
if (route === '/token-stats') {
|
|
294
|
+
this.sendJSON(res, this.getTokenStatsSnapshot());
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
210
298
|
if (route === '/task-output') {
|
|
211
299
|
const filePath = params.get('path');
|
|
212
300
|
if (!filePath) { this.sendJSON(res, { error: 'Missing path param' }, 400); return; }
|
|
@@ -262,6 +350,7 @@ class DashboardServer {
|
|
|
262
350
|
this.sendSnapshot(ws);
|
|
263
351
|
this.sendItemBatch(ws);
|
|
264
352
|
this.sendContext(ws);
|
|
353
|
+
this.sendTokenStats(ws);
|
|
265
354
|
this.sendConfig(ws);
|
|
266
355
|
}
|
|
267
356
|
|
|
@@ -294,6 +383,10 @@ class DashboardServer {
|
|
|
294
383
|
try { ws.send(JSON.stringify({ type, payload })); } catch {}
|
|
295
384
|
}
|
|
296
385
|
|
|
386
|
+
sendTokenStats(ws) {
|
|
387
|
+
this.send(ws, 'tokenStats', this.getTokenStatsSnapshot());
|
|
388
|
+
}
|
|
389
|
+
|
|
297
390
|
sendSnapshot(ws) {
|
|
298
391
|
if (!this.watcher) return;
|
|
299
392
|
const sessions = this.watcher.getSessionsSnapshot().map(s => ({
|
|
@@ -359,12 +452,20 @@ class DashboardServer {
|
|
|
359
452
|
}
|
|
360
453
|
this.updateContext(item);
|
|
361
454
|
this._pendingItems.push(item);
|
|
455
|
+
// Track if any item in this batch has token data (for tokenStats broadcast)
|
|
456
|
+
if (item.inputTokens || item.outputTokens || item.cacheCreationTokens || item.cacheReadTokens) {
|
|
457
|
+
this._tokenStatsDirty = true;
|
|
458
|
+
}
|
|
362
459
|
if (this._pendingItems.length >= FLUSH_BATCH_LIMIT) {
|
|
363
460
|
// Batch size hit limit — flush immediately
|
|
364
461
|
if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; }
|
|
365
462
|
const batch = this._pendingItems;
|
|
366
463
|
this._pendingItems = [];
|
|
367
464
|
this.broadcast('itemBatch', batch);
|
|
465
|
+
if (this._tokenStatsDirty) {
|
|
466
|
+
this._tokenStatsDirty = false;
|
|
467
|
+
this.broadcast('tokenStats', this.getTokenStatsSnapshot());
|
|
468
|
+
}
|
|
368
469
|
} else if (!this._flushTimer) {
|
|
369
470
|
this._flushTimer = setTimeout(() => {
|
|
370
471
|
this._flushTimer = null;
|
|
@@ -375,6 +476,10 @@ class DashboardServer {
|
|
|
375
476
|
} else if (batch.length > 1) {
|
|
376
477
|
this.broadcast('itemBatch', batch);
|
|
377
478
|
}
|
|
479
|
+
if (this._tokenStatsDirty) {
|
|
480
|
+
this._tokenStatsDirty = false;
|
|
481
|
+
this.broadcast('tokenStats', this.getTokenStatsSnapshot());
|
|
482
|
+
}
|
|
378
483
|
}, 50);
|
|
379
484
|
}
|
|
380
485
|
});
|
|
@@ -497,6 +602,26 @@ class DashboardServer {
|
|
|
497
602
|
}
|
|
498
603
|
});
|
|
499
604
|
|
|
605
|
+
// ── Full-scan historical JSONL files for token stats ──
|
|
606
|
+
// This runs BEFORE watcher starts, scanning ALL files regardless of age
|
|
607
|
+
console.log(' Scanning historical token data...');
|
|
608
|
+
try {
|
|
609
|
+
const scannedDaily = await fullScanTokenUsage((done, total) => {
|
|
610
|
+
if (total > 0 && (done % 100 === 0 || done === total)) {
|
|
611
|
+
console.log(` Scanned ${done}/${total} files...`);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
// Merge scanned data into this.dailyStats
|
|
615
|
+
for (const [dateStr, day] of scannedDaily) {
|
|
616
|
+
this.dailyStats.set(dateStr, day);
|
|
617
|
+
}
|
|
618
|
+
const totalDays = this.dailyStats.size;
|
|
619
|
+
const totalMsgs = [...this.dailyStats.values()].reduce((s, d) => s + d.messages, 0);
|
|
620
|
+
console.log(` Token scan complete: ${totalDays} days, ${totalMsgs.toLocaleString()} messages`);
|
|
621
|
+
} catch (err) {
|
|
622
|
+
console.error(' Token scan error (non-critical, continuing):', err.message);
|
|
623
|
+
}
|
|
624
|
+
|
|
500
625
|
const w = this.setupWatcher(watcherOpts);
|
|
501
626
|
|
|
502
627
|
try {
|
|
@@ -581,4 +706,4 @@ function askYesNo(prompt) {
|
|
|
581
706
|
});
|
|
582
707
|
}
|
|
583
708
|
|
|
584
|
-
module.exports = { DashboardServer, startServer };
|
|
709
|
+
module.exports = { DashboardServer, startServer };
|