phase: 29_gateway_dashboard_page

Phase 29 completion pass is done. Final state:

**Verification (all re-run this pass, no task files touched)**
- `uv run pytest` → **1746 passed, 16 skipped**; `uv run pytest --cov` → **99.05 %** (gate 94 %); `bash .agents/validate.sh` → **rc=0** (ruff clean, pyright `0 errors, 0 warnings`, `node --check` clean); `uv run pytest -m live --live` → **12 passed**.
- Live, read-only: dev server **10/10 sources `ok`** (incl. `gateway`); `data/logs/29-task06-live.cjs` → **49 PASS / 0 FAIL** with per-port inbound counts reconciled against a direct `/proc/net/tcp{,6}` read (divergence 0); `data/logs/verify-29-task06-predates.py` → 0 failed (1.1.0 arm renders `WHY_PREDATES`, no pipes, every tally `n/a`, job still `ok`); phase-27 mobile audit re-run over all nine served pages → **371 PASS / 0 FAIL / 0 FINDING** (`data/logs/29-final-mobile-audit.out`).
- Criteria: (1) unconfigured `GATEWAY_HOST` → `404 {configured:false}` with dial spies + `#gate-empty` sentence ✅; (2) 1.1.0/`--no-proxy` → `supported:false`, no `0`s, never red ✅; (3) live gateway 1.2.0 → pipes animate, totals reconcile ✅; (4) no `severity`/verdict colour anywhere, validate green ✅; (5) docs updated + gap 13 logged ✅; (6) the five existing dashboards, jobs and `/api/summary` unchanged (live control leg) ✅.

**Docs (consistency pass, incl. pre-existing drift)**
- `README.md`: architecture diagram now lists `sources/gateway.py`, the 30 s gateway job and the third `/snapshot` reader; `GATEWAY_HOST` row says *a name, never a URL*; the `POLL_MS_*` row states `/gateway`'s fixed 15 s browser poll; "nine pollers" → per-source cadences; phase-28 "until phase 29" wording → current state; new Testing paragraph for `tests/test_gateway_source.py` (join pinned from the captures, reconciliation identity, `getaddrinfo` spy).
- `AGENTS.md`: phase-28 stop-point wording replaced with the shipped phase-29 state; suite/coverage figures → 1746 / 16 skipped / 99.08 %→99.05 % band and `~99 %`; window-chips paragraph records that `/gateway` has **no** chips (fixed 24 h); new testing-policy bullet for the join; stale page counts fixed (seven → eight tabbed pages / `MARKUP_PAGES`, clock-order and badge-count transcripts annotated); gap 12 now records the phase-29 nine-page audit re-run.
- `tests/test_api.py`: comment/docstring-only fixes for the "seven pages" wording that phase 29 made eight.

**Deviations / notes**
- Nothing implemented: all six task files were already in `complete/`, so this run was the final verification + docs pass; no git index or phase file was touched.
- Coverage wobbles 99.05–99.08 % between runs on a pre-existing timing-dependent line (`collectors.py` `/slots`-alias `except`/`break`), not a phase-29 path (`sources/gateway.py` is 100 %).
- `uv pip list --outdated` still lists upgrades; dependencies did not move in this phase, so they were left alone rather than upgraded mid-verification.
- Historical browser-transcript counts (phase 18/20/25 script runs) were left as recorded history and annotated instead of rewritten.

