claude-usage-dashboard 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/css/style.css +8 -0
- package/public/index.html +8 -0
- package/public/js/api.js +2 -0
- package/public/js/app.js +34 -2
- package/public/js/charts/quota-gauge.js +78 -0
- package/public/js/components/plan-selector.js +21 -1
- package/server/credentials.js +37 -0
- package/server/quota.js +49 -0
- package/server/routes/api.js +17 -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,5 @@ 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(); }
|
|
16
|
+
export async function fetchSubscription() { return (await fetch(`${BASE}/subscription`)).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, fetchSubscription } 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', () => {
|
|
@@ -173,7 +190,22 @@ function init() {
|
|
|
173
190
|
}
|
|
174
191
|
});
|
|
175
192
|
|
|
193
|
+
// Auto-detect subscription tier
|
|
194
|
+
fetchSubscription().then(info => {
|
|
195
|
+
if (info.plan) {
|
|
196
|
+
planSelector.setDetectedPlan(info.plan);
|
|
197
|
+
state.plan = planSelector.getPlan();
|
|
198
|
+
}
|
|
199
|
+
const tierLabels = { pro: 'Pro', max5x: 'Max 5x', max20x: 'Max 20x' };
|
|
200
|
+
const label = tierLabels[info.plan];
|
|
201
|
+
if (label) {
|
|
202
|
+
const h2 = document.querySelector('#quota-section h2');
|
|
203
|
+
if (h2) h2.textContent = `Subscription Quota (${label})`;
|
|
204
|
+
}
|
|
205
|
+
}).catch(() => {});
|
|
206
|
+
|
|
176
207
|
loadAll();
|
|
208
|
+
loadQuota();
|
|
177
209
|
startAutoRefresh();
|
|
178
210
|
}
|
|
179
211
|
|
|
@@ -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
|
+
}
|
|
@@ -7,6 +7,7 @@ const PLANS = {
|
|
|
7
7
|
export function initPlanSelector(container, onChange) {
|
|
8
8
|
const saved = localStorage.getItem('selectedPlan') || 'max20x';
|
|
9
9
|
const savedPrice = localStorage.getItem('customPrice') || '';
|
|
10
|
+
let detectedPlan = null;
|
|
10
11
|
|
|
11
12
|
container.innerHTML = `
|
|
12
13
|
<select id="plan-select">
|
|
@@ -33,5 +34,24 @@ export function initPlanSelector(container, onChange) {
|
|
|
33
34
|
customInput.style.display = customInput.style.display === 'none' ? 'inline-block' : 'none';
|
|
34
35
|
});
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
function setDetectedPlan(planKey) {
|
|
38
|
+
if (!planKey || !PLANS[planKey]) return;
|
|
39
|
+
detectedPlan = planKey;
|
|
40
|
+
// Only auto-select if user hasn't manually chosen a plan
|
|
41
|
+
if (!localStorage.getItem('selectedPlan')) {
|
|
42
|
+
select.value = planKey;
|
|
43
|
+
emitChange();
|
|
44
|
+
}
|
|
45
|
+
// Update option labels to show which is detected
|
|
46
|
+
for (const opt of select.options) {
|
|
47
|
+
const p = PLANS[opt.value];
|
|
48
|
+
const suffix = opt.value === planKey ? ' ✓' : '';
|
|
49
|
+
opt.textContent = `${p.label} ($${p.price}/mo)${suffix}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
getPlan: () => ({ plan: select.value, customPrice: customInput.value ? parseFloat(customInput.value) : null }),
|
|
55
|
+
setDetectedPlan,
|
|
56
|
+
};
|
|
37
57
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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 getSubscriptionInfo(credentialsPath = CREDENTIALS_PATH) {
|
|
18
|
+
const creds = readCredentials(credentialsPath);
|
|
19
|
+
if (!creds) return null;
|
|
20
|
+
|
|
21
|
+
const { subscriptionType, rateLimitTier } = creds;
|
|
22
|
+
const combined = `${subscriptionType || ''} ${rateLimitTier || ''}`.toLowerCase();
|
|
23
|
+
|
|
24
|
+
let plan = null;
|
|
25
|
+
if (combined.includes('20x')) plan = 'max20x';
|
|
26
|
+
else if (combined.includes('5x')) plan = 'max5x';
|
|
27
|
+
else if (combined.includes('pro')) plan = 'pro';
|
|
28
|
+
|
|
29
|
+
return { subscriptionType: subscriptionType || null, rateLimitTier: rateLimitTier || null, plan };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getAccessToken(credentialsPath = CREDENTIALS_PATH) {
|
|
33
|
+
const creds = readCredentials(credentialsPath);
|
|
34
|
+
if (!creds || !creds.accessToken) return null;
|
|
35
|
+
if (creds.expiresAt && creds.expiresAt < Date.now()) return null;
|
|
36
|
+
return creds.accessToken;
|
|
37
|
+
}
|
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,8 @@ 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';
|
|
6
|
+
import { getSubscriptionInfo } from '../credentials.js';
|
|
5
7
|
|
|
6
8
|
export function createApiRouter(logBaseDir, options = {}) {
|
|
7
9
|
const router = Router();
|
|
@@ -98,6 +100,21 @@ export function createApiRouter(logBaseDir, options = {}) {
|
|
|
98
100
|
|
|
99
101
|
router.get('/cache', (req, res) => { res.json(aggregateCache(applyFilters(req.query))); });
|
|
100
102
|
|
|
103
|
+
const quotaFetcher = options.quotaFetcher || createQuotaFetcher();
|
|
104
|
+
router.get('/quota', async (req, res) => {
|
|
105
|
+
try {
|
|
106
|
+
const data = await quotaFetcher.fetchQuota();
|
|
107
|
+
res.json(data);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
res.json({ available: false, error: err.message });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
router.get('/subscription', (req, res) => {
|
|
114
|
+
const info = options.getSubscriptionInfo ? options.getSubscriptionInfo() : getSubscriptionInfo();
|
|
115
|
+
res.json(info || { plan: null, subscriptionType: null, rateLimitTier: null });
|
|
116
|
+
});
|
|
117
|
+
|
|
101
118
|
router.get('/status', (req, res) => {
|
|
102
119
|
res.json({
|
|
103
120
|
record_count: cachedRecords.length,
|