claude-usage-dashboard 1.0.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/README.md +52 -0
- package/bin/cli.js +2 -0
- package/package.json +34 -0
- package/public/css/style.css +221 -0
- package/public/index.html +93 -0
- package/public/js/api.js +13 -0
- package/public/js/app.js +140 -0
- package/public/js/charts/cache-efficiency.js +29 -0
- package/public/js/charts/cost-comparison.js +39 -0
- package/public/js/charts/model-distribution.js +48 -0
- package/public/js/charts/project-distribution.js +92 -0
- package/public/js/charts/session-stats.js +117 -0
- package/public/js/charts/token-trend.js +113 -0
- package/public/js/components/date-picker.js +21 -0
- package/public/js/components/plan-selector.js +37 -0
- package/server/aggregator.js +147 -0
- package/server/index.js +18 -0
- package/server/parser.js +92 -0
- package/server/pricing.js +52 -0
- package/server/routes/api.js +87 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Claude Usage Dashboard
|
|
2
|
+
|
|
3
|
+
A self-hosted dashboard that visualizes your [Claude Code](https://claude.ai/code) usage by parsing local JSONL session logs from `~/.claude/projects/`.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Token tracking** — Total tokens with breakdown by input, output, cache read, and cache write
|
|
10
|
+
- **Cost estimation** — API cost equivalent at standard pricing, compared against your subscription plan (Pro / Max 5x / Max 20x)
|
|
11
|
+
- **Token consumption trend** — Stacked bar chart with hourly, daily, weekly, or monthly granularity
|
|
12
|
+
- **Model distribution** — Donut chart showing usage across Claude models
|
|
13
|
+
- **Cache efficiency** — Visual breakdown of cache read, cache creation, and uncached requests
|
|
14
|
+
- **Project distribution** — Horizontal bar chart comparing token usage across projects
|
|
15
|
+
- **Session details** — Sortable, paginated table of every session with cost and duration
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
Run directly without installing:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx claude-usage-dashboard
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Open http://localhost:3000 in your browser.
|
|
26
|
+
|
|
27
|
+
### From Source
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
git clone https://github.com/ludengz/claudeUsageDashboard.git
|
|
31
|
+
cd claudeUsageDashboard
|
|
32
|
+
npm install
|
|
33
|
+
npm start
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The dashboard reads logs from `~/.claude/projects/` — if you use Claude Code, these already exist on your machine. Logs are parsed once at startup; restart the server to pick up new session data.
|
|
37
|
+
|
|
38
|
+
## Tech Stack
|
|
39
|
+
|
|
40
|
+
- **Backend:** Node.js, Express 5
|
|
41
|
+
- **Frontend:** Vanilla JS (ES modules), D3.js v7
|
|
42
|
+
- **Tests:** Mocha + Chai
|
|
43
|
+
|
|
44
|
+
## Running Tests
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm test
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
ISC
|
package/bin/cli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-usage-dashboard",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Dashboard that visualizes Claude Code usage from local session logs",
|
|
5
|
+
"main": "server/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-usage-dashboard": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"server/",
|
|
12
|
+
"public/"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node server/index.js",
|
|
16
|
+
"test": "mocha test/**/*.test.js --timeout 5000"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["claude", "usage", "dashboard", "token", "cost"],
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/ludengz/claudeUsageDashboard.git"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"d3": "^7.9.0",
|
|
28
|
+
"express": "^5.2.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"chai": "^6.2.2",
|
|
32
|
+
"mocha": "^11.7.5"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg-primary: #0f172a;
|
|
5
|
+
--bg-card: #1e293b;
|
|
6
|
+
--bg-input: #334155;
|
|
7
|
+
--border: #475569;
|
|
8
|
+
--text-primary: #f8fafc;
|
|
9
|
+
--text-secondary: #94a3b8;
|
|
10
|
+
--text-muted: #64748b;
|
|
11
|
+
--blue: #3b82f6;
|
|
12
|
+
--blue-light: #60a5fa;
|
|
13
|
+
--purple: #8b5cf6;
|
|
14
|
+
--orange: #f97316;
|
|
15
|
+
--amber: #f59e0b;
|
|
16
|
+
--green: #4ade80;
|
|
17
|
+
--red: #ef4444;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
body {
|
|
21
|
+
background: var(--bg-primary);
|
|
22
|
+
color: var(--text-primary);
|
|
23
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
24
|
+
padding: 0 24px 40px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.top-bar {
|
|
28
|
+
display: flex;
|
|
29
|
+
justify-content: space-between;
|
|
30
|
+
align-items: center;
|
|
31
|
+
padding: 16px 0;
|
|
32
|
+
border-bottom: 1px solid var(--bg-card);
|
|
33
|
+
margin-bottom: 20px;
|
|
34
|
+
}
|
|
35
|
+
.logo { font-size: 18px; font-weight: 700; }
|
|
36
|
+
.controls { display: flex; gap: 12px; align-items: center; }
|
|
37
|
+
|
|
38
|
+
.summary-cards {
|
|
39
|
+
display: grid;
|
|
40
|
+
grid-template-columns: repeat(4, 1fr);
|
|
41
|
+
gap: 12px;
|
|
42
|
+
margin-bottom: 20px;
|
|
43
|
+
}
|
|
44
|
+
.card {
|
|
45
|
+
background: var(--bg-card);
|
|
46
|
+
border-radius: 8px;
|
|
47
|
+
padding: 16px;
|
|
48
|
+
}
|
|
49
|
+
.card-label {
|
|
50
|
+
font-size: 11px;
|
|
51
|
+
color: var(--text-muted);
|
|
52
|
+
text-transform: uppercase;
|
|
53
|
+
letter-spacing: 1px;
|
|
54
|
+
}
|
|
55
|
+
.card-value {
|
|
56
|
+
font-size: 24px;
|
|
57
|
+
font-weight: 700;
|
|
58
|
+
margin-top: 4px;
|
|
59
|
+
}
|
|
60
|
+
.card-sub {
|
|
61
|
+
font-size: 11px;
|
|
62
|
+
color: var(--text-secondary);
|
|
63
|
+
margin-top: 2px;
|
|
64
|
+
}
|
|
65
|
+
#val-api-cost { color: var(--amber); }
|
|
66
|
+
#val-savings { color: var(--green); }
|
|
67
|
+
#val-cache-rate { color: var(--blue-light); }
|
|
68
|
+
|
|
69
|
+
.chart-section {
|
|
70
|
+
background: var(--bg-card);
|
|
71
|
+
border-radius: 8px;
|
|
72
|
+
padding: 20px;
|
|
73
|
+
margin-bottom: 12px;
|
|
74
|
+
}
|
|
75
|
+
.chart-section h2 {
|
|
76
|
+
font-size: 14px;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
margin-bottom: 16px;
|
|
79
|
+
}
|
|
80
|
+
.chart-header {
|
|
81
|
+
display: flex;
|
|
82
|
+
justify-content: space-between;
|
|
83
|
+
align-items: center;
|
|
84
|
+
margin-bottom: 16px;
|
|
85
|
+
}
|
|
86
|
+
.chart-header h2 { margin-bottom: 0; }
|
|
87
|
+
.chart-container { min-height: 200px; }
|
|
88
|
+
|
|
89
|
+
.chart-row-3 {
|
|
90
|
+
display: grid;
|
|
91
|
+
grid-template-columns: repeat(3, 1fr);
|
|
92
|
+
gap: 12px;
|
|
93
|
+
margin-bottom: 12px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.granularity-toggle { display: flex; gap: 4px; }
|
|
97
|
+
.granularity-toggle button {
|
|
98
|
+
padding: 4px 12px;
|
|
99
|
+
background: var(--bg-input);
|
|
100
|
+
border: none;
|
|
101
|
+
border-radius: 4px;
|
|
102
|
+
color: var(--text-secondary);
|
|
103
|
+
font-size: 12px;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
}
|
|
106
|
+
.granularity-toggle button.active {
|
|
107
|
+
background: var(--blue);
|
|
108
|
+
color: white;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.table-controls { display: flex; gap: 8px; }
|
|
112
|
+
.filter-input, .sort-select {
|
|
113
|
+
padding: 6px 10px;
|
|
114
|
+
background: var(--bg-input);
|
|
115
|
+
border: 1px solid var(--border);
|
|
116
|
+
border-radius: 6px;
|
|
117
|
+
color: var(--text-primary);
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
}
|
|
120
|
+
.filter-input { width: 180px; }
|
|
121
|
+
|
|
122
|
+
.table-container { overflow-x: auto; }
|
|
123
|
+
.table-container table {
|
|
124
|
+
width: 100%;
|
|
125
|
+
border-collapse: collapse;
|
|
126
|
+
font-size: 12px;
|
|
127
|
+
}
|
|
128
|
+
.table-container th {
|
|
129
|
+
text-align: left;
|
|
130
|
+
padding: 10px 8px;
|
|
131
|
+
border-bottom: 2px solid var(--bg-input);
|
|
132
|
+
color: var(--text-muted);
|
|
133
|
+
text-transform: uppercase;
|
|
134
|
+
font-size: 10px;
|
|
135
|
+
letter-spacing: 1px;
|
|
136
|
+
cursor: pointer;
|
|
137
|
+
}
|
|
138
|
+
.table-container th.align-right,
|
|
139
|
+
.table-container td.align-right { text-align: right; }
|
|
140
|
+
.table-container td {
|
|
141
|
+
padding: 10px 8px;
|
|
142
|
+
border-bottom: 1px solid var(--bg-primary);
|
|
143
|
+
color: var(--text-secondary);
|
|
144
|
+
}
|
|
145
|
+
.table-container tfoot td {
|
|
146
|
+
border-top: 2px solid var(--bg-input);
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
color: var(--text-primary);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.tag {
|
|
152
|
+
display: inline-block;
|
|
153
|
+
padding: 2px 8px;
|
|
154
|
+
border-radius: 4px;
|
|
155
|
+
font-size: 11px;
|
|
156
|
+
}
|
|
157
|
+
.tag-project { background: #1e3a5f; color: var(--blue-light); }
|
|
158
|
+
.tag-model-sonnet { background: #1e3a5f; color: var(--blue-light); }
|
|
159
|
+
.tag-model-opus { background: #3b1764; color: #c084fc; }
|
|
160
|
+
.tag-model-haiku { background: #1a2e1a; color: var(--green); }
|
|
161
|
+
|
|
162
|
+
.pagination {
|
|
163
|
+
display: flex;
|
|
164
|
+
justify-content: center;
|
|
165
|
+
gap: 4px;
|
|
166
|
+
margin-top: 12px;
|
|
167
|
+
}
|
|
168
|
+
.pagination button {
|
|
169
|
+
padding: 4px 10px;
|
|
170
|
+
background: var(--bg-input);
|
|
171
|
+
border: none;
|
|
172
|
+
border-radius: 4px;
|
|
173
|
+
font-size: 11px;
|
|
174
|
+
color: var(--text-secondary);
|
|
175
|
+
cursor: pointer;
|
|
176
|
+
}
|
|
177
|
+
.pagination button.active {
|
|
178
|
+
background: var(--blue);
|
|
179
|
+
color: white;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.date-picker {
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: 8px;
|
|
186
|
+
}
|
|
187
|
+
.date-picker input {
|
|
188
|
+
padding: 6px 10px;
|
|
189
|
+
background: var(--bg-input);
|
|
190
|
+
border: 1px solid var(--border);
|
|
191
|
+
border-radius: 6px;
|
|
192
|
+
color: var(--text-primary);
|
|
193
|
+
font-size: 12px;
|
|
194
|
+
}
|
|
195
|
+
.date-picker span { color: var(--text-secondary); font-size: 12px; }
|
|
196
|
+
|
|
197
|
+
.plan-selector select, .plan-selector input {
|
|
198
|
+
padding: 6px 10px;
|
|
199
|
+
background: var(--bg-input);
|
|
200
|
+
border: 1px solid var(--border);
|
|
201
|
+
border-radius: 6px;
|
|
202
|
+
color: var(--text-primary);
|
|
203
|
+
font-size: 12px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.d3-tooltip {
|
|
207
|
+
position: absolute;
|
|
208
|
+
padding: 8px 12px;
|
|
209
|
+
background: rgba(15, 23, 42, 0.95);
|
|
210
|
+
border: 1px solid var(--border);
|
|
211
|
+
border-radius: 6px;
|
|
212
|
+
font-size: 12px;
|
|
213
|
+
color: var(--text-primary);
|
|
214
|
+
pointer-events: none;
|
|
215
|
+
z-index: 100;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@media (max-width: 768px) {
|
|
219
|
+
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
|
220
|
+
.chart-row-3 { grid-template-columns: 1fr; }
|
|
221
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Claude Usage Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="/css/style.css">
|
|
8
|
+
<script src="/lib/d3/d3.min.js"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="top-bar">
|
|
12
|
+
<h1 class="logo">⚡ Claude Usage Dashboard</h1>
|
|
13
|
+
<div class="controls">
|
|
14
|
+
<div id="date-picker" class="date-picker"></div>
|
|
15
|
+
<div id="plan-selector" class="plan-selector"></div>
|
|
16
|
+
</div>
|
|
17
|
+
</header>
|
|
18
|
+
|
|
19
|
+
<section class="summary-cards" id="summary-cards">
|
|
20
|
+
<div class="card" id="card-total-tokens">
|
|
21
|
+
<div class="card-label">Total Tokens</div>
|
|
22
|
+
<div class="card-value" id="val-total-tokens">—</div>
|
|
23
|
+
<div class="card-sub" id="sub-total-tokens"></div>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="card" id="card-api-cost">
|
|
26
|
+
<div class="card-label">API Cost Equivalent</div>
|
|
27
|
+
<div class="card-value" id="val-api-cost">—</div>
|
|
28
|
+
<div class="card-sub" id="sub-api-cost">at standard API pricing</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="card" id="card-savings">
|
|
31
|
+
<div class="card-label">You Saved</div>
|
|
32
|
+
<div class="card-value" id="val-savings">—</div>
|
|
33
|
+
<div class="card-sub" id="sub-savings"></div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="card" id="card-cache">
|
|
36
|
+
<div class="card-label">Cache Hit Rate</div>
|
|
37
|
+
<div class="card-value" id="val-cache-rate">—</div>
|
|
38
|
+
<div class="card-sub" id="sub-cache-rate">cache_read / total input</div>
|
|
39
|
+
</div>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<section class="chart-section">
|
|
43
|
+
<div class="chart-header">
|
|
44
|
+
<h2>Token Consumption Trend</h2>
|
|
45
|
+
<div class="granularity-toggle" id="granularity-toggle">
|
|
46
|
+
<button data-granularity="hourly">Hourly</button>
|
|
47
|
+
<button data-granularity="daily" class="active">Daily</button>
|
|
48
|
+
<button data-granularity="weekly">Weekly</button>
|
|
49
|
+
<button data-granularity="monthly">Monthly</button>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div id="chart-token-trend" class="chart-container"></div>
|
|
53
|
+
</section>
|
|
54
|
+
|
|
55
|
+
<section class="chart-row-3">
|
|
56
|
+
<div class="chart-section">
|
|
57
|
+
<h2>Cost: Subscription vs API</h2>
|
|
58
|
+
<div id="chart-cost-comparison" class="chart-container"></div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="chart-section">
|
|
61
|
+
<h2>Model Distribution</h2>
|
|
62
|
+
<div id="chart-model-distribution" class="chart-container"></div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="chart-section">
|
|
65
|
+
<h2>Cache Efficiency</h2>
|
|
66
|
+
<div id="chart-cache-efficiency" class="chart-container"></div>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
<section class="chart-section">
|
|
71
|
+
<h2>Project Distribution</h2>
|
|
72
|
+
<div id="chart-project-distribution" class="chart-container"></div>
|
|
73
|
+
</section>
|
|
74
|
+
|
|
75
|
+
<section class="chart-section">
|
|
76
|
+
<div class="chart-header">
|
|
77
|
+
<h2>Session Details</h2>
|
|
78
|
+
<div class="table-controls">
|
|
79
|
+
<input type="text" id="session-filter" placeholder="Filter by project..." class="filter-input">
|
|
80
|
+
<select id="session-sort" class="sort-select">
|
|
81
|
+
<option value="date">Sort by: Date</option>
|
|
82
|
+
<option value="cost">Sort by: Cost</option>
|
|
83
|
+
<option value="tokens">Sort by: Tokens</option>
|
|
84
|
+
</select>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
<div id="session-table" class="table-container"></div>
|
|
88
|
+
<div id="session-pagination" class="pagination"></div>
|
|
89
|
+
</section>
|
|
90
|
+
|
|
91
|
+
<script type="module" src="/js/app.js"></script>
|
|
92
|
+
</body>
|
|
93
|
+
</html>
|
package/public/js/api.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const BASE = '/api';
|
|
2
|
+
|
|
3
|
+
function qs(params) {
|
|
4
|
+
const entries = Object.entries(params).filter(([, v]) => v != null && v !== '');
|
|
5
|
+
return entries.length ? '?' + new URLSearchParams(entries).toString() : '';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function fetchUsage(params = {}) { return (await fetch(`${BASE}/usage${qs(params)}`)).json(); }
|
|
9
|
+
export async function fetchModels(params = {}) { return (await fetch(`${BASE}/models${qs(params)}`)).json(); }
|
|
10
|
+
export async function fetchProjects(params = {}) { return (await fetch(`${BASE}/projects${qs(params)}`)).json(); }
|
|
11
|
+
export async function fetchSessions(params = {}) { return (await fetch(`${BASE}/sessions${qs(params)}`)).json(); }
|
|
12
|
+
export async function fetchCost(params = {}) { return (await fetch(`${BASE}/cost${qs(params)}`)).json(); }
|
|
13
|
+
export async function fetchCache(params = {}) { return (await fetch(`${BASE}/cache${qs(params)}`)).json(); }
|
package/public/js/app.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { fetchUsage, fetchModels, fetchProjects, fetchSessions, fetchCost, fetchCache } from './api.js';
|
|
2
|
+
import { initDatePicker } from './components/date-picker.js';
|
|
3
|
+
import { initPlanSelector } from './components/plan-selector.js';
|
|
4
|
+
import { renderTokenTrend } from './charts/token-trend.js';
|
|
5
|
+
import { renderCostComparison } from './charts/cost-comparison.js';
|
|
6
|
+
import { renderModelDistribution } from './charts/model-distribution.js';
|
|
7
|
+
import { renderCacheEfficiency } from './charts/cache-efficiency.js';
|
|
8
|
+
import { renderProjectDistribution } from './charts/project-distribution.js';
|
|
9
|
+
import { renderSessionTable } from './charts/session-stats.js';
|
|
10
|
+
|
|
11
|
+
const state = {
|
|
12
|
+
dateRange: { from: null, to: null },
|
|
13
|
+
plan: { plan: 'max20x', customPrice: null },
|
|
14
|
+
granularity: localStorage.getItem('selectedGranularity') || 'hourly',
|
|
15
|
+
sessionSort: 'date',
|
|
16
|
+
sessionOrder: 'desc',
|
|
17
|
+
sessionPage: 1,
|
|
18
|
+
sessionProject: '',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let datePicker, planSelector;
|
|
22
|
+
|
|
23
|
+
function formatNumber(n) {
|
|
24
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
25
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
|
|
26
|
+
return n.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function loadAll() {
|
|
30
|
+
const params = { ...state.dateRange };
|
|
31
|
+
const planParams = { ...state.dateRange, plan: state.plan.plan };
|
|
32
|
+
if (state.plan.customPrice) planParams.customPrice = state.plan.customPrice;
|
|
33
|
+
|
|
34
|
+
const [usage, models, projects, sessions, cost, cache] = await Promise.all([
|
|
35
|
+
fetchUsage({ ...params, granularity: state.granularity }),
|
|
36
|
+
fetchModels(params),
|
|
37
|
+
fetchProjects(params),
|
|
38
|
+
fetchSessions({
|
|
39
|
+
...params,
|
|
40
|
+
project: state.sessionProject,
|
|
41
|
+
sort: state.sessionSort,
|
|
42
|
+
order: state.sessionOrder,
|
|
43
|
+
page: state.sessionPage,
|
|
44
|
+
}),
|
|
45
|
+
fetchCost(planParams),
|
|
46
|
+
fetchCache(params),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// Summary cards
|
|
50
|
+
const t = usage.total;
|
|
51
|
+
const totalAll = t.input_tokens + t.output_tokens + t.cache_read_tokens + t.cache_creation_tokens;
|
|
52
|
+
document.getElementById('val-total-tokens').textContent = formatNumber(totalAll);
|
|
53
|
+
document.getElementById('sub-total-tokens').innerHTML =
|
|
54
|
+
`<span style="color:#4ade80">cache read:${formatNumber(t.cache_read_tokens)}</span> · ` +
|
|
55
|
+
`<span style="color:#f59e0b">cache write:${formatNumber(t.cache_creation_tokens)}</span> · ` +
|
|
56
|
+
`<span style="color:#60a5fa">in:${formatNumber(t.input_tokens)}</span> · ` +
|
|
57
|
+
`<span style="color:#f97316">out:${formatNumber(t.output_tokens)}</span>`;
|
|
58
|
+
document.getElementById('val-api-cost').textContent = `$${cost.api_equivalent_cost_usd.toFixed(2)}`;
|
|
59
|
+
|
|
60
|
+
const savings = cost.savings_usd;
|
|
61
|
+
const savingsEl = document.getElementById('val-savings');
|
|
62
|
+
savingsEl.textContent = `$${Math.abs(savings).toFixed(2)}`;
|
|
63
|
+
savingsEl.style.color = savings >= 0 ? '#4ade80' : '#ef4444';
|
|
64
|
+
document.getElementById('sub-savings').textContent = savings >= 0 ? 'subscription saved you this much!' : 'API would have been cheaper';
|
|
65
|
+
|
|
66
|
+
document.getElementById('val-cache-rate').textContent = `${(cache.cache_read_rate * 100).toFixed(1)}%`;
|
|
67
|
+
|
|
68
|
+
// Set active granularity button
|
|
69
|
+
const activeGran = usage.granularity;
|
|
70
|
+
document.querySelectorAll('.granularity-toggle button').forEach(btn => {
|
|
71
|
+
btn.classList.toggle('active', btn.dataset.granularity === activeGran);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Charts
|
|
75
|
+
renderTokenTrend(document.getElementById('chart-token-trend'), usage);
|
|
76
|
+
renderCostComparison(document.getElementById('chart-cost-comparison'), cost);
|
|
77
|
+
renderModelDistribution(document.getElementById('chart-model-distribution'), models);
|
|
78
|
+
renderCacheEfficiency(document.getElementById('chart-cache-efficiency'), cache);
|
|
79
|
+
renderProjectDistribution(document.getElementById('chart-project-distribution'), projects);
|
|
80
|
+
renderSessionTable(document.getElementById('session-table'), sessions, {
|
|
81
|
+
onSort: (key) => {
|
|
82
|
+
if (state.sessionSort === key) {
|
|
83
|
+
state.sessionOrder = state.sessionOrder === 'desc' ? 'asc' : 'desc';
|
|
84
|
+
} else {
|
|
85
|
+
state.sessionSort = key;
|
|
86
|
+
state.sessionOrder = 'desc';
|
|
87
|
+
}
|
|
88
|
+
state.sessionPage = 1;
|
|
89
|
+
loadAll();
|
|
90
|
+
},
|
|
91
|
+
onPageChange: (page) => {
|
|
92
|
+
state.sessionPage = page;
|
|
93
|
+
loadAll();
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function init() {
|
|
99
|
+
datePicker = initDatePicker(document.getElementById('date-picker'), (range) => {
|
|
100
|
+
state.dateRange = range;
|
|
101
|
+
state.sessionPage = 1;
|
|
102
|
+
loadAll();
|
|
103
|
+
});
|
|
104
|
+
state.dateRange = datePicker.getRange();
|
|
105
|
+
|
|
106
|
+
planSelector = initPlanSelector(document.getElementById('plan-selector'), (plan) => {
|
|
107
|
+
state.plan = plan;
|
|
108
|
+
loadAll();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
document.getElementById('granularity-toggle').addEventListener('click', (e) => {
|
|
112
|
+
if (e.target.tagName === 'BUTTON') {
|
|
113
|
+
state.granularity = e.target.dataset.granularity;
|
|
114
|
+
localStorage.setItem('selectedGranularity', state.granularity);
|
|
115
|
+
loadAll();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const filterInput = document.getElementById('session-filter');
|
|
120
|
+
let filterTimeout;
|
|
121
|
+
filterInput.addEventListener('input', () => {
|
|
122
|
+
clearTimeout(filterTimeout);
|
|
123
|
+
filterTimeout = setTimeout(() => {
|
|
124
|
+
state.sessionProject = filterInput.value.trim();
|
|
125
|
+
state.sessionPage = 1;
|
|
126
|
+
loadAll();
|
|
127
|
+
}, 300);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
document.getElementById('session-sort').addEventListener('change', (e) => {
|
|
131
|
+
state.sessionSort = e.target.value;
|
|
132
|
+
state.sessionOrder = 'desc';
|
|
133
|
+
state.sessionPage = 1;
|
|
134
|
+
loadAll();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
loadAll();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function renderCacheEfficiency(container, data) {
|
|
2
|
+
container.innerHTML = '';
|
|
3
|
+
|
|
4
|
+
const items = [
|
|
5
|
+
{ label: 'Cache Read', value: data.cache_read_rate, color: '#4ade80', tokens: data.cache_read_tokens },
|
|
6
|
+
{ label: 'Cache Creation', value: data.cache_creation_rate, color: '#f59e0b', tokens: data.cache_creation_tokens },
|
|
7
|
+
{ label: 'No Cache', value: data.no_cache_rate, color: '#ef4444', tokens: data.non_cached_input_tokens },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
for (const item of items) {
|
|
11
|
+
const row = document.createElement('div');
|
|
12
|
+
row.style.marginBottom = '12px';
|
|
13
|
+
|
|
14
|
+
const header = document.createElement('div');
|
|
15
|
+
header.style.cssText = 'display:flex;justify-content:space-between;font-size:11px;color:#94a3b8;margin-bottom:4px';
|
|
16
|
+
header.innerHTML = `<span>${item.label}</span><span>${(item.value * 100).toFixed(1)}%</span>`;
|
|
17
|
+
|
|
18
|
+
const barBg = document.createElement('div');
|
|
19
|
+
barBg.style.cssText = 'height:8px;background:#334155;border-radius:4px;overflow:hidden';
|
|
20
|
+
|
|
21
|
+
const barFill = document.createElement('div');
|
|
22
|
+
barFill.style.cssText = `width:${item.value * 100}%;height:100%;background:${item.color};border-radius:4px;transition:width 0.5s`;
|
|
23
|
+
|
|
24
|
+
barBg.appendChild(barFill);
|
|
25
|
+
row.appendChild(header);
|
|
26
|
+
row.appendChild(barBg);
|
|
27
|
+
container.appendChild(row);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function renderCostComparison(container, data) {
|
|
2
|
+
const el = d3.select(container);
|
|
3
|
+
el.selectAll('*').remove();
|
|
4
|
+
|
|
5
|
+
const margin = { top: 10, right: 20, bottom: 40, left: 50 };
|
|
6
|
+
const width = container.clientWidth - margin.left - margin.right;
|
|
7
|
+
const height = 180 - margin.top - margin.bottom;
|
|
8
|
+
|
|
9
|
+
const svg = el.append('svg')
|
|
10
|
+
.attr('width', width + margin.left + margin.right)
|
|
11
|
+
.attr('height', height + margin.top + margin.bottom)
|
|
12
|
+
.append('g')
|
|
13
|
+
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
14
|
+
|
|
15
|
+
const bars = [
|
|
16
|
+
{ label: 'Subscription', value: data.subscription_cost_usd, color: '#3b82f6' },
|
|
17
|
+
{ label: 'API Cost', value: data.api_equivalent_cost_usd, color: '#f59e0b' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const x = d3.scaleBand().domain(bars.map(d => d.label)).range([0, width]).padding(0.4);
|
|
21
|
+
const y = d3.scaleLinear().domain([0, d3.max(bars, d => d.value) * 1.2]).range([height, 0]);
|
|
22
|
+
|
|
23
|
+
svg.append('g').attr('transform', `translate(0,${height})`)
|
|
24
|
+
.call(d3.axisBottom(x))
|
|
25
|
+
.selectAll('text').style('fill', '#94a3b8').style('font-size', '11px');
|
|
26
|
+
svg.append('g').call(d3.axisLeft(y).ticks(4).tickFormat(d => `$${d}`))
|
|
27
|
+
.selectAll('text').style('fill', '#64748b').style('font-size', '10px');
|
|
28
|
+
|
|
29
|
+
svg.selectAll('.bar').data(bars).enter().append('rect')
|
|
30
|
+
.attr('x', d => x(d.label)).attr('y', d => y(d.value))
|
|
31
|
+
.attr('width', x.bandwidth()).attr('height', d => height - y(d.value))
|
|
32
|
+
.attr('fill', d => d.color).attr('rx', 4);
|
|
33
|
+
|
|
34
|
+
svg.selectAll('.label').data(bars).enter().append('text')
|
|
35
|
+
.attr('x', d => x(d.label) + x.bandwidth() / 2).attr('y', d => y(d.value) - 5)
|
|
36
|
+
.attr('text-anchor', 'middle')
|
|
37
|
+
.style('fill', '#f8fafc').style('font-size', '12px').style('font-weight', '600')
|
|
38
|
+
.text(d => `$${d.value.toFixed(2)}`);
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const MODEL_COLORS = {
|
|
2
|
+
'claude-sonnet-4-6': '#3b82f6',
|
|
3
|
+
'claude-opus-4-6': '#8b5cf6',
|
|
4
|
+
'claude-haiku-4-5': '#f59e0b',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const MODEL_DISPLAY = {
|
|
8
|
+
'claude-opus-4-6': 'opus 4.6',
|
|
9
|
+
'claude-sonnet-4-6': 'sonnet 4.6',
|
|
10
|
+
'claude-haiku-4-5': 'haiku 4.5',
|
|
11
|
+
'claude-haiku-4-5-20251001': 'haiku 4.5',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function renderModelDistribution(container, data) {
|
|
15
|
+
const el = d3.select(container);
|
|
16
|
+
el.selectAll('*').remove();
|
|
17
|
+
|
|
18
|
+
if (!data.models || data.models.length === 0) {
|
|
19
|
+
el.append('p').style('color', '#64748b').text('No data');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const size = Math.min(container.clientWidth, 200);
|
|
24
|
+
const radius = size / 2;
|
|
25
|
+
const innerRadius = radius * 0.55;
|
|
26
|
+
|
|
27
|
+
const wrapper = el.append('div').style('display', 'flex').style('align-items', 'center').style('gap', '20px');
|
|
28
|
+
|
|
29
|
+
const svg = wrapper.append('svg').attr('width', size).attr('height', size)
|
|
30
|
+
.append('g').attr('transform', `translate(${size / 2},${size / 2})`);
|
|
31
|
+
|
|
32
|
+
const total = d3.sum(data.models, d => d.total_tokens);
|
|
33
|
+
const pie = d3.pie().value(d => d.total_tokens).sort(null);
|
|
34
|
+
const arc = d3.arc().innerRadius(innerRadius).outerRadius(radius);
|
|
35
|
+
|
|
36
|
+
svg.selectAll('path').data(pie(data.models)).enter().append('path')
|
|
37
|
+
.attr('d', arc).attr('fill', d => MODEL_COLORS[d.data.id] || '#64748b')
|
|
38
|
+
.attr('stroke', '#1e293b').attr('stroke-width', 2);
|
|
39
|
+
|
|
40
|
+
const legend = wrapper.append('div');
|
|
41
|
+
data.models.forEach(m => {
|
|
42
|
+
const pct = ((m.total_tokens / total) * 100).toFixed(1);
|
|
43
|
+
const color = MODEL_COLORS[m.id] || '#64748b';
|
|
44
|
+
const shortName = MODEL_DISPLAY[m.id] || m.id.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2');
|
|
45
|
+
legend.append('div').style('font-size', '11px').style('color', '#94a3b8').style('margin-bottom', '4px')
|
|
46
|
+
.html(`<span style="color:${color}">●</span> ${shortName} — ${pct}%`);
|
|
47
|
+
});
|
|
48
|
+
}
|