claude-usage-dashboard 1.0.6 → 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 +38 -0
- package/public/index.html +15 -0
- package/public/js/api.js +2 -0
- package/public/js/app.js +59 -1
- 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 +42 -8
package/package.json
CHANGED
package/public/css/style.css
CHANGED
|
@@ -194,6 +194,36 @@ body {
|
|
|
194
194
|
}
|
|
195
195
|
.date-picker span { color: var(--text-secondary); font-size: 12px; }
|
|
196
196
|
|
|
197
|
+
.refresh-controls {
|
|
198
|
+
display: flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
gap: 8px;
|
|
201
|
+
}
|
|
202
|
+
.btn-refresh {
|
|
203
|
+
padding: 4px 10px;
|
|
204
|
+
background: var(--bg-input);
|
|
205
|
+
border: 1px solid var(--border);
|
|
206
|
+
border-radius: 6px;
|
|
207
|
+
color: var(--text-primary);
|
|
208
|
+
font-size: 16px;
|
|
209
|
+
cursor: pointer;
|
|
210
|
+
line-height: 1;
|
|
211
|
+
}
|
|
212
|
+
.btn-refresh:hover { background: var(--blue); }
|
|
213
|
+
.auto-refresh-label {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 4px;
|
|
217
|
+
font-size: 12px;
|
|
218
|
+
color: var(--text-secondary);
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
}
|
|
221
|
+
.last-updated {
|
|
222
|
+
font-size: 11px;
|
|
223
|
+
color: var(--text-muted);
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
}
|
|
226
|
+
|
|
197
227
|
.plan-selector select, .plan-selector input {
|
|
198
228
|
padding: 6px 10px;
|
|
199
229
|
background: var(--bg-input);
|
|
@@ -215,6 +245,14 @@ body {
|
|
|
215
245
|
z-index: 100;
|
|
216
246
|
}
|
|
217
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
|
+
|
|
218
256
|
@media (max-width: 768px) {
|
|
219
257
|
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
|
220
258
|
.chart-row-3 { grid-template-columns: 1fr; }
|
package/public/index.html
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
<div class="controls">
|
|
14
14
|
<div id="date-picker" class="date-picker"></div>
|
|
15
15
|
<div id="plan-selector" class="plan-selector"></div>
|
|
16
|
+
<div class="refresh-controls">
|
|
17
|
+
<button id="btn-refresh" class="btn-refresh" title="Refresh now">↻</button>
|
|
18
|
+
<label class="auto-refresh-label">
|
|
19
|
+
<input type="checkbox" id="auto-refresh-toggle" checked> Auto
|
|
20
|
+
</label>
|
|
21
|
+
<span id="last-updated" class="last-updated"></span>
|
|
22
|
+
</div>
|
|
16
23
|
</div>
|
|
17
24
|
</header>
|
|
18
25
|
|
|
@@ -39,6 +46,14 @@
|
|
|
39
46
|
</div>
|
|
40
47
|
</section>
|
|
41
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
|
+
|
|
42
57
|
<section class="chart-section">
|
|
43
58
|
<div class="chart-header">
|
|
44
59
|
<h2>Token Consumption Trend</h2>
|
package/public/js/api.js
CHANGED
|
@@ -11,3 +11,5 @@ export async function fetchProjects(params = {}) { return (await fetch(`${BASE}/
|
|
|
11
11
|
export async function fetchSessions(params = {}) { return (await fetch(`${BASE}/sessions${qs(params)}`)).json(); }
|
|
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
|
+
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 } 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 },
|
|
@@ -16,6 +17,11 @@ const state = {
|
|
|
16
17
|
sessionOrder: 'desc',
|
|
17
18
|
sessionPage: 1,
|
|
18
19
|
sessionProject: '',
|
|
20
|
+
autoRefresh: true,
|
|
21
|
+
autoRefreshInterval: 30,
|
|
22
|
+
_refreshTimer: null,
|
|
23
|
+
quotaRefreshInterval: 120,
|
|
24
|
+
_quotaTimer: null,
|
|
19
25
|
};
|
|
20
26
|
|
|
21
27
|
let datePicker, planSelector;
|
|
@@ -26,6 +32,42 @@ function formatNumber(n) {
|
|
|
26
32
|
return n.toString();
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
function updateLastUpdated() {
|
|
36
|
+
const el = document.getElementById('last-updated');
|
|
37
|
+
if (el) {
|
|
38
|
+
const now = new Date();
|
|
39
|
+
el.textContent = `Updated ${now.toLocaleTimeString()}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
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
|
+
|
|
52
|
+
function startAutoRefresh() {
|
|
53
|
+
stopAutoRefresh();
|
|
54
|
+
if (state.autoRefresh) {
|
|
55
|
+
state._refreshTimer = setInterval(() => loadAll(), state.autoRefreshInterval * 1000);
|
|
56
|
+
state._quotaTimer = setInterval(() => loadQuota(), state.quotaRefreshInterval * 1000);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stopAutoRefresh() {
|
|
61
|
+
if (state._refreshTimer) {
|
|
62
|
+
clearInterval(state._refreshTimer);
|
|
63
|
+
state._refreshTimer = null;
|
|
64
|
+
}
|
|
65
|
+
if (state._quotaTimer) {
|
|
66
|
+
clearInterval(state._quotaTimer);
|
|
67
|
+
state._quotaTimer = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
async function loadAll() {
|
|
30
72
|
const params = { ...state.dateRange };
|
|
31
73
|
const planParams = { ...state.dateRange, plan: state.plan.plan };
|
|
@@ -93,6 +135,8 @@ async function loadAll() {
|
|
|
93
135
|
loadAll();
|
|
94
136
|
},
|
|
95
137
|
});
|
|
138
|
+
|
|
139
|
+
updateLastUpdated();
|
|
96
140
|
}
|
|
97
141
|
|
|
98
142
|
function init() {
|
|
@@ -134,7 +178,21 @@ function init() {
|
|
|
134
178
|
loadAll();
|
|
135
179
|
});
|
|
136
180
|
|
|
181
|
+
document.getElementById('btn-refresh').addEventListener('click', () => { loadAll(); loadQuota(); });
|
|
182
|
+
|
|
183
|
+
const autoToggle = document.getElementById('auto-refresh-toggle');
|
|
184
|
+
autoToggle.addEventListener('change', () => {
|
|
185
|
+
state.autoRefresh = autoToggle.checked;
|
|
186
|
+
if (state.autoRefresh) {
|
|
187
|
+
startAutoRefresh();
|
|
188
|
+
} else {
|
|
189
|
+
stopAutoRefresh();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
137
193
|
loadAll();
|
|
194
|
+
loadQuota();
|
|
195
|
+
startAutoRefresh();
|
|
138
196
|
}
|
|
139
197
|
|
|
140
198
|
document.addEventListener('DOMContentLoaded', init);
|
|
@@ -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,19 +2,34 @@ 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
|
-
export function createApiRouter(logBaseDir) {
|
|
7
|
+
export function createApiRouter(logBaseDir, options = {}) {
|
|
7
8
|
const router = Router();
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
const CACHE_TTL_MS = options.cacheTtlMs || 5000;
|
|
10
|
+
let cachedRecords = [];
|
|
11
|
+
let lastRefreshed = null;
|
|
12
|
+
|
|
13
|
+
function refreshRecords() {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
if (lastRefreshed && (now - lastRefreshed) < CACHE_TTL_MS) return cachedRecords;
|
|
16
|
+
try {
|
|
17
|
+
cachedRecords = parseLogDirectory(logBaseDir);
|
|
18
|
+
lastRefreshed = now;
|
|
19
|
+
console.log(`Parsed ${cachedRecords.length} records from ${logBaseDir}`);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error('Failed to parse log directory:', err.message);
|
|
22
|
+
// Keep stale data on failure
|
|
23
|
+
if (!lastRefreshed) lastRefreshed = now;
|
|
24
|
+
}
|
|
25
|
+
return cachedRecords;
|
|
14
26
|
}
|
|
15
27
|
|
|
28
|
+
// Initial parse
|
|
29
|
+
refreshRecords();
|
|
30
|
+
|
|
16
31
|
function applyFilters(query) {
|
|
17
|
-
let records = filterByDateRange(
|
|
32
|
+
let records = filterByDateRange(refreshRecords(), query.from, query.to);
|
|
18
33
|
if (query.project) records = records.filter(r => r.project === query.project);
|
|
19
34
|
if (query.model) records = records.filter(r => r.model === query.model);
|
|
20
35
|
return records;
|
|
@@ -83,5 +98,24 @@ export function createApiRouter(logBaseDir) {
|
|
|
83
98
|
});
|
|
84
99
|
|
|
85
100
|
router.get('/cache', (req, res) => { res.json(aggregateCache(applyFilters(req.query))); });
|
|
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
|
+
|
|
112
|
+
router.get('/status', (req, res) => {
|
|
113
|
+
res.json({
|
|
114
|
+
record_count: cachedRecords.length,
|
|
115
|
+
last_refreshed: lastRefreshed ? new Date(lastRefreshed).toISOString() : null,
|
|
116
|
+
cache_ttl_ms: CACHE_TTL_MS,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
86
120
|
return router;
|
|
87
121
|
}
|