claude-usage-dashboard 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/package.json +2 -2
- package/public/css/style.css +22 -0
- package/public/index.html +15 -0
- package/public/js/api.js +1 -0
- package/public/js/app.js +23 -1
- package/public/js/charts/quota-cycles.js +209 -0
- package/server/quota-cycles.js +274 -0
- package/server/routes/api.js +34 -0
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/claude-usage-dashboard)
|
|
4
4
|
[](https://www.npmjs.com/package/claude-usage-dashboard)
|
|
5
5
|
|
|
6
|
-
> Find out what your Claude Code subscription is actually worth in API costs — across every machine you own.
|
|
6
|
+
> Find out what your Claude Code subscription is actually worth in API costs — and whether your quota is shrinking — across every machine you own.
|
|
7
7
|
|
|
8
|
-
Your $200/month Max plan might be consuming **$15,000+/month** in API-equivalent value. This dashboard shows you exactly how much — per project, per session, per model, and across all your machines in one unified view. One command to start. Completely local.
|
|
8
|
+
Your $200/month Max plan might be consuming **$15,000+/month** in API-equivalent value. This dashboard shows you exactly how much — per project, per session, per model, and across all your machines in one unified view. It also tracks your 7-day quota cycles, projects token usage at full utilization, and keeps history so you can spot if your effective quota quietly changed. One command to start. Completely local.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
npx claude-usage-dashboard
|
|
@@ -29,6 +29,14 @@ Real-time projected API cost at your current usage rate — weekly and monthly.
|
|
|
29
29
|
|
|
30
30
|
Live utilization gauges for 5-hour, 7-day, and per-model quotas pulled directly from the Anthropic API. Auto-detects your plan tier (Pro / Max 5x / Max 20x). Never get throttled by surprise again.
|
|
31
31
|
|
|
32
|
+
### Track Your Quota Across Cycles
|
|
33
|
+
|
|
34
|
+
Anthropic doesn't publish exact token limits, and those limits can change without notice. The Quota Cycle History section gives you a running record of each 7-day cycle so you can see exactly how much you got and whether it stayed consistent.
|
|
35
|
+
|
|
36
|
+
For each cycle, the dashboard projects what your full token quota would be at 100% utilization — broken down by Input, Output, Cache Read, Cache Write, and Total. A horizontal bar chart overlays actual vs. projected per cycle, and the history table highlights projected tokens and cost with a delta column showing the change versus the previous cycle.
|
|
37
|
+
|
|
38
|
+
That delta is the key signal: if your projected quota drops from one cycle to the next without you changing how you work, something changed on the platform side. Track your actual consumption week-over-week so you have real numbers when something feels off — not just a gut feeling. Up to 10 past cycles are retained across all your machines.
|
|
39
|
+
|
|
32
40
|
### Find What's Eating Your Tokens
|
|
33
41
|
|
|
34
42
|
Per-project and per-session cost breakdowns show exactly where your usage goes. Sortable session table with cost, duration, and full token breakdown. Spot the expensive sessions instantly.
|
|
@@ -39,7 +47,7 @@ You'll probably discover that ~95% of your tokens are cache reads at 1/10th the
|
|
|
39
47
|
|
|
40
48
|
### Everything Else
|
|
41
49
|
|
|
42
|
-
**Multi-machine sync** — aggregate usage across all your devices via a shared folder · Hourly/daily/weekly/monthly token trends · Dollar and token toggle · Model distribution across Opus/Sonnet/Haiku · Active hours heatmap · Auto-refresh (30s) · Persistent filters via localStorage · Dark theme
|
|
50
|
+
**Multi-machine sync** — aggregate usage across all your devices via a shared folder · **Quota cycle tracking** — monitors each 7-day reset window, projects usage at 100% utilization, logs history to detect quota changes · Hourly/daily/weekly/monthly token trends · Dollar and token toggle · Model distribution across Opus/Sonnet/Haiku · Active hours heatmap · Auto-refresh (30s) · Persistent filters via localStorage · Dark theme
|
|
43
51
|
|
|
44
52
|
## Quick Start
|
|
45
53
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-dashboard",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Claude Code usage dashboard
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Claude Code usage dashboard — token costs, quota cycle tracking, cache efficiency, multi-machine sync across all your devices",
|
|
5
5
|
"main": "server/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-usage-dashboard": "bin/cli.cjs"
|
package/public/css/style.css
CHANGED
|
@@ -259,7 +259,29 @@ body {
|
|
|
259
259
|
}
|
|
260
260
|
#quota-section .chart-container { min-height: auto; }
|
|
261
261
|
|
|
262
|
+
.delta-positive { color: var(--green); }
|
|
263
|
+
.delta-negative { color: var(--red); }
|
|
264
|
+
.table-container th.col-highlight,
|
|
265
|
+
.table-container td.col-highlight { color: var(--amber); font-weight: 600; }
|
|
266
|
+
|
|
267
|
+
.cycle-summary {
|
|
268
|
+
font-size: 12px;
|
|
269
|
+
font-weight: 400;
|
|
270
|
+
color: var(--amber);
|
|
271
|
+
margin-left: 8px;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.quota-cycles-layout {
|
|
275
|
+
display: grid;
|
|
276
|
+
grid-template-columns: 300px 1fr;
|
|
277
|
+
gap: 12px;
|
|
278
|
+
align-items: start;
|
|
279
|
+
}
|
|
280
|
+
.quota-cycles-chart { min-height: 160px; position: relative; }
|
|
281
|
+
.quota-cycles-table-wrap { overflow-x: auto; }
|
|
282
|
+
|
|
262
283
|
@media (max-width: 768px) {
|
|
263
284
|
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
|
264
285
|
.chart-row-3 { grid-template-columns: 1fr; }
|
|
286
|
+
.quota-cycles-layout { grid-template-columns: 1fr; }
|
|
265
287
|
}
|
package/public/index.html
CHANGED
|
@@ -50,6 +50,21 @@
|
|
|
50
50
|
<div id="chart-quota" class="chart-container"></div>
|
|
51
51
|
</section>
|
|
52
52
|
|
|
53
|
+
<section class="chart-section" id="quota-cycles-section">
|
|
54
|
+
<div class="chart-header">
|
|
55
|
+
<h2>Quota Cycle History <span id="cycle-projection-summary" class="cycle-summary"></span></h2>
|
|
56
|
+
<div class="granularity-toggle" id="cycle-model-toggle">
|
|
57
|
+
<button data-cycle-model="overall" class="active">Overall</button>
|
|
58
|
+
<button data-cycle-model="opus">Opus</button>
|
|
59
|
+
<button data-cycle-model="sonnet">Sonnet</button>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="quota-cycles-layout">
|
|
63
|
+
<div id="chart-quota-cycles" class="quota-cycles-chart"></div>
|
|
64
|
+
<div id="quota-cycles-table" class="table-container quota-cycles-table-wrap"></div>
|
|
65
|
+
</div>
|
|
66
|
+
</section>
|
|
67
|
+
|
|
53
68
|
<section class="chart-section">
|
|
54
69
|
<div class="chart-header">
|
|
55
70
|
<h2>Token Consumption Trend</h2>
|
package/public/js/api.js
CHANGED
|
@@ -14,3 +14,4 @@ export async function fetchCache(params = {}) { return (await fetch(`${BASE}/cac
|
|
|
14
14
|
export async function fetchStatus() { return (await fetch(`${BASE}/status`)).json(); }
|
|
15
15
|
export async function fetchQuota() { return (await fetch(`${BASE}/quota`)).json(); }
|
|
16
16
|
export async function fetchSubscription() { return (await fetch(`${BASE}/subscription`)).json(); }
|
|
17
|
+
export async function fetchQuotaCycles() { return (await fetch(`${BASE}/quota-cycles`)).json(); }
|
package/public/js/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchUsage, fetchModels, fetchProjects, fetchSessions, fetchCost, fetchCache, fetchStatus, fetchQuota, fetchSubscription } from './api.js';
|
|
1
|
+
import { fetchUsage, fetchModels, fetchProjects, fetchSessions, fetchCost, fetchCache, fetchStatus, fetchQuota, fetchSubscription, fetchQuotaCycles } from './api.js';
|
|
2
2
|
import { initDatePicker } from './components/date-picker.js';
|
|
3
3
|
import { initPlanSelector } from './components/plan-selector.js';
|
|
4
4
|
import { renderTokenTrend } from './charts/token-trend.js';
|
|
@@ -8,6 +8,7 @@ import { renderCacheEfficiency } from './charts/cache-efficiency.js';
|
|
|
8
8
|
import { renderProjectDistribution } from './charts/project-distribution.js';
|
|
9
9
|
import { renderSessionTable } from './charts/session-stats.js';
|
|
10
10
|
import { renderQuotaGauges } from './charts/quota-gauge.js';
|
|
11
|
+
import { renderQuotaCycles } from './charts/quota-cycles.js';
|
|
11
12
|
|
|
12
13
|
const state = {
|
|
13
14
|
dateRange: { from: null, to: null },
|
|
@@ -19,6 +20,7 @@ const state = {
|
|
|
19
20
|
sessionPage: 1,
|
|
20
21
|
sessionProject: '',
|
|
21
22
|
autoRefresh: localStorage.getItem('autoRefresh') !== 'false',
|
|
23
|
+
cycleModel: 'overall',
|
|
22
24
|
autoRefreshInterval: 30,
|
|
23
25
|
_refreshTimer: null,
|
|
24
26
|
quotaRefreshInterval: 120,
|
|
@@ -83,6 +85,16 @@ async function loadQuota() {
|
|
|
83
85
|
});
|
|
84
86
|
const el = document.getElementById('quota-last-updated');
|
|
85
87
|
if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()} ${getTimezoneAbbr()}`;
|
|
88
|
+
loadQuotaCyclesData();
|
|
89
|
+
} catch { /* silently degrade */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function loadQuotaCyclesData() {
|
|
93
|
+
try {
|
|
94
|
+
const data = await fetchQuotaCycles();
|
|
95
|
+
renderQuotaCycles(document.getElementById('chart-quota-cycles'), data, {
|
|
96
|
+
modelKey: state.cycleModel,
|
|
97
|
+
});
|
|
86
98
|
} catch { /* silently degrade */ }
|
|
87
99
|
}
|
|
88
100
|
|
|
@@ -273,6 +285,16 @@ function init() {
|
|
|
273
285
|
}
|
|
274
286
|
});
|
|
275
287
|
|
|
288
|
+
document.getElementById('cycle-model-toggle').addEventListener('click', (e) => {
|
|
289
|
+
if (e.target.tagName === 'BUTTON') {
|
|
290
|
+
state.cycleModel = e.target.dataset.cycleModel;
|
|
291
|
+
document.querySelectorAll('#cycle-model-toggle button').forEach(btn => {
|
|
292
|
+
btn.classList.toggle('active', btn.dataset.cycleModel === state.cycleModel);
|
|
293
|
+
});
|
|
294
|
+
loadQuotaCyclesData();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
276
298
|
// Auto-detect subscription tier
|
|
277
299
|
fetchSubscription().then(info => {
|
|
278
300
|
if (info.plan) {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
const fmt = (n) => {
|
|
2
|
+
if (n == null) return '—';
|
|
3
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
4
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
|
|
5
|
+
return n.toString();
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const fmtCost = (n) => n == null ? '—' : `$${n.toFixed(2)}`;
|
|
9
|
+
|
|
10
|
+
const fmtDate = (iso) => {
|
|
11
|
+
const d = new Date(iso);
|
|
12
|
+
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function getModelData(cycle, modelKey) {
|
|
16
|
+
if (modelKey === 'overall') return cycle.overall;
|
|
17
|
+
return cycle.models?.[modelKey] || { utilization: 0, actualTokens: 0, projectedTokensAt100: null, actualCost: 0, projectedCostAt100: null, tokens: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_DISPLAY_CYCLES = 10;
|
|
21
|
+
|
|
22
|
+
export function renderQuotaCycles(container, data, { modelKey = 'overall' } = {}) {
|
|
23
|
+
if (!container) return;
|
|
24
|
+
|
|
25
|
+
const hasModelData = data.currentCycle &&
|
|
26
|
+
((data.currentCycle.models?.opus?.utilization > 0) ||
|
|
27
|
+
(data.currentCycle.models?.sonnet?.utilization > 0));
|
|
28
|
+
|
|
29
|
+
// --- Model toggle: grey out when no data ---
|
|
30
|
+
const toggleEl = document.getElementById('cycle-model-toggle');
|
|
31
|
+
if (toggleEl) {
|
|
32
|
+
toggleEl.querySelectorAll('button[data-cycle-model="opus"], button[data-cycle-model="sonnet"]').forEach(btn => {
|
|
33
|
+
btn.disabled = !hasModelData;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- Inline projection summary in header ---
|
|
38
|
+
const summaryEl = document.getElementById('cycle-projection-summary');
|
|
39
|
+
if (summaryEl) {
|
|
40
|
+
summaryEl.textContent = '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- Bar Chart (compact) ---
|
|
44
|
+
container.innerHTML = '';
|
|
45
|
+
|
|
46
|
+
const allCycles = [];
|
|
47
|
+
if (data.history) allCycles.push(...[...data.history].reverse());
|
|
48
|
+
if (data.currentCycle) allCycles.push(data.currentCycle);
|
|
49
|
+
|
|
50
|
+
if (allCycles.length === 0) {
|
|
51
|
+
container.innerHTML = '<div style="color:#64748b;text-align:center;padding:20px;font-size:12px">No cycle data yet.</div>';
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const displayAll = allCycles.slice(-MAX_DISPLAY_CYCLES);
|
|
56
|
+
|
|
57
|
+
const chartData = displayAll.map(c => {
|
|
58
|
+
const d = getModelData(c, modelKey);
|
|
59
|
+
return {
|
|
60
|
+
label: `${fmtDate(c.start)}–${fmtDate(c.resets_at)}`,
|
|
61
|
+
actual: d.actualTokens,
|
|
62
|
+
projected: d.projectedTokensAt100,
|
|
63
|
+
isCurrent: c === data.currentCycle,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Horizontal bar chart
|
|
68
|
+
const rowHeight = 28;
|
|
69
|
+
const margin = { top: 20, right: 50, bottom: 6, left: 70 };
|
|
70
|
+
const width = container.clientWidth - margin.left - margin.right;
|
|
71
|
+
const height = chartData.length * rowHeight;
|
|
72
|
+
|
|
73
|
+
const svg = d3.select(container).append('svg')
|
|
74
|
+
.attr('width', width + margin.left + margin.right)
|
|
75
|
+
.attr('height', height + margin.top + margin.bottom)
|
|
76
|
+
.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
|
|
77
|
+
|
|
78
|
+
const y0 = d3.scaleBand().domain(chartData.map(d => d.label)).range([0, height]).padding(0.25);
|
|
79
|
+
const maxVal = d3.max(chartData, d => Math.max(d.actual, d.projected || 0)) || 1;
|
|
80
|
+
const x = d3.scaleLinear().domain([0, maxVal * 1.1]).range([0, width]);
|
|
81
|
+
|
|
82
|
+
// Axes
|
|
83
|
+
svg.append('g')
|
|
84
|
+
.call(d3.axisLeft(y0).tickSize(0))
|
|
85
|
+
.selectAll('text').attr('fill', '#94a3b8').style('font-size', '9px');
|
|
86
|
+
svg.append('g').attr('transform', `translate(0,${height})`)
|
|
87
|
+
.call(d3.axisBottom(x).ticks(4).tickFormat(d => fmt(d)))
|
|
88
|
+
.selectAll('text').attr('fill', '#94a3b8').style('font-size', '9px');
|
|
89
|
+
svg.selectAll('.domain, .tick line').attr('stroke', '#334155');
|
|
90
|
+
|
|
91
|
+
const barH = Math.min(y0.bandwidth() / 2.5, 12);
|
|
92
|
+
|
|
93
|
+
// Projected bars (behind, semi-transparent)
|
|
94
|
+
svg.selectAll('.bar-projected').data(chartData.filter(d => d.projected != null))
|
|
95
|
+
.join('rect').attr('class', 'bar-projected')
|
|
96
|
+
.attr('x', 0)
|
|
97
|
+
.attr('width', d => x(d.projected))
|
|
98
|
+
.attr('y', d => y0(d.label) + y0.bandwidth() / 2 - barH)
|
|
99
|
+
.attr('height', barH * 2)
|
|
100
|
+
.attr('fill', '#f59e0b').attr('opacity', 0.2)
|
|
101
|
+
.attr('rx', 2);
|
|
102
|
+
|
|
103
|
+
// Tooltip
|
|
104
|
+
const tooltip = d3.select(container).append('div')
|
|
105
|
+
.attr('class', 'd3-tooltip')
|
|
106
|
+
.style('opacity', 0);
|
|
107
|
+
|
|
108
|
+
function showTip(event, d) {
|
|
109
|
+
const proj = d.projected != null ? fmt(d.projected) : '—';
|
|
110
|
+
const rect = container.getBoundingClientRect();
|
|
111
|
+
tooltip.html(`<strong>${d.label}</strong><br>Actual: ${fmt(d.actual)}<br>Proj@100%: ${proj}`)
|
|
112
|
+
.style('opacity', 1)
|
|
113
|
+
.style('left', (event.clientX - rect.left + 12) + 'px')
|
|
114
|
+
.style('top', (event.clientY - rect.top - 10) + 'px');
|
|
115
|
+
}
|
|
116
|
+
function hideTip() { tooltip.style('opacity', 0); }
|
|
117
|
+
|
|
118
|
+
// Actual bars (front)
|
|
119
|
+
svg.selectAll('.bar-actual').data(chartData)
|
|
120
|
+
.join('rect').attr('class', 'bar-actual')
|
|
121
|
+
.attr('x', 0)
|
|
122
|
+
.attr('width', d => x(d.actual))
|
|
123
|
+
.attr('y', d => y0(d.label) + y0.bandwidth() / 2 - barH / 2)
|
|
124
|
+
.attr('height', barH)
|
|
125
|
+
.attr('fill', d => d.isCurrent ? '#3b82f6' : '#60a5fa')
|
|
126
|
+
.attr('rx', 2);
|
|
127
|
+
|
|
128
|
+
// Hover areas (full row for easy targeting)
|
|
129
|
+
svg.selectAll('.bar-hover').data(chartData)
|
|
130
|
+
.join('rect').attr('class', 'bar-hover')
|
|
131
|
+
.attr('x', 0)
|
|
132
|
+
.attr('width', width)
|
|
133
|
+
.attr('y', d => y0(d.label))
|
|
134
|
+
.attr('height', y0.bandwidth())
|
|
135
|
+
.attr('fill', 'transparent')
|
|
136
|
+
.style('cursor', 'pointer')
|
|
137
|
+
.on('mousemove', showTip)
|
|
138
|
+
.on('mouseleave', hideTip);
|
|
139
|
+
|
|
140
|
+
// Compact legend
|
|
141
|
+
const legend = svg.append('g').attr('transform', `translate(0, -8)`);
|
|
142
|
+
legend.append('rect').attr('width', 8).attr('height', 8).attr('fill', '#60a5fa').attr('rx', 1);
|
|
143
|
+
legend.append('text').attr('x', 10).attr('y', 7).text('Actual').attr('fill', '#64748b').style('font-size', '9px');
|
|
144
|
+
legend.append('rect').attr('x', 50).attr('width', 8).attr('height', 8).attr('fill', '#f59e0b').attr('opacity', 0.4).attr('rx', 1);
|
|
145
|
+
legend.append('text').attr('x', 60).attr('y', 7).text('Proj@100%').attr('fill', '#64748b').style('font-size', '9px');
|
|
146
|
+
|
|
147
|
+
// --- History Table ---
|
|
148
|
+
const tableEl = document.getElementById('quota-cycles-table');
|
|
149
|
+
if (!tableEl) return;
|
|
150
|
+
tableEl.innerHTML = '';
|
|
151
|
+
|
|
152
|
+
const table = document.createElement('table');
|
|
153
|
+
const thead = document.createElement('thead');
|
|
154
|
+
thead.innerHTML = `<tr>
|
|
155
|
+
<th>Cycle</th>
|
|
156
|
+
<th class="align-right">Util%</th>
|
|
157
|
+
<th class="align-right">In</th>
|
|
158
|
+
<th class="align-right">Out</th>
|
|
159
|
+
<th class="align-right">CR</th>
|
|
160
|
+
<th class="align-right">CW</th>
|
|
161
|
+
<th class="align-right">Total</th>
|
|
162
|
+
<th class="align-right">Excl CR</th>
|
|
163
|
+
<th class="align-right">Cost</th>
|
|
164
|
+
<th class="align-right col-highlight">Proj Tokens</th>
|
|
165
|
+
<th class="align-right col-highlight">Proj Cost</th>
|
|
166
|
+
<th class="align-right">\u0394 Prev</th>
|
|
167
|
+
</tr>`;
|
|
168
|
+
table.appendChild(thead);
|
|
169
|
+
|
|
170
|
+
const tbody = document.createElement('tbody');
|
|
171
|
+
const displayCycles = [...displayAll].reverse();
|
|
172
|
+
for (let i = 0; i < displayCycles.length; i++) {
|
|
173
|
+
const c = displayCycles[i];
|
|
174
|
+
const d = getModelData(c, modelKey);
|
|
175
|
+
const t = d.tokens || { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
176
|
+
const totalInclCR = t.input + t.output + t.cacheRead + t.cacheCreation;
|
|
177
|
+
const prev = displayCycles[i + 1] ? getModelData(displayCycles[i + 1], modelKey) : null;
|
|
178
|
+
|
|
179
|
+
let deltaStr = '—';
|
|
180
|
+
let deltaClass = '';
|
|
181
|
+
if (prev && prev.projectedTokensAt100 != null && d.projectedTokensAt100 != null && prev.projectedTokensAt100 > 0) {
|
|
182
|
+
const delta = ((d.projectedTokensAt100 - prev.projectedTokensAt100) / prev.projectedTokensAt100) * 100;
|
|
183
|
+
deltaStr = `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}%`;
|
|
184
|
+
deltaClass = delta >= 0 ? 'delta-positive' : 'delta-negative';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const isCurrent = c === data.currentCycle;
|
|
188
|
+
const label = `${fmtDate(c.start)}–${fmtDate(c.resets_at)}${isCurrent ? ' *' : ''}`;
|
|
189
|
+
|
|
190
|
+
const tr = document.createElement('tr');
|
|
191
|
+
tr.innerHTML = `
|
|
192
|
+
<td>${label}</td>
|
|
193
|
+
<td class="align-right">${d.utilization.toFixed(1)}%</td>
|
|
194
|
+
<td class="align-right">${fmt(t.input)}</td>
|
|
195
|
+
<td class="align-right">${fmt(t.output)}</td>
|
|
196
|
+
<td class="align-right">${fmt(t.cacheRead)}</td>
|
|
197
|
+
<td class="align-right">${fmt(t.cacheCreation)}</td>
|
|
198
|
+
<td class="align-right">${fmt(totalInclCR)}</td>
|
|
199
|
+
<td class="align-right">${fmt(d.actualTokens)}</td>
|
|
200
|
+
<td class="align-right">${fmtCost(d.actualCost)}</td>
|
|
201
|
+
<td class="align-right col-highlight">${fmt(d.projectedTokensAt100)}</td>
|
|
202
|
+
<td class="align-right col-highlight">${fmtCost(d.projectedCostAt100)}</td>
|
|
203
|
+
<td class="align-right ${deltaClass}">${deltaStr}</td>
|
|
204
|
+
`;
|
|
205
|
+
tbody.appendChild(tr);
|
|
206
|
+
}
|
|
207
|
+
table.appendChild(tbody);
|
|
208
|
+
tableEl.appendChild(table);
|
|
209
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { parseLogDirectory } from './parser.js';
|
|
5
|
+
import { filterByDateRange } from './aggregator.js';
|
|
6
|
+
import { calculateRecordCost } from './pricing.js';
|
|
7
|
+
|
|
8
|
+
const MAX_HISTORY = 52;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pure computation: given records (already filtered to a cycle) and quota data,
|
|
12
|
+
* compute actual tokens/cost and project at 100% utilization.
|
|
13
|
+
*/
|
|
14
|
+
export function computeCycleData(records, quotaData) {
|
|
15
|
+
const overallUtil = quotaData.seven_day?.utilization || 0;
|
|
16
|
+
const opusUtil = quotaData.seven_day_opus?.utilization || 0;
|
|
17
|
+
const sonnetUtil = quotaData.seven_day_sonnet?.utilization || 0;
|
|
18
|
+
|
|
19
|
+
// Per-type accumulators
|
|
20
|
+
let inTok = 0, outTok = 0, crTok = 0, cwTok = 0, totalCost = 0;
|
|
21
|
+
let opusIn = 0, opusOut = 0, opusCR = 0, opusCW = 0, opusCost = 0;
|
|
22
|
+
let sonIn = 0, sonOut = 0, sonCR = 0, sonCW = 0, sonnetCost = 0;
|
|
23
|
+
|
|
24
|
+
for (const r of records) {
|
|
25
|
+
const cost = calculateRecordCost(r);
|
|
26
|
+
inTok += r.input_tokens; outTok += r.output_tokens;
|
|
27
|
+
crTok += r.cache_read_tokens; cwTok += r.cache_creation_tokens;
|
|
28
|
+
totalCost += cost;
|
|
29
|
+
|
|
30
|
+
if (r.model?.includes('opus')) {
|
|
31
|
+
opusIn += r.input_tokens; opusOut += r.output_tokens;
|
|
32
|
+
opusCR += r.cache_read_tokens; opusCW += r.cache_creation_tokens;
|
|
33
|
+
opusCost += cost;
|
|
34
|
+
} else if (r.model?.includes('sonnet')) {
|
|
35
|
+
sonIn += r.input_tokens; sonOut += r.output_tokens;
|
|
36
|
+
sonCR += r.cache_read_tokens; sonCW += r.cache_creation_tokens;
|
|
37
|
+
sonnetCost += cost;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
totalCost = Math.round(totalCost * 100) / 100;
|
|
42
|
+
opusCost = Math.round(opusCost * 100) / 100;
|
|
43
|
+
sonnetCost = Math.round(sonnetCost * 100) / 100;
|
|
44
|
+
|
|
45
|
+
function buildTokens(inp, out, cr, cw) {
|
|
46
|
+
return { input: inp, output: out, cacheRead: cr, cacheCreation: cw };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function project(actual, utilization) {
|
|
50
|
+
if (utilization <= 0) return null;
|
|
51
|
+
return Math.round(actual / (utilization / 100));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function projectCost(actual, utilization) {
|
|
55
|
+
if (utilization <= 0) return null;
|
|
56
|
+
return Math.round((actual / (utilization / 100)) * 100) / 100;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// actualTokens = total excluding cache reads (in + out + cw) — used for projections
|
|
60
|
+
const totalExclCR = inTok + outTok + cwTok;
|
|
61
|
+
const opusExclCR = opusIn + opusOut + opusCW;
|
|
62
|
+
const sonExclCR = sonIn + sonOut + sonCW;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
overall: {
|
|
66
|
+
utilization: overallUtil,
|
|
67
|
+
tokens: buildTokens(inTok, outTok, crTok, cwTok),
|
|
68
|
+
actualTokens: totalExclCR,
|
|
69
|
+
projectedTokensAt100: project(totalExclCR, overallUtil),
|
|
70
|
+
actualCost: totalCost,
|
|
71
|
+
projectedCostAt100: projectCost(totalCost, overallUtil),
|
|
72
|
+
},
|
|
73
|
+
models: {
|
|
74
|
+
opus: {
|
|
75
|
+
utilization: opusUtil,
|
|
76
|
+
tokens: buildTokens(opusIn, opusOut, opusCR, opusCW),
|
|
77
|
+
actualTokens: opusExclCR,
|
|
78
|
+
projectedTokensAt100: project(opusExclCR, opusUtil),
|
|
79
|
+
actualCost: opusCost,
|
|
80
|
+
projectedCostAt100: projectCost(opusCost, opusUtil),
|
|
81
|
+
},
|
|
82
|
+
sonnet: {
|
|
83
|
+
utilization: sonnetUtil,
|
|
84
|
+
tokens: buildTokens(sonIn, sonOut, sonCR, sonCW),
|
|
85
|
+
actualTokens: sonExclCR,
|
|
86
|
+
projectedTokensAt100: project(sonExclCR, sonnetUtil),
|
|
87
|
+
actualCost: sonnetCost,
|
|
88
|
+
projectedCostAt100: projectCost(sonnetCost, sonnetUtil),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Update the quota cycle snapshot file for this machine.
|
|
96
|
+
* Called after each successful quota API fetch.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} quotaData - Quota API response (must have available === true)
|
|
99
|
+
* @param {string} logBaseDir - This machine's log directory (~/.claude/projects/)
|
|
100
|
+
* @param {string} machineName - Identifier for this machine
|
|
101
|
+
* @param {string} [snapshotDir] - Directory for snapshot files (defaults to syncDir or ~/.claude/)
|
|
102
|
+
* @param {string} [syncDir] - Shared sync directory; used as fallback when snapshotDir is not set
|
|
103
|
+
*/
|
|
104
|
+
export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, snapshotDir, syncDir) {
|
|
105
|
+
if (!quotaData?.available || !quotaData.seven_day?.resets_at) return;
|
|
106
|
+
|
|
107
|
+
const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
|
|
108
|
+
const filePath = path.join(dir, `quota-cycles-${machineName}.json`);
|
|
109
|
+
|
|
110
|
+
// Normalize resets_at to second precision — the API returns varying microseconds
|
|
111
|
+
// on each call (e.g. .905316 vs .581788) which would cause false cycle switches
|
|
112
|
+
const rawResetsAt = new Date(quotaData.seven_day.resets_at);
|
|
113
|
+
rawResetsAt.setMilliseconds(0);
|
|
114
|
+
const resetsAt = rawResetsAt.toISOString();
|
|
115
|
+
const start = new Date(rawResetsAt.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
116
|
+
|
|
117
|
+
let snapshot;
|
|
118
|
+
try {
|
|
119
|
+
snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
120
|
+
} catch {
|
|
121
|
+
snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Compare normalized timestamps to detect actual cycle boundary changes
|
|
125
|
+
const storedResetNorm = snapshot.currentCycle
|
|
126
|
+
? new Date(snapshot.currentCycle.resets_at).setMilliseconds(0)
|
|
127
|
+
: null;
|
|
128
|
+
if (snapshot.currentCycle && storedResetNorm !== rawResetsAt.getTime()) {
|
|
129
|
+
snapshot.history.unshift(snapshot.currentCycle);
|
|
130
|
+
if (snapshot.history.length > MAX_HISTORY) {
|
|
131
|
+
snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
|
|
132
|
+
}
|
|
133
|
+
snapshot.currentCycle = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const allRecords = parseLogDirectory(logBaseDir);
|
|
137
|
+
const cycleRecords = filterByDateRange(allRecords, start, resetsAt);
|
|
138
|
+
const cycleData = computeCycleData(cycleRecords, quotaData);
|
|
139
|
+
|
|
140
|
+
snapshot.currentCycle = {
|
|
141
|
+
resets_at: resetsAt,
|
|
142
|
+
start,
|
|
143
|
+
lastUpdated: new Date().toISOString(),
|
|
144
|
+
...cycleData,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Load and merge quota cycle data from all machines.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} machineName - This machine's name
|
|
154
|
+
* @param {string|null} syncDir - Shared sync directory (null if single-machine)
|
|
155
|
+
* @param {string} [snapshotDir] - Directory for snapshot files (defaults to ~/.claude/)
|
|
156
|
+
* @returns {{ currentCycle: object|null, history: object[], machines: string[] }}
|
|
157
|
+
*/
|
|
158
|
+
export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
|
|
159
|
+
const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
|
|
160
|
+
const empty = { currentCycle: null, history: [], machines: [] };
|
|
161
|
+
|
|
162
|
+
let files;
|
|
163
|
+
try {
|
|
164
|
+
files = fs.readdirSync(dir).filter(f => f.startsWith('quota-cycles-') && f.endsWith('.json'));
|
|
165
|
+
} catch {
|
|
166
|
+
return empty;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (files.length === 0) return empty;
|
|
170
|
+
|
|
171
|
+
const snapshots = [];
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
try {
|
|
174
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
|
|
175
|
+
if (data.schemaVersion === 1) snapshots.push(data);
|
|
176
|
+
} catch { /* skip corrupt files */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (snapshots.length === 0) return empty;
|
|
180
|
+
|
|
181
|
+
const machines = snapshots.map(s => s.machineName);
|
|
182
|
+
|
|
183
|
+
if (snapshots.length === 1) {
|
|
184
|
+
const s = snapshots[0];
|
|
185
|
+
return { currentCycle: s.currentCycle, history: s.history, machines };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
currentCycle: mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean)),
|
|
190
|
+
history: mergeHistories(snapshots.map(s => s.history)),
|
|
191
|
+
machines,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function mergeCycles(cycles) {
|
|
196
|
+
if (cycles.length === 0) return null;
|
|
197
|
+
if (cycles.length === 1) return cycles[0];
|
|
198
|
+
|
|
199
|
+
const byReset = new Map();
|
|
200
|
+
for (const c of cycles) {
|
|
201
|
+
const key = c.resets_at;
|
|
202
|
+
if (!byReset.has(key)) byReset.set(key, []);
|
|
203
|
+
byReset.get(key).push(c);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let bestKey = null, bestCount = 0;
|
|
207
|
+
for (const [key, arr] of byReset) {
|
|
208
|
+
if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sameCycle = byReset.get(bestKey);
|
|
212
|
+
return mergeSamePeriodCycles(sameCycle);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function mergeSamePeriodCycles(cycles) {
|
|
216
|
+
const mostRecent = cycles.reduce((a, b) =>
|
|
217
|
+
new Date(a.lastUpdated) > new Date(b.lastUpdated) ? a : b
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
resets_at: mostRecent.resets_at,
|
|
222
|
+
start: mostRecent.start,
|
|
223
|
+
lastUpdated: mostRecent.lastUpdated,
|
|
224
|
+
overall: mergeMetrics(cycles.map(c => c.overall), mostRecent.overall.utilization),
|
|
225
|
+
models: {
|
|
226
|
+
opus: mergeMetrics(cycles.map(c => c.models.opus), mostRecent.models?.opus?.utilization || 0),
|
|
227
|
+
sonnet: mergeMetrics(cycles.map(c => c.models.sonnet), mostRecent.models?.sonnet?.utilization || 0),
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mergeMetrics(metricsArray, utilization) {
|
|
233
|
+
const totalTokens = metricsArray.reduce((sum, m) => sum + (m?.actualTokens || 0), 0);
|
|
234
|
+
const totalCost = Math.round(metricsArray.reduce((sum, m) => sum + (m?.actualCost || 0), 0) * 100) / 100;
|
|
235
|
+
|
|
236
|
+
// Merge per-type token breakdown
|
|
237
|
+
const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
238
|
+
for (const m of metricsArray) {
|
|
239
|
+
if (m?.tokens) {
|
|
240
|
+
tokens.input += m.tokens.input || 0;
|
|
241
|
+
tokens.output += m.tokens.output || 0;
|
|
242
|
+
tokens.cacheRead += m.tokens.cacheRead || 0;
|
|
243
|
+
tokens.cacheCreation += m.tokens.cacheCreation || 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
utilization,
|
|
249
|
+
tokens,
|
|
250
|
+
actualTokens: totalTokens,
|
|
251
|
+
projectedTokensAt100: utilization > 0 ? Math.round(totalTokens / (utilization / 100)) : null,
|
|
252
|
+
actualCost: totalCost,
|
|
253
|
+
projectedCostAt100: utilization > 0 ? Math.round((totalCost / (utilization / 100)) * 100) / 100 : null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function mergeHistories(historyArrays) {
|
|
258
|
+
const byReset = new Map();
|
|
259
|
+
for (const history of historyArrays) {
|
|
260
|
+
for (const entry of history) {
|
|
261
|
+
const key = entry.resets_at;
|
|
262
|
+
if (!byReset.has(key)) byReset.set(key, []);
|
|
263
|
+
byReset.get(key).push(entry);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const merged = [];
|
|
268
|
+
for (const [, entries] of byReset) {
|
|
269
|
+
merged.push(mergeSamePeriodCycles(entries));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
merged.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
|
|
273
|
+
return merged.slice(0, MAX_HISTORY);
|
|
274
|
+
}
|
package/server/routes/api.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
|
+
import os from 'os';
|
|
2
3
|
import { parseLogDirectory, parseMultiMachineDirectory } from '../parser.js';
|
|
3
4
|
import { syncLocalToShared } from '../sync.js';
|
|
4
5
|
import { filterByDateRange, autoGranularity, aggregateByTime, aggregateBySession, aggregateByProject, aggregateByModel, aggregateCache } from '../aggregator.js';
|
|
5
6
|
import { calculateRecordCost, PLAN_DEFAULTS } from '../pricing.js';
|
|
6
7
|
import { createQuotaFetcher } from '../quota.js';
|
|
7
8
|
import { getSubscriptionInfo } from '../credentials.js';
|
|
9
|
+
import { updateQuotaCycleSnapshot, loadQuotaCycles } from '../quota-cycles.js';
|
|
8
10
|
|
|
9
11
|
export function createApiRouter(logBaseDir, options = {}) {
|
|
10
12
|
const router = Router();
|
|
@@ -117,12 +119,44 @@ export function createApiRouter(logBaseDir, options = {}) {
|
|
|
117
119
|
router.get('/quota', async (req, res) => {
|
|
118
120
|
try {
|
|
119
121
|
const data = await quotaFetcher.fetchQuota();
|
|
122
|
+
if (data.available) {
|
|
123
|
+
try {
|
|
124
|
+
updateQuotaCycleSnapshot(
|
|
125
|
+
data,
|
|
126
|
+
logBaseDir,
|
|
127
|
+
options.machineName || os.hostname(),
|
|
128
|
+
options.snapshotDir,
|
|
129
|
+
options.syncDir
|
|
130
|
+
);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.warn('Failed to update quota cycle snapshot:', err.message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
120
135
|
res.json(data);
|
|
121
136
|
} catch (err) {
|
|
122
137
|
res.json({ available: false, error: err.message });
|
|
123
138
|
}
|
|
124
139
|
});
|
|
125
140
|
|
|
141
|
+
router.get('/quota-cycles', (req, res) => {
|
|
142
|
+
try {
|
|
143
|
+
const data = loadQuotaCycles(
|
|
144
|
+
options.machineName || os.hostname(),
|
|
145
|
+
options.syncDir || null,
|
|
146
|
+
options.snapshotDir
|
|
147
|
+
);
|
|
148
|
+
if (data.currentCycle) {
|
|
149
|
+
const start = new Date(data.currentCycle.start);
|
|
150
|
+
const now = new Date();
|
|
151
|
+
data.currentCycle.daysElapsed = Math.round(((now - start) / (1000 * 60 * 60 * 24)) * 10) / 10;
|
|
152
|
+
data.currentCycle.daysTotal = 7;
|
|
153
|
+
}
|
|
154
|
+
res.json(data);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
res.status(500).json({ error: err.message });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
126
160
|
router.get('/subscription', (req, res) => {
|
|
127
161
|
const info = options.getSubscriptionInfo ? options.getSubscriptionInfo() : getSubscriptionInfo();
|
|
128
162
|
res.json(info || { plan: null, subscriptionType: null, rateLimitTier: null });
|