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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user