@caius_kong/ccusage-dashboard 0.2.5 → 0.2.7

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
@@ -5,9 +5,15 @@
5
5
  **A tiny local dashboard built specifically for [ccusage](https://github.com/ccusage/ccusage).**
6
6
 
7
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`
8
+ > `ccusage ... --json`. No own pricing tables, no re-parsing of session logs for costs
9
+ > the dashboard is a thin view over ccusage's own accurate cost engine. If `ccusage`
10
10
  > says it, this dashboard shows it.
11
+ >
12
+ > The only exception is the **workdir label** in the Sessions tab: ccusage's session
13
+ > report doesn't expose a real directory name for every agent, so the dashboard reads
14
+ > each agent's own local session file (pi/openclaw/claude/codex stores) to display the
15
+ > actual working directory. This is purely a display label — every cost/token number
16
+ > still comes 100% from ccusage.
11
17
 
12
18
  </div>
13
19
 
@@ -50,12 +56,14 @@ CCUSAGE_UI_NO_OPEN=1 npx @caius_kong/ccusage-dashboard # don't auto-open brows
50
56
 
51
57
  | | |
52
58
  |---|---|
53
- | **Today / This Week / This Month / Custom Range** | totals + tokens + cache breakdown |
59
+ | **Today / This Week / This Month / Custom Range** | totals + tokens + cache breakdown + **cache hit rate** |
54
60
  | **By model** | per-model cost, % of total, in/out/cache-read/cache-write tokens |
55
61
  | **30-day trend** | daily cost bar chart (hover for values, weekends marked) |
62
+ | **Sessions** | per-session rows grouped by workdir name + short session id, sorted by cost — shares the top time-filter tabs (today/week/month/custom) |
56
63
  | **Budget alert** | monthly cap (default $300) — green <80%, yellow <100%, red ≥100% |
57
64
 
58
- All costs in USD. Auto-refresh: today every 15s, others every 60s.
65
+ All costs in USD. **Cache hit rate** is the standard input-side metric:
66
+ `cacheReadTokens / (cacheReadTokens + non-cached inputTokens)`. Auto-refresh every 15s.
59
67
 
60
68
  ## How it works
61
69
 
package/lib/index.html CHANGED
@@ -77,7 +77,6 @@
77
77
  <div class="tab" role="tab" tabindex="0" aria-selected="false" data-tab="week">This Week</div>
78
78
  <div class="tab" role="tab" tabindex="0" aria-selected="false" data-tab="month">This Month</div>
79
79
  <div class="tab" role="tab" tabindex="0" aria-selected="false" data-tab="range">Custom Range</div>
80
- <div class="tab" role="tab" tabindex="0" aria-selected="false" data-tab="sessions">Sessions</div>
81
80
  <div class="tab-budget">
82
81
  <span class="budget-pill" id="budgetPill">budget …</span>
83
82
  </div>
@@ -93,6 +92,7 @@
93
92
  <div class="card"><div class="label">Tokens</div><div class="value" id="c2">–</div></div>
94
93
  <div class="card"><div class="label">Cache Read</div><div class="value" id="c3">–</div></div>
95
94
  <div class="card"><div class="label">Cache Write</div><div class="value" id="c4">–</div></div>
95
+ <div class="card"><div class="label">Cache Hit Rate</div><div class="value" id="c5">–</div><div class="period" id="c5p"></div></div>
96
96
  </div>
97
97
 
98
98
  <div id="rangeRow" class="panel" style="display:none">
@@ -108,18 +108,10 @@
108
108
 
109
109
  <div class="panel"><h2>Models <span class="tot" id="modelSum"></span></h2><div id="modelList"><div class="empty">loading…</div></div></div>
110
110
 
111
- <div class="panel" id="sessionsPanel" style="display:none">
111
+ <div class="panel" id="sessionsPanel">
112
112
  <h2>Sessions <span class="tot" id="sessSum"></span>
113
113
  <span class="pill" id="sessRange"></span>
114
114
  </h2>
115
- <div class="range" style="margin-bottom:12px">
116
- <span class="pill">last</span>
117
- <button data-sdays="7" class="sday">7d</button>
118
- <button data-sdays="30" class="sday active">30d</button>
119
- <button data-sdays="90" class="sday">90d</button>
120
- <button data-sdays="365" class="sday">all</button>
121
- <span class="pill" style="margin-left:auto" id="sessHint"></span>
122
- </div>
123
115
  <div id="sessionsList"><div class="empty">loading…</div></div>
124
116
  </div>
125
117
 
@@ -127,8 +119,8 @@
127
119
 
128
120
  <script>
129
121
  const $=(id)=>document.getElementById(id);
130
- 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}};
131
- let active="today", modelCtx=[], sessDays=30;
122
+ 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}};
123
+ let active="today", modelCtx=[];
132
124
 
133
125
  function fmtUsd(n){return "$"+(n==null?"–":Number(n).toFixed(2));}
134
126
  function fmtTok(n){
@@ -138,6 +130,7 @@ function fmtTok(n){
138
130
  if(n>=1e3)return (n/1e3).toFixed(1)+"k";
139
131
  return String(n);
140
132
  }
133
+ function fmtPct(n){return n==null?"–":n.toFixed(1)+"%";}
141
134
  function esc(s){const d=document.createElement("div");d.textContent=s;return d.innerHTML;}
142
135
 
143
136
  function renderModels(data){
@@ -173,8 +166,17 @@ function applyBudget(month){
173
166
  }
174
167
 
175
168
  async function loadSessions(){
176
- $("sessionsList").innerHTML='<div class="empty">loading sessions…</div>';
177
- const data=await(await fetch(`/api/sessions?days=${sessDays}`)).json();
169
+ // follow the top-level time filter (today/week/month/custom range)
170
+ let q="";
171
+ if(active==="today")q="period=today";
172
+ else if(active==="week")q="period=week";
173
+ else if(active==="month")q="period=month";
174
+ else if(active==="range"){
175
+ const f=$("fromDate").value,t=$("toDate").value;
176
+ if(!f||!t){return;}
177
+ q=`from=${f}&to=${t}`;
178
+ }else{return;}
179
+ const data=await(await fetch(`/api/sessions?${q}`)).json();
178
180
  if(data.error)throw new Error(data.error);
179
181
  renderSessions(data);
180
182
  }
@@ -182,14 +184,14 @@ async function loadSessions(){
182
184
  function renderSessions(data){
183
185
  const list=data.sessions||[];
184
186
  const total=data.total||0;
185
- const sum=list.reduce((a,s)=>a+(s.cost||0),0);
187
+ const sum=data.totalCost!=null?data.totalCost:list.reduce((a,s)=>a+(s.cost||0),0);
186
188
  $("sessSum").textContent=fmtUsd(sum);
187
- $("sessRange").textContent=`${total} sessions · last ${sessDays===365?"year":sessDays+"d"}`;
189
+ $("sessRange").textContent=`${total} sessions`;
188
190
  if(!list.length){$("sessionsList").innerHTML='<div class="empty">no sessions in range</div>';return;}
189
191
  $("sessionsList").innerHTML=list.map(s=>{
190
192
  const label = s.hasCwd
191
- ? `<span class="name" title="workdir from ccusage: ${esc(s.cwd||s.projectKey)}">${esc(s.dirName||s.id.slice(0,8))} <span class="pill sessid">${esc(s.id.slice(0,8))}</span></span>`
192
- : `<span class="name muted" title="workdir not provided by ccusage for ${esc(s.agent)}">${esc(s.id.slice(0,8))}</span>`;
193
+ ? `<span class="name" title="workdir: ${esc(s.cwd||s.projectKey)}">${esc(s.dirName||s.id.slice(0,8))} <span class="pill sessid">${esc(s.id.slice(0,8))}</span></span>`
194
+ : `<span class="name muted" title="no workdir found for this ${esc(s.agent)} session">${esc(s.id.slice(0,8))}</span>`;
193
195
  const last=(s.lastActivity||"").slice(0,10);
194
196
  return `<div class="row">
195
197
  <div class="row-top"><span>${label} <span class="pill">${esc(s.agent)}</span></span><span class="cost">${fmtUsd(s.cost)}</span></div>
@@ -206,6 +208,11 @@ function applyCards(data){
206
208
  $("c2").textContent=fmtTok(tt);
207
209
  $("c3").textContent=fmtTok(data.cacheReadTokens);
208
210
  $("c4").textContent=fmtTok(data.cacheCreationTokens);
211
+ // cache hit rate = cache-read input / (cache-read input + non-cached input)
212
+ const hitIn=data.cacheReadTokens||0, plainIn=data.inputTokens||0;
213
+ const hitRate = (hitIn+plainIn)>0 ? (hitIn/(hitIn+plainIn))*100 : null;
214
+ $("c5").innerHTML=fmtPct(hitRate)+(hitRate!=null?"<small> cache</small>":"");
215
+ $("c5p").textContent=hitRate!=null?`${fmtTok(plainIn)} miss · ${fmtTok(hitIn)} hit`:"";
209
216
  $("c1label").textContent=tabs[active].label;
210
217
  $("modelSum").textContent=fmtUsd(total);
211
218
  }
@@ -298,6 +305,7 @@ async function tick(){
298
305
  applyBudget(month);
299
306
  await loadModels();
300
307
  await loadTrend();
308
+ await loadSessions();
301
309
  dot.className="dot ok";st.textContent="live";
302
310
  $("lastUpd").textContent="last updated "+new Date().toLocaleTimeString();
303
311
  }catch(e){dot.className="dot err";st.textContent="error: "+e.message;}
@@ -310,28 +318,18 @@ document.querySelectorAll(".tab").forEach(t=>{
310
318
  t.setAttribute("aria-selected","true");
311
319
  active=t.dataset.tab;
312
320
  $("rangeRow").style.display = active==="range"?"block":"none";
313
- $("sessionsPanel").style.display = active==="sessions"?"block":"none";
314
321
  if(active==="range"){
315
322
  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);}
316
323
  rangeApply();
317
- }else if(active==="sessions"){
318
- loadSessions();
319
324
  }else{
320
325
  loadModels();
321
326
  }
327
+ loadSessions();
322
328
  };
