ccusage-ui 0.1.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/.claude/settings.local.json +23 -0
- package/package.json +19 -0
- package/public/index.html +715 -0
- package/server.js +109 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash",
|
|
5
|
+
"WebSearch",
|
|
6
|
+
"WebFetch",
|
|
7
|
+
"Read",
|
|
8
|
+
"Write",
|
|
9
|
+
"Edit",
|
|
10
|
+
"Skill(impersonate_cto)",
|
|
11
|
+
"Skill(impersonate_cto:*)",
|
|
12
|
+
"Bash(gh release create:*)",
|
|
13
|
+
"Bash(chmod:*)",
|
|
14
|
+
"Bash(npx:*)",
|
|
15
|
+
"Bash(npm cache clean:*)",
|
|
16
|
+
"Bash(npm view:*)"
|
|
17
|
+
],
|
|
18
|
+
"deny": [],
|
|
19
|
+
"ask": [
|
|
20
|
+
"Bash(rm -rf:*)"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ccusage-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "server.js",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ccusage-ui": "server.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node server.js",
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [],
|
|
13
|
+
"author": "",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"description": "Web UI for Claude Code usage statistics",
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"ccusage": "^18.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,715 @@
|
|
|
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 Code Usage UI</title>
|
|
7
|
+
<!-- Google tag (gtag.js) -->
|
|
8
|
+
<script async src="https://www.googletagmanager.com/gtag/js?id=G-18DJ0TJVR1"></script>
|
|
9
|
+
<script>
|
|
10
|
+
window.dataLayer = window.dataLayer || [];
|
|
11
|
+
function gtag(){dataLayer.push(arguments);}
|
|
12
|
+
gtag('js', new Date());
|
|
13
|
+
gtag('config', 'G-18DJ0TJVR1');
|
|
14
|
+
</script>
|
|
15
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
16
|
+
<script src="https://unpkg.com/@popperjs/core@2"></script>
|
|
17
|
+
<script src="https://unpkg.com/tippy.js@6"></script>
|
|
18
|
+
<style>
|
|
19
|
+
:root {
|
|
20
|
+
--bg-color: #f5f5f7;
|
|
21
|
+
--card-bg: #ffffff;
|
|
22
|
+
--text-primary: #1d1d1f;
|
|
23
|
+
--text-secondary: #86868b;
|
|
24
|
+
--accent-color: #0071e3;
|
|
25
|
+
--border-radius: 12px;
|
|
26
|
+
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
31
|
+
background-color: var(--bg-color);
|
|
32
|
+
color: var(--text-primary);
|
|
33
|
+
margin: 0;
|
|
34
|
+
padding: 40px 20px;
|
|
35
|
+
line-height: 1.5;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.container {
|
|
39
|
+
max-width: 1200px;
|
|
40
|
+
margin: 0 auto;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
header {
|
|
44
|
+
margin-bottom: 40px;
|
|
45
|
+
display: flex;
|
|
46
|
+
justify-content: space-between;
|
|
47
|
+
align-items: center;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
h1 {
|
|
51
|
+
font-size: 32px;
|
|
52
|
+
font-weight: 700;
|
|
53
|
+
margin: 0;
|
|
54
|
+
letter-spacing: -0.02em;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.refresh-btn {
|
|
58
|
+
background: var(--card-bg);
|
|
59
|
+
border: 1px solid #d2d2d7;
|
|
60
|
+
padding: 8px 16px;
|
|
61
|
+
border-radius: 8px;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
font-size: 14px;
|
|
64
|
+
font-weight: 500;
|
|
65
|
+
color: var(--text-primary);
|
|
66
|
+
transition: all 0.2s;
|
|
67
|
+
}
|
|
68
|
+
.refresh-btn:hover { background: #f2f2f7; }
|
|
69
|
+
|
|
70
|
+
.stats-grid {
|
|
71
|
+
display: grid;
|
|
72
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
73
|
+
gap: 20px;
|
|
74
|
+
margin-bottom: 30px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.card {
|
|
78
|
+
background: var(--card-bg);
|
|
79
|
+
border-radius: var(--border-radius);
|
|
80
|
+
padding: 24px;
|
|
81
|
+
box-shadow: var(--shadow);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.stat-card h3 {
|
|
85
|
+
margin: 0 0 10px 0;
|
|
86
|
+
font-size: 14px;
|
|
87
|
+
font-weight: 500;
|
|
88
|
+
color: var(--text-secondary);
|
|
89
|
+
text-transform: uppercase;
|
|
90
|
+
letter-spacing: 0.05em;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.stat-value {
|
|
94
|
+
font-size: 36px;
|
|
95
|
+
font-weight: 700;
|
|
96
|
+
letter-spacing: -0.03em;
|
|
97
|
+
color: var(--text-primary);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.stat-sub {
|
|
101
|
+
font-size: 14px;
|
|
102
|
+
color: var(--text-secondary);
|
|
103
|
+
margin-top: 5px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.chart-container {
|
|
107
|
+
position: relative;
|
|
108
|
+
height: 350px;
|
|
109
|
+
width: 100%;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.tabs {
|
|
113
|
+
display: flex;
|
|
114
|
+
gap: 10px;
|
|
115
|
+
margin-bottom: 20px;
|
|
116
|
+
background: #e5e5ea;
|
|
117
|
+
padding: 4px;
|
|
118
|
+
border-radius: 10px;
|
|
119
|
+
width: fit-content;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.tab {
|
|
123
|
+
padding: 8px 24px;
|
|
124
|
+
border-radius: 8px;
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
font-weight: 500;
|
|
127
|
+
font-size: 14px;
|
|
128
|
+
color: #636366;
|
|
129
|
+
border: none;
|
|
130
|
+
background: transparent;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.tab.active {
|
|
134
|
+
background: white;
|
|
135
|
+
color: #000;
|
|
136
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.tab-sm {
|
|
140
|
+
padding: 4px 12px;
|
|
141
|
+
border-radius: 6px;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
font-size: 12px;
|
|
144
|
+
border: 1px solid #d2d2d7;
|
|
145
|
+
background: transparent;
|
|
146
|
+
color: #636366;
|
|
147
|
+
}
|
|
148
|
+
.tab-sm.active {
|
|
149
|
+
background: #0071e3;
|
|
150
|
+
color: white;
|
|
151
|
+
border-color: #0071e3;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.tier-info {
|
|
155
|
+
display: inline-block;
|
|
156
|
+
width: 16px;
|
|
157
|
+
height: 16px;
|
|
158
|
+
background: #86868b;
|
|
159
|
+
color: white;
|
|
160
|
+
border-radius: 50%;
|
|
161
|
+
font-size: 11px;
|
|
162
|
+
text-align: center;
|
|
163
|
+
line-height: 16px;
|
|
164
|
+
cursor: help;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
footer a:hover {
|
|
168
|
+
color: var(--accent-color) !important;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#loading {
|
|
172
|
+
position: fixed;
|
|
173
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
174
|
+
background: rgba(245, 245, 247, 0.9);
|
|
175
|
+
backdrop-filter: blur(5px);
|
|
176
|
+
z-index: 1000;
|
|
177
|
+
display: flex;
|
|
178
|
+
flex-direction: column;
|
|
179
|
+
justify-content: center;
|
|
180
|
+
align-items: center;
|
|
181
|
+
transition: opacity 0.3s ease;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.spinner {
|
|
185
|
+
width: 40px;
|
|
186
|
+
height: 40px;
|
|
187
|
+
border: 3px solid rgba(0, 113, 227, 0.2);
|
|
188
|
+
border-radius: 50%;
|
|
189
|
+
border-top-color: #0071e3;
|
|
190
|
+
animation: spin 1s ease-in-out infinite;
|
|
191
|
+
margin-bottom: 20px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@keyframes spin {
|
|
195
|
+
to { transform: rotate(360deg); }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.loading-text {
|
|
199
|
+
font-size: 16px;
|
|
200
|
+
font-weight: 500;
|
|
201
|
+
color: var(--text-primary);
|
|
202
|
+
margin-bottom: 8px;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.loading-sub {
|
|
206
|
+
font-size: 13px;
|
|
207
|
+
color: var(--text-secondary);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.grid-2 {
|
|
211
|
+
display: grid;
|
|
212
|
+
grid-template-columns: 2fr 1fr;
|
|
213
|
+
gap: 20px;
|
|
214
|
+
margin-bottom: 30px;
|
|
215
|
+
}
|
|
216
|
+
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
|
|
217
|
+
|
|
218
|
+
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
219
|
+
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #f0f0f0; }
|
|
220
|
+
th { color: var(--text-secondary); font-weight: 600; }
|
|
221
|
+
tr:last-child td { border-bottom: none; }
|
|
222
|
+
.cost-positive { color: #ff3b30; font-weight: 600; }
|
|
223
|
+
</style>
|
|
224
|
+
</head>
|
|
225
|
+
<body>
|
|
226
|
+
<div id="loading">
|
|
227
|
+
<div class="spinner"></div>
|
|
228
|
+
<div class="loading-text">Analyzing Usage Data...</div>
|
|
229
|
+
<div class="loading-sub">Running ccusage CLI</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div class="container" id="dashboard" style="display: none; opacity: 0; transition: opacity 0.5s;">
|
|
233
|
+
<header>
|
|
234
|
+
<div style="display: flex; align-items: baseline; gap: 10px;">
|
|
235
|
+
<h1>Claude Code Usage</h1>
|
|
236
|
+
<span id="appVersion" style="font-size: 12px; color: var(--text-secondary);"></span>
|
|
237
|
+
</div>
|
|
238
|
+
<button class="refresh-btn" onclick="loadData(true)">🔄 Force Refresh</button>
|
|
239
|
+
</header>
|
|
240
|
+
|
|
241
|
+
<div class="stats-grid">
|
|
242
|
+
<div class="card stat-card">
|
|
243
|
+
<h3>Total Cost</h3>
|
|
244
|
+
<div class="stat-value" id="totalCost">$0.00</div>
|
|
245
|
+
<div class="stat-sub">All time estimated</div>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="card stat-card">
|
|
248
|
+
<h3>This Month</h3>
|
|
249
|
+
<div class="stat-value" id="monthCost">$0.00</div>
|
|
250
|
+
<div class="stat-sub" id="monthLabel">Current Month</div>
|
|
251
|
+
</div>
|
|
252
|
+
<div class="card stat-card">
|
|
253
|
+
<h3>Total Tokens</h3>
|
|
254
|
+
<div class="stat-value" id="totalTokens">0M</div>
|
|
255
|
+
<div class="stat-sub">Input + Output + Cache</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div class="grid-2">
|
|
260
|
+
<div class="card">
|
|
261
|
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 20px;">
|
|
262
|
+
<div>
|
|
263
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
264
|
+
<h2 style="margin:0; font-size:18px;">Usage Trend</h2>
|
|
265
|
+
<span class="tier-info" data-tippy-content="🌱 Starter: ~10M<br>⚡ Pro: ~100M (Active User)<br>🔴 Power: ~500M (Tool User)<br>🟣 Mega: ~1B (Agent Operator)<br>🟣 Legendary: 1B+ (Hive Mind)">?</span>
|
|
266
|
+
</div>
|
|
267
|
+
<div id="userLevel" style="font-size:12px; color:#0071e3; margin-top:4px; font-weight:500;"></div>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="tabs">
|
|
270
|
+
<button class="tab active" onclick="switchMetric('tokens')">Tokens</button>
|
|
271
|
+
<button class="tab" onclick="switchMetric('cost')">Cost ($)</button>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
<div style="margin-bottom: 15px; display:flex; gap:10px; justify-content:flex-end;">
|
|
275
|
+
<button class="tab-sm active" id="view-daily" onclick="switchView('daily')">Daily</button>
|
|
276
|
+
<button class="tab-sm" id="view-monthly" onclick="switchView('monthly')">Monthly</button>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="chart-container">
|
|
279
|
+
<canvas id="trendChart"></canvas>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="card">
|
|
283
|
+
<h2 style="margin:0 0 20px 0; font-size:18px;">Model Breakdown (Last 30 Days)</h2>
|
|
284
|
+
<div class="chart-container" style="height: 300px;">
|
|
285
|
+
<canvas id="modelChart"></canvas>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<div class="card">
|
|
291
|
+
<h2 style="margin:0 0 20px 0; font-size:18px;">Recent Daily Usage</h2>
|
|
292
|
+
<div style="overflow-x: auto;">
|
|
293
|
+
<table id="dataTable">
|
|
294
|
+
<thead>
|
|
295
|
+
<tr>
|
|
296
|
+
<th>Date</th>
|
|
297
|
+
<th>Input Tokens</th>
|
|
298
|
+
<th>Output Tokens</th>
|
|
299
|
+
<th>Cache Tokens</th>
|
|
300
|
+
<th>Cost</th>
|
|
301
|
+
</tr>
|
|
302
|
+
</thead>
|
|
303
|
+
<tbody></tbody>
|
|
304
|
+
</table>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<footer style="text-align: center; margin-top: 40px; padding: 30px 20px; color: var(--text-secondary); font-size: 13px; border-top: 1px solid #e5e5e7;">
|
|
309
|
+
<div style="display: flex; justify-content: center; align-items: center; gap: 20px; margin-bottom: 16px;">
|
|
310
|
+
<a href="https://github.com/sowonlabs/ccusage-ui" target="_blank" title="GitHub" style="color: var(--text-secondary); transition: color 0.2s;">
|
|
311
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
|
312
|
+
</a>
|
|
313
|
+
<a href="https://x.com/dohapark81" target="_blank" title="X (Twitter)" style="color: var(--text-secondary); transition: color 0.2s;">
|
|
314
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
|
315
|
+
</a>
|
|
316
|
+
<a href="https://www.threads.com/@dohapark81" target="_blank" title="Threads" style="color: var(--text-secondary); transition: color 0.2s;">
|
|
317
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.783 3.631 2.698 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.96-.065-1.17.408-2.274 1.33-3.109.858-.775 2.071-1.263 3.51-1.413 1.044-.11 2.016-.073 2.91.088-.07-.653-.274-1.176-.617-1.58-.457-.539-1.148-.823-2.002-.823h-.082c-.68.013-1.241.209-1.669.583l-1.394-1.54c.78-.705 1.793-1.082 3.02-1.126h.11c1.515 0 2.725.503 3.6 1.494.78.885 1.207 2.084 1.27 3.56.455.155.874.337 1.255.548 1.253.695 2.17 1.678 2.647 2.843.716 1.748.682 4.494-1.433 6.564-1.905 1.865-4.287 2.727-7.492 2.747zm-1.25-9.01c-1.29.137-2.24.49-2.75.962-.41.382-.594.817-.548 1.298.063 1.04 1.09 1.696 2.61 1.613 1.076-.058 1.907-.453 2.47-1.173.496-.634.783-1.503.855-2.592-.846-.153-1.735-.194-2.637-.108z"/></svg>
|
|
318
|
+
</a>
|
|
319
|
+
<a href="https://www.linkedin.com/in/dohapark81/" target="_blank" title="LinkedIn" style="color: var(--text-secondary); transition: color 0.2s;">
|
|
320
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
|
321
|
+
</a>
|
|
322
|
+
</div>
|
|
323
|
+
<a href="https://www.sowonlabs.com" target="_blank" style="color: var(--text-secondary); text-decoration: none; display: inline-flex; align-items: center; gap: 6px;">
|
|
324
|
+
<img src="https://www.sowonai.com/img/LogoIcon2.png" alt="Sowon Labs" style="height: 20px; width: auto; opacity: 0.7;">
|
|
325
|
+
<span style="opacity: 0.8;">sowonlabs.com</span>
|
|
326
|
+
</a>
|
|
327
|
+
</footer>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<script>
|
|
331
|
+
let dailyData = [];
|
|
332
|
+
let monthlyData = [];
|
|
333
|
+
let trendChart = null;
|
|
334
|
+
let modelChart = null;
|
|
335
|
+
|
|
336
|
+
async function loadData(force = false) {
|
|
337
|
+
const loading = document.getElementById('loading');
|
|
338
|
+
const dashboard = document.getElementById('dashboard');
|
|
339
|
+
const statusText = document.querySelector('.loading-text');
|
|
340
|
+
const subText = document.querySelector('.loading-sub');
|
|
341
|
+
|
|
342
|
+
loading.style.display = 'flex';
|
|
343
|
+
loading.style.opacity = '1';
|
|
344
|
+
dashboard.style.opacity = '0'; // Fade out dashboard while reloading
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
statusText.textContent = force ? "Refreshing Data..." : "Loading Cached Data...";
|
|
348
|
+
subText.textContent = force ? "Ignoring cache, fetching latest logs..." : "Checking for recent updates...";
|
|
349
|
+
|
|
350
|
+
const query = force ? '?force=true' : '';
|
|
351
|
+
|
|
352
|
+
const [dailyRes, monthlyRes] = await Promise.all([
|
|
353
|
+
fetch('/api/daily' + query),
|
|
354
|
+
fetch('/api/monthly' + query)
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
statusText.textContent = "Processing...";
|
|
358
|
+
|
|
359
|
+
const dData = await dailyRes.json();
|
|
360
|
+
const mData = await monthlyRes.json();
|
|
361
|
+
|
|
362
|
+
// Check cache status from headers for feedback (optional)
|
|
363
|
+
const isCacheHit = dailyRes.headers.get('X-Cache') === 'HIT';
|
|
364
|
+
if(isCacheHit) subText.textContent = "Loaded from 5min cache (Fast!)";
|
|
365
|
+
else subText.textContent = "Fresh data loaded!";
|
|
366
|
+
|
|
367
|
+
dailyData = dData.daily || [];
|
|
368
|
+
monthlyData = mData.monthly || [];
|
|
369
|
+
const totals = mData.totals || {};
|
|
370
|
+
|
|
371
|
+
renderDashboard(totals);
|
|
372
|
+
|
|
373
|
+
// Hide loading
|
|
374
|
+
setTimeout(() => {
|
|
375
|
+
loading.style.opacity = '0';
|
|
376
|
+
setTimeout(() => {
|
|
377
|
+
loading.style.display = 'none';
|
|
378
|
+
dashboard.style.display = 'block';
|
|
379
|
+
setTimeout(() => dashboard.style.opacity = '1', 50);
|
|
380
|
+
}, 300);
|
|
381
|
+
}, 500); // Slight delay to show "Fresh data" message
|
|
382
|
+
|
|
383
|
+
} catch (err) {
|
|
384
|
+
statusText.textContent = "Error Loading Data";
|
|
385
|
+
subText.textContent = "Please check server logs.";
|
|
386
|
+
console.error(err);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function renderDashboard(totals) {
|
|
391
|
+
// Update Top Stats
|
|
392
|
+
document.getElementById('totalCost').textContent = '$' + (totals.totalCost || 0).toFixed(2);
|
|
393
|
+
document.getElementById('totalTokens').textContent = ((totals.totalTokens || 0) / 1000000).toFixed(1) + 'M';
|
|
394
|
+
|
|
395
|
+
const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
|
|
396
|
+
const thisMonthData = monthlyData.find(m => m.month === currentMonth);
|
|
397
|
+
document.getElementById('monthCost').textContent = '$' + (thisMonthData ? thisMonthData.totalCost.toFixed(2) : '0.00');
|
|
398
|
+
document.getElementById('monthLabel').textContent = new Date().toLocaleString('default', { month: 'long', year: 'numeric' });
|
|
399
|
+
|
|
400
|
+
// Render Charts
|
|
401
|
+
renderTrendChart();
|
|
402
|
+
renderModelChart();
|
|
403
|
+
renderTable();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let currentMetric = 'tokens'; // Changed default to 'tokens'
|
|
407
|
+
let currentView = 'daily';
|
|
408
|
+
|
|
409
|
+
function switchMetric(metric) {
|
|
410
|
+
currentMetric = metric;
|
|
411
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
412
|
+
|
|
413
|
+
// Update button states manually
|
|
414
|
+
const btns = document.querySelectorAll('.tabs .tab');
|
|
415
|
+
if(metric === 'tokens') { btns[0].classList.add('active'); btns[1].classList.remove('active'); }
|
|
416
|
+
else { btns[1].classList.add('active'); btns[0].classList.remove('active'); }
|
|
417
|
+
|
|
418
|
+
renderTrendChart();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function switchView(view) {
|
|
422
|
+
currentView = view;
|
|
423
|
+
document.getElementById('view-daily').classList.toggle('active', view === 'daily');
|
|
424
|
+
document.getElementById('view-monthly').classList.toggle('active', view === 'monthly');
|
|
425
|
+
renderTrendChart();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function renderTrendChart() {
|
|
429
|
+
const ctx = document.getElementById('trendChart').getContext('2d');
|
|
430
|
+
if (trendChart) trendChart.destroy();
|
|
431
|
+
|
|
432
|
+
const dataSrc = currentView === 'daily' ? dailyData.slice(-30) : monthlyData;
|
|
433
|
+
const labels = dataSrc.map(d => currentView === 'daily' ? d.date : d.month);
|
|
434
|
+
|
|
435
|
+
let datasetData;
|
|
436
|
+
let label;
|
|
437
|
+
let color;
|
|
438
|
+
let annotations = {};
|
|
439
|
+
|
|
440
|
+
if (currentMetric === 'cost') {
|
|
441
|
+
datasetData = dataSrc.map(d => d.totalCost);
|
|
442
|
+
label = 'Cost ($)';
|
|
443
|
+
color = '#0071e3';
|
|
444
|
+
document.getElementById('userLevel').textContent = '';
|
|
445
|
+
} else {
|
|
446
|
+
// Tokens in Millions
|
|
447
|
+
datasetData = dataSrc.map(d => (d.totalTokens || (d.inputTokens + d.outputTokens + (d.cacheReadTokens||0) + (d.cacheCreationTokens||0))) / 1000000);
|
|
448
|
+
label = 'Total Tokens (Millions)';
|
|
449
|
+
color = '#34c759';
|
|
450
|
+
|
|
451
|
+
// Add Benchmarks
|
|
452
|
+
if (currentMetric === 'tokens') {
|
|
453
|
+
if (currentView === 'monthly') {
|
|
454
|
+
// Monthly Benchmarks & Projection
|
|
455
|
+
const lastDataPoint = dataSrc[dataSrc.length - 1];
|
|
456
|
+
const lastVal = datasetData[datasetData.length - 1]; // Millions
|
|
457
|
+
|
|
458
|
+
const now = new Date();
|
|
459
|
+
const currentMonthStr = now.toISOString().slice(0, 7);
|
|
460
|
+
|
|
461
|
+
let displayHTML = "";
|
|
462
|
+
|
|
463
|
+
const getLevelName = (val) => {
|
|
464
|
+
if (val >= 1000) return "🟣 Legendary (Hive Mind)";
|
|
465
|
+
if (val >= 500) return "🟣 Mega (Agent Operator)";
|
|
466
|
+
if (val >= 100) return "🔴 Power (Tool User)";
|
|
467
|
+
if (val >= 10) return "âš¡ Pro (Assisted)";
|
|
468
|
+
return "🌱 Starter";
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// Only project if the last data point is the current month
|
|
472
|
+
if (lastDataPoint && lastDataPoint.month === currentMonthStr) {
|
|
473
|
+
const day = now.getDate();
|
|
474
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
475
|
+
const projection = (lastVal / day) * daysInMonth;
|
|
476
|
+
|
|
477
|
+
displayHTML = `
|
|
478
|
+
<div style="display:flex; flex-direction:column; gap:4px; border-left: 3px solid #0071e3; padding-left: 10px;">
|
|
479
|
+
<div style="font-size: 14px; color: #1d1d1f; display: flex; align-items: center;">
|
|
480
|
+
Current: <b style="color:#0071e3">${getLevelName(lastVal)}</b> <span style="color:#86868b; margin-left: 5px;">(${lastVal.toFixed(0)}M)</span>
|
|
481
|
+
</div>
|
|
482
|
+
<div style="font-size: 14px; color: #1d1d1f;">
|
|
483
|
+
🚀 Pace: <b style="color:#5856d6">${getLevelName(projection)}</b> <span style="color:#86868b">(${projection.toFixed(0)}M projected)</span>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
`;
|
|
487
|
+
} else {
|
|
488
|
+
displayHTML = `<div>Level: <b>${getLevelName(lastVal)}</b></div>`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
document.getElementById('userLevel').innerHTML = displayHTML;
|
|
492
|
+
} else {
|
|
493
|
+
// Daily Benchmarks
|
|
494
|
+
document.getElementById('userLevel').textContent = 'Daily Pace Benchmarks (Monthly Equivalent)';
|
|
495
|
+
}
|
|
496
|
+
} else {
|
|
497
|
+
document.getElementById('userLevel').textContent = '';
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const plugins = [
|
|
502
|
+
{
|
|
503
|
+
id: 'custom_lines',
|
|
504
|
+
beforeDraw: (chart) => {
|
|
505
|
+
if (currentMetric === 'tokens') {
|
|
506
|
+
const ctx = chart.ctx;
|
|
507
|
+
const yAxis = chart.scales.y;
|
|
508
|
+
const xAxis = chart.scales.x;
|
|
509
|
+
|
|
510
|
+
const drawLine = (value, color, text) => {
|
|
511
|
+
const y = yAxis.getPixelForValue(value);
|
|
512
|
+
if (y <= yAxis.bottom && y >= yAxis.top) {
|
|
513
|
+
ctx.save();
|
|
514
|
+
ctx.beginPath();
|
|
515
|
+
ctx.strokeStyle = color;
|
|
516
|
+
ctx.lineWidth = 2;
|
|
517
|
+
ctx.setLineDash([5, 5]);
|
|
518
|
+
ctx.moveTo(xAxis.left, y);
|
|
519
|
+
ctx.lineTo(xAxis.right, y);
|
|
520
|
+
ctx.stroke();
|
|
521
|
+
|
|
522
|
+
ctx.fillStyle = color;
|
|
523
|
+
ctx.font = "bold 11px -apple-system";
|
|
524
|
+
ctx.fillText(text, xAxis.left + 5, y - 6);
|
|
525
|
+
ctx.restore();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
if (currentView === 'monthly') {
|
|
530
|
+
drawLine(100, '#ff3b30', 'Power (100M)');
|
|
531
|
+
drawLine(500, '#af52de', 'Mega (500M)');
|
|
532
|
+
drawLine(1000, '#5856d6', 'Legendary (1B)');
|
|
533
|
+
} else {
|
|
534
|
+
// Daily Benchmarks
|
|
535
|
+
|
|
536
|
+
// 1. Tier Thresholds (Lighter, background context)
|
|
537
|
+
// Legendary (1B/mo -> ~33.3M/day)
|
|
538
|
+
drawLine(33.3, 'rgba(88, 86, 214, 0.4)', 'Legendary (33M)');
|
|
539
|
+
// Mega (500M/mo -> ~16.6M/day)
|
|
540
|
+
drawLine(16.6, 'rgba(175, 82, 222, 0.4)', 'Mega (16M)');
|
|
541
|
+
// Power (100M/mo -> ~3.3M/day)
|
|
542
|
+
drawLine(3.3, 'rgba(255, 59, 48, 0.4)', 'Power (3.3M)');
|
|
543
|
+
|
|
544
|
+
// 2. User Average (Stronger, personal metric)
|
|
545
|
+
const sum = datasetData.reduce((a, b) => a + b, 0);
|
|
546
|
+
const avg = sum / datasetData.length;
|
|
547
|
+
drawLine(avg, '#0071e3', `My Avg (${avg.toFixed(1)}M)`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
];
|
|
553
|
+
|
|
554
|
+
let datasets = [];
|
|
555
|
+
const now = new Date();
|
|
556
|
+
const currentMonthStr = now.toISOString().slice(0, 7);
|
|
557
|
+
|
|
558
|
+
// Logic for Monthly Token Projection
|
|
559
|
+
if (currentMetric === 'tokens' && currentView === 'monthly') {
|
|
560
|
+
const lastIdx = dataSrc.length - 1;
|
|
561
|
+
const lastData = dataSrc[lastIdx];
|
|
562
|
+
|
|
563
|
+
// Only if the last bar is the current month
|
|
564
|
+
if (lastData && lastData.month === currentMonthStr) {
|
|
565
|
+
const day = now.getDate();
|
|
566
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
567
|
+
|
|
568
|
+
if (day > 0 && day < daysInMonth) {
|
|
569
|
+
const currentVal = datasetData[lastIdx];
|
|
570
|
+
const projectedTotal = (currentVal / day) * daysInMonth;
|
|
571
|
+
const remaining = projectedTotal - currentVal;
|
|
572
|
+
|
|
573
|
+
// Create Projection Dataset (Zeros for past months, value for current)
|
|
574
|
+
const projectionData = new Array(datasetData.length).fill(0);
|
|
575
|
+
projectionData[lastIdx] = remaining;
|
|
576
|
+
|
|
577
|
+
datasets = [
|
|
578
|
+
{
|
|
579
|
+
label: 'Actual',
|
|
580
|
+
data: datasetData,
|
|
581
|
+
backgroundColor: color,
|
|
582
|
+
borderRadius: 4,
|
|
583
|
+
stack: 'stack1'
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
label: 'Projected',
|
|
587
|
+
data: projectionData,
|
|
588
|
+
backgroundColor: 'rgba(52, 199, 89, 0.1)', // Very light green
|
|
589
|
+
borderColor: '#34c759',
|
|
590
|
+
borderWidth: 2,
|
|
591
|
+
borderDash: [5, 5], // Dotted line style
|
|
592
|
+
borderRadius: 4,
|
|
593
|
+
stack: 'stack1'
|
|
594
|
+
}
|
|
595
|
+
];
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Fallback for standard views
|
|
601
|
+
if (datasets.length === 0) {
|
|
602
|
+
datasets = [{
|
|
603
|
+
label: label,
|
|
604
|
+
data: datasetData,
|
|
605
|
+
backgroundColor: color,
|
|
606
|
+
borderRadius: 4,
|
|
607
|
+
hoverBackgroundColor: color
|
|
608
|
+
}];
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
trendChart = new Chart(ctx, {
|
|
612
|
+
type: 'bar',
|
|
613
|
+
data: {
|
|
614
|
+
labels: labels,
|
|
615
|
+
datasets: datasets
|
|
616
|
+
},
|
|
617
|
+
options: {
|
|
618
|
+
responsive: true,
|
|
619
|
+
maintainAspectRatio: false,
|
|
620
|
+
plugins: {
|
|
621
|
+
legend: { display: datasets.length > 1 }, // Show legend if projected
|
|
622
|
+
tooltip: {
|
|
623
|
+
callbacks: {
|
|
624
|
+
label: function(context) {
|
|
625
|
+
return context.dataset.label + ': ' + context.raw.toFixed(1) + (currentMetric === 'cost' ? ' $' : ' M');
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
scales: {
|
|
631
|
+
y: {
|
|
632
|
+
beginAtZero: true,
|
|
633
|
+
stacked: true, // Enable stacking
|
|
634
|
+
grid: { borderDash: [2, 2] },
|
|
635
|
+
suggestedMax: 10
|
|
636
|
+
},
|
|
637
|
+
x: {
|
|
638
|
+
grid: { display: false },
|
|
639
|
+
stacked: true
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
plugins: plugins
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function renderModelChart() {
|
|
648
|
+
const ctx = document.getElementById('modelChart').getContext('2d');
|
|
649
|
+
if (modelChart) modelChart.destroy();
|
|
650
|
+
|
|
651
|
+
// Aggregate costs by model from the last 30 days of daily data
|
|
652
|
+
const modelCosts = {};
|
|
653
|
+
dailyData.slice(-30).forEach(day => {
|
|
654
|
+
if (day.modelBreakdowns) {
|
|
655
|
+
day.modelBreakdowns.forEach(m => {
|
|
656
|
+
const name = m.modelName.split('-').slice(0, 2).join(' ');
|
|
657
|
+
modelCosts[name] = (modelCosts[name] || 0) + m.cost;
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
modelChart = new Chart(ctx, {
|
|
663
|
+
type: 'doughnut',
|
|
664
|
+
data: {
|
|
665
|
+
labels: Object.keys(modelCosts),
|
|
666
|
+
datasets: [{
|
|
667
|
+
data: Object.values(modelCosts),
|
|
668
|
+
backgroundColor: ['#5856d6', '#0071e3', '#34c759', '#ff9500', '#ff2d55'],
|
|
669
|
+
borderWidth: 0
|
|
670
|
+
}]
|
|
671
|
+
},
|
|
672
|
+
options: {
|
|
673
|
+
responsive: true,
|
|
674
|
+
maintainAspectRatio: false,
|
|
675
|
+
plugins: { legend: { position: 'right' } }
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function renderTable() {
|
|
681
|
+
const tbody = document.querySelector('#dataTable tbody');
|
|
682
|
+
tbody.innerHTML = '';
|
|
683
|
+
// Show last 10 days reversed
|
|
684
|
+
const recent = [...dailyData].reverse().slice(0, 10);
|
|
685
|
+
|
|
686
|
+
recent.forEach(d => {
|
|
687
|
+
const tr = document.createElement('tr');
|
|
688
|
+
tr.innerHTML = `
|
|
689
|
+
<td style="font-weight:500">${d.date}</td>
|
|
690
|
+
<td>${(d.inputTokens/1000).toFixed(1)}k</td>
|
|
691
|
+
<td>${(d.outputTokens/1000).toFixed(1)}k</td>
|
|
692
|
+
<td>${((d.cacheReadTokens + d.cacheCreationTokens)/1000000).toFixed(2)}M</td>
|
|
693
|
+
<td class="cost-positive">$${d.totalCost.toFixed(2)}</td>
|
|
694
|
+
`;
|
|
695
|
+
tbody.appendChild(tr);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Initialize tooltips
|
|
700
|
+
tippy('.tier-info', {
|
|
701
|
+
allowHTML: true,
|
|
702
|
+
placement: 'bottom',
|
|
703
|
+
theme: 'light'
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
// Load version
|
|
707
|
+
fetch('/api/version')
|
|
708
|
+
.then(r => r.json())
|
|
709
|
+
.then(data => document.getElementById('appVersion').textContent = 'v' + data.version);
|
|
710
|
+
|
|
711
|
+
// Start loading
|
|
712
|
+
loadData();
|
|
713
|
+
</script>
|
|
714
|
+
</body>
|
|
715
|
+
</html>
|
package/server.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const { exec } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { loadDailyUsageData, loadMonthlyUsageData } = require('ccusage/data-loader');
|
|
7
|
+
const { calculateTotals, createTotalsObject, getTotalTokens } = require('ccusage/calculate-cost');
|
|
8
|
+
|
|
9
|
+
const PORT = 8150;
|
|
10
|
+
const pkg = require('./package.json');
|
|
11
|
+
const OPEN_CMD = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
12
|
+
|
|
13
|
+
// Format data to match CLI JSON output
|
|
14
|
+
const formatDailyData = (data) => data.map(d => ({
|
|
15
|
+
date: d.date,
|
|
16
|
+
inputTokens: d.inputTokens,
|
|
17
|
+
outputTokens: d.outputTokens,
|
|
18
|
+
cacheCreationTokens: d.cacheCreationTokens,
|
|
19
|
+
cacheReadTokens: d.cacheReadTokens,
|
|
20
|
+
totalTokens: getTotalTokens(d),
|
|
21
|
+
totalCost: d.totalCost,
|
|
22
|
+
modelsUsed: d.modelsUsed,
|
|
23
|
+
modelBreakdowns: d.modelBreakdowns,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const formatMonthlyData = (data) => data.map(d => ({
|
|
27
|
+
month: d.month,
|
|
28
|
+
inputTokens: d.inputTokens,
|
|
29
|
+
outputTokens: d.outputTokens,
|
|
30
|
+
cacheCreationTokens: d.cacheCreationTokens,
|
|
31
|
+
cacheReadTokens: d.cacheReadTokens,
|
|
32
|
+
totalTokens: getTotalTokens(d),
|
|
33
|
+
totalCost: d.totalCost,
|
|
34
|
+
modelsUsed: d.modelsUsed,
|
|
35
|
+
modelBreakdowns: d.modelBreakdowns,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const server = http.createServer(async (req, res) => {
|
|
39
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
40
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
41
|
+
const force = url.searchParams.get('force') === 'true';
|
|
42
|
+
|
|
43
|
+
if (url.pathname === '/') {
|
|
44
|
+
fs.readFile(path.join(__dirname, 'public', 'index.html'), (err, content) => {
|
|
45
|
+
if (err) {
|
|
46
|
+
res.writeHead(500);
|
|
47
|
+
res.end('Error loading index.html');
|
|
48
|
+
} else {
|
|
49
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
50
|
+
res.end(content);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Cache Implementation
|
|
57
|
+
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
58
|
+
const handleCachedRequest = async (key, loader, formatter, dataKey) => {
|
|
59
|
+
if (!global.cache) global.cache = {};
|
|
60
|
+
const cached = global.cache[key];
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
|
|
63
|
+
if (!force && cached && (now - cached.timestamp < CACHE_DURATION)) {
|
|
64
|
+
console.log(`Serving ${key} from cache (${Math.round((CACHE_DURATION - (now - cached.timestamp))/1000)}s left)`);
|
|
65
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Cache': 'HIT' });
|
|
66
|
+
res.end(cached.data);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
console.log(`Fetching ${key} data (Live)...`);
|
|
72
|
+
const rawData = await loader();
|
|
73
|
+
const totals = createTotalsObject(calculateTotals(rawData));
|
|
74
|
+
const result = JSON.stringify({ [dataKey]: formatter(rawData), totals });
|
|
75
|
+
global.cache[key] = { timestamp: now, data: result };
|
|
76
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Cache': 'MISS' });
|
|
77
|
+
res.end(result);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error(`Error fetching ${key} data:`, error);
|
|
80
|
+
res.writeHead(500);
|
|
81
|
+
res.end(JSON.stringify({ error: `Failed to fetch ${key} data` }));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (url.pathname === '/api/monthly') {
|
|
86
|
+
await handleCachedRequest('monthly', loadMonthlyUsageData, formatMonthlyData, 'monthly');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (url.pathname === '/api/daily') {
|
|
91
|
+
await handleCachedRequest('daily', loadDailyUsageData, formatDailyData, 'daily');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (url.pathname === '/api/version') {
|
|
96
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
97
|
+
res.end(JSON.stringify({ version: pkg.version }));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
res.writeHead(404);
|
|
102
|
+
res.end('Not Found');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
server.listen(PORT, () => {
|
|
106
|
+
console.log(`\n🚀 ccusage-ui server running at http://localhost:${PORT}`);
|
|
107
|
+
console.log('Fetching data and opening browser...\n');
|
|
108
|
+
exec(`${OPEN_CMD} http://localhost:${PORT}`);
|
|
109
|
+
});
|