@caius_kong/ccusage-dashboard 0.2.17 → 0.2.19
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 +13 -6
- package/lib/index.html +46 -11
- package/lib/server.py +123 -78
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -28,6 +28,10 @@ token usage & cost from local data — accurate, but terminal-only and hard to *
|
|
|
28
28
|
custom-range cost grouped by model, a 30-day cost trend, and a monthly budget alert.
|
|
29
29
|
It auto-refreshes while you work, so you can *see* spend happen instead of running reports.
|
|
30
30
|
|
|
31
|
+
Only the view you're looking at is polled: **Today** (the default) refreshes every 60s;
|
|
32
|
+
**Week / Month / Custom Range** refresh every 10 minutes. Leaving the dashboard on Today
|
|
33
|
+
never re-scans the longer periods in the background.
|
|
34
|
+
|
|
31
35
|
Because it shells out to `ccusage` for every number, **cost estimates are always identical
|
|
32
36
|
to what ccusage itself reports** — the source you already trust.
|
|
33
37
|
|
|
@@ -74,7 +78,8 @@ launcher exits and the server keeps running, so you can close the terminal. Use
|
|
|
74
78
|
| **Budget alert** | monthly cap (default $300) — green <80%, yellow <100%, red ≥100% |
|
|
75
79
|
|
|
76
80
|
All costs in USD. **Cache hit rate** is the standard input-side metric:
|
|
77
|
-
`cacheReadTokens / (cacheReadTokens + non-cached inputTokens)`. Auto-refresh every
|
|
81
|
+
`cacheReadTokens / (cacheReadTokens + non-cached inputTokens)`. Auto-refresh every 60s on the
|
|
82
|
+
default Today view (10 min for the other views; budget + trend 10 min).
|
|
78
83
|
|
|
79
84
|
## Update check (opt-out, purely manual)
|
|
80
85
|
|
|
@@ -90,17 +95,19 @@ automatically, ever. Disable even this with `--no-update-check` or
|
|
|
90
95
|
|
|
91
96
|
```
|
|
92
97
|
Browser (index.html)
|
|
93
|
-
│ fetch /api/... (
|
|
98
|
+
│ fetch /api/... (polls only the ACTIVE view: today 60s, week/month/range 10min)
|
|
94
99
|
▼
|
|
95
100
|
server.py (Python stdlib, zero deps)
|
|
96
|
-
│ spawns: ccusage daily/monthly/weekly ... --json --offline
|
|
101
|
+
│ spawns: ccusage daily/monthly/weekly/session ... --json --offline
|
|
97
102
|
▼
|
|
98
103
|
ccusage (your installed version — the real cost engine)
|
|
99
104
|
```
|
|
100
105
|
|
|
101
|
-
- `lib/server.py` — Python stdlib HTTP server. Resolves
|
|
102
|
-
(PATH →
|
|
103
|
-
|
|
106
|
+
- `lib/server.py` — Python stdlib HTTP server. Resolves ccusage the same way you run
|
|
107
|
+
it (PATH → `npx ccusage`), so it always uses the system's version. Warms the reports the
|
|
108
|
+
first page load needs, then serves cached JSON: same-key requests coalesce onto one run
|
|
109
|
+
(single-flight), and a small semaphore keeps different keys from stacking full-history
|
|
110
|
+
scans on top of each other.
|
|
104
111
|
- `lib/index.html` — single-file dashboard. No build step, no CDN.
|
|
105
112
|
- `bin/ccusage-ui.js` — Node launcher (finds python3, starts server, prints URL).
|
|
106
113
|
|
package/lib/index.html
CHANGED
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
<div id="sessionsList"><div class="empty">loading…</div></div>
|
|
132
132
|
</div>
|
|
133
133
|
|
|
134
|
-
<div class="hint">auto-refresh
|
|
134
|
+
<div class="hint">auto-refresh 60s · costs in USD · <span id="lastUpd"></span></div>
|
|
135
135
|
|
|
136
136
|
<div class="modal-mask" id="updModal" style="display:none">
|
|
137
137
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="updTitle">
|
|
@@ -324,11 +324,12 @@ function applyCardsRange(data){
|
|
|
324
324
|
renderModels(data);
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
async function loadModels(){
|
|
327
|
+
async function loadModels(monthData){
|
|
328
328
|
let ep,data;
|
|
329
329
|
if(active==="today"){ep="/api/today";data=await (await fetch(ep)).json();}
|
|
330
330
|
else if(active==="week"){data=await (await fetch("/api/week")).json();}
|
|
331
|
-
|
|
331
|
+
// monthData is the response tick()/the budget poll already fetched.
|
|
332
|
+
else if(active==="month"){data=monthData||await fetchMonth();applyBudget(data);}
|
|
332
333
|
else{return;} // handled by rangeApply
|
|
333
334
|
if(data.error)throw new Error(data.error);
|
|
334
335
|
applyCards(data);
|
|
@@ -397,20 +398,52 @@ function drawTrend(days){
|
|
|
397
398
|
};
|
|
398
399
|
}
|
|
399
400
|
|
|
400
|
-
|
|
401
|
+
// The dashboard polls only the view the user is actually looking at.
|
|
402
|
+
// today (the default) -> every 60s — this is the view people watch live
|
|
403
|
+
// week / month / range -> every 600s — switching to one is a deliberate act
|
|
404
|
+
// budget pill + 30-day trend (always visible) -> every 600s
|
|
405
|
+
// Inactive views are never fetched in the background: leaving the tab on "today"
|
|
406
|
+
// does not keep re-scanning monthly/weekly data, which used to cost a full
|
|
407
|
+
// ccusage run each.
|
|
408
|
+
const VIEW_MS={today:60000, week:600000, month:600000, range:600000};
|
|
409
|
+
const AUX_MS=600000;
|
|
410
|
+
let viewTimer=null;
|
|
411
|
+
|
|
412
|
+
async function fetchMonth(){
|
|
413
|
+
const d=await(await fetch("/api/month")).json();
|
|
414
|
+
if(d.error)throw new Error(d.error);
|
|
415
|
+
return d;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function tick(){ // the active view + the sessions panel
|
|
401
419
|
const dot=$("dot"),st=$("status");
|
|
402
420
|
try{
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
applyBudget(month);
|
|
406
|
-
await loadModels();
|
|
407
|
-
await loadTrend();
|
|
421
|
+
if(active==="range")await rangeApply();
|
|
422
|
+
else await loadModels();
|
|
408
423
|
await loadSessions();
|
|
409
424
|
dot.className="dot ok";st.textContent="live";
|
|
410
425
|
$("lastUpd").textContent="last updated "+new Date().toLocaleTimeString();
|
|
411
426
|
}catch(e){dot.className="dot err";st.textContent="error: "+e.message;}
|
|
412
427
|
}
|
|
413
428
|
|
|
429
|
+
async function auxTick(){ // always-visible panels: budget + 30d trend
|
|
430
|
+
try{
|
|
431
|
+
applyBudget(await fetchMonth());
|
|
432
|
+
await loadTrend();
|
|
433
|
+
}catch(e){/* status is reported by tick() */}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// The poll rate follows the active view, so switching tabs re-arms the timer.
|
|
437
|
+
function scheduleViewPoll(){
|
|
438
|
+
if(viewTimer)clearInterval(viewTimer);
|
|
439
|
+
viewTimer=setInterval(tick, VIEW_MS[active]||60000);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function refreshView(){
|
|
443
|
+
scheduleViewPoll();
|
|
444
|
+
tick();
|
|
445
|
+
}
|
|
446
|
+
|
|
414
447
|
document.querySelectorAll(".tab").forEach(t=>{
|
|
415
448
|
const activate=()=>{
|
|
416
449
|
document.querySelectorAll(".tab").forEach(x=>{x.classList.remove("active");x.setAttribute("aria-selected","false");});
|
|
@@ -425,6 +458,7 @@ document.querySelectorAll(".tab").forEach(t=>{
|
|
|
425
458
|
loadModels();
|
|
426
459
|
}
|
|
427
460
|
loadSessions();
|
|
461
|
+
scheduleViewPoll(); // the new view gets its own cadence
|
|
428
462
|
};
|
|
429
463
|
t.addEventListener("click",activate);
|
|
430
464
|
t.addEventListener("keydown",(e)=>{if(e.key==="Enter"||e.key===" "||e.key==="Spacebar"){e.preventDefault();activate();}});
|
|
@@ -434,8 +468,9 @@ $("updBtn").addEventListener("click",checkUpdate);
|
|
|
434
468
|
$("updOk").addEventListener("click",hideUpdModal);
|
|
435
469
|
$("updModal").addEventListener("click",(e)=>{if(e.target.id==="updModal")hideUpdModal();});
|
|
436
470
|
|
|
437
|
-
|
|
438
|
-
|
|
471
|
+
refreshView(); // active view (own cadence) + sessions; re-arms per view
|
|
472
|
+
auxTick(); // budget pill + 30-day trend (slow cadence)
|
|
473
|
+
setInterval(auxTick, AUX_MS);
|
|
439
474
|
window.addEventListener("resize",()=>{if($("trendChart").width)loadTrend();});
|
|
440
475
|
</script>
|
|
441
476
|
</body>
|
package/lib/server.py
CHANGED
|
@@ -40,72 +40,68 @@ APP_DIR = Path(__file__).resolve().parent
|
|
|
40
40
|
# Cache: args-key -> (expires_at, data)
|
|
41
41
|
_cache: dict[str, tuple[float, object]] = {}
|
|
42
42
|
_lock = threading.Lock()
|
|
43
|
+
# key -> Event set when the in-flight run for that key finishes (single-flight)
|
|
44
|
+
_inflight: dict[str, threading.Event] = {}
|
|
45
|
+
|
|
46
|
+
# Every report scans the full local session history, so N concurrent runs burn N
|
|
47
|
+
# cores to compute an answer. Same-key requests coalesce onto a single run (see
|
|
48
|
+
# run_ccusage); this gate additionally keeps DIFFERENT keys from stacking. It is
|
|
49
|
+
# deliberately small rather than 1: with per-endpoint TTLs (below) a steady-state
|
|
50
|
+
# tick has at most one miss, so the gate is only touched when warm-up or a slow
|
|
51
|
+
# endpoint's expiry collides with the default view's refresh. 2 bounds that
|
|
52
|
+
# collision without letting it re-stack into the multi-core spikes this repo had.
|
|
53
|
+
_MAX_CONCURRENT_RUNS = 2
|
|
54
|
+
_run_gate = threading.Semaphore(_MAX_CONCURRENT_RUNS)
|
|
43
55
|
|
|
44
56
|
|
|
45
57
|
def resolve_ccusage() -> list[str]:
|
|
46
|
-
"""Resolve
|
|
58
|
+
"""Resolve the ccusage CLI the system itself uses, and only that.
|
|
47
59
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
Hard constraint: every number comes from the ccusage CLI, and it must be
|
|
61
|
+
the same one the user runs (`npx ccusage ...`). So we look for, in order,
|
|
62
|
+
an explicit --ccusage-path, a `ccusage` on PATH, then `npx --yes ccusage`.
|
|
63
|
+
No cache-scanning and no version-picking: the version is whatever the
|
|
64
|
+
system resolves, never a stale copy we chose ourselves.
|
|
53
65
|
"""
|
|
54
|
-
def _pkg_version(cli: Path) -> tuple | None:
|
|
55
|
-
"""Real version of the ccusage package owning `cli`.
|
|
56
|
-
|
|
57
|
-
Read it from package.json, NEVER from the directory path: npm's npx
|
|
58
|
-
cache uses opaque hash dirs (no "ccusage@x.y.z"), so path-regex would
|
|
59
|
-
degrade every candidate to (0, 0, 0) and make "pick highest" a silent
|
|
60
|
-
no-op that picks an arbitrary (often stale) version.
|
|
61
|
-
"""
|
|
62
|
-
pj = cli.parent.parent / "package.json"
|
|
63
|
-
try:
|
|
64
|
-
m = re.search(r'"version"\s*:\s*"(\d+)\.(\d+)\.(\d+)', pj.read_text())
|
|
65
|
-
except OSError:
|
|
66
|
-
return None
|
|
67
|
-
return tuple(map(int, m.groups())) if m else None
|
|
68
|
-
|
|
69
|
-
def _npx_cached_cli() -> str | None:
|
|
70
|
-
"""Highest-versioned ccusage in the npx/bun caches, or None.
|
|
71
|
-
|
|
72
|
-
Only candidates whose real version is readable are considered, so we
|
|
73
|
-
never fall back to an arbitrary pick. Returning None lets the caller
|
|
74
|
-
defer to `npx ccusage@latest`, which resolves the system's version.
|
|
75
|
-
"""
|
|
76
|
-
roots = []
|
|
77
|
-
npm_target = Path.home() / ".npm/_npx"
|
|
78
|
-
if npm_target.is_dir():
|
|
79
|
-
roots.extend(npm_target.glob("*/node_modules/ccusage/src/cli.js"))
|
|
80
|
-
bun_target = Path.home() / ".bun/install/cache"
|
|
81
|
-
if bun_target.is_dir():
|
|
82
|
-
roots.extend(bun_target.glob("ccusage*/src/cli.js"))
|
|
83
|
-
scored = [(v, p) for p in roots if (v := _pkg_version(p)) is not None]
|
|
84
|
-
return str(max(scored)[1]) if scored else None
|
|
85
|
-
|
|
86
66
|
explicit = _CCUSAGE_PATH_OVERRIDE
|
|
87
67
|
if explicit:
|
|
88
68
|
return [explicit]
|
|
89
69
|
p = shutil.which("ccusage")
|
|
90
70
|
if p:
|
|
91
71
|
return [p]
|
|
92
|
-
|
|
93
|
-
if cli:
|
|
94
|
-
return [_node(), str(cli)]
|
|
95
|
-
return ["npx", "--yes", "ccusage@latest"]
|
|
72
|
+
return ["npx", "--yes", "ccusage"]
|
|
96
73
|
|
|
97
74
|
|
|
98
75
|
_CCUSAGE_PATH_OVERRIDE: str | None = None
|
|
99
76
|
|
|
100
|
-
|
|
101
|
-
def _node() -> str:
|
|
102
|
-
import shutil as _s
|
|
103
|
-
|
|
104
|
-
n = _s.which("node") or "node"
|
|
105
|
-
return n
|
|
106
|
-
|
|
107
77
|
BUDGET = 300.0 # monthly cap in USD (override via --budget or CCUSAGE_BUDGET)
|
|
108
|
-
|
|
78
|
+
|
|
79
|
+
# Refresh cadence is driven by the browser (lib/index.html), which polls the
|
|
80
|
+
# ACTIVE view only. Only the default Today view needs re-scanning every minute;
|
|
81
|
+
# the heavier reports cost ~12-16 CPU-seconds each and are rarely watched, so
|
|
82
|
+
# they re-scan a tenth as often. Inactive views are never polled at all.
|
|
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.
|
|
90
|
+
_REFRESH_INTERVAL = 60 # browser poll interval; must match lib/index.html
|
|
91
|
+
_SLOW_INTERVAL = 600 # cadence for the non-default views
|
|
92
|
+
TTL = {
|
|
93
|
+
"/api/today": _REFRESH_INTERVAL, # default view: re-scanned every minute
|
|
94
|
+
"/api/week": _SLOW_INTERVAL,
|
|
95
|
+
"/api/month": _SLOW_INTERVAL,
|
|
96
|
+
"/api/range": _SLOW_INTERVAL,
|
|
97
|
+
"/api/trend": _SLOW_INTERVAL,
|
|
98
|
+
"/api/sessions": _SLOW_INTERVAL, # the today view overrides this (see sessions())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# A transient failure is remembered only briefly: long enough that concurrent
|
|
102
|
+
# waiters share it instead of each retrying, short enough that the endpoint
|
|
103
|
+
# recovers quickly rather than staying broken for a whole TTL.
|
|
104
|
+
_ERROR_TTL = 30
|
|
109
105
|
|
|
110
106
|
# --- optional self-update check (purely user-triggered) ----------------------
|
|
111
107
|
# The dashboard is otherwise fully offline: it makes a network request ONLY when
|
|
@@ -122,25 +118,63 @@ _last_check = None # (timestamp, result dict) — debounce + last outcome
|
|
|
122
118
|
|
|
123
119
|
|
|
124
120
|
def run_ccusage(args: list[str], ttl: float) -> dict:
|
|
121
|
+
"""Run ccusage and cache the JSON report under its argv for one `ttl` window.
|
|
122
|
+
|
|
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
|
+
|
|
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.
|
|
132
|
+
|
|
133
|
+
Requests for the same key are coalesced (single-flight): the first caller
|
|
134
|
+
runs ccusage and the rest wait for its result, so N concurrent misses spawn
|
|
135
|
+
1 subprocess. A global semaphore additionally caps concurrent ccusage
|
|
136
|
+
processes at _MAX_CONCURRENT_RUNS across all keys, so warm-up and browser
|
|
137
|
+
polls cannot stack heavy scans on top of each other.
|
|
138
|
+
"""
|
|
125
139
|
key = " ".join(args)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
140
|
+
while True:
|
|
141
|
+
now = time.time()
|
|
142
|
+
with _lock:
|
|
143
|
+
hit = _cache.get(key)
|
|
144
|
+
if hit and hit[0] > now:
|
|
145
|
+
return hit[1] # type: ignore[return-value]
|
|
146
|
+
waiter = _inflight.get(key)
|
|
147
|
+
if waiter is None:
|
|
148
|
+
waiter = threading.Event()
|
|
149
|
+
_inflight[key] = waiter
|
|
150
|
+
break
|
|
151
|
+
waiter.wait() # someone else is already running this exact key
|
|
131
152
|
try:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
153
|
+
with _run_gate:
|
|
154
|
+
started = time.time()
|
|
155
|
+
try:
|
|
156
|
+
proc = subprocess.run(
|
|
157
|
+
resolve_ccusage() + args,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
timeout=120,
|
|
161
|
+
)
|
|
162
|
+
data = json.loads(proc.stdout)
|
|
163
|
+
except Exception as exc: # noqa: BLE001
|
|
164
|
+
data = {"error": f"{exc}"}
|
|
165
|
+
is_error = isinstance(data, dict) and bool(data.get("error"))
|
|
166
|
+
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
|
|
170
|
+
with _lock:
|
|
171
|
+
_cache[key] = (expires_at, data)
|
|
172
|
+
return data
|
|
173
|
+
finally:
|
|
174
|
+
with _lock:
|
|
175
|
+
done = _inflight.pop(key, None)
|
|
176
|
+
if done is not None:
|
|
177
|
+
done.set()
|
|
144
178
|
|
|
145
179
|
|
|
146
180
|
def _read_current_version() -> str | None:
|
|
@@ -328,7 +362,12 @@ def sessions(since_days: int = 30, period: str = "", from_date: str = "", to_dat
|
|
|
328
362
|
ccusage_args = ["session", "--json", "--offline"]
|
|
329
363
|
if lo and hi:
|
|
330
364
|
ccusage_args += ["--since", lo.isoformat(), "--until", hi.isoformat()]
|
|
331
|
-
|
|
365
|
+
# The sessions panel follows the active view, so its cache lifetime must
|
|
366
|
+
# match that view's cadence: the today view is polled every minute, the
|
|
367
|
+
# others every _SLOW_INTERVAL. Using the slow TTL for a today window would
|
|
368
|
+
# keep the panel a minute stale behind its own refresh.
|
|
369
|
+
sessions_ttl = TTL["/api/today"] if period == "today" else TTL["/api/sessions"]
|
|
370
|
+
data = run_ccusage(ccusage_args, sessions_ttl)
|
|
332
371
|
rows = data.get("session") or []
|
|
333
372
|
|
|
334
373
|
out = []
|
|
@@ -645,24 +684,30 @@ def main() -> None:
|
|
|
645
684
|
UPDATE_CHECKS = False
|
|
646
685
|
print(f"using ccusage → {' '.join(resolve_ccusage())}", flush=True)
|
|
647
686
|
|
|
648
|
-
def warm_one(fn):
|
|
649
|
-
fn()
|
|
650
|
-
|
|
651
687
|
def warm():
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
688
|
+
# Fire concurrently and let _run_gate bound how many actually run at once;
|
|
689
|
+
# serializing here would make a cold start pay for all the scans
|
|
690
|
+
# back-to-back for no benefit.
|
|
691
|
+
#
|
|
692
|
+
# 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
|
|
696
|
+
# full-history scan at every boot.
|
|
697
|
+
jobs = [
|
|
698
|
+
lambda: run_ccusage(["daily", "--last", "1", "--json", "--offline"], TTL["/api/today"]),
|
|
699
|
+
lambda: run_ccusage(["monthly", "--last", "1", "--json", "--offline"], TTL["/api/month"]),
|
|
700
|
+
lambda: trend(30),
|
|
658
701
|
]
|
|
702
|
+
threads = [threading.Thread(target=job) for job in jobs]
|
|
659
703
|
for t in threads:
|
|
660
704
|
t.start()
|
|
661
705
|
for t in threads:
|
|
662
706
|
t.join()
|
|
663
707
|
|
|
664
708
|
# warm the caches in the background so the server is reachable immediately;
|
|
665
|
-
#
|
|
709
|
+
# a page load arriving mid-warm-up joins the matching in-flight run (see
|
|
710
|
+
# single-flight in run_ccusage) instead of starting a duplicate scan.
|
|
666
711
|
if not args.no_warm:
|
|
667
712
|
print("warming ccusage caches in background…", flush=True)
|
|
668
713
|
threading.Thread(target=warm, daemon=True).start()
|
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.19",
|
|
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",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"token-usage"
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
|
-
"start": "node bin/ccusage-ui.js"
|
|
28
|
+
"start": "node bin/ccusage-ui.js",
|
|
29
|
+
"test": "python3 -m unittest discover -s tests"
|
|
29
30
|
},
|
|
30
31
|
"repository": {
|
|
31
32
|
"type": "git",
|