@caius_kong/ccusage-dashboard 0.2.10 → 0.2.12
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 -4
- package/bin/ccusage-ui.js +128 -16
- package/lib/server.py +1 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,8 @@ npx @caius_kong/ccusage-dashboard
|
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
That's it. npx downloads the package (including its own `ccusage` dependency),
|
|
41
|
-
starts a local server on `http://127.0.0.1:8799`, and
|
|
41
|
+
starts a local server on `http://127.0.0.1:8799`, and prints the dashboard URL
|
|
42
|
+
for you to open.
|
|
42
43
|
|
|
43
44
|
> Requirements: **Node.js** (for the launcher) and **Python 3.8+** (for the server).
|
|
44
45
|
> On macOS: `brew install python3`. No other installs, no build step, no config.
|
|
@@ -48,10 +49,18 @@ starts a local server on `http://127.0.0.1:8799`, and opens your browser.
|
|
|
48
49
|
```bash
|
|
49
50
|
npx @caius_kong/ccusage-dashboard --port 9000 # change port
|
|
50
51
|
npx @caius_kong/ccusage-dashboard --budget 500 # monthly budget cap (default $300)
|
|
51
|
-
npx @caius_kong/ccusage-dashboard --no-warm # skip
|
|
52
|
-
|
|
52
|
+
npx @caius_kong/ccusage-dashboard --no-warm # skip background warm-up
|
|
53
|
+
npx @caius_kong/ccusage-dashboard --daemon # run in the background (terminal exits, server keeps running)
|
|
54
|
+
npx @caius_kong/ccusage-dashboard --stop # stop a background instance
|
|
53
55
|
```
|
|
54
56
|
|
|
57
|
+
Once started, the launcher prints the dashboard URL — open it in your browser
|
|
58
|
+
(no browser is auto-launched).
|
|
59
|
+
|
|
60
|
+
**`--daemon`** starts the server detached so you can close the terminal and it keeps
|
|
61
|
+
running; run `--stop` to shut it down. Running `--daemon` again reuses the running
|
|
62
|
+
instance instead of starting a second one.
|
|
63
|
+
|
|
55
64
|
## What it shows
|
|
56
65
|
|
|
57
66
|
| | |
|
|
@@ -80,7 +89,7 @@ ccusage (bundled dependency — the real cost engine)
|
|
|
80
89
|
- `lib/server.py` — Python stdlib HTTP server. Resolves a local ccusage
|
|
81
90
|
(bundled dep → PATH → npx cache), warms caches on boot (~3s), then serves instant responses.
|
|
82
91
|
- `lib/index.html` — single-file dashboard. No build step, no CDN.
|
|
83
|
-
- `bin/ccusage-ui.js` — Node launcher (finds python3, starts server,
|
|
92
|
+
- `bin/ccusage-ui.js` — Node launcher (finds python3, starts server, prints URL).
|
|
84
93
|
|
|
85
94
|
## Local development
|
|
86
95
|
|
package/bin/ccusage-ui.js
CHANGED
|
@@ -6,10 +6,16 @@
|
|
|
6
6
|
* the local python3, forwarding any CLI args. The server itself shells out to
|
|
7
7
|
* ccusage for all numbers, so the dashboard always matches ccusage.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Modes:
|
|
10
|
+
* foreground (default) — the server runs attached to this terminal; Ctrl+C
|
|
11
|
+
* stops it. A URL banner is printed when it is up.
|
|
12
|
+
* --daemon — start the server detached in the background, print
|
|
13
|
+
* the URL, and exit immediately. The server keeps
|
|
14
|
+
* running after this terminal closes. If a server is
|
|
15
|
+
* already up on the port, it reuses it instead of
|
|
16
|
+
* starting a second one.
|
|
17
|
+
*
|
|
18
|
+
* Stop a daemon with: npx @caius_kong/ccusage-dashboard --stop
|
|
13
19
|
*/
|
|
14
20
|
'use strict';
|
|
15
21
|
|
|
@@ -17,6 +23,7 @@ const { spawn, spawnSync } = require('node:child_process');
|
|
|
17
23
|
const path = require('node:path');
|
|
18
24
|
const fs = require('node:fs');
|
|
19
25
|
const http = require('node:http');
|
|
26
|
+
const os = require('node:os');
|
|
20
27
|
|
|
21
28
|
// Data files live in package/lib (installed layout) or the repo root (dev layout).
|
|
22
29
|
function resolveLibFile(name) {
|
|
@@ -47,7 +54,7 @@ if (!py) {
|
|
|
47
54
|
process.exit(1);
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
//
|
|
57
|
+
// --- arg parsing -----------------------------------------------------------
|
|
51
58
|
const rawArgs = process.argv.slice(2);
|
|
52
59
|
function argValue(name, fallback) {
|
|
53
60
|
const i = rawArgs.indexOf(name);
|
|
@@ -56,26 +63,131 @@ function argValue(name, fallback) {
|
|
|
56
63
|
const host = argValue('--host', '127.0.0.1');
|
|
57
64
|
const port = parseInt(argValue('--port', '8799'), 10);
|
|
58
65
|
const url = `http://${host}:${port}`;
|
|
66
|
+
const wantDaemon = rawArgs.includes('--daemon');
|
|
67
|
+
const wantStop = rawArgs.includes('--stop');
|
|
68
|
+
|
|
69
|
+
// Filter launcher-only flags before forwarding to server.py.
|
|
70
|
+
const serverArgs = rawArgs.filter((a) => !['--daemon', '--stop'].includes(a));
|
|
59
71
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
72
|
+
// --- daemon pid file (per host:port) ----------------------------------------
|
|
73
|
+
function pidFile() {
|
|
74
|
+
const safeHost = host.replace(/[^a-z0-9]/gi, '_');
|
|
75
|
+
return path.join(os.tmpdir(), `ccusage-dashboard-${safeHost}-${port}.pid`);
|
|
63
76
|
}
|
|
64
77
|
|
|
65
|
-
|
|
78
|
+
function readDaemonPid() {
|
|
79
|
+
try {
|
|
80
|
+
const pid = parseInt(fs.readFileSync(pidFile(), 'utf8').trim(), 10);
|
|
81
|
+
return Number.isFinite(pid) ? pid : null;
|
|
82
|
+
} catch { return null; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function daemonAlive(pid) {
|
|
86
|
+
if (!pid) return false;
|
|
87
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isServerUp() {
|
|
91
|
+
return new Promise((resolve) => {
|
|
92
|
+
const req = http.get(url + '/api/health', { timeout: 1200 }, (res) => {
|
|
93
|
+
resolve(res.statusCode === 200);
|
|
94
|
+
});
|
|
95
|
+
req.on('error', () => resolve(false));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
66
98
|
|
|
67
|
-
// Once the server answers /, print a clear URL banner for manual opening.
|
|
68
|
-
let bannerPrinted = false;
|
|
69
99
|
function printBanner() {
|
|
70
|
-
if (bannerPrinted) return;
|
|
71
|
-
bannerPrinted = true;
|
|
72
100
|
const line = '─'.repeat(Math.max(20, url.length + 6));
|
|
73
101
|
console.log('');
|
|
74
102
|
console.log(line);
|
|
75
103
|
console.log(` Dashboard is running → ${url}`);
|
|
76
104
|
console.log(` Open it manually: ${url}`);
|
|
77
105
|
console.log(line);
|
|
78
|
-
|
|
106
|
+
if (wantDaemon) {
|
|
107
|
+
console.log(` (background mode — stop with: npx @caius_kong/ccusage-dashboard --stop)`);
|
|
108
|
+
console.log('');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function waitForUp(timeoutMs) {
|
|
113
|
+
const start = Date.now();
|
|
114
|
+
while (Date.now() - start < timeoutMs) {
|
|
115
|
+
if (await isServerUp()) return true;
|
|
116
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- stop -------------------------------------------------------------------
|
|
122
|
+
async function handleStop() {
|
|
123
|
+
const pid = readDaemonPid();
|
|
124
|
+
if (pid && daemonAlive(pid)) {
|
|
125
|
+
try { process.kill(pid, 'SIGTERM'); } catch {}
|
|
126
|
+
console.log(`Stopping dashboard (pid ${pid}) …`);
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
while (Date.now() - t0 < 5000 && await isServerUp()) {
|
|
129
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
130
|
+
}
|
|
131
|
+
try { fs.unlinkSync(pidFile()); } catch {}
|
|
132
|
+
console.log('Dashboard stopped.');
|
|
133
|
+
process.exit(0);
|
|
134
|
+
}
|
|
135
|
+
console.log('No running dashboard instance found.');
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (wantStop) {
|
|
140
|
+
handleStop();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- daemon mode ------------------------------------------------------------
|
|
145
|
+
async function handleDaemon() {
|
|
146
|
+
// Reuse an already-running instance on this port.
|
|
147
|
+
if (await isServerUp()) {
|
|
148
|
+
console.log(`A dashboard is already running at ${url} — reusing it.`);
|
|
149
|
+
printBanner();
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
// If the pid file points at a dead pid, clear it.
|
|
153
|
+
const oldPid = readDaemonPid();
|
|
154
|
+
if (oldPid && !daemonAlive(oldPid)) {
|
|
155
|
+
try { fs.unlinkSync(pidFile()); } catch {}
|
|
156
|
+
}
|
|
157
|
+
// Start detached; the server outlives this launcher.
|
|
158
|
+
const out = fs.openSync(path.join(os.tmpdir(), `ccusage-dashboard-${port}.log`), 'a');
|
|
159
|
+
const child = spawn(py, [serverPy, ...serverArgs], {
|
|
160
|
+
detached: true,
|
|
161
|
+
stdio: ['ignore', out, out],
|
|
162
|
+
env: { ...process.env },
|
|
163
|
+
});
|
|
164
|
+
child.unref();
|
|
165
|
+
fs.writeFileSync(pidFile(), String(child.pid));
|
|
166
|
+
const up = await waitForUp(15000);
|
|
167
|
+
if (!up) {
|
|
168
|
+
console.error(`[ccusage-dashboard] server did not come up within 15s — check ${path.join(os.tmpdir(), `ccusage-dashboard-${port}.log`)}`);
|
|
169
|
+
try { process.kill(child.pid, 'SIGTERM'); } catch {}
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
console.log(`ccusage-dashboard started in background (pid ${child.pid}).`);
|
|
173
|
+
printBanner();
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (wantDaemon) {
|
|
178
|
+
handleDaemon();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- foreground mode (default) ----------------------------------------------
|
|
183
|
+
const child = spawn(py, [serverPy, ...serverArgs], { stdio: 'inherit', env: { ...process.env } });
|
|
184
|
+
|
|
185
|
+
// Once the server answers /, print a clear URL banner for manual opening.
|
|
186
|
+
let bannerPrinted = false;
|
|
187
|
+
function printBannerFg() {
|
|
188
|
+
if (bannerPrinted) return;
|
|
189
|
+
bannerPrinted = true;
|
|
190
|
+
printBanner();
|
|
79
191
|
}
|
|
80
192
|
|
|
81
193
|
let checked = false;
|
|
@@ -83,7 +195,7 @@ function pingAndBanner() {
|
|
|
83
195
|
if (checked) return;
|
|
84
196
|
checked = true;
|
|
85
197
|
const req = http.get(url + '/api/health', { timeout: 1500 }, (res) => {
|
|
86
|
-
if (res.statusCode === 200)
|
|
198
|
+
if (res.statusCode === 200) printBannerFg();
|
|
87
199
|
else setTimeout(pingAndBanner, 500);
|
|
88
200
|
});
|
|
89
201
|
req.on('error', () => setTimeout(pingAndBanner, 500));
|
|
@@ -94,4 +206,4 @@ setTimeout(pingAndBanner, 800); // give the python server a beat to bind
|
|
|
94
206
|
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
95
207
|
process.on(sig, () => { if (!child.killed) child.kill(sig); });
|
|
96
208
|
}
|
|
97
|
-
child.on('exit', (code) => process.exit(code == null ? 0 : code));
|
|
209
|
+
child.on('exit', (code) => process.exit(code == null ? 0 : code));
|
package/lib/server.py
CHANGED
|
@@ -12,7 +12,7 @@ Because all numbers come from ccusage itself, the figures always match what
|
|
|
12
12
|
to maintain, no third-party packages — only the Python standard library.
|
|
13
13
|
|
|
14
14
|
Usage:
|
|
15
|
-
python3 server.py [--port 8799] [--budget 300]
|
|
15
|
+
python3 server.py [--port 8799] [--budget 300]
|
|
16
16
|
"""
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
@@ -543,7 +543,6 @@ def main() -> None:
|
|
|
543
543
|
parser.add_argument("--budget", type=float, default=None, help="monthly budget cap in USD (default 300)")
|
|
544
544
|
parser.add_argument("--ccusage-path", default=None, help="explicit path to a ccusage binary or src/cli.js")
|
|
545
545
|
parser.add_argument("--no-warm", action="store_true", help="skip background warm-up (first requests may be slow)")
|
|
546
|
-
parser.add_argument("--open", action="store_true", help="open browser after start")
|
|
547
546
|
args = parser.parse_args()
|
|
548
547
|
|
|
549
548
|
BUDGET = args.budget if args.budget is not None else float(os_env_budget() or 300.0)
|
|
@@ -575,10 +574,6 @@ def main() -> None:
|
|
|
575
574
|
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
576
575
|
url = f"http://{args.host}:{args.port}"
|
|
577
576
|
print(f"ccusage-ui → {url} (monthly budget ${BUDGET:g}, Ctrl+C to stop)", flush=True)
|
|
578
|
-
if args.open:
|
|
579
|
-
_open_browser(url)
|
|
580
|
-
else:
|
|
581
|
-
print(f"dashboard ready at {url} — open it in your browser", flush=True)
|
|
582
577
|
|
|
583
578
|
try:
|
|
584
579
|
httpd.serve_forever()
|
|
@@ -586,46 +581,6 @@ def main() -> None:
|
|
|
586
581
|
print("\nbye", flush=True)
|
|
587
582
|
|
|
588
583
|
|
|
589
|
-
def _open_browser(url: str) -> None:
|
|
590
|
-
"""Open the dashboard in a browser. Tries, in order:
|
|
591
|
-
1. $BROWSER (e.g. 'Google Chrome', 'Safari', 'firefox')
|
|
592
|
-
2. the OS default opener (open / xdg-open / start)
|
|
593
|
-
3. prints the URL as a fallback so the user can open it manually.
|
|
594
|
-
Every attempt logs its outcome so auto-open is verifiable.
|
|
595
|
-
"""
|
|
596
|
-
import os
|
|
597
|
-
import subprocess
|
|
598
|
-
import sys
|
|
599
|
-
|
|
600
|
-
browser = os.environ.get("BROWSER", "").strip()
|
|
601
|
-
attempts = []
|
|
602
|
-
if sys.platform == "darwin":
|
|
603
|
-
if browser:
|
|
604
|
-
attempts.append(["open", "-a", browser, url])
|
|
605
|
-
attempts.append(["open", url])
|
|
606
|
-
elif sys.platform.startswith("win"):
|
|
607
|
-
if browser:
|
|
608
|
-
attempts.append(["cmd", "/c", "start", "", "chrome", url])
|
|
609
|
-
attempts.append(["cmd", "/c", "start", "", url])
|
|
610
|
-
else:
|
|
611
|
-
if browser:
|
|
612
|
-
attempts.append([browser, url])
|
|
613
|
-
attempts.append(["xdg-open", url])
|
|
614
|
-
|
|
615
|
-
for cmd in attempts:
|
|
616
|
-
try:
|
|
617
|
-
r = subprocess.run(cmd, timeout=5, capture_output=True, text=True)
|
|
618
|
-
if r.returncode == 0:
|
|
619
|
-
print(f"browser opened → {url} ({' '.join(cmd[:2])})", flush=True)
|
|
620
|
-
return
|
|
621
|
-
print(f"browser attempt failed (rc={r.returncode}): {' '.join(cmd[:2])}", flush=True)
|
|
622
|
-
except FileNotFoundError:
|
|
623
|
-
print(f"opener not found: {' '.join(cmd[:2])}", flush=True)
|
|
624
|
-
except Exception as e: # noqa: BLE001
|
|
625
|
-
print(f"browser open error: {e}", flush=True)
|
|
626
|
-
print(f"could not auto-open a browser — open it manually: {url}", flush=True)
|
|
627
|
-
|
|
628
|
-
|
|
629
584
|
def os_env_budget() -> str:
|
|
630
585
|
import os
|
|
631
586
|
|
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.12",
|
|
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",
|