phase: 29_gateway_dashboard_page
Phase 29 completion pass is done. Final state:
**Verification (all re-run this pass, no task files touched)**
- `uv run pytest` → **1746 passed, 16 skipped**; `uv run pytest --cov` → **99.05 %** (gate 94 %); `bash .agents/validate.sh` → **rc=0** (ruff clean, pyright `0 errors, 0 warnings`, `node --check` clean); `uv run pytest -m live --live` → **12 passed**.
- Live, read-only: dev server **10/10 sources `ok`** (incl. `gateway`); `data/logs/29-task06-live.cjs` → **49 PASS / 0 FAIL** with per-port inbound counts reconciled against a direct `/proc/net/tcp{,6}` read (divergence 0); `data/logs/verify-29-task06-predates.py` → 0 failed (1.1.0 arm renders `WHY_PREDATES`, no pipes, every tally `n/a`, job still `ok`); phase-27 mobile audit re-run over all nine served pages → **371 PASS / 0 FAIL / 0 FINDING** (`data/logs/29-final-mobile-audit.out`).
- Criteria: (1) unconfigured `GATEWAY_HOST` → `404 {configured:false}` with dial spies + `#gate-empty` sentence ✅; (2) 1.1.0/`--no-proxy` → `supported:false`, no `0`s, never red ✅; (3) live gateway 1.2.0 → pipes animate, totals reconcile ✅; (4) no `severity`/verdict colour anywhere, validate green ✅; (5) docs updated + gap 13 logged ✅; (6) the five existing dashboards, jobs and `/api/summary` unchanged (live control leg) ✅.
**Docs (consistency pass, incl. pre-existing drift)**
- `README.md`: architecture diagram now lists `sources/gateway.py`, the 30 s gateway job and the third `/snapshot` reader; `GATEWAY_HOST` row says *a name, never a URL*; the `POLL_MS_*` row states `/gateway`'s fixed 15 s browser poll; "nine pollers" → per-source cadences; phase-28 "until phase 29" wording → current state; new Testing paragraph for `tests/test_gateway_source.py` (join pinned from the captures, reconciliation identity, `getaddrinfo` spy).
- `AGENTS.md`: phase-28 stop-point wording replaced with the shipped phase-29 state; suite/coverage figures → 1746 / 16 skipped / 99.08 %→99.05 % band and `~99 %`; window-chips paragraph records that `/gateway` has **no** chips (fixed 24 h); new testing-policy bullet for the join; stale page counts fixed (seven → eight tabbed pages / `MARKUP_PAGES`, clock-order and badge-count transcripts annotated); gap 12 now records the phase-29 nine-page audit re-run.
- `tests/test_api.py`: comment/docstring-only fixes for the "seven pages" wording that phase 29 made eight.
**Deviations / notes**
- Nothing implemented: all six task files were already in `complete/`, so this run was the final verification + docs pass; no git index or phase file was touched.
- Coverage wobbles 99.05–99.08 % between runs on a pre-existing timing-dependent line (`collectors.py` `/slots`-alias `except`/`break`), not a phase-29 path (`sources/gateway.py` is 100 %).
- `uv pip list --outdated` still lists upgrades; dependencies did not move in this phase, so they were left alone rather than upgraded mid-verification.
- Historical browser-transcript counts (phase 18/20/25 script runs) were left as recorded history and annotated instead of rewritten.
**Next phase:** none — `.agents/phases/todo/` is empty after 29; further work needs a new phase authored first.
This commit is contained in:
+79
-1
@@ -14,9 +14,10 @@ are the ones that make the landing page trustworthy rather than merely busy:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from dashboards import alerts
|
||||
from dashboards import alerts, severity
|
||||
from dashboards.config import Settings
|
||||
from dashboards.sources import nut
|
||||
from dashboards.sources.nut_sim import NutSimulator
|
||||
@@ -481,6 +482,83 @@ def test_a_dead_upsd_is_one_alert_and_not_its_stale_cards_too() -> None:
|
||||
assert [item["source"] for item in out] == ["NUT server"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the gateway page: reachability is the only voice, and a dead agent is still one line
|
||||
# --------------------------------------------------------------------------- #
|
||||
def gateway_state(**kwargs: Any) -> dict[str, Any]:
|
||||
"""The health record of the one `gateway` job (the `nut` shape: key `gateway`, label = host)."""
|
||||
return state("gateway", "gateway", "gateway", **kwargs)
|
||||
|
||||
|
||||
def test_a_stale_gateway_job_is_a_warn_measured_in_its_own_thirty_second_poll() -> None:
|
||||
"""The pipes are drawn from a 30 s poll, so 60 s of silence is two healthy polls.
|
||||
|
||||
Judging this source by the 10 s system interval would amber the landing page every minute for
|
||||
a job behaving exactly as configured — the same mistake the `disks` rule exists to avoid, one
|
||||
cadence later.
|
||||
"""
|
||||
settings = Settings(gateway_interval=30.0)
|
||||
assert alerts.build(settings, [gateway_state(age=60.0)]) == []
|
||||
out = alerts.build(settings, [gateway_state(age=100.0)])
|
||||
assert out[0]["severity"] == "warn"
|
||||
assert out[0]["href"] == "/gateway", "the overview's row must link to the page it describes"
|
||||
assert "every 30s" in out[0]["message"], "the message prints the gateway's own interval"
|
||||
|
||||
|
||||
def test_a_gateway_job_that_fails_while_its_host_answers_is_the_only_voice_it_has() -> None:
|
||||
"""A wedged proxy/socket section is a fault nothing else can report (the `disks` rule, once more)."""
|
||||
out = alerts.build(SETTINGS, [
|
||||
gateway_state(ok=False, error="UpstreamError: gateway: timed out"),
|
||||
state("host:gateway", "system", "gateway"),
|
||||
], now=2_000.0)
|
||||
assert len(out) == 1
|
||||
assert out[0]["source"] == "gateway"
|
||||
assert out[0]["href"] == "/gateway"
|
||||
assert "timed out" in out[0]["message"]
|
||||
|
||||
|
||||
def test_a_gateway_job_that_failed_because_its_agent_is_down_is_one_line_not_two() -> None:
|
||||
"""Three jobs dial one agent; the operator has one problem, so the overview says it once."""
|
||||
down = {"ok": False,
|
||||
"error": "UpstreamError: gateway: http://h:9120/snapshot unreachable (ConnectError)"}
|
||||
out = alerts.build(SETTINGS, [
|
||||
gateway_state(**down),
|
||||
state("disks:gateway", "disks", "gateway", **down),
|
||||
state("host:gateway", "system", "gateway", **down),
|
||||
], now=2_000.0)
|
||||
assert len(out) == 1, "the echo of one outage must not read as three problems"
|
||||
assert out[0]["id"].startswith("gateway:host:"), out[0]["id"]
|
||||
|
||||
|
||||
def test_an_unconfigured_gateway_source_says_nothing_even_when_its_job_says_down() -> None:
|
||||
"""No `HOST_TARGETS` entry named `GATEWAY_HOST` means no job — and a reported one stays quiet."""
|
||||
out = alerts.build(Settings(gateway_interval=30.0),
|
||||
[gateway_state(ok=False, configured=False, error="not configured")],
|
||||
now=2_000.0)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_a_gateway_payload_with_a_fabricated_verdict_alerts_nothing() -> None:
|
||||
"""The phase's no-verdict rule, asserted as a rule and not only as an absence.
|
||||
|
||||
Three ways, because deleting a rule satisfies an absence-only assertion:
|
||||
|
||||
* `alerts.build` takes no gateway payload at all — its signature is the whole surface, so a
|
||||
gateway count can reach an alert only through a threshold added to this module;
|
||||
* `severity.THRESHOLDS` has no `gateway.*` row, so no such threshold could be written without
|
||||
editing the one file that owns thresholds (and a busy proxy is the job, the `net.*` case);
|
||||
* the module's `HREF` does carry `/gateway`, so reachability still reaches the overview.
|
||||
"""
|
||||
assert not {"gateway", "gateway_payload"} & set(inspect.signature(alerts.build).parameters)
|
||||
assert not [name for name in severity.THRESHOLDS if name.startswith("gateway.")], (
|
||||
"a connection count is not a graded number")
|
||||
assert alerts.HREF["gateway"] == "/gateway"
|
||||
assert alerts.AGENT_ECHO_DASHBOARDS == ("disks", "gateway")
|
||||
# and an answering gateway with nothing else going on is the all clear
|
||||
assert alerts.build(Settings(gateway_interval=30.0), [gateway_state()], [], [], None, [],
|
||||
now=2_000.0) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# configuration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user