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:
2026-10-23 09:52:30 -04:00
parent 44d61d3ab9
commit fa4f38c694
68 changed files with 5714 additions and 200 deletions
+77 -1
View File
@@ -28,11 +28,13 @@ from dashboards.collectors import (
disks_jobs,
fetch_disk_snapshot,
fetch_engine,
fetch_gateway_snapshot,
fetch_host_snapshot,
fetch_snapshot,
gateway_job,
host_jobs,
)
from dashboards.config import Target
from dashboards.config import Settings, Target
from dashboards.poller import Result
from dashboards.rates import RateEstimator
from dashboards.sources import nut
@@ -409,6 +411,80 @@ async def test_smart_addon_errors_reach_the_disk_payload_verbatim(
assert "degraded" not in system.payload, "and /system stays unbothered by it"
# --------------------------------------------------------------------------- #
# gateway topology (the /gateway source's fetch half, phase 29)
# --------------------------------------------------------------------------- #
async def test_the_gateway_fetch_reads_the_same_agent_snapshot_as_the_other_two(
gateway_snapshot: Any) -> None:
"""One endpoint, three readers: the pipes come out of `/snapshot` and nothing else.
The gateway job has no URL of its own (`GATEWAY_HOST` is a *name*), so this is the pin that
keeps it that way — and that the projection's own honesty rules survive the trip through the
collector: gauges under the `gateway` family, **no** events (a count has no transition worth a
line), and no `window_seconds` for a number that was never diffed (rule 2).
"""
body = gateway_snapshot("full")
seen: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/snapshot", "the gateway page has no endpoint of its own"
seen.append(request.url.path)
return httpx.Response(200, json=body)
target = Target("gateway", "http://agent.test")
async with client_for(handler) as client:
result = await fetch_gateway_snapshot(client, target, timeout=10)
assert seen == ["/snapshot"]
assert result.payload["host"] == "gateway"
assert result.payload["supported"] is True
assert result.payload["inbound"], "the configured pipes arrive with the snapshot"
assert result.samples and all(sample.source == "gateway" for sample in result.samples), (
"the gauges are their own sample family, not `host`'s")
assert result.events == [], "a connection count has no transition to report"
assert "window_seconds" not in result.payload, "a count is not a rate"
async def test_the_gateway_poll_carries_its_own_timeout_and_never_disables_one() -> None:
"""The third budget on one endpoint: the client default until the job asks for its own.
Same rule the `disks` poll was pinned with — a literal `None` means *no timeout at all* to
httpx, which would hang the only poller this source has (invariant 4) — and the reason
`GATEWAY_TIMEOUT_SECONDS` is a setting rather than a constant inherited from `system`.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True, "ts": 1.0, "errors": []})
client = TimeoutRecordingClient(transport=httpx.MockTransport(handler),
timeout=4.0, base_url="http://agent.test")
target = Target("gateway", "http://agent.test")
async with client:
await fetch_gateway_snapshot(client, target)
assert client.timeouts[-1] is httpx.USE_CLIENT_DEFAULT
await fetch_gateway_snapshot(client, target, timeout=10)
assert client.timeouts[-1] == 10, "the gateway poll waits on its own budget"
assert all(timeout is not None for timeout in client.timeouts)
async def test_the_gateway_job_is_one_job_named_after_the_host_it_reads() -> None:
"""`key`/`dashboard`/`label`/`interval` at the factory, and `None` for a name nobody dials.
The label is what an alert and an event print and what `alerts.py` uses to find the `host:` job
reading the same agent, so it is the host's name and not the source's — while the key stays the
single word `gateway` because there is exactly one such job per deployment.
"""
settings = Settings(host_targets=(Target("gateway", "https://gw.test"),),
gateway_interval=45.0, gateway_timeout=7.0)
entry = AgentTls(verify="/ca.pem", cert=("/c.pem", "/k.pem"), status=lambda: certs.ACTIVE)
async with httpx.AsyncClient() as client:
job = gateway_job(settings, client, tls={"gateway": entry})
assert job is not None
assert (job.key, job.dashboard, job.label) == ("gateway", "gateway", "gateway")
assert job.interval == 45.0
assert job.enabled is True, "a job that exists is a job that runs"
missing = Settings(host_targets=(Target("deskwork", "http://desk.test"),))
assert gateway_job(missing, httpx.AsyncClient()) is None, "unconfigured is no task at all"
# --------------------------------------------------------------------------- #
# TLS: the dashboard dials a protected exporter with its own client certificate
# --------------------------------------------------------------------------- #