@caius_kong/ccusage-dashboard 0.2.14 → 0.2.16

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
@@ -52,6 +52,7 @@ npx @caius_kong/ccusage-dashboard --budget 500 # monthly budget cap (default
52
52
  npx @caius_kong/ccusage-dashboard --no-warm # skip background warm-up
53
53
  npx @caius_kong/ccusage-dashboard --foreground # run attached to this terminal (Ctrl+C stops it)
54
54
  npx @caius_kong/ccusage-dashboard --stop # stop the background instance
55
+ npx @caius_kong/ccusage-dashboard --no-update-check # disable the npm update hint entirely
55
56
  ```
56
57
 
57
58
  Once started, the launcher prints the dashboard URL — open it in your browser
@@ -75,6 +76,16 @@ launcher exits and the server keeps running, so you can close the terminal. Use
75
76
  All costs in USD. **Cache hit rate** is the standard input-side metric:
76
77
  `cacheReadTokens / (cacheReadTokens + non-cached inputTokens)`. Auto-refresh every 15s.
77
78
 
79
+ ## Update check (opt-out, purely manual)
80
+
81
+ This dashboard is otherwise fully offline. The only way it touches the network
82
+ is when **you click the "⇪ check update" button** in the header: the server then
83
+ makes one quick request to the npm registry and pops up the result — up to date,
84
+ update available (with the exact `npx @caius_kong/ccusage-dashboard@latest`
85
+ command to run yourself in a terminal), or unreachable. Nothing is checked
86
+ automatically, ever. Disable even this with `--no-update-check` or
87
+ `CCUSAGE_NO_UPDATE_CHECK=1`.
88
+
78
89
  ## How it works
79
90
 
80
91
  ```
package/lib/index.html CHANGED
@@ -49,6 +49,19 @@
49
49
  .empty{color:var(--muted);padding:18px 0;text-align:center}
50
50
  .err{color:var(--red)}
51
51
  .pill{background:var(--panel-2);border:1px solid var(--border);border-radius:999px;padding:1px 8px;font-size:11px;color:var(--muted);margin-left:6px}
52
+ .upd-btn{background:transparent;border:1px solid var(--border);border-radius:999px;padding:2px 11px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:border-color .15s,color .15s}
53
+ .upd-btn:hover{border-color:var(--accent);color:var(--text)}
54
+ .upd-btn:disabled{opacity:.55;cursor:default}
55
+ .btn{background:var(--panel-2);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:5px 14px;cursor:pointer;font-family:inherit;font-size:13px}
56
+ .btn:hover{border-color:var(--accent)}
57
+ .btn.primary{background:var(--accent);border-color:var(--accent);color:#0d1117;font-weight:600}
58
+ .modal-mask{position:fixed;inset:0;background:rgba(1,4,9,.6);display:flex;align-items:center;justify-content:center;z-index:60}
59
+ .modal{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;max-width:430px;width:calc(100vw - 40px);box-shadow:0 14px 44px rgba(0,0,0,.5)}
60
+ .modal h3{font-size:15px;margin:0 0 10px}
61
+ .modal .body{color:var(--text);font-size:13px;line-height:1.55}
62
+ .cmd{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin:12px 0}
63
+ .cmd code{flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text);word-break:break-all}
64
+ .modal .foot{display:flex;justify-content:flex-end;margin-top:16px}
52
65
  .hint{color:var(--muted);font-size:12px;margin-top:14px;text-align:center}
53
66
  .range{display:flex;gap:8px;align-items:center;margin-left:6px;flex-wrap:wrap}
54
67
  .range input{background:var(--panel-2);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 8px;font-size:12px;font-family:inherit}
@@ -71,7 +84,7 @@
71
84
  <header>
72
85
  <h1>ccusage · Live Cost</h1>
73
86
  <span class="sub">by model, straight from ccusage</span>
74
- <span class="status"><span class="dot" id="dot"></span><span id="status">connecting…</span></span>
87
+ <span class="status"><button class="upd-btn" id="updBtn" type="button" title="Check the npm registry for a newer version">⇪ check update</button><span class="dot" id="dot"></span><span id="status">connecting…</span></span>
75
88
  </header>
76
89
 
77
90
  <div class="tabs" role="tablist" aria-label="Time period">
@@ -120,11 +133,64 @@
120
133
 
