@caius_kong/ccusage-dashboard 0.2.19 → 0.2.21

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,16 @@ 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. Expiry is measured from the
105
+ # REQUEST, so it does not care when the scan started; the margin only has to
106
+ # cover the gap between a poll and the entry it replaces. Must stay well below
107
+ # the smallest period.
108
+ _EXPIRY_MARGIN = 5
109
+
106
110
  # --- optional self-update check (purely user-triggered) ----------------------
107
111
  # The dashboard is otherwise fully offline: it makes a network request ONLY when
108
112
  # the user clicks the "check update" button in the UI. A short debounce stops
@@ -120,15 +124,22 @@ _last_check = None # (timestamp, result dict) — debounce + last outcome
120
124
  def run_ccusage(args: list[str], ttl: float) -> dict:
121
125
  """Run ccusage and cache the JSON report under its argv for one `ttl` window.
122
126
 
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.
127
+ ``ttl`` here is a refresh PERIOD (the cadence the browser polls this report
128
+ at), and the entry is made to expire just before that period elapses — see
129
+ _EXPIRY_MARGIN. Expiry is measured from the REQUEST time (the tick), not from
130
+ when the scan happened to start, so queueing behind other scans cannot push
131
+ an entry past the next tick and silently double the cadence.
132
+
133
+ Two failure modes this avoids:
134
+
135
+ * Stamping the pre-run clock gave an entry a lifetime of `ttl - runtime`,
136
+ i.e. negative when a run outlasted its period, so the cache never hit and
137
+ every poll re-executed ccusage.
138
+ * Anchoring to the wall clock ("end of the current minute") expires an entry
139
+ on write whenever a run starts near a boundary.
128
140
 
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.
141
+ Post-condition for any run shorter than `ttl - _EXPIRY_MARGIN`: the entry is
142
+ valid now, and stale by the next poll of the same cadence.
132
143
 
133
144
  Requests for the same key are coalesced (single-flight): the first caller
134
145
  runs ccusage and the rest wait for its result, so N concurrent misses spawn
@@ -147,11 +158,11 @@ def run_ccusage(args: list[str], ttl: float) -> dict:
147
158
  if waiter is None:
148
159
  waiter = threading.Event()
149
160
  _inflight[key] = waiter
161
+ requested_at = now
150
162
  break
151
163
  waiter.wait() # someone else is already running this exact key
152
164
  try:
153
165
  with _run_gate:
154
- started = time.time()
155
166
  try:
156
167
  proc = subprocess.run(
157
168
  resolve_ccusage() + args,
@@ -164,9 +175,11 @@ def run_ccusage(args: list[str], ttl: float) -> dict:
164
175
  data = {"error": f"{exc}"}
165
176
  is_error = isinstance(data, dict) and bool(data.get("error"))
166
177
  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
178
+ # Expire just before this period ends, measured from the REQUEST, so the
179
+ # next poll of this cadence always finds it stale even if the scan was
180
+ # queued behind others. Never let the margin swallow a short window.
181
+ margin = min(_EXPIRY_MARGIN, window / 4)
182
+ expires_at = requested_at + window - margin
170
183
  with _lock:
171
184
  _cache[key] = (expires_at, data)
172
185
  return data
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
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",