Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ Claude Code writes one JSONL file per session to `~/.claude/projects/`. Each lin

`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`).

If the port is already in use, the dashboard steps up to the next free one and prints which port it settled on. Port 8080 is a popular default, and a reverse proxy such as OrbStack or Docker Desktop holding it is easy to miss: those accept the connection and answer with nothing, so the browser reports `ERR_EMPTY_RESPONSE` rather than a connection refusal. The dashboard therefore checks whether a port answers before binding it, on both IPv4 and IPv6, since a plain bind can silently coexist with a wildcard one. For the same reason it opens `http://127.0.0.1:PORT` rather than `localhost`: on macOS `localhost` resolves to `::1` first, while the server listens on IPv4. Passing `--no-browser` pins the port instead: a supervising process (the VS Code extension) polls the exact port it asked for, so the server binds that port or exits.

---

## Cost estimates
Expand Down
32 changes: 18 additions & 14 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,8 @@ def cmd_stats():

def cmd_dashboard(projects_dir=None, host=None, port=None, no_browser=False, surface=None):
import threading
import time

from dashboard import serve
from dashboard import serve, browser_url

host = host or os.environ.get("HOST", "localhost")
port = int(port or os.environ.get("PORT", "8080"))
Expand All @@ -422,18 +421,23 @@ def background_scan():

threading.Thread(target=background_scan, daemon=True).start()

# Open a browser for users running this as a script (see README). The VS Code
# extension passes --no-browser since it embeds the dashboard in a webview.
if not no_browser:
import webbrowser

def open_browser():
time.sleep(1.0)
webbrowser.open(f"http://{host}:{port}")

threading.Thread(target=open_browser, daemon=True).start()

serve(host=host, port=port, surface=surface)
# Open a browser for users running this as a script (see README). The VS
# Code extension passes --no-browser since it embeds the dashboard in a
# webview. serve() calls this once the socket is listening and tells us the
# port it actually bound, which is not always the one we asked for -- no
# sleep to guess at readiness, no guess at the port either.
def on_ready(actual_port):
if not no_browser:
import webbrowser

webbrowser.open(browser_url(host, actual_port))

# Only the interactive path may drift to another port. Under --no-browser a
# supervisor picked this port, polls it for readiness, and keys the
# webview's localStorage on that origin (see vscode-extension/src/
# extension.ts) -- moving would read as a failed start.
serve(host=host, port=port, surface=surface,
fallback=not no_browser, on_ready=on_ready)


# ── Entry point ───────────────────────────────────────────────────────────────
Expand Down
96 changes: 93 additions & 3 deletions dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import os
import socket
import sqlite3
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
Expand Down Expand Up @@ -2293,19 +2294,108 @@ def do_POST(self):
self.end_headers()


def serve(host=None, port=None, surface=None):
PORT_PROBE_TIMEOUT = 0.2
PORT_ATTEMPTS = 10


def port_is_taken(port, timeout=PORT_PROBE_TIMEOUT):
"""True if anything already answers on `port`, over IPv4 or IPv6.

bind() cannot answer this question. TCPServer sets allow_reuse_address, so
on macOS/BSD a specific bind (127.0.0.1:8080) coexists happily with a
wildcard bind (*:8080) held by a reverse proxy such as OrbStack or Docker
Desktop -- no EADDRINUSE. The proxy still wins connections addressed to the
family it holds, and answers them with nothing, which a browser reports as
ERR_EMPTY_RESPONSE. Both families matter: `localhost` resolves to ::1 first
on macOS, so an IPv6-only squatter intercepts the browser even when the
IPv4 bind succeeded. Connecting is the only reliable probe.
"""
for family, addr in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
try:
sock = socket.socket(family, socket.SOCK_STREAM)
except OSError:
continue # host has no support for this family
sock.settimeout(timeout)
try:
sock.connect((addr, port))
return True
except OSError:
pass
finally:
sock.close()
return False