121
134
  <div class="hint">auto-refresh 15s · costs in USD · <span id="lastUpd"></span></div>
122
135
 
136
+ <div class="modal-mask" id="updModal" style="display:none">
137
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="updTitle">
138
+ <h3 id="updTitle"></h3>
139
+ <div class="body" id="updBody"></div>
140
+ <div class="foot"><button class="btn primary" id="updOk" type="button">OK</button></div>
141
+ </div>
142
+ </div>
143
+
123
144
  <script>
124
145
  const $=(id)=>document.getElementById(id);
125
146
  const tabs={today:{label:"Today",len:0},week:{label:"This Week",len:0},month:{label:"This Month",len:0},range:{label:"Custom Range",len:1}};
126
147
  let active="today", modelCtx=[];
127
148
 
149
+ const PKG_NPM="@caius_kong/ccusage-dashboard";
150
+ const CMD_UPDATE=`npx ${PKG_NPM}@latest`;
151
+ function showUpdModal(title,body){
152
+ $("updTitle").textContent=title;
153
+ $("updBody").innerHTML=body;
154
+ $("updModal").style.display="flex";
155
+ }
156
+ function hideUpdModal(){$("updModal").style.display="none";}
157
+ async function copyText(t,btn){
158
+ try{await navigator.clipboard.writeText(t);}
159
+ catch(e){
160
+ const ta=document.createElement("textarea");ta.value=t;ta.style.position="fixed";ta.style.opacity="0";
161
+ document.body.appendChild(ta);ta.select();
162
+ try{document.execCommand("copy");}catch(_){}
163
+ document.body.removeChild(ta);
164
+ }
165
+ const old=btn.textContent;btn.textContent="copied ✓";
166
+ setTimeout(()=>{btn.textContent=old;},1200);
167
+ }
168
+ async function checkUpdate(){
169
+ const b=$("updBtn"),orig=b.textContent;
170
+ b.disabled=true;b.textContent="checking…";
171
+ try{
172
+ const d=await(await fetch("/api/update")).json();
173
+ if(d.disabled){
174
+ showUpdModal("Update check is off","This instance was started with <code>--no-update-check</code>, so version checks are disabled.");
175
+ }else if(d.error){
176
+ showUpdModal("Couldn't reach npm","The npm registry is not reachable right now. Check your internet connection and try again.");
177
+ }else if(d.outdated){
178
+ const cur=esc(d.current||"?"),lat=esc(d.latest||"?");
179
+ showUpdModal("Update available",
180
+ `You're running <b>v${cur}</b> — the latest version is <b>v${lat}</b>.<br>`+
181
+ `Update by running this in your terminal:`+
182
+ `<div class="cmd"><code>${CMD_UPDATE}</code><button class="btn" id="updCopy" type="button">copy</button></div>`+
183
+ `<div style="color:var(--muted);font-size:12px">Then restart the dashboard (run <code>--stop</code> first if it's running in the background).</div>`);
184
+ const cp=$("updCopy");if(cp)cp.addEventListener("click",(ev)=>copyText(CMD_UPDATE,ev.currentTarget));
185
+ }else{
186
+ showUpdModal("Up to date",`You're on the latest version <b>v${esc(d.current||"?")}</b> ✓`);
187
+ }
188
+ }catch(e){
189
+ showUpdModal("Check failed","Couldn't reach the dashboard server for a version check.");
190
+ }finally{
191
+ b.disabled=false;b.textContent=orig;
192
+ }
193
+ }
128
194
  function fmtUsd(n){return "$"+(n==null?"–":Number(n).toFixed(2));}
