**Phase 03 final verification pass — all 4 tasks already in `complete/`; verified criteria, fixed 3 pre-existing defects.**
- **Verified live** (headless Chromium + curl against `:8090`, all 6 sources `ok`): 3 panels × six heroes in order `cpu,mem,temp,disk,gpus,net`; charts per panel `util,net[,gpu]` (gateway: no GPU chart/hole); legends name metrics only — no legend names a foreign host; every `<svg>` `role="img"` + host-scoped `aria-label`; 2 sections, 0 `<select>`; `grep -rn "renderCores|core_usage" src/dashboards/web/` → nothing; console error-free; new shot `data/logs/final-system-panels.png`.
- **Defect fixed (credential hygiene, invariant 6)**: `collectors.py` put the *dialed* URL into every `UpstreamError`, and that string lands in the health record (`/api/health`) and the `event` table (`/api/events` → page); `/api/config` also echoed `GUFO_BASE_URL`/`VLLM_BASE_URL` verbatim. Added `config.strip_userinfo()` (now what `Target.display_url` uses) and redacted all message/config URLs. Tests: `test_a_failing_host_never_echoes_its_token_in_the_message` (×3 paths), `test_a_failing_engine_never_echoes_credentials_in_its_url`, `test_a_dead_host_never_writes_its_token_into_the_feed`, `test_engine_urls_are_redacted_the_same_way_hosts_are`, `test_strip_userinfo_is_the_one_redaction_every_url_leaves_by`. (`parse_targets` already moves `HOST_TARGETS` userinfo into a header, so this is a backstop for hosts and a real fix for engine URLs.)
- **Defect fixed (invariant 2)**: `/system` footnote printed `Δ{HOST_INTERVAL}` as the measurement; it now prints each host's measured `window_seconds` (range when they disagree) — `rates over Δ10.0s · poll 10s`. Pinned by `test_system_footnote_prints_the_window_it_actually_measured`. Also fixed the page's stale "window in each header" note and the empty-state's `--token` (argv) advice → `--token-file`.
- `bash .agents/validate.sh` → **EXIT=0**: **472 passed, 7 skipped**, coverage **96.87%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` clean · `uv run pytest -m live --live` → **7 passed** · `uv pip list --outdated` empty.
- Criteria: no multi-host legend ✅ · no per-core visual ✅ · six heroes/panel incl. no-GPU `n/a` uncoloured ✅ · per-core history still queryable ✅ (354 pts) · ≥3 shape screenshots ✅ (`system-driveripper/deskwork/gateway.png` + dark/light/stale) · pytest+coverage+lint ✅ · phases 01/02 unchanged (their pin tests + `/api/summary` shape tests pass; live UPS gap in AGENTS.md re-confirmed read-only against real upsd) ✅.
- **Docs**: AGENTS.md — invariant 6 rewritten to cover message/event redaction, `collectors.py` line, new "Δ is a measurement" rule, "hosts answering total" → "nine-figure fleet-totals panel" (the subtitle still counts hosts), test count 452→472, coverage 96.8→96.9, rewrapped an over-long line. README.md — same fleet-totals wording, `RATE_WINDOW_SECONDS` now covers every measured rate (not just tokens), agent route list completed (`/`, `/schema`, `?top=N`), `LIST CMD` in the diagram, new paragraph on URL redaction.
- **Deviation**: the criterion's literal `curl …/api/system/history?host=…&metric=cpu.usage_pct@core7` uses singular params that endpoint has never accepted (it takes `hosts=`/`metrics=` and ignores unknown ones). Verified 354 points via `…system/history?hosts=driveripper&metrics=cpu.usage_pct@core7&range=1h` and `/api/history?source=host&host=…&metric=…`; I did **not** add singular param aliases (unauthorized API surface). No live infra changes made — reads only.
- **Next pending phase**: `04_llm_chart_readability`.
289 lines
14 KiB
Python
289 lines
14 KiB
Python
"""The payload contract behind the `/system` hero cards (phase 03, task 01).
|
|
|
|
Phase 03 turns `/system` into one panel per host with six big colour-coded numbers. Before
|
|
any of it is rendered, the data has to be proven present — otherwise a card that shows `n/a`
|
|
gets "fixed" by inventing a field, and the field it invents is a number the agent never
|
|
reported. This file drives the verbatim captures in `tests/fixtures/` through
|
|
`parse_host_snapshot()` (and through a real app), so a rename breaks here rather than in a
|
|
browser at 2am (`AGENTS.md` invariant 7).
|
|
|
|
The path table itself lives in `tests/card_contract.py` so the live suite asserts the same
|
|
list against real agents (`tests/test_live_fleet.py::test_every_host_serves_the_hero_card_contract`)
|
|
and the two cannot drift apart.
|
|
|
|
What is **deliberately not on the card** — a legibility decision, not a missing feature:
|
|
per-core usage (still stored per logical CPU as `cpu.usage_pct@coreN`, see
|
|
`test_visuals_the_page_drops_are_still_stored`), swap, the process table, the raw sensor
|
|
list, and per-interface rows. Removing a plot is not removing data (PLAN.md *Dropped
|
|
visuals*), so the decision stays reversible.
|
|
|
|
Two choices are recorded here because the task asked for them in writing:
|
|
|
|
* **CPU temp comes from `cpu.temp_c`**, not `temperatures.cpu_c`. They are the same
|
|
measurement (the agent's own `cpu_temperature_celsius`) on every capture, `cpu.temp_c` is
|
|
what `/api/summary` flattens into its fleet row, and a test below keeps them equal.
|
|
* **The disk verdict lives at `disks.severity.worst_used_pct`** (phase 01 attaches severity
|
|
as a sibling dict per section, so there is no nested `disks.worst` object to hang it on).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import math
|
|
import time
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from card_contract import GPU_METRICS, HOST_SHAPES, assert_card_contract, dig
|
|
from dashboards.app import create_app
|
|
from dashboards.config import Settings, Target
|
|
from dashboards.rates import RateEstimator
|
|
from dashboards.severity import classify
|
|
from dashboards.sources.host import HostCollector, parse_host_snapshot
|
|
from dashboards.store import SampleStore
|
|
|
|
#: One scrape is not a rate, so the API-level app points at a port that refuses instantly.
|
|
DEAD_URL = "http://127.0.0.1:1"
|
|
|
|
|
|
def make_collector() -> HostCollector:
|
|
return HostCollector(RateEstimator(span=60.0))
|
|
|
|
|
|
def _bump_modes(modes: dict[str, Any], delta: float) -> None:
|
|
"""Move a /proc/stat mode set so the window reads `delta` ticks, 10 % of them busy."""
|
|
if not isinstance(modes.get("total"), (int, float)):
|
|
return
|
|
modes["total"] += delta
|
|
for idle_mode, share in (("idle", 0.8), ("iowait", 0.1)):
|
|
if isinstance(modes.get(idle_mode), (int, float)):
|
|
modes[idle_mode] += delta * share
|
|
|
|
|
|
def _advance(snapshot: dict[str, Any], dt: float = 10.0) -> dict[str, Any]:
|
|
"""The same scrape `dt` later with counters moved — the second point every rate needs."""
|
|
moved = copy.deepcopy(snapshot)
|
|
moved["ts"] = float(snapshot["ts"]) + dt
|
|
_bump_modes(moved["cpu"]["modes"], 1_000.0)
|
|
for core in moved["cpu"].get("cores") or []:
|
|
_bump_modes(core.get("modes") or {}, 1_000.0)
|
|
for iface in (moved.get("network") or {}).get("interfaces") or []:
|
|
for key, value in list(iface.items()):
|
|
if key.endswith("_total") and isinstance(value, (int, float)):
|
|
iface[key] = value + 50_000.0
|
|
return moved
|
|
|
|
|
|
@pytest.fixture
|
|
def payloads(agent_snapshots: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
"""Each capture parsed once, first scrape — the shape the cards must tolerate."""
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for name, snapshot in agent_snapshots.items():
|
|
out[name] = parse_host_snapshot(name, snapshot, make_collector(), now=1_000.0).payload
|
|
return out
|
|
|
|
|
|
@pytest.fixture
|
|
def api_client(tmp_path: Any, agent_snapshot: dict[str, Any],
|
|
host_scrape: Any) -> Iterator[TestClient]:
|
|
"""A running app serving one host payload, so the contract is checked over HTTP too."""
|
|
db = str(tmp_path / "contract.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, host_targets=(Target("driveripper", DEAD_URL),)),
|
|
store)
|
|
client = TestClient(app)
|
|
client.__enter__()
|
|
try:
|
|
# the job's port refuses, so a poll can never overwrite what is injected here
|
|
app.state.ctx.hub.jobs["host:driveripper"].state.payload = host_scrape(
|
|
agent_snapshot, cpu_busy=10.0)
|
|
yield client
|
|
finally:
|
|
client.__exit__(None, None, None)
|
|
store.close()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Every card metric has a path, on every host shape
|
|
# --------------------------------------------------------------------------- #
|
|
@pytest.mark.parametrize("name", HOST_SHAPES)
|
|
def test_every_card_metric_has_a_payload_path_on_every_host(
|
|
payloads: dict[str, dict[str, Any]], name: str) -> None:
|
|
assert_card_contract(payloads[name])
|
|
|
|
|
|
def test_the_contract_covers_every_captured_host(agent_snapshots: dict[str, dict[str, Any]],
|
|
payloads: dict[str, dict[str, Any]]) -> None:
|
|
"""A fourth capture must join `HOST_SHAPES`, or its host shape goes uncontracted."""
|
|
assert set(HOST_SHAPES) == set(payloads) == set(agent_snapshots)
|
|
|
|
|
|
def test_the_discrete_gpu_capture_supplies_real_numbers(
|
|
payloads: dict[str, dict[str, Any]], agent_snapshots: dict[str, dict[str, Any]]) -> None:
|
|
"""Not just present: correct, on the host that has all six metrics."""
|
|
payload = payloads["driveripper"]
|
|
snapshot = agent_snapshots["driveripper"]
|
|
|
|
assert payload["cpu"]["temp_c"] == snapshot["cpu_temperature_celsius"]
|
|
# the two places the CPU temperature is exposed are the same measurement, always
|
|
assert payload["temperatures"]["cpu_c"] == payload["cpu"]["temp_c"]
|
|
|
|
memory = snapshot["memory"]
|
|
assert payload["memory"]["used_pct"] == pytest.approx(
|
|
memory["used_bytes"] / memory["total_bytes"] * 100.0)
|
|
|
|
worst = max(snapshot["filesystems"], key=lambda fs: fs["used_pct"])
|
|
assert payload["disks"]["worst_mount"] == worst["mountpoint"]
|
|
assert payload["disks"]["worst_used_pct"] == pytest.approx(worst["used_pct"])
|
|
|
|
gpu = payload["gpus"][0]
|
|
assert gpu["model"]
|
|
assert gpu["busy_pct"] == pytest.approx(snapshot["gpus"][0]["busy_pct"])
|
|
assert gpu["vram_used_pct"] == pytest.approx(
|
|
gpu["vram_used_bytes"] / gpu["vram_total_bytes"] * 100.0)
|
|
|
|
# one scrape is not a rate: usage and throughput are honestly n/a until the second
|
|
assert payload["cpu"]["usage_pct"] is None
|
|
assert payload["network"]["rx_bytes_per_s"] is None
|
|
|
|
|
|
def test_measured_values_appear_on_the_second_scrape(host_scrape: Any,
|
|
agent_snapshot: dict[str, Any]) -> None:
|
|
"""The payload the API actually serves (after two scrapes) has numbers in all six places."""
|
|
payload = host_scrape(agent_snapshot, cpu_busy=33.0)
|
|
assert payload["cpu"]["usage_pct"] == pytest.approx(33.0)
|
|
assert math.isfinite(payload["network"]["rx_bytes_per_s"])
|
|
assert math.isfinite(payload["network"]["tx_bytes_per_s"])
|
|
assert payload["memory"]["used_pct"] is not None
|
|
assert payload["disks"]["worst_used_pct"] is not None
|
|
assert payload["cpu"]["temp_c"] is not None
|
|
gpu = payload["gpus"][0]
|
|
assert gpu["busy_pct"] is not None
|
|
assert gpu["vram_used_pct"] is not None
|
|
assert gpu["mem_used_pct"] is not None
|
|
|
|
|
|
def test_each_card_number_travels_with_its_own_verdict(host_scrape: Any,
|
|
agent_snapshot: dict[str, Any]) -> None:
|
|
"""The card colours a verdict from the payload; it never holds a threshold (phase 01)."""
|
|
payload = host_scrape(agent_snapshot, cpu_busy=33.0)
|
|
assert payload["cpu"]["severity"] == {
|
|
"usage_pct": classify("cpu.usage_pct", payload["cpu"]["usage_pct"]),
|
|
"temp_c": classify("cpu.temp_c", payload["cpu"]["temp_c"]),
|
|
}
|
|
assert payload["memory"]["severity"]["used_pct"] == classify(
|
|
"mem.used_pct", payload["memory"]["used_pct"])
|
|
assert payload["disks"]["severity"]["worst_used_pct"] == classify(
|
|
"disk.used_pct", payload["disks"]["worst_used_pct"])
|
|
gpu = payload["gpus"][0]
|
|
assert gpu["severity"] == {
|
|
"busy_pct": classify("gpu.busy_pct", gpu["busy_pct"]),
|
|
"mem_used_pct": classify("gpu.mem_used_pct", gpu["mem_used_pct"]),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The two host shapes that are not "a box with a discrete GPU"
|
|
# --------------------------------------------------------------------------- #
|
|
def test_the_box_with_no_gpu_reports_an_empty_list_not_a_missing_key(
|
|
payloads: dict[str, dict[str, Any]]) -> None:
|
|
"""gateway has no GPU: `gpus == []`, never a missing key and never a card at 0 % busy."""
|
|
payload = payloads["gateway"]
|
|
assert "gpus" in payload, "a host with no GPU still has the key; the page says 'no GPU'"
|
|
assert payload["gpus"] == []
|
|
# the other five numbers keep their paths, with the honest None where nothing is measured
|
|
assert_card_contract(payload)
|
|
assert payload["cpu"]["temp_c"] is None, "this agent reports no CPU sensor"
|
|
assert payload["cpu"]["severity"]["temp_c"] is None, "unknown is not ok"
|
|
|
|
|
|
def test_the_apu_reports_vram_and_the_whole_addressable_pool(
|
|
payloads: dict[str, dict[str, Any]]) -> None:
|
|
"""deskwork (Strix Halo) is why `mem_used_pct` exists: 3 % VRAM and 78 % memory, both true."""
|
|
gpu = payloads["deskwork"]["gpus"][0]
|
|
assert gpu["vram_total_bytes"] < gpu["mem_total_bytes"]
|
|
assert gpu["vram_used_pct"] < gpu["mem_used_pct"]
|
|
assert gpu["model"], "the card needs a name to put beside the numbers"
|
|
|
|
|
|
def test_paths_survive_a_host_that_reports_almost_nothing(agent_snapshot: dict[str, Any]) -> None:
|
|
"""Keys present, values None: the card must render `n/a` without a KeyError."""
|
|
snapshot = copy.deepcopy(agent_snapshot)
|
|
snapshot["cpu_temperature_celsius"] = None
|
|
snapshot["filesystems"] = []
|
|
snapshot["network"]["interfaces"] = []
|
|
snapshot["gpus"][0]["busy_pct"] = None
|
|
snapshot["gpus"][0]["vram_total_bytes"] = None
|
|
|
|
payload = parse_host_snapshot("driveripper", snapshot, make_collector(),
|
|
now=1_000.0).payload
|
|
assert_card_contract(payload)
|
|
for path in GPU_METRICS:
|
|
dig(payload["gpus"][0], path)
|
|
|
|
assert payload["cpu"]["temp_c"] is None and payload["cpu"]["severity"]["temp_c"] is None
|
|
assert payload["disks"]["worst_used_pct"] is None
|
|
assert payload["disks"]["worst_mount"] is None
|
|
assert payload["disks"]["severity"]["worst_used_pct"] is None
|
|
assert payload["network"]["rx_bytes_per_s"] is None
|
|
gpu = payload["gpus"][0]
|
|
assert gpu["busy_pct"] is None and gpu["severity"]["busy_pct"] is None
|
|
assert gpu["vram_used_pct"] is None, "no total, so no percentage — not 0 %"
|
|
assert gpu["mem_used_pct"] is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# What the cards drop stays in the database
|
|
# --------------------------------------------------------------------------- #
|
|
def test_visuals_the_page_drops_are_still_stored(agent_snapshot: dict[str, Any]) -> None:
|
|
"""Per-core / per-mount / per-interface / sensor / process data leaves the page.
|
|
|
|
It must still be *sampled*: PLAN.md's *Dropped visuals* anchor says the page stops
|
|
drawing what the operator stopped asking for while history keeps the series, so the
|
|
decision stays reversible.
|
|
"""
|
|
collector = make_collector()
|
|
parse_host_snapshot("driveripper", agent_snapshot, collector, now=1_000.0)
|
|
result = parse_host_snapshot("driveripper", _advance(agent_snapshot), collector,
|
|
now=1_010.0)
|
|
stored = {sample.metric for sample in result.samples}
|
|
|
|
assert result.payload["cpu"]["usage_pct"] == pytest.approx(10.0), "the counters moved"
|
|
cores = {metric for metric in stored if metric.startswith("cpu.usage_pct@core")}
|
|
logical_cpus = result.payload["info"]["cpu_cores_logical"]
|
|
assert len(cores) == logical_cpus, "one series per logical CPU, plotted or not"
|
|
assert "cpu.usage_pct@core0" in stored and f"cpu.usage_pct@core{logical_cpus - 1}" in stored
|
|
assert "disk.used_pct@/" in stored, "per-mount usage is still stored; the card shows worst"
|
|
assert any(metric.startswith("net.rx_bytes_per_s@") for metric in stored), (
|
|
"per-interface throughput is still stored; the card shows the totals")
|
|
assert "temp.max_c" in stored, "the sensor list is not on the card but stays in history"
|
|
assert "host.processes" in stored
|
|
# swap is not a card metric either; it is absent here only because this capture has no
|
|
# swap device (swap_total_bytes == 0), not because storing it stopped
|
|
assert result.payload["memory"]["swap_used_pct"] is None
|
|
assert "mem.swap_used_pct" not in stored
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The same contract over HTTP
|
|
# --------------------------------------------------------------------------- #
|
|
def test_snapshot_endpoint_serves_the_card_paths(api_client: TestClient) -> None:
|
|
"""The cards read `/api/system/snapshot`, so the contract is checked on that body too."""
|
|
hosts = api_client.get("/api/system/snapshot").json()["hosts"]
|
|
assert len(hosts) == 1
|
|
assert_card_contract(hosts[0])
|
|
|
|
|
|
def test_history_endpoint_still_answers_for_a_per_core_metric(api_client: TestClient) -> None:
|
|
"""`cpu.usage_pct@core7` is off the page but still queryable — dropping the plot is
|
|
not dropping the series."""
|
|
store = api_client.app.state.ctx.store # type: ignore[attr-defined]
|
|
store.add("host", "driveripper", "cpu.usage_pct@core7", 42.0, time.time())
|
|
body = api_client.get("/api/system/history",
|
|
params={"metrics": "cpu.usage_pct@core7", "hosts": "driveripper",
|
|
"range": "1h"}).json()
|
|
assert body["series"], "the per-core series must still be retrievable"
|
|
assert [point["value"] for point in body["series"][0]["points"]] == [42.0]
|