323
329
  t.addEventListener("click",activate);
324
330
  t.addEventListener("keydown",(e)=>{if(e.key==="Enter"||e.key===" "||e.key==="Spacebar"){e.preventDefault();activate();}});
325
331
  });
326
332
  $("rangeGo").addEventListener("click",rangeApply);
327
- document.querySelectorAll(".sday").forEach(b=>{
328
- b.addEventListener("click",()=>{
329
- document.querySelectorAll(".sday").forEach(x=>x.classList.remove("active"));
330
- b.classList.add("active");
331
- sessDays=parseInt(b.dataset.sdays,10);
332
- loadSessions();
333
- });
334
- });
335
333
 
336
334
  tick();
337
335
  setInterval(tick,15000);
package/lib/server.py CHANGED
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  import argparse
20
20
  import json
21
+ import os
21
22
  import re
22
23
  import shutil
23
24
  import subprocess
@@ -193,9 +194,14 @@ def trend(days: int = 30) -> dict:
193
194
  return {"start": start.isoformat(), "end": end.isoformat(), "days": out}
194
195
 
195
196
 
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.
197
+ def sessions(since_days: int = 30, period: str = "", from_date: str = "", to_date: str = "") -> dict:
198
+ """Return ccusage's session report, each with its real workdir (dirName) when
199
+ the agent's local session store records one. Every numeric cost/token field
200
+ comes 100% from ccusage; the workdir is only a display label.
201
+
202
+ Filtering: pass `period` in ("today" | "week" | "month") OR an explicit
203
+ date range via `from_date`/`to_date` (YYYY-MM-DD). `since_days` is kept for
204
+ backward compatibility with the old 7d/30d/90d/all selector.
199
205
  """
