@caius_kong/ccusage-dashboard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,41 +1,87 @@
1
- # ccusage-ui
1
+ <div align="center">
2
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.
3
+ # ccusage-dashboard
7
4
 
8
- All numbers come directly from `ccusage ... --json`, so the figures always match
9
- what ccusage reports (no own pricing tables, no drift).
5
+ **A tiny local dashboard built specifically for [ccusage](https://github.com/ccusage/ccusage).**
10
6
 
11
- ## Run
7
+ > ⚠️ **This is not a re-implementation.** Every number you see comes straight from
8
+ > `ccusage ... --json`. No own pricing tables, no re-parsing of session logs — the
9
+ > dashboard is a thin view over ccusage's own accurate cost engine. If `ccusage`
10
+ > says it, this dashboard shows it.
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## What is this?
17
+
18
+ [ccusage](https://github.com/ccusage/ccusage) is a powerful CLI that analyzes coding-agent
19
+ token usage & cost from local data — accurate, but terminal-only and hard to *watch*.
20
+
21
+ **ccusage-dashboard puts a live web UI on top of it**: today / this week / this month /
22
+ custom-range cost grouped by model, a 30-day cost trend, and a monthly budget alert.
23
+ It auto-refreshes while you work, so you can *see* spend happen instead of running reports.
24
+
25
+ Because it shells out to `ccusage` for every number, **cost estimates are always identical
26
+ to what ccusage itself reports** — the source you already trust.
27
+
28
+ ## One-command install & start
12
29
 
13
30
  ```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
31
+ npx @caius_kong/ccusage-dashboard
19
32
  ```
20
33
 
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.
34
+ That's it. npx downloads the package (including its own `ccusage` dependency),
35
+ starts a local server on `http://127.0.0.1:8799`, and opens your browser.
36
+
37
+ > Requirements: **Node.js** (for the launcher) and **Python 3.8+** (for the server).
38
+ > On macOS: `brew install python3`. No other installs, no build step, no config.
39
+
40
+ ### CLI options
41
+
42
+ ```bash
43
+ npx @caius_kong/ccusage-dashboard --port 9000 # change port
44
+ npx @caius_kong/ccusage-dashboard --budget 500 # monthly budget cap (default $300)
45
+ npx @caius_kong/ccusage-dashboard --no-warm # skip 3s startup warm-up
46
+ CCUSAGE_UI_NO_OPEN=1 npx @caius_kong/ccusage-dashboard # don't auto-open browser
47
+ ```
25
48
 
26
- ## Endpoints
49
+ ## What it shows
27
50
 
28
- | Path | What it returns |
51
+ | | |
29
52
  |---|---|
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)
53
+ | **Today / This Week / This Month / Custom Range** | totals + tokens + cache breakdown |
54
+ | **By model** | per-model cost, % of total, in/out/cache-read/cache-write tokens |
55
+ | **30-day trend** | daily cost bar chart (hover for values, weekends marked) |
56
+ | **Budget alert** | monthly cap (default $300) — green <80%, yellow <100%, red ≥100% |
57
+
58
+ All costs in USD. Auto-refresh: today every 15s, others every 60s.
59
+
60
+ ## How it works
61
+
62
+ ```
63
+ Browser (index.html)
64
+ │ fetch /api/... (auto-refresh)
65
+ ▼
66
+ server.py (Python stdlib, zero deps)
67
+ │ spawns: ccusage daily/monthly/weekly ... --json --offline
68
+ ▼
69
+ ccusage (bundled dependency — the real cost engine)
70
+ ```
71
+
72
+ - `lib/server.py` — Python stdlib HTTP server. Resolves a local ccusage
73
+ (bundled dep → PATH → npx cache), warms caches on boot (~3s), then serves instant responses.
74
+ - `lib/index.html` — single-file dashboard. No build step, no CDN.
75
+ - `bin/ccusage-ui.js` — Node launcher (finds python3, starts server, opens browser).
76
+
77
+ ## Local development
78
+
79
+ ```bash
80
+ python3 lib/server.py --budget 300 # run server directly from the repo
81
+ # or
82
+ node bin/ccusage-ui.js # same as the npx experience
83
+ ```
84
+
85
+ ## License
86
+
87
+ MIT © Caius Kong
package/lib/index.html CHANGED
@@ -53,6 +53,8 @@
53
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
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
55
  .range button:hover{border-color:var(--accent)}
56
+ .range button.active{background:var(--accent);border-color:var(--accent);color:#0d1117;font-weight:600}
57
+ .sessid{font-variant-numeric:tabular-nums;letter-spacing:.3px}
56
58
  .budget-band{margin:2px 0 14px;background:var(--panel-2);border-radius:8px;padding:12px 16px;display:flex;flex-direction:column;gap:8px}
57
59
  .budget-band .bl{display:flex;justify-content:space-between;font-size:13px;color:var(--muted)}
58
60
  .budget-band .bl b{color:var(--text)}
@@ -74,6 +76,7 @@
74
76
  <div class="tab" data-tab="week">This Week</div>
75
77
  <div class="tab" data-tab="month">This Month</div>
76
78
  <div class="tab" data-tab="range">Custom Range</div>
79
+ <div class="tab" data-tab="sessions">Sessions</div>
77
80
  <div class="tab-budget">
78
81
  <span class="budget-pill" id="budgetPill">budget …</span>
79
82
  </div>
@@ -104,12 +107,27 @@
104
107
 
105
108
  <div class="panel"><h2>Models <span class="tot" id="modelSum"></span></h2><div id="modelList"><div class="empty">loading…</div></div></div>
106
109
 
110
+ <div class="panel" id="sessionsPanel" style="display:none">
111
+ <h2>Sessions <span class="tot" id="sessSum"></span>
112
+ <span class="pill" id="sessRange"></span>
113
+ </h2>
114
+ <div class="range" style="margin-bottom:12px">
115
+ <span class="pill">last</span>
116
+ <button data-sdays="7" class="sday">7d</button>
117
+ <button data-sdays="30" class="sday active">30d</button>
118
+ <button data-sdays="90" class="sday">90d</button>
119
+ <button data-sdays="365" class="sday">all</button>
120
+ <span class="pill" style="margin-left:auto" id="sessHint"></span>
121
+ </div>
122
+ <div id="sessionsList"><div class="empty">loading…</div></div>
123
+ </div>
124
+
107
125
  <div class="hint">auto-refresh: today 15s · others 60s · costs in USD · <span id="lastUpd"></span></div>
108
126
 
109
127
  <script>
110
128
  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=[];
129
+ 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},sessions:{label:"Sessions",len:0}};
130
+ let active="today", modelCtx=[], sessDays=30;
113
131
 
