@caius_kong/ccusage-dashboard 0.2.8 → 0.2.10

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/bin/ccusage-ui.js CHANGED
@@ -5,12 +5,18 @@
5
5
  * Locates the bundled server.py (and its index.html sibling) and runs it with
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
+ *
9
+ * Startup UX:
10
+ * - the server tries to auto-open the default browser (best effort)
11
+ * - regardless of that, this launcher prints a clear URL banner once the
12
+ * server is up, so the user can always click/copy it manually.
8
13
  */
9
14
  'use strict';
10
15
 
11
16
  const { spawn, spawnSync } = require('node:child_process');
12
17
  const path = require('node:path');
13
18
  const fs = require('node:fs');
19
+ const http = require('node:http');
14
20
 
15
21
  // Data files live in package/lib (installed layout) or the repo root (dev layout).
16
22
  function resolveLibFile(name) {
@@ -41,13 +47,50 @@ if (!py) {
41
47
  process.exit(1);
42
48
  }
43
49
 
44
- const args = process.argv.slice(2);
50
+ // Parse the user's --port / --host so the banner points at the real URL.
51
+ const rawArgs = process.argv.slice(2);
52
+ function argValue(name, fallback) {
53
+ const i = rawArgs.indexOf(name);
54
+ return i >= 0 && rawArgs[i + 1] ? rawArgs[i + 1] : fallback;
55
+ }
56
+ const host = argValue('--host', '127.0.0.1');
57
+ const port = parseInt(argValue('--port', '8799'), 10);
58
+ const url = `http://${host}:${port}`;
59
+
60
+ const args = rawArgs.slice();
45
61
  if (!args.includes('--open') && !process.env.CCUSAGE_UI_NO_OPEN) {
46
- args.push('--open'); // default: open browser after start
62
+ args.push('--open'); // default: try to auto-open the browser (best effort)
47
63
  }
48
64
 
49
65
  const child = spawn(py, [serverPy, ...args], { stdio: 'inherit', env: { ...process.env } });
50
66
 
67
+ // Once the server answers /, print a clear URL banner for manual opening.
68
+ let bannerPrinted = false;
69
+ function printBanner() {
70
+ if (bannerPrinted) return;
71
+ bannerPrinted = true;
72
+ const line = '─'.repeat(Math.max(20, url.length + 6));
73
+ console.log('');
74
+ console.log(line);
75
+ console.log(` Dashboard is running → ${url}`);
76
+ console.log(` Open it manually: ${url}`);
77
+ console.log(line);
78
+ console.log('');
79
+ }
80
+
81
+ let checked = false;
82
+ function pingAndBanner() {
83
+ if (checked) return;
84
+ checked = true;
85
+ const req = http.get(url + '/api/health', { timeout: 1500 }, (res) => {
86
+ if (res.statusCode === 200) printBanner();
87
+ else setTimeout(pingAndBanner, 500);
88
+ });
89
+ req.on('error', () => setTimeout(pingAndBanner, 500));
90
+ }
91
+
92
+ setTimeout(pingAndBanner, 800); // give the python server a beat to bind
93
+
51
94
  for (const sig of ['SIGINT', 'SIGTERM']) {
52
95
  process.on(sig, () => { if (!child.killed) child.kill(sig); });
53
96
  }
package/lib/server.py CHANGED
@@ -542,13 +542,13 @@ def main() -> None:
542
542
  parser.add_argument("--host", default="127.0.0.1")
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
- parser.add_argument("--no-warm", action="store_true", help="skip blocking warm-up (first requests may be slow)")
545
+ parser.add_argument("--no-warm", action="store_true", help="skip background warm-up (first requests may be slow)")
546
546
  parser.add_argument("--open", action="store_true", help="open browser after start")
547
547
  args = parser.parse_args()
548
548
 
549
549
  BUDGET = args.budget if args.budget is not None else float(os_env_budget() or 300.0)
550
550
  _CCUSAGE_PATH_OVERRIDE = args.ccusage_path
551
- print(f"using ccusage → {' '.join(resolve_ccusage())}")
551
+ print(f"using ccusage → {' '.join(resolve_ccusage())}", flush=True)
552
552
 
553
553
  def warm_one(fn):
554
554
  fn()
@@ -566,22 +566,64 @@ def main() -> None:
566
566
  for t in threads:
567
567
  t.join()
568
568
 
569
+ # warm the caches in the background so the server is reachable immediately;
570
+ # the first page load will wait for warm-up to finish via the TTL cache lock.
569
571
  if not args.no_warm:
570
- print("warming ccusage caches (first load will be instant after this)…", flush=True)
571
- warm()
572
- print("warm-up complete.")
572
+ print("warming ccusage caches in background…", flush=True)
573
+ threading.Thread(target=warm, daemon=True).start()
573
574
 
574
575
  httpd = ThreadingHTTPServer((args.host, args.port), Handler)
575
- print(f"ccusage-ui → http://{args.host}:{args.port} (monthly budget ${BUDGET:g}, Ctrl+C to stop)")
576
+ url = f"http://{args.host}:{args.port}"
577
+ print(f"ccusage-ui → {url} (monthly budget ${BUDGET:g}, Ctrl+C to stop)", flush=True)
576
578
  if args.open:
577
- import webbrowser
578
-
579
- webbrowser.open(f"http://{args.host}:{args.port}")
579
+ _open_browser(url)
580
+ else:
581
+ print(f"dashboard ready at {url} — open it in your browser", flush=True)
580
582
 
581
583
  try:
582
584
  httpd.serve_forever()
583
585
  except KeyboardInterrupt:
584
- print("\nbye")
586
+ print("\nbye", flush=True)
587
+
588
+
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)
585
627
 
586
628
 
587
629
  def os_env_budget() -> str:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caius_kong/ccusage-dashboard",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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",