From 41157436df3c4105e3932107343f953e73b39872 Mon Sep 17 00:00:00 2001 From: Diego Date: Thu, 23 Jul 2026 10:14:35 -0300 Subject: [PATCH 1/2] fix(dashboard): rescan periodically so a long-lived dashboard stays live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/data only reads the DB and the browser polls it every 30s, but nothing rescanned transcripts after the startup scan — so a dashboard left open served a frozen snapshot while the footer advertised "Auto-refresh in 30s". cmd_dashboard's background thread now keeps rescanning on the same cadence. Rescans are incremental (processed_files skips unchanged mtimes), so a steady-state pass over a ~700-file backlog touches only the live sessions (~0.1s), and runs with verbose=False to keep the terminal quiet. Adds --auto-refresh SECONDS (and the AUTO_REFRESH env var) to tune the interval, plus --no-auto-refresh / --auto-refresh 0 to restore the scan-once-at-startup behaviour. --- AGENTS.md | 5 ++- README.md | 8 ++++- cli.py | 61 ++++++++++++++++++++++++++++++++-- tests/test_cli.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 153 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f44a3f46..2c346b28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,9 +23,12 @@ python cli.py week # last 7 days, per-day + by-model python cli.py stats # all-time stats python cli.py dashboard # scan + open http://localhost:8080 python cli.py dashboard --host 0.0.0.0 --port 9000 +python cli.py dashboard --auto-refresh 120 # rescan every 120s (default 30) +python cli.py dashboard --no-auto-refresh # scan once at startup only python cli.py scan --projects-dir PATH # scan a custom transcripts dir # or via env vars: HOST=0.0.0.0 PORT=9000 python cli.py dashboard +AUTO_REFRESH=120 python cli.py dashboard python -m unittest discover -s tests -v # full test suite (CI runs this) python -m unittest tests.test_scanner -v # one file @@ -81,7 +84,7 @@ Pricing is duplicated in two places that **must stay in sync**: ### Dashboard server `http.server.BaseHTTPRequestHandler`-based, two endpoints: -- `GET /api/data` → JSON snapshot from `get_dashboard_data()`. Returns *all* history; client-side filters by date range and model. +- `GET /api/data` → JSON snapshot from `get_dashboard_data()`. Returns *all* history; client-side filters by date range and model. **It only reads the DB — it never scans.** Freshness comes from the rescan loop in `cli.cmd_dashboard`'s background thread (`--auto-refresh`, default 30s), which is what makes the browser's 30s `/api/data` poll actually show new data. Before that loop existed, a dashboard left open served the startup scan's snapshot forever while the footer cheerfully advertised "Auto-refresh in 30s". If you refactor the scan scheduling, keep *something* writing to the DB while the server runs. - `POST /api/rescan` → deletes the DB and runs a full rescan. Passes `db_path` and `projects_dirs` explicitly so tests that monkey-patch the module globals work — scan's default arg values are frozen at def time, so don't switch to bare defaults. The entire UI lives in `HTML_TEMPLATE` as a raw string. Chart.js is loaded from CDN. diff --git a/README.md b/README.md index b8276aee..3e38f7b8 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,12 @@ python cli.py dashboard --host 0.0.0.0 --port 9000 # Environment variables are also supported HOST=0.0.0.0 PORT=9000 python cli.py dashboard +# Rescan every 2 minutes instead of the default 30 seconds +python cli.py dashboard --auto-refresh 120 + +# Only scan once at startup (the pre-1.5.6 behaviour) +python cli.py dashboard --no-auto-refresh + # Scan a custom projects directory python cli.py scan --projects-dir /path/to/transcripts ``` @@ -136,7 +142,7 @@ Claude Code writes one JSONL file per session to `~/.claude/projects/`. Each lin `scanner.py` parses those files and stores the data in a SQLite database at `~/.claude/usage.db`. -`dashboard.py` serves a single-page dashboard on `localhost:8080` with Chart.js charts (loaded from CDN). It auto-refreshes every 30 seconds and supports model filtering and a date-range dropdown with bookmarkable URLs. A sticky section nav jumps between sections, and every chart/table can be collapsed (remembered across reloads). The bind address and port can be configured with the `--host` and `--port` flags, or the `HOST` and `PORT` environment variables (defaults: `localhost`, `8080`). +`dashboard.py` serves a single-page dashboard on `localhost:8080` with Chart.js charts (loaded from CDN). It auto-refreshes every 30 seconds — the browser re-polls `/api/data` while the server rescans your transcripts on the same cadence, so a dashboard left open keeps tracking live sessions. Use `--auto-refresh SECONDS` to change the rescan interval or `--no-auto-refresh` to scan only at startup. It also supports model filtering and a date-range dropdown with bookmarkable URLs. A sticky section nav jumps between sections, and every chart/table can be collapsed (remembered across reloads). The bind address and port can be configured with the `--host` and `--port` flags, or the `HOST` and `PORT` environment variables (defaults: `localhost`, `8080`). --- diff --git a/cli.py b/cli.py index 7a45c769..881a0cd4 100644 --- a/cli.py +++ b/cli.py @@ -18,6 +18,10 @@ DB_PATH = Path(os.environ.get("CLAUDE_USAGE_DB", Path.home() / ".claude" / "usage.db")) +# Seconds between the dashboard's background rescans. Matches the browser's own +# 30s /api/data poll, so a refresh lands on data at most one tick old. +DEFAULT_AUTO_REFRESH = 30 + PRICING = { # Fable / Mythos — Anthropic's most capable class, priced at 2x Opus. # (Mythos 5 shares Fable 5's pricing; Project-Glasswing access only.) @@ -98,9 +102,9 @@ def require_db(): # ── Commands ────────────────────────────────────────────────────────────────── -def cmd_scan(projects_dir=None): +def cmd_scan(projects_dir=None, verbose=True): from scanner import scan - scan(projects_dir=Path(projects_dir) if projects_dir else None) + scan(projects_dir=Path(projects_dir) if projects_dir else None, verbose=verbose) def cmd_today(): @@ -394,7 +398,8 @@ def cmd_stats(): conn.close() -def cmd_dashboard(projects_dir=None, host=None, port=None, no_browser=False, surface=None): +def cmd_dashboard(projects_dir=None, host=None, port=None, no_browser=False, surface=None, + auto_refresh=None): import threading import time @@ -402,6 +407,8 @@ def cmd_dashboard(projects_dir=None, host=None, port=None, no_browser=False, sur host = host or os.environ.get("HOST", "localhost") port = int(port or os.environ.get("PORT", "8080")) + if auto_refresh is None: + auto_refresh = int(os.environ.get("AUTO_REFRESH", DEFAULT_AUTO_REFRESH)) # Bind and serve the port *first*, then scan in the background. A cold scan # over a large ~/.claude/projects backlog can take well over a minute, and @@ -420,6 +427,27 @@ def background_scan(): scan(projects_dir=projects_dir) print("Background scan complete.") + # Then keep rescanning, so a dashboard left open overnight doesn't serve a + # frozen snapshot. /api/data only *reads* the DB, and the browser polls it + # every 30s advertising "Auto-refresh in 30s" in the footer — without this + # loop nothing ever writes new turns, so that promise silently went stale + # the moment the startup scan finished. + # + # Rescans are incremental and cheap: processed_files skips any transcript + # whose mtime is unchanged, so a steady-state pass over a ~700-file backlog + # touches only the handful of live sessions (~0.1s). verbose=False keeps the + # terminal quiet — the startup scan already reported the backlog. + if not auto_refresh: + return + while True: + time.sleep(auto_refresh) + try: + scan(projects_dir=projects_dir, verbose=False) + except Exception as exc: + # Never let a transient failure (locked DB, transcript mid-write) + # kill the loop — the next tick usually succeeds. + print(f"Auto-refresh scan failed: {exc}") + threading.Thread(target=background_scan, daemon=True).start() # Open a browser for users running this as a script (see README). The VS Code @@ -447,7 +475,9 @@ def open_browser(): python cli.py week Show last 7 days (per-day + by-model) python cli.py stats Show all-time statistics python cli.py dashboard [--projects-dir PATH] [--host HOST] [--port PORT] [--no-browser] [--surface SURFACE] + [--auto-refresh SECONDS | --no-auto-refresh] Scan + start dashboard (opens a browser unless --no-browser) + Rescans every 30s by default; --no-auto-refresh scans only at startup python cli.py --version Print the version and exit """ @@ -466,6 +496,30 @@ def parse_named_arg(args, flag): return args[i + 1] return None + +def parse_auto_refresh(args): + """Resolve --auto-refresh [SECONDS] / --no-auto-refresh into a seconds value. + + Returns None when neither flag is given, so the caller can fall back to the + AUTO_REFRESH env var and then DEFAULT_AUTO_REFRESH. 0 disables the rescan loop. + """ + if "--no-auto-refresh" in args: + return 0 + if "--auto-refresh" not in args: + return None + + value = parse_named_arg(args, "--auto-refresh") + # Bare flag, or immediately followed by another flag ("--auto-refresh --port 7070"): + # parse_named_arg would hand back "--port", so treat both as "use the default". + if value is None or value.startswith("--"): + return DEFAULT_AUTO_REFRESH + try: + return max(0, int(value)) + except ValueError: + print(f"Invalid --auto-refresh value: {value!r} (expected seconds, or 0 to disable)") + sys.exit(2) + + def main(): """Console entry point (``claude-usage``) and ``python cli.py`` dispatch.""" if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"): @@ -487,6 +541,7 @@ def main(): port=parse_named_arg(rest, "--port"), no_browser="--no-browser" in rest, surface=parse_named_arg(rest, "--surface"), + auto_refresh=parse_auto_refresh(rest), ) elif command == "scan" and projects_dir: cmd_scan(projects_dir=projects_dir) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f9cec9d..266cd764 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,11 +1,12 @@ """Tests for cli.py - pricing, formatting, and cost calculation.""" import io +import threading import unittest from contextlib import redirect_stdout from unittest import mock import cli -from cli import get_pricing, calc_cost, fmt, fmt_cost, PRICING +from cli import get_pricing, calc_cost, fmt, fmt_cost, PRICING, parse_auto_refresh class TestGetPricing(unittest.TestCase): @@ -215,5 +216,87 @@ def test_no_browser_suppresses_webbrowser(self): mock_serve.assert_called_once() +class TestParseAutoRefresh(unittest.TestCase): + """--auto-refresh SECONDS / --no-auto-refresh resolution.""" + + def test_absent_returns_none_so_caller_can_use_env_then_default(self): + self.assertIsNone(parse_auto_refresh(["--port", "7070"])) + + def test_explicit_seconds(self): + self.assertEqual(parse_auto_refresh(["--auto-refresh", "120"]), 120) + + def test_bare_flag_uses_default(self): + self.assertEqual(parse_auto_refresh(["--auto-refresh"]), cli.DEFAULT_AUTO_REFRESH) + + def test_flag_followed_by_another_flag_uses_default(self): + # parse_named_arg would hand back "--port" here; it must not be read as a value. + self.assertEqual(parse_auto_refresh(["--auto-refresh", "--port", "7070"]), + cli.DEFAULT_AUTO_REFRESH) + + def test_no_auto_refresh_disables(self): + self.assertEqual(parse_auto_refresh(["--no-auto-refresh"]), 0) + + def test_no_auto_refresh_wins_over_auto_refresh(self): + self.assertEqual(parse_auto_refresh(["--auto-refresh", "60", "--no-auto-refresh"]), 0) + + def test_zero_disables(self): + self.assertEqual(parse_auto_refresh(["--auto-refresh", "0"]), 0) + + def test_negative_clamps_to_disabled(self): + self.assertEqual(parse_auto_refresh(["--auto-refresh", "-5"]), 0) + + def test_non_numeric_exits(self): + with self.assertRaises(SystemExit), redirect_stdout(io.StringIO()): + parse_auto_refresh(["--auto-refresh", "soon"]) + + +class TestDashboardAutoRefreshLoop(unittest.TestCase): + """The background thread keeps rescanning, so a long-lived dashboard stays live. + + Without the loop, /api/data serves whatever the single startup scan committed — + the browser's 30s poll then re-renders the same frozen snapshot forever. + """ + + def _run(self, auto_refresh, stop_after, timeout=5): + """Drive cmd_dashboard with scan/serve/sleep stubbed. Returns the scan calls.""" + calls = [] + done = threading.Event() + + def fake_scan(*args, **kwargs): + calls.append(kwargs) + if len(calls) >= stop_after: + done.set() + # BaseException, so the loop's `except Exception` won't swallow it: + # unwinds the daemon thread instead of spinning for the whole suite. + raise SystemExit + + with mock.patch.object(cli, "cmd_scan", side_effect=fake_scan), \ + mock.patch("dashboard.serve"), \ + mock.patch("time.sleep", return_value=None), \ + redirect_stdout(io.StringIO()): + cli.cmd_dashboard(host="127.0.0.1", port=9999, no_browser=True, + auto_refresh=auto_refresh) + fired = done.wait(timeout=timeout) + return calls, fired + + def test_loop_rescans_after_the_startup_scan(self): + calls, fired = self._run(auto_refresh=30, stop_after=3) + self.assertTrue(fired, "background loop never rescanned") + self.assertGreaterEqual(len(calls), 3) + + def test_startup_scan_is_verbose_and_rescans_are_quiet(self): + calls, fired = self._run(auto_refresh=30, stop_after=3) + self.assertTrue(fired) + # The startup scan reports the backlog; per-tick rescans must not spam stdout. + self.assertNotIn("verbose", calls[0]) + self.assertFalse(calls[1]["verbose"]) + + def test_zero_disables_the_loop(self): + # Nothing should fire, so a short window is enough — don't stall the suite. + calls, fired = self._run(auto_refresh=0, stop_after=2, timeout=1) + self.assertFalse(fired, "loop ran despite auto_refresh=0") + self.assertEqual(len(calls), 1, "expected only the startup scan") + + if __name__ == "__main__": unittest.main() From 9f52baf5c2901f5b4ba398d577961791a992d7c6 Mon Sep 17 00:00:00 2001 From: Diego Date: Thu, 23 Jul 2026 10:14:42 -0300 Subject: [PATCH 2/2] chore(release): CHANGELOG entry for v1.5.6 + version bump in lockstep --- CHANGELOG.md | 7 +++++++ scanner.py | 2 +- vscode-extension/package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2695585..40396bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v1.5.6 — TBD + +### Dashboard + +- Fixed the dashboard serving a **frozen snapshot** when left open: the browser polls `/api/data` every 30s and the footer advertises "Auto-refresh in 30s", but nothing ever rescanned the transcripts after the startup scan, so the numbers stopped moving the moment that scan finished. `cli.py dashboard` now runs an incremental rescan on the same 30s cadence, so today's usage keeps filling in without a restart. +- Added `--auto-refresh SECONDS` (and the `AUTO_REFRESH` environment variable) to tune the rescan interval, plus `--no-auto-refresh` / `--auto-refresh 0` to restore the scan-once-at-startup behaviour. Rescans are incremental — `processed_files` skips any transcript whose mtime is unchanged — so a steady-state pass over a ~700-file backlog touches only the live sessions. + ## v1.5.5 — 2026-07-10 ### Dashboard diff --git a/scanner.py b/scanner.py index 9bce026d..9173fde0 100644 --- a/scanner.py +++ b/scanner.py @@ -15,7 +15,7 @@ # runtime version has to live here as a constant. Keep this in lockstep with the # top CHANGELOG heading and vscode-extension/package.json (a parity test guards # all three; see tests/test_version.py). -VERSION = "1.5.5" +VERSION = "1.5.6" PROJECTS_DIR = Path.home() / ".claude" / "projects" XCODE_PROJECTS_DIR = Path.home() / "Library" / "Developer" / "Xcode" / "CodingAssistant" / "ClaudeAgentConfig" / "projects" diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 31a07985..b7fc075a 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "claude-usage-phuryn", "displayName": "Claude Code Usage by Paweł Huryn", "description": "Embed your Claude Code usage dashboard (token counts, costs, sessions, projects) directly inside VS Code. Reads local JSONL transcripts, no API calls.", - "version": "1.5.5", + "version": "1.5.6", "publisher": "PawelHuryn", "author": { "name": "Paweł Huryn",