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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-dashboard",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Dashboard that visualizes Claude Code usage from local session logs",
5
5
  "main": "server/index.js",
6
6
  "bin": {
@@ -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
+ }
@@ -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
+ }
@@ -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,