129
195
  function fmtTok(n){
130
196
  if(n==null)return "–";
@@ -134,6 +200,18 @@ function fmtTok(n){
134
200
  return String(n);
135
201
  }
136
202
  function fmtPct(n){return n==null?"–":n.toFixed(1)+"%";}
203
+ // cache hit rate (input side): read / (read + non-cached input). Shared by the
204
+ // c5 summary card and per-row hit spans.
205
+ function cacheHitStats(o){
206
+ const readIn=(o&&o.cacheReadTokens)||0, missIn=(o&&o.inputTokens)||0;
207
+ const rate=(readIn+missIn)>0?(readIn/(readIn+missIn))*100:null;
208
+ return {readIn, missIn, rate};
209
+ }
210
+ function cacheHitSpan(o){
211
+ const {readIn, missIn, rate}=cacheHitStats(o);
212
+ const tip=rate!=null?`cache hit rate: ${fmtPct(rate)} (read ${fmtTok(readIn)} / in ${fmtTok(missIn)})`:"no cache read data";
213
+ return `<span title="${tip}">hit ${fmtPct(rate)}</span>`;
214
+ }
137
215
  function esc(s){const d=document.createElement("div");d.textContent=s;return d.innerHTML;}
138
216
  async function copySessionId(id, ev){
139
217
  ev.stopPropagation();
@@ -165,7 +243,7 @@ function renderModels(data){
165
243
  const badge=m2?`<span class="pill">${esc(m2[1])}</span>`:"";
166
244
  const mn=m2?esc(m2[2]||m2[1]):esc(m.name);
167
245
  return `<div class="row"><div class="row-top"><span class="name">${mn}${badge ? " " + badge : ""}</span><span class="cost">${fmtUsd(m.cost)} · ${pct.toFixed(1)}%</span></div>
168
- <div class="toks"><span title="input">in ${fmtTok(m.inputTokens)}</span><span title="output">out ${fmtTok(m.outputTokens)}</span><span title="cache read">read ${fmtTok(m.cacheReadTokens)}</span><span title="cache write">write ${fmtTok(m.cacheCreationTokens)}</span></div>
246
+ <div class="toks"><span title="input">in ${fmtTok(m.inputTokens)}</span><span title="output">out ${fmtTok(m.outputTokens)}</span><span title="cache read">read ${fmtTok(m.cacheReadTokens)}</span>${cacheHitSpan(m)}<span title="cache write">write ${fmtTok(m.cacheCreationTokens)}</span></div>
169
247
  <div class="bar"><div style="width:${Math.max(pct,1.2)}%"></div></div></div>`;
170
248
  }).join("");
171
249
  }
@@ -218,7 +296,7 @@ function renderSessions(data){
218
296
  const last=(s.lastActivity||"").slice(0,10);
219
297
  return `<div class="row">
220
298
  <div class="row-top"><span>${label} <span class="pill">${esc(s.agent)}</span></span><span class="cost">${fmtUsd(s.cost)}</span></div>
221
- <div class="toks">${last?`<span>${esc(last)}</span>`:""}<span title="input">in ${fmtTok(s.inputTokens)}</span><span title="output">out ${fmtTok(s.outputTokens)}</span><span title="cache read">read ${fmtTok(s.cacheReadTokens)}</span><span title="cache write">write ${fmtTok(s.cacheCreationTokens)}</span></div>
299
+ <div class="toks">${last?`<span>${esc(last)}</span>`:""}<span title="input">in ${fmtTok(s.inputTokens)}</span><span title="output">out ${fmtTok(s.outputTokens)}</span><span title="cache read">read ${fmtTok(s.cacheReadTokens)}</span>${cacheHitSpan(s)}<span title="cache write">write ${fmtTok(s.cacheCreationTokens)}</span></div>
222
300
  </div>`;
223
301
  }).join("");
224
302
  }
@@ -232,10 +310,9 @@ function applyCards(data){
232
310
  $("c3").textContent=fmtTok(data.cacheReadTokens);
233
311
  $("c4").textContent=fmtTok(data.cacheCreationTokens);
234
312
  // cache hit rate = cache-read input / (cache-read input + non-cached input)
235
- const hitIn=data.cacheReadTokens||0, plainIn=data.inputTokens||0;
236
- const hitRate = (hitIn+plainIn)>0 ? (hitIn/(hitIn+plainIn))*100 : null;
313
+ const {readIn, missIn, rate: hitRate}=cacheHitStats(data);
237
314
  $("c5").innerHTML=fmtPct(hitRate)+(hitRate!=null?"<small> cache</small>":"");
238
- $("c5p").textContent=hitRate!=null?`${fmtTok(plainIn)} miss · ${fmtTok(hitIn)} hit`:"";
315
+ $("c5p").textContent=hitRate!=null?`${fmtTok(missIn)} miss · ${fmtTok(readIn)} hit`:"";
239
316
  $("c1label").textContent=tabs[active].label;
240
317
  $("modelSum").textContent=fmtUsd(total);
241
318
  }