200
206
  data = run_ccusage(
201
207
  ["session", "--by-agent", "--json", "--offline"],
@@ -203,52 +209,76 @@ def sessions(since_days: int = 30) -> dict:
203
209
  )
204
210
  rows = data.get("session") or []
205
211
 
206
- cutoff = date.today() - timedelta(days=since_days)
212
+ # ---- choose the cutoff window ----------------
213
+ today = date.today()
214
+ lo = hi = None
215
+ if from_date and to_date:
216
+ try:
217
+ lo = date.fromisoformat(from_date)
218
+ hi = date.fromisoformat(to_date)
219
+ except ValueError:
220
+ lo = hi = None
221
+ elif period == "today":
222
+ lo = hi = today
223
+ elif period == "week":
224
+ lo = today - timedelta(days=6)
225
+ hi = today
226
+ elif period == "month":
227
+ lo = today.replace(day=1)
228
+ hi = today
229
+ elif period == "custom":
230
+ pass # fall through to since_days below (legacy)
231
+ if lo is None:
232
+ lo = today - timedelta(days=since_days)
233
+ hi = today
234
+
207
235
  out = []
208
236
  for r in rows:
209
237
  meta = r.get("metadata") or {}
210
- sid = r.get("period") # session id in ccusage's session report
238
+ sid = r.get("period")
211
239
  last_activity = meta.get("lastActivity") or ""
