**Phase 02 final verification pass — all three tasks already complete; verified criteria, fixed 3 defects found live.**
- Verified: `/` = exactly 2 `<section>`s (Warnings & errors + Fleet), alerts render from `/api/summary` verdicts with each alert's own `href`; `/system`, `/llm`, `/ups` unchanged (charts + data live).
- Fixed (`src/dashboards/alerts.py`): a dead source's message printed the name twice — `gateway: UpstreamError: gateway: http://…`; the poller's `UpstreamError:` wrapper is now stripped (other exception names kept). Tests: 2 new in `tests/test_alerts.py`.
- Fixed (`src/dashboards/api.py`): `/api/summary` dropped a configured host/engine that had never answered post-start, so the fleet said `2/2 hosts` while a third was down. Stub rows (`unreachable: true`, all `n/a`) now match `/api/*/snapshot`; `hosts_total=3 / hosts_live=2`. Test: `test_a_host_that_has_never_answered_is_still_a_row_in_the_fleet`.
- Fixed (`web/static/page-index.js`): fleet row of a non-answering host is now dimmed with the shared `.stale` class (invariant 4 on `/`). Test: `test_overview_dims_the_fleet_row_of_a_host_that_stopped_answering`.
- `bash .agents/validate.sh` exit 0: `uv run pytest --cov --cov-fail-under=94` → **439 passed, 6 skipped, 96.82%** (`alerts.py` 100%, `api.py` 100%); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check web/static` clean; hygiene clean; `uv pip list --outdated` empty; `uv run pytest -m live --live` → **6 passed**.
- Live proof (tunnel kill, then restored — all 6 sources `ok` again): killing the gateway forward produced exactly **one** alert, `fail gateway /system … unreachable (ConnectError)`; screenshots `data/logs/phase02-final-{alert,system,llm,ups,recovered}.png`.
- Completion criteria: two panels ✅ · `[]` when healthy ✅ (hermetic `healthy_client` + a live gateway-only instance returned `[]`; the real fleet can never be `[]` — its UPSes permanently warn on battery age/self-test) · one alert per dead host with `/system` link ✅ · pytest/live/coverage/ruff/pyright ✅ · no change on `/system`,`/llm`,`/ups` ✅ · phase 01 verdict vocabulary intact ✅.
- Docs: README `web/` bullet now states the dimmed `n/a` row; AGENTS.md invariant 4, `alerts.py`/`api.py` bullets, UI-conventions stale-row + window-chip ranges (5m…30d vs 1h…90d), metric example `llm.decode_tps` → `llm.decode_tokens_per_second` (verified against `/api/metrics`), test count 435 → 439. Env table, Containerfile, agent/install-script claims, fleet facts re-checked and consistent.
- Notable: left the fleet's real hostnames/domain in AGENTS.md's "real environment" section (deliberate, prior-phase content) even though rule 6 discourages personal hostnames in tracked files — worth a separate decision; no live config was changed beyond stopping/starting a local dev ssh forward.
- Next pending phase: `.agents/phases/todo/03_system_per_host_panels/`.
125 lines
4.4 KiB
Python
125 lines
4.4 KiB
Python
"""Shared fixtures: real captures for shape, fakes for behaviour.
|
|
|
|
Anything that must match an upstream (an agent snapshot, an engine's /metrics text, the NUT
|
|
wire protocol) is asserted against a verbatim capture in tests/fixtures/. Everything that
|
|
needs a *failure* — a dead host, a refused command, a counter reset — is a fake here.
|
|
|
|
The `live` marker is the bridge to real hardware: those tests dial the fleet, the engines,
|
|
or upsd, so they SKIP unless you pass `--live` and have set the env vars they read (see
|
|
tests/test_live_fleet.py). That keeps `uv run pytest` hermetic and offline while still
|
|
letting `uv run pytest -m live --live` catch an upstream that renamed something.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
|
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
parser.addoption(
|
|
"--live",
|
|
action="store_true",
|
|
default=False,
|
|
help="run tests marked `live` against the real fleet/engines/upsd (they skip otherwise)",
|
|
)
|
|
|
|
|
|
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
|
if config.getoption("--live"):
|
|
return
|
|
skip = pytest.mark.skip(reason="needs --live and a reachable target (see tests/test_live_fleet.py)")
|
|
for item in items:
|
|
if "live" in item.keywords:
|
|
item.add_marker(skip)
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SRC = ROOT / "src"
|
|
if str(SRC) not in sys.path:
|
|
sys.path.insert(0, str(SRC))
|
|
|
|
from dashboards.rates import RateEstimator # noqa: E402
|
|
from dashboards.sources.host import HostCollector, parse_host_snapshot # noqa: E402
|
|
from dashboards.store import SampleStore # noqa: E402
|
|
|
|
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
|
|
|
|
|
@pytest.fixture
|
|
def db(tmp_path: Path) -> Any:
|
|
store = SampleStore(tmp_path / "t.sqlite3")
|
|
try:
|
|
yield store
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def store(db: Any) -> Any:
|
|
"""Alias: some tests read better with `store`, others with `db`. Same object."""
|
|
return db
|
|
|
|
|
|
@pytest.fixture
|
|
def agent_snapshot() -> dict[str, Any]:
|
|
"""A real driveripper snapshot (Threadripper + R9700, 48 logical CPUs)."""
|
|
return json.loads((FIXTURES / "agent_driveripper.json").read_text())
|
|
|
|
|
|
@pytest.fixture
|
|
def agent_snapshots() -> dict[str, dict[str, Any]]:
|
|
"""Every captured host, keyed by name — fleet code paths need >1 host to mean anything."""
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for path in sorted(FIXTURES.glob("agent_*.json")):
|
|
out[path.stem.removeprefix("agent_")] = json.loads(path.read_text())
|
|
return out
|
|
|
|
|
|
@pytest.fixture
|
|
def host_scrape() -> Any:
|
|
"""`host_scrape(snapshot, cpu_busy=10.0, mutate=None)` -> one parsed host payload.
|
|
|
|
Two scrapes are needed before any rate exists (the agent reports counters; the
|
|
dashboard owns the window), and the payload the API serves is the second one. `mutate`
|
|
edits the snapshot before both scrapes so a test can put a value on a chosen side of
|
|
`severity.THRESHOLDS`; `cpu_busy` sets the CPU percentage over the window directly.
|
|
Driven from the real capture so a schema drift breaks here rather than in a browser.
|
|
"""
|
|
def scrape(snapshot: dict[str, Any], *, name: str = "driveripper", cpu_busy: float = 10.0,
|
|
mutate: Any = None) -> dict[str, Any]:
|
|
snap = copy.deepcopy(snapshot)
|
|
if mutate is not None:
|
|
mutate(snap)
|
|
collector = HostCollector(RateEstimator(span=60.0))
|
|
parse_host_snapshot(name, snap, collector, now=1_000.0)
|
|
second = copy.deepcopy(snap)
|
|
second["ts"] = 1_010.0
|
|
modes = second["cpu"]["modes"]
|
|
# busy = (Δtotal - Δidle)/Δtotal, and iowait is left untouched so it rates at 0
|
|
modes["total"] += 1_000.0
|
|
modes["idle"] += 1_000.0 - (cpu_busy * 10.0)
|
|
return parse_host_snapshot(name, second, collector, now=1_010.0).payload
|
|
|
|
return scrape
|
|
|
|
|
|
@pytest.fixture
|
|
def metrics_text() -> Any:
|
|
"""metrics_text('gufo') / metrics_text('vllm') -> the captured exposition verbatim."""
|
|
def load(engine: str) -> str:
|
|
path = FIXTURES / f"metrics_{engine}.txt"
|
|
if not path.exists():
|
|
pytest.skip(f"missing fixture {path.name} — run scripts/capture-fixtures.sh --with-llm")
|
|
return path.read_text()
|
|
|
|
return load
|
|
|
|
|
|
@pytest.fixture
|
|
def rates() -> RateEstimator:
|
|
return RateEstimator(span=10.0)
|