@caius_kong/ccusage-dashboard 0.2.15 → 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 +11 -0
- package/lib/index.html +70 -1
- package/lib/server.py +99 -3
- package/package.json +1 -1
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 "–";
|
|
@@ -364,6 +430,9 @@ document.querySelectorAll(".tab").forEach(t=>{
|
|
|
364
430
|
t.addEventListener("keydown",(e)=>{if(e.key==="Enter"||e.key===" "||e.key==="Spacebar"){e.preventDefault();activate();}});
|
|
365
431
|
});
|
|
366
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();});
|
|
367
436
|
|
|
368
437
|
tick();
|
|
369
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
|
|
12
|
-
|
|
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.
|
|
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",
|