claude-usage-dashboard 1.1.0 → 1.2.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/package.json +1 -1
- package/public/css/style.css +8 -0
- package/public/index.html +8 -0
- package/public/js/api.js +1 -0
- package/public/js/app.js +20 -2
- package/public/js/charts/quota-gauge.js +78 -0
- package/server/credentials.js +22 -0
- package/server/quota.js +49 -0
- package/server/routes/api.js +11 -0
package/package.json
CHANGED
package/public/css/style.css
CHANGED
|
@@ -245,6 +245,14 @@ body {
|
|
|
245
245
|
z-index: 100;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
.quota-unavailable {
|
|
249
|
+
color: var(--text-muted);
|
|
250
|
+
font-size: 13px;
|
|
251
|
+
padding: 20px;
|
|
252
|
+
text-align: center;
|
|
253
|
+
}
|
|
254
|
+
#quota-section .chart-container { min-height: auto; }
|
|
255
|
+
|
|
248
256
|
@media (max-width: 768px) {
|
|
249
257
|
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
|
250
258
|
.chart-row-3 { grid-template-columns: 1fr; }
|
package/public/index.html
CHANGED
|
@@ -46,6 +46,14 @@
|
|
|
46
46
|
</div>
|
|
47
47
|
</section>
|
|
48
48
|
|
|
49
|
+
<section class="chart-section" id="quota-section">
|
|
50
|
+
<div class="chart-header">
|
|
51
|
+
<h2>Subscription Quota</h2>
|
|
52
|
+
<span id="quota-last-updated" class="last-updated"></span>
|
|
53
|
+
</div>
|
|
54
|
+
<div id="chart-quota" class="chart-container"></div>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
49
57
|
<section class="chart-section">
|
|
50
58
|
<div class="chart-header">
|
|
51
59
|
<h2>Token Consumption Trend</h2>
|
package/public/js/api.js
CHANGED
|
@@ -12,3 +12,4 @@ export async function fetchSessions(params = {}) { return (await fetch(`${BASE}/
|
|
|
12
12
|
export async function fetchCost(params = {}) { return (await fetch(`${BASE}/cost${qs(params)}`)).json(); }
|
|
13
13
|
export async function fetchCache(params = {}) { return (await fetch(`${BASE}/cache${qs(params)}`)).json(); }
|
|
14
14
|
export async function fetchStatus() { return (await fetch(`${BASE}/status`)).json(); }
|
|
15
|
+
export async function fetchQuota() { return (await fetch(`${BASE}/quota`)).json(); }
|
package/public/js/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchUsage, fetchModels, fetchProjects, fetchSessions, fetchCost, fetchCache, fetchStatus } from './api.js';
|
|
1
|
+
import { fetchUsage, fetchModels, fetchProjects, fetchSessions, fetchCost, fetchCache, fetchStatus, fetchQuota } 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';
|
|
@@ -7,6 +7,7 @@ import { renderModelDistribution } from './charts/model-distribution.js';
|
|
|
7
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
|
+
import { renderQuotaGauges } from './charts/quota-gauge.js';
|
|
10
11
|
|
|
11
12
|
const state = {
|
|
12
13
|
dateRange: { from: null, to: null },
|
|
@@ -19,6 +20,8 @@ const state = {
|
|
|
19
20
|
autoRefresh: true,
|
|
20
21
|
autoRefreshInterval: 30,
|
|
21
22
|
_refreshTimer: null,
|
|
23
|
+
quotaRefreshInterval: 120,
|
|
24
|
+
_quotaTimer: null,
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
let datePicker, planSelector;
|
|
@@ -37,10 +40,20 @@ function updateLastUpdated() {
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
async function loadQuota() {
|
|
44
|
+
try {
|
|
45
|
+
const data = await fetchQuota();
|
|
46
|
+
renderQuotaGauges(document.getElementById('chart-quota'), data);
|
|
47
|
+
const el = document.getElementById('quota-last-updated');
|
|
48
|
+
if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()}`;
|
|
49
|
+
} catch { /* silently degrade */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
function startAutoRefresh() {
|
|
41
53
|
stopAutoRefresh();
|
|
42
54
|
if (state.autoRefresh) {
|
|
43
55
|
state._refreshTimer = setInterval(() => loadAll(), state.autoRefreshInterval * 1000);
|
|
56
|
+
state._quotaTimer = setInterval(() => loadQuota(), state.quotaRefreshInterval * 1000);
|
|
44
57
|
}
|
|
45
58
|
}
|
|
46
59
|
|
|
@@ -49,6 +62,10 @@ function stopAutoRefresh() {
|
|
|
49
62
|
clearInterval(state._refreshTimer);
|
|
50
63
|
state._refreshTimer = null;
|
|
51
64
|
}
|
|
65
|
+
if (state._quotaTimer) {
|
|
66
|
+
clearInterval(state._quotaTimer);
|
|
67
|
+
state._quotaTimer = null;
|
|
68
|
+
}
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
async function loadAll() {
|
|
@@ -161,7 +178,7 @@ function init() {
|
|
|
161
178
|
loadAll();
|
|
162
179
|
});
|
|
163
180
|
|
|
164
|
-
document.getElementById('btn-refresh').addEventListener('click', () => loadAll());
|
|
181
|
+
document.getElementById('btn-refresh').addEventListener('click', () => { loadAll(); loadQuota(); });
|
|
165
182
|
|
|
166
183
|
const autoToggle = document.getElementById('auto-refresh-toggle');
|
|
167
184
|
autoToggle.addEventListener('change', () => {
|
|
@@ -174,6 +191,7 @@ function init() {
|
|
|
174
191
|
});
|
|
175
192
|
|
|
176
193
|
loadAll();
|
|
194
|
+
loadQuota();
|
|
177
195
|
startAutoRefresh();
|
|
178
196
|
}
|
|
179
197
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function renderQuotaGauges(container, data) {
|
|
2
|
+
container.innerHTML = '';
|
|
3
|
+
|
|
4
|
+
if (!data || data.available === false) {
|
|
5
|
+
const msg = document.createElement('div');
|
|
6
|
+
msg.className = 'quota-unavailable';
|
|
7
|
+
const reason = data?.error === 'no_credentials'
|
|
8
|
+
? 'No Claude credentials found. Run "claude" CLI to authenticate.'
|
|
9
|
+
: data?.error === 'rate_limited'
|
|
10
|
+
? 'Quota API rate limited. Will retry on next refresh.'
|
|
11
|
+
: 'Quota data unavailable';
|
|
12
|
+
msg.textContent = reason;
|
|
13
|
+
container.appendChild(msg);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const items = [];
|
|
18
|
+
if (data.five_hour) items.push({ label: '5-Hour Window', ...data.five_hour });
|
|
19
|
+
if (data.seven_day) items.push({ label: '7-Day Total', ...data.seven_day });
|
|
20
|
+
if (data.seven_day_opus) items.push({ label: '7-Day Opus', ...data.seven_day_opus });
|
|
21
|
+
if (data.seven_day_sonnet) items.push({ label: '7-Day Sonnet', ...data.seven_day_sonnet });
|
|
22
|
+
if (data.extra_usage?.is_enabled) {
|
|
23
|
+
items.push({
|
|
24
|
+
label: 'Extra Usage',
|
|
25
|
+
utilization: data.extra_usage.utilization || 0,
|
|
26
|
+
resets_at: null,
|
|
27
|
+
extraDetail: data.extra_usage.monthly_limit != null
|
|
28
|
+
? `$${(data.extra_usage.used_credits || 0).toFixed(2)} / $${data.extra_usage.monthly_limit.toFixed(2)}`
|
|
29
|
+
: null,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (items.length === 0) {
|
|
34
|
+
const msg = document.createElement('div');
|
|
35
|
+
msg.className = 'quota-unavailable';
|
|
36
|
+
msg.textContent = 'No quota data available';
|
|
37
|
+
container.appendChild(msg);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const wrapper = document.createElement('div');
|
|
42
|
+
wrapper.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px';
|
|
43
|
+
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
const pct = Math.min(100, Math.max(0, item.utilization || 0));
|
|
46
|
+
const color = pct < 50 ? '#4ade80' : pct < 80 ? '#f59e0b' : '#ef4444';
|
|
47
|
+
|
|
48
|
+
const cell = document.createElement('div');
|
|
49
|
+
|
|
50
|
+
const header = document.createElement('div');
|
|
51
|
+
header.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#94a3b8;margin-bottom:4px';
|
|
52
|
+
header.innerHTML = `<span>${item.label}</span><span style="color:${color};font-weight:600">${pct.toFixed(1)}%</span>`;
|
|
53
|
+
|
|
54
|
+
const barBg = document.createElement('div');
|
|
55
|
+
barBg.style.cssText = 'height:8px;background:#334155;border-radius:4px;overflow:hidden';
|
|
56
|
+
|
|
57
|
+
const barFill = document.createElement('div');
|
|
58
|
+
barFill.style.cssText = `width:${pct}%;height:100%;background:${color};border-radius:4px;transition:width 0.5s`;
|
|
59
|
+
|
|
60
|
+
barBg.appendChild(barFill);
|
|
61
|
+
cell.appendChild(header);
|
|
62
|
+
cell.appendChild(barBg);
|
|
63
|
+
|
|
64
|
+
// Reset time or extra detail
|
|
65
|
+
const sub = document.createElement('div');
|
|
66
|
+
sub.style.cssText = 'font-size:10px;color:#64748b;margin-top:2px';
|
|
67
|
+
if (item.extraDetail) {
|
|
68
|
+
sub.textContent = item.extraDetail;
|
|
69
|
+
} else if (item.resets_at) {
|
|
70
|
+
sub.textContent = `Resets ${new Date(item.resets_at).toLocaleTimeString()}`;
|
|
71
|
+
}
|
|
72
|
+
cell.appendChild(sub);
|
|
73
|
+
|
|
74
|
+
wrapper.appendChild(cell);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
container.appendChild(wrapper);
|
|
78
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const CREDENTIALS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
6
|
+
|
|
7
|
+
export function readCredentials(credentialsPath = CREDENTIALS_PATH) {
|
|
8
|
+
try {
|
|
9
|
+
const raw = fs.readFileSync(credentialsPath, 'utf-8');
|
|
10
|
+
const data = JSON.parse(raw);
|
|
11
|
+
return data.claudeAiOauth || null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getAccessToken(credentialsPath = CREDENTIALS_PATH) {
|
|
18
|
+
const creds = readCredentials(credentialsPath);
|
|
19
|
+
if (!creds || !creds.accessToken) return null;
|
|
20
|
+
if (creds.expiresAt && creds.expiresAt < Date.now()) return null;
|
|
21
|
+
return creds.accessToken;
|
|
22
|
+
}
|
package/server/quota.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { getAccessToken } from './credentials.js';
|
|
2
|
+
|
|
3
|
+
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
|
4
|
+
|
|
5
|
+
export function createQuotaFetcher(options = {}) {
|
|
6
|
+
const CACHE_TTL = options.cacheTtlMs || 120_000;
|
|
7
|
+
const getToken = options.getAccessToken || getAccessToken;
|
|
8
|
+
let cached = null;
|
|
9
|
+
let lastFetched = 0;
|
|
10
|
+
let fetchInProgress = null;
|
|
11
|
+
|
|
12
|
+
async function fetchQuota() {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
if (cached && (now - lastFetched) < CACHE_TTL) return cached;
|
|
15
|
+
if (fetchInProgress) return fetchInProgress;
|
|
16
|
+
|
|
17
|
+
fetchInProgress = (async () => {
|
|
18
|
+
try {
|
|
19
|
+
const token = getToken();
|
|
20
|
+
if (!token) return cached || { available: false, error: 'no_credentials' };
|
|
21
|
+
|
|
22
|
+
const res = await fetch(USAGE_URL, {
|
|
23
|
+
headers: {
|
|
24
|
+
'Authorization': `Bearer ${token}`,
|
|
25
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
if (res.status === 429) return cached || { available: false, error: 'rate_limited' };
|
|
31
|
+
return cached || { available: false, error: `http_${res.status}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
cached = { available: true, ...data, lastFetched: new Date().toISOString() };
|
|
36
|
+
lastFetched = Date.now();
|
|
37
|
+
return cached;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return cached || { available: false, error: err.message };
|
|
40
|
+
} finally {
|
|
41
|
+
fetchInProgress = null;
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
return fetchInProgress;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { fetchQuota };
|
|
49
|
+
}
|
package/server/routes/api.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|
|
2
2
|
import { parseLogDirectory } from '../parser.js';
|
|
3
3
|
import { filterByDateRange, autoGranularity, aggregateByTime, aggregateBySession, aggregateByProject, aggregateByModel, aggregateCache } from '../aggregator.js';
|
|
4
4
|
import { calculateRecordCost, PLAN_DEFAULTS } from '../pricing.js';
|
|
5
|
+
import { createQuotaFetcher } from '../quota.js';
|
|
5
6
|
|
|
6
7
|
export function createApiRouter(logBaseDir, options = {}) {
|
|
7
8
|
const router = Router();
|
|
@@ -98,6 +99,16 @@ export function createApiRouter(logBaseDir, options = {}) {
|
|
|
98
99
|
|
|
99
100
|
router.get('/cache', (req, res) => { res.json(aggregateCache(applyFilters(req.query))); });
|
|
100
101
|
|
|
102
|
+
const quotaFetcher = options.quotaFetcher || createQuotaFetcher();
|
|
103
|
+
router.get('/quota', async (req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
const data = await quotaFetcher.fetchQuota();
|
|
106
|
+
res.json(data);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
res.json({ available: false, error: err.message });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
101
112
|
router.get('/status', (req, res) => {
|
|
102
113
|
res.json({
|
|
103
114
|
record_count: cachedRecords.length,
|