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:
2026-10-23 22:56:21 -04:00
parent fa4f38c694
commit e366f599f3
51 changed files with 5419 additions and 1519 deletions
+341 -126
View File
@@ -29,7 +29,7 @@ file — plus an overview page that says which of the five you need:
| `/disks` | Are any of my physical disks failing, and which one — health, temp, hours, wear | the same agents, `smartctl -i -A -H` per whole drive (never a partition) |
| `/llm` | Tokens/sec, active sessions, queue depth, KV pressure, latency percentiles | gufo `/metrics` + `/slots`, vLLM `/metrics` |
| `/ups` | Battery health, runtime left, power events, battery-test buttons | NUT `upsd` (our own wire-protocol client; 3051 here, not 3493) |
| `/gateway` | What is coming in through the router, and where does it go — frontends, `server_name`s, the SNI map, live pipes | the same agent (1.2.0) on the one host `GATEWAY_HOST` names: its `haproxy` + `nginx` config sections joined to its `/proc/net/tcp{,6}` sockets |
| `/gateway` | Which domains come 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 carrying the config's own word for the route and its live socket count | the same agent (1.2.0) on the one host `GATEWAY_HOST` names: its `haproxy` + `nginx` config sections joined to its `/proc/net/tcp{,6}` sockets |
History is a first-class feature, not a sparkline afterthought: every number worth graphing
is stored, rolled up (raw → 1m → 1h), and served from `/api/*/history`.
@@ -472,39 +472,97 @@ Responsibilities, one line each:
arithmetic and a dead agent never shrinks a fleet total.
- `sources/gateway.py` — the same snapshot projected onto the **connection topology** of the one
host `GATEWAY_HOST` names (phase 29): `parse(snapshot, host)` joins agent 1.2.0's `haproxy`
frontends and `nginx` `listen`s into **inbound** pipes (one per listen port, carrying the
`server_name` domains and the live ESTABLISHED count + distinct client addresses) and haproxy
`server` lines, concrete `proxy_pass` targets and the `$var`→`map` entries into **outbound**
pipes, plus the `unmatched` tallies (`inbound` = connections on a listening port no config
names, `outbound` = connections to something no target claims, `other` = live listeners no
config claims — phase 28's "an unclaimed socket stays visible as one"). `count` semantics are
the whole honesty of the module: **no DNS** (the spy test pins `socket.getaddrinfo` is never
called — `ipaddress` decides "is this already an address", and it never asks), an exact
`(address, port)` match for a target written as an address (`by_port: false`), a port-only
match for a **name** and only when that port has exactly one configured host
(`by_port: true`), and **no attribution at all** when several names share a port (the real
`stream.conf` map's sixteen `*.reeselink.com:443`): those pipes carry `connections: null`, the
sockets go to `unmatched.outbound`, and `unmatched.groups` says once per port how many and
among which candidates — picking one would be rule 2, and repeating the port's total on
sixteen pipes reads as sixteen times the traffic. A pipe is `null` (never `0`) whenever the
socket table did not answer or the join could not decide, and publishes **no sample** then, so
a chart cannot draw a zero for an unknown. The classification is one exclusive pass
(`join_sockets`), which is what makes "inbound + outbound + unmatched = the socket table"
hold — the identity task 06 checks against a direct `/proc/net/tcp` read. `supported: false`
frontends and `nginx` `listen`s into **`listeners`** (one per listen port, carrying the
`server_name` domains and the live ESTABLISHED count + distinct client addresses — the key was
`inbound` until phase 30 task 01 renamed it, because "a port a config names" is the thing the
page draws a trunk for, not a direction) and haproxy
and the config-declared routes into **`connections[]`**. **The join is two attributions and
nothing else** (phase 30 task 02 — the operator called the phase-29 version overengineered, and it
was). Inbound: a socket's **local** port names a configured listener, which is a fact (`/proc`
states it, no config reading needed). Outbound: a socket's remote `(address, port)` **equals a
target written as an address** in one of the two config files — the only outbound match that
claims nothing, because the target of a `server` line, a `proxy_pass` or an `upstream` member *is*
the endpoint the gateway opened. `join_sockets()` still partitions the table in **one** pass, so a
socket classified both ways would be counted twice — which is exactly what `127.0.0.1:8443` is:
the stream `map`'s `default` sends `:443` to that port and the box's own http vhosts listen on it,
so the loopback hop counts **once as one inbound and once as one outbound**, a pipe on each card,
neither claiming the other's number. Everything else is **ignored**: not counted, not totalled,
not warned about. What that retires is the guessing and the bookkeeping it existed to explain —
the port-only guess for a name target, `unmatched` / `unmatched.groups` / `partial` /
`unmatched_listeners`, and `by_port` with its `MAX_GROUP_HOSTS` chip. A **name** target
(`reeseapps-immich.reeselink.com:443`) is therefore never credited: crediting it needs DNS (which
the module still never calls, spy-pinned) and every name here resolves back through this same
router, so the count would be attributed to something the config never said. Two consequences the
page and the tests both depend on: **`live_out` is not the bandwidth** and the socket table never
reconciles to it — a pipe count means "this route has N live connections" and nothing more — and
several `(listen, endpoint)` lines reaching one endpoint carry one count **repeated** with
`shared: true` (never a split nobody measured), which is why `/2`'s totals and the tests sum
**distinct endpoints**. A pipe is `null` (never `0`) whenever the socket table did not answer or no
line reaches its endpoint, and publishes **no sample** then, so a chart cannot draw a zero for an
unknown. `supported: false`
(a 1.1.0 agent, or `--no-proxy`) keeps every key with empty lists and null counts plus
`WHY_PREDATES`, `available: false`/`null` per section becomes a `why` sentence and the agent's
own line in `section_errors`, and nothing here carries a `severity` (`net.*` precedent: a busy
proxy is the job) or a `window_seconds` (a count is not a rate). Gauges: `gateway.in_conns@<port>`
and `gateway.out_conns@<host>:<port>` — the port **is** in the outbound name, because this
fleet's two haproxy Minecraft backends share `minecraft.reeselink.com` on 25565 and 25566 and
two gauges on one name would overwrite each other in silence. `parse_gateway_snapshot()` is the
proxy is the job) or a `window_seconds` (a count is not a rate). **`connections[]`** (phase 30
task 01) is the graph the pipe lists never stated: one row per route the two config files
actually declare, from `config_connections()`, each carrying its `in.port`, its `out`
`{host, port, name, kind, selectors, address}` (`kind` is `backend` / `server_name` / `pool` /
`map`, `selectors` the config's own word(s) — a haproxy backend name, an nginx `server_name`, a
stream `map` key, the literal `default` kept verbatim), its own `metric`, and `connections: null`
until task 02 re-points the socket join at it. Four derivations (haproxy `frontend → routes →
backend → server`, an nginx vhost's concrete `proxy_pass` per `listen` port of **its own block**
— which is why `nginx_server_blocks()` replaced the per-port `nginx_listen_ports()` — a
port-less `proxy_pass` naming an `upstream`, and a `proxy_pass $var` fanning out through the
`map` whose `variable` matches), one merge rule (identical `(source, in_port, host, port)` is one
line whose selectors union in first-seen order; **never** merged across `in_port`) and two silent
refusals (a `proxy_pass` with neither target nor variable, and a haproxy frontend that routes by
a fetch expression, which the agent reports as no name — that trunk is drawn alone, because "no
name" and "no routing directive" are one value in this payload). **Name-keyed outbound gauges are
retired** (phase 30 task 02): `gateway.out_conns@<host>:<port>` is written **only** for an endpoint
the config wrote as an address (`out.address` true), because the sole reason a name-keyed gauge
existed was the port-only guess the join no longer makes — such a series would be a chart of a
number that is always `null`. `gateway.in_conns@<port>` is unchanged, and the port **stays** in the
outbound name, because this fleet's two haproxy Minecraft backends share `minecraft.reeselink.com`
on 25565 and 25566 and two gauges on one name would overwrite each other in silence; lines sharing
an endpoint write the same value to the same name (they are one count, not two) and `samples()`
de-duplicates by metric. `parse_gateway_snapshot()` is the
`Result` the poller consumes; there are **no events** (a connection count has no transition
worth a line — a proxy that stops routing shows up as its host's own poll failing). The
never-answered arm is `stub(host, ts)` — the **same skeleton** `parse` fills in, plus
`unreachable: true`, `supported: null` and `WHY_NO_ANSWER` — because the route that serves a host
with no payload yet must not hand the page a shape with keys missing, and `WHY_NO_ANSWER` is not
`WHY_PREDATES`: an agent that has not replied has not said it has no sections (invariant 3's two
facts staying two sentences).
facts staying two sentences). **`warnings()`** (phase 30 task 03) is the payload's other half: the
two config files judged against **themselves**, seven kinds and nothing else —
`haproxy_route_dangling` (a `default_backend`/`use_backend` name no `backend` declares, one row per
frontend→backend line, `subject` `"<frontend>-><backend>"`), `haproxy_backend_orphan` (a `backend`
no frontend routes to, its server count from the config), `haproxy_backend_empty` (a `backend` with
no `server` — deliberately *not* also an orphan: `orphan` needs a server to have nowhere to go),
`nginx_server_unlistened` (a `server` block with a `server_name` and no `listen`, in both its
proxying and non-proxying arms), `nginx_pool_undeclared` (a `proxy_pass` whose value has no port —
the shape task 01's `out.kind: "pool"` already draws no line for — naming no `upstream`; a block
with two such `listen`s is **one** warning, `subject` `"<pool>@<port>+<port>"`, because the repair
is one line), `nginx_variable_unmapped` (a `proxy_pass $var` no `map` defines — the `map` *is* this
fleet's router, so that is a broken route and not a style note) and `nginx_map_route_missing` (an
entry of a **referenced** `map` whose value is not an address). Every row is
`{id, kind, subject, message}` with `id = "<kind>:<subject>"`, `message` complete on its own because
`alerts.py` quotes `warnings[0]` verbatim, `kind` the vocabulary the page groups by and `subject`
the config's own name for the thing, never a paraphrase of the message. Three things it refuses to
do, each pinned from the real capture and not only from a fake section: it never **joins** a warning
to `connections[]` or `listeners[]` (a page indexes one row by its own `id`; a boolean beside a
hundred rows is a second source of truth about the same route), it never guesses a **name
target**'s address (the module still never calls `socket.getaddrinfo`, spy-pinned), and it stays
silent about the three shapes that are legal or unreadable rather than broken — a vhost with a
`listen` and no `proxy_pass` (a `:80` `return 301` is identical here), a frontend whose only routing
is a fetch expression (agent 1.2.0 reports *no name*, the same value "no routing directive" has, so
distinguishing them is an agent change and not a source-level guess), and an entry of a `map` no
`proxy_pass` names (the `$connection_upgrade` map's `upgrade`/`close` are header words, not missing
routes). Like the socket half, a section whose `available` is not `True` is **not judged** — a host
that cannot be read has not been found guilty of anything — so `warnings` is `[]` for an absent,
unreadable or pre-1.2.0 config and the page cannot tell "clean" from "unreadable" by that list
alone; it reads `sections` for that, the same pair `supported`/`why` already form. Order is
deterministic (haproxy kinds in config order, then nginx kinds) and rows are deduplicated by `id`,
because the page keys its DOM on it and the alert quotes the first.
- `sources/llm.py` — gufo + vLLM field maps (`GUFO_SERIES`, `VLLM_SERIES`,
`VLLM_HISTOGRAMS`), measured Δ rates **diffed per label set** (`rate_key()` keys the
estimator on the resolved name plus its labels; the cell is the Σ of the per-series
@@ -705,9 +763,22 @@ Responsibilities, one line each:
A missing `host:<name>` record suppresses nothing — "I cannot find the host's job" is not evidence
the host is fine. The gateway source has **no verdict rule and none is wanted**, the same shape as
the disk rule: its payload publishes no `severity`, `severity.THRESHOLDS` has no `gateway.*` row,
`alerts.build` takes no gateway payload at all, so a `gateway` alert can only ever be
reachability — stale past 3× its own 30 s interval or stopped answering — and 400 connections on
an ingress is the job, not a fault.
so 400 connections on an ingress is the job, not a fault. What phase 30 task 03 added is the one
non-reachability voice it *can* have without a threshold — **config integrity** — because "a proxy
config that does not connect to itself" is a structural fact, not a graded number: `alerts.build`
gained a keyword-only `gateway_payload`, and one `warn` row (`id` `gateway:config`, `href`
`/gateway`, `at` the payload's own timestamp so the line does not reset every 30 s) exists exactly
when that payload carries `warnings` **and** its job is configured, `ok` and not stale. One row
whatever the count — seven dangling routes are one config to fix and the page lists all seven —
reading `"{n} proxy config {problem|problems} to address — {first message}"`, with the message
built by `sources/gateway.py` and only quoted here. It is suppressed whenever the job's own
reachability row is on screen (`ok: false` or stale past 3× its interval), because a router that is
not answering is the operator's problem and re-listing warnings from a reading they cannot refresh
is noise beside it; the page keeps showing them beside the last good numbers the whole time. So
`alerts.build`'s signature is now the boundary that keeps the *counts* out: no `severity` reaches
it and no `gateway.*` threshold can be consulted, which is what
`test_a_gateway_payload_with_a_fabricated_verdict_alerts_nothing` re-pins after it replaced the
older "takes no gateway payload at all" wording.
- `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning. The
`meta` table is the only durable key/value state in the app and is reached through three
named helpers — `get_meta`/`set_meta`/`delete_meta` (the last returns its rowcount, because
@@ -849,10 +920,14 @@ Responsibilities, one line each:
lists — the third is phase 15's, and it writes `meta` rows and nothing else).
`/api/gateway/*` (phase 29 task 03) is the ingress pair, and it is the same two rules on a
single-host source. `/snapshot` answers `sources/gateway.parse`'s payload for the one
`GATEWAY_HOST` entry — the haproxy frontends and the nginx `listen`/`map` echo, the inbound pipes
(port, domains, live count, up to `MAX_CLIENTS` client addresses), the outbound pipes (`by_port`
naming which half of the match is a socket fact), each pipe's own `metric`, the `unmatched`
tallies, and `supported`/`sections`/`why` — with `states` appended and the payload's own `ts`
`GATEWAY_HOST` entry — the haproxy frontends and the nginx `listen`/`map` echo, the `listeners`
(port, domains, live count, up to `MAX_CLIENTS` client addresses), the `connections` graph (one row
per route the configs declare, its `in.port` always one of those listeners, its own `metric`,
`shared` when more than one line reaches its endpoint), and the `warnings` list (phase 30 task 03:
seven kinds of config that does not connect to itself, each `{id, kind, subject, message}` with
`id = "<kind>:<subject>"`, empty for a pre-1.2.0 agent and for a section that did not answer) —
with `states` appended and the payload's own
`ts`
left alone, because that is when the agent took the reading and it is what the page ages. Three
arms reach it and none of them is red: `404 {"configured": false}` **in the route's first line**
when `GATEWAY_HOST` names no `HOST_TARGETS` entry (unset and wrong-name are both spy-pinned to
@@ -862,7 +937,9 @@ Responsibilities, one line each:
and minus only the `agent_version` nobody published, every count `null` because an agent that has
not spoken has not reported an idle gateway (rule 2). Nothing in the payload carries a `severity`
or a `window_seconds`, at any depth (pinned by a recursive walk: a busy proxy is the job and no
Δ was ever measured). `/history` serves the `gateway.in_conns@<port>` /
Δ was ever measured). Phase 30 task 02 took `outbound[]`, `unmatched` and `by_port` out of it: the
flat list duplicated the graph with a worse count, and the residual existed only to explain a
guessing rule that no longer runs. `/history` serves the `gateway.in_conns@<port>` /
`gateway.out_conns@<host>:<port>` gauges with `hosts=` defaulting to the ingress **alone** (the
name the samples are stored under — defaulting to every target would draw empty plots for
machines nobody asked to route), and it carries no configured gate because it dials nothing:
@@ -896,20 +973,35 @@ Responsibilities, one line each:
`system.hosts`/`llm.engines` exactly as `/api/system/snapshot` does, so `hosts_total`
counts the machines there are and `hosts_live` the ones answering.
The **`gateway` block** (phase 29 task 02) is additive in the same way: `{configured, host, href,
supported, unreachable, inbound, outbound, map_targets, unmatched, unmatched_listeners, partial,
states}`. Task 02 wrote it for programmatic consumers (the page itself reads
supported, unreachable, listeners, connections, live_in, live_out, map_targets, warnings, states}`
— twelve keys, `warnings` added by task 03. The middle six were renamed by phase 30 task 02 from
`inbound`/`outbound`/`unmatched`/`unmatched_listeners`/`partial`, because the first pair now counts
a graph and not a flat pipe list and the residual no longer exists; `tests/test_api.py` pins the
set, so a thirteenth field is a decision and not an accident. `listeners` and `connections` are **row counts** ("how many trunks / routes does
this gateway declare", which is what the overview's one line prints) and `live_in`/`live_out` are
the two socket-derived totals; Task 02 wrote it for programmatic consumers (the page itself reads
`/api/gateway/snapshot`, task 03); task 05 gave it one page-side reader, the single line `/`
renders under the fleet table, so `href` joined the block — derived from `alerts.HREF`, the one
table that writes a dashboard's route, so the overview's link and an alert about the same source
cannot spell the page differently (`test_the_gateway_block_links_to_the_page_the_alert_row_links_to`,
and the page half reads only block keys: `test_the_overview_line_reads_the_gateway_block_it_claims`
resolves the served `GATEWAY_FIELDS` against a live document). `inbound`/`outbound` are the sums of the pipes' own counts and `map_targets` counts the stream
map's routing entries; every figure is `null` — never `0` — in the arms that did not measure
(unconfigured, configured-but-never-answered, a 1.1.0/`--no-proxy agent, a silent socket table),
and `partial: true` says a total had to leave an unknown pipe out (a port whose configured targets
cannot be told apart) rather than pretend to be the whole number. `unmatched` counts
*connections* and `unmatched_listeners` counts *ports*, so they are two fields and never one sum,
and nothing in the block carries a `severity`. `/api/config` grew the matching row (`enabled`,
resolves the served `GATEWAY_FIELDS` against a live document). `live_in` sums the listeners and
`live_out` the **distinct outbound endpoints** — never the connection lines — because several
`(listen, endpoint)` lines carry one socket count repeated with `shared: true`, and summing the
lines would book the same sockets once per line and print a router busier than it is (the same
rule that keys the outbound gauges by `(address, port)`); `api._endpoint_values()` is the one
reader of that, and a line with no `metric` (its target is a name the join can never count) is
absent rather than an unmeasured endpoint holding the total hostage. `map_targets` counts the
stream map's routing entries. Every figure is `null` — never `0` — in the arms that did not
measure (unconfigured, configured-but-never-answered, a 1.1.0/`--no-proxy agent, a silent socket
table), and a total is `null` rather than a part of itself when any countable endpoint is unknown
(`page-gateway.js`'s `sumLive()` mirrors it for the one tally the page paints — the page has no
outbound total of its own, so only the inbound half is summed there). Task 30.03 added the
twelfth field, `warnings` — the **count** of `sources/gateway.warnings()` rows, which is what the
page's `warnings` head tally and the overview's ` · N to address` suffix both read (the overview
prints it only above zero; a permanent `0 to address` is the ink the empty-state rules exist to
avoid). Nothing in the
block carries a `severity`. `/api/config` grew the matching row (`enabled`,
`host`, `url`, `interval_seconds`, `timeout_seconds`), where `url` is the named entry's
`Target.display_url` and `null` when no target carries the name — invariant 6's boundary applies
to a name-only source exactly as it does to a URL-bearing one, because this body is what a browser
@@ -1058,9 +1150,11 @@ Responsibilities, one line each:
to a threshold. `Dash.severityFor(value, warnAt, failAt)` is the documented fallback for
values the API does not rate, and **no page script calls it any more** — `/` was converted
in phase 02, `/system` in phase 03, `/llm` in phase 04, `/ups` in phase 05 and `/disks`
shipped that way from its first commit, so every colour on all five pages comes from a
shipped that way from its first commit, so every colour on those five pages comes from a
`severity` verdict the API sent
(`tests/test_api.py` fails if a page script names it again). A value the API should rate
(`tests/test_api.py` fails if a page script names it again). The sixth dashboard colours nothing
by verdict at all — `/gateway`'s only colour pair is existence (`--gate-on`/`--gate-off`), and it
carries no `severity` token anywhere. A value the API should rate
gets a row in `severity.py`, never a bound in JS.
**The masthead is three rows at every width, the two control rows flush right** (phase 20 task
01 made the rows, `TODO.md:5-6`; phase 25 task 02 re-justified them, `TODO.md:4` — "I made a
@@ -1223,7 +1317,10 @@ dashboard family — `host`, `llm`, `ups`, `disks` for the SMART series
`disk.uncorrectable@…`, `disk.percentage_used@…`), and `gateway` for the phase-29 connection
gauges (`gateway.in_conns@<listen-port>`, `gateway.out_conns@<host>:<port>` — the port belongs
in the outbound name because two services on one host are two facts and one name would make the
second insert overwrite the first). Those keep their own family instead of
second insert overwrite the first; since phase 30 task 02 the outbound gauge is written **only** for
an endpoint the config wrote as an **address**, so no name-keyed `gateway.out_conns@…` name is ever
inserted again — a host that had one before this change keeps the old series until `maintain()` ages
it out, and its chart simply stops growing points). Those keep their own family instead of
sitting beside `host`'s `disk.used_pct@/mount` because the cadences differ thirty-fold and
`GET /api/metrics?source=disks` has to answer "what does *this page* have history for".
That scheme is the history API's key — treat it as a public interface.
@@ -1260,7 +1357,8 @@ That scheme is the history API's key — treat it as a public interface.
`/usr/local/lib/dashboards-agent/host_agent.py` installed at 13:27, and then its bearer token
went into `HOST_TARGETS`' URL userinfo — so `host:driveripper` and `disks:driveripper` are
**`ok`** and the `live` suite is **11 passed / 0 failed** (it was 9/2 on the missing token that
morning, and 8/3 on the ConnectError before that). The unit reads
morning, and 8/3 on the ConnectError before that; the suite has grown since — `uv run pytest -m
live --live` reads **12 passed / 0 failed** on 2026-10-23). The unit reads
`--host 10.1.0.126 --port 9120 --token-file /etc/dashboards-agent.token` (`640
root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) and is still in the `disk`
group (`getent group disk` → `disk:x:6:dashboards-agent`) — but it does **not** run as that
@@ -1518,9 +1616,9 @@ uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
# GET /api/certs/<name>/bundle and writes
# data/certs/<name>.bundle at 0600 — mints nothing
uv run pytest # hermetic: 1746 passed + 16 skipped (12 live,
uv run pytest # hermetic: 1791 passed + 16 skipped (12 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (99.08%; floor 94%)
uv run pytest --cov # coverage gate (98.9%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
uv run pytest -m store_perf --store-perf # opt-in: the store hot-path timings over the seeded
# store, priced against `store_perf.BUDGETS` (the cached
@@ -1639,14 +1737,26 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
`test_the_real_drives_answer_every_number_the_disks_page_prints` asserts every number
`/disks` prints comes out of real drive output — that test is what caught the parser
reading the end of an attribute row instead of its annotated RAW_VALUE column.
- **A join is pinned by the identity it must satisfy and by the one question it may never ask**
(phase 29). `tests/test_gateway_source.py` runs the captured gateway configs and the recorded
`/proc/net/tcp{,6}` tables through the **real** `host_agent` parsers, so the join is compared with
the agent's document and not with a copy of its field names, and then demands the arithmetic:
inbound + outbound + unmatched equals the socket table's own ESTABLISHED count, a port no config
claims stays visible in `unmatched.other`, several names sharing one port answer `null` rather than
picking a favourite, and `socket.getaddrinfo` is spied so the day the join resolves a name the run
fails — deciding "already an address, or a name?" is all that module may ask (rule 2).
- **A join is pinned by the rules it applies and the one question it may never ask** (phase 29,
simplified by phase 30 task 02). `tests/test_gateway_source.py` runs the captured gateway configs
and the recorded `/proc/net/tcp{,6}` tables through the **real** `host_agent` parsers, so the join
is compared with the agent's document and not with a copy of its field names, and then demands the
two attributions and nothing beyond them: a socket whose **local** port names a listener is
inbound, a socket whose remote `(address, port)` equals a configured target is outbound, and
**every other socket is ignored** — the capture's `established` (18 that day) and the two totals
(9 in, 2 out) are therefore both printed and deliberately never reconciled, because the identity
this suite used to demand (in + out + unmatched = the table) was the arithmetic of a guessing rule.
The rules that must never come back are pinned as absences: no `by_port`/`partial`/`unmatched`
vocabulary in the payload or on the page, no `0` for a pipe the join could not count, and
`socket.getaddrinfo` spied so the day the join resolves a name the run fails — a name is never
credited with a connection (rule 2), which is why 14 of this fleet's 18 live sockets are nobody's
pipe and why **`live_out` is not the bandwidth**. One number in that suite is a **date**, not a fact:
the captured `stream.conf` has 17 `map` entries (so 32 connections and 17 leaves on `:443`), while the
live router answered **18** entries / 33 connections on 2026-10-23 (`data/logs/30-live-verify.out`)
because a domain was added after the capture. That is the designed difference between a fixture and a
reading — folding the drift in means re-running `./scripts/capture-gateway-config.sh` **and**
re-tallying `tests/test_gateway_source.py` in the same commit, never editing a test to match a live
read.
- Anything that needs a failure mode — upsd refusing a command, an engine 500ing, a host
rebooting mid-poll, a counter reset — is a fake in `tests/conftest.py`.
- **A verdict the operator sees is pinned on the surface that shows it, in both directions.**
@@ -1881,9 +1991,18 @@ two-panel shape against the served HTML.
Under the fleet table sits the **ingress's one line** (phase 29 task 05): `#fleet-gate`, filled by
`renderGateway()` from `/api/summary`'s `gateway` block with the block's own `href`, so the page
invents neither a route nor a number (`gateway 14 inbound · 4 outbound`, `n/a` for a count the
source did not measure, the shared `.stale` dim when the agent stopped answering, and the reason in
its `title` when it is `partial`). `configured: false` renders **nothing at all** — the `disks`
invents neither a route nor a number (`gateway 10 listeners · 33 connections · 17 live in · 2 live
out` as the live ingress answered on 2026-10-23 — those four figures are a *reading*, the same way a
listener count is, and only the field names behind them are a contract — since phase 30 task 02
reshaped the block to say what the configs declare as well as what
the sockets are doing, `n/a` for a count the source did not measure, the shared `.stale` dim when
the agent stopped answering). A non-zero `warnings` appends ` · N to address` through its own
one-row table, `GATEWAY_WARNING_FIELD`, and **only** when `Number.isInteger` and `> 0`: a clean
ingress and an ingress that never answered both print nothing, because a permanent `0 to address`
is the ink the empty-state rules exist to avoid and a permanent sentence about checks that did not
fire trains the eye to skip the one that did (phase 30 task 03 — the same file also pins that the
tally is read through the table, so a rename fails there rather than printing nothing forever).
`configured: false` renders **nothing at all** — the `disks`
block's invariant-3 rule, no row and no red one to learn to ignore — and the row is a `<p>` inside
the fleet panel because the page is two panels, not three (the pin counts `<section>`s).
@@ -2038,40 +2157,116 @@ repaints only when charge / verdict / state change (`drawGlyph()` keys a `data-d
charge float jitters every `NUT_INTERVAL_SECONDS`, and rebuilding the `<svg>` on that re-announces
an accessible name for a difference no eye can see.
**The `/gateway` page draws pipes, and a pipe's only claim is a count** (phase 29): four cards keyed
on the payload's own `source` word (`haproxy`, `nginx`, `map`) plus `unmatched`, each shipping
`hidden` and appearing on the poll that has pipes for it — a gateway with no nginx in it spends no
panel saying so, and a section that did not answer at all is the page's one `why` line rather than a
card repeating it in a smaller font. A row is inbound (`:<listen port>`, its `server_name`s as the
domains, up to `MAX_CLIENTS` client addresses as the footnote) or outbound (the target **as the
config wrote it**, with the SNI name the stream map matched on in front of it when exactly one key
routes there), and its count is `countText()` — a `null` from the join prints `n/a` and never `0`
(rule 2). The pipe is hand-rolled SVG (`role="img"` + its own `aria-label` naming port/target,
domains, clients and the count), it **animates only while the count is ≥ 1**, and it is **never
coloured by a verdict**: `severity.py` has no `gateway.*` row, the payload carries no `severity` at
any depth, and `tests/test_api.py::test_no_gateway_count_is_coloured_by_a_verdict` fails the day a
`severity`/`ok|warn|fail` token appears in the page or the script (a busy ingress is the job, the
`net.*` and GPU precedents). A target matched on its socket **port** alone carries a `by port` chip,
because that count is the port's and not the target's (`by_port`, and the reason a shared `:443`
fan-out prints `n/a` instead of repeating one number sixteen times). The page head's five tallies are
the socket table's own totals (`listeners`, `established`) beside the totals the pipes carry (`pipes
in`, `pipes out`) plus `unmatched`, so the page prints its reconciliation rather than asserting it,
and the `unmatched` card's three cells stay three (`other` counts **ports**, the other two
**connections**). A label gets its **line**: `.pipe-label` wraps and `.pipe-name` is based on `max-content`, so a short
`:22` sits beside its count and a long target takes the whole column with the `by port` chip, the
config's own `server` name and the count beneath it (task 06 measured the alternative — one `nowrap`
line, seven characters of a hostname — and `data/logs/29-task06-card-haproxy.png` is the result). What
still does not fit a ~150px column is ellipsised and not lost: `renderPipeRow` puts the whole string
on the element as `title` and `pipeAria` carries the whole endpoint, which is what
`test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins along with the two declarations;
the residual and the options for it are gap 13. Charts follow `/system`: one
collapsed `Charts` `<details>` per card, built and
painted only while open, fed by **one** `/api/gateway/history` request per card per refresh naming
that card's own gauges, the legend naming pipes because the card heading already says which config
family it is. Every degradation arm uses the payload's own words — the `why` sentence for a
pre-1.2.0/`--no-proxy` agent or a silent section, the `unreachable` dim for a host that has never
answered, and `#gate-empty` (naming `GATEWAY_HOST` and agent **1.2.0**) for `404 {configured: false}`
— and a failed poll re-draws nothing, so the last topology stays on screen.
**The `/gateway` page is the config's own tree, and a line's only claim is a count** (phase 29;
joined differently by phase 30 task 02; laid out as trunk→leaf panels by phase 30 task 04): **exactly
two panels**, one per config file, in the order `nginx` then `haproxy` (`GATEWAY_CARD_KEYS`), each
shipping `hidden` and appearing on the poll that has rows for it — a gateway with no nginx in it
spends no panel saying so, and a section that did not answer at all is the page's one `why` line
rather than a card repeating it in a smaller font. There is **no `map` panel and no `unmatched` one**:
the stream `map` is nginx config, so its fan-out is the nginx panel's `:443` leaves
(`out.kind: "map"`, its SNI key as the selector) read beside the port they reach, and a residual was
the left-over of a rule the join stopped applying.
Inside a panel the rows are **groups** (`groupsFor()` → `renderGroups()` → `renderTrunk()` +
`renderLeaves()`): a *trunk* is one port that file binds (`:<port>`, its live count, up to
`MAX_CLIENTS` client addresses, and its `server_name` count with the whole list on `title`), and the
*leaves* hanging off it are the endpoints that port names, each with the config's own word for the
route above it (a haproxy `server` name, a vhost's `server_name`, a stream `map` key) and its own
`metric`. The page draws what the config declares and nothing else: the deleted `0`-padding
(listeners with no connection, endpoints no line reaches) is gone because a group *is* a listener and
an unrendered endpoint has no row to sit in. **A trunk whose config names no target is a fact and not
a gap in the drawing** — this fleet's nginx `:80` is a `return 301`, so its leaf list renders the
script's own "no target named in the config" rather than an empty box (the payload publishes no row
for it; `sources/gateway.py` refuses to warn about that shape for the same reason). Groups are keyed
on the port, and rows on the connection's own `id`, in per-panel `Map`s (`state.groups`,
`state.rows`):
built once, patched afterwards, reordered with `appendChild` of the existing node and pruned once per
panel by id, because rebuilding a 17-row fan-out every 30 s re-announces all of it to assistive tech
for a difference of one connection (the `/llm` slot-grid rule). `pruneCards()` wiping a panel's whole
map is the only bulk clear, for the case where the family itself left the payload.
A count is printed by `countText()`, which answers `null` for anything that is not a finite number —
**never `0`** (rule 2) — and the two unknowns stay two sentences: a **trunk** whose socket table went
quiet prints `n/a` (`setTxt`'s own word), while a **leaf** whose endpoint the config wrote as a
**name** prints an em dash (`NO_COUNT`) whose `title` says `connections not attributed — the config
writes a name and nothing here resolves names`. That is the normal look of 31 of this ingress's 33
lines, and a column of `n/a` would be twelve identical words competing with the page's one footnote.
The line itself is hand-rolled SVG (`role="img"` + an `aria-label` naming the inbound port, the
endpoint, the selector and the count) and it carries **two independent class pairs** (phase 30 task
05), because the operator's green and the socket count are two different facts:
* `live` / `still` — "≥ 1 ESTABLISHED socket on this pipe right now" — decides **motion only**. One
`pipe-flow` keyframe whose sole property is `stroke-dashoffset`; the live rule declares no `stroke`,
so a busy line and a resting line are the same colour and motion can never become a verdict.
* `on` / `off` — "a proxy config declares this route" — decides **stroke only**, through the sheet's
own existence pair `--gate-on` / `--gate-off` (declared once in `:root` as a substitution of the
theme colours, so both themes follow and no gateway rule names `--ok`). `routeInConfig()` answers
`true` unconditionally, which is honest rather than a stub: `connections[]` **is** the config's
declaration list, so `off` is the styled seam the write phase will fill and never a second state in
use. The base `.pipe-path` declares no `stroke` at all, which is what makes the state class the only
way a colour can arrive.
So the page is **green because the route exists**, never because a number is good: `severity.py` has
no `gateway.*` row, the payload carries no `severity` at any depth, and
`test_no_gateway_count_is_coloured_by_a_verdict` fails the day a `severity`/`ok|warn|fail` token
appears in the page or the script — the one `warn` in the file is the class `renderWarnings` takes
from `alerts.py`, pinned by count. The **switch** at each line's head (`buildSwitch()`) is the
control from the operator's sketch and it is **built, not wired**: a real `<button type="button"
role="switch">` pill with a knob, keyed on `[aria-checked]` (never `:enabled`, which would paint every
one of them off) and coloured from the same existence pair, carrying a full accessible name
(`endpoint, reached by <selector>, in the nginx config, on`) because a colour cannot say that to a
screen reader. It is `aria-disabled="true"` — **never** the `disabled` attribute, which would take the
reading out of the a11y tree — with a `title` naming the missing write path, no listener (the file's
only `addEventListener` calls are the charts `toggle` and `DOMContentLoaded`) and no `:hover` or
`cursor: pointer` anywhere in the map, because invariant 1 means no browser can disable a route:
`haproxy.cfg` and the nginx `server` block are the only switches on this fleet. The one `transition`
is the knob's, and the reduced-motion block names it beside both pipe states.
The branch is four CSS rules and no JavaScript: each `.gate-link` carries its own upper and lower half
of the spine at the gutter's left edge (`::before` / `::after`, each reaching half a leaf gap past its
row so consecutive halves meet), the first and last rows drop theirs so the spine starts at the first
branch and stops at the last with no mask, no measured height and no background-coloured eraser —
which is also why it stays right on the poll that adds a leaf — and `.gate-group.solo`, set by the
script from the group's own leaf count, takes the vertical away so a one-route port runs **straight
across** (every haproxy frontend here). `.gate-group { column-gap: 0 }` is what makes the trunk's
right edge and the spine the same x, so each tick starts *at* its trunk, and at ≤640px the stacked
trunk is reconnected by bringing the first row's upper half back at `top: calc(var(--gate-row-gap) * -1)`.
The head's **four** tallies are the payload's own figures and nothing is summed twice: `ports` =
`listeners.length`, `connections` = `connections.length`, `live` = `sumLive()` over the listener rows
that publish a `metric` (`null` — never `0` — when none measured, dimmed `stale` with a tooltip naming
the port when some did not), and `warnings` = `warnings.length`. `#gate-warnings` sits **above** the
panels and ships `hidden`: a non-empty `warnings` list raises it, rendered through
`ul.alerts#warnings-list` in **the overview's own row vocabulary** (`li.warn` with a `.src` from
`familyWord(kind)` and a `.msg` carrying `warning.message` verbatim) because "a backend no frontend
routes to" is the same kind of sentence as an alert and must not grow a second look; back to clean it
hides and its rows go with it (both directions pinned, and the live ingress is clean, so the panel is
off). Charts follow `/system` exactly: one collapsed `Charts` `<details>` per panel, **no** window
chips (a fixed 24 h named by the chip beside the toggle), the request
fired **once per visible panel per refresh** naming that panel's own gauges and cached so the first
expand paints from data in hand, while the `LineChart` is built and painted only while the disclosure
is open (`drawCardCharts()` returns at `chartsOpen(key)` — a `display:none` container measures 0 px),
and the legend names pipes because the panel heading already says which config file this is. Every
degradation arm uses the payload's own words — the `why` sentence for a pre-1.2.0/`--no-proxy` agent or
a silent section, the `unreachable` dim for a host that has never answered, and `#gate-empty` (naming
`GATEWAY_HOST` and agent **1.2.0**) for `404 {configured: false}` — and a failed poll re-draws nothing,
so the last topology stays on screen. The footer is **three** paragraphs and no more: why a line is
drawn and what its motion means; why a **name** endpoint carries no number; and that the switch is not
wired. The layout is three load-bearing declarations (`minmax(0, 9.5rem) minmax(0, 1fr)` per group so
a fan-out lines its leaves up instead of re-flowing them to the widest target, `minmax(0, …)` on every
track so a 40-character hostname ellipsises instead of growing the card — phase 27's rule — and one
column with the trunk above its leaves at ≤640 px). Task 04 measured that layout with
`data/logs/30-task04-panels.cjs` (transcript `30-task04-panels.out`, screenshots
`gateway-30-task04-{1440,360,reduced}.png`) and task 06 re-measured it, with the switches and the
pipe states in place, in `data/logs/30-connection-map.cjs` (transcript `30-connection-map.out`,
screenshots `30-connection-map-{desktop,light,warnings,predates,phone}.png` — the phase's own pass:
two panels, straight haproxy rows, one branched nginx `:443`, green in both themes, motion only where
a socket is live, dead switches, injected warnings, both degradation arms, 63 checks, 0 px of sideways
document scroll at 1440 px and 360 px). What it printed on 2026-10-23: 10
listeners / 33 connections, nginx 4 ports · 27 routes (`:443` 18 leaves, `:4443` 6, `:8443` 3, `:80`
the refusal sentence), haproxy 6 groups of one, 31 dashes beside 2 numbered lines, every line keeping
its full text on `title`, and **0 px** of sideways document scroll at both widths with the widest
fan-out and both charts open.
An event row names its subject **once**. Every feed (`/system`, `/disks`, `/llm`, `/ups`) renders the
message through `Dash.eventText(event)`, which drops the `UpstreamError: ` wrapper and a
@@ -2921,8 +3116,15 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
(`data/logs/27-task02-mobile-audit.out`, screenshots `mobile-27-*.png`; the first run is kept
as `27-task02-first-run.out`). Phase 29's completion pass re-ran the same sweep now that
`/gateway` is in it — **371 PASS / 0 FAIL / 0 FINDING**, 10 skips, nine served pages, exit 0
(`data/logs/29-final-mobile-audit.out`) — so the two declarations above still hold on a page
this audit was written before, and `data/logs/27-task03-control.cjs` proves the new check is
(`data/logs/29-final-mobile-audit.out`) — and phase 30 task 04 re-ran it once that page became two
trunk→leaf panels: **370 PASS / 0 FAIL / 0 FINDING**, 10 skips, exit 0, with only its `/gateway`
wait selector moved from the deleted `#t-inbound` tally to `#t-ports` (the clip counts inside the
new rows are `data/logs/30-task04-panels.out`, not this audit's business). Phase 30's completion
pass ran it again after the switches went in — **371 PASS / 0 FAIL / 0 FINDING**, 10 skips, nine
served pages, exit 0 (`data/logs/30-final-mobile-audit.out`) — so neither the two-panels rewrite,
nor the switch pill in every leaf row, nor the pipe classes reintroduced a clipped fact or a
sideways document anywhere on the app. So the two declarations
above still hold on a page this audit was written before, and `data/logs/27-task03-control.cjs` proves the new check is
not vacuous by neutralising both declarations and re-creating the whole first-run findings list
(3/4/2 header offenders at 360 px, 8 stranded disclosure tables at 1280 px). The served-byte
pins are `test_a_card_header_wraps_its_facts_instead_of_losing_its_tail` and
@@ -2942,39 +3144,52 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
seed one (`tests/test_deploy_scripts.py` keeps it that way). What remains of phase 24 is
ordinary rotation hygiene, not a missing step.
13. **`/gateway`'s long pipe labels are ellipsised at a desktop width — a column-width question,
not a lost fact** (found and half-fixed by phase 29 task 06's live pass,
`data/logs/29-task06-live.out`; the picture is `data/logs/29-task06-card-haproxy.png`).
13. **`/gateway`'s long endpoint labels are ellipsised on a phone — a column-width question, not a
lost fact** (found by phase 29 task 06's live pass, **closed at the desktop width by phase 30
task 04's trunk→leaf row and re-measured on 2026-10-23 by task 06's pass**;
`data/logs/30-connection-map.out` is the current measurement, `data/logs/30-task04-panels.out`,
`data/logs/29-task06-live.out` and `data/logs/29-task06-card-haproxy.png` the history).
What the pass found first: at 1440px the outbound name showed **seven characters**
(`reeseapps-…`) for a 40-character `server_name → target` string. `.pipe-label` was one `nowrap`
flex row, so the name shrank to whatever the `by port` chip, the config's own `server` name and
the `flex: none` count left. Wrapping the *text* inside that box was measured and rejected — it
tore one hostname over eleven lines of a word each. What shipped instead is the label getting
the line to itself: `flex-wrap: wrap` on `.pipe-label` beside `flex: 1 1 max-content` on
`.pipe-name`, so a short label (`:22`) keeps its count on the same line as before and a long one
is by itself wider than the column, takes the whole column, and lets the chip, the footnote and
the count wrap beneath it. That is ~22 characters instead of 7, the config's own `server` name
now reads in full, and nothing was truncated that was not truncated before.
**Phase 30 task 04 closed the desktop half by changing the row, not the font.** The two
equal-width pipe columns of phase 29 became a group of `minmax(0, 9.5rem) minmax(0, 1fr)` — a
trunk that only ever says `:25566 · 4 live`, and a leaf list that gets everything else — so on the
live ingress at 1440 px the target column measures **361 px** (391 px when task 04 measured it,
before task 05's switch pill took 30 px out of the row) and **0 of 33** endpoint labels and
**0 of 33** selector lines are ellipsised. What used to be the whole gap is now one line in a
`FINDING` the script prints rather than an assertion, because it is arithmetic about a viewport
and not a bug: the longest target here is 42 characters
(`reeseapps-brain-of-code.reeselink.com:443`) and it fits.
**What is left** is arithmetic, not a bug: **28 of 42** labels still show an ellipsis because
`.pipe-columns` is `minmax(0, 1fr) auto minmax(0, 1fr)` inside one of three cards across a
1440px viewport, which is a ~150px column, and `reeseapps-brain-of-code.reeselink.com:443` is 41
characters. No fact is lost — `renderPipeRow` puts the whole string on the element as `title` and
`pipeAria` carries the whole endpoint, which is what
`tests/test_api.py::test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins, and the
two declarations above are pinned beside it so the label cannot be squeezed back to seven
characters. In order of cost: (a) give the outbound column more of the card than the inbound one
(inbound labels are `:22`-shaped, so `minmax(0, .45fr) auto minmax(0, 1.55fr)` buys ~90px); (b)
two cards per row for the fan-out, which is the honest layout for a 17-row map but leaves the
third of the viewport empty; (c) accept it — the stream map's rows are read as a set, the
left-hand SNI name is the part that fits, and the count is the number the row is about. The
≤640px arm already stacks the two columns into one, where the label gets the whole card and
**16 of 42** clip instead of 28 (a stream-map string here runs to 70 characters, so even a 312px
column is not enough for the longest few) — measured in the same pass, which prints both counts
and demands a `title` on every clipped one. Whoever picks this up re-runs `data/logs/29-task06-live.cjs`: its
FINDING lines are the two counts, and the run fails if a clipped label ever stops carrying its
full text.
The arm that remains is the **phone**, and it got narrower before it got better: task 05's
**switch** moved into the leaf row and a group stacks to one column at 360 px, so the same labels
now clip at **31 of 33** targets and **22 of 33** selectors in a **139 px** column (this phase's
re-run; 27 / 13 in a 177 px column when task 04 measured it, the difference being the switch
pill's 34 px and the tick's 26 px taken off the label). Nothing is lost —
`renderLeaf()` puts the endpoint *and* the config's own selector on the row as `title` and
`leafAria()` carries the whole endpoint on the line, which is what
`tests/test_api.py::test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins, alongside
the declarations that decide *how much* it clips (`min-width: 0` on `.gate-target`,
`minmax(0, 1fr)` on the leaf track, `max-content` on `.pipe-name`, `flex-wrap` on the trunk) so a
future edit cannot squeeze a label back to the seven characters phase 29 measured. In order of cost:
(a) at ≤640 px let the leaf row put the count on the target's second line instead of its own
column, which buys roughly two more characters per label and is a stylesheet-only change;
(b) accept it — the fan-out is read as a set, the part that fits is the selector, and the number
the row is about is the count. Whoever picks this up re-runs
`data/logs/30-connection-map.cjs` (task 06's pass, which supersedes
`data/logs/30-task04-panels.cjs`): its two `FINDING` lines are the counts at both widths, and the
run **fails** if any line ever stops carrying its full text — 33 of 33 rows carried a `title`
naming their whole endpoint at 1440 px and at 360 px as this phase closed, which is the assertion
the gap is actually about.
How it got here, kept because the two rejected fixes are the reason the current shape looks the
way it does: at 1440 px phase 29's outbound name showed **seven characters** (`reeseapps-…`) for a
40-character `server_name → target` string, because `.pipe-label` was one `nowrap` flex row and the
name shrank to whatever the `by port` chip (retired since), the config's own `server` name and the
`flex: none` count left. Wrapping the *text* inside that box was measured and rejected — it tore
one hostname over eleven lines of a word each — so task 06 gave the label the line to itself
(`flex-wrap: wrap` plus `flex: 1 1 max-content`), which reached ~22 characters. Task 04 kept both
declarations and removed the reason they were needed: with the trunk in its own track, the leaf's
name is the widest thing in a column that is now wide enough for it.
## Locked decisions (and why)