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