@@ -353,6 +430,9 @@ document.querySelectorAll(".tab").forEach(t=>{
353
430
  t.addEventListener("keydown",(e)=>{if(e.key==="Enter"||e.key===" "||e.key==="Spacebar"){e.preventDefault();activate();}});
354
431
  });
355
432
  $("rangeGo").addEventListener("click",rangeApply);
433
+ $("updBtn").addEventListener("click",checkUpdate);
434
+ $("updOk").addEventListener("click",hideUpdModal);
435
+ $("updModal").addEventListener("click",(e)=>{if(e.target.id==="updModal")hideUpdModal();});
356
436
 
357
437
  tick();
358
438
  setInterval(tick,15000);
package/lib/server.py CHANGED
@@ -8,8 +8,13 @@ It shells out to ccusage's JSON reports and renders:
8
8
  - a monthly budget alert (default cap: $300)
9
9
 
10
10
  Because all numbers come from ccusage itself, the figures always match what
11
- `ccusage` reports (the source you already trust). No network, no pricing table
12
- to maintain, no third-party packages — only the Python standard library.
11
+ `ccusage` reports (the source you already trust). No pricing table to maintain,
12
+ no third-party packages — only the Python standard library. The sole optional
13
+ This dashboard is fully offline except one manual check: clicking the
14
+ "⇪ check update" button in the header makes a single request to the npm
15
+ registry and shows the result in a popup (up to date / update available
16
+ with the `npx @caius_kong/ccusage-dashboard@latest` command to run yourself /
17
+ unreachable). Nothing is ever checked automatically.
13
18
 
14
19
  Usage:
15
20
  python3 server.py [--port 8799] [--budget 300]
@@ -25,6 +30,7 @@ import subprocess
25
30
  import sys
26
31
  import threading
27
32
  import time
33
+ import urllib.request
28
34
  from datetime import date, timedelta
29
35
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
30
36
  from pathlib import Path
@@ -97,6 +103,19 @@ def _node() -> str:
97
103
  BUDGET = 300.0 # monthly cap in USD (override via --budget or CCUSAGE_BUDGET)
98
104
  TTL = {"/api/today": 15, "/api/week": 60, "/api/month": 60, "/api/range": 120, "/api/trend": 120, "/api/sessions": 60}
99
105
 
106
+ # --- optional self-update check (purely user-triggered) ----------------------
107
+ # The dashboard is otherwise fully offline: it makes a network request ONLY when
108
+ # the user clicks the "check update" button in the UI. A short debounce stops
109
+ # rapid re-clicks from hammering the registry. Disable entirely with
110
+ # --no-update-check / CCUSAGE_NO_UPDATE_CHECK=1.
111
+ PACKAGE_NAME = "@caius_kong/ccusage-dashboard"
112
+ UPDATE_CHECKS = os.environ.get("CCUSAGE_NO_UPDATE_CHECK", "").lower() not in ("1", "true", "yes")
113
+ UPDATE_REGISTRY = os.environ.get("CCUSAGE_REGISTRY", "https://registry.npmjs.org")
114
+ CHECK_DEBOUNCE = 10 # seconds; ignore bursts of clicks
115
+
116
+ _update_lock = threading.Lock()
117
+ _last_check = None # (timestamp, result dict) — debounce + last outcome
118
+
100
119
 
101
120
  def run_ccusage(args: list[str], ttl: float) -> dict:
102
121
  key = " ".join(args)
@@ -120,6 +139,77 @@ def run_ccusage(args: list[str], ttl: float) -> dict:
120
139
  return data
121
140
 
122
141
 
