@caius_kong/ccusage-dashboard 0.2.20 → 0.2.22
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 +28 -9
- package/lib/server.py +40 -22
- package/package.json +1 -1
package/lib/index.html
CHANGED
|
@@ -346,10 +346,9 @@ async function rangeApply(){
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
async function loadTrend(){
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
$("trendSum").textContent=fmtUsd((d.days||[]).reduce((a,x)=>a+(x.totalCost||0),0));
|
|
349
|
+
// Used by the resize redraw and first paint; a failed trend must not surface
|
|
350
|
+
// as an unhandled rejection (auxTick reports status via the tick() indicator).
|
|
351
|
+
try{renderTrend(await fetchTrend());}catch(e){}
|
|
353
352
|
}
|
|
354
353
|
|
|
355
354
|
function drawTrend(days){
|
|
@@ -415,12 +414,30 @@ async function fetchMonth(){
|
|
|
415
414
|
return d;
|
|
416
415
|
}
|
|
417
416
|
|
|
417
|
+
async function fetchTrend(){
|
|
418
|
+
const d=await(await fetch("/api/trend?days=30")).json();
|
|
419
|
+
if(d.error)throw new Error(d.error);
|
|
420
|
+
return d;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function renderTrend(d){
|
|
424
|
+
drawTrend(d.days||[]);
|
|
425
|
+
$("trendSum").textContent=fmtUsd((d.days||[]).reduce((a,x)=>a+(x.totalCost||0),0));
|
|
426
|
+
}
|
|
427
|
+
|
|
418
428
|
async function tick(){ // the active view + the sessions panel
|
|
419
429
|
const dot=$("dot"),st=$("status");
|
|
420
430
|
try{
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
431
|
+
// These two hit independent endpoints with independent caches and render
|
|
432
|
+
// into different panels, so run them together. Awaiting one before starting
|
|
433
|
+
// the other would make a cold tick cost `today + sessions` worth of scans
|
|
434
|
+
// (~13-24s each) — close enough to the 60s refresh window to risk the next
|
|
435
|
+
// tick arriving before this one finishes. The server's run gate is sized for
|
|
436
|
+
// exactly this small overlap.
|
|
437
|
+
await Promise.all([
|
|
438
|
+
active==="range"?rangeApply():loadModels(),
|
|
439
|
+
loadSessions(),
|
|
440
|
+
]);
|
|
424
441
|
dot.className="dot ok";st.textContent="live";
|
|
425
442
|
$("lastUpd").textContent="last updated "+new Date().toLocaleTimeString();
|
|
426
443
|
}catch(e){dot.className="dot err";st.textContent="error: "+e.message;}
|
|
@@ -428,8 +445,10 @@ async function tick(){ // the active view + the sessions panel
|
|
|
428
445
|
|
|
429
446
|
async function auxTick(){ // always-visible panels: budget + 30d trend
|
|
430
447
|
try{
|
|
431
|
-
|
|
432
|
-
await
|
|
448
|
+
// Independent endpoints again: fetch both, then render.
|
|
449
|
+
const [month,trend]=await Promise.all([fetchMonth(), fetchTrend()]);
|
|
450
|
+
applyBudget(month);
|
|
451
|
+
renderTrend(trend);
|
|
433
452
|
}catch(e){/* status is reported by tick() */}
|
|
434
453
|
}
|
|
435
454
|
|
package/lib/server.py
CHANGED
|
@@ -81,12 +81,9 @@ BUDGET = 300.0 # monthly cap in USD (override via --budget or CCUSAGE_BUDGET)
|
|
|
81
81
|
# the heavier reports cost ~12-16 CPU-seconds each and are rarely watched, so
|
|
82
82
|
# they re-scan a tenth as often. Inactive views are never polled at all.
|
|
83
83
|
#
|
|
84
|
-
# A value here is the refresh PERIOD, not a
|
|
85
|
-
#
|
|
86
|
-
#
|
|
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.
|
|
84
|
+
# A value here is the refresh PERIOD, not a length of time an entry is kept.
|
|
85
|
+
# run_ccusage expires entries slightly before their period elapses, so each new
|
|
86
|
+
# period triggers exactly one fresh scan regardless of how long a scan takes.
|
|
90
87
|
_REFRESH_INTERVAL = 60 # browser poll interval; must match lib/index.html
|
|
91
88
|
_SLOW_INTERVAL = 600 # cadence for the non-default views
|
|
92
89
|
TTL = {
|
|
@@ -100,9 +97,17 @@ TTL = {
|
|
|
100
97
|
|
|
101
98
|
# A transient failure is remembered only briefly: long enough that concurrent
|
|
102
99
|
# waiters share it instead of each retrying, short enough that the endpoint
|
|
103
|
-
# recovers quickly rather than staying
|
|
100
|
+
# recovers quickly rather than staying whole a full period.
|
|
104
101
|
_ERROR_TTL = 30
|
|
105
102
|
|
|
103
|
+
# Entries expire this many seconds before their period ends, so the next poll of
|
|
104
|
+
# the same cadence always triggers a fresh scan. Measured from the REQUEST, so it
|
|
105
|
+
# does not care when the scan started. The entry stays valid for
|
|
106
|
+
# `period - _EXPIRY_MARGIN` seconds, which must exceed the total time until a
|
|
107
|
+
# scan's data exists (queueing delay + runtime) or the entry would expire on
|
|
108
|
+
# write — keep the margin small and the period comfortably above one scan.
|
|
109
|
+
_EXPIRY_MARGIN = 5
|
|
110
|
+
|
|
106
111
|
# --- optional self-update check (purely user-triggered) ----------------------
|
|
107
112
|
# The dashboard is otherwise fully offline: it makes a network request ONLY when
|
|
108
113
|
# the user clicks the "check update" button in the UI. A short debounce stops
|
|
@@ -120,15 +125,22 @@ _last_check = None # (timestamp, result dict) — debounce + last outcome
|
|
|
120
125
|
def run_ccusage(args: list[str], ttl: float) -> dict:
|
|
121
126
|
"""Run ccusage and cache the JSON report under its argv for one `ttl` window.
|
|
122
127
|
|
|
123
|
-
``ttl`` is a refresh PERIOD
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
past the next tick and
|
|
128
|
+
``ttl`` here is a refresh PERIOD (the cadence the browser polls this report
|
|
129
|
+
at), and the entry is made to expire just before that period elapses — see
|
|
130
|
+
_EXPIRY_MARGIN. Expiry is measured from the REQUEST time (the tick), not from
|
|
131
|
+
when the scan happened to start, so queueing behind other scans cannot push
|
|
132
|
+
an entry past the next tick and silently double the cadence.
|
|
133
|
+
|
|
134
|
+
Two failure modes this avoids:
|
|
135
|
+
|
|
136
|
+
* Stamping the pre-run clock gave an entry a lifetime of `ttl - runtime`,
|
|
137
|
+
i.e. negative when a run outlasted its period, so the cache never hit and
|
|
138
|
+
every poll re-executed ccusage.
|
|
139
|
+
* Anchoring to the wall clock ("end of the current minute") expires an entry
|
|
140
|
+
on write whenever a run starts near a boundary.
|
|
128
141
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
entry stale and triggers one fresh scan, no matter how long the scan took.
|
|
142
|
+
Post-condition for any run shorter than `ttl - _EXPIRY_MARGIN`: the entry is
|
|
143
|
+
valid now, and stale by the next poll of the same cadence.
|
|
132
144
|
|
|
133
145
|
Requests for the same key are coalesced (single-flight): the first caller
|
|
134
146
|
runs ccusage and the rest wait for its result, so N concurrent misses spawn
|
|
@@ -147,11 +159,11 @@ def run_ccusage(args: list[str], ttl: float) -> dict:
|
|
|
147
159
|
if waiter is None:
|
|
148
160
|
waiter = threading.Event()
|
|
149
161
|
_inflight[key] = waiter
|
|
162
|
+
requested_at = now
|
|
150
163
|
break
|
|
151
164
|
waiter.wait() # someone else is already running this exact key
|
|
152
165
|
try:
|
|
153
166
|
with _run_gate:
|
|
154
|
-
started = time.time()
|
|
155
167
|
try:
|
|
156
168
|
proc = subprocess.run(
|
|
157
169
|
resolve_ccusage() + args,
|
|
@@ -164,9 +176,11 @@ def run_ccusage(args: list[str], ttl: float) -> dict:
|
|
|
164
176
|
data = {"error": f"{exc}"}
|
|
165
177
|
is_error = isinstance(data, dict) and bool(data.get("error"))
|
|
166
178
|
window = _ERROR_TTL if is_error else ttl
|
|
167
|
-
# Expire
|
|
168
|
-
#
|
|
169
|
-
|
|
179
|
+
# Expire just before this period ends, measured from the REQUEST, so the
|
|
180
|
+
# next poll of this cadence always finds it stale even if the scan was
|
|
181
|
+
# queued behind others. Never let the margin swallow a short window.
|
|
182
|
+
margin = min(_EXPIRY_MARGIN, window / 4)
|
|
183
|
+
expires_at = requested_at + window - margin
|
|
170
184
|
with _lock:
|
|
171
185
|
_cache[key] = (expires_at, data)
|
|
172
186
|
return data
|
|
@@ -690,14 +704,18 @@ def main() -> None:
|
|
|
690
704
|
# back-to-back for no benefit.
|
|
691
705
|
#
|
|
692
706
|
# Only reports the browser asks for on load are pre-warmed: the default
|
|
693
|
-
# view (today), the
|
|
694
|
-
#
|
|
695
|
-
#
|
|
707
|
+
# view (today), the always-visible panels (budget=month, 30-day trend)
|
|
708
|
+
# and the sessions list, which the first tick also requests. Pre-warming
|
|
709
|
+
# sessions matters: otherwise it queues behind the other three cold scans,
|
|
710
|
+
# and a queued run can finish too late to serve the next tick.
|
|
711
|
+
# week/range are omitted on purpose — the UI only fetches them when the
|
|
712
|
+
# user switches to that tab, so warming them would be a wasted
|
|
696
713
|
# full-history scan at every boot.
|
|
697
714
|
jobs = [
|
|
698
715
|
lambda: run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL["/api/today"]),
|
|
699
716
|
lambda: run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL["/api/month"]),
|
|
700
717
|
lambda: trend(30),
|
|
718
|
+
lambda: sessions(period="today"),
|
|
701
719
|
]
|
|
702
720
|
threads = [threading.Thread(target=job) for job in jobs]
|
|
703
721
|
for t in threads:
|
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.22",
|
|
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",
|