@caius_kong/ccusage-dashboard 0.2.11 → 0.2.13

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
@@ -50,11 +50,18 @@ for you to open.
50
50
  npx @caius_kong/ccusage-dashboard --port 9000 # change port
51
51
  npx @caius_kong/ccusage-dashboard --budget 500 # monthly budget cap (default $300)
52
52
  npx @caius_kong/ccusage-dashboard --no-warm # skip background warm-up
53
+ npx @caius_kong/ccusage-dashboard --foreground # run attached to this terminal (Ctrl+C stops it)
54
+ npx @caius_kong/ccusage-dashboard --stop # stop the background instance
53
55
  ```
54
56
 
55
57
  Once started, the launcher prints the dashboard URL — open it in your browser
56
58
  (no browser is auto-launched).
57
59
 
60
+ By default the dashboard runs **in the background**: after printing its URL the
61
+ launcher exits and the server keeps running, so you can close the terminal. Use
62
+ `--stop` to shut it down; running it again reuses the already-running instance.
63
+ `--foreground` keeps it attached to the terminal instead (Ctrl+C to stop).
64
+
58
65
  ## What it shows
59
66
 
60
67
  | | |
package/bin/ccusage-ui.js CHANGED
@@ -6,8 +6,14 @@
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
- * Startup UX: once the server is up, a clear URL banner is printed so the
10
- * user can click/copy it to open the dashboard. No background magic.
9
+ * Modes:
10
+ * default — start the server detached in the background, print
11
+ * the URL, and exit immediately. The server keeps running
12
+ * after this terminal closes. If a server is already up on
13
+ * the port, it reuses it instead of starting a second one.
14
+ * --foreground — run the server attached to this terminal (Ctrl+C stops it).
15
+ *
16
+ * Stop a background instance with: npx @caius_kong/ccusage-dashboard --stop
11
17
  */
12
18
  'use strict';
13
19
 
@@ -15,6 +21,7 @@ const { spawn, spawnSync } = require('node:child_process');
15
21
  const path = require('node:path');
16
22
  const fs = require('node:fs');
17
23
  const http = require('node:http');
24
+ const os = require('node:os');
18
25
 
19
26
  // Data files live in package/lib (installed layout) or the repo root (dev layout).
20
27
  function resolveLibFile(name) {
@@ -45,7 +52,7 @@ if (!py) {
45
52
  process.exit(1);
46
53
  }
47
54
 
48
- // Parse the user's --port / --host so the banner points at the real URL.
55
+ // --- arg parsing -----------------------------------------------------------
49
56
  const rawArgs = process.argv.slice(2);
50
57
  function argValue(name, fallback) {
51
58
  const i = rawArgs.indexOf(name);
@@ -54,23 +61,132 @@ function argValue(name, fallback) {
54
61
  const host = argValue('--host', '127.0.0.1');
55
62
  const port = parseInt(argValue('--port', '8799'), 10);
56
63
  const url = `http://${host}:${port}`;
64
+ const wantDaemon = !rawArgs.includes('--foreground'); // background by default
65
+ const wantStop = rawArgs.includes('--stop');
66
+ const wantForeground = rawArgs.includes('--foreground');
57
67
 
58
- const args = rawArgs.slice();
68
+ // Filter launcher-only flags before forwarding to server.py.
69
+ const serverArgs = rawArgs.filter((a) => !['--daemon', '--stop', '--foreground'].includes(a));
59
70
 
60
- const child = spawn(py, [serverPy, ...args], { stdio: 'inherit', env: { ...process.env } });
71
+ // --- daemon pid file (per host:port) ----------------------------------------
72
+ function pidFile() {
73
+ const safeHost = host.replace(/[^a-z0-9]/gi, '_');
74
+ return path.join(os.tmpdir(), `ccusage-dashboard-${safeHost}-${port}.pid`);
75
+ }
76
+
77
+ function readDaemonPid() {
78
+ try {
79
+ const pid = parseInt(fs.readFileSync(pidFile(), 'utf8').trim(), 10);
80
+ return Number.isFinite(pid) ? pid : null;
81
+ } catch { return null; }
82
+ }
83
+
84
+ function daemonAlive(pid) {
85
+ if (!pid) return false;
86
+ try { process.kill(pid, 0); return true; } catch { return false; }
87
+ }
88
+
89
+ function isServerUp() {
90
+ return new Promise((resolve) => {
91
+ const req = http.get(url + '/api/health', { timeout: 1200 }, (res) => {
92
+ resolve(res.statusCode === 200);
93
+ });
94
+ req.on('error', () => resolve(false));
95
+ });
96
+ }
61
97
 
