phase: 31_gateway_route_readability

**Phase 31 verification complete — all 5 tasks already in `complete/`; this was the final verification pass.**

Verified / changed
- Re-ran every phase-31 artifact live and green: `verify-31-page-only.py` 6 PASS/0 FAIL; browser pass `31-route-readability.cjs` **64 PASS / 0 FAIL** (exit 0); `27-task02-mobile-audit.cjs` **371 PASS / 0 FAIL / 0 FINDING** (exit 0); rebuilt `data/logs/31-route-readability.out` as the 4-part transcript.
- Added the 4 degradation-arm screenshots the criterion asked for but the pass lacked (`31-gateway-{unconfigured,unreachable,no-nginx,sockets-quiet}.png`) — 12 frames now, one per arm.
- Grep criteria: no gateway connector rule uses `border-strong`; no verdict token in the gateway CSS section or `page-gateway.js` beyond the `alerts.py`-owned `.warn`; exactly 4 `addEventListener` calls (none for the switch); `routeInConfig()` returns `true`; **no `.py` under `src/dashboards/` changed**.

Tests / lint (all 100% green)
- `uv run pytest` → **1796 passed, 16 skipped**; `--cov` → **98.94%** (floor 94); `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `bash .agents/validate.sh` → **exit 0**.
- `uv run pytest -m live --live` → **12 passed / 0 failed**; `./scripts/dev-server.sh status` → all **10 sources ok**.

Completion criteria: colour (33/33 rows every segment `--gate-on`, dark `rgb(63,185,80)` + light `rgb(26,127,55)`, zero grey dashes) ✔ · inbound-first rows + filter (33/33 line 1 = `out.selectors`, line 2 = `→ host:port · kind`; `stackexpected` → **6** rows, not the overview's "five" — the capture declares 6 such routes; tallies byte-identical `10/33/16/0`, zero requests) ✔ · trunk motion truth table + `null` arm prints `n/a` never `0` + reduced-motion stills 43 paths and every knob ✔ · screenshots/transcripts ✔ · payload page-only ✔ (literal byte-diff of a live socket table can never pass; answered by the no-`.py`-diff + shape-equality proof, as recorded in the transcript) · no behaviour change elsewhere ✔.

Docs: fixed 6 inconsistencies — two identifiers split across line breaks (`test_the_pipe_animation_stills_in_both_states_under_reduced_motion`, `32_gateway_resolved_target_liveness`), stale "sixteen routes" count, stale "four machines/hosts" in README **and** AGENTS (fleet is three), "three fields"→"three field types", gap-14 headline wording; updated the pass's screenshot list.

Deviations: `uv pip list --outdated` shows one advisory (`starlette 1.9.0 → 1.10.0`) — not upgraded, this phase moved no dependency (rule 8 / DoD apply "if deps moved"). `TODO.md` carries two new operator disk requests, left untouched.

Next pending phase: `32_gateway_resolved_target_liveness`.
This commit is contained in:
2026-10-25 05:32:30 -04:00
parent e366f599f3
commit 25fd74b7c5
52 changed files with 3434 additions and 181 deletions
+50 -13
View File
@@ -10,7 +10,7 @@ file:
| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page |
| `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only |
| `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) |
| `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured endpoints branching off it, each line labelled with the config's own word for the route (`server_name`, `server`, `map` key) and its live connection count | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
| `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) on line 1 over the counterpart it routes to on line 2 (`→ host:port`), plus its live connection count — and one filter box above the map that hides the routes whose own labels do not contain what you typed, asking nothing of the server | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
runtime; every asset is served by this app.
@@ -732,7 +732,7 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
`alerts.py` is why one dead agent is still one line on the overview rather than two. The gateway
page's job (phase 29) is the **third** reader of that same `/snapshot`, and one job for the whole
deployment rather than one per host — `GATEWAY_HOST` names the single ingress, and a job per host
would ask four machines a question only one can answer — on its own 30 s cadence with no
would ask every machine a question only one of them can answer — on its own 30 s cadence with no
`RateEstimator`, because a connection count is not a rate. And it never quietly *stops*: the task
is that source's only poller, so a SQLite write that gets refused is reported (`ok: false`, the
real reason, the numbers aging, one line in the event log) instead of freezing the source under a
@@ -1078,21 +1078,54 @@ more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
what routes out of each, and how many connections are live on each. It is **two panels** — one per
config file, `nginx` then `haproxy` — each hidden until a poll actually has rows for it, so a router
with no nginx in it spends no panel saying so. Inside a panel the rows are **groups**: a *trunk* is
one port that file binds, and hanging off it are the *leaves* — the endpoints it names, the endpoint
**exactly as the config wrote it** with the config's own word for the route above it (a `server_name`,
a haproxy `server` name, a stream `map` key). That is the shape of the file, which is why the stream
one port that file binds, and hanging off it are the *leaves* — the routes that port's config names,
each reading **inbound first**: line 1 is the config's own word for who reaches the endpoint (a
`server_name`, a haproxy `backend` name, a stream `map` key, the literal `default`), and line 2 is
the counterpart it flows to, **exactly as the config wrote it**, marked `→ host:port` with the kind
of route after it. Both ends are verbatim, and both are carried whole on the row's tooltip and the
line's accessible name whichever one CSS has to ellipsise. That is the shape of the file, which is
why the stream
`map` is not a third panel: its entries are the nginx panel's `:443` leaves, so every SNI domain
reads beside the port it reaches. A trunk that names no target — this fleet's `:80` `return 301` —
prints "no target named in the config" instead of drawing nothing. Each line is joined to its
counterparts: nginx's ports **branch** — a dashed spine down the fan-out with a tick into every
endpoint — and haproxy's one-`server` frontends run **straight across**, because a port with one
route behind it is not a branch. A line is **green because the config declares the route**, an
existence colour of the page's own, and it **animates only while it carries at least one live
connection** — motion is the socket, colour is the config, and a resting route keeps the same green
as a busy one. **Nothing on the page is coloured by a verdict** — a busy ingress is the job, so
`severity.py` rates no `gateway.*` metric and no number gets a green/amber/red tint. A line to an endpoint the config wrote as a **name** prints `—`, never `0`:
counting it would need a DNS lookup and the join never asks one, and the row's tooltip says which of
the two facts it is looking at. The header's four tallies are `ports`, `connections`, `live` and
route behind it is not a branch. A route is **green because the config declares it**, an existence
colour of the page's own, and that green covers **every segment of the row** — spine, tick, line and
switch all read one class on the row, so no part of an active route is drawn as grey plumbing — while
the line **animates only while it carries at least one live connection**: motion is the socket,
colour is the config, and a resting route keeps the same green as a busy one. The trunk carries a
**short line of its own** for that reason, and it reads from a *different* field: a leaf moves on its
endpoint's count, the trunk's stub moves on `listeners[].connections` — the sockets whose **local**
end is that port, which `/proc` states whether or not the route's target is a resolvable address. So
a busy port is visibly busy even when every leaf under it carries a `—`, which is the normal look of
this fleet's `:443`. That line's claim is about **the port and never about one route**: its
accessible name and tooltip say `:4443 — 9 connections are open on this port right now` (or
`no connections…`, or `connections on this port are unknown: the socket table did not answer`) and
name no endpoint, and the unknown arm prints `n/a` where the count is and no motion at all — never
`0`, and never a dash, because "the socket table went quiet" is a different fact from "this target
is a name".
Above the map, one **filter box** finds a domain among 33 routes (`stackexpected`, `brain`, `4443`,
`minecraft`) because the rows are in config order and the question is always about one name. It hides
what the payload already sent and asks for **nothing**: no request, no re-derivation, no new route.
It matches the strings the config wrote — the inbound word(s), the endpoint, the port, the haproxy
`server` name, and a trunk's own domains and client addresses — case-insensitively, so a label CSS
ellipsised still matches its whole text. A trunk that matches keeps its **whole fan-out**: typing
`4443` asks for the port, and every route hanging off it is the answer. `Escape` clears it, a chip
beside the field counts `N of M routes`, and a query that matches nothing leaves both panels standing
with `no route matches "…"` in each. What it never touches is the header's four tallies, the warnings
panel, or the charts — those describe the ingress, not the operator's current view of it — and it
never **groups, sorts or colours** by domain suffix: `reeseapps.com` versus `stackexpected.com` is a
fact neither config file states, so the operator types the suffix and the page hides the rest rather
than inventing a zone no proxy declared.
**Nothing on the page
is coloured by a verdict** — a busy ingress is the job, so `severity.py` rates no `gateway.*` metric
and no number gets a green/amber/red tint. A line to an endpoint the config wrote as a **name**
prints `—`, never `0`: counting it would need a DNS lookup and the join never asks one, and the row's
tooltip says which of the two facts it is looking at. Expect that on most of this ingress's routes —
31 of its 33 are written as domains — and read it as *drawn, not counted* rather than as a fault; the
footer says the same thing once, in plain terms, because a dash per row needs one explanation and not
thirty-two. The header's four tallies are `ports`, `connections`, `live` and
`warnings`, every one of them the payload's own figure. A `warnings` count above zero raises a panel
above the map with the config-integrity rows (`a backend no frontend routes to`, a `proxy_pass` with
no `upstream` behind it, an SNI entry that reaches no address) in the overview's alert vocabulary;
@@ -1105,7 +1138,11 @@ more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
for a screen reader (`endpoint, reached by <selector>, in the nginx config, on`) — and it is
**unwired on purpose**: `aria-disabled` with a tooltip that says so, and this app writes to no
upstream, so a route is disabled by editing `haproxy.cfg` or an nginx `server` block and by nothing
in a browser.
in a browser. Giving either of those two dashes or knobs a real backing store is deliberately **not**
documented here as a thing you can have: name-based liveness needs the agent to report resolved
targets (a future agent version), and switching a route needs a second gated write path in front of
the proxy configs, which is a change to this app's read-only promise rather than a feature. What runs
today is **agent 1.2.0**, no name resolution, and no switching.
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.