diff --git a/README.md b/README.md index 3a3c34b..5644de6 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ all in one browser page. Built with **FastAPI** (Python, managed by [uv]) and | Overview | Disks | | --------------- | --------------- | -| ![Overview](screenshots/Overview_20260830.png) | ![Disks](screenshots/Disks_20260830.png) | +| ![Overview](screenshots/Overview_20260831.png) | ![Disks](screenshots/Disks_20260831.png) | | Processes | Journal | -| ![Processes](screenshots/Processes_20260830.png) | ![Journal](screenshots/Journal_20260830.png) | +| ![Processes](screenshots/Processes_20260831.png) | ![Journal](screenshots/Journal_20260831.png) | | Services | Plugins | -| ![Services](screenshots/Services_20260830.png) | ![Plugins](screenshots/Plugins_20260830.png) | +| ![Services](screenshots/Services_20260831.png) | ![Plugins](screenshots/Plugins_20260831.png) | ## Authorship diff --git a/screenshots/Disks_20260830.png b/screenshots/Disks_20260830.png deleted file mode 100644 index a9a95fa..0000000 Binary files a/screenshots/Disks_20260830.png and /dev/null differ diff --git a/screenshots/Disks_20260831.png b/screenshots/Disks_20260831.png new file mode 100644 index 0000000..525820d Binary files /dev/null and b/screenshots/Disks_20260831.png differ diff --git a/screenshots/Journal_20260830.png b/screenshots/Journal_20260830.png deleted file mode 100644 index a1de304..0000000 Binary files a/screenshots/Journal_20260830.png and /dev/null differ diff --git a/screenshots/Journal_20260831.png b/screenshots/Journal_20260831.png new file mode 100644 index 0000000..a56df91 Binary files /dev/null and b/screenshots/Journal_20260831.png differ diff --git a/screenshots/Overview_20260830.png b/screenshots/Overview_20260830.png deleted file mode 100644 index 943d860..0000000 Binary files a/screenshots/Overview_20260830.png and /dev/null differ diff --git a/screenshots/Overview_20260831.png b/screenshots/Overview_20260831.png new file mode 100644 index 0000000..9c6d788 Binary files /dev/null and b/screenshots/Overview_20260831.png differ diff --git a/screenshots/Plugins_20260830.png b/screenshots/Plugins_20260830.png deleted file mode 100644 index d0703fc..0000000 Binary files a/screenshots/Plugins_20260830.png and /dev/null differ diff --git a/screenshots/Plugins_20260831.png b/screenshots/Plugins_20260831.png new file mode 100644 index 0000000..eb44e55 Binary files /dev/null and b/screenshots/Plugins_20260831.png differ diff --git a/screenshots/Processes_20260830.png b/screenshots/Processes_20260830.png deleted file mode 100644 index a7d783c..0000000 Binary files a/screenshots/Processes_20260830.png and /dev/null differ diff --git a/screenshots/Processes_20260831.png b/screenshots/Processes_20260831.png new file mode 100644 index 0000000..8db7431 Binary files /dev/null and b/screenshots/Processes_20260831.png differ diff --git a/screenshots/Services_20260830.png b/screenshots/Services_20260830.png deleted file mode 100644 index 0854631..0000000 Binary files a/screenshots/Services_20260830.png and /dev/null differ diff --git a/screenshots/Services_20260831.png b/screenshots/Services_20260831.png new file mode 100644 index 0000000..e2ff261 Binary files /dev/null and b/screenshots/Services_20260831.png differ diff --git a/scripts/screenshot.py b/scripts/screenshot.py new file mode 100644 index 0000000..b6ed11a --- /dev/null +++ b/scripts/screenshot.py @@ -0,0 +1,337 @@ +"""Capture per-tab screenshots of the dashboard with headless Chromium. + +A maintenance tool, not an application dependency: it drives Chromium over +the Chrome DevTools Protocol using the ``websocket-client`` package, so it +must be run with a Python that has it installed (the system ``python3`` has +it; the project venv does not) and a ``chromium`` (or ``chromium-browser`` / +``google-chrome``) binary on PATH. The dashboard server must already be +running, because the screenshots show its live state. + +Usage: + + python3 scripts/screenshot.py [--url URL] [--out DIR] [--date YYYYMMDD] + [--width N] [--height N] [--tabs overview,disks,...] + +For every selected tab the script clicks the tab button, waits until the +tab's content has been fetched and rendered, then stores a full-page PNG at +``/_.png`` (e.g. ``screenshots/Overview_20260831.png``), +matching the filenames referenced in the README. +""" + +import argparse +import base64 +import json +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path +from typing import Any + +import websocket + +TABS = ("overview", "disks", "processes", "journal", "services", "plugins") + +# JavaScript readiness conditions, one per tab: a tab is captured once its +# expression evaluates truthy. The journal waits for real lines (the error +# banner also has the jline class), and the overview extra settle below gives +# the 2 s /api/history poller time to fill the charts. +WAIT: dict[str, str] = { + "overview": "document.querySelectorAll('#overview .card').length >= 1", + "disks": "document.querySelectorAll('#disks .part').length >= 1", + "processes": "document.querySelectorAll('#processes-body .table tr').length >= 1", + "journal": "document.querySelectorAll('#journal-log .jline:not(.j-error-once)').length >= 5", + "services": "document.querySelectorAll('#services tr.svc-row').length >= 1", + "plugins": "document.querySelectorAll('#plugins .plugin-card').length >= 1", +} + +# Extra seconds to wait after the readiness condition, per tab; the overview +# needs two history polls so its charts are not empty. +SETTLE: dict[str, float] = { + "overview": 6.0, +} + +WAIT_TIMEOUT = 20.0 +POLL_INTERVAL = 0.25 + + +class Cdp: + """Minimal Chrome DevTools Protocol client over a WebSocket. + + Only what the capture flow needs: command/response correlation + (events are discarded), JavaScript evaluation, and truthy-condition + waiting. + """ + + def __init__(self, url: str) -> None: + """Connect to a DevTools WebSocket endpoint. + + Args: + url: The target's webSocketDebuggerUrl. + + Raises: + websocket.WebSocketException: If the connection fails. + """ + self._ws = websocket.create_connection(url, timeout=30) + self._next_id = 0 + + def send(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Send a CDP command and wait for its response. + + Args: + method: CDP method name, e.g. "Page.navigate". + params: Optional command parameters. + + Returns: + The "result" object of the response ({} if absent). + + Raises: + RuntimeError: If the response carries a protocol error. + """ + self._next_id += 1 + message: dict[str, Any] = {"id": self._next_id, "method": method} + if params: + message["params"] = params + self._ws.send(json.dumps(message)) + while True: + data = json.loads(self._ws.recv()) + if data.get("id") != self._next_id: + continue + if "error" in data: + raise RuntimeError(f"{method} failed: {data['error']}") + return data.get("result", {}) + + def evaluate(self, expression: str) -> Any: + """Evaluate JavaScript in the page and return its value. + + Args: + expression: JavaScript expression, evaluated by value. + + Raises: + RuntimeError: If evaluation throws in the page. + """ + result = self.send( + "Runtime.evaluate", + {"expression": expression, "returnByValue": True}, + ) + if "exceptionDetails" in result: + detail = result["exceptionDetails"] + raise RuntimeError(f"JS exception: {detail.get('text', detail)}") + return result.get("result", {}).get("value") + + def wait_until(self, expression: str, timeout: float) -> None: + """Wait until a JavaScript expression evaluates truthy. + + Args: + expression: Expression polled every POLL_INTERVAL seconds. + timeout: Maximum seconds to wait. + + Raises: + TimeoutError: If the condition is not met in time. + """ + deadline = time.monotonic() + timeout + last: Any = None + while time.monotonic() < deadline: + last = self.evaluate(expression) + if last: + return + time.sleep(POLL_INTERVAL) + raise TimeoutError( + f"condition not met within {timeout:.0f}s: {expression} (last: {last!r})" + ) + + def close(self) -> None: + """Close the WebSocket, ignoring errors.""" + try: + self._ws.close() + except Exception: + pass + + +def free_port() -> int: + """Return an unused localhost TCP port. + + Returns: + A port number that was free at call time. + """ + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def launch_chromium(port: int, workdir: Path) -> subprocess.Popen: + """Start headless Chromium with remote debugging enabled. + + Args: + port: TCP port for the DevTools endpoint. + workdir: Temporary directory; the profile and log file go inside it. + + Returns: + The running Popen handle. + + Raises: + RuntimeError: If no supported browser binary is on PATH. + """ + binary = ( + shutil.which("chromium") + or shutil.which("chromium-browser") + or shutil.which("google-chrome") + ) + if binary is None: + raise RuntimeError("no chromium/chrome binary found on PATH") + log = open(workdir / "chromium.log", "wb") + return subprocess.Popen( + [ + binary, + "--headless=new", + "--no-first-run", + "--no-default-browser-check", + "--disable-gpu", + "--hide-scrollbars", + "--force-device-scale-factor=1", + f"--user-data-dir={workdir / 'profile'}", + f"--remote-debugging-port={port}", + "--remote-allow-origins=*", + "about:blank", + ], + stdout=log, + stderr=subprocess.STDOUT, + ) + + +def devtools_url(port: int, timeout: float = 30.0) -> str: + """Wait for Chromium and return the page target's DevTools WebSocket URL. + + Args: + port: DevTools TCP port. + timeout: Maximum seconds to wait. + + Returns: + The webSocketDebuggerUrl of the first page target. + + Raises: + TimeoutError: If no page target appears in time. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/json", timeout=2) as resp: + targets = json.load(resp) + except (OSError, ValueError): + targets = [] + for target in targets: + if target.get("type") == "page" and target.get("webSocketDebuggerUrl"): + return target["webSocketDebuggerUrl"] + time.sleep(POLL_INTERVAL) + raise TimeoutError(f"DevTools page target not ready on port {port}") + + +def capture(url: str, out: Path, date: str, width: int, height: int, tabs: list[str]) -> list[Path]: + """Capture one full-page screenshot per tab. + + Args: + url: Dashboard base URL. + out: Output directory for the PNG files. + date: Date stamp embedded in the filenames (YYYYMMDD). + width: Viewport width in CSS pixels. + height: Viewport height in CSS pixels. + tabs: Tab names to capture, in order. + + Returns: + The written file paths, in capture order. + + Raises: + OSError: If the browser or network fails. + TimeoutError: If the browser or a tab does not become ready. + RuntimeError: If a CDP command or page script fails. + """ + workdir = Path(tempfile.mkdtemp(prefix="dashshot-")) + port = free_port() + browser = launch_chromium(port, workdir) + try: + cdp = Cdp(devtools_url(port)) + cdp.send("Page.enable") + cdp.send( + "Emulation.setDeviceMetricsOverride", + {"width": width, "height": height, "deviceScaleFactor": 1, "mobile": False}, + ) + cdp.send("Page.navigate", {"url": url}) + cdp.wait_until("document.readyState === 'complete'", 30.0) + written: list[Path] = [] + for tab in tabs: + cdp.evaluate(f'document.querySelector(`.tab-btn[data-tab="{tab}"]`).click()') + cdp.wait_until(WAIT[tab], WAIT_TIMEOUT) + time.sleep(SETTLE.get(tab, 0.4)) + shot = cdp.send( + "Page.captureScreenshot", + {"format": "png", "captureBeyondViewport": True}, + ) + path = out / f"{tab.capitalize()}_{date}.png" + path.write_bytes(base64.b64decode(shot["data"])) + print(f"wrote {path}") + written.append(path) + cdp.close() + return written + finally: + browser.terminate() + try: + browser.wait(timeout=10) + except subprocess.TimeoutExpired: + browser.kill() + shutil.rmtree(workdir, ignore_errors=True) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command line arguments. + + Args: + argv: Argument list (defaults to sys.argv[1:]). + + Returns: + The parsed namespace. + """ + parser = argparse.ArgumentParser( + description="Capture per-tab dashboard screenshots with headless Chromium." + ) + parser.add_argument("--url", default="http://127.0.0.1:8501", help="dashboard base URL") + parser.add_argument( + "--out", + type=Path, + default=Path(__file__).resolve().parent.parent / "screenshots", + help="output directory for the PNG files", + ) + parser.add_argument("--date", default=time.strftime("%Y%m%d"), help="date stamp for filenames") + parser.add_argument("--width", type=int, default=1671, help="viewport width in CSS pixels") + parser.add_argument("--height", type=int, default=707, help="viewport height in CSS pixels") + parser.add_argument("--tabs", default=",".join(TABS), help="comma-separated tab names") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Entry point. + + Args: + argv: Argument list (defaults to sys.argv[1:]). + + Returns: + Process exit code: 0 on success, 1 on runtime error, 2 on bad input. + """ + args = parse_args(argv) + tabs = [t.strip() for t in args.tabs.split(",") if t.strip()] + unknown = [t for t in tabs if t not in TABS] + if unknown: + print(f"unknown tabs: {', '.join(unknown)}", file=sys.stderr) + return 2 + try: + capture(args.url, args.out, args.date, args.width, args.height, tabs) + except (OSError, TimeoutError, RuntimeError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())