phase: 30_gateway_connection_map
**Phase 30 final verification pass — all 6 tasks already in `complete/`, so this was the phase-completion pass.** - **Verified (re-run, not trusted):** `/gateway` serves exactly two panels (nginx→haproxy, `GATEWAY_CARD_KEYS`); haproxy rows `.solo` straight across, nginx `:443` branched with one tick per leaf (18 live, fixture 17); every pipe computes the theme `--ok` in dark+light, animates only at count ≥1, fully static under `prefers-reduced-motion`; switch = `role="switch"`+`aria-checked`+`aria-disabled`, no listener, footer+title name the missing write path; zero non-GET requests anywhere. - **Live read-only re-run** (`data/logs/30-final-live-verify.out`): 17 PASS — `warnings: []`, 6 haproxy frontends→own `server`, `:443` 18 map leaves incl. `default→127.0.0.1:4443`, `:4443` 6 / `:8443` 3, `:80` bare listener, no `unmatched`/`outbound`/`by_port`/`partial`, no `9120`; inbound reconciliation **page 16 = kernel 16**; `gateway` block = the 12 declared keys; no `gateway:config` alert on a clean config. `git diff --stat agent/ deploy/` empty; agents 1.2.0 (gateway) / 1.1.0 ×2, all `tls`, `bundle_rev 2`. - **Tests/lint (exact):** `uv run pytest` → **1791 passed, 16 skipped**, exit 0 · `bash .agents/validate.sh` → **exit 0**, `--cov` **98.94%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` all page scripts, hygiene clean · `uv run pytest -m live --live` → **12 passed** · `uv pip list --outdated` empty · `./scripts/dev-server.sh status` → **10 sources ok**, `gateway` included · browser pass `30-connection-map.out` 63 PASS/0 FAIL (5 screenshots at 1440/360) · `30-final-mobile-audit.out` 371 PASS/0 FAIL/0 FINDING, exit 0, nine pages. - **Docs fixed:** README `alerts.py` bullet omitted phase 30's keyword-only `gateway_payload` and its one `warn` config-integrity row (+suppression) — added; overview ingress line labelled a dated reading. AGENTS gap 13 corrected 391 px → **361 px** (task 05's pill took 30 px), "task 06 measured" → "phase 29 measured", coverage figure un-pinned to 98.9 %, live-suite count noted (12 today), "all five pages" severity sentence clarified for `/gateway`'s existence-only palette. Everything else reviewed in full and **verified consistent**. - **Defect fixed:** duplicated footer-assertion block at the end of `tests/test_api.py::test_the_switch_is_built_and_explicitly_unwired` (one copy removed). - **Notable:** `NOTES.md` is empty in the working tree — both items in it (non-mTLS agents, disappearing admin links) landed in phases 24 and 20; left cleared. Fixture/live `map`-count drift (17 vs 18) is recorded in AGENTS.md, not silently re-captured. - **Next pending phase:** none — `30_gateway_connection_map` was the only entry in `.agents/phases/todo/`.
This commit is contained in:
@@ -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 the traffic goes — the ingress's listeners, `server_name`s and SNI map joined to its live connections | 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 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 |
|
||||
|
||||
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
|
||||
runtime; every asset is served by this app.
|
||||
@@ -207,8 +207,10 @@ not know its domain or its backend, so the **gateway page** is what joins these
|
||||
in `sources/gateway.py`, never in the agent: `/snapshot` keeps the three sections as the agent read
|
||||
them, `/api/gateway/snapshot` serves the joined topology, and only the counts that join produces are
|
||||
stored — as the `gateway.in_conns@<port>` / `gateway.out_conns@<host>:<port>` gauges the page
|
||||
charts. Nothing in `/api/system/snapshot` grows a proxy or socket field, and no `sample` row is
|
||||
written for a config.
|
||||
charts (since phase 30 task 02 the outbound gauge exists **only** for an endpoint the config wrote as
|
||||
an address: the name-keyed ones were a chart of the port-only guess the join no longer makes, so they
|
||||
would be permanently empty). Nothing in `/api/system/snapshot` grows a proxy or socket field, and no
|
||||
`sample` row is written for a config.
|
||||
|
||||
All three parsers are pinned to captures rather than to descriptions, and
|
||||
`./scripts/capture-gateway-config.sh` re-records them over the `GATEWAY_SSH` alias (default
|
||||
@@ -806,9 +808,9 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
**no row on purpose**: a non-zero counter is an alarm chip on presence, zero is the absence
|
||||
of a chip and never a green "healthy drive", and an odometer has no bad value.
|
||||
* `alerts.py` — "what is wrong right now", as data. `alerts.build(settings, job_states,
|
||||
host_payloads, engine_payloads, ups_payload, recent_events)` is a pure projection of the
|
||||
state the poller already keeps plus the verdicts `severity.py` and the UPS health checks
|
||||
already reached — no I/O, no thresholds of its own — and `/api/summary` returns it as
|
||||
host_payloads, engine_payloads, ups_payload, recent_events, *, gateway_payload=None)` is a pure
|
||||
projection of the state the poller already keeps plus the verdicts `severity.py` and the UPS health
|
||||
checks already reached — no I/O, no thresholds of its own — and `/api/summary` returns it as
|
||||
`alerts`, worst first: `{id, severity, source, message, href, at}`. A configured source
|
||||
that stopped answering is `fail`; one that is stale but still serving its last good value
|
||||
is `warn`; an **unconfigured** source never alerts; a dead source is one line rather than
|
||||
@@ -819,6 +821,13 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
answers* keeps its own voice and links to the page that explains it (`/disks` for a wedged SMART
|
||||
pass, `/gateway` for a proxy or socket section that went quiet). Staleness is measured in each
|
||||
source's own interval, so the gateway page goes amber after 90 quiet seconds and not after 30.
|
||||
The keyword-only `gateway_payload` (phase 30 task 03) is the one non-reachability voice this
|
||||
module has for that source: a non-empty `warnings` list — the two config files not connecting to
|
||||
themselves — is **one** `warn` row (`N proxy config problem(s) to address — <first message>`,
|
||||
`href` `/gateway`), because a dangling backend is a structural fact and not a graded number, and
|
||||
it is emitted only while the job is configured, `ok` and not stale, so a router that has stopped
|
||||
answering keeps its single reachability line instead of growing a second one about a reading the
|
||||
operator cannot refresh. No count of connections can produce a row here at all.
|
||||
* `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`,
|
||||
`/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per
|
||||
dashboard (`/api/system/*`, `/api/disks/*`, `/api/gateway/*`, `/api/llm/*`, `/api/ups/*` —
|
||||
@@ -860,25 +869,50 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
was, and the key paths of both are pinned with and without targets configured. Since phase 22
|
||||
that block lists the **same hosts** `/api/disks/snapshot` does, and `fleet.hosts_total` is
|
||||
that row count, so `/` and `/disks` can never describe two different fleets. `/api/summary` grew a
|
||||
`gateway` block the same way in phase 29 (`configured`, `host`, `supported`, `unreachable`,
|
||||
`inbound`, `outbound`, `map_targets`, `unmatched`, `unmatched_listeners`, `partial`, `states`):
|
||||
the ingress's own tallies, `null` and never `0` in every arm that did not measure, `partial: true`
|
||||
when a total had to leave an unattributable pipe out, and no `severity` anywhere — a connection
|
||||
count is not a graded number. `/api/config` carries the matching row, including
|
||||
`gateway` block the same way in phase 29, reshaped by phase 30 task 02 (`configured`, `host`,
|
||||
`href`, `supported`, `unreachable`, `listeners`, `connections`, `live_in`, `live_out`,
|
||||
`map_targets`, `states`) and given `warnings` by task 03: what the configs declare (`listeners`,
|
||||
`connections`, `map_targets`, `warnings`) beside what the sockets are doing (`live_in`, `live_out`
|
||||
— the latter summed over **distinct endpoints**, since several routes reaching one address is one
|
||||
count and not two). `null` and never `0` in every arm that did not measure — **except**
|
||||
`warnings`, which is a config fact and so reads a real `0` when the two files connect to
|
||||
themselves. No `severity` anywhere: a connection count is not a graded number, and a structural
|
||||
fault needs no threshold because the hardware of a config file either names a thing that exists or
|
||||
it does not. The `unmatched` / `unmatched_listeners` / `partial` fields went out with the
|
||||
guessing rule they existed to explain. `/api/config` carries the matching row, including
|
||||
`gateway.interval_seconds`/`timeout_seconds` and the named target's redacted `url`.
|
||||
`/api/gateway/*` (phase 29 task 03) is that page's pair. `/snapshot` is one host's whole
|
||||
topology — the haproxy frontends and the nginx `listen`/`map` echo the pipes were built from,
|
||||
the inbound pipes (port, its domains, its live connection count, up to 32 client addresses), the
|
||||
outbound pipes (target `host:port`, `by_port` naming which half of the match is a socket fact,
|
||||
the count, the config that named it) each carrying the `metric` it is stored under, the
|
||||
`unmatched` tallies, and `supported`/`sections`/`why` for the degradation arms — and the counts
|
||||
reconcile: inbound + outbound + unmatched == `sockets.established`, so the page's numbers are
|
||||
provable against a direct `/proc/net/tcp` read. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
the `listeners` (port, its domains, its live connection count, up to 32 client addresses), the
|
||||
`connections` graph (phase 30: one row per route the two config files declare, each naming its
|
||||
inbound port, its outbound endpoint, and the config's own selector for it — a haproxy backend
|
||||
name, an nginx `server_name`, a stream `map` key) with its **live count** — the socket table's
|
||||
ESTABLISHED rows, inbound by the socket's local port and outbound only where a remote
|
||||
`(address, port)` equals a target the config wrote as an address. Each row carries the `metric` it
|
||||
is stored under, and `shared: true` when more than one route reaches its endpoint (one count, shown
|
||||
twice, never split). `supported`/`sections`/`why` cover the degradation arms. `warnings` (phase 30
|
||||
task 03) is the list of the two config files **not connecting to themselves** — seven kinds, each
|
||||
one row of `{id, kind, subject, message}` where `id` is `"<kind>:<subject>"` and the message is the
|
||||
sentence the page prints: `haproxy_route_dangling`, `haproxy_backend_orphan`,
|
||||
`haproxy_backend_empty`, `nginx_server_unlistened`, `nginx_pool_undeclared`,
|
||||
`nginx_variable_unmapped`, `nginx_map_route_missing`. Nothing is inferred to fill a gap, so three
|
||||
shapes are deliberately **silent**: a vhost that listens and passes nothing (a `return 301` looks
|
||||
identical here), a frontend that routes by a fetch expression (agent 1.2.0 reports "no name", which
|
||||
is the same value "no routing directive" has), and an entry of a `map` no `proxy_pass` names (the
|
||||
`$connection_upgrade` map's `upgrade`/`close` are header words, not missing routes). An unreadable
|
||||
or absent section warns about nothing — a host that cannot be read has not been found guilty of
|
||||
anything — and a `warnings: []` is therefore the truth, not a shrug. Non-zero, `/` gets **one**
|
||||
amber row (`N proxy config problems to address — <first message>`) and this page gets the list. **Nothing reconciles
|
||||
to `sockets.established` and that is the design**: a connection to a target the config wrote as a
|
||||
name (most of this fleet's fan-out) or on a port no config names is ignored — not counted, not
|
||||
totalled, not warned about — so a pipe count means "this route has N live connections" and nothing
|
||||
more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
a `404 {"configured": false}` answered before any client is built (both arms spy-pinned), a host
|
||||
that has never answered is the `unreachable` stub whose every count is `null`, and a 1.1.0 agent
|
||||
is `supported: false` plus its one `why` sentence — three arms, no red among them (invariant 3).
|
||||
`/history` serves the `gateway.in_conns@<port>` and `gateway.out_conns@<host>:<port>` gauges —
|
||||
the port in the outbound name because two services on one host are two facts — with `hosts=`
|
||||
the port in the outbound name because two services on one host are two facts, and only ever for an
|
||||
address endpoint — with `hosts=`
|
||||
defaulting to the ingress alone, unknown names answering as empty series, and no dial either way
|
||||
because history reads only SQLite.
|
||||
* `web/` — six static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/gateway`) plus
|
||||
@@ -927,9 +961,13 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
list reads as an explicit "all clear", and a live problem moves the panel above the
|
||||
table. A host that is not answering keeps its row — dimmed, every number `n/a` — so the
|
||||
fleet never quietly gets smaller and last good values are never mistaken for live ones.
|
||||
Under that table is the ingress's **one line** (`gateway · 14 inbound · 4 outbound`, linked to
|
||||
`/gateway`): the counts are `/api/summary`'s own sums, a count the source did not measure prints
|
||||
`n/a` rather than `0`, and a deployment with no `GATEWAY_HOST` renders no line at all.
|
||||
Under that table is the ingress's **one line** (`gateway · 10 listeners · 33 connections · 17 live
|
||||
in · 2 live out` as the live router answered on 2026-10-23 — a reading, the same way a listener count
|
||||
is, and only the field names behind it are a contract), linked to `/gateway`: every figure is `/api/summary`'s own, a count the source
|
||||
did not measure prints `n/a` rather than `0`, and a deployment with no `GATEWAY_HOST` renders no
|
||||
line at all. A non-zero `warnings` appends ` · N to address` and nothing else — `0` and `null` both
|
||||
print nothing, so a clean ingress never grows a permanent sentence about the checks that did not
|
||||
fire (phase 30 task 03).
|
||||
A big colour-coded number is `Dash.hero(el, {...})` on the
|
||||
`.hero` / `.hero-bar` styles, coloured by the `severity` in the payload via
|
||||
`Dash.severityFrom()` — the only colour vocabulary is `ok` / `warn` / `fail`, and a value
|
||||
@@ -1036,26 +1074,38 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
`No test initiated` or no variable at all — says the same "run a battery test" sentence as
|
||||
`unknown`. Only a word the map has never seen stays a warning, printed verbatim so it can
|
||||
be added.
|
||||
`/gateway` answers the topology question `/system` cannot: which sockets on the ingress face
|
||||
inbound, where each one's traffic goes, and how many connections are live on each. It is four
|
||||
cards keyed on what the **config** said a pipe came from — `haproxy`, `nginx`, the `stream map`,
|
||||
and `unmatched` — each hidden until a poll actually has pipes for it, so a router with no nginx
|
||||
in it spends no panel saying so. An inbound row is a listen port with its `server_name`s and the
|
||||
client addresses on it; an outbound row is the target **exactly as the config wrote it** (the SNI
|
||||
name the stream map matched on in front of it when one key routes there), and a target the join
|
||||
could only match on its socket **port** carries a `by port` chip, because that count belongs to
|
||||
the port and not to that service. A pipe animates while it carries at least one live connection
|
||||
and rests at zero, and **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
|
||||
count the join did not decide prints `n/a` and never `0`, which is why the fan-out on a port many
|
||||
domains share is `n/a` rather than one number repeated as if it were sixteen. The header's five
|
||||
tallies print the reconciliation instead of asserting it: what the kernel's socket table reported
|
||||
(`listeners`, `established`) beside what the pipes carry (`pipes in`, `pipes out`) and what no
|
||||
config claims (`unmatched` — ports and connections kept as separate cells). Each card's trend is
|
||||
`/gateway` answers the topology question `/system` cannot: which ports on the ingress are open,
|
||||
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
|
||||
`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
|
||||
`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;
|
||||
the live ingress here is clean, so that panel is off. Each panel's trend is
|
||||
behind a collapsed **Charts** toggle like `/system`'s, one `/api/gateway/history` request per
|
||||
card per refresh. With no `GATEWAY_HOST` the page is the setup sentence rather than a red panel,
|
||||
panel per refresh. With no `GATEWAY_HOST` the page is the setup sentence rather than a red panel,
|
||||
and an ingress running an agent older than **1.2.0** says so in the agent's own words with every
|
||||
count `n/a` and nothing coloured.
|
||||
count `n/a` and nothing coloured. Each line's head carries the sketched **switch** — a real
|
||||
`role="switch"` button, green and to the position because the route is in the config, named in full
|
||||
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.
|
||||
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.
|
||||
@@ -1237,14 +1287,18 @@ the page never reads it.
|
||||
from both ends. The input is the **captured** gateway — `gateway_haproxy.cfg`, the nginx main config
|
||||
plus its included documents, and the two `/proc/net/tcp{,6}` captures — run through the real
|
||||
`host_agent` parsers, so the test compares the join against the agent's own document rather than
|
||||
against a copy of its field names. The assertions are the join's: the pipes carry exactly the ports,
|
||||
`server_name`s and targets those documents name; `inbound + outbound + unmatched` equals the socket
|
||||
table's own ESTABLISHED count, so no connection is counted twice or dropped; a listening port no
|
||||
config claims stays visible as `unmatched.other`; several names sharing one port produce `null`
|
||||
counts and a `unmatched.groups` line rather than one number repeated on sixteen pipes; and **no join
|
||||
ever asks a resolver** — `socket.getaddrinfo` is spied on and fails the test, because deciding
|
||||
"already an address, or a name?" is the only question that module may ask, and a DNS lookup in the
|
||||
poll loop would be rule 2 with a round trip attached.
|
||||
against a copy of its field names. The assertions are the join's two rules and the limits around
|
||||
them: the graph carries exactly the ports, `server_name`s and targets those documents declare (all
|
||||
32 routes on this capture, as a **set**, so a missing or invented route fails by name); a socket is
|
||||
inbound by its local port and outbound only when its remote `(address, port)` equals a target the
|
||||
config wrote as an address; **every other socket is ignored** — `established`, `live_in` and
|
||||
`live_out` are all printed and deliberately never reconciled, because a connection to a name, or on
|
||||
a port nobody configured, is not a fact this page may claim; several routes reaching one endpoint
|
||||
carry one count repeated with `shared: true` rather than a split nobody measured (so the totals, in
|
||||
the source and on `/2`, sum **distinct endpoints**); a name target is never credited, and `0` never
|
||||
stands for "not counted"; and **no join ever asks a resolver** — `socket.getaddrinfo` is spied on and
|
||||
fails the test, because deciding "already an address, or a name?" is the only question that module
|
||||
may ask, and a DNS lookup in the poll loop would be rule 2 with a round trip attached.
|
||||
|
||||
`tests/rate_window_contract.py` is the same device for one number rather than a field table: the Δ
|
||||
window each source measures its rates over — `RATE_WINDOW_SECONDS` for the host agents,
|
||||
|
||||
Reference in New Issue
Block a user