def create_server(host, port, attempts=PORT_ATTEMPTS, probe=True):
"""Bind a dashboard server, stepping past ports that are already in use.

Returns (server, actual_port). Set probe=False with attempts=1 to demand
one exact port and fail loudly -- that is what a supervising process (the
VS Code extension) needs, since it polls the port it asked for.
"""
last_error = None
for candidate in range(port, port + attempts):
if probe and port_is_taken(candidate):
continue
try:
return ThreadingHTTPServer((host, candidate), DashboardHandler), candidate
except OSError as e:
last_error = e
continue
if attempts == 1:
# No range was searched, so don't describe one. This is the message a
# supervising process shows its user.
raise OSError(f"Port {port} is already in use.") from last_error
last = port + attempts - 1
raise OSError(
f"No free port available in range {port}-{last}. "
f"Free one up, or pass --port with a different starting point."
) from last_error


def browser_url(host, port):
"""The address to hand a client for a server bound to `host`.

`host` is a bind address, which is not always somewhere you can connect to.
`0.0.0.0` and `::` are wildcards, not destinations. `localhost` is worse
than useless: getaddrinfo returns ::1 ahead of 127.0.0.1 on macOS while the
server binds IPv4 only, so it routes clients to whatever else holds the
IPv6 wildcard, or to nothing at all.
"""
if host in ("localhost", "", "0.0.0.0"):
return f"http://127.0.0.1:{port}"
if host in ("::", "::0"):
return f"http://[::1]:{port}"
if ":" in host: # IPv6 literal needs brackets in a URL
return f"http://[{host}]:{port}"
return f"http://{host}:{port}"


def serve(host=None, port=None, surface=None, fallback=True, on_ready=None):
global SURFACE
if surface:
SURFACE = surface
host = host or os.environ.get("HOST", "localhost")
port = port or int(os.environ.get("PORT", "8080"))
server = ThreadingHTTPServer((host, port), DashboardHandler)
print(f"Dashboard running at http://{host}:{port}")
if fallback:
server, actual_port = create_server(host, port)
else:
# A supervising process pinned this port; bind it or die, exactly as
# before. No probe -- a stale socket in TIME_WAIT must not turn a
# restart that bind() would have allowed into a spurious failure.
server, actual_port = create_server(host, port, attempts=1, probe=False)
if actual_port != port:
print(f"Port {port} is in use. Serving on {actual_port} instead.")
print(f"Dashboard running at {browser_url(host, actual_port)}")
print("Press Ctrl+C to stop.")
try:
if on_ready:
on_ready(actual_port)
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped.")
finally:
server.server_close()


if __name__ == "__main__":
Expand Down
35 changes: 35 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,5 +215,40 @@ def test_no_browser_suppresses_webbrowser(self):
mock_serve.assert_called_once()


class TestDashboardPortFallback(unittest.TestCase):
"""Fallback belongs to the interactive path, not the supervised one."""

def test_cli_run_allows_fallback(self):
with mock.patch.object(cli, "cmd_scan"), \
mock.patch("dashboard.serve") as mock_serve, \
mock.patch("webbrowser.open"), \
redirect_stdout(io.StringIO()):
cli.cmd_dashboard(host="localhost", port=8080, no_browser=False)
self.assertTrue(mock_serve.call_args.kwargs["fallback"])

def test_no_browser_pins_the_port(self):
"""The extension polls the exact port it passed (extension.ts:126) and
keys the webview's localStorage on that origin, so silently landing
elsewhere would read as a failed start."""
with mock.patch.object(cli, "cmd_scan"), \
mock.patch("dashboard.serve") as mock_serve, \
mock.patch("webbrowser.open"), \
redirect_stdout(io.StringIO()):
cli.cmd_dashboard(host="127.0.0.1", port=9999, no_browser=True)
self.assertFalse(mock_serve.call_args.kwargs["fallback"])

def test_browser_opens_the_port_actually_bound(self):
"""serve() reports the bound port via on_ready; the browser must follow
it, not the port that was originally requested."""
with mock.patch.object(cli, "cmd_scan"), \
mock.patch("dashboard.serve") as mock_serve, \
mock.patch("webbrowser.open") as mock_open, \
redirect_stdout(io.StringIO()):
cli.cmd_dashboard(host="localhost", port=8080, no_browser=False)
on_ready = mock_serve.call_args.kwargs["on_ready"]
on_ready(8083) # serve() landed here after stepping past squatters
mock_open.assert_called_once_with("http://127.0.0.1:8083")


if __name__ == "__main__":
unittest.main()
Loading