212
- # date filter by last activity
240
+ # date filter by last activity against [lo, hi]
213
241
  if last_activity:
214
242
  try:
215
243
  act_date = date.fromisoformat(last_activity[:10])
216
244
  except ValueError:
217
245
  act_date = None
218
- if act_date is not None and act_date < cutoff:
219
- continue
246
+ if act_date is not None:
247
+ if act_date < lo or act_date > hi:
248
+ continue
220
249
  project_raw = meta.get("projectPath") or ""
221
- dir_name = _basename(project_raw)
222
- cwd = _decode_cwd(project_raw)
223
- # For pi sessions the authoritative workdir lives on disk (session file first-line
224
- # "cwd"). Your dashboards want the REAL dir name (e.g. "fund-tracker", not the
225
- # lossy basename "tracker"). This only affects the DISPLAY label — all numeric
226
- # cost/token data still comes 100% from ccusage.
227
- if project_raw and dir_name:
228
- disk_cwd = _pi_cwd_from_disk(project_raw)
250
+ agent_name = r.get("agent", "?")
251
+ cwd = ""
252
+ dir_name = ""
253
+ if sid:
254
+ disk_cwd = _cwd_for(agent_name, sid, project_raw)
229
255
  if disk_cwd:
230
256
  cwd = disk_cwd
231
- disk_base = disk_cwd.rstrip("/").split("/")[-1]
232
- if disk_base:
233
- dir_name = disk_base
257
+ cwd_base = disk_cwd.rstrip("/").split("/")[-1]
258
+ if cwd_base:
259
+ dir_name = cwd_base
260
+ if not cwd:
261
+ cwd = _decode_cwd(project_raw)
262
+ dir_name = _basename(project_raw)
234
263
  out.append(
235
264
  {
236
265
  "id": sid or "",
237
- "agent": r.get("agent", "?"),
266
+ "agent": agent_name,
238
267
  "cost": round(r.get("totalCost", 0) or 0, 4),
239
268
  "inputTokens": r.get("inputTokens", 0),
240
269
  "outputTokens": r.get("outputTokens", 0),
241
270
  "cacheReadTokens": r.get("cacheReadTokens", 0),
242
271
  "cacheCreationTokens": r.get("cacheCreationTokens", 0),
243
272
  "lastActivity": last_activity,
244
- "cwd": cwd, # real workdir when known (pi), else best-effort decode
245
- "dirName": dir_name, # real dir name when known (pi), else last path segment
246
- "projectKey": project_raw, # raw ccusage projectPath
247
- "hasCwd": bool(project_raw),
273
+ "cwd": cwd,
274
+ "dirName": dir_name,
275
+ "projectKey": project_raw,
276
+ "hasCwd": bool(cwd),
248
277
  }
249
278
  )