114
132
  function fmtUsd(n){return "$"+(n==null?"–":Number(n).toFixed(2));}
115
133
  function fmtTok(n){
@@ -152,6 +170,32 @@ function applyBudget(month){
152
170
  const p=$("budgetPill");p.className="budget-pill "+cls;p.textContent=txt;
153
171
  }
154
172
 
173
+ async function loadSessions(){
174
+ $("sessionsList").innerHTML='<div class="empty">loading sessions…</div>';
175
+ const data=await(await fetch(`/api/sessions?days=${sessDays}`)).json();
176
+ if(data.error)throw new Error(data.error);
177
+ renderSessions(data);
178
+ }
179
+
180
+ function renderSessions(data){
181
+ const list=data.sessions||[];
182
+ const total=data.total||0;
183
+ const sum=list.reduce((a,s)=>a+(s.cost||0),0);
184
+ $("sessSum").textContent=fmtUsd(sum);
185
+ $("sessRange").textContent=`${total} sessions · last ${sessDays===365?"year":sessDays+"d"}`;
186
+ if(!list.length){$("sessionsList").innerHTML='<div class="empty">no sessions in range</div>';return;}
187
+ $("sessionsList").innerHTML=list.map(s=>{
188
+ const label = s.hasCwd
189
+ ? `<span class="name">${esc(s.dirName||s.cwd)} <span class="pill sessid">${esc(s.id.slice(0,8))}</span></span>`
190
+ : `<span class="name muted">${esc(s.id.slice(0,8))}</span>`;
191
+ const last=(s.lastActivity||"").slice(0,10);
192
+ return `<div class="row">
193
+ <div class="row-top"><span>${label} <span class="pill">${esc(s.agent)}</span></span><span class="cost">${fmtUsd(s.cost)}</span></div>
194
+ <div class="toks">${last?`<span>${esc(last)}</span>`:""}<span title="input">in ${fmtTok(s.inputTokens)}</span><span title="output">out ${fmtTok(s.outputTokens)}</span><span title="cache read">read ${fmtTok(s.cacheReadTokens)}</span><span title="cache write">write ${fmtTok(s.cacheCreationTokens)}</span></div>
195
+ </div>`;
196
+ }).join("");
197
+ }
198
+
155
199
  function applyCards(data){
156
200
  const total=data.totalCost||0;
157
201
  $("c1").innerHTML=fmtUsd(total)+(total?"<small> USD</small>":"");
@@ -263,15 +307,26 @@ document.querySelectorAll(".tab").forEach(t=>{
263
307
  t.classList.add("active");
264
308
  active=t.dataset.tab;
265
309
  $("rangeRow").style.display = active==="range"?"block":"none";
310
+ $("sessionsPanel").style.display = active==="sessions"?"block":"none";
266
311
  if(active==="range"){
267
312
  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
313
  rangeApply();
314
+ }else if(active==="sessions"){
315
+ loadSessions();
269
316
  }else{
270
317
  loadModels();
271
318
  }
272
319
  });
273
320
  });