**Next phase:** none — `.agents/phases/todo/` is empty after 29; further work needs a new phase authored first.
This commit is contained in:
2026-10-23 09:52:30 -04:00
parent 44d61d3ab9
commit fa4f38c694
68 changed files with 5714 additions and 200 deletions
+293 -59
View File
@@ -19,16 +19,17 @@ keep its numbers honest, and the state of the fleet it watches.
## What this is
Four dashboards for one homelab, behind one domain, in one process, backed by one SQLite
file — plus an overview page that says which of the four you need:
Five dashboards for one homelab, behind one domain, in one process, backed by one SQLite
file — plus an overview page that says which of the five you need:
| Route | Question it answers | Source |
| --------- | ------------------------------------------------------------------------------ | ------------------------------------------------- |
| `/` | Is anything wrong, and where do I look first? | all four, in one request |
| `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history | `agent/host_agent.py`, our own stdlib agent per host |
| `/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) |
| Route | Question it answers | Source |
| ---------- | ------------------------------------------------------------------------------ | ------------------------------------------------- |
| `/` | Is anything wrong, and where do I look first? | all five, in one request |
| `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history | `agent/host_agent.py`, our own stdlib agent per host |
| `/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 |
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`.
@@ -342,11 +343,12 @@ Responsibilities, one line each:
publish: the same rule that scrubs a drive's serial (invariant 6). Those two tables are volatile by
nature, so `tests/test_agent.py` asserts their **shape**, the ports the recorded configs claim, and
the absence of a globally routable endpoint — never a particular row, which a re-run may lawfully
change. What phase 28 stops at is the wire: `sources/host.py` passes **none** of the three into
`/api/system/snapshot` and `store.py` records no series for a proxy or a socket, so the sections
live in the agent's own document and reach no page until phase 29's. What the dashboard does read
today is their **error lines**, through `collectors.ADDON_ERROR_PREFIXES`, so a host with no proxy
config stays out of `/system`'s `degraded` (invariant 3).
change. What phase 28 stopped at was the wire: `sources/host.py` still passes **none** of the
three into `/api/system/snapshot`, and `store.py` still records no series for a config — the join
and the two gauges it publishes are phase 29's `sources/gateway.py`, which reads the same
`/snapshot` through its own `gateway` job. What the *system* page does read today is their
**error lines**, through `collectors.ADDON_ERROR_PREFIXES`, so a host with no proxy config stays
out of `/system`'s `degraded` (invariant 3).
- `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real
env var always wins. Defaults are safe except the sources, which default to unset. The auth
block (phase 11) is `DASH_ADMIN_PASSWORD` (the **seed**, and `_env()` strips, so `VAR=` means
@@ -355,19 +357,32 @@ Responsibilities, one line each:
`http://127.0.0.1`; task 03 also treats `X-Forwarded-Proto: https` as secure), and
`DASH_PBKDF2_ITERATIONS` (default `auth.PBKDF2_ITERATIONS`, imported so the primitive and the
setting cannot drift) — that knob exists so the hermetic suite hashes in microseconds instead
of 0.3 s per client and is not a production tuning parameter.
of 0.3 s per client and is not a production tuning parameter. The gateway block (phase 29) is
`GATEWAY_HOST` (default `gateway`, and **a name, never a URL**: `gateway_enabled` is true only
when a `HOST_TARGETS` entry has it, so the page is unconfigured rather than red for a
deployment with no ingress — the `nut_enabled` shape, and the reason `any_source_enabled`
includes it), `GATEWAY_INTERVAL_SECONDS` (30 — a connection count is the shape of half a
minute, not of ten seconds) and `GATEWAY_TIMEOUT_SECONDS` (10, the plain agent fetch with
headroom for a handler that may be mid-smartctl pass for `/disks`).
- `poller.py` — job loop, health records, stale/last-good semantics, transition events.
`/disks` is a **source of its own** (`disks:<host>`, `DISKS_INTERVAL_SECONDS` 300 s,
`DISKS_TIMEOUT_SECONDS` 30 s, events under the dashboard name `disks`) reading the same
agents the `system` jobs read: the separate task is what keeps a SMART poll out of the
10-second utilisation loop and lets `/disks` age on its own instead of blinking `/system`.
`gateway` is a **job of its own** for the same reason (phase 29 task 02) — `GATEWAY_INTERVAL_SECONDS`
30 s, `GATEWAY_TIMEOUT_SECONDS` 10 s, health and events under the dashboard name `gateway` — but
**one** job rather than one per host (the `nut` shape), dialling the `HOST_TARGETS` entry
`GATEWAY_HOST` names with its `label` set to that host's name, because the page is about the single
ingress and a record per machine would ask four hosts a question only one can answer. It exists
only when that name is a target: an unconfigured source has no task that could look red
(invariant 3), and it is handed no `RateEstimator` because a count is not a rate (rule 2).
The job list is built once (`app.build_hub()`) because it is wired twice — production and
the injected-store test path — and two hand-written copies drifting is how a source ends up
polling in tests and not in the container. The
success path records (payload → samples → events) and only *then* moves `last_ok`, so a
refused SQLite write ages the source and alerts instead of leaving it current with charts
that have silently stopped drawing.
Events are stored under the **dashboard** name (`system`/`disks`/`llm`/`ups`), not the sample
Events are stored under the **dashboard** name (`system`/`disks`/`gateway`/`llm`/`ups`), not the sample
source (`host`) — a page filtering `/api/events` by `host` gets an empty feed forever
(found on `/system` during phase 03 browser verification).
- `collectors.py` — the fetch half. `AgentTls` (phase 15 task 03) is what makes an `https://`
@@ -390,8 +405,16 @@ Responsibilities, one line each:
problem. `agent_tls_map()` builds one entry per configured host (the CA and client pair are
shared — the dashboard has one identity — only the revocation answer is per name, and an absent
entry is *active*, never-issued being a different fact from revoked); `build_hub` takes that map
and hands the **same** entry to the `host:` and `disks:` jobs of one host, so a revocation
fails both sources with one sentence and neither of them dials. `fetch_snapshot()` keeps
and hands the **same** entry to the `host:`, `disks:` and `gateway` jobs of one host, so a
revocation fails every source reading that agent with one sentence and none of them dials
(`tests/test_api.py`'s revoked-gateway pin is the three-job version of the `host:`/`disks:` one).
`fetch_gateway_snapshot()` + `gateway_job()` (phase 29 task 02) are the third reader of one
`/snapshot`: the job is **one per deployment** (the `nut` shape, key `gateway`, `label` = the
`GATEWAY_HOST` name) on `GATEWAY_INTERVAL_SECONDS` with `GATEWAY_TIMEOUT_SECONDS`, it is handed
**no** `RateEstimator` (a connection count that goes through a Δ-window is a rate with a label and
no measurement behind it, rule 2), and `gateway_job` returns `None` when the name is not in
`HOST_TARGETS` — the `disks` configured gate, so an unconfigured source gets no task that could
look broken and `/api/health` says nothing about it. `fetch_snapshot()` keeps
`http://` targets on the shared client with their bearer token, unchanged from before the phase,
so a rollout is per host.
`NutMonitor` owns per-UPS transition bookkeeping (its
@@ -447,6 +470,41 @@ Responsibilities, one line each:
counts for the page header and drops a host with no payload from the sums while still
counting it in `hosts_total` — the `/llm` fleet-block rule, so the page never does
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`
(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
`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).
- `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
@@ -638,7 +696,18 @@ Responsibilities, one line each:
`severity.py` rates no sector counter and this module may not have a threshold of its own, so
grading a drive here would be the bug, not the fix (phase 09). Its `HREF` table does carry
`/disks`, so the one alert a disk job *can* raise — SMART failing while its host answers —
links to the page that names the drive.
links to the page that names the drive, and since phase 29 `/gateway` for the same reason: a
`gateway` job failing **while its host answers** (the proxy configs or the socket table went
quiet inside a healthy agent) has no other voice. The echo suppression is now a table
(`AGENT_ECHO_DASHBOARDS` = `disks`, `gateway`) because three jobs read one agent, and it finds the
partner differently for each: `disks:<host>` names the host in its key, while the single `gateway`
job names it in its **label** (its key is one word, and `GATEWAY_HOST` is what an alert prints).
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.
- `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
@@ -654,7 +723,7 @@ Responsibilities, one line each:
cannot remove a name, so `_note_series_write()` ends the cache on a `(source, host, metric)` the
store has never written and leaves a poll of an existing series alone — invalidating on every
sample was task 02's defect, and it put the 572 ms scan back on nearly every `/api/metrics` call
on a store nine pollers write to, behind a cache that never served; `delete_series()`/`maintain()`
on a store every poller writes to, behind a cache that never served; `delete_series()`/`maintain()`
invalidate unconditionally because a `DELETE` can take a name, `delete_series()` also forgetting
the triples it emptied so a re-created series counts as new again), through a generation counter so
a write racing a read cannot park a stale answer under a fresh timestamp, with the name cache
@@ -756,7 +825,7 @@ Responsibilities, one line each:
configured host **with drives to show** (disk rows + `smart` + `smartctl_available` +
per-row `severity` + a `fleet` block summed by the API, with a configured-but-never-answered
host as an `unreachable` stub whose counts are `None`, not `0`), and `/history` serves the
`disk.*@dev` series with unknown names answering as empty series like the other two
`disk.*@dev` series with unknown names answering as empty series like the other
history endpoints. The configured gate runs **before** anything that could reach the
network — `HOST_TARGETS` empty is `404 {"configured": false}` with no client, no fetch and
no `disks` job in the hub at all (invariant 3), and `tests/test_api.py` proves it with
@@ -778,11 +847,33 @@ Responsibilities, one line each:
only POST that **dials an upstream** stays `/api/ups/command` (invariant 1, pinned by enumerating
every served route's verbs and sorting the writes into fleet / session / certificate-authority
lists — the third is phase 15's, and it writes `meta` rows and nothing else).
All four `history` routes reach the store through one door, `api._history_batch()` →
`/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`
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
`httpx.AsyncClient.get` and `collectors.fetch_snapshot`, and neither arm has a `gateway` job to
age out either), `supported: false` for a 1.1.0/`--no-proxy agent, and `gateway_source.stub()`
for a configured host that has never answered — the payload's key set plus `unreachable: true`
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>` /
`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:
like `/api/disks/history`, an unconfigured source answers no hosts and no series. The pair adds
no write (the verb enumeration pins the two GETs and that the app's one upstream POST is still
`/api/ups/command`), and `tests/test_auth_api.py` lists both paths as session-required.
All five `history` routes reach the store through one door, `api._history_batch()` →
`store.history_many()` (phase 14 task 02). That is a cost change and nothing else: the series
list is host-major and metric-minor in request order, each entry the same
`{source, host, metric, range_seconds, points}` document the per-series loop built, with the
same empty-series answer for a name that has no rows. `tests/test_api.py` pins it for all four
same empty-series answer for a name that has no rows. `tests/test_api.py` pins it for all five
routes against points read straight out of the store with `query()` — a third path, in neither
the loop nor the batch — so a future "optimisation" that moves a point, an order, or a key
fails a test instead of redrawing a chart wrong.
@@ -804,6 +895,25 @@ Responsibilities, one line each:
job with no payload yet is listed as a stub (`unreachable: true`, every value `null`) in
`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
`/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`,
`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
renders.
- `app.py` — assembly, and the **session gate** (`session_gate` middleware, invariant 12) whose
`PUBLIC_PATHS`/`PUBLIC_PREFIXES` are the whole anonymous surface. It answers in this order and
the order is the design: no `AuthState` → `503` for everything; no session and no such route →
@@ -829,14 +939,18 @@ Responsibilities, one line each:
directory the lifespan's `finally` removes. What hands that material to the loops is
`agent_tls_map(material, store, targets)` → `build_hub(..., tls=...)`, called in **both**
branches the same way: no material (or no targets) means no TLS map, which is the plain
`http://`/token path rather than an error, and one map covers both of a host's jobs. A corrupt stored certificate raises out of the same
`http://`/token path rather than an error, and one map covers all of a host's jobs — including the
`gateway` job, which is why one revoke in `/certs` silences the pipes with no restart. A corrupt stored certificate raises out of the same
place — a dashboard that cannot present its own client certificate would otherwise spend the
fleet's TLS history reporting an upstream problem it caused itself.
`build_hub` also builds **two** `RateEstimator`s, because a window is only a claim about the
polls that fill it (phase 23, `TODO.md:8`'s "ramp up/ramp down"): the `host` jobs get
`RateEstimator(span=settings.rate_window)` (10 s — what a 10 s cadence can fill) and the `llm`
jobs `RateEstimator(span=settings.llm_interval)` (2 s, one poll interval, so a slot that starts
reads steady on the next poll instead of climbing one fifth of the true rate per poll), and
reads steady on the next poll instead of climbing one fifth of the true rate per poll), and the
`gateway` job (phase 29) is handed **neither** — it is added to the hub between the `disks` and
`llm` jobs, only when `settings.gateway_enabled`, because a gauge needs no window and one job
cannot be right about two cadences' worth of them.,
there is no third knob — the LLM window **is** `LLM_INTERVAL_SECONDS`, which is the only way
the pair cannot drift. One estimator for both could only be right about one of them: a 2 s span
over a 10 s poll sits the window-empty fallback against the `2 × span` ceiling and answers
@@ -844,9 +958,14 @@ Responsibilities, one line each:
The estimator's own gates (the `MIN_WINDOW_SECONDS` floor, the ceiling, the 8× jump
confirmation with its independent anchor) are unchanged and apply to the smaller window, whose
`Δ2.0s` label the payload carries with the value.
- `web/` — five dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the login page
(`/login`, `pages/login.html` + `static/page-login.js`), one stylesheet, `charts.js`. No
framework. `/login` renders the masthead's **brand link and theme toggle** and nothing else
- `web/` — six dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/gateway`) plus the
login page (`/login`, `pages/login.html` + `static/page-login.js`), one stylesheet, `charts.js`.
No framework. The tab nav is those six and nothing else, in that order, on **all eight**
signed-in pages (phase 29 task 05): `tests/test_api.py::test_the_disks_tab_is_in_the_masthead_of_every_page`
compares the nav's hrefs to `TAB_ORDER` (which **is** `PAGES`) and asserts the `aria-current`
count per page — one, naming itself, on the six dashboards and zero on `/settings` and `/certs`.
`/gateway`'s own tab arrived with that task; until then the page existed behind a route with no
way to click to it. `/login` renders the masthead's **brand link and theme toggle** and nothing else
from it: no tabs (every tab bounces straight back here, and a nav of links that all redirect is
a lie about what is available), no status dots and no clock (there is nothing signed in for them
to say). It posts through `Dash.fetchJSON` like every other script, which is only safe because
@@ -864,8 +983,8 @@ Responsibilities, one line each:
login page is audited for orphan `id`s and CDN references too (`tests/test_auth_api.py`), fetched
unsigned because a signed-in `GET /login` is a `303` and would audit `/`'s markup under the wrong
name. `/settings` (`pages/settings.html` + `static/page-settings.js`) is a **page and not a
sixth dashboard**: same `<head>` and masthead, the five tabs with **no** `aria-current` (it is
not one of them), the same four `#nav-status` badges as every dashboard (phase 25 task 01 —
seventh dashboard**: same `<head>` and masthead, the six tabs with **no** `aria-current` (it is
not one of them), the same five `#nav-status` badges as every dashboard (phase 25 task 01 —
`TODO.md:3`'s "hosts disks engines UPS disappear when in the certs/account page" was the
missing container, and `Dash.initChrome()` fills it from `/api/health` with no JS change), and
it is reached from the `⚙ Account` link in
@@ -884,19 +1003,19 @@ Responsibilities, one line each:
`csrf` is now invalid — every later write, the UPS buttons included, 403s until a reload) and on
any `401` bounce.
`/certs` (`pages/certs.html` + `static/page-certs.js`) is the second of those admin pages, and it
is the same shape for the same reasons: five tabs with **no** `aria-current`, the four fleet
is the same shape for the same reasons: six tabs with **no** `aria-current`, the five fleet
badges in `#nav-status` like every other masthead (phase 25 task 01, same reasoning as
`/settings` above), and both masthead links — `⚷ Certs` beside `⚙ Account` — exactly as the five
`/settings` above), and both masthead links — `⚷ Certs` beside `⚙ Account` — exactly as the six
dashboards render them. **Both links are on both admin pages** (phase 20 task 04, `TODO.md:4`),
which retires phase 15's "no self-link" precedent: chrome that changes shape depending on where you already are reads
as a broken button, while a link to the page you are on is a no-op that answers with the page you
were looking at (`data/logs/20-task04-admin-links.cjs` measures exactly that — one document
response, no redirect, the same cluster afterwards). The two links therefore sit on all seven
signed-in pages (the five dashboards plus `/settings` and `/certs`) in one order
response, no redirect, the same cluster afterwards). The two links therefore sit on all eight
signed-in pages (the six dashboards plus `/settings` and `/certs`) in one order
(`⚷ Certs` → `⚙ Account` → theme toggle → clock — phase 25 task 03 moved the clock to the end so
it is the farthest-right item on a `flex-end` row), which is what lets
the pinned audit be one loop over `MARKUP_PAGES` instead of two over different tuples; `/login`
is the eighth page and the one with neither. Everything on it is
is the ninth page and the one with neither. Everything on it is
`GET /api/certs`: the Root CA block, the display-only Dashboard client line (that certificate is
minted with the root and has no route of its own), and one row per host with its status chip,
serial, expiry (local timestamp *and* `Dash.duration` left), issued-vs-deployed `bundle_rev`,
@@ -926,7 +1045,12 @@ Responsibilities, one line each:
`data/logs/27-task01-scroll.cjs`).
`app.js`'s masthead status dots are one per dashboard job —
including `disks`, because the same agent can answer the 10-second utilisation poll and
have a wedged SMART pass, and one dot for both would be right about neither. `app.js` owns the
have a wedged SMART pass, and one dot for both would be right about neither, and including
`gateway` (phase 29 task 05), which is one dot rather than one per host because the ingress is
one job and a dot for a source that stays `idle` when `GATEWAY_HOST` names nothing is how an
unconfigured source reads grey rather than red. The badge table is a literal in `initChrome()`,
so a new dashboard's job means a new entry there — `NAV_STATUS_BADGES` in `tests/test_api.py`
is the pin that says so. `app.js` owns the
colour vocabulary: `Dash.severityFrom(cellOrSeverity)` turns a payload verdict into an
`ok|warn|fail` class (`''` for anything unrated, so unknown never looks green) and
`Dash.hero(el, {label, value, unit, digits, severity, sub, bar})` renders/patches a
@@ -941,7 +1065,7 @@ Responsibilities, one line each:
**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
mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right
justified"): row 1 the brand, `#nav-toggle` and the five tabs; row 2 the `#nav-status`
justified"): row 1 the brand, `#nav-toggle` and the six tabs; row 2 the `#nav-status`
per-source dots; row 3
the `.masthead-right` controls (`⚷ Certs`, `⚙ Account`, theme, then the clock — phase 25 task 03
moved the clock last for `TODO.md:5`'s "the time should show up as the farthest right item";
@@ -960,11 +1084,20 @@ Responsibilities, one line each:
brand and the tabs on separate rows. `height: var(--header-h)` became **`min-height`** (three rows
do not fit in 58 px and a fixed height clips the controls) and is declared **once** — the ≤640px
block no longer restates `height`/`min-height`/`flex-wrap`, and that pair drifting is exactly what
would clip the bar on a phone only. Every page that renders the bar now renders three rows —
would clip the bar on a phone only — except that **`#nav-status` wraps at ≤640px since phase 29
task 05**: five badges are 352 px of chips against this bar's 320 px content box, and a `nowrap`
item that may not shrink does not get smaller, it widens the *document* (measured 372 px — the
sideways scroll phase 18 forbids). The fifth chip takes a second line of the same right-aligned
row and the bar grows by one line box; no label shrinks and nothing is hidden. Phase 18's
`nowrap` was right for four (267 px) and the pin now demands the opposite, with
`data/logs/29-task05-tabs.cjs` measuring 360 px documents on all eight pages.
Every page that renders the bar now renders three rows —
`/certs` and `/settings` grew the dots row in phase 25 task 01, where `#nav-status` is pinned as
the **first child** of `.masthead-right` on all seven signed-in pages (`app.js` is untouched:
the container's existence is what makes `initChrome` build the four badges, which is why the fix
was markup and not JS) — and `/login` remains the one page with no dots, rendering two rows
the **first child** of `.masthead-right` on all eight signed-in pages (`app.js`'s *badge list* is
a literal, and phase 25's fix was markup because the container was what was missing; phase 29
task 05 added the fifth entry — `{ key: 'gateway', label: 'gateway', href: '/gateway' }` — because
the ingress has a job of its own and a job with no badge is a source that blinks in silence) —
and `/login` remains the one page with no dots, rendering two rows
(brand, then the theme toggle). Pinned on the served bytes by phase 20's three tests in
`tests/test_api.py` — the row declarations (task 02 renamed that one to
`test_the_masthead_is_three_rows_right_justified_at_every_width`), the mobile block's silence
@@ -980,12 +1113,14 @@ Responsibilities, one line each:
(the row-break assertions are the same ones phase 20 wrote, deliberately: the re-justify must
not cost the rows), and the measured half is `data/logs/25-task02-justify.cjs` — seven pages ×
two widths, each demanding both rows compute `flex-end` and sit flush with the bar's right
padding edge, the four badges present, three rows intact, the dropdown still under the whole
padding edge, the four badges present (a four-badge claim — `29-task05-tabs.cjs` is the pin that
the fifth, `gateway`, joined them), three rows intact, the dropdown still under the whole
bar and no sideways scroll, plus `/login`'s two-row bar with no badges
(`data/logs/25-task02-justify.out`, screenshots `data/logs/25-task02-*.png`). **Phase 25 task 03
then put the clock at the end of the control row** (`TODO.md:5` — "The time should show up as the
farthest right item on the navbar"): the `<span class="clock" id="clock">` moved from the first
control to the last one on all seven templates, so the row reads `⚷ Certs` → `⚙ Account` → theme
control to the last one on all seven templates then (eight since `/gateway` joined the nav), so
the row reads `⚷ Certs` → `⚙ Account` → theme
toggle → clock. It is a **markup** move and deliberately not a CSS `order:` declaration, because
on a `flex-end` row the last child *is* the farthest-right item and an `order:` override would be
something the served-byte pin cannot see — `test_every_page_links_to_both_admin_pages` therefore
@@ -1015,7 +1150,8 @@ Responsibilities, one line each:
off it after a scroll; screenshots `data/logs/masthead-unsticky*.png`). Note that a browser cannot
tell `relative` from `relative; top: 0` — Chrome computes `top: 0px` for either — so the absence
of the offset is a served-CSS pin, not a measurement.
**The tab nav collapses into a hamburger at ≤640px** (phase 18): the seven tabbed pages each
**The tab nav collapses into a hamburger at ≤640px** (phase 18): the eight tabbed pages (the six
dashboards plus `/settings` and `/certs`; `/gateway` joined in phase 29 task 05) each
carry `#nav-toggle` (`class="ghost nav-toggle"`, `type="button"`, `aria-controls="nav-tabs"`,
`aria-label="Toggle navigation"`, `☰`) between `.brand` and `<nav class="tabs" id="nav-tabs">`,
and `/login` carries neither — it renders the brand link and the theme toggle only. Its base
@@ -1059,8 +1195,9 @@ Responsibilities, one line each:
The `if (navToggle && appNav)` guard is the whole `/login` story: the shared script loads there
and does nothing. The `640px` literal lives in two files — the stylesheet block and that media
query — and has to move in both, or the menu opens into a hidden nav. The contract is
pinned on the **served** bytes by three `tests/test_api.py` pins — the button on all seven pages
and nowhere on `/login`, and one test that **compares** the CSS media query with the JS
pinned on the **served** bytes by three `tests/test_api.py` pins — the button on every page in
`MARKUP_PAGES` (eight since `/gateway` joined the nav) and nowhere on `/login`, and one test that
**compares** the CSS media query with the JS
`matchMedia` instead of grepping for `640px` twice, so a drift fails with both numbers in the
message — and proven in a browser at 360px by `data/logs/nav-task04-verify.cjs` (the `/` +
`/system` interaction, 24 checks) and `data/logs/nav-final-pass.cjs` (the phase-completion
@@ -1081,9 +1218,12 @@ Responsibilities, one line each:
Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`,
`gpu.busy_pct@0`, `llm.decode_tokens_per_second`, `ups.charge_pct`), `@stream` for per-device
series — `/api/metrics` answers with exactly these names. The `sample.source` column is the
dashboard family — `host`, `llm`, `ups`, and `disks` for the SMART series
dashboard family — `host`, `llm`, `ups`, `disks` for the SMART series
(`disk.temp_c@nvme0n1`, `disk.power_on_hours@…`, `disk.reallocated@…`, `disk.pending@…`,
`disk.uncorrectable@…`, `disk.percentage_used@…`). Those keep their own family instead of
`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
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.
@@ -1134,9 +1274,11 @@ That scheme is the history API's key — treat it as a public interface.
`sudo deploy/install-agent.sh --bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force`
reproduces it and a re-run that would quietly replace a hand-fixed unit is now a refusal with a
diff attached rather than a clobber (gap 10).
`/api/health` today reads **all nine sources `ok`**: `host:driveripper`, `host:deskwork`,
`host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`,
`llm:vllm`.
`/api/health` today reads **all ten sources `ok`**: `host:driveripper`, `host:deskwork`,
`host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `gateway`, `nut`,
`llm:gufo`, `llm:vllm`. The tenth is phase 29's: one job for the ingress host, dialling the same
`https://` agent as `host:gateway` with the same certificate entry, and re-verified live on the
dev server with no change to any pre-existing source.
**The fleet is mTLS since 2026-10-21 (phase 24 task 03), so the bearer-token transport is now
history rather than the designed shape.** `gateway` runs `agent/host_agent.py` **1.2.0** (phase 28
task 05, 2026-10-22) and `deskwork`/`driveripper` still run **1.1.0**; the spread costs nothing,
@@ -1376,9 +1518,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: 1650 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1746 passed + 16 skipped (12 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (98.97%; floor 94%)
uv run pytest --cov # coverage gate (99.08%; 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
@@ -1497,6 +1639,14 @@ 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).
- 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.**
@@ -1676,7 +1826,7 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
existing series issues no `SELECT`, a write of a name nobody wrote ends the cache — and the live
version is `scripts/watch-store-perf.py`, which is where the defect showed up.
- Coverage gate: 80% is the repo floor, the phase gate in `.agents/validate.sh` enforces
94% (`--cov-fail-under`), and ~98% is where we are; keep it there. New/modified Python
94% (`--cov-fail-under`), and ~99% is where we are; keep it there. New/modified Python
still has to clear >90% on its own. API tests use `create_app()` with an in-memory DB,
never a real target, and never a real upstream: `/api/ups/command`'s send is proven against
a scripted `nut.NutClient` (`ScriptedNutClient` in `tests/test_api.py`) because the live
@@ -1693,7 +1843,10 @@ one — `/system`'s three per-host slots, `/disks`'s two per-host plots, `/llm`'
plots, `/ups`'s four battery plots — each pinned by `tests/test_api.py`, so a chart that ships
anonymous fails a test.
Window chips (5m…30d on `/system` and `/llm`, 1h…90d on `/ups` and `/disks`; the API accepts
5m…90d — SMART lands every 300 s, so a 5-minute window on `/disks` is one dot). A
5m…90d — SMART lands every 300 s, so a 5-minute window on `/disks` is one dot). `/gateway` has
**no** chips and one window: a connection trend is read as "the last day of this pipe", so each
card's plot is a fixed 24 h named by the `Charts` chip beside its toggle rather than a page control
that would re-fetch every card at once. A
panel that cannot get data says why in the panel — the operator should never have to open
devtools to learn that a source is unconfigured. Escape anything upstream-provided through `DashCharts.escapeHtml`.
@@ -1726,6 +1879,14 @@ the table never implies last good numbers are live and never silently loses a ma
No summary cards and no raw event feed survive on `/` — `tests/test_api.py` pins the
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`
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).
Nothing waits for a writer that never came: `tests/test_api.py` fails if a page keeps an
`id` that no page script, no shared script, no stylesheet rule and no `aria-*`/`for`
attribute references — the orphan a deleted renderer leaves behind, caught on served bytes
@@ -1877,6 +2038,41 @@ 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.
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
*leading* duplicated subject the way `alerts._clean_error()` does for the overview — `poller.py`
@@ -2660,8 +2856,9 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
Task 02 wrote that audit: `data/logs/27-task02-mobile-audit.cjs`
(`NODE_PATH=<playwright node_modules> node …`, `DASH_BASE`/`DASH_PW`, transcript
`27-task02-mobile-audit.out`, screenshots `data/logs/mobile-27-<page>-<width>.png`) sweeps
**every page the app serves** — the seven signed-in ones plus `/login` unsigned, eight in
total, not the nine the phase file counted — at 360 px and 1280 px, and per page demands: no
**every page the app serves** — every signed-in page plus `/login` reached unsigned (nine of
them since phase 29 task 05 put `/gateway` in the list; the sweep is exactly the kind of page a
hand-written four-page loop would have missed) — at 360 px and 1280 px, and per page demands: no
sideways document scroll, no `table.data` clipped (its `overflow-x` must be `auto`/`scroll`
when its content is wider, and its tail column measured reachable), no element past the
viewport except inside a horizontal scroll container, the three-row masthead with its controls
@@ -2722,7 +2919,10 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
0 FAIL / 0 FINDING** (9 skips, each one a check that width cannot answer — the clock, hidden
below 900 px) on all eight served pages at both widths, exit 0
(`data/logs/27-task02-mobile-audit.out`, screenshots `mobile-27-*.png`; the first run is kept
as `27-task02-first-run.out`), and `data/logs/27-task03-control.cjs` proves the new check is
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
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
@@ -2734,7 +2934,7 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
beside it, `20_masthead_nav_polish` has landed in full (now complete): the bar is three
rows at every width (flush right on the two control rows since phase 25 task 02), it is no
longer sticky, the phone menu slides, and both
`⚙ Account` and `⚷ Certs` render on all seven signed-in pages including the two they name
`⚙ Account` and `⚷ Certs` render on all eight signed-in pages including the two they name
(task 04, reversing phase 15's "no self-link" rule at the operator's request). The other,
`24_dev_mtls_rollout` (never accepting a non-mTLS agent) is closed: its fleet half landed on
2026-10-21 and task 04 re-read the end state read-only the same day — gap 11. No `http://`
@@ -2742,6 +2942,40 @@ 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`).
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.
**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.
## Locked decisions (and why)
- **SQLite, not Prometheus/Grafana.** Handful of boxes, 1–10s resolution, one file the