250
279
  out.sort(key=lambda s: s["cost"], reverse=True)
251
- return {"total": len(out), "sessions": out}
280
+ total_cost = round(sum(s["cost"] for s in out), 4)
281
+ return {"total": len(out), "totalCost": total_cost, "sessions": out}
252
282
 
253
283
 
254
284
  def _pi_cwd_from_disk(project_key: str):
@@ -269,29 +299,141 @@ def _pi_cwd_from_disk(project_key: str):
269
299
  if not root.is_dir():
270
300
  return None
271
301
  candidates = sorted(glob.glob(str(root / "*.jsonl")))
272
- if not candidates:
273
- return None
274
302
  for path in candidates:
275
- try:
276
- with open(path, encoding="utf-8") as fh:
277
- for line in fh:
278
- line = line.strip()
279
- if not line:
280
- continue
281
- try:
282
- rec = json.loads(line)
283
- except Exception:
284
- continue
285
- cwd = rec.get("cwd")
286
- if isinstance(cwd, str) and cwd.strip():
287
- return cwd.strip().rstrip("/")
288
- except (OSError, ValueError):
289
- continue
303
+ cwd = _cwd_from_jsonl(path)
304
+ if cwd:
305
+ return cwd
290
306
  except Exception:
291
307
  return None
292
308
  return None
293
309
 
294
310
 
