@caius_kong/ccusage-dashboard 0.2.18 → 0.2.19
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 +12 -4
- package/lib/index.html +46 -11
- package/lib/server.py +116 -29
- package/package.json +3 -2
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
|
|
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/... (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
#
|
|
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.
|
|
3
|
+
"version": "0.2.19",
|
|
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",
|