Files
oh-my-pi/python/robomp/src/dashboard.py
T
2026-05-17 04:10:50 +02:00

140 lines
4.6 KiB
Python

"""Status dashboard helpers: log tail + the static SPA served at `/`.
The HTML/JS/CSS live under `src/static/`, produced by the Vite build in
`web/`. This module just locates the bundle, substitutes the per-instance
config sentinel, and exposes a small API to the FastAPI app.
"""
from __future__ import annotations
import json
from functools import cache
from pathlib import Path
from typing import Any
# Tail at most this many bytes from the end of the log file. Caps work for any
# `limit`, even pathologically large ones, on a multi-MB rotating file.
_TAIL_MAX_BYTES = 2 * 1024 * 1024
# Sentinel literally embedded in the built `index.html`; replaced per-request
# with a JSON config blob so the SPA can pick up the replay token.
_CONFIG_SENTINEL = "__ROBOMP_CONFIG__"
_STATIC_DIR = Path(__file__).resolve().parent / "static"
_INDEX_PATH = _STATIC_DIR / "index.html"
def tail_jsonl(path: Path, *, limit: int) -> list[dict[str, Any]]:
"""Return up to `limit` JSON log records from the tail of `path` (oldest first).
Lines that fail to parse are returned as `{"level": "RAW", "msg": <line>}`
so a malformed final line never blanks the whole view.
"""
if limit <= 0 or not path.exists():
return []
try:
size = path.stat().st_size
except OSError:
return []
if size == 0:
return []
read_size = min(size, _TAIL_MAX_BYTES)
with path.open("rb") as fh:
fh.seek(size - read_size)
chunk = fh.read(read_size)
# If we started mid-line, drop the partial leading line.
if read_size < size:
nl = chunk.find(b"\n")
if nl == -1:
return []
chunk = chunk[nl + 1 :]
lines = chunk.splitlines()
out: list[dict[str, Any]] = []
for raw in lines[-limit:]:
line = raw.strip()
if not line:
continue
try:
obj = json.loads(line)
if isinstance(obj, dict):
out.append(obj)
continue
except json.JSONDecodeError:
pass
out.append({"level": "RAW", "logger": "raw", "msg": line.decode("utf-8", errors="replace")})
return out
class DashboardBundleMissing(RuntimeError):
"""Raised when the built frontend bundle is unavailable.
The dev workflow is `bun run web:build` (one-shot Bun + Vite build); the
Docker image bakes the bundle in via the `web-builder` stage. Tests use a
placeholder `index.html` written into the static dir by `conftest.py`,
so this never fires in CI.
"""
def static_dir() -> Path:
"""Filesystem path the FastAPI app mounts at `/static`.
Creates the directory lazily so a fresh checkout (or a runtime container
that hasn't shipped the bundle yet) can still construct the app —
`_load_index_template()` raises `DashboardBundleMissing` separately when
the `index.html` itself is missing. Without this mkdir,
`StaticFiles(directory=...)` would raise at app construction time and
block every other route.
"""
_STATIC_DIR.mkdir(parents=True, exist_ok=True)
return _STATIC_DIR
@cache
def _load_index_template() -> str:
try:
text = _INDEX_PATH.read_text(encoding="utf-8")
except FileNotFoundError as exc: # pragma: no cover — repo ships the stub
raise DashboardBundleMissing(f"frontend bundle missing at {_INDEX_PATH}; run `bun run web:build`") from exc
if _CONFIG_SENTINEL not in text:
raise DashboardBundleMissing(
f"frontend bundle at {_INDEX_PATH} is missing the {_CONFIG_SENTINEL} sentinel; "
"rebuild with `bun run web:build`"
)
return text
def reset_index_cache() -> None:
"""Drop the cached template. Called by tests that swap the static dir."""
_load_index_template.cache_clear()
def render_index(replay_token: str | None) -> str:
"""Render the dashboard HTML with the server's replay token baked in.
The token lands inside a `<script type="application/json">` block that the
page parses at startup and attaches to every privileged fetch. The user
never sees or types it; the only credential to manage is the env var on
the server itself.
"""
config = {
"replayEnabled": bool(replay_token),
"replayToken": replay_token or "",
}
# `</` would otherwise let an attacker-controlled token break out of the
# script element; escape it the standard way.
payload = json.dumps(config, separators=(",", ":")).replace("</", "<\\/")
return _load_index_template().replace(_CONFIG_SENTINEL, payload)
__all__ = [
"DashboardBundleMissing",
"render_index",
"reset_index_cache",
"static_dir",
"tail_jsonl",
]