142
+ def _read_current_version() -> str | None:
143
+ """Version of the running package. Finds the nearest package.json whose name
144
+ is ours (installed layout: <pkg>/package.json next to lib/; dev: repo root)."""
145
+ try:
146
+ for parent in APP_DIR.parents:
147
+ pj = parent / "package.json"
148
+ if pj.is_file():
149
+ meta = json.loads(pj.read_text(encoding="utf-8"))
150
+ if meta.get("name") == PACKAGE_NAME and meta.get("version"):
151
+ return str(meta["version"])
152
+ except Exception:
153
+ return None
154
+ return None
155
+
156
+
157
+ def _num(s: str) -> int:
158
+ m = re.match(r"\d+", s)
159
+ return int(m.group()) if m else 0
160
+
161
+
162
+ def compare_versions(a: str, b: str) -> int:
163
+ """Minimal numeric semver compare for simple x.y.z tags (no deps)."""
164
+ pa = [_num(x) for x in re.sub(r"^v", "", a).split("-")[0].split(".")]
165
+ pb = [_num(x) for x in re.sub(r"^v", "", b).split("-")[0].split(".")]
166
+ for i in range(max(len(pa), len(pb))):
167
+ x = pa[i] if i < len(pa) else 0
168
+ y = pb[i] if i < len(pb) else 0
169
+ if x != y:
170
+ return -1 if x < y else 1
171
+ return 0
172
+
173
+
174
+ def _fetch_latest_version() -> str:
175
+ """One GET to the npm registry's <latest> dist-tag endpoint."""
176
+ pkg = PACKAGE_NAME.replace("/", "%2F")
177
+ url = f"{UPDATE_REGISTRY.rstrip('/')}/{pkg}/latest"
178
+ req = urllib.request.Request(
179
+ url,
180
+ headers={
181
+ "User-Agent": f"ccusage-dashboard/{_read_current_version() or 'unknown'}",
182
+ },
183
+ )
184
+ with urllib.request.urlopen(req, timeout=4) as resp:
185
+ return str(json.loads(resp.read().decode("utf-8"))["version"])
186
+
187
+
188
+ def check_now() -> dict:
189
+ """One synchronous registry lookup, triggered by the user's click. Debounced
190
+ briefly so double-clicks don't hit the registry twice; never runs on its own."""
191
+ global _last_check
192
+ if not UPDATE_CHECKS:
193
+ return {"disabled": True, "outdated": False}
194
+ now = time.time()
195
+ with _update_lock:
196
+ if _last_check and now - _last_check[0] < CHECK_DEBOUNCE:
197
+ return _last_check[1]
198
+ current = _read_current_version()
199
+ try:
200
+ latest = _fetch_latest_version()
201
+ result = {
202
+ "current": current,
203
+ "latest": latest,
204
+ "outdated": bool(current) and bool(latest) and compare_versions(latest, current) > 0,
205
+ }
206
+ except Exception:
207
+ result = {"current": current, "latest": None, "outdated": False, "error": "unreachable"}
208
+ with _update_lock:
209
+ _last_check = (time.time(), result)
210
+ return result
211
+
212
+
123
213
  def pick_all_row(rows: list[dict]) -> dict:
124
214
  return next((r for r in rows if r.get("agent") == "all"), None) or (rows[0] if rows else {})
125
215
 
@@ -492,6 +582,9 @@ class Handler(BaseHTTPRequestHandler):
492
582
  if path == "/api/health":
493
583
  self._json({"ok": True, "budget": BUDGET})
494
584
  return
585
+ if path == "/api/update":
586
+ self._json(check_now())
587
+ return
495
588
 
496
589
  if path == "/api/today":
497
590
  data = run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL[path])
@@ -532,17 +625,20 @@ class Handler(BaseHTTPRequestHandler):
532
625
 
533
626
 
534
627
  def main() -> None:
535
- global BUDGET, _CCUSAGE_PATH_OVERRIDE
628
+ global BUDGET, _CCUSAGE_PATH_OVERRIDE, UPDATE_CHECKS
536
629
  parser = argparse.ArgumentParser(description="ccusage dashboard")
537
630
  parser.add_argument("--port", type=int, default=8799)
538
631
  parser.add_argument("--host", default="127.0.0.1")
539
632
  parser.add_argument("--budget", type=float, default=None, help="monthly budget cap in USD (default 300)")
540
633
  parser.add_argument("--ccusage-path", default=None, help="explicit path to a ccusage binary or src/cli.js")
541
634
  parser.add_argument("--no-warm", action="store_true", help="skip background warm-up (first requests may be slow)")
635
+ parser.add_argument("--no-update-check", action="store_true", help="disable the npm version hint entirely")
542
636
  args = parser.parse_args()
543
637
 
544
638
  BUDGET = args.budget if args.budget is not None else float(os_env_budget() or 300.0)
545
639
  _CCUSAGE_PATH_OVERRIDE = args.ccusage_path
640
+ if args.no_update_check:
641
+ UPDATE_CHECKS = False
546
642
  print(f"using ccusage → {' '.join(resolve_ccusage())}", flush=True)
547
643
 
548
644
  def warm_one(fn):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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",