claude-usage-dashboard 1.5.3 → 1.5.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/js/app.js +46 -22
- package/public/js/charts/quota-cycles.js +5 -5
- package/server/quota-cycles.js +73 -22
package/package.json
CHANGED
package/public/js/app.js
CHANGED
|
@@ -28,6 +28,7 @@ const state = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
let datePicker, planSelector;
|
|
31
|
+
let _cachedCycleData = null;
|
|
31
32
|
|
|
32
33
|
function formatNumber(n) {
|
|
33
34
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
@@ -61,7 +62,8 @@ function getQuotaWindow(sevenDay) {
|
|
|
61
62
|
|
|
62
63
|
async function loadQuota() {
|
|
63
64
|
try {
|
|
64
|
-
const data = await fetchQuota();
|
|
65
|
+
const [data, cycleData] = await Promise.all([fetchQuota(), fetchQuotaCycles()]);
|
|
66
|
+
_cachedCycleData = cycleData;
|
|
65
67
|
|
|
66
68
|
// Use the actual quota window (resets_at - 7 days → resets_at)
|
|
67
69
|
let cost7dValue = 0;
|
|
@@ -72,12 +74,16 @@ async function loadQuota() {
|
|
|
72
74
|
if (window && sevenDay.utilization > 0) {
|
|
73
75
|
quotaWindowFrom = window.from;
|
|
74
76
|
quotaWindowTo = window.to;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
// Prefer cycle data (multi-machine merged) over cost API (local only)
|
|
78
|
+
cost7dValue = cycleData?.currentCycle?.overall?.actualCost || 0;
|
|
79
|
+
if (cost7dValue === 0) {
|
|
80
|
+
const cost7d = await fetchCost({
|
|
81
|
+
from: window.from.toISOString(),
|
|
82
|
+
to: window.to.toISOString(),
|
|
83
|
+
plan: state.plan.plan,
|
|
84
|
+
});
|
|
85
|
+
cost7dValue = cost7d.api_equivalent_cost_usd;
|
|
86
|
+
}
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
renderQuotaGauges(document.getElementById('chart-quota'), data, {
|
|
@@ -85,17 +91,18 @@ async function loadQuota() {
|
|
|
85
91
|
});
|
|
86
92
|
const el = document.getElementById('quota-last-updated');
|
|
87
93
|
if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()} ${getTimezoneAbbr()}`;
|
|
88
|
-
|
|
94
|
+
renderQuotaCycles(document.getElementById('chart-quota-cycles'), cycleData, {
|
|
95
|
+
modelKey: state.cycleModel,
|
|
96
|
+
});
|
|
89
97
|
} catch { /* silently degrade */ }
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
renderQuotaCycles(document.getElementById('chart-quota-cycles'), data, {
|
|
100
|
+
function loadQuotaCyclesData() {
|
|
101
|
+
if (_cachedCycleData) {
|
|
102
|
+
renderQuotaCycles(document.getElementById('chart-quota-cycles'), _cachedCycleData, {
|
|
96
103
|
modelKey: state.cycleModel,
|
|
97
104
|
});
|
|
98
|
-
}
|
|
105
|
+
}
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
function startAutoRefresh() {
|
|
@@ -137,18 +144,35 @@ async function loadAll() {
|
|
|
137
144
|
fetchCache(params),
|
|
138
145
|
]);
|
|
139
146
|
|
|
140
|
-
// Summary cards
|
|
147
|
+
// Summary cards — use multi-machine cycle data when viewing current cycle
|
|
141
148
|
const t = usage.total;
|
|
142
|
-
|
|
149
|
+
let tokIn = t.input_tokens, tokOut = t.output_tokens;
|
|
150
|
+
let tokCR = t.cache_read_tokens, tokCW = t.cache_creation_tokens;
|
|
151
|
+
let apiCost = cost.api_equivalent_cost_usd;
|
|
152
|
+
const cc = _cachedCycleData?.currentCycle?.overall;
|
|
153
|
+
if (cc?.tokens) {
|
|
154
|
+
const cct = cc.tokens;
|
|
155
|
+
const mergedAll = cct.input + cct.output + cct.cacheRead + cct.cacheCreation;
|
|
156
|
+
const localAll = tokIn + tokOut + tokCR + tokCW;
|
|
157
|
+
if (mergedAll > localAll) {
|
|
158
|
+
tokIn = cct.input; tokOut = cct.output;
|
|
159
|
+
tokCR = cct.cacheRead; tokCW = cct.cacheCreation;
|
|
160
|
+
apiCost = cc.actualCost;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const totalAll = tokIn + tokOut + tokCR + tokCW;
|
|
143
164
|
document.getElementById('val-total-tokens').textContent = formatNumber(totalAll);
|
|
144
165
|
document.getElementById('sub-total-tokens').innerHTML =
|
|
145
|
-
`<span style="color:#4ade80">cache read:${formatNumber(
|
|
146
|
-
`<span style="color:#f59e0b">cache write:${formatNumber(
|
|
147
|
-
`<span style="color:#60a5fa">in:${formatNumber(
|
|
148
|
-
`<span style="color:#f97316">out:${formatNumber(
|
|
149
|
-
document.getElementById('val-api-cost').textContent = `$${
|
|
150
|
-
|
|
151
|
-
|
|
166
|
+
`<span style="color:#4ade80">cache read:${formatNumber(tokCR)}</span> · ` +
|
|
167
|
+
`<span style="color:#f59e0b">cache write:${formatNumber(tokCW)}</span> · ` +
|
|
168
|
+
`<span style="color:#60a5fa">in:${formatNumber(tokIn)}</span> · ` +
|
|
169
|
+
`<span style="color:#f97316">out:${formatNumber(tokOut)}</span>`;
|
|
170
|
+
document.getElementById('val-api-cost').textContent = `$${apiCost.toFixed(2)}`;
|
|
171
|
+
|
|
172
|
+
const totalInput = tokIn + tokCR + tokCW;
|
|
173
|
+
document.getElementById('val-cache-rate').textContent = totalInput > 0
|
|
174
|
+
? `${((tokCR / totalInput) * 100).toFixed(1)}%`
|
|
175
|
+
: `${(cache.cache_read_rate * 100).toFixed(1)}%`;
|
|
152
176
|
|
|
153
177
|
// Set active granularity button
|
|
154
178
|
const activeGran = usage.granularity;
|
|
@@ -161,9 +161,9 @@ export function renderQuotaCycles(container, data, { modelKey = 'overall' } = {}
|
|
|
161
161
|
<th class="align-right">Total</th>
|
|
162
162
|
<th class="align-right">Excl CR</th>
|
|
163
163
|
<th class="align-right">Cost</th>
|
|
164
|
-
<th class="align-right col-highlight">Proj
|
|
165
|
-
<th class="align-right col-highlight">Proj Cost</th>
|
|
166
|
-
<th class="align-right">\u0394
|
|
164
|
+
<th class="align-right col-highlight">Proj Token Limit</th>
|
|
165
|
+
<th class="align-right col-highlight">Proj Cost Limit</th>
|
|
166
|
+
<th class="align-right">\u0394 Limit</th>
|
|
167
167
|
</tr>`;
|
|
168
168
|
table.appendChild(thead);
|
|
169
169
|
|
|
@@ -178,8 +178,8 @@ export function renderQuotaCycles(container, data, { modelKey = 'overall' } = {}
|
|
|
178
178
|
|
|
179
179
|
let deltaStr = '—';
|
|
180
180
|
let deltaClass = '';
|
|
181
|
-
if (prev && prev.
|
|
182
|
-
const delta = ((d.
|
|
181
|
+
if (prev && prev.projectedCostAt100 != null && d.projectedCostAt100 != null && prev.projectedCostAt100 > 0) {
|
|
182
|
+
const delta = ((d.projectedCostAt100 - prev.projectedCostAt100) / prev.projectedCostAt100) * 100;
|
|
183
183
|
deltaStr = `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}%`;
|
|
184
184
|
deltaClass = delta >= 0 ? 'delta-positive' : 'delta-negative';
|
|
185
185
|
}
|
package/server/quota-cycles.js
CHANGED
|
@@ -7,6 +7,35 @@ import { calculateRecordCost } from './pricing.js';
|
|
|
7
7
|
|
|
8
8
|
const MAX_HISTORY = 52;
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a cycle's resets_at to hour precision for use as a dedup key.
|
|
12
|
+
* The API returns varying sub-second precision across calls and machines,
|
|
13
|
+
* so raw strings cannot be used for grouping.
|
|
14
|
+
*/
|
|
15
|
+
function cyclePeriodKey(cycle) {
|
|
16
|
+
const d = new Date(cycle.resets_at);
|
|
17
|
+
d.setMinutes(0, 0, 0);
|
|
18
|
+
return d.toISOString();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Deduplicate history entries that represent the same quota cycle period.
|
|
23
|
+
* Keeps the entry with the latest lastUpdated for each period.
|
|
24
|
+
*/
|
|
25
|
+
function deduplicateHistory(history) {
|
|
26
|
+
const byKey = new Map();
|
|
27
|
+
for (const entry of history) {
|
|
28
|
+
const key = cyclePeriodKey(entry);
|
|
29
|
+
const existing = byKey.get(key);
|
|
30
|
+
if (!existing || new Date(entry.lastUpdated) > new Date(existing.lastUpdated)) {
|
|
31
|
+
byKey.set(key, entry);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const result = Array.from(byKey.values());
|
|
35
|
+
result.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
10
39
|
/**
|
|
11
40
|
* Pure computation: given records (already filtered to a cycle) and quota data,
|
|
12
41
|
* compute actual tokens/cost and project at 100% utilization.
|
|
@@ -121,12 +150,13 @@ export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, sna
|
|
|
121
150
|
snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
|
|
122
151
|
}
|
|
123
152
|
|
|
124
|
-
// Compare normalized
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (snapshot.currentCycle &&
|
|
153
|
+
// Compare normalized period keys to detect actual cycle boundary changes.
|
|
154
|
+
// Uses hour-precision keys to tolerate varying sub-second timestamps from the API.
|
|
155
|
+
const storedKey = snapshot.currentCycle ? cyclePeriodKey(snapshot.currentCycle) : null;
|
|
156
|
+
const newKey = cyclePeriodKey({ resets_at: resetsAt });
|
|
157
|
+
if (snapshot.currentCycle && storedKey !== newKey) {
|
|
129
158
|
snapshot.history.unshift(snapshot.currentCycle);
|
|
159
|
+
snapshot.history = deduplicateHistory(snapshot.history);
|
|
130
160
|
if (snapshot.history.length > MAX_HISTORY) {
|
|
131
161
|
snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
|
|
132
162
|
}
|
|
@@ -181,34 +211,55 @@ export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
|
|
|
181
211
|
const machines = snapshots.map(s => s.machineName);
|
|
182
212
|
|
|
183
213
|
if (snapshots.length === 1) {
|
|
214
|
+
// Single machine: duplicates are time-series snapshots of the same data,
|
|
215
|
+
// so dedup (keep most recent) and filter out current-cycle overlap.
|
|
184
216
|
const s = snapshots[0];
|
|
185
|
-
|
|
217
|
+
let history = deduplicateHistory(s.history);
|
|
218
|
+
if (s.currentCycle) {
|
|
219
|
+
const currentKey = cyclePeriodKey(s.currentCycle);
|
|
220
|
+
history = history.filter(h => cyclePeriodKey(h) !== currentKey);
|
|
221
|
+
}
|
|
222
|
+
return { currentCycle: s.currentCycle, history, machines };
|
|
186
223
|
}
|
|
187
224
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
225
|
+
// Multi-machine: dedup within each machine first (removes false-switch
|
|
226
|
+
// duplicates), then merge across machines (sums different machines' data).
|
|
227
|
+
const dedupedHistories = snapshots.map(s => deduplicateHistory(s.history));
|
|
228
|
+
let currentCycle = mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean));
|
|
229
|
+
let history = mergeHistories(dedupedHistories);
|
|
230
|
+
|
|
231
|
+
// If history contains entries for the current cycle's period (e.g. from an
|
|
232
|
+
// offline machine whose cycle was archived), merge them INTO the current
|
|
233
|
+
// cycle instead of dropping them.
|
|
234
|
+
if (currentCycle) {
|
|
235
|
+
const currentKey = cyclePeriodKey(currentCycle);
|
|
236
|
+
const overlapping = history.filter(h => cyclePeriodKey(h) === currentKey);
|
|
237
|
+
history = history.filter(h => cyclePeriodKey(h) !== currentKey);
|
|
238
|
+
if (overlapping.length > 0) {
|
|
239
|
+
currentCycle = mergeSamePeriodCycles([currentCycle, ...overlapping]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { currentCycle, history, machines };
|
|
193
244
|
}
|
|
194
245
|
|
|
195
246
|
function mergeCycles(cycles) {
|
|
196
247
|
if (cycles.length === 0) return null;
|
|
197
248
|
if (cycles.length === 1) return cycles[0];
|
|
198
249
|
|
|
199
|
-
const
|
|
250
|
+
const byPeriod = new Map();
|
|
200
251
|
for (const c of cycles) {
|
|
201
|
-
const key = c
|
|
202
|
-
if (!
|
|
203
|
-
|
|
252
|
+
const key = cyclePeriodKey(c);
|
|
253
|
+
if (!byPeriod.has(key)) byPeriod.set(key, []);
|
|
254
|
+
byPeriod.get(key).push(c);
|
|
204
255
|
}
|
|
205
256
|
|
|
206
257
|
let bestKey = null, bestCount = 0;
|
|
207
|
-
for (const [key, arr] of
|
|
258
|
+
for (const [key, arr] of byPeriod) {
|
|
208
259
|
if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
|
|
209
260
|
}
|
|
210
261
|
|
|
211
|
-
const sameCycle =
|
|
262
|
+
const sameCycle = byPeriod.get(bestKey);
|
|
212
263
|
return mergeSamePeriodCycles(sameCycle);
|
|
213
264
|
}
|
|
214
265
|
|
|
@@ -255,17 +306,17 @@ function mergeMetrics(metricsArray, utilization) {
|
|
|
255
306
|
}
|
|
256
307
|
|
|
257
308
|
function mergeHistories(historyArrays) {
|
|
258
|
-
const
|
|
309
|
+
const byPeriod = new Map();
|
|
259
310
|
for (const history of historyArrays) {
|
|
260
311
|
for (const entry of history) {
|
|
261
|
-
const key = entry
|
|
262
|
-
if (!
|
|
263
|
-
|
|
312
|
+
const key = cyclePeriodKey(entry);
|
|
313
|
+
if (!byPeriod.has(key)) byPeriod.set(key, []);
|
|
314
|
+
byPeriod.get(key).push(entry);
|
|
264
315
|
}
|
|
265
316
|
}
|
|
266
317
|
|
|
267
318
|
const merged = [];
|
|
268
|
-
for (const [, entries] of
|
|
319
|
+
for (const [, entries] of byPeriod) {
|
|
269
320
|
merged.push(mergeSamePeriodCycles(entries));
|
|
270
321
|
}
|
|
271
322
|
|