274
321
  $("rangeGo").addEventListener("click",rangeApply);
322
+ document.querySelectorAll(".sday").forEach(b=>{
323
+ b.addEventListener("click",()=>{
324
+ document.querySelectorAll(".sday").forEach(x=>x.classList.remove("active"));
325
+ b.classList.add("active");
326
+ sessDays=parseInt(b.dataset.sdays,10);
327
+ loadSessions();
328
+ });
329
+ });
275
330
 
276
331
  tick();
277
332
  setInterval(tick,15000);
package/lib/server.py CHANGED
@@ -94,7 +94,7 @@ def _node() -> str:
94
94
  return n
95
95
 
96
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}
97
+ TTL = {"/api/today": 15, "/api/week": 60, "/api/month": 60, "/api/range": 120, "/api/trend": 120, "/api/sessions": 60}
98
98
 
99
99
 
100
100
  def run_ccusage(args: list[str], ttl: float) -> dict:
@@ -193,6 +193,81 @@ def trend(days: int = 30) -> dict:
193
193
  return {"start": start.isoformat(), "end": end.isoformat(), "days": out}
194
194
 
195
195
 
196
+ def sessions(since_days: int = 30) -> dict:
197
+ """Return ccusage's session report, with each session's cwd (projectPath) when
198
+ ccusage provides it. Data comes 100% from ccusage — we only re-shape it.
199
+ """
200
+ data = run_ccusage(
201
+ ["session", "--by-agent", "--json", "--offline"],
202
+ TTL["/api/sessions"],
203
+ )
204
+ rows = data.get("session") or []
205
+
206
+ cutoff = date.today() - timedelta(days=since_days)
207
+ out = []
208
+ for r in rows:
209
+ meta = r.get("metadata") or {}
210
+ sid = r.get("period") # session id in ccusage's session report
211
+ last_activity = meta.get("lastActivity") or ""
212
+ # date filter by last activity
213
+ if last_activity:
214
+ try:
215
+ act_date = date.fromisoformat(last_activity[:10])
216
+ except ValueError:
217
+ act_date = None
218
+ if act_date is not None and act_date < cutoff:
219
+ continue
220
+ project_raw = meta.get("projectPath") or ""
221
+ cwd = _decode_cwd(project_raw)
222
+ dir_name = _basename(project_raw)
223
+ out.append(
224
+ {
225
+ "id": sid or "",
226
+ "agent": r.get("agent", "?"),
227
+ "cost": round(r.get("totalCost", 0) or 0, 4),
228
+ "inputTokens": r.get("inputTokens", 0),
229
+ "outputTokens": r.get("outputTokens", 0),
230
+ "cacheReadTokens": r.get("cacheReadTokens", 0),
231
+ "cacheCreationTokens": r.get("cacheCreationTokens", 0),
232
+ "lastActivity": last_activity,
233
+ "cwd": cwd, # absolute-ish path, empty if ccusage didn't provide it
234
+ "dirName": dir_name, # last path segment; empty if ccusage didn't provide
235
+ "hasCwd": bool(project_raw),
236
+ }
237
+ )
238
+ out.sort(key=lambda s: s["cost"], reverse=True)
239
+ return {"total": len(out), "sessions": out}
240
+
241
+
242
+ def _decode_cwd(raw: str) -> str:
243
+ """Best-effort decode of ccusage's projectPath into a readable path.
244
+
245
+ pi's projectPath is a Claude-Code-style encoded dir name where '/' became '-'.
246
+ Example: '--Users-caius-kong-Documents-...-AutoTrans--' -> /Users/caius_kong/.../AutoTrans
247
+ """
248
+ if not raw:
249
+ return ""
250
+ return raw.replace("-", "/")
251
+
252
+
253
+ def _basename(raw: str) -> str:
254
+ """Last path segment of projectPath as a readable dir name.
255
+
256
+ Works even for encoded paths (the trailing segment is unaffected by the
257
+ dash encoding except when the real dir name itself contains '-'s).
258
+ """
259
+ if not raw:
260
+ return ""
261
+ cleaned = raw.strip("/-\\")
262
+ if not cleaned:
263
+ return ""
264
+ parts = cleaned.replace("-", "/").split("/")
265
+ last = parts[-1] if parts else cleaned
266
+ # If the real dir name itself got dash-encoded (e.g. 'my-proj' -> 'my_proj'),
267
+ # we can't perfectly recover it; fall back to a reasonable label.
268
+ return last or cleaned
269
+
270
+
196
271
  class Handler(BaseHTTPRequestHandler):
