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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-dashboard",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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,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
- return { getPlan: () => ({ plan: select.value, customPrice: customInput.value ? parseFloat(customInput.value) : null }) };
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
+ }
@@ -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,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,