@caius_kong/ccusage-dashboard 0.1.1 → 0.2.1

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/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.1",
3
+ "version": "0.2.1",
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",