Files
dashboards/tests/test_api.py
T
ducoterra f44a617ef9 phase: 04_llm_chart_readability
**Phase 04 final verification pass — all tasks already in `complete/`; verified + fixed 3 defects found in verification.**

- Verified `/llm` live (both engines, gufo-only instance on :8099, `llm_monitor` on :8095) in a real browser; dev server left running/healthy.
- Completed task-04's truncated layout fix: `#engines` now carries `.items-start` (the CSS rule existed but nothing used it) — gufo 544 px vs vLLM 715 px, no blank card; pinned by `test_llm_engine_panels_are_not_stretched_to_their_tallest_sibling`.
- Fixed: event rows said the subject twice ("gufo gufo is answering again"), and `/ups` mangled mid-message names ("sent to apc: OK" → "sent to  OK") → shared `Dash.eventText()` (strips `UpstreamError: ` + a *leading* name only, mirroring `alerts._clean_error`) used by `/system`, `/llm`, `/ups`; new test.
- Fixed: count sparklines autoscaled rollup averages (queue max 0.1379) into full-height spikes → `sparkline({minSpan})`, `minSpan: 1` on sessions/queued (spike 30 px → 4 px); assertions added.
- Tests: `bash .agents/validate.sh` → **EXIT=0** (`uv run pytest --cov --cov-fail-under=94`: **485 passed, 7 skipped**, coverage **96.87%**; `ruff check src tests agent`: All checks passed; `pyright`: 0 errors; `node --check` all static JS clean; repo hygiene clean; `uv pip list --outdated` empty). `uv run pytest -m live --live` → **7 passed**.
- Completion criteria: heroes largest per engine ✔ (38.4 px vs 16.8 px row values); sparkline per cell + selected window ✔ (5/5 gufo, 7/7 vLLM; 6 h chip moved KV max 53 %→58 %); mean+p95 without hover ✔ (243 / 48.8 / 285 ms feet); provenance tag ✔ (`Δ10.0s`/`Δ8.0s` + `title`=exact source); single-engine ✔ (1 panel, totals hidden, zero red); screenshots ✔ `data/logs/llm-verify-{both-engines,charts-and-slots,single-engine,monitor-reference}.png` + `NOTES-chart-set.md`; pytest/cov/ruff/pyright ✔; earlier phases unchanged-behaviour ✔ (all 01–03 tests pass).
- Deviation (documented, not code): throughput heroes stay **neutral ink** — `severity.py` has no throughput row and 0 tok/s idle is healthy; colour comes from rated cells (KV) + reachability. Kept per `NOTES-chart-set`.
- Deviation: could not append the task-04 written verdict to `NOTES-chart-set.md` (it lives in `todo/`, which I must not edit). Verdict: ours reads better than `:8095` — bigger throughput numbers, per-cell shape over the selected window, percentiles inline; `llm_monitor` only sparklines decode+KV and has no window control.
- Docs: AGENTS.md updated (485 test count, `sparkline` `minSpan` rule, `Dash.eventText` event-row rule, `/llm` panel `items-start`); README reviewed in full — commands, config table (all `config.py` vars present), architecture bullets, page descriptions verified accurate; no other inconsistencies found.
- Note for the harness: `.agents/phases/todo/03…/NOTES-verify.md` and `todo/04…/NOTES-chart-set.md` are untracked notes left behind by their phases (task files moved, notes did not).
- Next pending phase: `05_ups_hero_battery_glyph`.
2026-10-17 07:03:58 -04:00

1575 lines
81 KiB
Python