311
+ # --- Unified authoritative-workdir resolution for every agent that stores one ---
312
+ #
313
+ # ccusage's session report only exposes projectPath for pi (and it's lossy for
314
+ # hyphenated dir names). To show the REAL dir name for all sessions we resolve the
315
+ # workdir from each agent's own local session store. This drives only the DISPLAY
316
+ # label (dirName); every numeric cost/token value still comes 100% from ccusage.
317
+
318
+ _CWD_CACHE = {} # "agent\x1fsid" -> cwd | None
319
+ _AGENT_INDEX = {} # agent -> {session_id: [paths]} (built lazily)
320
+
321
+
322
+ def _cwd_from_jsonl(path):
323
+ """Scan a session .jsonl (first few lines) for the first real 'cwd' string."""
324
+ try:
325
+ with open(path, encoding="utf-8") as fh:
326
+ for _ in range(80):
327
+ line = fh.readline()
328
+ if not line:
329
+ break
330
+ line = line.strip()
331
+ if not line:
332
+ continue
333
+ try:
334
+ rec = json.loads(line)
335
+ except Exception:
336
+ continue
337
+ if isinstance(rec, dict):
338
+ cwd = rec.get("cwd")
339
+ if isinstance(cwd, str) and cwd.strip():
340
+ return cwd.strip().rstrip("/")
341
+ except (OSError, ValueError):
342
+ return None
343
+ return None
344
+
345
+
346
+ def _codex_cwd(path):
347
+ """codex stores cwd inside the session_meta payload, not top-level."""
348
+ try:
349
+ with open(path, encoding="utf-8") as fh:
350
+ for _ in range(200):
351
+ line = fh.readline()
352
+ if not line:
353
+ break
354
+ line = line.strip()
355
+ if not line:
356
+ continue
357
+ try:
358
+ rec = json.loads(line)
359
+ except Exception:
360
+ continue
361
+ if isinstance(rec, dict) and rec.get("type") == "session_meta":
362
+ cwd = (rec.get("payload") or {}).get("cwd")
363
+ if isinstance(cwd, str) and cwd.strip():
364
+ return cwd.strip().rstrip("/")
365
+ except (OSError, ValueError):
366
+ return None
367
+ return None
368
+
369
+
370
+ def _agent_index(agent):
371
+ """Build (once) a {session_id: [paths]} for an agent's local session store."""
372
+ if agent in _AGENT_INDEX:
373
+ return _AGENT_INDEX[agent]
374
+ home = Path.home()
375
+ idx = {}
376
+ if agent == "openclaw":
377
+ base = home / ".openclaw" / "agents" / "main" / "sessions"
378
+ for f in (base.glob("*.jsonl") if base.is_dir() else []):
379
+ idx.setdefault(f.name[:-6], []).append(str(f))
380
+ elif agent == "claude":
381
+ proj = home / ".claude" / "projects"
382
+ if proj.is_dir():
383
+ for sub in proj.iterdir():
384
+ if sub.is_dir():
385
+ for f in sub.glob("*.jsonl"):
386
+ idx.setdefault(f.name[:-6], []).append(str(f))
387
+ elif agent == "codex":
388
+ root = home / ".codex" / "sessions"
389
+ if root.is_dir():
390
+ for f in root.glob("*/*/*/rollout-*.jsonl"):
391
+ idx.setdefault(f.name[len("rollout-"):-len(".jsonl")], []).append(str(f))
392
+ for f in root.glob("*/*/rollout-*.jsonl"):
393
+ idx.setdefault(f.name[len("rollout-"):-len(".jsonl")], []).append(str(f))
394
+ _AGENT_INDEX[agent] = idx
395
+ return idx
396
+
397
+
398
+ def _session_to_cwd(agent, sid):
399
+ """Locate an agent's session file by id and return its real cwd (or None)."""
400
+ paths = _agent_index(agent).get(sid)
401
+ if not paths:
402
+ return None
403
+ for p in paths:
404
+ cwd = _codex_cwd(p) if agent == "codex" else _cwd_from_jsonl(p)
405
+ if cwd:
406
+ return cwd
407
+ return None
408
+
409
+
410
+ def _cwd_for(agent, sid, project_key=""):
411
+ """Real workdir for a session. pi resolves by project key; the rest by session id.
412
+ Returns the real cwd (or None if unresolvable). Only drives the DISPLAY label."""
413
+ if agent == "pi":
414
+ if not project_key:
415
+ return None
416
+ key = "pi\x1f" + project_key
417
+ if key in _CWD_CACHE:
418
+ return _CWD_CACHE[key]
419
+ cwd = _pi_cwd_from_disk(project_key)
420
+ _CWD_CACHE[key] = cwd
421
+ return cwd
422
+ if not sid:
423
+ return None
424
+ # codex session ids are date-path prefixed ("2025/10/17/rollout-<id>"); the
425
+ # local file is named "rollout-<id>.jsonl", so match on the final segment.
426
+ lookup_sid = sid.split("/")[-1]
427
+ if lookup_sid.startswith("rollout-") and agent == "codex":
428
+ lookup_sid = lookup_sid[len("rollout-"):]
429
+ key = agent + "\x1f" + lookup_sid
430
+ if key in _CWD_CACHE:
431
+ return _CWD_CACHE[key]
432
+ cwd = _session_to_cwd(agent, lookup_sid)
433
+ _CWD_CACHE[key] = cwd
434
+ return cwd
435
+
436
+
295
437
  def _decode_cwd(raw: str) -> str:
296
438
  """Best-effort decode of ccusage's projectPath into a readable path.
297
439
 
@@ -378,8 +520,10 @@ class Handler(BaseHTTPRequestHandler):
378
520
  self._json(trend(days))
379
521
  return
380
522
  elif path == "/api/sessions":
523
+ period = params.get("period", "")
524
+ frm, to = params.get("from", ""), params.get("to", "")
381
525
  days = max(1, min(366, int(params.get("days", "30"))))
382
- self._json(sessions(days))
526
+ self._json(sessions(days, period, frm, to))
383
527
  return
384
528
  else:
385
529
  self._send(404, b"not found", "text/plain")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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",