197
272
  server_version = "ccusage-ui/0.2"
198
273
 
@@ -243,6 +318,10 @@ class Handler(BaseHTTPRequestHandler):
243
318
  days = max(1, min(366, int(params.get("days", "30"))))
244
319
  self._json(trend(days))
245
320
  return
321
+ elif path == "/api/sessions":
322
+ days = max(1, min(366, int(params.get("days", "30"))))
323
+ self._json(sessions(days))
324
+ return
246
325
  else:
247
326
  self._send(404, b"not found", "text/plain")
248
327
  return
@@ -276,6 +355,7 @@ def main() -> None:
276
355
  threading.Thread(target=warm_one, args=(lambda: run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL["/api/today"]),)),
277
356
  threading.Thread(target=warm_one, args=(lambda: run_ccusage(["weekly", "--last", "1", "--json", "--offline"], TTL["/api/week"]),)),
278
357
  threading.Thread(target=warm_one, args=(lambda: run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL["/api/month"]),)),
358
+ threading.Thread(target=warm_one, args=(lambda: run_ccusage(["session", "--by-agent", "--json", "--offline"], TTL["/api/sessions"]),)),
279
359
  threading.Thread(target=warm_one, args=(lambda: trend(30),)),
280
360
  ]
281
361
  for t in threads:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -38,4 +38,4 @@
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  }
41
- }
41
+ }