"""HTTP contract: what the pages may assume, and what must never leak.
The behaviours here are the ones a screenshot cannot show: an unconfigured source answering
404 `configured: false` instead of dialling a hostname that does not exist; a bearer token
that stops at the backend; the single write endpoint refusing on every gate; no external
asset the network can fail to deliver.
"""
from __future__ import annotations
import re
import time
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from dashboards import alerts
from dashboards.api import RANGE_SECONDS, parse_range
from dashboards.app import create_app
from dashboards.config import Settings, Target
from dashboards.severity import classify
from dashboards.sources import nut
from dashboards.sources.llm import HISTORY_METRICS
from dashboards.store import Event, Sample, SampleStore
# refused immediately, and never dials out of the test environment
DEAD_URL = "http://127.0.0.1:1"
@contextmanager
def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False,
**overrides: Any) -> Iterator[TestClient]:
"""A TestClient whose lifespan runs, against a private DB and no real network.
The store is injected, so `create_app()` deliberately does not close it (production
owns its own); the caller here does, or every test leaks a SQLite handle and the
`--cov` log fills with ResourceWarnings that bury the warnings worth reading.
"""
if simulate:
monkeypatch.setenv("NUT_SIMULATE", "1")
else:
monkeypatch.delenv("NUT_SIMULATE", raising=False)
db = str(tmp_path / "api.sqlite3")
settings = Settings(db_path=db, **overrides)
store = SampleStore(db)
client = TestClient(create_app(settings, store))
client.__enter__()
try:
yield client
finally:
client.__exit__(None, None, None)
store.close()
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
with build_client(tmp_path, monkeypatch) as test_client:
yield test_client
@pytest.fixture
def ups_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
"""NUT on the labelled simulator, actions allowed — the dev configuration."""
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
nut_allow_actions=True) as c:
yield c
def wait_for_payload(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]:
deadline = time.time() + timeout
while time.time() < deadline:
response = client.get(path)
if response.status_code == 200:
return response.json()
time.sleep(0.1)
pytest.fail(f"{path} never became available")
def failing_host_payload(scrape: Any, agent_snapshot: dict[str, Any]) -> dict[str, Any]:
"""Two scrapes of the real driveripper capture, nudged onto known sides of the table.
Driven from the fixture rather than hand-written so a schema drift breaks here first.
"""
def nudge(snap: dict[str, Any]) -> None:
snap["memory"]["used_bytes"] = snap["memory"]["total_bytes"] * 0.95 # mem.used_pct -> fail
snap["cpu_temperature_celsius"] = 91.0 # cpu.temp_c -> fail
for fs in snap["filesystems"]:
fs["used_pct"] = 93.0 # disk -> fail
return scrape(agent_snapshot, cpu_busy=10.0, mutate=nudge)
@pytest.fixture
def data_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
agent_snapshot: dict[str, Any], host_scrape: Any) -> Iterator[TestClient]:
"""Overview-shaped app: one host payload in the hub plus the simulated UPSes.
The host job points at a dead port, so a poll can never overwrite the injected payload
(a failed poll keeps the last good one) — the numbers below are ours, not the fleet's.
"""
monkeypatch.setenv("NUT_SIMULATE", "1")
db = str(tmp_path / "summary.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, nut_host="127.0.0.1",
host_targets=(Target("driveripper", DEAD_URL),)), store)
test_client = TestClient(app)
test_client.__enter__()
try:
app.state.ctx.hub.jobs["host:driveripper"].state.payload = failing_host_payload(
host_scrape, agent_snapshot)
wait_for_payload(test_client, "/api/ups/snapshot")
yield test_client
finally:
test_client.__exit__(None, None, None)
store.close()
@pytest.fixture
def healthy_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
agent_snapshot: dict[str, Any],
host_scrape: Any) -> Iterator[TestClient]:
"""The all-clear app: one host answering, nothing past a threshold, no UPS configured.
The job's collector is swapped for one that hands back the injected payload, so the
health record says `ok` truthfully instead of being overridden behind the poller.
"""
with build_client(tmp_path, monkeypatch,
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
from dashboards.poller import Result # noqa: PLC0415 - only this fixture needs it
payload = host_scrape(agent_snapshot, cpu_busy=10.0)
async def collect() -> Result:
return Result(payload=payload, samples=[])
job = test_client.app.state.ctx.hub.jobs["host:driveripper"] # type: ignore[attr-defined]
job.fn = collect
# let the poll already in flight against the dead port land first (it fails in
# microseconds), then take the health record over: the alerts must describe a host
# that is answering, not the half-second of startup where it was not
deadline = time.time() + 2.0
while time.time() < deadline and job.state.attempts == 0:
time.sleep(0.02)
time.sleep(0.05)
job.state.payload = payload
job.state.ok = True
job.state.error = None
job.state.consecutive_failures = 0
job.state.last_ok = time.time()
yield test_client
# ---- severity travels with the value --------------------------------------- #
def test_summary_carries_the_verdict_for_every_column_the_page_colours(data_client) -> None:
body = data_client.get("/api/summary").json()
row = body["system"]["hosts"][0]
assert row["severity"] == {"cpu_usage_pct": "ok", "mem_used_pct": "fail",
"disk_worst_used_pct": "fail", "temp_c": "fail"}
# the fleet card is judged by the same table, so amber means the same thing at both sizes
assert body["system"]["fleet"]["severity"] == {
"cpu_usage_pct_avg": "ok", "memory_used_pct": "fail",
"disk_worst_used_pct": "fail", "gpu_busy_pct_avg": "ok",
}
for ups in body["ups"]["ups"]:
assert set(ups["severity"]) == {"charge_pct", "load_pct", "runtime_seconds"}
assert ups["severity"]["charge_pct"] == classify("ups.charge_pct", ups["charge_pct"])
assert ups["severity"]["charge_pct"] == "ok", "the simulator starts at float charge"
def test_summary_shape_did_not_shrink(data_client) -> None:
"""Additive-only anchor: /api/summary gained `severity` and lost nothing.
The overview, the live tests, and any external consumer read the flat keys; this test is
the tripwire for a well-meaning refactor that "tidies" them away.
"""
body = data_client.get("/api/summary").json()
fleet_keys = {"hosts_total", "hosts_live", "cpu_cores", "cpu_usage_pct_avg",
"memory_total_bytes", "memory_used_bytes", "memory_used_pct",
"disk_worst_used_pct", "load1_total", "net_rx_bytes_per_s",
"net_tx_bytes_per_s", "gpu_count", "gpu_busy_pct_avg", "power_estimate_w"}
host_keys = {"name", "os", "cpu_usage_pct", "load1", "cores", "mem_used_pct",
"disk_worst_used_pct", "gpu_busy_pct", "temp_c", "uptime_seconds"}
ups_keys = {"name", "overall", "status", "charge_pct", "load_pct", "runtime_seconds"}
assert fleet_keys <= set(body["system"]["fleet"]), fleet_keys - set(body["system"]["fleet"])
assert host_keys <= set(body["system"]["hosts"][0]), host_keys - set(body["system"]["hosts"][0])
assert ups_keys <= set(body["ups"]["ups"][0]), ups_keys - set(body["ups"]["ups"][0])
assert set(body) == {"ts", "system", "llm", "ups", "recent_events", "alerts"}
# ...and the new keys are there beside the old ones, not instead of them
assert "severity" in body["system"]["fleet"]
assert "severity" in body["system"]["hosts"][0]
assert "severity" in body["ups"]["ups"][0]
# `/` paints two panels; the payload is still the whole-fleet contract. Every path here is
# read by something that is not the overview page: the live suite, an external probe, a
# notifier later. Deleting a card must never delete a key.
SUMMARY_CONTRACT = ("system.enabled", "system.fleet", "system.hosts",
"llm.engines", "ups.ups", "recent_events", "alerts")
def dig(body: dict[str, Any], path: str) -> Any:
"""`body["a"]["b"]` that names the missing path when it is not there."""
node: Any = body
for part in path.split("."):
assert isinstance(node, dict) and part in node, f"/api/summary lost `{path}`"
node = node[part]
return node
@pytest.mark.parametrize("fixture", ["client", "data_client"])
def test_summary_serves_every_path_another_consumer_reads(request, fixture: str) -> None:
"""The keys survive the overview cleanup whether or not anything is configured.
Run twice on purpose: `client` has no targets at all, so it proves the keys are present
as `None`/`[]` rather than omitted when a source is unconfigured (invariant 3 is about
the payload shape as much as about colour); `data_client` proves the same paths carry
the fleet once there is one.
"""
test_client = request.getfixturevalue(fixture)
body = test_client.get("/api/summary").json()
populated = fixture == "data_client"
for path in SUMMARY_CONTRACT:
dig(body, path) # presence is the assertion; a missing path raises with its name
assert dig(body, "system.enabled") is populated
assert (dig(body, "system.fleet") is not None) is populated
names = {host["name"] for host in dig(body, "system.hosts")}
assert names == ({"driveripper"} if populated else set())
batteries = {item["name"] for item in dig(body, "ups.ups")}
assert batteries == ({"apc", "cyberpower"} if populated else set())
assert dig(body, "llm.engines") == [], "neither fixture configures an engine"
assert isinstance(dig(body, "recent_events"), list)
assert isinstance(dig(body, "alerts"), list)
# and the payload as a whole still carries every top-level section it always did
assert set(body) >= {"ts", "system", "llm", "ups", "recent_events", "alerts"}
# ---- alerts: the landing page's answer to "what is wrong" ------------------- #
def test_summary_is_empty_of_alerts_when_everything_is_healthy(healthy_client) -> None:
"""`[]`, not a green banner: an empty list is the clearest possible all-clear."""
body = healthy_client.get("/api/summary").json()
assert body["alerts"] == []
assert body["system"]["fleet"]["hosts_live"] == 1
def test_summary_raises_one_alert_for_the_host_that_stopped_answering(data_client) -> None:
"""One dead host is one line naming it, with the link to the page that explains it."""
body = data_client.get("/api/summary").json()
down = [item for item in body["alerts"] if item["source"] == "driveripper"]
assert len(down) == 1, "its stale payload must not add amber lines on top of the red one"
assert down[0]["severity"] == "fail"
assert down[0]["href"] == "/system"
assert "unreachable" in down[0]["message"]
def test_a_host_that_has_never_answered_is_still_a_row_in_the_fleet(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A dead agent is a red row in the fleet, not a fleet that quietly got smaller.
`/api/system/snapshot` already stubs a job with no payload; `/api/summary` has to agree
with it, or a target that never answered since startup leaves the table at "2/2 hosts"
while the panel above says a third box is down (`AGENTS.md` invariants 3 and 4).
"""
with build_client(tmp_path, monkeypatch,
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
body = test_client.get("/api/summary").json()
rows = body["system"]["hosts"]
assert [row["name"] for row in rows] == ["driveripper"]
assert rows[0]["unreachable"] is True
assert rows[0]["cpu_usage_pct"] is None, "n/a, never a 0.0 nobody measured"
assert body["system"]["fleet"]["hosts_total"] == 1
assert body["system"]["fleet"]["hosts_live"] == 0
# and the alert list stays exactly one line for it, not a row plus a stale payload
assert [item["source"] for item in body["alerts"]] == ["driveripper"]
def test_summary_alerts_are_worst_first_and_carry_the_contract(data_client) -> None:
body = data_client.get("/api/summary").json()
alerts_list = body["alerts"]
assert alerts_list, "a dead host plus simulated UPS data cannot read as all-clear"
for item in alerts_list:
assert set(item) == {"id", "severity", "source", "message", "href", "at"}
assert item["severity"] in ("warn", "fail")
ranks = [0 if item["severity"] == "fail" else 1 for item in alerts_list]
assert ranks == sorted(ranks), "fails before warns"
assert any("simulat" in item["message"].lower() and item["href"] == "/ups"
for item in alerts_list), "NUT_SIMULATE must be said out loud on the landing page"
def test_summary_reuses_the_alert_builder_verbatim(data_client) -> None:
"""The endpoint is a thin call: what `alerts.build` says over the same state is the body."""
body = data_client.get("/api/summary").json()
hub = data_client.app.state.ctx.hub # type: ignore[attr-defined]
settings = data_client.app.state.ctx.settings # type: ignore[attr-defined]
hosts = [job.state.payload for job in hub.jobs.values()
if job.dashboard == "system" and job.state.payload]
ups_payload = hub.jobs["nut"].state.payload
expected = alerts.build(settings, hub.states(), hosts, [], ups_payload, [])
assert [item["id"] for item in body["alerts"]] == [item["id"] for item in expected]
def test_snapshot_payloads_carry_their_own_severity_sections(data_client) -> None:
"""/api/system/snapshot and /api/ups/snapshot ship the same verdicts the summary reuses."""
system = data_client.get("/api/system/snapshot").json()
host = system["hosts"][0]
assert set(host["cpu"]["severity"]) == {"usage_pct", "temp_c"}
assert set(host["memory"]["severity"]) == {"used_pct"}
assert set(host["disks"]["severity"]) == {"worst_used_pct"}
assert set(host["gpus"][0]["severity"]) == {"busy_pct", "mem_used_pct"}
ups = data_client.get("/api/ups/snapshot").json()
for card in ups["ups"]:
assert set(card["severity"]) == {"charge_pct", "load_pct", "battery_runtime"}
# numbers keep their own shape; severity is a sibling, never a replacement
assert isinstance(card["numbers"], dict) and "charge_pct" in card["numbers"]
# ---- unconfigured: say so, and do not dial -------------------------------- #
def test_config_reports_every_source_as_off(client) -> None:
body = client.get("/api/config").json()
assert body["system"]["enabled"] is False
assert body["system"]["targets"] == []
assert body["llm"]["gufo"]["enabled"] is False
assert body["llm"]["vllm"]["enabled"] is False
assert body["ups"]["enabled"] is False
assert body["ups"]["actions_enabled"] is False
assert body["ranges"], "the page builds its window buttons from this list"
def test_health_with_nothing_configured_is_not_a_failure(client) -> None:
"""Nothing configured is 'empty', not 'broken' — and the page must not go red."""
body = client.get("/api/health").json()
assert body["status"] in ("ok", "empty")
assert body["ok"] is True
assert body["sources"] == []
@pytest.mark.parametrize("path", ["/api/system/snapshot", "/api/llm/snapshot",
"/api/ups/snapshot", "/api/ups/commands"])
def test_unconfigured_source_is_404_configured_false(client, path) -> None:
response = client.get(path)
assert response.status_code == 404
assert response.json()["configured"] is False
assert "detail" in response.json()
def test_summary_still_answers_with_no_sources(client) -> None:
body = client.get("/api/summary").json()
assert body["system"]["enabled"] is False
assert body["system"]["fleet"] is None
assert body["llm"]["enabled"] is False
assert body["ups"]["enabled"] is False
def test_unknown_route_is_json_not_html(client) -> None:
response = client.get("/api/nope")
assert response.status_code == 404
assert response.headers["content-type"].startswith("application/json")
assert "no such route" in response.json()["detail"]
# ---- tokens --------------------------------------------------------------- #
def test_agent_tokens_never_reach_the_browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NUT_SIMULATE", "")
targets = [Target(name="driveripper", url="http://driveripper:9120",
headers={"Authorization": "Bearer SEKRIT-TOKEN"})]
db = str(tmp_path / "tokens.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, host_targets=tuple(targets)), store)
with TestClient(app) as client:
body = client.get("/api/config")
assert "SEKRIT-TOKEN" not in body.text
target = body.json()["system"]["targets"][0]
assert target["name"] == "driveripper"
assert "SEKRIT" not in target["url"] and "@" not in target["url"]
# and a host that never answered stays in the list, marked, with the fleet card intact
snapshot = wait_for_state(client, "/api/system/snapshot")
assert snapshot["hosts"][0]["name"] == "driveripper"
assert snapshot["hosts"][0].get("unreachable") is True
assert snapshot["fleet"]["hosts_total"] == 1
assert snapshot["fleet"]["hosts_live"] == 0
assert snapshot["fleet"]["memory_total_bytes"] is None
store.close()
def test_a_dead_host_never_writes_its_token_into_the_feed(tmp_path: Path) -> None:
"""The second path a credential can take to a browser: the failure message.
`parse_targets` moves a userinfo token out of the URL and into a bearer header, so the
documented `HOST_TARGETS` path never puts one in `Target.url` — this is the backstop for
any `Target` that does (a hand-built one, a future parser), and the message path is where
it would matter: the poller keeps an `UpstreamError` string in the health record and logs
it as an event, so a userinfo-bearing URL there would sit in SQLite and be served on
`/api/events` forever (AGENTS.md invariant 6). Asserted on every endpoint a page reads,
not just `/api/config`.
"""
db = str(tmp_path / "dead-host-token.sqlite3")
store = SampleStore(db)
settings = Settings(
db_path=db,
host_interval=0.05,
host_targets=(Target(name="driveripper", url="http://SEKRIT-AGENT-TOKEN@127.0.0.1:1"),),
)
with TestClient(create_app(settings, store)) as client:
deadline = time.time() + 10
while time.time() < deadline:
health = client.get("/api/health").json()
if health["sources"] and health["sources"][0]["error"]:
break
time.sleep(0.05)
else:
pytest.fail("the dead host never recorded an error")
for path in ("/api/health", "/api/events", "/api/system/snapshot", "/api/summary"):
body = client.get(path).text
assert "SEKRIT-AGENT-TOKEN" not in body, f"{path} leaked the agent token"
# and the outage is still legible — redaction is not the same as silence
assert "driveripper" in client.get("/api/health").text
assert "unreachable" in client.get("/api/events").text
store.close()
def test_engine_urls_are_redacted_the_same_way_hosts_are(tmp_path: Path) -> None:
"""`GUFO_BASE_URL`/`VLLM_BASE_URL` go to the browser verbatim in `/api/config`, and unlike
`HOST_TARGETS` nothing parses a userinfo token out of them into a header — so a URL that
happens to carry credentials has to be redacted where it is served (invariant 6).
"""
db = str(tmp_path / "engine-redact.sqlite3")
settings = Settings(db_path=db, gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731",
vllm_url="http://driveripper:8000")
store = SampleStore(db)
with TestClient(create_app(settings, store)) as client:
body = client.get("/api/config")
assert "SEKRIT-ENGINE-TOKEN" not in body.text
llm = body.json()["llm"]
assert llm["gufo"]["base_url"] == "http://deskwork:8731"
assert llm["vllm"]["base_url"] == "http://driveripper:8000" # untouched when clean
store.close()
def wait_for_state(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]:
deadline = time.time() + timeout
while time.time() < deadline:
response = client.get(path)
if response.status_code == 200:
return response.json()
time.sleep(0.1)
pytest.fail(f"{path} never answered")
def test_target_problems_are_surfaced_not_swallowed(client) -> None:
body = client.get("/api/config").json()
assert body["system"]["target_problems"] == []
# ---- pages ---------------------------------------------------------------- #
@pytest.mark.parametrize("path,title", [
("/", "overview"), ("/system", "system"), ("/llm", "llm"), ("/ups", "ups"),
])
def test_pages_serve_and_declare_themselves(client, path, title) -> None:
response = client.get(path)
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert title in response.text.lower()
def test_no_page_loads_anything_from_the_internet(client) -> None:
"""No CDN, no webfonts: this dashboard has to work with no route to the internet."""
for path in ("/", "/system", "/llm", "/ups"):
html = client.get(path).text
for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"):
assert fragment not in html, f"{path} references {fragment}"
def test_static_assets_are_served(client) -> None:
for asset in ("/static/dash.css", "/static/app.js", "/static/charts.js",
"/static/page-system.js", "/static/page-llm.js", "/static/page-ups.js",
"/static/page-index.js"):
assert client.get(asset).status_code == 200, asset
# ---- the severity language in the static assets --------------------------- #
#
# There is no JS test runner (a locked anchor: adding one would mean a build step).
# What *can* be asserted end-to-end, from the served bytes, is the contract the pages
# depend on: the hero style exists, its colour comes from the shared ok/warn/fail tokens,
# no parallel `.sev-*` vocabulary appeared, and `Dash.hero()` reads the verdict instead of
# comparing the number to a threshold in JavaScript.
ASSETS = ("/static/dash.css", "/static/app.js", "/static/charts.js",
"/static/page-index.js", "/static/page-system.js", "/static/page-llm.js",
"/static/page-ups.js")
def _css(client: TestClient) -> str:
return client.get("/static/dash.css").text
def _hero_css(css: str) -> str:
"""Just the `hero numbers` section of the stylesheet."""
return css.split("/* ---- hero numbers", 1)[1].split("/* ---- bars", 1)[0]
def test_css_ships_the_hero_number_style(client) -> None:
css = _css(client)
hero_block = _hero_css(css)
for rule in (".hero {", ".hero .label", ".hero .value", ".hero .unit", ".hero .sub",
".hero.ok .value", ".hero.warn .value", ".hero.fail .value",
".hero-bar", ".hero-bar.ok .bar"):
assert rule in hero_block, f"dash.css lost the {rule} rule"
# a display size with fixed-width digits, so the number does not jitter between polls
assert "2.4rem" in hero_block
assert "tabular-nums" in hero_block
def test_hero_colour_comes_from_the_shared_verdict_tokens(client) -> None:
"""`.hero` reuses `--ok/--warn/--fail`; it does not invent a palette of its own."""
hero_block = _hero_css(_css(client))
for token in ("var(--ok)", "var(--warn)", "var(--fail)", "var(--text)",
"var(--text-muted)", "var(--text-faint)"):
assert token in hero_block, f"the hero style no longer uses {token}"
def test_no_stylesheet_rule_uses_an_undeclared_token(client) -> None:
"""A typo'd `var(--x)` renders unstyled and reads as a broken panel, not an error."""
css = _css(client)
root = re.search(r":root\s*\{(.*?)\n\}", css, re.S)
assert root is not None
declared = set(re.findall(r"(--[a-z0-9-]+)\s*:", root.group(1)))
used = set(re.findall(r"var\(\s*(--[a-z0-9-]+)", css))
assert not used - declared
def _code(body: str) -> str:
"""An asset with its comments stripped — prose may *name* a class the code must not define."""
body = re.sub(r"/\*.*?\*/", " ", body, flags=re.S)
body = re.sub(r"<!--.*?-->", " ", body, flags=re.S)
return re.sub(r"^\s*//.*$", " ", body, flags=re.M)
def test_no_parallel_severity_vocabulary_exists_anywhere(client) -> None:
"""One word per colour: `ok|warn|fail`, as in severity.py and the existing components."""
for asset in (*ASSETS, "/", "/system", "/llm", "/ups"):
body = _code(client.get(asset).text)
assert ".sev-" not in body, f"{asset} invented a .sev-* class"
assert not re.search(r"classList\.(?:add|toggle)\(\s*[\"'](?:danger|good|warning)[\"']",
body), f"{asset} invented a synonym for fail"
def test_app_js_exposes_the_hero_helpers(client) -> None:
js = client.get("/static/app.js").text
assert "function hero(" in js
assert "function severityFrom(" in js
exports = js.rsplit("return {", 1)[1].split("}", 1)[0]
for name in ("hero", "severityFrom", "severityFor"):
assert re.search(rf"\b{name}\b", exports), f"Dash.{name} is not exported"
def test_hero_reads_the_verdict_instead_of_a_threshold(client) -> None:
"""`hero()` may only colour what the API rated — no thresholds re-derived in JS."""
js = client.get("/static/app.js").text
start = js.index("function hero(")
body = js[start:js.index("\n function setDot", start)]
assert "severityFrom(" in body
assert "severityFor(" not in body
# an unknown value gets n/a and no colour class at all
assert "naText" in body
assert re.search(r"raw != null", body)
def test_severity_for_is_kept_as_the_documented_fallback(client) -> None:
"""The old call sites still work until the remaining phases convert each page.
`/` was converted in phase 02, `/system` in phase 03 and `/llm` in phase 04: each
colours itself from the `severity` verdicts the API already sends, so it carries no
threshold of its own. `/ups` is phase 05's job and must keep compiling against the
fallback until it is converted — deleting it early would break that page.
"""
js = client.get("/static/app.js").text
assert "FALLBACK ONLY" in js
assert "severityFor" in client.get("/static/page-ups.js").text, \
"/ups still uses the fallback until phase 05 converts it"
def test_overview_colours_from_the_payload_not_from_js_thresholds(client) -> None:
"""`page-index.js` reads verdicts; it does not compare a number to a bound."""
js = client.get("/static/page-index.js").text
assert "severityFrom" in js
assert "severityFor" not in js
def test_system_page_colours_from_the_payload_not_from_js_thresholds(client) -> None:
"""`page-system.js` hero cards paint payload verdicts, not thresholds of their own.
Phase 03 task 02's promise: every colour on a host card comes from a `severity` dict
`severity.py` produced (`cpu.severity.usage_pct`, `memory.severity.used_pct`,
`disks.severity.worst_used_pct`, `gpus[].severity.busy_pct`, `cpu.severity.temp_c`).
A threshold written here could disagree with the overview's alerts; a hero with no
verdict prints `n/a` uncoloured, which is `Dash.hero()`'s contract.
"""
js = client.get("/static/page-system.js").text
assert "severityFor(" not in js, "the system page re-invented a threshold"
assert "Dash.hero(" in js
for hook in ("cpu.severity.usage_pct", "memory.severity.used_pct",
"disks.severity.worst_used_pct", "gpus[].severity.busy_pct",
"cpu.severity.temp_c"):
assert hook in js, f"the card no longer names {hook} as a colour source"
def test_system_host_card_is_six_heroes_without_per_core(client) -> None:
"""Each host card leads with exactly six heroes: CPU, MEM, TEMP, DISK, GPU, NET.
Asserted on the served script because there is no JS test runner (a locked anchor).
The two dense `.metrics` rows and the per-core heat bars are exactly what made the
card unreadable, and "no per-core anywhere on /system" is a promise a stray
`renderCores` call would silently break — the series stays stored, the page just
stopped drawing it (PLAN.md *Dropped visuals*).
"""
js = client.get("/static/page-system.js").text
card = js[js.index("function hostCard("):js.index("function renderHostCard(")]
slots = re.findall(r'<div class="(?:hero|hero-slot)[^"]*" data-k="([^"]+)"', card)
assert slots == ["cpu", "mem", "temp", "disk", "gpus", "net"], \
"the hero row is no longer the six numbers in the agreed order"
# per-core visualisation is gone from the card code entirely
assert "cores" not in card, "a per-core visual came back to the card"
assert "renderCores" not in js and "core_usage" not in js
# the NET slot is a slot with two lines and no verdict: a byte rate has no good/bad
assert 'data-k="net"' in card
assert not re.search(r'data-k="net"[^>]*data-sev', card), "the NET slot invented a colour"
# what the glance leaves out stays reachable, collapsed by default
assert "More detail" in card
for key in ("load", "freq", "dr", "dw", "dutil", "uptime", "details"):
assert f'data-k="{key}"' in card, f"{key} left the page instead of the glance"
# the panel owns its charts: the slots live inside the card, not on the page
for chart in ("util", "net"):
assert f'data-chart="{chart}"' in card, f"the panel lost its {chart} chart slot"
def test_system_page_keeps_no_fleet_wide_chart_or_metric_picker(client) -> None:
"""Nothing on `/system` plots two hosts, and nothing needs a selection to make sense.
TODO.md L4 is the instruction ("The system page doesn't need shared charts for
anything") and the overview's alerts + fleet table are what made removing them safe
(phase 02). Asserted on the served bytes because the only thing that would otherwise
notice a resurrected `<select>` is a browser: there is no JS test runner (a locked
anchor), and `test_no_page_carries_an_id_nothing_reads` cannot see ids that were deleted
correctly from the page but are still queried by the script.
"""
html = _code(client.get("/system").text)
for gone in ('id="chart-cpu"', 'id="chart-mem"', 'id="chart-disk"', 'id="chart-net"',
'id="chart-gpu"', 'id="chart-temp"', 'id="chart-detail"', 'id="detail-host"',
'id="detail-metric"', 'id="f-cpu"', 'id="f-load"', 'id="f-gpu"',
"Fleet totals", "Fleet charts", "Series detail", "<select"):
assert gone not in html, f"/system still carries {gone}"
# the two panels that are left are the per-host grid and the event feed
assert html.count("<section") == 2, "/system grew a panel back on top of the host grid"
assert 'id="hosts"' in html and 'id="events"' in html
# and the script must not reach for the deleted endpoints/ids either — a `#detail-host`
# query against a removed node is a silent null, so the picker would just never appear
js = client.get("/static/page-system.js").text
for gone in ("#chart-", "detail-host", "detail-metric", "detail-note",
"renderFleet", "makeCharts", "refreshGpuChart", "populateDetailMetrics"):
assert gone not in js, f"page-system.js still references {gone}"
def test_every_system_chart_is_drawn_inside_its_own_host_panel(client) -> None:
"""One host per chart: the legends of `/system` can never list two machine names.
The construction is a factory that takes the card, so a chart cannot be built against a
page-level id; the metric list is per host, so the GPU streams cannot leak from one
panel into another; and a panel is torn down (with its charts) when its host leaves the
payload, which is the bug class a keyed-by-host chart map exists to avoid.
"""
js = client.get("/static/page-system.js").text
builds = re.findall(r"new DashCharts\.LineChart\(([^,]+),", js)
assert builds, "the system page builds no charts at all"
# every chart is built from a `[data-chart]` slot inside the host card, never from a
# page-level id — which is what makes "one host per chart" structural, not a convention
assert all(re.search(r"(?:card|slot)\.querySelector\(.\[data-chart=", b) for b in builds), \
f"a chart is built outside a host panel: {builds}"
# three construction sites, one per slot (util, net, gpu) — never one per metric
assert len(builds) == 3, f"charts are not built one per panel slot: {builds}"
assert sorted(re.findall(r'data-chart=\\?"(\w+)"', " ".join(builds))) == ["gpu", "net", "util"]
factory = js[js.index("async function renderHostCharts("):js.index("function pruneHosts(")]
assert "hosts=${encodeURIComponent(host.name)}" in factory, \
"the per-host chart query is not scoped to one host"
assert "&metrics=" in factory and "state.range" in factory, \
"a host's charts need one metric list and the selected window"
# GPU series are named from the payload's own `gpus[].index`, never discovered/guessed
assert "gpu.busy_pct@" in js and "gpu.index" in js
# the GPU chart exists only while the host reports cards
gpu_slot = js[js.index("function hostChartSet("):js.index("function seriesFor(")]
assert re.search(r"if \(!wantsGpu && set\.gpu\)", gpu_slot), \
"a host that stops reporting GPUs keeps a GPU chart it has no data for"
pruner = js[js.index("function pruneHosts("):js.index("// ---- snapshot")]
assert "state.hosts.delete(name)" in pruner and "state.charts.delete(name)" in pruner, \
"a host that left the payload keeps a live panel and drawing charts"
def test_every_system_chart_names_itself_for_assistive_tech(client) -> None:
"""A 120px SVG is silence to a screen reader unless the page names it.
The phase-03 accessibility pass (task 04) found the per-host charts drawing anonymous
plots: the legend is a sibling element and labels nothing, so the `<svg>` itself must
carry `role="img"` + `aria-label` that says which host and which metrics. Pinned on the
served bytes because there is no JS test runner (a locked anchor).
"""
charts_js = client.get("/static/charts.js").text
# the shared LineChart applies the name to the svg, and only when the caller opted in
assert "if (this.opts.ariaLabel)" in charts_js, "LineChart stopped honouring ariaLabel"
assert "setAttribute('role', 'img')" in charts_js
assert "setAttribute('aria-label', String(this.opts.ariaLabel))" in charts_js
js = client.get("/static/page-system.js").text
# all three slots name themselves, and each name starts with the host the panel belongs
# to — an aria-label that cannot say *which machine* is not an accessible name here
assert js.count("ariaLabel: `") == 3, "a per-host chart slot lost its accessible name"
for slot in ("utilisation", "network traffic", "GPU busy"):
assert f"${{host.name}} \u2014 {slot}" in js or f"${{host.name}} — {slot}" in js, \
f"the {slot} chart has no host-scoped accessible name"
def test_system_event_feed_queries_the_source_transitions_are_stored_under(client) -> None:
"""Host transitions are stored under the *dashboard* name, not the sample source.
`poller.py` logs `Event(ts, self.dashboard, …)` — host jobs write `system`, the way
the UPS job writes `ups` — while *samples* use `host`. The `/system` feed queried
`source=host` since the first commit and therefore always rendered "no transitions
yet", even with a real outage (a gateway disconnect) sitting in the store. Pinned on
the served bytes because there is no JS test runner (a locked anchor).
"""
js = client.get("/static/page-system.js").text
feed = js[js.index("async function refreshEvents("):]
url = re.search(r"fetchJSON\('(/api/events[^']*)'\)", feed)
assert url, "the /system page no longer fetches the event feed"
assert "source=system" in url.group(1), \
"the /system event feed must ask for the source the poller stores events under"
assert "source=host" not in url.group(1), \
"source=host matches no stored event — the feed is empty forever"
def test_system_footnote_prints_the_window_it_actually_measured(client) -> None:
"""`Δ<window>` is the page's promise that a rate is a measurement (invariant 2), so the
number behind it has to come from the measurement.
The `window_seconds` beside the fleet is `HOST_INTERVAL_SECONDS`, while the window behind a
rate is the estimator's `RATE_WINDOW_SECONDS` — equal at the defaults, which is why the
footnote could quote the poll interval and look right. Set `RATE_WINDOW_SECONDS=30` and it
would have labelled every rate on the page with a window it never used. The footnote now
reads each host's own measured `window_seconds`, and prints a range when they disagree.
"""
js = client.get("/static/page-system.js").text
assert "function windowLabel(" in js, "the footnote lost its measured-window helper"
footnote = js[js.index("$('#foot-note').textContent"):js.index("await Promise.all(chartJobs)")]
assert "windowLabel(" in footnote
assert "host.window_seconds" in footnote, \
"the footnote is quoting the poll interval as a measurement again"
# a footnote that says "window" and a page that says "poll" must stay two separate claims
assert "poll ${Math.round(state.pollMs / 1000)}s" in footnote
def test_no_per_core_visual_library_survives_the_page(client) -> None:
"""The per-core heat bars left `/system` and `charts.js` with them (PLAN.md *Dropped
visuals* is about what the pages draw, not what the store keeps).
`renderCores` had one caller — the hero-card per-core block deleted in task 02 — so
keeping it would be dead markup generating unstyled `.core` spans; `.cores` was the CSS
that block needed. Both are checked absent here so neither can come back as a
half-change, while `sparkline`/`LineChart` stay for phase 04 and the UPS page.
"""
assert "renderCores" not in client.get("/static/charts.js").text
css = client.get("/static/dash.css").text
assert ".cores" not in css
for asset in ASSETS:
assert "core_usage" not in _code(client.get(asset).text), f"{asset} plots per-core"
# what phase 04 and the other pages still chart from
for name in ("LineChart", "sparkline", "renderLegend"):
assert name in client.get("/static/charts.js").text
def test_sparkline_ships_the_fixed_scale_and_verdict_options(client) -> None:
"""Phase 04 turns `/llm` cells into sparkline rows, so the shared `sparkline()` must
behave like `llm_monitor`'s `drawSpark` (the task-02 contract, NOTES-chart-set).
Pinned on the served bytes because there is no JS test runner (a locked anchor). What
matters is not the option names but the semantics behind each one: a percent metric
that silently rescaled to its own data would make an 8 % KV cache and a 92 % spike the
same squiggle, and a stroke coloured from a page-invented threshold would break the
one-colour-language rule the same way.
"""
charts_js = client.get("/static/charts.js").text
body = charts_js[charts_js.index("function sparkline("):charts_js.index("window.DashCharts")]
# the phase grep criterion, as a test: the options are really in the shipped file
hits = [ln for ln in charts_js.splitlines()
if re.search(r"fixedMax|align|severity", ln)]
assert len(hits) >= 3, "the sparkline upgrade is not in the served charts.js"
for opt in ("fixedMax", "align", "labels", "severity", "minPoints", "span", "format",
"minSpan"):
assert opt in body, f"sparkline() lost its `{opt}` option"
# fixedMax: the data clamps to the axis, never the axis to the data
assert "Math.min(Math.max(v, lo), hi)" in body, \
"a fixedMax sparkline rescales to its own data again"
# align:'right' grows within a real time window (span seconds), not a sample count
assert "x1 - o.span" in body, "align:'right' forgot the window it grows inside"
# verdict colour comes from the theme tokens only; unrated is faint grey, never green
for token in ("cssVar('--ok')", "cssVar('--warn')", "cssVar('--fail')",
"cssVar('--text-faint')"):
assert token in body, f"sparkline() stopped taking its colour from {token}"
assert ".sev-" not in body, "sparkline invented a parallel severity vocabulary"
# the `now / max` foot llm_monitor prints, via the caller's format callback
assert "now ${fmt(last.value)}" in body and "max ${fmt(dataMax)}" in body
# never a confident line through one point, and the foot quotes data, not the padded axis
assert "minPoints: 2" in body and "no history" in body
# ASSUMPTION (task 02): no animation at all, so prefers-reduced-motion holds by design
assert "transition:" not in body and "animation:" not in body
# ---- the `/llm` layout (phase 04 task 03) ---------------------------------
#
# The drift this phase fixes was a 14-cell grid where everything was the same size and
# nothing showed recent shape. These tests pin the *shape* the page committed to on the
# served bytes (a locked anchor: no JS test runner), so a regression to "all cells equal"
# fails a test rather than a screenshot.
def test_llm_page_colours_from_the_payload_not_from_js_thresholds(client) -> None:
"""`page-llm.js` paints the KV verdict `severity.py` produced; it owns no threshold.
Both `severityFor(value, 75, 90)` call sites are gone (the task's explicit work item):
the KV row takes `severity` from the rated ratio cell, the fleet KV bar was deleted
with the summed card, and every unrated number — throughput above all, because 0 tok/s
on an idle server is healthy — draws no colour at all.
"""
js = client.get("/static/page-llm.js").text
assert "severityFor(" not in js, "the LLM page re-invented a threshold"
assert "severityFrom" in js
# the KV verdict comes from the payload cells `sources/llm.py` rates
kv = js[js.index("case 'cache':"):js.index("case 'ttft':")]
assert "kv_cache_usage_ratio" in kv and "gpu_cache_usage" in kv and "severity" in kv
def test_llm_engine_panel_leads_with_two_throughput_heroes(client) -> None:
"""Decode and prompt are the largest elements per engine; the rest is visibly secondary.
The hero row is `Dash.hero()` markup (2.4rem via `.hero`), built from the `hero: true`
flags in `CELLS`, and throughput carries **no** severity — `severity.py` has no
throughput row and the page invents none (the neutral-ink decision in NOTES-chart-set,
mirroring the `/system` NET precedent). The provenance tag stays in the hero's `.sub`
with the exact `source` as the tooltip: removing it to look cleaner is a regression.
"""
js = client.get("/static/page-llm.js").text
panel = js[js.index("function enginePanel("):js.index("function renderPanel(")]
assert 'data-hero="${cell.key}"' in panel, "the hero row is not flag-driven markup"
assert '<div class="heroes">' in panel, "the hero row left the engine panel"
heroes = re.findall(r"{ key: '(\w+)'[^}]*?hero: true", js)
assert heroes == ["decode", "prompt"], "the hero row is no longer the two throughputs"
# driven by flags, not fourteen identical cells
assert "SPARK_CELLS" in js and "ALARM_CELLS" in js
assert js.count("spark: '") >= 6, "the compact sparkline list shrank back to a grid"
# the hero call must not pass a severity — unrated by design
hero_call = js[js.index("const node = hero("):js.index("if (node) node.title")]
assert "severity" not in hero_call, "throughput invented a verdict"
# provenance intact: the tag is generated from the cell's `source`, never hardcoded
assert "provenance" in js and "Δ" in js
assert "node.title = value.source" in js
def test_every_llm_cell_carries_a_window_sparkline_with_percentile_feet(client) -> None:
"""Each compact cell draws its own sparkline over the *selected* window; latency cells
print mean **and** p95 without a hover.
The window is honest time (`align: 'right'` + `span`), percent-like metrics keep a
fixed 0-100 axis, and the p95 comes from the snapshot's histogram cells — p95 needs no
stored series. The `only` scopes are what keep a gufo panel from showing a latency
mystery and a vLLM panel from showing a spec-decode row it cannot fill.
"""
js = client.get("/static/page-llm.js").text
sparks = js[js.index("function drawSparks("):js.index("function renderFleet(")]
assert "DashCharts.sparkline(" in sparks
for opt in ("align: 'right'", "span: state.range", "fixedMax", "labels: { max: true }"):
assert opt in sparks, f"cell sparklines lost `{opt}`"
# percent cells pin the axis; ratio series are scaled to the printed percent
assert js.count("fixedMax: 100") >= 3, "KV / prefix-hit / spec-accept lost their 0-100 axis"
assert "scale: 1000" in js and "scale: 100" in js
# every spark cell declares its history series in the markup it is built from
assert 'data-spark data-metric="${cell.spark}"' in js
# percentile foot lines: mean big in the row, p95 beneath, n/a when nothing measured
assert "function p95Value(" in js
for name in ("time_to_first_token_seconds__p95", "time_per_output_token_seconds__p95",
"request_queue_time_seconds__p95"):
assert name in js, f"{name} no longer feeds a foot line"
assert "p95 n/a" in js
assert 'data-foot' in js
# engine-scoped rows: gufo never grows a TTFT row, vLLM never a spec-accept row
assert "only: ['vllm']" in js and "only: ['gufo']" in js
scoped = js[js.index("function sparkCellsFor("):js.index("function enginePanel(")]
assert "cell.only" in scoped
# a count row never gets an axis finer than one request: history arrives as rollups,
# so 8 s of a single queued request averages to 0.14 and a fitted axis draws a spike for
# a queue nobody was in — the twin of the rescale `fixedMax` fixes
assert _code(js).count("minSpan: 1") == 2, "sessions/queued lost their one-request axis floor"
assert "minSpan: cell.minSpan" in sparks, "cell minSpan never reaches the sparkline"
def test_llm_sparklines_and_charts_cost_one_batched_history_request(client) -> None:
"""One `/api/llm/history` call per refresh feeds charts *and* every sparkline row.
The endpoint answers a metric list for every engine, and unknown names come back as
empty series on purpose — so the page asks for the full cell set once instead of one
XHR per cell, and the two series phase 04 added (`tpot_seconds`, `spec_accept_rate`)
must be in that list or those rows are permanent "no history" slots.
"""
js = client.get("/static/page-llm.js").text
# comments aside, exactly one fetch hits the endpoint: one request, every metric
calls = re.findall(r"fetchJSON\(\s*`/api/llm/history", _code(js))
assert len(calls) == 1, "the page makes more than one history request again"
assert "HISTORY_SERIES.join" in js and "SPARK_CELLS.map((cell) => cell.spark)" in js
request = js[js.index("async function refreshHistory("):js.index("function drawSparks(")]
assert "metrics=${HISTORY_SERIES.join" in request
assert "range=${state.range}" in request
# and the endpoint really answers every name the page asks for
for name in ("tpot_seconds", "spec_accept_rate", "ttft_seconds", "queue_seconds",
"kv_cache_usage", "requests_running", "requests_waiting",
"prefix_cache_hit_ratio"):
assert name in HISTORY_METRICS, f"llm.{name} is not stored, so its spark is dead"
def test_llm_alarm_cells_exist_only_while_there_is_an_incident(client) -> None:
"""`preemptions` / `device_lost` are absent at zero and red chips above zero.
The rule is the *existence of an incident*, not a graded threshold — `sources/llm.py`
already treats any non-zero `gufo_device_lost_total` as one. The chip reuses the shared
`.chip.fail` vocabulary; nothing new is painted, and a container emptied at zero means
the row does not exist rather than showing a permanent ignored 0.
"""
js = client.get("/static/page-llm.js").text
alarms = js[js.index("function renderAlarms("):js.index("function renderSlots(")]
assert "class=\"chip fail\"" in alarms, "the alarm chip left the shared fail vocabulary"
assert "value.value > 0" in alarms
assert "host.hidden = chips.length === 0" in alarms
assert 'data-alarm="${cell.key}"' in alarms
assert js.count("alarm: true") == 2
def test_llm_totals_card_says_only_what_no_engine_panel_says(client) -> None:
"""Engines answering, active, queued — and hidden below two engines.
The summed decode/prompt and the un-rated fleet KV bar are gone: per-engine is the
honest view (the fleet KV bar had no `severity` to render, which is invariant-grade
trouble), and a cross-engine sum of one engine just duplicates that engine's own card
(the `llm_monitor` rule). `/api/llm/snapshot` keeps every field — this is a page
decision, not an API one.
"""
html = _code(client.get("/llm").text)
for gone in ("f-decode", "f-prompt", "f-kv", "f-tokens", "seam-note"):
assert gone not in html, f"/llm still totals {gone}"
for kept in ("f-live", "f-active", "f-waiting", 'id="totals"'):
assert kept in html, f"/llm lost {kept}"
js = client.get("/static/page-llm.js").text
assert "card.hidden = total < 2" in js, "the totals card no longer hides for one engine"
def test_llm_charts_are_secondary_and_named(client) -> None:
"""The four trend charts stay, at half height, *below* the engine panels, each named.
Half of the old 165px and moved after `#engines` is the "cells read first" decision;
every `LineChart` gets an `ariaLabel` because the legend is a sibling that labels
nothing (`/system` did this in phase 03, `/llm` never had).
"""
html = _code(client.get("/llm").text)
assert html.index('id="engines"') < html.index('id="chart-decode"'), \
"the charts are back on top of the engine panels"
for chart in ("chart-decode", "chart-prompt", "chart-kv", "chart-queue"):
assert f'id="{chart}"' in html, f"/llm lost the {chart} chart"
js = client.get("/static/page-llm.js").text
assert "height: 110" in js and "height: 165" not in js
charts = js[js.index("function makeCharts("):js.index("function pointsFor(")]
assert charts.count("ariaLabel:") == 4, "an /llm chart lost its accessible name"
def test_llm_engine_panels_are_not_stretched_to_their_tallest_sibling(client) -> None:
"""A 5-row gufo panel beside a 7-row vLLM panel keeps its own height.
Found while comparing against `llm_monitor`: the shorter engine card grew 172 px of
blank card, and a blank panel reads as "no data" — the exact misreading the unconfigured
rules exist to prevent. Pinned on the served markup so the `.grid.items-start` rule in
`dash.css` cannot silently become dead CSS (and the void cannot silently come back).
"""
html = _code(client.get("/llm").text)
engines_div = html[html.index('<div class="grid'):html.index('id="engines"') + 40]
assert "items-start" in engines_div, "the engine grid stretches its cards again"
css = client.get("/static/dash.css").text
assert ".grid.items-start" in css, "the rule the engine grid relies on is gone"
def test_overview_is_exactly_two_panels(client) -> None:
"""The landing page answers one question: what is wrong, and what is the fleet doing.
Asserted on the served bytes because there is no JS test runner (a locked anchor):
the summary cards and the raw event feed are what made this page busy, and once a
panel is gone a browser is the only thing that would notice its return.
"""
html = client.get("/").text
assert html.count("<section") == 2, "the overview grew a third panel"
assert 'id="alerts"' in html and 'id="alerts-clear"' in html
assert 'aria-live="polite"' in html
assert 'id="fleet-table"' in html
for gone in ("sys-cpu", "sys-mem", "sys-disk", "sys-gpus", "llm-decode", "llm-kv",
"ups-list", 'id="events"'):
assert gone not in html, f"the overview still renders {gone}"
# the header's source dots are chrome owned by app.js, not the deleted card dots
assert 'id="dot-system"' not in html and 'id="nav-status"' in html
def test_overview_alert_rows_link_to_the_dashboard_that_explains_them(client) -> None:
"""Every alert carries the `href` its row turns into a link; the page invents none."""
page_js = client.get("/static/page-index.js").text
assert "alert.href" in page_js
css = client.get("/static/dash.css").text
for selector in ("ul.alerts li.ok .src", "ul.alerts li.warn .src", "ul.alerts li.fail .src",
"table.data td.warn", "table.data td.fail"):
assert selector in css, f"dash.css lost {selector}"
# the swap that puts a live problem above the fleet table
assert 'data-state="alerting"' in css
def test_overview_dims_the_fleet_row_of_a_host_that_stopped_answering(client) -> None:
"""A dead host keeps its last good numbers, so the row has to say they are stale.
`AGENTS.md` invariant 4: keep the payload and mark it stale, never zero it. The
overview's table is the one place those last good numbers still print, so it reads the
per-source verdicts `/api/summary` sends (`system.states`) and applies the shared
`.stale` class the other pages already use — no new palette, no JS threshold.
"""
page_js = client.get("/static/page-index.js").text
assert "system.states" in page_js, "the fleet table stopped reading reachability"
assert 'class="stale"' in page_js
assert ".stale" in client.get("/static/dash.css").text
# Which script owns which page, for the orphan check below. `charts.js` and `app.js` are
# loaded by all four pages, so an id either page code or shared chrome may read.
PAGE_SCRIPTS = {
"/": "page-index.js",
"/system": "page-system.js",
"/llm": "page-llm.js",
"/ups": "page-ups.js",
}
SHARED_SCRIPTS = ("app.js", "charts.js")
def test_no_page_carries_an_id_nothing_reads(client) -> None:
"""An id no script queries, no stylesheet styles and no attribute points at is dead markup.
The summary cards were retired this way — a `<span id="sys-cpu">` left behind after its
writer was deleted is the same mistake in slower motion. Asserted on served bytes: the
browser console is otherwise the only thing that would notice.
"""
scripts = {name: client.get(f"/static/{name}").text for name in
(*SHARED_SCRIPTS, *PAGE_SCRIPTS.values(), "dash.css")}
for path, own_script in PAGE_SCRIPTS.items():
html = _code(client.get(path).text)
corpus = "".join(_code(scripts[name])
for name in (*SHARED_SCRIPTS, own_script, "dash.css"))
# ids the markup uses itself: labels, targets, `<label for>`
anchors = set(re.findall(
r'(?:aria-labelledby|aria-describedby|aria-controls|for)="([^"]+)"', html))
for element_id in re.findall(r'id="([^"]+)"', html):
quoted = (f'"{element_id}"' in corpus or f"'{element_id}'" in corpus
or f"#{element_id}" in corpus or element_id in anchors)
assert quoted, f"{path} has id=\"{element_id}\" that nothing reads"
def test_api_responses_are_not_cacheable(client) -> None:
for path in ("/api/config", "/api/health", "/api/summary"):
assert "no-store" in client.get(path).headers.get("cache-control", "")
# ---- history -------------------------------------------------------------- #
def test_history_returns_seeded_samples(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
db = str(tmp_path / "hist.sqlite3")
store = SampleStore(db)
now = time.time()
for i in range(30):
store.add("host", "driveripper", "cpu.usage_pct", 10.0 + i, ts=now - 300 + i)
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),)), store)
with TestClient(app) as client:
body = client.get("/api/history", params={"source": "host", "host": "driveripper",
"metric": "cpu.usage_pct", "range": "15m"})
assert response_ok(body)
points = body.json()["points"]
assert len(points) >= 25
assert points[-1]["value"] == pytest.approx(39.0)
store.close()
def test_history_rejects_an_unknown_range(client) -> None:
response = client.get("/api/history", params={"source": "host", "host": "h",
"metric": "m", "range": "banana"})
assert response.status_code == 400
def test_history_requires_the_series_coordinates(client) -> None:
assert client.get("/api/history").status_code == 422
@pytest.mark.parametrize(("raw", "seconds"), [
*[(key, float(seconds)) for key, seconds in RANGE_SECONDS.items()],
("45s", 45.0), ("13h", 46_800.0), ("7200", 7_200.0), (" 2D ", 172_800.0),
(None, 3_600.0), ("", 3_600.0),
])
def test_parse_range_reads_every_window_the_ui_offers(raw: Any, seconds: float) -> None:
"""Every chip key (5m…90d), any `<n><s|m|h|d>`, raw seconds, and the 1h default.
`parse_range` is the only thing between a typo'd query string and a chart of the wrong
window, and a wrong window is worse than an error: it still looks like data.
"""
assert parse_range(raw) == seconds
@pytest.mark.parametrize("raw", ["banana", "1w", "h", "-4h", "one"])
def test_parse_range_refuses_a_window_it_cannot_interpret(raw: str) -> None:
"""A 400 that names the value, never a silent fallback to 1h."""
with pytest.raises(HTTPException) as excinfo:
parse_range(raw)
assert excinfo.value.status_code == 400
assert raw in str(excinfo.value.detail)
# ---- the routes the *other* pages chart from -------------------------------- #
#
# `/` renders two panels now; `/system`, `/llm` and `/ups` still need these, and so does
# anything outside the browser. Retiring overview code must not retire them, so the shapes
# are pinned here rather than discovered by a page three phases from now.
def test_system_history_charts_every_configured_host_in_one_request(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""N hosts x M metrics, one round trip.
`/system` no longer uses the N-hosts half of this (every chart belongs to one host and
the page issues one request per host, pinned by
`test_every_system_chart_is_drawn_inside_its_own_host_panel`); the shape is kept because
the metric-list half is exactly what the per-host query costs one request with, and
because the endpoint is a public interface for programmatic consumers.
"""
with build_client(tmp_path, monkeypatch,
host_targets=(Target("driveripper", DEAD_URL),
Target("deskwork", DEAD_URL))) as test_client:
body = test_client.get("/api/system/history",
params={"metrics": "cpu.usage_pct,mem.used_pct",
"range": "1h"}).json()
assert body["hosts"] == ["driveripper", "deskwork"]
assert body["range_seconds"] == 3_600.0
assert len(body["series"]) == 4
assert {(item["host"], item["metric"]) for item in body["series"]} == {
("driveripper", "cpu.usage_pct"), ("driveripper", "mem.used_pct"),
("deskwork", "cpu.usage_pct"), ("deskwork", "mem.used_pct")}
# an explicit `hosts` list narrows it instead of adding to the default
narrowed = test_client.get("/api/system/history",
params={"metrics": "cpu.usage_pct",
"hosts": "deskwork"}).json()
assert narrowed["hosts"] == ["deskwork"]
def test_one_request_per_host_answers_every_per_host_chart(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The exact query `/system` issues per host returns every series that panel draws.
`page-system.js` sends one request per host carrying the utilisation trio
(`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct`), both network rates, and one
`gpu.busy_pct@<index>` per card the payload reported — so a name missing here is a chart
that silently reads "no samples yet" in the browser, and a renamed series in
`sources/host.py` fails here rather than in a screenshot. This is the metric-name half of
the contract; `tests/test_system_contract.py` holds the payload-path half.
"""
with build_client(tmp_path, monkeypatch,
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
store = test_client.app.state.ctx.store # type: ignore[attr-defined]
now = time.time()
metrics = ["cpu.usage_pct", "mem.used_pct", "disk.used_pct",
"net.rx_bytes_per_s", "net.tx_bytes_per_s", "gpu.busy_pct@0"]
for metric in metrics:
store.add("host", "driveripper", metric, 12.5, ts=now - 60)
store.add("host", "driveripper", metric, 25.0, ts=now - 30)
body = test_client.get("/api/system/history", params={
"hosts": "driveripper", "metrics": ",".join(metrics), "range": "1h"}).json()
assert body["hosts"] == ["driveripper"], "one panel, one host"
assert [item["metric"] for item in body["series"]] == metrics
assert [len(item["points"]) for item in body["series"]] == [2] * len(metrics)
# a second card the host does not have answers empty, never zero and never 404
gap = test_client.get("/api/system/history",
params={"hosts": "driveripper", "metrics": "gpu.busy_pct@1"}).json()
assert gap["series"][0]["points"] == []
def test_llm_snapshot_keeps_an_engine_that_never_answered_in_the_list(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A configured-but-dead engine is listed as unreachable, not dropped (invariant 4)."""
# a host target too, so the snapshot's job filter has to skip a non-LLM job
with build_client(tmp_path, monkeypatch, vllm_url=DEAD_URL,
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
body = test_client.get("/api/llm/snapshot").json()
assert [engine["engine"] for engine in body["engines"]] == ["vLLM"]
assert body["engines"][0]["unreachable"] is True
assert body["fleet"]["engines_total"] == 1
assert body["fleet"]["engines_live"] == 0
assert [state["key"] for state in body["states"]] == ["llm:vllm"]
def test_llm_history_charts_both_engines_by_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
with build_client(tmp_path, monkeypatch, gufo_url=DEAD_URL,
vllm_url=DEAD_URL) as test_client:
body = test_client.get("/api/llm/history",
params={"metrics": "decode_tokens_per_second,kv_cache_usage_ratio",
"range": "15m"}).json()
assert body["engines"] == ["gufo", "vllm"]
assert body["range_seconds"] == 900.0
assert {(item["host"], item["metric"]) for item in body["series"]} == {
("gufo", "llm.decode_tokens_per_second"),
("gufo", "llm.kv_cache_usage_ratio"),
("vllm", "llm.decode_tokens_per_second"),
("vllm", "llm.kv_cache_usage_ratio")}
def test_one_batched_llm_history_request_answers_every_cell_series(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The exact query `/llm` issues per refresh returns every chart *and* sparkline series.
Phase 04 sends one request carrying the chart trio and the whole spark cell list —
including the two series the layout required (`tpot_seconds` for the vLLM TPOT row,
`spec_accept_rate` for the gufo spec row). A name missing here is a cell that reads
"no history" forever in the browser, which is the dead-cell anti-pattern the phase
wrote NOTES-chart-set to prevent; a rename in `sources/llm.py` fails here first.
"""
with build_client(tmp_path, monkeypatch, gufo_url=DEAD_URL,
vllm_url=DEAD_URL) as test_client:
store = test_client.app.state.ctx.store # type: ignore[attr-defined]
now = time.time()
metrics = ["decode_tokens_per_second", "prompt_tokens_per_second",
"kv_cache_usage", "requests_running", "requests_waiting",
"ttft_seconds", "tpot_seconds", "queue_seconds",
"prefix_cache_hit_ratio", "spec_accept_rate"]
for metric in metrics:
store.add("llm", "gufo", f"llm.{metric}", 1.5, ts=now - 60)
store.add("llm", "vllm", f"llm.{metric}", 2.5, ts=now - 30)
body = test_client.get("/api/llm/history", params={
"engines": "gufo,vllm", "metrics": ",".join(metrics), "range": "1h"}).json()
assert body["range_seconds"] == 3_600.0
by_key = {(item["host"], item["metric"]) for item in body["series"]}
assert by_key == {(engine, f"llm.{metric}")
for engine in ("gufo", "vllm") for metric in metrics}
points = {(item["host"], item["metric"]): item["points"] for item in body["series"]}
assert points[("gufo", "llm.spec_accept_rate")][0]["value"] == pytest.approx(1.5)
assert points[("vllm", "llm.tpot_seconds")][0]["value"] == pytest.approx(2.5)
def test_metrics_endpoint_lists_what_actually_has_history(client) -> None:
body = client.get("/api/metrics").json()
assert body["metrics"] == []
# ---- events --------------------------------------------------------------- #
def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
db = str(tmp_path / "events.sqlite3")
store = SampleStore(db)
store.log_event(Event(ts=time.time(), source="host", host="deskwork", kind="down",
severity="error", message="deskwork is not answering"))
app = create_app(Settings(db_path=db), store)
with TestClient(app) as client:
events = client.get("/api/events").json()["events"]
assert events[0]["host"] == "deskwork"
assert events[0]["severity"] == "error"
store.close()
def test_event_rows_say_their_subject_once(client) -> None:
"""`poller.py` messages name their own subject, and the row prints a host chip too.
"gufo gufo is answering again after 1 failure" reads as two problems — the same defect
`alerts._clean_error()` already fixes for the overview's alert list. `/ups` had the
mirror-image bug: it `replace()`d `host + ':'` *anywhere*, so "Quick battery test sent
to apc: OK" printed as "Quick battery test sent to OK". All three feeds now share
`Dash.eventText()`, which strips the wrapper and a **leading** name only.
"""
app_js = client.get("/static/app.js").text
helper = app_js[app_js.index("function eventText("):app_js.index("// ---- DOM helpers")]
assert "eventText" in app_js[app_js.index("return {"):], "eventText is not exported"
assert "^UpstreamError:\\s+" in helper, "the wrapper `alerts.py` drops is back"
assert "new RegExp" in helper and "stripped.trim() ? stripped : out" in helper, \
"the host strip is no longer anchored to the front, or can empty a message"
for page in ("/static/page-system.js", "/static/page-llm.js", "/static/page-ups.js"):
js = client.get(page).text
assert "eventText(event)" in js, f"{page} prints the raw message again"
assert "replace(event.host" not in client.get("/static/page-ups.js").text, \
"/ups is mangling the host name mid-message again"
# ---- the one write endpoint ------------------------------------------------ #
def test_command_endpoint_is_post_only(ups_client) -> None:
assert ups_client.post("/api/ups/command", json={}).status_code in (400, 403, 404)
assert ups_client.put("/api/ups/command", json={}).status_code == 405
assert ups_client.delete("/api/ups/command").status_code == 405
def test_commands_are_offered_when_actions_are_enabled(ups_client) -> None:
body = wait_for_payload(ups_client, "/api/ups/snapshot")
assert body["simulated"] is True, "the simulator must label itself"
assert body["actions_enabled"] is True
ups = body["ups"][0]
enabled = [item for item in ups["commands"] if item["enabled"]]
assert enabled, "with NUT_ALLOW_ACTIONS=1 and a simulator, safe commands are sendable"
assert all(item["risk"] in ("safe", "caution", "dangerous") for item in ups["commands"])
def test_a_safe_command_actually_reaches_the_driver(ups_client) -> None:
body = wait_for_payload(ups_client, "/api/ups/snapshot")
name = body["ups"][0]["name"]
response = ups_client.post("/api/ups/command",
json={"ups": name, "command": "test.panel.start"})
assert response.status_code == 200, response.text
payload = response.json()
assert payload["ok"] is True and payload["simulated"] is True
events = ups_client.get("/api/events").json()["events"]
assert any("test.panel.start" in event["message"] or "Panel test" in event["message"]
for event in events), "every sent command must leave an audit trail"
def test_a_dangerous_command_needs_the_confirmation_word(ups_client) -> None:
name = wait_for_payload(ups_client, "/api/ups/snapshot")["ups"][0]["name"]
deep = "test.battery.start.deep"
refusal = ups_client.post("/api/ups/command", json={"ups": name, "command": deep})
assert refusal.status_code == 422
assert "DANGER" in refusal.json()["detail"]
assert ups_client.post("/api/ups/command",
json={"ups": name, "command": deep, "confirm": "DANGER"}).status_code == 200
def test_a_command_the_driver_does_not_advertise_is_refused_before_it_is_sent(
ups_client) -> None:
"""Being in the risk catalog is not permission; `LIST CMD` from the driver is (invariant 1).
`shutdown.return` is a real NUT command the catalog knows and this UPS does not offer,
so the refusal must come from the advertisement check and not from the confirmation gate.
"""
name = wait_for_payload(ups_client, "/api/ups/snapshot")["ups"][0]["name"]
response = ups_client.post("/api/ups/command",
json={"ups": name, "command": "shutdown.return"})
assert response.status_code == 400
assert "does not advertise" in response.json()["detail"]
def test_cutting_the_output_is_refused_while_the_ups_is_already_on_battery(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Invariant 1, the one that matters most: no power cut while the pack is carrying the load.
Driven through the simulator's own state machine — a quick battery test puts the pack on
battery — so the 409 comes from the same poll the buttons are drawn from, with a 0.5s
interval to keep the wait short. Nothing here can touch real hardware: `NUT_SIMULATE=1`.
"""
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
nut_allow_actions=True, nut_interval=0.5) as test_client:
name = wait_for_payload(test_client, "/api/ups/snapshot")["ups"][0]["name"]
start = test_client.post("/api/ups/command",
json={"ups": name, "command": "test.battery.start.quick"})
assert start.status_code == 200
deadline = time.time() + 6.0
card: dict[str, Any] = {}
while time.time() < deadline:
card = next(item for item in test_client.get("/api/ups/snapshot").json()["ups"]
if item["name"] == name)
if card["on_battery"]:
break
time.sleep(0.05)
assert card.get("on_battery") is True, "the quick test must put the simulated pack on battery"
refusal = test_client.post("/api/ups/command",
json={"ups": name, "command": "load.off",
"confirm": "DANGER"})
assert refusal.status_code == 409
assert "on battery" in refusal.json()["detail"]
# and the same command is still offered once the pack is back on line power
assert not any(item["command"] == "load.off" and item["enabled"]
for item in card["commands"]), "the panel must not offer a blocked button"
class ScriptedNutClient:
"""`nut.NutClient` with the socket left out: the real code path, no battery attached.
`AGENTS.md` keeps live tests read-only, so this is where the one write endpoint's real
branch gets proven. The variable names are the ones `VAR_MAP` already pins from real
upsd captures; only the replies are scripted (a fake for behaviour, per the test policy).
"""
#: set per test: an exception instance to raise from `instcmd`, e.g. a refusal
refusal: Exception | None = None
sent: list[tuple[str, str]]
logged_in: list[str]
def __init__(self, host: str, port: int, *, timeout: float = 4.0,
username: str | None = None, password: str | None = None) -> None:
self.credentials = (username, password)
async def __aenter__(self) -> ScriptedNutClient:
return self
async def __aexit__(self, *_exc: object) -> None:
return None
async def list_ups(self) -> list[dict[str, str]]:
return [{"name": "apc", "description": "APC Back-UPS RS 1500MS"}]
async def list_vars(self, ups: str) -> dict[str, str]:
return {"ups.mfr": "APC", "ups.model": "Back-UPS RS 1500MS",
"ups.status": "OL", "battery.charge": "100", "ups.load": "18",
"battery.voltage": "13.6", "input.voltage": "230",
"battery.mfr.date": "2022-04", "ups.test.result": "Ok last test passed"}
async def list_cmds(self, ups: str) -> list[str]:
return ["test.panel.start", "beeper.mute", "load.off"]
async def list_rw_vars(self, ups: str) -> list[str]:
return []
async def login(self) -> None:
assert self.credentials[0], "a command send must authenticate as a named user"
ScriptedNutClient.logged_in.append(str(self.credentials[0]))
async def instcmd(self, ups: str, command: str) -> str:
if ScriptedNutClient.refusal is not None:
raise ScriptedNutClient.refusal
ScriptedNutClient.sent.append((ups, command))
return "OK Start Panel Test"
@pytest.fixture
def real_upsd_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[type[ScriptedNutClient]]:
"""Swap the module-level `NutClient` so the collector and the send use the same stand-in."""
ScriptedNutClient.sent = []
ScriptedNutClient.logged_in = []
ScriptedNutClient.refusal = None
monkeypatch.setattr(nut, "NutClient", ScriptedNutClient)
yield ScriptedNutClient
@pytest.fixture
def nut_client_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
real_upsd_client: type[ScriptedNutClient]) -> Iterator[TestClient]:
"""A non-simulated UPS app: real code paths, scripted upsd, 0.5s polls."""
with build_client(tmp_path, monkeypatch, nut_host="127.0.0.1", nut_user="dashboards",
nut_password="not-the-real-one", nut_allow_actions=True,
nut_interval=0.5) as test_client:
yield test_client
def test_a_real_upsd_send_authenticates_sends_and_audits(nut_client_app,
real_upsd_client) -> None:
body = wait_for_payload(nut_client_app, "/api/ups/snapshot")
assert body["simulated"] is False, "this test is about the branch that talks to upsd"
name = body["ups"][0]["name"]
response = nut_client_app.post("/api/ups/command",
json={"ups": name, "command": "test.panel.start"})
assert response.status_code == 200, response.text
assert response.json()["simulated"] is False
assert real_upsd_client.sent == [(name, "test.panel.start")]
assert real_upsd_client.logged_in, "instcmd requires a LOGIN first (upsd refuses otherwise)"
events = nut_client_app.get("/api/events").json()["events"]
assert any(event["kind"] == "action" and name in event["message"] for event in events)
def test_a_refusal_from_upsd_is_a_502_and_an_error_event(nut_client_app,
real_upsd_client) -> None:
real_upsd_client.refusal = nut.NutError("Access denied", code="ACCESS-DENIED")
name = wait_for_payload(nut_client_app, "/api/ups/snapshot")["ups"][0]["name"]
response = nut_client_app.post("/api/ups/command",
json={"ups": name, "command": "beeper.mute"})
assert response.status_code == 502
detail = response.json()["detail"]
assert "ACCESS-DENIED" in detail or "Access denied" in detail, detail
assert real_upsd_client.sent == [], "a refused command was never accepted"
events = nut_client_app.get("/api/events").json()["events"]
failures = [event for event in events if event["kind"] == "action"
and event["severity"] == "error"]
assert failures, "an upsd refusal has to leave a trace, not just an HTTP status"
def test_unknown_commands_are_refused_even_with_actions_on(ups_client) -> None:
name = wait_for_payload(ups_client, "/api/ups/snapshot")["ups"][0]["name"]
for command in ("load.format", "rm -rf /", "INSTCMD x y"):
response = ups_client.post("/api/ups/command", json={"ups": name, "command": command})
assert response.status_code == 400, command
def test_commands_for_an_unknown_ups_are_404(ups_client) -> None:
wait_for_payload(ups_client, "/api/ups/snapshot")
response = ups_client.post("/api/ups/command",
json={"ups": "not-real", "command": "beeper.mute"})
assert response.status_code == 404
def test_missing_fields_are_a_400(ups_client) -> None:
assert ups_client.post("/api/ups/command", json={"ups": ""}).status_code == 400
def test_actions_flag_off_blocks_every_command(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1") as client:
body = wait_for_payload(client, "/api/ups/snapshot")
name = body["ups"][0]["name"]
assert body["actions_enabled"] is False
response = client.post("/api/ups/command",
json={"ups": name, "command": "beeper.mute"})
assert response.status_code == 403
assert "NUT_ALLOW_ACTIONS" in response.json()["detail"]
def test_read_only_beats_the_actions_flag(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
nut_allow_actions=True, read_only=True) as client:
body = wait_for_payload(client, "/api/ups/snapshot")
name = body["ups"][0]["name"]
response = client.post("/api/ups/command",
json={"ups": name, "command": "beeper.mute"})
assert response.status_code == 403
commands = wait_for_payload(client, "/api/ups/commands")
assert commands["read_only"] is True
assert all(item["enabled"] is False for ups in commands["ups"]
for item in ups["commands"])
def test_ups_history_answers_for_simulated_ups(ups_client) -> None:
wait_for_payload(ups_client, "/api/ups/snapshot")
time.sleep(1.2)
body = ups_client.get("/api/ups/history", params={"metrics": "ups.charge_pct",
"range": "1h"}).json()
assert body["ups"], "the UPS page needs a name to chart before any history exists"
def response_ok(response) -> bool:
assert response.status_code == 200, response.text
return True
def test_samples_written_by_the_poller_are_queryable(tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
"""The write path (poller -> store) and the read path (/api/history) must agree on names."""
db = str(tmp_path / "roundtrip.sqlite3")
store = SampleStore(db)
store.add_many([Sample("llm", "gufo", "llm.decode_tokens_per_second", 40.0, time.time() - 60),
Sample("llm", "gufo", "llm.decode_tokens_per_second", 45.0, time.time() - 30)])
app = create_app(Settings(db_path=db), store)
with TestClient(app) as client:
metrics = client.get("/api/metrics", params={"source": "llm"}).json()["metrics"]
assert "llm.decode_tokens_per_second" in metrics
points = client.get("/api/history", params={"source": "llm", "host": "gufo",
"metric": "llm.decode_tokens_per_second",
"range": "15m"}).json()["points"]
assert [p["value"] for p in points] == [40.0, 45.0]
store.close()