@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 CHANGED
@@ -346,10 +346,9 @@ async function rangeApply(){
346
346
  }
347
347
 
348
348
  async function loadTrend(){
349
- const d=await(await fetch("/api/trend?days=30")).json();
350
- if(d.error){return;}
351
- drawTrend(d.days||[]);
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
- if(active==="range")await rangeApply();
422
- else await loadModels();
423
- await loadSessions();
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
- applyBudget(await fetchMonth());
432
- await loadTrend();
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 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.
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 broken for a whole TTL.
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, 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
+ ``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
- 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.
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 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
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 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
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.20",
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",