@caius_kong/ccusage-dashboard 0.2.6 → 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
@@ -56,13 +56,14 @@ CCUSAGE_UI_NO_OPEN=1 npx @caius_kong/ccusage-dashboard # don't auto-open brows
56
56
 
57
57
  | | |
58
58
  |---|---|
59
- | **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** |
60
60
  | **By model** | per-model cost, % of total, in/out/cache-read/cache-write tokens |
61
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, filterable 7d/30d/90d/all, sorted by cost |
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) |
63
63
  | **Budget alert** | monthly cap (default $300) — green <80%, yellow <100%, red ≥100% |
64
64
 
65
- All costs in USD. Auto-refresh every 15s.
65
+ All costs in USD. **Cache hit rate** is the standard input-side metric:
66
+ `cacheReadTokens / (cacheReadTokens + non-cached inputTokens)`. Auto-refresh every 15s.
66
67
 
67
68
  ## How it works
68
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,9 +184,9 @@ 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
@@ -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
@@ -194,10 +194,14 @@ def trend(days: int = 30) -> dict:
194
194
  return {"start": start.isoformat(), "end": end.isoformat(), "days": out}
195
195
 
196
196
 
197
- def sessions(since_days: int = 30) -> dict:
197
+ def sessions(since_days: int = 30, period: str = "", from_date: str = "", to_date: str = "") -> dict:
198
198
  """Return ccusage's session report, each with its real workdir (dirName) when
199
199
  the agent's local session store records one. Every numeric cost/token field
200
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.
201
205
  """
202
206
  data = run_ccusage(
203
207
  ["session", "--by-agent", "--json", "--offline"],
@@ -205,26 +209,44 @@ def sessions(since_days: int = 30) -> dict:
205
209
  )
206
210
  rows = data.get("session") or []
207
211
 
208
- 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
+
209
235
  out = []
210
236
  for r in rows:
211
237
  meta = r.get("metadata") or {}
212
- sid = r.get("period") # session id in ccusage's session report
238
+ sid = r.get("period")
213
239
  last_activity = meta.get("lastActivity") or ""
214
- # date filter by last activity
240
+ # date filter by last activity against [lo, hi]
215
241
  if last_activity:
216
242
  try:
217
243
  act_date = date.fromisoformat(last_activity[:10])
218
244
  except ValueError:
219
245
  act_date = None
220
- if act_date is not None and act_date < cutoff:
221
- continue
246
+ if act_date is not None:
247
+ if act_date < lo or act_date > hi:
248
+ continue
222
249
  project_raw = meta.get("projectPath") or ""
223
- # Resolve the authoritative workdir (dir name) for every agent that stores one
224
- # locally. ccusage's projectPath only covers pi and is lossy for hyphenated names
225
- # ("fund-tracker" -> "tracker"). Each agent keeps its own session store whose
226
- # files carry the real cwd, so we read those. This ONLY affects the display label
227
- # (dirName/cwd) — every numeric cost/token field still comes 100% from ccusage.
228
250
  agent_name = r.get("agent", "?")
229
251
  cwd = ""
230
252
  dir_name = ""
@@ -236,7 +258,6 @@ def sessions(since_days: int = 30) -> dict:
236
258
  if cwd_base:
237
259
  dir_name = cwd_base
238
260
  if not cwd:
239
- # fall back to a best-effort decode of ccusage's projectPath (pi only)
240
261
  cwd = _decode_cwd(project_raw)
241
262
  dir_name = _basename(project_raw)
242
263
  out.append(
@@ -249,14 +270,15 @@ def sessions(since_days: int = 30) -> dict:
249
270
  "cacheReadTokens": r.get("cacheReadTokens", 0),
250
271
  "cacheCreationTokens": r.get("cacheCreationTokens", 0),
251
272
  "lastActivity": last_activity,
252
- "cwd": cwd, # real workdir when resolved, else best-effort decode
253
- "dirName": dir_name, # real dir name when resolved, else last path segment
254
- "projectKey": project_raw, # raw ccusage projectPath
273
+ "cwd": cwd,
274
+ "dirName": dir_name,
275
+ "projectKey": project_raw,
255
276
  "hasCwd": bool(cwd),
256
277
  }
257
278
  )
258
279
  out.sort(key=lambda s: s["cost"], reverse=True)
259
- 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}
260
282
 
261
283
 
262
284
  def _pi_cwd_from_disk(project_key: str):
@@ -498,8 +520,10 @@ class Handler(BaseHTTPRequestHandler):
498
520
  self._json(trend(days))
499
521
  return
500
522
  elif path == "/api/sessions":
523
+ period = params.get("period", "")
524
+ frm, to = params.get("from", ""), params.get("to", "")
501
525
  days = max(1, min(366, int(params.get("days", "30"))))
502
- self._json(sessions(days))
526
+ self._json(sessions(days, period, frm, to))
503
527
  return
504
528
  else:
505
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.6",
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",