@caius_kong/ccusage-dashboard 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/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # ccusage-ui
2
+
3
+ A tiny, zero-dependency local dashboard for [ccusage](https://github.com/ccusage/ccusage).
4
+ Shows **today / this week / this month / custom range** cost & tokens grouped by model,
5
+ a **30-day cost trend chart**, and a **monthly budget alert** (default cap $300).
6
+ Auto-refreshing, numbers straight from ccusage.
7
+
8
+ All numbers come directly from `ccusage ... --json`, so the figures always match
9
+ what ccusage reports (no own pricing tables, no drift).
10
+
11
+ ## Run
12
+
13
+ ```bash
14
+ python3 server.py # → http://127.0.0.1:8799
15
+ python3 server.py --port 9000 # different port
16
+ python3 server.py --budget 500 # change monthly budget cap (default 300)
17
+ python3 server.py --ccusage-path /path/to/ccusage # use a specific binary
18
+ ./run.sh # opens the browser automatically
19
+ ```
20
+
21
+ Requires Python 3.8+ (stdlib only). The server resolves a fast local ccusage
22
+ (npm/bun cache → PATH → `npx`), warms caches synchronously (~3s), then serves,
23
+ so the first page load is instant rather than a 7s cold wait. Use `--no-warm`
24
+ to skip that and accept a slower first load.
25
+
26
+ ## Endpoints
27
+
28
+ | Path | What it returns |
29
+ |---|---|
30
+ | `/` | the dashboard |
31
+ | `/api/today` | today's totals + per-model breakdown (cached 15s) |
32
+ | `/api/week` | this week's totals + per-model breakdown (cached 60s) |
33
+ | `/api/month` | this month's totals per-model, plus `budget` + `budgetUsedPct` (cached 60s) |
34
+ | `/api/range?from=YYYY-MM-DD&to=YYYY-MM-DD` | aggregated totals for a date range (cached 120s) |
35
+ | `/api/trend?days=30` | per-day cost series for the last N days (2m cache, capped 366) |
36
+ | `/api/health` | liveness check incl. current budget |
37
+
38
+ ## Files
39
+
40
+ - `server.py` — Python stdlib HTTP server; resolves a local ccusage, shells out with `--json --offline`, warms caches on boot
41
+ - `index.html` — single-file dashboard (no build step, no CDN)
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ccusage-dashboard — one-command launcher.
4
+ *
5
+ * Locates the bundled server.py (and its index.html sibling) and runs it with
6
+ * the local python3, forwarding any CLI args. The server itself shells out to
7
+ * ccusage for all numbers, so the dashboard always matches ccusage.
8
+ */
9
+ 'use strict';
10
+
11
+ const { spawn, spawnSync } = require('node:child_process');
12
+ const path = require('node:path');
13
+ const fs = require('node:fs');
14
+
15
+ // Data files live in package/lib (installed layout) or the repo root (dev layout).
16
+ function resolveLibFile(name) {
17
+ for (const dir of [path.join(__dirname, '..', 'lib'), path.join(__dirname, '..')]) {
18
+ const p = path.join(dir, name);
19
+ if (fs.existsSync(p)) return p;
20
+ }
21
+ console.error(`[ccusage-dashboard] missing bundled file ${name} — broken install?`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const serverPy = resolveLibFile('server.py');
26
+
27
+ function findPython() {
28
+ for (const bin of ['python3', 'python']) {
29
+ try {
30
+ const r = spawnSync(bin, ['--version'], { stdio: 'ignore', timeout: 5000 });
31
+ if (r.status === 0) return bin;
32
+ } catch { /* try next */ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ const py = findPython();
38
+ if (!py) {
39
+ console.error('[ccusage-dashboard] python3 is required but was not found on PATH.');
40
+ console.error('Install it with: brew install python3 (macOS)');
41
+ process.exit(1);
42
+ }
43
+
44
+ const args = process.argv.slice(2);
45
+ if (!args.includes('--open') && !process.env.CCUSAGE_UI_NO_OPEN) {
46
+ args.push('--open'); // default: open browser after start
47
+ }
48
+
49
+ const child = spawn(py, [serverPy, ...args], { stdio: 'inherit', env: { ...process.env } });
50
+
51
+ for (const sig of ['SIGINT', 'SIGTERM']) {
52
+ process.on(sig, () => { if (!child.killed) child.kill(sig); });
53
+ }
54
+ child.on('exit', (code) => process.exit(code == null ? 0 : code));
package/lib/index.html ADDED
@@ -0,0 +1,281 @@
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">
6
+ <title>ccusage — Live Cost Dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg:#0d1117; --panel:#161b22; --panel-2:#1c2330; --border:#2a3441;
10
+ --text:#e6edf3; --muted:#8b949e; --accent:#58a6ff; --green:#3fb950;
11
+ --red:#f85149; --yellow:#d29922;
12
+ }
13
+ *{box-sizing:border-box;margin:0;padding:0}
14
+ body{background:var(--bg);color:var(--text);font:14px/1.45 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;padding:24px;max-width:1120px;margin:0 auto}
15
+ header{display:flex;align-items:baseline;gap:14px;margin-bottom:16px;flex-wrap:wrap}
16
+ h1{font-size:20px;font-weight:650;letter-spacing:-.2px}
17
+ .sub{color:var(--muted);font-size:13px}
18
+ .status{margin-left:auto;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px}
19
+ .dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
20
+ .dot.ok{background:var(--green);box-shadow:0 0 6px var(--green)}
21
+ .dot.err{background:var(--red);box-shadow:0 0 6px var(--red)}
22
+ .tabs{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
23
+ .tab{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:7px 16px;cursor:pointer;color:var(--muted);font-size:13px}
24
+ .tab.active{background:var(--panel-2);border-color:var(--accent);color:var(--text);font-weight:600}
25
+ .tab:hover{color:var(--text)}
26
+ .tab-budget{margin-left:auto;display:flex;align-items:center;gap:10px}
27
+ .budget-pill{border-radius:999px;padding:4px 12px;font-size:12px;font-weight:600;border:1px solid var(--border)}
28
+ .budget-ok{color:var(--green);border-color:var(--border)}
29
+ .budget-warn{color:var(--yellow);border-color:var(--yellow)}
30
+ .budget-danger{color:#fff;background:var(--red);border-color:var(--red)}
31
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;margin-bottom:22px}
32
+ .card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
33
+ .card .label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.6px}
34
+ .card .value{font-size:26px;font-weight:650;margin-top:4px;font-variant-numeric:tabular-nums}
35
+ .card .value small{font-size:15px;color:var(--muted);font-weight:500}
36
+ .card .period{color:var(--muted);font-size:12px;margin-top:2px}
37
+ .panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:16px}
38
+ .panel h2{font-size:15px;margin-bottom:12px;display:flex;justify-content:space-between;align-items:baseline}
39
+ .panel h2 .tot{color:var(--accent);font-weight:650;font-variant-numeric:tabular-nums}
40
+ .row{padding:9px 2px;border-bottom:1px solid var(--border)}
41
+ .row:last-child{border-bottom:none}
42
+ .row-top{display:flex;justify-content:space-between;gap:12px;margin-bottom:5px}
43
+ .row .name{font-weight:550;word-break:break-all}
44
+ .row .cost{font-variant-numeric:tabular-nums;white-space:nowrap}
45
+ .row .toks{color:var(--muted);font-size:12px;display:flex;gap:12px;flex-wrap:wrap}
46
+ .bar{height:5px;background:var(--panel-2);border-radius:3px;overflow:hidden;margin-top:6px}
47
+ .bar>div{height:100%;background:linear-gradient(90deg,var(--accent),#79c0ff);border-radius:3px}
48
+ .empty{color:var(--muted);padding:18px 0;text-align:center}
49
+ .err{color:var(--red)}
50
+ .pill{background:var(--panel-2);border:1px solid var(--border);border-radius:999px;padding:1px 8px;font-size:11px;color:var(--muted);margin-left:6px}
51
+ .hint{color:var(--muted);font-size:12px;margin-top:14px;text-align:center}
52
+ .range{display:flex;gap:8px;align-items:center;margin-left:6px}
53
+ .range input{background:var(--panel-2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 8px;font-size:12px;font-family:inherit}
54
+ .range button{background:var(--panel-2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 12px;cursor:pointer;font-size:12px}
55
+ .range button:hover{border-color:var(--accent)}
56
+ .budget-band{margin:2px 0 14px;background:var(--panel-2);border-radius:8px;padding:12px 16px;display:flex;flex-direction:column;gap:8px}
57
+ .budget-band .bl{display:flex;justify-content:space-between;font-size:13px;color:var(--muted)}
58
+ .budget-band .bl b{color:var(--text)}
59
+ .bgtrack{height:8px;background:#0a0e14;border-radius:5px;overflow:hidden}
60
+ .bgtrack>div{height:100%;border-radius:5px;transition:width .4s}
61
+ .bg-ok{background:var(--green)} .bg-warn{background:var(--yellow)} .bg-danger{background:var(--red)}
62
+ #trendChart{width:100%;height:220px}
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <header>
67
+ <h1>ccusage · Live Cost</h1>
68
+ <span class="sub">by model, straight from ccusage</span>
69
+ <span class="status"><span class="dot" id="dot"></span><span id="status">connecting…</span></span>
70
+ </header>
71
+
72
+ <div class="tabs">
73
+ <div class="tab active" data-tab="today">Today</div>
74
+ <div class="tab" data-tab="week">This Week</div>
75
+ <div class="tab" data-tab="month">This Month</div>
76
+ <div class="tab" data-tab="range">Custom Range</div>
77
+ <div class="tab-budget">
78
+ <span class="budget-pill" id="budgetPill">budget …</span>
79
+ </div>
80
+ </div>
81
+
82
+ <div id="budgetBand" class="budget-band" style="display:none">
83
+ <div class="bl"><span>Monthly budget: <b id="bgCap"></b></span><span id="bgUsed"></span></div>
84
+ <div class="bgtrack"><div id="bgFill" style="width:0%"></div></div>
85
+ </div>
86
+
87
+ <div class="cards" id="cards">
88
+ <div class="card"><div class="label" id="c1label">Today</div><div class="value" id="c1">–</div><div class="period" id="c1p"></div></div>
89
+ <div class="card"><div class="label">Tokens</div><div class="value" id="c2">–</div></div>
90
+ <div class="card"><div class="label">Cache Read</div><div class="value" id="c3">–</div></div>
91
+ <div class="card"><div class="label">Cache Write</div><div class="value" id="c4">–</div></div>
92
+ </div>
93
+
94
+ <div id="rangeRow" class="panel" style="display:none">
95
+ <h2>Custom Range <span class="tot" id="rangeSum"></span></h2>
96
+ <div class="range">
97
+ <input type="date" id="fromDate"> <span>→</span> <input type="date" id="toDate">
98
+ <button id="rangeGo">Apply</button>
99
+ <span class="pill" id="rangeDays"></span>
100
+ </div>
101
+ </div>
102
+
103
+ <div class="panel"><h2>30-Day Trend <span class="tot" id="trendSum"></span></h2><canvas id="trendChart"></canvas></div>
104
+
105
+ <div class="panel"><h2>Models <span class="tot" id="modelSum"></span></h2><div id="modelList"><div class="empty">loading…</div></div></div>
106
+
107
+ <div class="hint">auto-refresh: today 15s · others 60s · costs in USD · <span id="lastUpd"></span></div>
108
+
109
+ <script>
110
+ const $=(id)=>document.getElementById(id);
111
+ const tabs={today:{label:"Today",len:0},week:{label:"This Week",len:0},month:{label:"This Month",len:0},range:{label:"Custom Range",len:1}};
112
+ let active="today", modelCtx=[];
113
+
114
+ function fmtUsd(n){return "$"+(n==null?"–":Number(n).toFixed(2));}
115
+ function fmtTok(n){
116
+ if(n==null)return "–";
117
+ if(n>=1e6)return (n/1e6).toFixed(2)+"M";
118
+ if(n>=1e3)return (n/1e3).toFixed(1)+"k";
119
+ return String(n);
120
+ }
121
+ function esc(s){const d=document.createElement("div");d.textContent=s;return d.innerHTML;}
122
+
123
+ function renderModels(data){
124
+ const total=data.totalCost||0;
125
+ const models=data.models||[];
126
+ if(!models.length){$("modelList").innerHTML='<div class="empty">no usage recorded</div>';modelCtx=[];return;}
127
+ modelCtx=models;
128
+ $("modelList").innerHTML=models.map(m=>{
129
+ const pct=total>0?(m.cost/total)*100:0;
130
+ const m2=m.name.match(/^\[(.+?)\]\s*(.*)$/);
131
+ const badge=m2?`<span class="pill">${esc(m2[1])}</span>`:"";
132
+ const mn=m2?esc(m2[2]||m2[1]):esc(m.name);
133
+ return `<div class="row"><div class="row-top"><span class="name">${mn}${badge ? " " + badge : ""}</span><span class="cost">${fmtUsd(m.cost)} · ${pct.toFixed(1)}%</span></div>
134
+ <div class="toks"><span title="input">in ${fmtTok(m.inputTokens)}</span><span title="output">out ${fmtTok(m.outputTokens)}</span><span title="cache read">read ${fmtTok(m.cacheReadTokens)}</span><span title="cache write">write ${fmtTok(m.cacheCreationTokens)}</span></div>
135
+ <div class="bar"><div style="width:${Math.max(pct,1.2)}%"></div></div></div>`;
136
+ }).join("");
137
+ }
138
+
139
+ function applyBudget(month){
140
+ const cap=month.budget;
141
+ if(!cap){$("budgetBand").style.display="none";$("budgetPill").textContent="budget: —";return;}
142
+ const spent=month.totalCost||0, pct=month.budgetUsedPct||0;
143
+ $("bgCap").textContent=fmtUsd(cap);
144
+ $("bgUsed").textContent=`${fmtUsd(spent)} spent · ${pct.toFixed(1)}% of budget`;
145
+ const fill=$("bgFill");
146
+ fill.style.width=Math.min(pct,100)+"%";
147
+ fill.className = pct>=100?"bg-danger":(pct>=80?"bg-warn":"bg-ok");
148
+ $("budgetBand").style.display="flex";
149
+ let cls="budget-ok",txt=`budget ${fmtUsd(cap)} · ${pct.toFixed(0)}%`;
150
+ if(pct>=100){cls="budget-danger";txt=`⚠ ${fmtUsd(spent)} ≥ budget`;}
151
+ else if(pct>=80){cls="budget-warn";txt=`${fmtUsd(spent)} / ${fmtUsd(cap)} (${pct.toFixed(0)}%)`;}
152
+ const p=$("budgetPill");p.className="budget-pill "+cls;p.textContent=txt;
153
+ }
154
+
155
+ function applyCards(data){
156
+ const total=data.totalCost||0;
157
+ $("c1").innerHTML=fmtUsd(total)+(total?"<small> USD</small>":"");
158
+ $("c1p").textContent=data.period||"";
159
+ const tt=(data.inputTokens||0)+(data.outputTokens||0)+(data.cacheReadTokens||0)+(data.cacheCreationTokens||0);
160
+ $("c2").textContent=fmtTok(tt);
161
+ $("c3").textContent=fmtTok(data.cacheReadTokens);
162
+ $("c4").textContent=fmtTok(data.cacheCreationTokens);
163
+ $("c1label").textContent=tabs[active].label;
164
+ $("modelSum").textContent=fmtUsd(total);
165
+ }
166
+ function applyCardsRange(data){
167
+ const p=$("rangeSum");
168
+ p.textContent=fmtUsd(data.totalCost||0);
169
+ $("rangeDays").textContent=data.dayCount?`${data.dayCount} days`:"";
170
+ applyCards(data);
171
+ renderModels(data);
172
+ }
173
+
174
+ async function loadModels(){
175
+ let ep,data;
176
+ if(active==="today"){ep="/api/today";data=await (await fetch(ep)).json();}
177
+ else if(active==="week"){data=await (await fetch("/api/week")).json();}
178
+ else if(active==="month"){data=await (await fetch("/api/month")).json();applyBudget(data);}
179
+ else{return;} // handled by rangeApply
180
+ if(data.error)throw new Error(data.error);
181
+ applyCards(data);
182
+ renderModels(data);
183
+ }
184
+
185
+ async function rangeApply(){
186
+ const f=$("fromDate").value,t=$("toDate").value;
187
+ if(!f||!t){return;}
188
+ $("modelList").innerHTML='<div class="empty">loading range…</div>';
189
+ const data=await(await fetch(`/api/range?from=${f}&to=${t}`)).json();
190
+ if(data.error)throw new Error(data.error);
191
+ applyCardsRange(data);
192
+ }
193
+
194
+ async function loadTrend(){
195
+ const d=await(await fetch("/api/trend?days=30")).json();
196
+ if(d.error){return;}
197
+ drawTrend(d.days||[]);
198
+ $("trendSum").textContent=fmtUsd((d.days||[]).reduce((a,x)=>a+(x.totalCost||0),0));
199
+ }
200
+
201
+ function drawTrend(days){
202
+ const cv=$("trendChart"),ctx=cv.getContext("2d");
203
+ const W=(cv.width=cv.offsetWidth)||900,H=(cv.height=220);
204
+ ctx.clearRect(0,0,W,H);
205
+ if(!days.length){ctx.fillStyle="#8b949e";ctx.fillText("no data",10,20);return;}
206
+ const max=Math.max(...days.map(x=>x.totalCost),0.0001);
207
+ const padL=54,padB=24,padT=12;
208
+ const iw=W-padL-10,ih=H-padB-padT;
209
+ const step=iw/days.length;
210
+ const bw=Math.max(2,step*0.6);
211
+ // grid + y labels
212
+ ctx.font="11px -apple-system,sans-serif";
213
+ for(let i=0;i<=4;i++){const y=padT+ih-(ih*(i/4));ctx.strokeStyle="#21262d";ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(W-10,y);ctx.stroke();ctx.fillStyle="#8b949e";ctx.textAlign="right";ctx.fillText(fmtUsd(max*(i/4)),padL-6,y+4);}
214
+ // bars
215
+ days.forEach((d,i)=>{
216
+ const h=Math.max(1,(d.totalCost/max)*ih);
217
+ const x=padL+step*i+(step-bw)/2;
218
+ const y=padT+ih-h;
219
+ ctx.fillStyle=d.totalCost>0?"#58a6ff":"#21262d";
220
+ ctx.beginPath();ctx.roundRect?ctx.roundRect(x,y,bw,h,3):ctx.rect(x,y,bw,h);ctx.fill();
221
+ // weekend dot
222
+ const dt=new Date(d.period+"T00:00:00");
223
+ const day=dt.getDay();
224
+ if(day===0||day===6){ctx.fillStyle="rgba(210,153,34,.85)";ctx.beginPath();ctx.arc(x+bw/2,padT+ih+5,2.5,0,7);ctx.fill();}
225
+ });
226
+ // x labels (every ~5th)
227
+ ctx.fillStyle="#8b949e";ctx.textAlign="center";
228
+ const every=Math.max(1,Math.floor(days.length/10));
229
+ days.forEach((d,i)=>{
230
+ if(i%every!==0&&i!==days.length-1)return;
231
+ const x=padL+step*i+step/2;
232
+ ctx.fillText(d.period.slice(5),x,H-6);
233
+ });
234
+ // tooltip via hover (simple)
235
+ cv.onmousemove=(e)=>{
236
+ const r=cv.getBoundingClientRect();const mx=e.clientX-r.left;
237
+ const idx=Math.min(days.length-1,Math.max(0,Math.floor((mx-padL)/step)));
238
+ const d=days[idx];if(!d)return;
239
+ ctx.clearRect(0,0,W,H);drawTrend(days);
240
+ ctx.fillStyle="rgba(22,27,34,.95)";const tx=Math.min(mx+10,W-120),ty=12;
241
+ ctx.beginPath();ctx.roundRect?ctx.roundRect(tx,ty,110,28,6):ctx.rect(tx,ty,110,28);ctx.fill();
242
+ ctx.fillStyle="#e6edf3";ctx.font="12px -apple-system,sans-serif";ctx.textAlign="left";
243
+ ctx.fillText(`${d.period}: ${fmtUsd(d.totalCost)}`,tx+8,ty+19);
244
+ };
245
+ }
246
+
247
+ async function tick(){
248
+ const dot=$("dot"),st=$("status");
249
+ try{
250
+ const month=await(await fetch("/api/month")).json();
251
+ if(month.error)throw new Error(month.error);
252
+ applyBudget(month);
253
+ await loadModels();
254
+ await loadTrend();
255
+ dot.className="dot ok";st.textContent="live";
256
+ $("lastUpd").textContent="last updated "+new Date().toLocaleTimeString();
257
+ }catch(e){dot.className="dot err";st.textContent="error: "+e.message;}
258
+ }
259
+
260
+ document.querySelectorAll(".tab").forEach(t=>{
261
+ t.addEventListener("click",()=>{
262
+ document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active"));
263
+ t.classList.add("active");
264
+ active=t.dataset.tab;
265
+ $("rangeRow").style.display = active==="range"?"block":"none";
266
+ if(active==="range"){
267
+ if(!$("fromDate").value){const e=new Date();$("toDate").value=e.toISOString().slice(0,10);const s=new Date();s.setDate(s.getDate()-6);$("fromDate").value=s.toISOString().slice(0,10);}
268
+ rangeApply();
269
+ }else{
270
+ loadModels();
271
+ }
272
+ });
273
+ });
274
+ $("rangeGo").addEventListener("click",rangeApply);
275
+
276
+ tick();
277
+ setInterval(tick,15000);
278
+ window.addEventListener("resize",()=>{if($("trendChart").width)loadTrend();});
279
+ </script>
280
+ </body>
281
+ </html>
package/lib/server.py ADDED
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ccusage-ui — a tiny zero-dependency local dashboard for ccusage.
4
+
5
+ It shells out to ccusage's JSON reports and renders:
6
+ - today / this week / this month / custom range, grouped by model
7
+ - a 30-day cost trend chart
8
+ - a monthly budget alert (default cap: $300)
9
+
10
+ Because all numbers come from ccusage itself, the figures always match what
11
+ `ccusage` reports (the source you already trust). No network, no pricing table
12
+ to maintain, no third-party packages — only the Python standard library.
13
+
14
+ Usage:
15
+ python3 server.py [--port 8799] [--budget 300] [--open]
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import threading
26
+ import time
27
+ from datetime import date, timedelta
28
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29
+ from pathlib import Path
30
+
31
+ APP_DIR = Path(__file__).resolve().parent
32
+
33
+ # Cache: args-key -> (expires_at, data)
34
+ _cache: dict[str, tuple[float, object]] = {}
35
+ _lock = threading.Lock()
36
+
37
+
38
+ def resolve_ccusage() -> list[str]:
39
+ """Resolve a fast local ccusage entrypoint.
40
+
41
+ Prefer a directly invocable local binary/cache so we don't pay an `npx`
42
+ resolution + possible network fetch on every report (~5-7s cold). Look for,
43
+ in order: an explicit --ccusage-path, a global `ccusage` on PATH, the npm
44
+ npx cache, the bun cache, then fall back to `npx --yes ccusage@latest`.
45
+ Returns a command list to run.
46
+ """
47
+ def _npx_cached_cli() -> str | None:
48
+ for base in ("npm", "bun"):
49
+ roots = []
50
+ if base == "npm":
51
+ target = Path.home() / ".npm/_npx"
52
+ if target.is_dir():
53
+ roots.extend(p for p in target.glob("*/node_modules/ccusage/src/cli.js"))
54
+ # also newer npm layout
55
+ roots.extend((Path.home() / ".npm/_npx").glob("*/node_modules/ccusage/src/cli.js"))
56
+ else:
57
+ target = Path.home() / ".bun/install/cache"
58
+ if target.is_dir():
59
+ roots.extend(p for p in target.glob("ccusage*/src/cli.js"))
60
+ # prefer highest semver-ish (sort by version suffix descending)
61
+ def _ver(p: Path) -> tuple:
62
+ s = p.as_posix()
63
+ m = re.search(r"ccusage@?(\d+)\.(\d+)\.(\d+)", s)
64
+ return tuple(map(int, m.groups())) if m else (0, 0, 0)
65
+ best = max(roots, key=_ver, default=None)
66
+ if best:
67
+ return str(best)
68
+ return None
69
+
70
+ explicit = _CCUSAGE_PATH_OVERRIDE
71
+ if explicit:
72
+ return [explicit]
73
+ p = shutil.which("ccusage")
74
+ if p:
75
+ return [p]
76
+ # Bundled ccusage dependency: walk up from the package to find node_modules/ccusage
77
+ for parent in APP_DIR.parents:
78
+ bundled = parent / "node_modules" / "ccusage" / "src" / "cli.js"
79
+ if bundled.exists():
80
+ return [_node(), str(bundled)]
81
+ cli = _npx_cached_cli()
82
+ if cli:
83
+ return [_node(), str(cli)]
84
+ return ["npx", "--yes", "ccusage@latest"]
85
+
86
+
87
+ _CCUSAGE_PATH_OVERRIDE: str | None = None
88
+
89
+
90
+ def _node() -> str:
91
+ import shutil as _s
92
+
93
+ n = _s.which("node") or "node"
94
+ return n
95
+
96
+ BUDGET = 300.0 # monthly cap in USD (override via --budget or CCUSAGE_BUDGET)
97
+ TTL = {"/api/today": 15, "/api/week": 60, "/api/month": 60, "/api/range": 120, "/api/trend": 120}
98
+
99
+
100
+ def run_ccusage(args: list[str], ttl: float) -> dict:
101
+ key = " ".join(args)
102
+ now = time.time()
103
+ with _lock:
104
+ hit = _cache.get(key)
105
+ if hit and hit[0] > now:
106
+ return hit[1] # type: ignore[return-value]
107
+ try:
108
+ proc = subprocess.run(
109
+ resolve_ccusage() + args,
110
+ capture_output=True,
111
+ text=True,
112
+ timeout=120,
113
+ )
114
+ data = json.loads(proc.stdout)
115
+ except Exception as exc: # noqa: BLE001
116
+ data = {"error": f"{exc}"}
117
+ with _lock:
118
+ _cache[key] = (now + ttl, data)
119
+ return data
120
+
121
+
122
+ def pick_all_row(rows: list[dict]) -> dict:
123
+ return next((r for r in rows if r.get("agent") == "all"), None) or (rows[0] if rows else {})
124
+
125
+
126
+ def summarize(row: dict) -> dict:
127
+ models = sorted(row.get("modelBreakdowns", []), key=lambda m: -m.get("cost", 0))
128
+ total_from_models = sum(m.get("cost", 0) for m in models)
129
+ return {
130
+ "period": row.get("period") or row.get("date"),
131
+ "totalCost": round(row.get("totalCost", total_from_models) or 0, 4),
132
+ "inputTokens": row.get("inputTokens", 0),
133
+ "outputTokens": row.get("outputTokens", 0),
134
+ "cacheReadTokens": row.get("cacheReadTokens", 0),
135
+ "cacheCreationTokens": row.get("cacheCreationTokens", 0),
136
+ "models": [
137
+ {
138
+ "name": m.get("modelName") or m.get("name") or "?",
139
+ "cost": round(m.get("cost", 0), 4),
140
+ "inputTokens": m.get("inputTokens", 0),
141
+ "outputTokens": m.get("outputTokens", 0),
142
+ "cacheReadTokens": m.get("cacheReadTokens", 0),
143
+ "cacheCreationTokens": m.get("cacheCreationTokens", 0),
144
+ }
145
+ for m in models
146
+ ],
147
+ }
148
+
149
+
150
+ def aggregate_range(data: dict, from_date: str, to_date: str) -> dict:
151
+ """Merge per-day rows (daily range report) into one period summary."""
152
+ rows = data.get("daily", []) or []
153
+ agg: dict[str, dict] = {}
154
+ total_cost = 0.0
155
+ keys = ("cost", "inputTokens", "outputTokens", "cacheReadTokens", "cacheCreationTokens")
156
+ for r in rows:
157
+ if r.get("agent") != "all":
158
+ continue
159
+ total_cost += r.get("totalCost", 0) or 0
160
+ for m in r.get("modelBreakdowns", []):
161
+ name = m.get("modelName") or "?"
162
+ t = agg.setdefault(name, {"name": name, **{k: 0 for k in keys}})
163
+ for k in keys:
164
+ t[k] += m.get(k, 0) or 0
165
+ row = pick_all_row(rows)
166
+ models = sorted(agg.values(), key=lambda m: -m["cost"])
167
+ return {
168
+ "period": f"{from_date} → {to_date}",
169
+ "totalCost": round(total_cost, 4),
170
+ "inputTokens": sum(r.get("inputTokens", 0) for r in rows if r.get("agent") == "all"),
171
+ "outputTokens": sum(r.get("outputTokens", 0) for r in rows if r.get("agent") == "all"),
172
+ "cacheReadTokens": sum(r.get("cacheReadTokens", 0) for r in rows if r.get("agent") == "all"),
173
+ "cacheCreationTokens": sum(r.get("cacheCreationTokens", 0) for r in rows if r.get("agent") == "all"),
174
+ "models": models,
175
+ "dayCount": len([r for r in rows if r.get("agent") == "all"]),
176
+ }
177
+
178
+
179
+ def trend(days: int = 30) -> dict:
180
+ end = date.today()
181
+ start = end - timedelta(days=days - 1)
182
+ data = run_ccusage(
183
+ ["daily", "--since", start.isoformat(), "--until", end.isoformat(), "--json", "--offline"],
184
+ TTL["/api/trend"],
185
+ )
186
+ rows = data.get("daily", []) or []
187
+ by_period = {r.get("period"): (r.get("totalCost", 0) or 0) for r in rows if r.get("agent") == "all"}
188
+ out, d = [], start
189
+ while d <= end:
190
+ iso = d.isoformat()
191
+ out.append({"period": iso, "totalCost": round(by_period.get(iso, 0.0), 4)})
192
+ d += timedelta(days=1)
193
+ return {"start": start.isoformat(), "end": end.isoformat(), "days": out}
194
+
195
+
196
+ class Handler(BaseHTTPRequestHandler):
197
+ server_version = "ccusage-ui/0.2"
198
+
199
+ def log_message(self, fmt, *args): # quieter logs
200
+ pass
201
+
202
+ def _send(self, code: int, body: bytes, content_type: str) -> None:
203
+ self.send_response(code)
204
+ self.send_header("Content-Type", content_type)
205
+ self.send_header("Content-Length", str(len(body)))
206
+ self.send_header("Cache-Control", "no-store")
207
+ self.end_headers()
208
+ self.wfile.write(body)
209
+
210
+ def _json(self, obj: dict) -> None:
211
+ self._send(200, json.dumps(obj).encode(), "application/json")
212
+
213
+ def do_GET(self): # noqa: N802
214
+ path, _, query = self.path.partition("?")
215
+ params = dict(p.split("=", 1) for p in query.split("&") if "=" in p)
216
+
217
+ if path in ("/", "/index.html"):
218
+ self._send(200, (APP_DIR / "index.html").read_bytes(), "text/html; charset=utf-8")
219
+ return
220
+ if path == "/api/health":
221
+ self._json({"ok": True, "budget": BUDGET})
222
+ return
223
+
224
+ if path == "/api/today":
225
+ data = run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL[path])
226
+ rows_key, summary = "daily", summarize(pick_all_row(data.get("daily", [])))
227
+ elif path == "/api/week":
228
+ data = run_ccusage(["weekly", "--last", "1", "--json", "--offline"], TTL[path])
229
+ summary = summarize(pick_all_row(data.get("weekly", [])))
230
+ elif path == "/api/month":
231
+ data = run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL[path])
232
+ summary = summarize(pick_all_row(data.get("monthly", [])))
233
+ summary["budget"] = BUDGET
234
+ summary["budgetUsedPct"] = round((summary["totalCost"] / BUDGET) * 100, 1) if BUDGET else 0
235
+ elif path == "/api/range":
236
+ frm, to = params.get("from", ""), params.get("to", "")
237
+ if not frm or not to:
238
+ self._json({"error": "from/to required (YYYY-MM-DD)"})
239
+ return
240
+ data = run_ccusage(["daily", "--since", frm, "--until", to, "--json", "--offline"], TTL[path])
241
+ summary = aggregate_range(data, frm, to)
242
+ elif path == "/api/trend":
243
+ days = max(1, min(366, int(params.get("days", "30"))))
244
+ self._json(trend(days))
245
+ return
246
+ else:
247
+ self._send(404, b"not found", "text/plain")
248
+ return
249
+
250
+ if isinstance(data, dict) and data.get("error"):
251
+ self._json({"error": data["error"]})
252
+ return
253
+ self._json(summary)
254
+
255
+
256
+ def main() -> None:
257
+ global BUDGET, _CCUSAGE_PATH_OVERRIDE
258
+ parser = argparse.ArgumentParser(description="ccusage dashboard")
259
+ parser.add_argument("--port", type=int, default=8799)
260
+ parser.add_argument("--host", default="127.0.0.1")
261
+ parser.add_argument("--budget", type=float, default=None, help="monthly budget cap in USD (default 300)")
262
+ parser.add_argument("--ccusage-path", default=None, help="explicit path to a ccusage binary or src/cli.js")
263
+ parser.add_argument("--no-warm", action="store_true", help="skip blocking warm-up (first requests may be slow)")
264
+ parser.add_argument("--open", action="store_true", help="open browser after start")
265
+ args = parser.parse_args()
266
+
267
+ BUDGET = args.budget if args.budget is not None else float(os_env_budget() or 300.0)
268
+ _CCUSAGE_PATH_OVERRIDE = args.ccusage_path
269
+ print(f"using ccusage → {' '.join(resolve_ccusage())}")
270
+
271
+ def warm_one(fn):
272
+ fn()
273
+
274
+ def warm():
275
+ threads = [
276
+ threading.Thread(target=warm_one, args=(lambda: run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL["/api/today"]),)),
277
+ threading.Thread(target=warm_one, args=(lambda: run_ccusage(["weekly", "--last", "1", "--json", "--offline"], TTL["/api/week"]),)),
278
+ threading.Thread(target=warm_one, args=(lambda: run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL["/api/month"]),)),
279
+ threading.Thread(target=warm_one, args=(lambda: trend(30),)),
280
+ ]
281
+ for t in threads:
282
+ t.start()
283
+ for t in threads:
284
+ t.join()
285
+
286
+ if not args.no_warm:
287
+ print("warming ccusage caches (first load will be instant after this)…", flush=True)
288
+ warm()
289
+ print("warm-up complete.")
290
+
291
+ httpd = ThreadingHTTPServer((args.host, args.port), Handler)
292
+ print(f"ccusage-ui → http://{args.host}:{args.port} (monthly budget ${BUDGET:g}, Ctrl+C to stop)")
293
+ if args.open:
294
+ import webbrowser
295
+
296
+ webbrowser.open(f"http://{args.host}:{args.port}")
297
+
298
+ try:
299
+ httpd.serve_forever()
300
+ except KeyboardInterrupt:
301
+ print("\nbye")
302
+
303
+
304
+ def os_env_budget() -> str:
305
+ import os
306
+
307
+ return os.environ.get("CCUSAGE_BUDGET", "")
308
+
309
+
310
+ if __name__ == "__main__":
311
+ main()
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@caius_kong/ccusage-dashboard",
3
+ "version": "0.1.0",
4
+ "description": "One-command local dashboard for ccusage: today/week/month/custom cost by model, 30-day trend, monthly budget alert. Numbers straight from ccusage.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "ccusage-dashboard": "bin/ccusage-ui.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "lib/"
13
+ ],
14
+ "engines": {
15
+ "node": ">=16"
16
+ },
17
+ "keywords": [
18
+ "ccusage",
19
+ "claude-code",
20
+ "codex",
21
+ "cost",
22
+ "usage",
23
+ "dashboard",
24
+ "llm-cost",
25
+ "token-usage"
26
+ ],
27
+ "scripts": {
28
+ "start": "node bin/ccusage-ui.js"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/caius-kong/ccusage-dashboard"
33
+ },
34
+ "author": "Caius Kong",
35
+ "dependencies": {
36
+ "ccusage": "^20.0.20"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }