@caius_kong/ccusage-dashboard 0.2.18 → 0.2.20

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
@@ -28,6 +28,10 @@ token usage & cost from local data — accurate, but terminal-only and hard to *
28
28
  custom-range cost grouped by model, a 30-day cost trend, and a monthly budget alert.
29
29
  It auto-refreshes while you work, so you can *see* spend happen instead of running reports.
30
30
 
31
+ Only the view you're looking at is polled: **Today** (the default) refreshes every 60s;
32
+ **Week / Month / Custom Range** refresh every 10 minutes. Leaving the dashboard on Today
33
+ never re-scans the longer periods in the background.
34
+
31
35
  Because it shells out to `ccusage` for every number, **cost estimates are always identical
32
36
  to what ccusage itself reports** — the source you already trust.
33
37
 
@@ -74,7 +78,8 @@ launcher exits and the server keeps running, so you can close the terminal. Use
74
78
  | **Budget alert** | monthly cap (default $300) — green <80%, yellow <100%, red ≥100% |
75
79
 
76
80
  All costs in USD. **Cache hit rate** is the standard input-side metric:
77
- `cacheReadTokens / (cacheReadTokens + non-cached inputTokens)`. Auto-refresh every 15s.
81
+ `cacheReadTokens / (cacheReadTokens + non-cached inputTokens)`. Auto-refresh every 60s on the
82
+ default Today view (10 min for the other views; budget + trend 10 min).
78
83
 
79
84
  ## Update check (opt-out, purely manual)
80
85
 
@@ -90,16 +95,19 @@ automatically, ever. Disable even this with `--no-update-check` or
90
95
 
91
96
  ```
92
97
  Browser (index.html)
93
- │ fetch /api/... (auto-refresh)
98
+ │ fetch /api/... (polls only the ACTIVE view: today 60s, week/month/range 10min)
94
99
 
95
100
  server.py (Python stdlib, zero deps)
96
- │ spawns: ccusage daily/monthly/weekly ... --json --offline
101
+ │ spawns: ccusage daily/monthly/weekly/session ... --json --offline
97
102
 
98
103
  ccusage (your installed version — the real cost engine)
99
104
  ```
100
105
 
101
106
  - `lib/server.py` — Python stdlib HTTP server. Resolves ccusage the same way you run
102
- it (PATH → `npx ccusage`), so it always uses the system's version; warms caches on boot (~3s), then serves instant responses.
107
+ it (PATH → `npx ccusage`), so it always uses the system's version. Warms the reports the
108
+ first page load needs, then serves cached JSON: same-key requests coalesce onto one run
109
+ (single-flight), and a small semaphore keeps different keys from stacking full-history
110
+ scans on top of each other.
103
111
  - `lib/index.html` — single-file dashboard. No build step, no CDN.
104
112
  - `bin/ccusage-ui.js` — Node launcher (finds python3, starts server, prints URL).
105
113
 
package/lib/index.html CHANGED
@@ -131,7 +131,7 @@
131
131
  <div id="sessionsList"><div class="empty">loading…</div></div>
132
132
  </div>
133
133
 
134
- <div class="hint">auto-refresh 15s · costs in USD · <span id="lastUpd"></span></div>
134
+ <div class="hint">auto-refresh 60s · costs in USD · <span id="lastUpd"></span></div>
135
135
 
136
136
  <div class="modal-mask" id="updModal" style="display:none">
137
137
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="updTitle">
@@ -324,11 +324,12 @@ function applyCardsRange(data){
324
324
  renderModels(data);
325
325
  }
326
326
 
327
- async function loadModels(){
327
+ async function loadModels(monthData){
328
328
  let ep,data;
329
329
  if(active==="today"){ep="/api/today";data=await (await fetch(ep)).json();}
330
330
  else if(active==="week"){data=await (await fetch("/api/week")).json();}
331
- else if(active==="month"){data=await (await fetch("/api/month")).json();applyBudget(data);}
331
+ // monthData is the response tick()/the budget poll already fetched.
332
+ else if(active==="month"){data=monthData||await fetchMonth();applyBudget(data);}
332
333
  else{return;} // handled by rangeApply
333
334
  if(data.error)throw new Error(data.error);
334
335
  applyCards(data);
@@ -397,20 +398,52 @@ function drawTrend(days){
397
398
  };
398
399
  }
399
400
 
400
- async function tick(){
401
+ // The dashboard polls only the view the user is actually looking at.
402
+ // today (the default) -> every 60s — this is the view people watch live
403
+ // week / month / range -> every 600s — switching to one is a deliberate act
404
+ // budget pill + 30-day trend (always visible) -> every 600s
405
+ // Inactive views are never fetched in the background: leaving the tab on "today"
406
+ // does not keep re-scanning monthly/weekly data, which used to cost a full
407
+ // ccusage run each.
408
+ const VIEW_MS={today:60000, week:600000, month:600000, range:600000};
409
+ const AUX_MS=600000;
410
+ let viewTimer=null;
411
+
412
+ async function fetchMonth(){
413
+ const d=await(await fetch("/api/month")).json();
414
+ if(d.error)throw new Error(d.error);
415
+ return d;
416
+ }
417
+
418
+ async function tick(){ // the active view + the sessions panel
401
419
  const dot=$("dot"),st=$("status");
402
420
  try{
403
- const month=await(await fetch("/api/month")).json();
404
- if(month.error)throw new Error(month.error);
405
- applyBudget(month);
406
- await loadModels();
407
- await loadTrend();
421
+ if(active==="range")await rangeApply();
422
+ else await loadModels();
408
423
  await loadSessions();
409
424
  dot.className="dot ok";st.textContent="live";
410
425
  $("lastUpd").textContent="last updated "+new Date().toLocaleTimeString();
411
426
  }catch(e){dot.className="dot err";st.textContent="error: "+e.message;}
412
427
  }
413
428
 
429
+ async function auxTick(){ // always-visible panels: budget + 30d trend
430
+ try{
431
+ applyBudget(await fetchMonth());
432
+ await loadTrend();
433
+ }catch(e){/* status is reported by tick() */}
434
+ }
435
+
436
+ // The poll rate follows the active view, so switching tabs re-arms the timer.
437
+ function scheduleViewPoll(){
438
+ if(viewTimer)clearInterval(viewTimer);
439
+ viewTimer=setInterval(tick, VIEW_MS[active]||60000);
440
+ }
441
+
442
+ function refreshView(){
443
+ scheduleViewPoll();
444
+ tick();
445
+ }
446
+
414
447
  document.querySelectorAll(".tab").forEach(t=>{
415
448
  const activate=()=>{
416
449
  document.querySelectorAll(".tab").forEach(x=>{x.classList.remove("active");x.setAttribute("aria-selected","false");});
@@ -425,6 +458,7 @@ document.querySelectorAll(".tab").forEach(t=>{
425
458
  loadModels();
426
459
  }
427
460
  loadSessions();
461
+ scheduleViewPoll(); // the new view gets its own cadence
428
462
  };
429
463
  t.addEventListener("click",activate);
430
464
  t.addEventListener("keydown",(e)=>{if(e.key==="Enter"||e.key===" "||e.key==="Spacebar"){e.preventDefault();activate();}});
@@ -434,8 +468,9 @@ $("updBtn").addEventListener("click",checkUpdate);
434
468
  $("updOk").addEventListener("click",hideUpdModal);
435
469
  $("updModal").addEventListener("click",(e)=>{if(e.target.id==="updModal")hideUpdModal();});
436
470
 
437
- tick();
438
- setInterval(tick,15000);
471
+ refreshView(); // active view (own cadence) + sessions; re-arms per view
472
+ auxTick(); // budget pill + 30-day trend (slow cadence)
473
+ setInterval(auxTick, AUX_MS);
439
474
  window.addEventListener("resize",()=>{if($("trendChart").width)loadTrend();});
440
475
  </script>
441
476
  </body>
package/lib/server.py CHANGED
@@ -40,6 +40,18 @@ APP_DIR = Path(__file__).resolve().parent
40
40
  # Cache: args-key -> (expires_at, data)
41
41
  _cache: dict[str, tuple[float, object]] = {}
42
42
  _lock = threading.Lock()
43
+ # key -> Event set when the in-flight run for that key finishes (single-flight)
44
+ _inflight: dict[str, threading.Event] = {}
45
+
46
+ # Every report scans the full local session history, so N concurrent runs burn N
47
+ # cores to compute an answer. Same-key requests coalesce onto a single run (see
48
+ # run_ccusage); this gate additionally keeps DIFFERENT keys from stacking. It is
49
+ # deliberately small rather than 1: with per-endpoint TTLs (below) a steady-state
50
+ # tick has at most one miss, so the gate is only touched when warm-up or a slow
51
+ # endpoint's expiry collides with the default view's refresh. 2 bounds that
52
+ # collision without letting it re-stack into the multi-core spikes this repo had.
53
+ _MAX_CONCURRENT_RUNS = 2
54
+ _run_gate = threading.Semaphore(_MAX_CONCURRENT_RUNS)
43
55
 
44
56
 
45
57
  def resolve_ccusage() -> list[str]:
@@ -63,7 +75,33 @@ def resolve_ccusage() -> list[str]:
63
75
  _CCUSAGE_PATH_OVERRIDE: str | None = None
64
76
 
65
77
  BUDGET = 300.0 # monthly cap in USD (override via --budget or CCUSAGE_BUDGET)
66
- TTL = {"/api/today": 15, "/api/week": 60, "/api/month": 60, "/api/range": 120, "/api/trend": 120, "/api/sessions": 60}
78
+
79
+ # Refresh cadence is driven by the browser (lib/index.html), which polls the
80
+ # ACTIVE view only. Only the default Today view needs re-scanning every minute;
81
+ # the heavier reports cost ~12-16 CPU-seconds each and are rarely watched, so
82
+ # they re-scan a tenth as often. Inactive views are never polled at all.
83
+ #
84
+ # A value here is the refresh PERIOD, not a from-now duration: an entry is valid
85
+ # until the end of its period (see run_ccusage), so a request at the next period
86
+ # boundary always finds it stale and triggers exactly one fresh scan. That makes
87
+ # the refresh cadence independent of how long a scan happens to take — with a
88
+ # plain "now + ttl", a scan finishing `runtime` after the tick would keep the
89
+ # entry alive past the next tick and silently halve the refresh rate.
90
+ _REFRESH_INTERVAL = 60 # browser poll interval; must match lib/index.html
91
+ _SLOW_INTERVAL = 600 # cadence for the non-default views
92
+ TTL = {
93
+ "/api/today": _REFRESH_INTERVAL, # default view: re-scanned every minute
94
+ "/api/week": _SLOW_INTERVAL,
95
+ "/api/month": _SLOW_INTERVAL,
96
+ "/api/range": _SLOW_INTERVAL,
97
+ "/api/trend": _SLOW_INTERVAL,
98
+ "/api/sessions": _SLOW_INTERVAL, # the today view overrides this (see sessions())
99
+ }
100
+
101
+ # A transient failure is remembered only briefly: long enough that concurrent
102
+ # waiters share it instead of each retrying, short enough that the endpoint
103
+ # recovers quickly rather than staying broken for a whole TTL.
104
+ _ERROR_TTL = 30
67
105
 
68
106
  # --- optional self-update check (purely user-triggered) ----------------------
69
107
  # The dashboard is otherwise fully offline: it makes a network request ONLY when
@@ -80,25 +118,63 @@ _last_check = None # (timestamp, result dict) — debounce + last outcome
80
118
 
81
119
 
82
120
  def run_ccusage(args: list[str], ttl: float) -> dict:
121
+ """Run ccusage and cache the JSON report under its argv for one `ttl` window.
122
+
123
+ ``ttl`` is a refresh PERIOD, and an entry stays valid until that window ends
124
+ (`expires_at` is the next `ttl` boundary, not `now + ttl`). Stamping a
125
+ from-now duration instead would make the effective cadence `runtime + ttl`,
126
+ i.e. a scan that finishes 12s after a 60s tick would keep its entry alive
127
+ past the next tick and halve the refresh rate.
128
+
129
+ The boundary is anchored to when the scan STARTS, so successive windows tile
130
+ the clock exactly: a request at the next period boundary always finds the
131
+ entry stale and triggers one fresh scan, no matter how long the scan took.
132
+
133
+ Requests for the same key are coalesced (single-flight): the first caller
134
+ runs ccusage and the rest wait for its result, so N concurrent misses spawn
135
+ 1 subprocess. A global semaphore additionally caps concurrent ccusage
136
+ processes at _MAX_CONCURRENT_RUNS across all keys, so warm-up and browser
137
+ polls cannot stack heavy scans on top of each other.
138
+ """
83
139
  key = " ".join(args)
84
- now = time.time()
85
- with _lock:
86
- hit = _cache.get(key)
87
- if hit and hit[0] > now:
88
- return hit[1] # type: ignore[return-value]
140
+ while True:
141
+ now = time.time()
142
+ with _lock:
143
+ hit = _cache.get(key)
144
+ if hit and hit[0] > now:
145
+ return hit[1] # type: ignore[return-value]
146
+ waiter = _inflight.get(key)
147
+ if waiter is None:
148
+ waiter = threading.Event()
149
+ _inflight[key] = waiter
150
+ break
151
+ waiter.wait() # someone else is already running this exact key
89
152
  try:
90
- proc = subprocess.run(
91
- resolve_ccusage() + args,
92
- capture_output=True,
93
- text=True,
94
- timeout=120,
95
- )
96
- data = json.loads(proc.stdout)
97
- except Exception as exc: # noqa: BLE001
98
- data = {"error": f"{exc}"}
99
- with _lock:
100
- _cache[key] = (now + ttl, data)
101
- return data
153
+ with _run_gate:
154
+ started = time.time()
155
+ try:
156
+ proc = subprocess.run(
157
+ resolve_ccusage() + args,
158
+ capture_output=True,
159
+ text=True,
160
+ timeout=120,
161
+ )
162
+ data = json.loads(proc.stdout)
163
+ except Exception as exc: # noqa: BLE001
164
+ data = {"error": f"{exc}"}
165
+ is_error = isinstance(data, dict) and bool(data.get("error"))
166
+ window = _ERROR_TTL if is_error else ttl
167
+ # Expire at the end of the window the scan STARTED in, so windows tile the
168
+ # clock and the next request triggers exactly one fresh scan.
169
+ expires_at = (int(started / window) + 1) * window
170
+ with _lock:
171
+ _cache[key] = (expires_at, data)
172
+ return data
173
+ finally:
174
+ with _lock:
175
+ done = _inflight.pop(key, None)
176
+ if done is not None:
177
+ done.set()
102
178
 
103
179
 
104
180
  def _read_current_version() -> str | None:
@@ -286,7 +362,12 @@ def sessions(since_days: int = 30, period: str = "", from_date: str = "", to_dat
286
362
  ccusage_args = ["session", "--json", "--offline"]
287
363
  if lo and hi:
288
364
  ccusage_args += ["--since", lo.isoformat(), "--until", hi.isoformat()]
289
- data = run_ccusage(ccusage_args, TTL["/api/sessions"])
365
+ # The sessions panel follows the active view, so its cache lifetime must
366
+ # match that view's cadence: the today view is polled every minute, the
367
+ # others every _SLOW_INTERVAL. Using the slow TTL for a today window would
368
+ # keep the panel a minute stale behind its own refresh.
369
+ sessions_ttl = TTL["/api/today"] if period == "today" else TTL["/api/sessions"]
370
+ data = run_ccusage(ccusage_args, sessions_ttl)
290
371
  rows = data.get("session") or []
291
372
 
292
373
  out = []
@@ -603,24 +684,30 @@ def main() -> None:
603
684
  UPDATE_CHECKS = False
604
685
  print(f"using ccusage → {' '.join(resolve_ccusage())}", flush=True)
605
686
 
606
- def warm_one(fn):
607
- fn()
608
-
609
687
  def warm():
610
- threads = [
611
- threading.Thread(target=warm_one, args=(lambda: run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL["/api/today"]),)),
612
- threading.Thread(target=warm_one, args=(lambda: run_ccusage(["weekly", "--last", "1", "--json", "--offline"], TTL["/api/week"]),)),
613
- threading.Thread(target=warm_one, args=(lambda: run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL["/api/month"]),)),
614
- threading.Thread(target=warm_one, args=(lambda: run_ccusage(["session", "--by-agent", "--json", "--offline"], TTL["/api/sessions"]),)),
615
- threading.Thread(target=warm_one, args=(lambda: trend(30),)),
688
+ # Fire concurrently and let _run_gate bound how many actually run at once;
689
+ # serializing here would make a cold start pay for all the scans
690
+ # back-to-back for no benefit.
691
+ #
692
+ # Only reports the browser asks for on load are pre-warmed: the default
693
+ # view (today), the two always-visible panels (budget=month, 30-day
694
+ # trend). week/range are omitted on purpose — the UI only fetches them
695
+ # when the user switches to that tab, so warming them would be a wasted
696
+ # full-history scan at every boot.
697
+ jobs = [
698
+ lambda: run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL["/api/today"]),
699
+ lambda: run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL["/api/month"]),
700
+ lambda: trend(30),
616
701
  ]
702
+ threads = [threading.Thread(target=job) for job in jobs]
617
703
  for t in threads:
618
704
  t.start()
619
705
  for t in threads:
620
706
  t.join()
621
707
 
622
708
  # warm the caches in the background so the server is reachable immediately;
623
- # the first page load will wait for warm-up to finish via the TTL cache lock.
709
+ # a page load arriving mid-warm-up joins the matching in-flight run (see
710
+ # single-flight in run_ccusage) instead of starting a duplicate scan.
624
711
  if not args.no_warm:
625
712
  print("warming ccusage caches in background…", flush=True)
626
713
  threading.Thread(target=warm, daemon=True).start()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
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",
@@ -25,7 +25,8 @@
25
25
  "token-usage"
26
26
  ],
27
27
  "scripts": {
28
- "start": "node bin/ccusage-ui.js"
28
+ "start": "node bin/ccusage-ui.js",
29
+ "test": "python3 -m unittest discover -s tests"
29
30
  },
30
31
  "repository": {
31
32
  "type": "git",