Update screenshots

This commit is contained in:
Johannes Schriewer 2026-08-31 03:14:04 +02:00
parent c756382178
commit 8958af2c67
14 changed files with 340 additions and 3 deletions

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

337
scripts/screenshot.py Normal file
View file

@ -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
``<out>/<Tab>_<date>.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())