62
- // Once the server answers /, print a clear URL banner for manual opening.
63
- let bannerPrinted = false;
64
98
  function printBanner() {
65
- if (bannerPrinted) return;
66
- bannerPrinted = true;
67
99
  const line = '─'.repeat(Math.max(20, url.length + 6));
68
100
  console.log('');
69
101
  console.log(line);
70
102
  console.log(` Dashboard is running → ${url}`);
71
103
  console.log(` Open it manually: ${url}`);
72
104
  console.log(line);
73
- console.log('');
105
+ if (wantDaemon) {
106
+ console.log(` (background — stop with: npx @caius_kong/ccusage-dashboard --stop)`);
107
+ console.log('');
108
+ }
109
+ }
110
+
111
+ async function waitForUp(timeoutMs) {
112
+ const start = Date.now();
113
+ while (Date.now() - start < timeoutMs) {
114
+ if (await isServerUp()) return true;
115
+ await new Promise((r) => setTimeout(r, 300));
116
+ }
117
+ return false;
118
+ }
119
+
120
+ // --- stop -------------------------------------------------------------------
121
+ async function handleStop() {
122
+ const pid = readDaemonPid();
123
+ if (pid && daemonAlive(pid)) {
124
+ try { process.kill(pid, 'SIGTERM'); } catch {}
125
+ console.log(`Stopping dashboard (pid ${pid}) …`);
126
+ const t0 = Date.now();
127
+ while (Date.now() - t0 < 5000 && await isServerUp()) {
128
+ await new Promise((r) => setTimeout(r, 300));
129
+ }
130
+ try { fs.unlinkSync(pidFile()); } catch {}
131
+ console.log('Dashboard stopped.');
132
+ process.exit(0);
133
+ }
134
+ console.log('No running dashboard instance found.');
135
+ process.exit(0);
136
+ }
137
+
138
+ if (wantStop) {
139
+ handleStop();
140
+ return;
141
+ }
142
+
143
+ // --- daemon mode ------------------------------------------------------------
144
+ async function handleDaemon() {
145
+ // Reuse an already-running instance on this port.
146
+ if (await isServerUp()) {
147
+ console.log(`A dashboard is already running at ${url} — reusing it.`);
148
+ printBanner();
149
+ process.exit(0);
150
+ }
151
+ // If the pid file points at a dead pid, clear it.
152
+ const oldPid = readDaemonPid();
153
+ if (oldPid && !daemonAlive(oldPid)) {
154
+ try { fs.unlinkSync(pidFile()); } catch {}
155
+ }
156
+ // Start detached; the server outlives this launcher.
157
+ const out = fs.openSync(path.join(os.tmpdir(), `ccusage-dashboard-${port}.log`), 'a');
158
+ const child = spawn(py, [serverPy, ...serverArgs], {
159
+ detached: true,
160
+ stdio: ['ignore', out, out],
161
+ env: { ...process.env },
162
+ });
163
+ child.unref();
164
+ fs.writeFileSync(pidFile(), String(child.pid));
165
+ const up = await waitForUp(15000);
166
+ if (!up) {
167
+ console.error(`[ccusage-dashboard] server did not come up within 15s — check ${path.join(os.tmpdir(), `ccusage-dashboard-${port}.log`)}`);
168
+ try { process.kill(child.pid, 'SIGTERM'); } catch {}
169
+ process.exit(1);
170
+ }
171
+ console.log(`ccusage-dashboard started in background (pid ${child.pid}).`);
172
+ printBanner();
173
+ process.exit(0);
174
+ }
175
+
176
+ if (wantDaemon) {
177
+ handleDaemon();
178
+ return;
179
+ }
180
+
181
+ // --- foreground mode (default) ----------------------------------------------
182
+ const child = spawn(py, [serverPy, ...serverArgs], { stdio: 'inherit', env: { ...process.env } });
183
+
184
+ // Once the server answers /, print a clear URL banner for manual opening.
185
+ let bannerPrinted = false;
186
+ function printBannerFg() {
187
+ if (bannerPrinted) return;
188
+ bannerPrinted = true;
189
+ printBanner();
74
190
  }
75
191
 
76
192
  let checked = false;
@@ -78,7 +194,7 @@ function pingAndBanner() {
78
194
  if (checked) return;
79
195
  checked = true;
80
196
  const req = http.get(url + '/api/health', { timeout: 1500 }, (res) => {
81
- if (res.statusCode === 200) printBanner();
197
+ if (res.statusCode === 200) printBannerFg();
82
198
  else setTimeout(pingAndBanner, 500);
83
199
  });
84
200
  req.on('error', () => setTimeout(pingAndBanner, 500));
@@ -89,4 +205,4 @@ setTimeout(pingAndBanner, 800); // give the python server a beat to bind
89
205
  for (const sig of ['SIGINT', 'SIGTERM']) {
90
206
  process.on(sig, () => { if (!child.killed) child.kill(sig); });
91
207
  }
92
- child.on('exit', (code) => process.exit(code == null ? 0 : code));
208
+ child.on('exit', (code) => process.exit(code == null ? 0 : code));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
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",