phase: 30_gateway_connection_map

**Phase 30 final verification pass — all 6 tasks already in `complete/`, so this was the phase-completion pass.**

- **Verified (re-run, not trusted):** `/gateway` serves exactly two panels (nginx→haproxy, `GATEWAY_CARD_KEYS`); haproxy rows `.solo` straight across, nginx `:443` branched with one tick per leaf (18 live, fixture 17); every pipe computes the theme `--ok` in dark+light, animates only at count ≥1, fully static under `prefers-reduced-motion`; switch = `role="switch"`+`aria-checked`+`aria-disabled`, no listener, footer+title name the missing write path; zero non-GET requests anywhere.
- **Live read-only re-run** (`data/logs/30-final-live-verify.out`): 17 PASS — `warnings: []`, 6 haproxy frontends→own `server`, `:443` 18 map leaves incl. `default→127.0.0.1:4443`, `:4443` 6 / `:8443` 3, `:80` bare listener, no `unmatched`/`outbound`/`by_port`/`partial`, no `9120`; inbound reconciliation **page 16 = kernel 16**; `gateway` block = the 12 declared keys; no `gateway:config` alert on a clean config. `git diff --stat agent/ deploy/` empty; agents 1.2.0 (gateway) / 1.1.0 ×2, all `tls`, `bundle_rev 2`.
- **Tests/lint (exact):** `uv run pytest` → **1791 passed, 16 skipped**, exit 0 · `bash .agents/validate.sh` → **exit 0**, `--cov` **98.94%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` all page scripts, hygiene clean · `uv run pytest -m live --live` → **12 passed** · `uv pip list --outdated` empty · `./scripts/dev-server.sh status` → **10 sources ok**, `gateway` included · browser pass `30-connection-map.out` 63 PASS/0 FAIL (5 screenshots at 1440/360) · `30-final-mobile-audit.out` 371 PASS/0 FAIL/0 FINDING, exit 0, nine pages.
- **Docs fixed:** README `alerts.py` bullet omitted phase 30's keyword-only `gateway_payload` and its one `warn` config-integrity row (+suppression) — added; overview ingress line labelled a dated reading. AGENTS gap 13 corrected 391 px → **361 px** (task 05's pill took 30 px), "task 06 measured" → "phase 29 measured", coverage figure un-pinned to 98.9 %, live-suite count noted (12 today), "all five pages" severity sentence clarified for `/gateway`'s existence-only palette. Everything else reviewed in full and **verified consistent**.
- **Defect fixed:** duplicated footer-assertion block at the end of `tests/test_api.py::test_the_switch_is_built_and_explicitly_unwired` (one copy removed).
- **Notable:** `NOTES.md` is empty in the working tree — both items in it (non-mTLS agents, disappearing admin links) landed in phases 24 and 20; left cleared. Fixture/live `map`-count drift (17 vs 18) is recorded in AGENTS.md, not silently re-captured.
- **Next pending phase:** none — `30_gateway_connection_map` was the only entry in `.agents/phases/todo/`.
This commit is contained in:
2026-10-23 22:56:21 -04:00
parent fa4f38c694
commit e366f599f3
51 changed files with 5419 additions and 1519 deletions
@@ -0,0 +1,64 @@
# Phase 30 — The gateway connection map: two config-derived tables, joined lines, green = in the config
**Source:** operator request, 2026-10-23 (post-phase-29 review of `/gateway`)
**Story:** n/a
**Context:** `src/dashboards/sources/gateway.py` (the join this phase rewrites), `agent/host_agent.py` **1.2.0** (its `haproxy` / `nginx` / `sockets` sections — the *only* upstream this phase reads, and it needs **no agent change**), `tests/fixtures/gateway_haproxy.cfg` + `gateway_nginx_main.conf` + `gateway_nginx_conf_d_http.conf` + `gateway_nginx_stream_d_stream.conf` + `gateway_proc_net_tcp{,6}.txt` (the verbatim captures the graph is pinned to), `src/dashboards/api.py` (`/api/gateway/*`, the `/api/summary` `gateway` block), `src/dashboards/alerts.py` (the reachability-only rule this phase changes by decision), `src/dashboards/web/pages/gateway.html` + `static/page-gateway.js` + `static/dash.css` (the pipe vocabulary), `tests/test_gateway_source.py`, `tests/test_api.py` (the served-bytes pins this phase deliberately rewrites).
## Objective
`/gateway` becomes the **connection map** it was meant to be: **two tables — nginx and haproxy** — in which every row is a connection **that exists in a config file**, drawn as a line that physically joins its inbound end to its outbound end (haproxy straight across; nginx branching out of `:443` to the services the stream `map` routes to), each line **green because the config says it exists**, each line carrying the **switch** shape from the operator's sketch (built, visible, not yet wired). The socket table is demoted to a number beside the line instead of deciding its colour or its existence; the `stream map` card and the `unmatched` card are gone; anything the two configs do not describe is **not reported at all**, and the warnings that remain are **config-integrity** problems — a backend no frontend routes to, a `proxy_pass $var` with no `map` — surfaced as a warnings panel on the page and one alert row on `/`.
## Scope decisions taken with the operator (binding for this phase)
| # | Decision | Consequence |
|---|----------|-------------|
| 1 | **Green means "in the config."** | Every configured connection draws green. Colour is an **existence state**, not a verdict: `severity.py` still has no `gateway.*` row and no payload key carries `severity`. Red is the **off** state (a route switched out of the config), styled now, unreachable until the write path exists. |
| 2 | **Motion means "a socket is open this instant."** | The dash animates while that pipe's live count is ≥ 1 and sits still otherwise. No rolling/recent-peak derivation, no speed tied to volume. |
| 3 | **The switch is built, not wired.** | Real `<button role="switch">` per connection, `aria-checked="true"` (in config = on), `aria-disabled="true"` (not `disabled`, which would drop it from the a11y tree), no click handler, a `title` naming why. The UPS controls are the precedent for a gated control; the write path is a **later phase**. |
| 4 | **Live counts stay, demoted.** | The inbound trunk carries the real ESTABLISHED count + client addresses; the 24 h per-card chart stays; the header keeps tallies. A leaf endpoint written as a **name** shows `—` (never `0`, never a fake `n/a` column). |
| 5 | **Only the unambiguous socket join survives.** | Inbound = the socket's local port is a configured listen port. Outbound = the socket's remote `(address, port)` **equals a target the config wrote as an address**. `by_port`, `unmatched.inbound`, `unmatched.outbound`, `unmatched.groups`, `partial`, `unmatched_listeners` are **deleted**. |
| 6 | **Anything outside the two configs is ignored.** | `9120` (this app's own agent), `2222`, DHCP/mDNS/whatever else listens is neither drawn, nor counted, nor warned about. `sockets.listens` / `sockets.established` stay in the payload as the kernel's raw totals for programmatic consumers only. |
| 7 | **The warnings list is config integrity.** | Seven kinds (`03_config_warnings.md`) plus three explicit refusals where the payload cannot tell broken from merely unreadable; all derived from config text, all actionable. Panel on `/gateway`, and **one** `warn` row in `/api/summary`'s `alerts` — which changes the "a `gateway` alert is reachability only" rule in `alerts.py` and in `AGENTS.md`. |
**Out of scope:** any change to `agent/host_agent.py` (1.2.0 already publishes everything needed, including `upstreams` and `maps`), any write path or config edit, any new dependency, any new sample family (the existing `gateway.in_conns@<port>` / `gateway.out_conns@<host>:<port>` names stay, so stored history keeps its meaning).
## Dependencies
- `28_gateway_agent_proxy` (complete) — agent 1.2.0's `haproxy` / `nginx` / `sockets` sections and the captured fixtures.
- `29_gateway_dashboard_page` (complete) — the `gateway` poller job, `/api/gateway/*`, the page's chrome, the six-tab masthead. This phase **reshapes** that payload and page; it does not remove the job, the routes, the tab, or the gauges.
- `27_mobile_tables_and_audit` (complete) — the 360 px audit script `data/logs/27-task02-mobile-audit.cjs` must still exit 0 with `/gateway`'s new rows in it.
## Tasks
1. `01_the_connection_graph.md` — `sources/gateway.py`: the config-derived graph — `listeners[]` + `connections[]` (haproxy `frontend → backend → server`, nginx vhost `server_name → proxy_pass`, stream `proxy_pass $var → map` entries, `proxy_pass <pool> → upstream` servers), with no counts yet.
2. `02_the_join_simplified.md` — the surviving socket join (inbound by local port, outbound by exact address only), the shared-endpoint rule, the deletion of `outbound` / `by_port` / `unmatched` / `partial`, the gauges re-pointed, and `/api/summary`'s `gateway` block plus the overview's one line reshaped in the same commit.
3. `03_config_warnings.md` — `warnings()`: the seven config-integrity kinds (and three refusals), empty on this fleet's captured configs (a pinned assertion), plus one `warn` overview row from `alerts.py`.
4. `04_page_two_tables.md` — `gateway.html` + `page-gateway.js` + structural CSS: exactly **two** panels (nginx, haproxy), trunk → leaf rows, the warnings panel on the shared `ul.alerts` vocabulary, four header tallies, the degradation arms kept.
5. `05_lines_and_switches.md` — `dash.css` + `page-gateway.js`: the connecting line (straight across for haproxy, branched for nginx), the green `--gate-on`/`--gate-off` existence pair, `live` motion, the switch built to the sketch and pinned as unwired. The `ok|warn|fail`-in-the-pipe-section pin is **restated**, not deleted.
6. `06_docs_and_verify.md` — `AGENTS.md` (the `sources/gateway.py`, `api.py`, `alerts.py`, metric-naming, UI-conventions and gap-13 passages) + `README.md`, the browser pass at 1440 px and 360 px with screenshots, the mobile-audit re-run, and the read-only live verification against the real ingress.
## Testing & Quality
- Unit: `tests/test_gateway_source.py` is **rewritten around the graph** — every connection the captured configs declare is present and no other; the join identity is restated as *"the inbound counts sum to the ESTABLISHED sockets whose local port a config names"* (the old `inbound + outbound + unmatched == the table` identity dies with `unmatched`); `socket.getaddrinfo` stays spied and **never called**; the six warning kinds each get a synthetic broken config and the captured configs pin **zero** warnings. `tests/test_api.py`'s gateway pins are rewritten in the same tasks that change the payload/page — including `GATEWAY_PAGE_FIELDS`, `GATEWAY_FIELDS`, the stub/answer key-parity pin, the no-verdict/no-window walk, and the colour pin (restated per decision 1).
- Integration: the page renders from a fixture-driven dev store; the warnings panel appears only when a warning exists; the chart disclosure stays collapsed-lazy-one-request-per-card.
- Coverage: **>90%** on new/modified Python (`sources/gateway.py`, `alerts.py`, `api.py`).
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's) and `bash .agents/validate.sh` is green, coverage floor included.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. For this phase that specifically means: `AGENTS.md`'s `sources/gateway.py` bullet (the payload keys, the join, the deleted `unmatched`/`by_port`/`partial`), its `api.py` `/api/gateway/*` paragraph and the `/api/summary` `gateway` block field list, its `alerts.py` bullet (the gateway alert is no longer reachability-only), the `/gateway` UI-conventions paragraph (two tables, joined lines, green = in the config, the unwired switch), the metric-naming paragraph, and **gap 13** (the clipped-pipe-label gap, which the new row layout either resolves or restates honestly).
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible.
## Completion Criteria
- [ ] `/gateway` serves and renders **exactly two** cards, `nginx` and `haproxy`; no `stream map` card, no `unmatched` card, no third table.
- [ ] Every haproxy row draws its line **straight across** from `:<bind port>` to its `server` target; the nginx `:443` trunk **branches** to each `map` entry (16 on this fleet) and each http vhost trunk to its `proxy_pass` target — measured in the browser pass, not asserted in prose.
- [ ] Every configured line is **green**; it animates only while its live count ≥ 1; both states still static under emulated `prefers-reduced-motion: reduce`. No `severity` key at any depth of the payload; no `severityFrom`/`severityFor` in the page script.
- [ ] One switch per connection, `role="switch"` + `aria-checked="true"` + `aria-disabled="true"`, no click handler bound, `title` naming the missing write path — and a test that fails if it becomes clickable without a handler or stops saying it is unwired.
- [ ] `POST` nothing: the verb-enumeration pin still finds `/api/ups/command` as the app's only upstream write, and `/gateway` dials only `/api/gateway/snapshot` + `/api/gateway/history`.
- [ ] `GET /api/gateway/snapshot` on the live ingress: `warnings == []`, every one of the 6 haproxy frontends connected to its `server`, `:443` connected to the 17 `map` entries, `:4443`/`:8443` connected to their `proxy_pass` targets, and no `unmatched` / `by_port` / `partial` key anywhere.
- [ ] A `gateway` `warn` row appears in `/api/summary.alerts` **iff** `warnings` is non-empty, and disappears when they are; a stale/stopped `gateway` job still alerts exactly as before.
- [ ] `uv run pytest` (incl. `--cov` ≥ 94 floor), `uv run ruff check src tests agent`, `uv run pyright`, `node --check` on the changed scripts, `bash .agents/validate.sh` — all green.
- [ ] `data/logs/30-*.png` screenshots at 1440 px and 360 px; `data/logs/27-task02-mobile-audit.cjs` still exits 0 with `/gateway` in its sweep.
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass).
- [ ] No behaviour change in completed work: the other five dashboards and `/settings`/`/certs` render as before; `host:*`, `disks:*`, `llm:*`, `nut` still read `ok` in `./scripts/dev-server.sh status` (10 sources, `gateway` included).
@@ -0,0 +1,78 @@
# Task 01 — The connection graph: `listeners` and `connections`, derived from config text only
**Phase:** `30_gateway_connection_map` · **Source:** operator request 2026-10-23 — "the dotted line connections need to connect to their counterparts… for nginx you'll need to branch the lines from `:443` to the services they connect to… this is just 'exists in config therefore it is a connection'"
**Story:** n/a
## Objective
`sources/gateway.py` learns the thing it never published: **which outbound target belongs to which inbound listener**. It gains `connections[]` — one entry per connection the two configs actually declare, each carrying its inbound port, its outbound endpoint, and the config's own selector for it (a haproxy backend name, an nginx `server_name`, a stream `map` key) — and renames `inbound` to `listeners`, because "inbound" was a direction and this is a thing the page draws a trunk for. Task 01 adds the graph and renames; task 02 deletes the guessing socket join. **No agent change**: everything needed is already in agent 1.2.0's `haproxy` / `nginx` sections, including `upstreams` and `maps`.
## The graph, stated exactly
**`listeners[]`** — one per configured listen port, same claim rule as today's `inbound`, same `metric` string:
```
{"port": 443, "source": "nginx", "domains": ["_"], "connections": 0|int|None,
"clients": [...], "metric": "gateway.in_conns@443"}
```
**`connections[]`** — one per `(source, in_port, out_host, out_port)`, selectors merged, in config order:
```
{"id": "nginx:443->reeseapps-homepage.reeselink.com:443",
"source": "nginx", # the card it belongs to: "haproxy" | "nginx"
"in": {"port": 443},
"out": {"host": "reeseapps-homepage.reeselink.com", "port": 443,
"name": None, # the haproxy `server` name when there is one
"kind": "map", # "backend" | "server_name" | "pool" | "map"
"selectors": ["homepage.reeseapps.com"], # the config's own word(s) that reach this target
"address": False}, # True when the config wrote an IP literal
"connections": None, # filled by task 02; task 01 always None
"metric": "gateway.out_conns@reeseapps-homepage.reeselink.com:443"}
```
Derivation, per config family:
1. **haproxy** — `frontend → routes → backend → server`. For every frontend row (one per `bind` port, `routes` = `default_backend` then each `use_backend` name, which `haproxy_frontends()` already builds — note the agent's own `frontend.backends` holds **only** the `use_backend` names and keeps `default_backend` separate, which is why that insert exists) and every `server` of each routed backend block: a connection with `in.port = the bind port`, `out = {host: server.target, port: server.port, name: server.name, kind: "backend", selectors: [backend name]}`. This needs `haproxy_targets()` to stop dropping the backend it was declared in — change its rows to `{name, host, port, backend}`.
2. **nginx http vhost** — for every `server` **block**, for every `proxy_passes` entry with a concrete `target` and a port: one connection **per `listen` port of that block**, `out.selectors = the block's server_names` verbatim (the catch-all `_` included), `kind: "server_name"`.
3. **nginx pool** — a `proxy_pass http://<name>` with **no port** names an `upstream` block. When an `upstreams[]` entry has that name: one connection **per upstream `server` host:port**, `kind: "pool"`, `selectors: [<pool name>]`. When none does: **no connection** (that is task 03's `nginx_pool_undeclared`).
4. **nginx stream map** — a `proxy_pass` with `variable` set: find the `maps[]` block whose `variable` matches after `lstrip("$")`. For each of its `entries` with a target, one connection **per `listen` port of the block that wrote the `proxy_pass`**, `kind: "map"`, `selectors: [entry.key]`. The key `default` is kept **verbatim** — it is the config's own word for "everything nobody mapped", not a guess. A map no `proxy_pass` names contributes nothing (the `$connection_upgrade` rule, unchanged), and an entry with a null target contributes nothing (task 03 warns about it).
5. **Never** invent a connection for what the payload cannot decide: a `proxy_pass` with neither `target` nor `variable` (a unix socket, a bare word) yields nothing and **no warning**; a haproxy frontend whose only routing is a fetch expression (`use_backend %[req.hdr(host),map_dom(...)]`, which the agent reports as no name) yields its **listener** and **no connections** — the page draws that trunk alone, and `AGENTS.md` rule 2 is why it is not a warning: "no name" and "no routing directive" are one value in this payload.
Merge rule: identical `(source, in_port, host, port)` is **one** connection whose `selectors` are the union in first-seen order (one vhost's two names arriving at one pool is one line, not two). Never merge across `in_port` — `:4443 → keycloak:8080` and `:8443 → keycloak:8080` are two pipes on two socket spaces (the `minecraft` 25565/25566 lesson).
## Work
1. `src/dashboards/sources/gateway.py`
- `haproxy_targets()`: rows gain `backend`; docstring says the backend name is what makes the frontend→server join possible.
- New `nginx_server_blocks(section)` returning one row per **server block** — `{ports: [int], names: [str], proxies: [entry]}` — so the block's `listen`, `server_name` and `proxy_pass` stay associated (today's `nginx_listen_ports()` merges names across blocks and loses exactly the fact this task needs; keep it if the echo still needs it, or derive the echo from `nginx_server_blocks()` — do not keep two readers of the same block).
- New `config_connections(snapshot_haproxy, snapshot_nginx) -> list[dict]` implementing rules 1–5 + the merge, pure and clock-free, with the module docstring gaining a **"The connection graph"** section stating the four derivations, the merge rule, and the two refusals (rule 5).
- `parse()`: rename the `inbound` payload key to `listeners` (its rows keep every field they had, including `metric`), add `payload["connections"] = config_connections(...)`, and keep `outbound` and `unmatched` **untouched for this task** (task 02's business). `connections[].connections` stays `None` here — say so in a comment so task 02's owner sees where the count lands.
- `_skeleton()`: add `"listeners": []` and `"connections": []` (replacing `"inbound": []`) so `stub()` grows the same key set — the key-parity test fails otherwise.
2. `src/dashboards/api.py` — the mechanical rename in every reader of the payload: `/api/gateway/snapshot` (it passes the payload through plus `states`), the `/api/summary` `gateway` block (its `inbound`/`outbound` totals now read `payload["listeners"]` / `payload["outbound"]`; **keep the block's field names as they are** — task 04 reshapes that block deliberately, and this task must not change `/api/summary`'s shape).
3. `src/dashboards/web/static/page-gateway.js` — the minimum that keeps the page honest while task 05 rewrites it: `familyPipes()` reads `snapshot.listeners` instead of `snapshot.inbound`, and `renderHeader()`'s inbound tally does too. Nothing else about the drawing changes.
4. `tests/test_gateway_source.py` — keep every existing assertion working under the rename, and **add the graph pins** (this task's real product):
- `test_every_connection_the_captured_configs_declare_is_drawn` — against the real `gateway_snapshot` fixture, **32** connections total, asserted as the **set** of `(in_port, out.host, out.port)` so a missing or extra one fails by name:
- **6** haproxy, one `server` each: `:22 → reeseapps-gitea.reeselink.com:2222` (`gitea`), `:2000 → stackexpected-gitea.reeselink.com:2222`, `:5201 → web.reeselink.com:5201`, `:8080 → unifi-external.reeselink.com:8080`, `:25565 → minecraft.reeselink.com:25565`, `:25566 → minecraft.reeselink.com:25566`
- **17** on nginx `:443` from the stream map — the 16 `*.reeselink.com:443` entries **plus** `default → 127.0.0.1:4443`
- **6** on nginx `:4443` (nextcloud `:11000`, gitea `:3000`, jellyfin `:8096`, bricktracker `:3333`, keycloak `:8080`, emmaleaf `:80`) and **3** on `:8443` (nextcloud `:11001`, `127.0.0.1:8081`, keycloak `:8080`), each with its own `server_name` in `selectors`
- `:80` (nginx, `server_name _`) is a **listener with no connection** — it declares no `proxy_pass`. That is this fleet's live instance of refusal rule 5 and it gets its own assertion: the trunk exists, `connections` names port 80 nowhere.
- `test_a_haproxy_connection_carries_the_backend_and_the_server_name` — `out.name == "gitea"`, `out.kind == "backend"`, `selectors == ["gitea-2222"]`.
- `test_the_stream_default_entry_is_kept_verbatim` — a connection whose `selectors == ["default"]` to `127.0.0.1:4443`, `out.address is True`.
- `test_a_pool_names_its_upstream_servers_and_an_undeclared_pool_draws_nothing` — synthetic nginx config with `upstream backend_pool { server 10.0.0.1:3000; server 10.0.0.2:3000; }` + `proxy_pass http://backend_pool;` → two connections `kind == "pool"`; and `proxy_pass http://no_such_pool;` → no connection at all (and **no** invented host).
- `test_two_names_one_target_one_port_is_one_connection` (selectors merged, first-seen order) and `test_the_same_target_on_two_listen_ports_is_two_connections` (the `admin.keycloak` / `keycloak` pair from the real `http.conf`).
- `test_a_frontend_that_routes_by_expression_is_a_listener_with_no_connection` and `test_a_proxy_pass_with_neither_target_nor_variable_draws_nothing` — the two refusals.
- `test_no_connection_is_ever_invented_for_a_port_no_config_names` — walk every `connections[].in.port` and assert it is in `listeners`.
5. `tests/test_api.py` — the rename only: `GATEWAY_PAGE_FIELDS`' `inbound…` paths → `listeners…`, the stub/answer key-parity pin's expected key set, `test_the_gateway_stub_and_the_answer_share_one_key_set`, `test_the_gateway_snapshot_carries_no_verdict_and_no_window` (walk `connections` too), `test_gateway_snapshot_answers_the_pipes_the_projection_joined`, and `/api/summary`'s `GATEWAY_FIELDS` **unchanged in names** (only its derivation moved).
## Testing & Quality
- Unit/integration: the pins above; `uv run pytest tests/test_gateway_source.py tests/test_api.py -q` then the full suite. Coverage **>90%** on `sources/gateway.py`'s new branches (all four derivations, both refusals, the merge, the undeclared-pool arm).
- `uv run ruff check src tests agent && uv run pyright` clean; `node --check src/dashboards/web/static/page-gateway.js`.
## Completion Criteria
- [ ] `GET /api/gateway/snapshot` on the dev server (live ingress) returns `listeners` (10 rows today), `connections` (**32** on the captured configs — 6 haproxy + 17 stream-map + 9 vhost; the *test* asserts the captured fixture's set, the live read is a recorded observation, not an assertion, because `/etc/nginx` on a router changes), and `outbound` still present.
- [ ] Every connection's `in.port` appears in `listeners`; every haproxy connection names its backend; every stream connection names its map key; `default` survived.
- [ ] `stub()` and `parse()` answer the same key set (pinned).
- [ ] `uv run pytest` **100% passing**, coverage not down; ruff + pyright + `node --check` clean.
- [ ] `README.md`/`AGENTS.md` — no user-visible change yet (the page still draws the old cards); note nothing here, task 04/05 do the doc updates.
- [ ] No behaviour change in completed work: the other pages byte-identical, the 10 sources `ok`.
@@ -0,0 +1,43 @@
# Task 02 — Delete the guessing socket join: inbound by port, outbound by exact address, nothing else counted
**Phase:** `30_gateway_connection_map` · **Source:** operator request 2026-10-23 — "This isn't live tracking, this is just 'exists in config therefore it is a connection'… I don't know what the unmatched section is" + the two follow-up decisions: *"exact-address match stays"* and *"ignore anything not in haproxy or nginx configs"*
**Story:** n/a
## Objective
The socket table stops deciding the page's shape. `sources/gateway.py` keeps **exactly two** sound attributions — a socket's **local port** names a configured listener (inbound), and a socket's remote `(address, port)` **equals a target the config wrote as an address** (outbound) — and ignores every other socket: not counted, not totalled, not warned about. `outbound[]`, `unmatched`, `unmatched.groups`, `partial`, `unmatched_listeners`, `by_port` and `MAX_GROUP_HOSTS` leave the payload; `AGENTS.md`'s "the join is the whole honesty of the module" passage is rewritten in task 06 against this file.
**Why the deleted arms went** (state this in the module docstring, because the reason is the rule): a config writes `reeseapps-brain.reeselink.com:443` and `/proc` reports `10.1.1.106:443`. Nothing in this stack resolves a name (pinned by the `socket.getaddrinfo` spy), and 16 map entries share port 443, so the old `by_port` arm could only credit a port — which the module itself refused to attribute and dumped in `unmatched.outbound`. On the live router that residual was **mostly this app talking to itself**: the "listeners no config names" were `9120` (the dashboards agent's own port) and `2222`. A residual that is mostly other software is not a signal, and a card of it teaches the operator to ignore the panel.
## Work
1. `src/dashboards/sources/gateway.py`
- `join_sockets(listeners, endpoints, established)` — rewrite to the two arms above and **return nothing**; it stamps counts in place. Signature docs state the new identity: *"every socket is counted at most once; inbound + outbound ≤ `established`; a socket that matches neither is not counted at all, by decision."* Keep the `continue`-after-count discipline (a loopback hop is two `/proc` rows and must not be drawn down one pipe twice).
- Build `endpoints: dict[(host, port), {"connections": int, "routes": int}]` from the **address-written** `connections` only (`out.address is True`); `routes` counts how many connections claim the endpoint. After the pass, stamp `conn["connections"] = endpoint["connections"]` and `conn["shared"] = endpoint["routes"] > 1`. A name-written connection keeps `connections: None` — the truth, not a zero.
- Delete: `outbound` / `_outbound_pipe()` / `_groups()` / `MAX_GROUP_HOSTS` / `live_listen_ports()` (its only job was the unmatched direction test) / the `unmatched` key from `_skeleton()`. Add `payload["endpoints_live"]`? **No** — nothing needs it; the page reads the count off each connection.
- `samples()`: gauges from `listeners[].metric` (`gateway.in_conns@<port>`) and `connections[].metric` (`gateway.out_conns@<host>:<port>`) whenever the value is a number. Consequence to state in the docstring **and** in task 06's `AGENTS.md` pass: **name-keyed `gateway.out_conns@<domain>:<port>` series stop being written**, because the number they carried was the port's, not the target's; existing rows age out under the unchanged retention policy, so no schema or prune change (invariant 9).
- Module docstring: replace "The join, and where its honesty lives" with the two surviving arms + the ignore rule + the loopback-hop note; keep "No DNS, ever" and "No verdict" verbatim (they are unchanged rules).
2. `src/dashboards/api.py`
- `/api/gateway/snapshot`: passes the payload through plus `states` — update to the new key set (drop `unmatched`), nothing else.
- `/api/summary`'s `gateway` block becomes exactly: `{configured, host, href, supported, unreachable, listeners, connections, live_in, live_out, map_targets, states}` — `listeners`/`connections` are the row counts, `live_in`/`live_out` are the sums of the counts that answered (**`None`, never `0`,** when the socket table did not answer or nothing answered), `map_targets` stays the stream-map entry count, `href` still comes from `alerts.HREF`. `unmatched`, `unmatched_listeners` and `partial` are **deleted**. (`warnings` joins this block in task 03.)
- `/api/config`'s `gateway` row is unchanged (it never named a pipe field).
3. `src/dashboards/web/static/page-index.js` — the one line under the fleet table reads the new block: `gateway <listeners> ports · <connections> routes · <live_in> live`, every figure `n/a` when the block says `null`, the shared `.stale` dim when `unreachable`, and **nothing at all** when `configured: false` (invariant 3, unchanged). `href` comes from the block; the page invents no route and no number.
4. `src/dashboards/web/static/page-gateway.js` — **compatibility only**, so the page stays truthful while task 04 rebuilds it: the outbound column reads `snapshot.connections` filtered by `source` (map each to the shape the existing row renderer wants, or inline a small renderer — do not add new visual vocabulary here), the `by port` chip and its `title` are **deleted**, the `unmatched` card is deleted from the JS and its `<article>` from `gateway.html`, the header's `unmatched` tally cell is deleted, and `sumConnections()` reads `listeners` / `connections`. Any deleted id must leave no orphan container (the orphan-`id` audit).
5. `tests/test_gateway_source.py` — rewrite the join pins:
- **delete** `test_a_domain_target_is_matched_on_port_alone_and_labelled_by_port`, `test_a_port_shared_by_several_names_resolves_none_of_them`, `test_the_group_host_list_is_bounded_and_reports_the_real_total`, `test_a_listening_port_no_config_claims_stays_visible`, `test_a_connection_on_a_port_no_config_names_is_reported_not_dropped`.
- **add** `test_a_name_target_is_never_credited_for_a_socket_on_its_port` (the 16-way `:443` fan-out keeps `connections: None`, and the sockets are not counted anywhere — assert the sum over `connections` is unchanged by their presence), `test_a_port_no_config_names_is_neither_counted_nor_reported` (add a LISTEN row on 9120 to a synthetic table: nothing in the payload mentions it), `test_an_endpoint_written_as_an_address_is_matched_exactly_and_carries_its_count` (no `by_port` key exists any more — assert its absence), `test_two_connections_sharing_an_endpoint_say_so_and_repeat_the_count_never_split_it` (`shared: true`, both carry the endpoint's number), `test_the_loopback_hop_counts_once_on_each_side` (`:4443` inbound and `127.0.0.1:4443` outbound each take their own `/proc` row, and no row is counted twice).
- **restate** `test_the_live_counts_reconcile_with_the_socket_table` as: every inbound count's port is a configured listener, `sum(listeners) + sum(endpoint counts) <= established`, and `sockets.listens` / `sockets.established` equal the fixture's raw row counts. Keep `test_no_join_ever_asks_a_resolver` exactly as it is.
6. `tests/test_api.py` — `GATEWAY_PAGE_FIELDS` and `GATEWAY_FIELDS` to the new key sets; the stub/answer key-parity pin; `test_the_gateway_snapshot_carries_no_verdict_and_no_window` walks `connections` too (recursive: no `severity`, no `window_seconds` at any depth); `test_the_summary_gateway_block_is_the_payloads_sums_not_the_pages_arithmetic`, `…_is_the_stub_of_a_source_that_never_answered`, `…_of_the_captured_ingress_reads_the_live_join`; `test_a_dead_gateway_host_is_one_overview_line_not_three` (the `disks`/`gateway` echo-suppression rule is unchanged — a dead host's `gateway` job still adds nothing new); `test_the_gateway_counts_grow_no_verdict_alert_even_with_verdicts_in_the_payload` now injects `severity` into `listeners` **and** `connections`; the overview-line pin `test_the_overview_line_reads_the_gateway_block_it_claims` resolves `GATEWAY_FIELDS` against a live document and `page-index.js`'s readers; `/gateway`'s markup pins for the deleted unmatched card and `by port` chip (the **absence** of both, now).
## Testing & Quality
- Unit/integration: the pins above; the full suite plus the **live** read (`uv run pytest -m live --live`) confirms `gateway` still reads `ok` and the snapshot's `listeners` counts match `ss -tan` on the router for configured ports.
- Coverage: **>90%** on `sources/gateway.py` (both arms, the shared-endpoint arm, the sockets-silent arm), `api.py`'s changed block, `page-index.js` pinned on served bytes.
## Completion Criteria
- [ ] `grep -rn "unmatched\|by_port\|MAX_GROUP_HOSTS" src/ tests/` finds nothing outside a comment explaining what was removed and why.
- [ ] `GET /api/gateway/snapshot` (live): no `unmatched`, no `outbound`, no `by_port`; `connections[].connections` is a number **only** for the address-written endpoints (`127.0.0.1:4443`, `127.0.0.1:8081` today) and `null` for every name.
- [ ] `GET /api/summary` → `gateway` block has exactly the 11 fields listed above; `unmatched` / `unmatched_listeners` / `partial` are gone; the overview page's one line prints ports / routes / live and vanishes when unconfigured.
- [ ] Nothing in the payload mentions port `9120` or any other port the two configs do not name (live check + pinned).
- [ ] `uv run pytest` **100% passing** incl. `--cov` ≥ 94 floor; ruff + pyright + `node --check` on both changed scripts.
- [ ] `AGENTS.md`'s `sources/gateway.py` / `api.py` `/api/gateway/*` / `/api/summary` passages no longer describe `unmatched`/`by_port`/`partial` (full rewrite lands in task 06; this task must not leave a sentence that **contradicts** the shipped payload).
- [ ] No behaviour change in completed work: the five other dashboards and the two admin pages unchanged, 10 sources `ok`.
@@ -0,0 +1,63 @@
# Task 03 — Config-integrity warnings: seven kinds, from the config text, on the page and one row on `/`
**Phase:** `30_gateway_connection_map` · **Source:** operator request 2026-10-23 — "I don't know what the unmatched section is, there should be nothing unmatched, but if there is that should be called out in a **list of warnings to address** like other things in this dashboard project" + the follow-up decision *"Ignore anything not in haproxy or nginx configs"* and *"Config integrity, yes"*
**Story:** n/a
## Objective
With everything outside the two configs ignored (task 02), the only warning left worth an operator's attention is a config that **does not connect to itself**. `sources/gateway.py` gains `warnings(snapshot)` — seven kinds, each derived from config text alone, each actionable — the payload carries them, `/api/summary` counts them, and `alerts.py` raises **one** `warn` row for them. That row is the deliberate change to the documented "a `gateway` alert is reachability only" rule, and task 06 rewrites that rule in `AGENTS.md` against this file.
## The seven kinds
Each warning is `{"id", "kind", "subject", "message"}`; `id = f"{kind}:{subject}"` is the page's stable DOM key; `message` is operator-facing prose written in this file's voice, carrying the config's own words verbatim.
| kind | condition | message shape |
|------|-----------|---------------|
| `haproxy_route_dangling` | a frontend's `default_backend` or any `use_backend` name no `backend` block declares | `frontend haproxy-22 routes to backend "gitea-2222", which no backend block declares` |
| `haproxy_backend_orphan` | a backend that declares ≥ 1 `server` and appears in no frontend's routes | `backend "old-tunnel" has 2 servers and no frontend routes to it` |
| `haproxy_backend_empty` | a backend block with no `server` line at all | `backend "gitea-2222" declares no server — it routes nowhere` |
| `nginx_server_unlistened` | an nginx `server` block with `server_name` and/or `proxy_pass` and **no** `listen` | `nginx server "nextcloud.example.com" proxy_passes but never listens` |
| `nginx_pool_undeclared` | a `proxy_pass http://<name>` with no port and no matching `upstream` block (task 01 draws no connection for it) | `nginx listen 4443 proxy_passes to pool "backend_pool", which no upstream block declares` |
| `nginx_variable_unmapped` | a `proxy_pass $var` with no `map` block defining that variable | `nginx listen 443 proxy_passes $targetBackend, which no map block defines` |
| `nginx_map_route_missing` | an entry in a **referenced** map whose target is null | `map $targetBackend entry "x.example.com" names no address to route to` |
**Three deliberate non-warnings** (each gets a comment where the check would have gone, because the absence is the rule):
- an nginx `server` block with a `listen` and no `proxy_pass` — it may be a static or redirect vhost, and this payload cannot tell the two apart, so a warning would invent a fault;
- a haproxy `frontend` whose only routing is a fetch expression (`use_backend %[req.hdr(host),map_dom(…)]`, which agent 1.2.0 reports as **no name**) — "no name" and "no routing directive" are one value on the wire, so warning about it blames a config this agent cannot read; making this distinguishable is an agent change (a `routes_dynamic` flag), not a source-level guess;
- a `proxy_pass` with neither `target` nor `variable` (a unix socket, a bare word) — legitimate, undrawable, not broken.
A section is judged **only** when its own `available` is `True`. `predates`, `no file`, and `unreadable config` produce **no** warnings — a host that cannot be read has not been found guilty of anything (invariant 3).
## Work
1. `src/dashboards/sources/gateway.py`
- New pure `warnings(haproxy_section, nginx_section) -> list[dict[str, str]]` implementing the table above with the three refusals, deterministic order (haproxy kinds in config order, then nginx kinds in config order), deduplicated by `id`.
- `parse()`: `payload["warnings"] = warnings(haproxy, nginx)` when supported, `[]` in the predates arm and whenever the socket/config halves did not answer; `_skeleton()` gains `"warnings": []` so `stub()` matches (the key-parity pin).
- Docstring: a short **"What this module warns about"** section — the seven kinds, the three refusals, and the sentence *"a warning here is a config that does not connect to itself; nothing outside the two configs is reported at all."*
2. `src/dashboards/alerts.py`
- `build(...)` gains `gateway_payload: Mapping[str, Any] | None = None` (keyword, last, defaulted, so every existing caller and test keeps working).
- One row, emitted **only when the payload exists, the job is currently answering, and `warnings` is non-empty**: `{id: "gateway:config", severity: "warn", source: "gateway", message: f"{n} proxy config {problem/problems} to address — {first['message']}", href: HREF["gateway"], at: <payload ts when it has one>}`. When the `gateway` job is failing or stale the reachability row it already produces is the **only** line (the existing "a dead source gets exactly one line" rule), so a dead agent never yields two.
- This module still holds **no thresholds** and no `severity.py` row: a config warning is a structural fact, not a graded number. Update this module's docstring rule list — it currently says the gateway source has "no verdict rule and none is wanted"; it now says: *reachability plus config-integrity, no graded number, ever.*
3. `src/dashboards/api.py` — pass the gateway payload to `alerts.build(...)` from `/api/summary`; add `warnings` (the count) to the `gateway` block; `GATEWAY_FIELDS` pin extended in the same commit.
4. `src/dashboards/web/static/page-index.js` — the overview's one gateway line appends ` · N to address` **only when `warnings > 0`**, so a clean ingress's line does not grow a permanent zero.
5. `tests/test_gateway_source.py` — one test per kind, each on a **synthetic** config written into the fake `--root` tree the `gateway_snapshot` fixture uses (or a hand-built section dict where the agent's parser would only be in the way, which is legitimate for a pure function over sections — say which you used in the test docstring), plus:
- `test_the_captured_configs_produce_no_warnings` — the real fixture asserts `warnings == []` **and** the exact reason per kind (this is the pin that keeps the seven checks from being a lie about this fleet);
- `test_an_unreadable_or_absent_section_warns_about_nothing` (`no_haproxy` / `no_nginx` / `predates` modes),
- `test_a_listen_with_no_proxy_pass_is_not_a_warning` and `test_a_frontend_routing_by_fetch_expression_is_not_a_warning` (the refusals, pinned in the direction that they produce **no** warning while still producing their listener),
- `test_warnings_are_deterministic_deduplicated_and_json_serialisable`.
6. `tests/test_alerts*.py` (whichever file holds the `alerts.build` suite today) — the row appears for one warning and for seven; **one** row, not one-per-warning; `severity == "warn"`; `href == "/gateway"`; a failing/stale `gateway` job yields its reachability row and **not** the config row; an unconfigured gateway yields nothing (invariant 3); the `disks`/`gateway` echo-suppression table is unchanged.
7. `tests/test_api.py` — `/api/summary` with a mutated gateway payload (the `self_test_app` precedent: swap the collector so a poll cannot overwrite what the test set) yields exactly one new `warn` row linking to `/gateway`, and the same mutation with an empty `warnings` list yields none. **Both directions**, per the project's testing policy.
## Testing & Quality
- Unit/integration: as listed; the alert row's two directions are mandatory.
- Coverage: **>90%** on `warnings()`, the `alerts.build()` branch, and the `api.py` wiring.
## Completion Criteria
- [ ] `GET /api/gateway/snapshot` on the live ingress answers `warnings: []`, and `GET /api/summary` carries **no** `gateway:config` alert row.
- [ ] Injecting one synthetic dangling `use_backend` into the fixture produces exactly one `haproxy_route_dangling` warning, one `warn` row on `/api/summary` whose `href` is `/gateway`; removing it produces none (both pinned).
- [ ] A `gateway` job that is failing produces its reachability row and **no** config row (pinned).
- [ ] `test_the_captured_configs_produce_no_warnings` is green and names why each of the seven kinds cannot fire on this fleet.
- [ ] `uv run pytest` **100% passing**, coverage not down; ruff + pyright clean.
- [ ] `AGENTS.md`'s `alerts.py` bullet updated in this task (it documents a rule this task changes) — task 06 re-checks the whole file.
- [ ] No behaviour change in completed work: no other source's alert rows change shape or count, the overview's two-panel structure is intact.
@@ -0,0 +1,68 @@
# Task 04 — Two panels, not four: the nginx and haproxy connection tables, grouped trunk → leaves
**Phase:** `30_gateway_connection_map` · **Source:** operator request 2026-10-23 — "the stream map shouldn't be its own table, it's part of nginx. There should be only 2 tables: nginx and haproxy"
**Story:** n/a
## Objective
`/gateway` becomes two panels and one warnings list. The `stream map` card is gone — a `map` is part of nginx, so its 17 entries are drawn as the leaves of nginx's `:443` trunk — and the `unmatched` card is gone (task 02 deleted the data behind it). Each card lists its **listeners**, and under each listener the **connections** that listener's config declares, so the structure of the page *is* the structure of the file. This task ships the markup and the render pass with the connectors present but plainly styled; task 05 gives them the green line, the elbows and the switches.
> **On the word "table":** these are `.card` panels with a header row and one row per connection, **not** `<table>` elements. A `<table>` cannot draw an elbow from `:443` to sixteen rows, and `AGENTS.md` pins `table.data` to the horizontal-scroll rules (phase 27) which a graph row does not want. The visual reading is a table — heading, aligned rows, a right-hand number column — and the orphan-`id` / every-`<table>`-carries-`data` pins stay green because no `<table>` is introduced.
## Work
1. `src/dashboards/web/pages/gateway.html`
- `#gate-cards` holds **exactly two** `<article class="card gate-card">`: `data-gate="nginx"` then `data-gate="haproxy"` (the operator named nginx first, and it is the bigger fan-out). Delete the `map` and `unmatched` articles and every id/class only they used.
- Each card body: `<div class="gate-map" data-k="map">` (groups injected here) + the `data-k="empty"` sentence + the existing `details.charts-box` block, unchanged in shape.
- New warnings panel, **above** the cards, shipped `hidden`:
```html
<section class="card" id="gate-warnings" hidden>
<header><h3>Warnings to address</h3><span class="chip" id="warnings-count"></span></header>
<div class="body"><ul class="alerts" id="warnings-list"></ul></div>
</section>
```
Its rows use the overview's vocabulary verbatim — `<li class="warn"><span class="src">haproxy</span><span class="msg">…</span></li>` — because "like other things in this dashboard project" is the instruction and `ul.alerts` *is* that thing. Clean means **hidden**, not an empty card.
- `#tallies` becomes four cells: `ports` (configured listeners), `connections` (config-declared connections), `live` (ESTABLISHED sockets on configured ports), `warnings`. `#t-listeners` / `#t-established` / `#t-unmatched` and their JS are deleted; `#sub` keeps `host · agent · N connections`.
- `<footer class="pagefoot">`: delete the `by port` sentence. Keep/rewrite three, each one sentence: the animation rule; **"only an endpoint the config wrote as an address can be matched to a socket — a name is never resolved, so its line carries no number"**; and **"the switch shows what the config says; toggling a route needs a config write path and is not wired up yet."**
2. `src/dashboards/web/static/page-gateway.js`
- `CARDS = [{key: 'nginx', …}, {key: 'haproxy', …}]` and no third entry; `pruneCards` unchanged in behaviour.
- New `groupsFor(snapshot, key)` → `[{port, listener, connections: [...]}]`, ordered: ports ascending, connections in payload order. A listener with **no** connections still produces a group (this fleet's `:80` vhost with no `proxy_pass` is the live case) and its leaf list renders the one faint sentence `no target named in the config` — a refusal, never a blank row.
- `renderGroup(card, group)` builds once and patches afterwards (the `page-disks.js` / slot-grid discipline: keyed `Map` per card, `appendChild` reorders rather than rebuilds, so a 17-row fan-out is not re-created every 30 s). Row structure:
```
<li class="gate-link" data-id="{connection.id}">
<span class="gate-elbow" aria-hidden="true"></span>
<span class="gate-switch-slot"></span> <!-- task 05 fills this -->
<svg class="pipe on">…</svg> <!-- task 05 styles it -->
<span class="gate-target">
<span class="pipe-name mono">{host}:{port}</span>
<span class="gate-selector faint">{selectors joined} · {kind}</span>
</span>
<span class="pipe-count">{count or '—'}</span>
</li>
```
Trunk: `:{port}` + the live count (`14 live` / `n/a`) + up to `CLIENTS_SHOWN` client addresses + the domains count (`16 names`, full list in `title`). Every upstream string (`server_name`, `proxy_pass`, map key, client address) goes out through `textContent`/`title` only, and the row's `title` + `aria-label` carry the **whole** endpoint even when CSS ellipsises it (gap 13's rule, re-measured in task 06).
- A count that is `null` prints `—` with `title` = `not attributed: the config names this endpoint as a name, and nothing here resolves names` — never `0`, and not a repeated `n/a` column of twelve identical words. A number prints through `countText`.
- `renderWarnings(snapshot)`: fills `#warnings-list` with one `li.warn` per payload warning (`src` = the config family word, `msg` = `warning.message`), sets `#warnings-count` to `N` , and keeps `#gate-warnings` `hidden` when the list is empty. The page renders `warnings` and never re-derives one.
- `renderHeader()` → the four tallies; the `.stale` dim and the three degradation arms (`why` banner, `unreachable` dim, `404 {configured:false}` → `#gate-empty` naming `GATEWAY_HOST` and agent **1.2.0**) keep their exact current behaviour. A failed poll still re-draws nothing (last good map stays).
- `cardStreams()` (the chart series list) reads `listeners` + `connections` for that source; one `/api/gateway/history` request per card per refresh, plot built only while the disclosure is open, `ariaLabel` on every plot — the phase-29 discipline, unchanged.
3. `src/dashboards/web/static/dash.css` — the **structure** only in this task: `.gate-map` (grid, `min-width: 0`), `.gate-group` (two columns: trunk `minmax(0, 9.5rem)` + leaves `minmax(0, 1fr)`), `.gate-leaves` (list-style none, `display: grid`, gap), `.gate-link` (`display: grid`, `align-items: center`, columns for elbow / switch / pipe / target / count), `.gate-target` (`min-width: 0`), `.gate-selector` (11 px, ellipsis), `.gate-trunk` (`min-width: 0`), `@media (max-width: 640px)` stacking the group to one column with the trunk above its leaves. No colours, no dashed borders, no switch rules yet — task 05's job, so the two are reviewable separately.
4. `tests/test_api.py` — rewrite the page-shape pins in the same commit:
- `test_the_gateway_page_is_a_card_per_source_family_with_a_reconciliation_row` → **`test_the_gateway_page_is_exactly_two_connection_panels_and_a_warnings_list`**: exactly two `[data-gate]` articles and they are `nginx` + `haproxy` (assert the list, ordered, so a third card fails); no `[data-gate="map"]`, no `[data-gate="unmatched"]`; `#gate-warnings` + `#warnings-list` exist and ship `hidden`; the four tally ids exist and the three deleted ones do not; every id on the page is referenced by the script or the stylesheet (the standing orphan audit must stay green by inspection of its own list).
- `test_the_gateway_page_prints_every_field_the_projection_publishes` → `GATEWAY_PAGE_FIELDS` resolves against a live payload, now including `connections[].in.port`, `connections[].out.host/port/name/kind/selectors/address`, `connections[].connections`, `warnings[].kind/subject/message`, `listeners[].domains/clients/connections`.
- `test_the_gateway_page_renders_each_degradation_arm_in_the_payloads_own_words` — still all four arms, on the new markup.
- `test_the_gateway_page_keeps_its_last_drawing_when_a_poll_fails` — unchanged behaviour, new selectors.
- **new** `test_the_stream_map_is_rendered_inside_nginx_and_nowhere_else` — the served script contains no `map` card key and the live payload's map-sourced connections are drawn under `source: "nginx"`; and `test_a_listener_with_no_declared_target_says_so_instead_of_drawing_nothing` (the `:80` arm), `test_warnings_render_in_the_overviews_alert_vocabulary_and_hide_when_clean` (both directions), `test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero`.
- `test_no_page_narrates_its_own_pipeline` — the new footer sentences must pass the existing deleted-sentence list (they are operator-facing why-lines, not pipeline narration) and no removed sentence comes back.
## Testing & Quality
- Unit: served-bytes pins above (no JS runner in this repo — task 06's browser pass is the behavioural proof).
- Coverage: the changed Python is nil-or-minimal here; the pins are the product. `node --check src/dashboards/web/static/page-gateway.js` clean.
## Completion Criteria
- [ ] `/gateway` renders **two** panels; the nginx panel's `:443` group lists 17 leaves on the live config, `:4443` six, `:8443` three, haproxy six groups of one, and `:80` shows its refusal sentence.
- [ ] No `stream map` card, no `unmatched` card, no `by port` chip anywhere (grep + pin).
- [ ] `#gate-warnings` is hidden with the live (clean) configs and appears with rows when a warning is injected into a fixture payload.
- [ ] No sideways document scroll at 360 px with the 17-row fan-out open (measured now, re-measured in task 06).
- [ ] `uv run pytest` **100% passing**, coverage not down; ruff + pyright + `node --check` clean.
- [ ] README/AGENTS: the `/gateway` UI paragraph gains "two panels, nginx + haproxy, map is nginx's" in this task if the sentence already contradicts the shipped page; task 06 does the full pass.
- [ ] No behaviour change in completed work: the other five dashboards + `/settings` + `/certs` byte-identical, 10 sources `ok`.
@@ -0,0 +1,96 @@
# Task 05 — The line and the switch: green because it is in the config, joined trunk to leaf, unwired by design
**Phase:** `30_gateway_connection_map` · **Source:** operator request 2026-10-23 — "the orange dashes look great but they should be **green if the connection is active**. 'Active' means it exists in the nginx config… the dotted line connections need to connect to their counterparts. This is easy for haproxy — just draw the line straight across. For nginx you'll need to branch the lines from `:443` to the services they connect to" + the sketch (`/tmp/pi-clipboard-…png`: green boxes + horizontal pill = on, red boxes + vertical pill = off) + "build the switches in now but don't wire them up"
**Story:** n/a
## Objective
Every connection row draws a **green dashed line from its inbound end to its outbound end** — straight across for a haproxy frontend with one `server`, **branched** from the `:443` trunk into one tick per map entry for nginx — with the **switch** from the sketch sitting in the middle of it. Colour is an **existence state** taken from the config (green = in the config, red = out of it), never a verdict on a number; motion still means "a socket is open this instant" (decision 2). The switch is real markup, real ARIA, red-and-green styled, **and unwired**: `aria-disabled="true"`, no handler, a `title` that says so.
## Work
1. `src/dashboards/web/static/dash.css`
- In the `:root` block that declares `--ok`/`--fail`, add the **existence pair** with a comment naming what it is *not*:
```css
/* The gateway's existence pair — deliberately NOT the severity vocabulary. Green means the
route is written in a proxy config; red means it is out of the config. No gateway figure
is graded (`severity.py` has no `gateway.*` row), so nothing here may reach for --ok,
--warn or --fail directly: the pair is one indirection whose name says what it answers. */
--gate-on: var(--ok);
--gate-off: var(--fail);
```
(The standing "no rule reaches for a token `:root` does not declare" pin sees both new tokens; per-theme `--ok` substitution happens on the root element, so light/dark both follow without a second declaration.)
- Rewrite the `gateway pipes` section's colour half:
```css
.pipe.on .pipe-path { stroke: var(--gate-on); }
.pipe.off .pipe-path { stroke: var(--gate-off); }
.pipe.live .pipe-path { animation: pipe-flow 1.2s linear infinite; }
.pipe.still .pipe-path { stroke-dasharray: 4 8; } /* a route with nothing on it right now */
```
`.pipe-path`'s base rule keeps **no** `animation` and **no** verdict colour; the `live`/`still` classes stay (their meaning is now "a socket is open this instant" — restate that in the comment, because the class name no longer says "idle route").
- The connectors, all of it pure CSS (no JS coordinate maths, no `ResizeObserver`, nothing measured at runtime):
```css
.gate-group { position: relative; display: grid; column-gap: 0;
grid-template-columns: minmax(0, 9.5rem) minmax(0, 1fr); }
.gate-leaves { position: relative; padding-inline-start: 26px; }
.gate-link { position: relative; }
/* each row carries its own half of the branch, so the spine starts at the first branch and
stops at the last without a mask, a magic height or a background-coloured eraser */
.gate-link::before { content: ""; position: absolute; left: -26px; top: 0; height: 50%;
border-left: 2px dashed var(--border-strong); }
.gate-link::after { content: ""; position: absolute; left: -26px; top: 50%; bottom: 0;
border-left: 2px dashed var(--border-strong); }
.gate-link:first-child::before, .gate-link:last-child::after { display: none; }
.gate-elbow { align-self: center; height: 0; border-top: 2px dashed var(--border-strong); }
.gate-trunk { position: relative; } /* its own tick reaches the spine at right: 0 */
/* straight across: a group with one leaf is not a branch, so it draws no vertical at all */
.gate-group.solo .gate-link::before, .gate-group.solo .gate-link::after { display: none; }
```
`.gate-group.solo` is added by the JS when a group has exactly one connection (every haproxy group on this fleet). The `.gate-link` grid columns are `26px auto minmax(2rem, 4.5rem) minmax(0, 1fr) auto` — elbow, switch, the animated run, the target, the count.
- The switch, keyed on `[aria-checked]` and **never** on `:enabled` (a not-yet-wired switch must still look green when it is on), and tinted from the same pair so the gateway owns one colour vocabulary:
```css
.gate-switch { width: 34px; height: 18px; padding: 2px; border-radius: 999px;
border: 1px solid var(--gate-on); display: inline-flex; align-items: center;
background: color-mix(in srgb, var(--gate-on) 14%, transparent);
cursor: not-allowed; }
.gate-switch-thumb { width: 12px; height: 12px; border-radius: 50%;
background: var(--gate-on); transform: translateX(16px);
transition: transform .16s ease; }
.gate-switch[aria-checked="false"] { border-color: var(--gate-off);
background: color-mix(in srgb, var(--gate-off) 14%, transparent); }
.gate-switch[aria-checked="false"] .gate-switch-thumb { transform: translateX(0); }
```
`color-mix` (already the sheet's idiom) is why no `--ok-soft` / `--fail-soft` appears in a gateway rule: the soft fill derives from the existence pair rather than borrowing the verdict tints.
- The existing `@media (prefers-reduced-motion: reduce)` block gains `.gate-switch-thumb { transition: none; }` beside its two `.pipe` rules (it must name every animated thing on the page — naming one and leaving the thumb sliding is the phase-18 lesson).
- `@media (max-width: 640px)`: the group stacks (task 04's rule) and the spine's `top` reaches up into the group gap so the trunk above still reads as connected; the switch and its row do not shrink and nothing scrolls sideways.
2. `src/dashboards/web/static/page-gateway.js`
- `gateLink()` builds the row in task 04's order and fills `.gate-switch-slot`:
```html
<button class="gate-switch" type="button" role="switch" aria-checked="true" aria-disabled="true"
title="Toggling a route is not wired up yet — it needs a config write path. This switch shows what the config says.">
<span class="gate-switch-thumb"></span>
</button>
```
`aria-checked` is `true` because the row exists at all (drawn ⇒ in the config); the `false` styling is present and unreachable until the write phase. `aria-label` names the endpoint, the selector and the config family (`"nextcloud.reeseapps.com → nextcloud.reeselink.com:11000, in the nginx config, on"`), since a switch with no accessible name is silence. **No click handler is bound, anywhere.**
- `renderGroup()` toggles `.solo` on the group from its own leaf count, and toggles `live`/`still` **and** `on`/`off` on the row's `<svg class="pipe">` (`on` always today; `off` is a one-line seam for the write phase).
- `pipeAria()` gains the state word and keeps every existing fact (port / target / domains / clients / count), so an unattributed count is still announced as unknown and never as zero.
3. `tests/test_api.py` — **restate, do not delete**, the colour pin:
- `test_no_gateway_count_is_coloured_by_a_verdict` keeps every current assertion (no `--ok|--warn|--fail|.ok|.warn|.fail|severity` inside the `gateway pipes` section, no `severityFrom`/`severityFor`/`.severity` in the script, no verdict class in the markup) and its docstring is rewritten to say the pipes/switches colour from `--gate-on`/`--gate-off`, that the pair is an **existence** state and not the severity vocabulary, and that the one place `.warn` legitimately appears is the warnings panel's `ul.alerts` rows (which are `alerts.py`'s verdict, not a number's).
- **new** `test_a_connection_is_green_because_it_is_in_the_config` — `.pipe.on .pipe-path` stroke is `var(--gate-on)`, `.pipe.off .pipe-path` is `var(--gate-off)`, `:root` declares both, and `.pipe-path`'s base rule declares no `stroke` verdict; the script sets `on` unconditionally for a drawn connection.
- **new** `test_haproxy_draws_straight_across_and_nginx_branches` — served CSS: `.gate-group.solo` hides `.gate-link::before`/`::after`; the branch halves and `.gate-elbow` exist; and the script's `.solo` toggle reads the group's own leaf count.
- **new** `test_the_switch_is_built_and_explicitly_unwired` — `role="switch"`, `aria-checked`, `aria-disabled="true"` (and **not** `disabled`, which would drop it from the a11y tree), the `title` names the missing write path, and no `addEventListener` in the file names `gate-switch` / binds a click on it. This is the pin that fails the day someone ships a dead-looking-live control.
- `test_the_pipe_animation_stills_in_both_states_under_reduced_motion` — both pipe states **and** `.gate-switch-thumb` are named in the file's one reduced-motion block.
- `test_a_pipe_moves_only_while_it_carries_a_connection` — unchanged claim, new class pairs (`live` animates, `still` does not, colour comes from `on`/`off`).
- The orphan-`id` audit and the CDN-reference pin stay green.
## Testing & Quality
- Unit: served-CSS + served-script pins above; the behavioural proof is task 06's browser pass (computed stroke colour, animation running, elbow geometry, 360 px).
- Coverage: no new Python; `node --check` clean; `bash .agents/validate.sh` (which runs `node --check` over `web/static`) green.
## Completion Criteria
- [ ] On live data: every haproxy row's line runs **straight** from `:22`-style trunk to its `server` with no vertical segment; the nginx `:443` trunk has a spine reaching every one of its 17 ticks; each row has exactly one switch.
- [ ] Every drawn pipe computes to the green of `--gate-on` in both themes; a row with ≥ 1 live connection animates and one with 0 or `null` does not; with `prefers-reduced-motion: reduce` emulated, nothing moves (pipes and thumb).
- [ ] `grep -n "disabled" src/dashboards/web/static/page-gateway.js` shows `aria-disabled` and no `disabled` attribute; no click listener exists for the switch; the `title` sentence is on every switch.
- [ ] The severity vocabulary is still absent from the pipes section, the script and the markup — and its absence is pinned with the restated docstring, not removed.
- [ ] `uv run pytest` **100% passing**, coverage not down; ruff + pyright + `node --check` clean.
- [ ] No behaviour change in completed work; the other pages byte-identical.
@@ -0,0 +1,52 @@
# Task 06 — Documentation, the browser pass, and the live read-only verification
**Phase:** `30_gateway_connection_map` · **Source:** `AGENTS.md` definition of done (browser screenshot against live data; README/AGENTS updated when a rule, command, gap or fleet fact changed)
**Story:** n/a
## Objective
Make the record match the shipped page: `AGENTS.md` and `README.md` rewritten where phase 30 changed a rule (two panels, config-derived connections, green = in the config, the unwired switch, the deleted `unmatched`/`by_port`/`partial`, the new `gateway` config-integrity alert, name-keyed outbound gauges no longer written), and the whole thing proven in a real browser at 1440 px and 360 px plus a read-only pass against the actual router.
## Work
1. `data/logs/30-connection-map.cjs` — the browser pass (Playwright, the repo's idiom: `NODE_PATH=/var/home/ducoterra/.agents/skills/interactive-browser/node_modules node data/logs/30-connection-map.cjs`, `DASH_BASE`/`DASH_PW` from `.env.dev`, transcript to `data/logs/30-connection-map.out`, **fails non-zero on any FAIL**). Checks, in this order, each with a message that names what broke:
- exactly two `[data-gate]` panels are visible, `nginx` then `haproxy`; no `map`/`unmatched` panel;
- **straight across:** every `.gate-group.solo` leaf has `display: none` on `::before`/`::after` (read through `getComputedStyle(el, '::before')`) and its trunk, elbow, switch, pipe and target share one horizontal centre line (`top` of each within 2 px) — haproxy's rows are the case;
- **branched:** nginx's `:443` group has a spine and one tick per leaf, the ticks are 26 px wide and land at each row's centre, and the first/last row's halves are suppressed (so the spine starts and stops at a branch);
- **green from the config:** every `.pipe-path` computes to the theme's `--ok` rgb, in `dark` **and** after clicking the theme toggle to `light`; the pipe never computes the accent orange;
- **motion:** `getAnimations()` (or `animation-name`) is `pipe-flow` on exactly the rows whose printed count is ≥ 1 and `none` on the rest; with `page.emulateMedia({ reducedMotion: 'reduce' })` every pipe and every `.gate-switch-thumb` reports no animation;
- **switch:** one `.gate-switch` per connection row, all `role=switch` + `aria-checked="true"` + `aria-disabled="true"` + a non-empty `title` + a non-empty accessible name; clicking one changes nothing on screen and issues **no** network request (assert on the page's request log);
- **warnings:** intercept `/api/gateway/snapshot` with `page.route` to inject two synthetic `warnings` entries — the panel appears with two `li.warn` rows in the `ul.alerts` vocabulary — and with the unmutated response it is `hidden`;
- **degradation:** intercept to `404 {configured:false}` → `#gate-empty` names `GATEWAY_HOST` and `1.2.0`, no cards; intercept `supported:false` → the `why` banner and every count `—`/`n/a`, nothing red;
- **360 px:** document `scrollWidth === clientWidth` on every state above with the 17-row `:443` group expanded, no element painted past the viewport that is not inside a horizontal scroll container, and every ellipsised label carries its full text in `title`;
- screenshots: `data/logs/30-connection-map-{desktop,light,phone,warnings,predates}.png`.
2. `data/logs/27-task02-mobile-audit.cjs` — re-run as-is and require **exit 0** with `/gateway` in the sweep (its per-table and per-disclosure claims must hold for the new rows). Transcript to `data/logs/30-final-mobile-audit.out`.
3. `AGENTS.md` — the full pass, task by task's own edits included. Sections that are now wrong and must be rewritten against the shipped code (read each one, do not patch a phrase):
- the **`/gateway` row of the page table** (what question it answers, and its source),
- the **`sources/gateway.py`** bullet — the payload's real key set (`listeners`, `connections`, `warnings`), the two surviving join arms, the ignore rule, the seven warning kinds and the three refusals, the shared-endpoint rule, and **the deletion of `unmatched` / `by_port` / `partial` / `MAX_GROUP_HOSTS`** (the bullet currently describes all four as the module's honesty),
- the **metric-naming paragraph** — `gateway.in_conns@<port>` and `gateway.out_conns@<host>:<port>` stay, and the paragraph must say name-keyed outbound gauges are **no longer written** and why, with the rows aging out under unchanged retention (invariant 9 — no schema change),
- the **`api.py`** bullet's `/api/gateway/*` paragraph and the `/api/summary` `gateway` block field list,
- the **`alerts.py`** bullet — reachability **and** config-integrity now, still no thresholds and no graded number,
- the **UI conventions** `/gateway` paragraph — two panels, trunk→leaf rows, the line joins its endpoints (straight across / branched), green = in the config via `--gate-on`/`--gate-off` and *why that is not the severity vocabulary*, motion = a socket open this instant, the switch built-and-unwired, the dash-for-unattributed rule,
- **gap 13** (the clipped pipe labels): re-measure with `data/logs/graph-probe.py` + the browser pass and either close it or restate it with today's numbers (the row layout gives a long `server_name → target` string a whole column now, so the residual is probably smaller — write the measured count, not a hope),
- any other inconsistency the full read turns up, including pre-existing ones.
4. `README.md` — the `/gateway` row and the "what each page answers" prose; the `GATEWAY_HOST` configuration row is unchanged (no new env setting was added by this phase — say so in the pass note); the rollout/agent sections are unchanged (no agent version change).
5. **Live, read-only** verification against the real ingress (no writes, no config edits, no battery-tests, nothing restarted):
- `GET /api/gateway/snapshot`: `warnings == []`; no `unmatched` / `outbound` / `by_port` / `partial` key anywhere; every haproxy frontend connected to exactly its `server`; `:443` connected to the 17 map entries (`default → 127.0.0.1:4443` among them); `:4443`/`:8443` connected to their `proxy_pass` targets; `:80` present as a listener with no connection; no figure mentions `9120` or `2222`.
- Reconcile the inbound counts with the kernel: `ssh gateway-ops "cat /proc/net/tcp /proc/net/tcp6"` (the same world-readable read the agent makes, recorded read-only) and assert `sum(listeners[].connections)` equals the ESTABLISHED rows whose local port is a configured listen port, allowing for the seconds between the two reads — record both numbers in the note.
- `./scripts/dev-server.sh status` → **10 sources `ok`**, `gateway` among them; `GET /api/summary` → no `gateway:config` alert row on a clean config, and one after a fixture mutation (the suite pins both).
- `git diff --stat agent/ deploy/` → **empty**: this phase touched no agent and no installer.
- Write the transcript to `data/logs/NOTES-30-connection-map.md` (the numbers, the screenshots, the reconciliation, and the two facts the next phase will need: that the switch markup exists and where its one `off` seam is).
6. Commit the phase with the repo's convention (`--no-gpg-sign`, `LLM-Generated: true` trailer) once everything below is green.
## Testing & Quality
- The browser pass and the mobile audit are the phase's behavioural proof; the hermetic suite remains the contract. No new Python is expected in this task, so coverage is verified, not raised.
## Completion Criteria
- [ ] `data/logs/30-connection-map.out` ends in a 0-exit with every check named above PASS, and `data/logs/30-connection-map-{desktop,light,phone,warnings,predates}.png` exist.
- [ ] `data/logs/27-task02-mobile-audit.out`-style re-run (`data/logs/30-final-mobile-audit.out`) exits 0 with `/gateway` swept at both widths.
- [ ] `AGENTS.md` contains no sentence that contradicts the shipped payload or page (grep for `unmatched`, `by_port`, `partial`, `stream map` card, "four cards" — none may survive outside a historical note), and `README.md` matches.
- [ ] Live read: `warnings: []`, the connection sets above, and the inbound reconciliation recorded in `data/logs/NOTES-30-connection-map.md`.
- [ ] `./scripts/dev-server.sh status` → 10 sources `ok`; `git diff --stat agent/ deploy/` empty.
- [ ] `uv run pytest` **100% passing**, `uv run pytest --cov` ≥ 94 floor, `uv run ruff check src tests agent` + `uv run pyright` clean, `bash .agents/validate.sh` green, `uv pip list --outdated` empty (no dependency moved this phase — verify, don't assume).
- [ ] No behaviour change in completed work: the other five dashboards, `/settings`, `/certs` and `/login` render as before; `host:*`, `disks:*`, `llm:*`, `nut` unchanged; `/api/health` shape untouched.
@@ -0,0 +1,9 @@
**Phase 30 final verification pass — all 6 tasks already in `complete/`, so this was the phase-completion pass.**
- **Verified (re-run, not trusted):** `/gateway` serves exactly two panels (nginx→haproxy, `GATEWAY_CARD_KEYS`); haproxy rows `.solo` straight across, nginx `:443` branched with one tick per leaf (18 live, fixture 17); every pipe computes the theme `--ok` in dark+light, animates only at count ≥1, fully static under `prefers-reduced-motion`; switch = `role="switch"`+`aria-checked`+`aria-disabled`, no listener, footer+title name the missing write path; zero non-GET requests anywhere.
- **Live read-only re-run** (`data/logs/30-final-live-verify.out`): 17 PASS — `warnings: []`, 6 haproxy frontends→own `server`, `:443` 18 map leaves incl. `default→127.0.0.1:4443`, `:4443` 6 / `:8443` 3, `:80` bare listener, no `unmatched`/`outbound`/`by_port`/`partial`, no `9120`; inbound reconciliation **page 16 = kernel 16**; `gateway` block = the 12 declared keys; no `gateway:config` alert on a clean config. `git diff --stat agent/ deploy/` empty; agents 1.2.0 (gateway) / 1.1.0 ×2, all `tls`, `bundle_rev 2`.
- **Tests/lint (exact):** `uv run pytest` → **1791 passed, 16 skipped**, exit 0 · `bash .agents/validate.sh` → **exit 0**, `--cov` **98.94%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` all page scripts, hygiene clean · `uv run pytest -m live --live` → **12 passed** · `uv pip list --outdated` empty · `./scripts/dev-server.sh status` → **10 sources ok**, `gateway` included · browser pass `30-connection-map.out` 63 PASS/0 FAIL (5 screenshots at 1440/360) · `30-final-mobile-audit.out` 371 PASS/0 FAIL/0 FINDING, exit 0, nine pages.
- **Docs fixed:** README `alerts.py` bullet omitted phase 30's keyword-only `gateway_payload` and its one `warn` config-integrity row (+suppression) — added; overview ingress line labelled a dated reading. AGENTS gap 13 corrected 391 px → **361 px** (task 05's pill took 30 px), "task 06 measured" → "phase 29 measured", coverage figure un-pinned to 98.9 %, live-suite count noted (12 today), "all five pages" severity sentence clarified for `/gateway`'s existence-only palette. Everything else reviewed in full and **verified consistent**.
- **Defect fixed:** duplicated footer-assertion block at the end of `tests/test_api.py::test_the_switch_is_built_and_explicitly_unwired` (one copy removed).
- **Notable:** `NOTES.md` is empty in the working tree — both items in it (non-mTLS agents, disappearing admin links) landed in phases 24 and 20; left cleared. Fixture/live `map`-count drift (17 vs 18) is recorded in AGENTS.md, not silently re-captured.
- **Next pending phase:** none — `30_gateway_connection_map` was the only entry in `.agents/phases/todo/`.
@@ -0,0 +1,69 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
..s..................................................................... [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
.................................ssssssssssss........................... [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
.....................sss................................................ [ 99%]
....... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 499 3 99% 220, 250, 253
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 4 98% 324, 328-329, 346
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 463 4 99% 518, 529, 637, 1129
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4063 43 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1791 passed, 16 skipped, 1 warning in 172.29s (0:02:52)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,17 @@
Task complete. Everything green.
**Implemented**
- `sources/gateway.py`: new `config_connections()` derives the connection graph from config text only — 4 derivations (haproxy `frontend→routes→backend→server`, nginx vhost `proxy_pass` per `listen` port of *its own block*, port-less `proxy_pass` → `upstream` servers, `proxy_pass $var` → `map` entries incl. verbatim `default`), merge rule `(source,in_port,host,port)` with selectors unioned first-seen, plus the two silent refusals. Module docstring gained a "The connection graph" section.
- `haproxy_targets()` rows gained `backend` (the frontend→server join key); `nginx_listen_ports()` replaced by `nginx_server_blocks()` (`{ports, names, proxies}`) — one reader of a block, the port echo now derived from it.
- Payload: `inbound` → **`listeners`**, new **`connections[]`** (`id`, `source`, `in.port`, `out.{host,port,name,kind,selectors,address}`, `metric`, `connections: null` until task 02); `_skeleton()`/`stub()`/`samples()` follow the rename.
- `api.py`: mechanical rename of the payload reader (`/api/summary`'s block field names unchanged); `page-gateway.js` reads `snapshot.listeners`.
- Tests: ~15 new graph pins in `test_gateway_source.py` (32-connection set on the captured configs, backend/server-name, `default` verbatim + `address: true`, pool + undeclared-pool, merge, two-ports-two-connections, both refusals, no-orphan walk, junk arms); `test_api.py` renamed `GATEWAY_PAGE_FIELDS`, plus served-graph/32/orphan/no-`by_port` pins.
- Live read-only verification (`data/logs/verify-30-task01.py` → `30-task01-live.out`): 10 listeners, **32** connections (6 backend / 17 map / 9 server_name), zero orphans, `:80` trunk with no connection, `outbound`+`unmatched` still present, 10/10 sources `ok`.
**Checks** — `uv run pytest`: 1764 passed, 16 skipped · `--cov`: total 99.07 % (floor 94), `sources/gateway.py` / `api.py` / `alerts.py` 100 % · `ruff check src tests agent`: clean · `pyright`: 0 errors · `node --check page-gateway.js`: ok · `bash .agents/validate.sh`: exit 0 · `pytest -m live --live`: 12 passed.
**Docs** — AGENTS.md (`sources/gateway.py` bullet: `listeners` + `connections[]`, the four derivations/merge/refusals, `nginx_server_blocks()`; `api.py` `/api/gateway/*` paragraph) and README.md (`/api/gateway/snapshot` field list) updated for the payload rename. Page/UI prose left for tasks 04–06 as the phase directs.
**Notes** — Dropped the planned "name back-fill on merge" arm: haproxy always claims before nginx, so first-seen `kind`/`name` is lossless and the arm was unreachable; the code says so. `/api/summary`'s `gateway` block shape is deliberately untouched (task 02 reshapes it). Dependency-advisory lines in validate.sh are pre-existing and not failures.
Next pending task: `30_gateway_connection_map/02_the_join_simplified.md`.
@@ -0,0 +1,74 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 28%]
........................................................s............... [ 32%]
........................................................................ [ 36%]
........................................................................ [ 40%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 68%]
........................................................................ [ 72%]
......ssssssssssss...................................................... [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 93%]
..................................................................sss... [ 97%]
.................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 182 0 100%
src/dashboards/api.py 491 0 100%
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 5 98% 323-324, 328-329, 346
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 400 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3973 37 99%
Required test coverage of 94% reached. Total coverage: 99.07%
1764 passed, 16 skipped, 1 warning in 166.53s (0:02:46)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.10.19 2026.10.23 wheel
coverage 7.16.2 7.17.0 wheel
fastapi 0.153.0 0.161.1 wheel
pycparser 3.11 3.12 wheel
ruff 0.17.1 0.17.2 wheel
@@ -0,0 +1,15 @@
All checks green. Final state:
**Implemented** — `sources/gateway.py`: the join is now two attributions only (inbound = socket's local port names a listener; outbound = remote `(address, port)` equals a config address target), one exclusive pass, everything else ignored. Deleted `outbound[]`, `by_port`, `unmatched`(+`.groups`/`.other`), `partial`, `unmatched_listeners`, `MAX_GROUP_HOSTS`; `connections[]` now carries `connections` + `shared`, and `samples()` writes `gateway.out_conns@<host>:<port>` **only** for address endpoints. `api.py`: `/api/summary`'s block is `listeners/connections/live_in/live_out/map_targets`, with `live_out` summed over **distinct endpoints** (`_endpoint_values`) so one socket is never booked twice. Page: two cards (`haproxy`, `nginx`), four tallies, one footnote, no `by port` chip; `/`'s line reads `10 listeners · 33 connections · 9 live in · 3 live out`.
**Defects fixed from task 01** — `test_gateway_source.py` had no pin for the pool derivation, the merge rules, the captured 32-route graph or the two refusals (the report claimed them, and their lines were uncovered); added 6 tests, plus the loopback double-hop and endpoint-sharing pins this task needs.
**Tests** — rewrote the summary/snapshot/history/page/live pins for the new shape and pinned the absences by name (`no `by_port` vocabulary`, `nothing reconciles to established`, `a null is never 0`).
**Checks** — `uv run pytest`: 1758 passed, 16 skipped · `--cov` 98.91 % (floor 94), `sources/gateway.py` 99 %, `api.py` 99 % · `ruff` clean · `pyright` 0 errors · `node --check` ok · `bash .agents/validate.sh` **exit 0** · `pytest -m live --live` 12 passed.
**Live (read-only)** — `data/logs/verify-30-task02.py` → `30-task02-live.out`: 16 PASS / 0 FAIL (10 listeners, 33 routes, 2 address endpoints counted, no `by_port`/`unmatched`/`partial` in the payload, and the 13 name-keyed gauges' last write is *before* the restart while address-keyed ones keep landing). Browser `data/logs/verify-30-task02.cjs` → `30-task02-browser.out`: 26 PASS / 0 FAIL at 1440 px and 360 px — two cards, four tallies, one footnote, every uncounted route `n/a` and never `0`, every painted count the payload's own, no console errors; screenshots `30-task02-gateway-{desktop,phone}.png`, `30-task02-overview.png`. Phase-27 sweep re-run with `/gateway` in it: **371 PASS / 0 FAIL / 0 FINDING** (`30-task02-mobile-audit.out`).
**Docs** — AGENTS.md (join bullet, retired gauges in the metric-naming section, `/api/summary` block, testing policy, `/gateway` UI conventions, gap 13 corrected: the column split was **not** widened) and README.md (API list, page description, test section, overview line).
**No live infra was changed**; no git index/history mutation. Next pending task: `30_gateway_connection_map/03_config_warnings.md`.
@@ -0,0 +1,74 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 28%]
.........................................................s.............. [ 32%]
........................................................................ [ 36%]
........................................................................ [ 40%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 68%]
........................................................................ [ 73%]
ssssssssssss............................................................ [ 77%]
........................................................................ [ 81%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 93%]
............................................................sss......... [ 97%]
.............................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 182 0 100%
src/dashboards/api.py 498 3 99% 220, 250, 253
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 5 98% 323-324, 328-329, 346
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 362 4 99% 471, 482, 590, 873
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3942 44 99%
Required test coverage of 94% reached. Total coverage: 98.88%
1758 passed, 16 skipped, 1 warning in 162.31s (0:02:42)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.10.19 2026.10.23 wheel
coverage 7.16.2 7.17.0 wheel
fastapi 0.153.0 0.162.0 wheel
pycparser 3.11 3.12 wheel
ruff 0.17.1 0.17.2 wheel
@@ -0,0 +1,22 @@
Task 03 complete. Full gate green.
**Implemented**
- `src/dashboards/sources/gateway.py` — `haproxy_warnings()` / `nginx_warnings()` / `warnings()`: the seven kinds (`haproxy_route_dangling`, `haproxy_backend_orphan`, `haproxy_backend_empty`, `nginx_server_unlistened`, `nginx_pool_undeclared`, `nginx_variable_unmapped`, `nginx_map_route_missing`), rows `{id, kind, subject, message}` with `id = "<kind>:<subject>"`, deterministic haproxy-then-nginx order, deduped by `id`; only a section with `available is True` is judged; the three refusals carry comments naming why; `payload["warnings"]` in every arm incl. `stub()`; new "What this module warns about" docstring section.
- `alerts.py` — keyword-only `gateway_payload`; `_gateway_alerts` emits **one** `warn` row (`gateway:config`, `href /gateway`, payload `ts`) only while the job is configured/ok/not-stale; docstring rule list rewritten to "reachability plus config integrity, no graded number, ever".
- `api.py` — `/api/summary`'s `gateway` block grew `warnings` (a count; `null` in the unconfigured/never-answered/unsupported arms, a real `0` when the configs connect), and passes the payload to `alerts.build`.
- `page-index.js` — the overview line appends ` · N to address` only when `Number.isInteger && > 0`.
**Tests / gate (exact)**
- `uv run pytest` → **1783 passed, 16 skipped** (baseline 1758; +25). Coverage **98.94%** (floor 94%); `alerts.py` 100%, `sources/gateway.py` 99% (4 remaining misses are pre-existing task-01/02 arms).
- `ruff check src tests agent` → All checks passed. `pyright` → 0 errors. `node --check web/static/*.js` → clean. `bash .agents/validate.sh` → green.
- Both alert-row directions pinned in `test_alerts.py` (1 vs 7 warnings = 1 row; failing/stale/unconfigured = no config row) and in `test_api.py` (inject a dangling `use_backend` → exactly one `warn` row + `warnings: 1`; remove it → `[]` + 0). Failing-job-keeps-one-row pinned through the real hub.
- Live, read-only: `data/logs/verify-30-task03.py` → `30-task03-verify.out` (15/15: seven kinds from one-line mutations of the **captured** configs via the real agent parser, clean capture = `[]`, three refusals, absent/unreadable arms); `data/logs/30-task03-warnings.cjs` → `30-task03-warnings.out` (23/23 at 1440 px and 360 px) + `overview-30-task03-1440.png`, `gateway-30-task03-{1440,360}.png`; `data/logs/30-task03-acceptance.out`.
**Docs** — `AGENTS.md` (`alerts.py`, `sources/gateway.py`, `api.py` bullets, overview-line paragraph — this task changes the documented "gateway alerts are reachability only" rule) and `README.md` (gateway block, `/api/gateway/snapshot`, `/` line).
**Decisions/deviations**
- `haproxy_backend_empty` and `haproxy_backend_orphan` are mutually exclusive (an empty backend is not *also* an orphan) — stated in code and tests.
- A frontend whose only routing is a fetch expression and a vhost with a `listen` and no `proxy_pass` produce no warning, per the task's refusals; making the former distinguishable needs an agent flag, not a source guess.
- No live infrastructure changed beyond restarting the local dev server; the fleet's real ingress reads `warnings: []`.
**Next pending task** — `.agents/phases/todo/30_gateway_connection_map/04_page_two_tables.md`.
@@ -0,0 +1,74 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 8%]
........................................................................ [ 12%]
........................................................................ [ 16%]
........................................................................ [ 20%]
........................................................................ [ 24%]
........................................................................ [ 28%]
..................................................................s..... [ 32%]
........................................................................ [ 36%]
........................................................................ [ 40%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 68%]
........................................................................ [ 72%]
.........................ssssssssssss................................... [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
.............sss....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 499 3 99% 220, 250, 253
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 5 98% 323-324, 328-329, 346
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 463 4 99% 518, 529, 637, 1129
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4063 44 99%
Required test coverage of 94% reached. Total coverage: 98.92%
1783 passed, 16 skipped, 1 warning in 166.68s (0:02:46)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.10.19 2026.10.23 wheel
coverage 7.16.2 7.17.0 wheel
fastapi 0.153.0 0.162.0 wheel
pycparser 3.11 3.12 wheel
ruff 0.17.1 0.17.2 wheel
@@ -0,0 +1,22 @@
**Task 04 complete — `/gateway` is now two trunk→leaf panels plus one warnings list.**
Implementation
- `web/pages/gateway.html`: exactly two `article[data-gate]` panels (`nginx` then `haproxy`), `map`/`unmatched` deleted; new `section.card#gate-warnings` (ships `hidden`, `ul.alerts#warnings-list`); four tallies `#t-ports/#t-connections/#t-live/#t-warnings` (`#t-listeners/#t-established/#t-unmatched` gone); footer rewritten to three sentences.
- `web/static/page-gateway.js`: `groupsFor()` (ports ascending, a no-target listener still gets a group → `no target named in the config`), `renderGroup/renderTrunk/renderLeaves` built-once-and-patched via keyed `Map`s + `appendChild`, `renderWarnings()` in the overview's `li.warn/.src/.msg` vocabulary, `countText()`→`null` so a name endpoint prints `—` with its why-title (never `0`), drawn-and-inert switch (`buildSwitch()`, `aria-hidden`, no listener), charts discipline unchanged.
- `dash.css`: `.gate-map/.gate-group (minmax(0,9.5rem) minmax(0,1fr))/.gate-leaves/.gate-link/.gate-target/.gate-selector` + ≤640px stacking; only switch ink added (needed so the drawn switch isn't a black blob) — elbows/colour stay task 05.
- `tests/test_api.py`: rewrote the page-shape/degradation/clipped-label/live-sum pins; added 5 tests (two-panels+warnings, stream-map-inside-nginx, `:80` refusal, warnings both directions, dash-not-zero, switch-inert); fixed the stale `wait:` selectors in `data/logs/27-task02-mobile-audit.cjs` and `30-task03-warnings.cjs` so both standing sweeps stay runnable.
Checks
- `bash .agents/validate.sh` → rc 0: 1789 passed / 16 skipped, coverage 98.94% (floor 94), ruff clean, pyright 0 errors, `node --check` clean.
- Browser, live fleet: `data/logs/30-task04-panels.cjs` → **49/49** (`30-task04-panels.out`; `gateway-30-task04-{1440,360,reduced}.png`): nginx 4 ports·27 routes (`:443` 18 leaves — the live map today — `:4443` 6, `:8443` 3, `:80` refusal), haproxy 6 groups of one, 31 `—` vs 2 numbered, warnings panel hidden live and raised by one injected row, 0 px sideways scroll at 1440/360; FINDING: 0/33 labels clip at 1440, 27/33 at 360.
- Mobile audit re-run: 370 PASS / 0 FAIL / 0 FINDING; task-03 sweep 23/23; dev server restarted, **10 sources `ok`**.
Docs
- README `/gateway` paragraph and AGENTS.md UI-conventions paragraph rewritten (two panels, map is nginx's, trunk→leaf, warnings in alert vocabulary, drawn-not-wired switch); gap 13 updated (desktop arm closed by measurement, phone residue re-numbered) and gap 12 records this audit re-run.
Decisions/deviations
- Switch is drawn now (task 04 requires it) with minimal ink; task 05 still owns geometry/colour/elbows.
- History request stays one per visible panel per refresh with the plot gated on `chartsOpen()` — phase-29 rule, verified by measurement rather than changed.
- No dependency changes, so `uv pip list --outdated` advisory was left alone; no `<table>` introduced; other pages byte-identical.
Next pending task: `05_lines_and_switches.md`.
@@ -0,0 +1,75 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
s....................................................................... [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
...............................ssssssssssss............................. [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
...................sss.................................................. [ 99%]
..... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 499 3 99% 220, 250, 253
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 5 98% 323-324, 328-329, 346
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 463 4 99% 518, 529, 637, 1129
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4063 44 99%
Required test coverage of 94% reached. Total coverage: 98.92%
1789 passed, 16 skipped, 1 warning in 166.82s (0:02:46)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.10.19 2026.10.23 wheel
coverage 7.16.2 7.17.0 wheel
fastapi 0.153.0 0.162.0 wheel
pycparser 3.11 3.12 wheel
ruff 0.17.1 0.17.2 wheel
@@ -0,0 +1,26 @@
Done. Task 05 (`/gateway` connection lines and switches) is implemented, tested, and verified against the live ingress.
```
Changed src/dashboards/web/static/dash.css --gate-on/--gate-off existence pair (+ --gate-leaf-gap/
row-gap); spine = each .gate-link's ::before/::after halves meeting across the leaf gap, first/last
dropped, .gate-group.solo → straight across; pipe colour from on/off, motion from live/still only.
page-gateway.js routeInConfig() → every drawn row `on`; switch is now <button role="switch"
aria-checked aria-disabled="true"> + knob, named ("endpoint, reached by <selector>, in the nginx
config, on"); renderGroups sets .solo from the group's own leaf count; no new listener.
tests/test_api.py new: test_a_connection_is_green_because_it_is_in_the_config,
test_haproxy_draws_straight_across_and_nginx_branches; rewrote test_the_switch_is_built_and_explicitly_
unwired (was the aria-hidden SVG mark), the motion pin (two class pairs) and the reduced-motion pin
(+.gate-switch-thumb, + "nothing in the map animates that isn't stilled"), and the no-verdict docstring.
AGENTS.md / README.md the /gateway UI paragraphs now say colour=config existence, motion=socket,
and describe the real-but-unwired switch and the branch geometry.
Green uv run pytest → 1791 passed, 16 skipped · coverage 98.92% (floor 94) · ruff clean · pyright 0 errors ·
bash .agents/validate.sh → exit 0.
Live data/logs/30-task05-lines.cjs → 46 PASS / 0 FAIL (exit 0; gateway-30-task05-{1440,360,reduced}.png):
33/33 lines compute to --gate-on in BOTH themes; 1 live line animates, 32 resting stay green;
reduced motion stills pipes AND knob; 6 haproxy groups solo/straight; nginx :443/18, :4443/6, :8443/3
spines at one x (186 = trunk right = tick left), continuous, stopping at first/last; 33 rows × 1 switch;
Playwright itself refused the click as "not enabled", forced → nothing changed, 0 non-GET requests;
360 px 0px sideways scroll, stacked trunk still connected. 27-task02-mobile-audit re-run: 371 PASS /
0 FAIL / 0 FINDING; 30-task04-panels re-run (its one switch check updated to the shipped shape): 49/0.
Left .agents/phases/todo/.../05_lines_and_switches.md untouched (not my file to move); task 06 still to run.
```
@@ -0,0 +1,75 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
..s..................................................................... [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
.................................ssssssssssss........................... [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
.....................sss................................................ [ 99%]
....... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 499 3 99% 220, 250, 253
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 4 98% 324, 328-329, 346
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 463 4 99% 518, 529, 637, 1129
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4063 43 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1791 passed, 16 skipped, 1 warning in 169.28s (0:02:49)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
certifi 2026.10.19 2026.10.23 wheel
coverage 7.16.2 7.17.0 wheel
fastapi 0.153.0 0.162.0 wheel
pycparser 3.11 3.12 wheel
ruff 0.17.1 0.17.2 wheel
@@ -0,0 +1,13 @@
Done. Task 06 complete.
**Implementation (docs + verification only — no behaviour change):**
- `AGENTS.md`: rewrote the `/gateway` page-table row (two panels, trunk→leaf), the `/api/summary` gateway block key list (now the twelve it serves, `warnings` included), `sumConnections()`→`sumLive()`, gap 12's audit re-run, gap 13 with today's measurements (1440 px 0/33+0/33 in a 361 px column; 360 px 31/33+22/33 in 139 px — the switch's 34+26 px — every row still carrying its full text on `title`), the fixture-vs-live drift note (fixture 17 map entries, live 18), and the commands block's counts.
- `README.md`: page-table row, ingress line figures, dropped hard-coded domain counts; `dash.css` + `gateway.html` comments likewise de-numbered. `GATEWAY_HOST` config row unchanged — the phase added no env setting.
- New: `data/logs/30-connection-map.cjs` (**63 PASS / 0 FAIL**, 5 screenshots), `data/logs/verify-30-connection-map.py` (**17 PASS**), `data/logs/30-final-mobile-audit.out` (**371 PASS / 0 FAIL / 0 FINDING**), `data/logs/NOTES-30-connection-map.md`.
- Live read-only: `warnings: []`; no `unmatched`/`outbound`/`by_port`/`partial`; 6 haproxy frontends → exactly their own `server`; 18 `map` entries = 18 `:443` leaves incl. `default → 127.0.0.1:4443`; `:4443` 6 / `:8443` 3; `:80` listener with no connection; no `9120`. **Inbound reconciliation: page 17 = kernel 17** (`ssh gateway-ops cat /proc/net/tcp{,6}`, bracketed reads in the transcript). 10 sources `ok`; `git diff --stat agent/ deploy/` empty.
**Tests/lint:** `bash .agents/validate.sh` exit 0 — 1791 passed, 16 skipped; coverage 98.94% (floor 94); ruff + pyright + `node --check` clean; `uv pip list --outdated` empty.
**Notable decisions:** rule 8 forced a lock refresh (certifi/coverage/fastapi 0.153→0.162/pycparser/ruff) and re-verified everything, including the browser pass, after a dev-server restart; that exposed a fixed-sleep flake in `tests/test_poller.py`'s maintenance tests, now a `_wait_for()` predicate on the real claim (no production code moved). Fixture/live map-count drift is recorded, not silently re-captured. Git commit left to the operator (harness forbids mutating the repo history).
**Next pending:** none — 06 was the last task in `.agents/phases/todo/30_gateway_connection_map/`.
@@ -0,0 +1,69 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 27%]
........................................................................ [ 31%]
..s..................................................................... [ 35%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 71%]
.................................ssssssssssss........................... [ 75%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
.....................sss................................................ [ 99%]
....... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 499 3 99% 220, 250, 253
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 5 98% 323-324, 328-329, 346
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 463 4 99% 518, 529, 637, 1129
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4063 44 99%
Required test coverage of 94% reached. Total coverage: 98.92%
1791 passed, 16 skipped, 1 warning in 168.76s (0:02:48)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
+341 -126
View File
@@ -29,7 +29,7 @@ file — plus an overview page that says which of the five you need:
| `/disks` | Are any of my physical disks failing, and which one — health, temp, hours, wear | the same agents, `smartctl -i -A -H` per whole drive (never a partition) | | `/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` | | `/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) | | `/ups` | Battery health, runtime left, power events, battery-test buttons | NUT `upsd` (our own wire-protocol client; 3051 here, not 3493) |
| `/gateway` | What is coming in through the router, and where does it go — frontends, `server_name`s, the SNI map, live pipes | the same agent (1.2.0) on the one host `GATEWAY_HOST` names: its `haproxy` + `nginx` config sections joined to its `/proc/net/tcp{,6}` sockets | | `/gateway` | Which domains come in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`), a trunk per bound port with its configured endpoints branching off it, each line carrying the config's own word for the route and its live socket count | the same agent (1.2.0) on the one host `GATEWAY_HOST` names: its `haproxy` + `nginx` config sections joined to its `/proc/net/tcp{,6}` sockets |
History is a first-class feature, not a sparkline afterthought: every number worth graphing 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`. is stored, rolled up (raw → 1m → 1h), and served from `/api/*/history`.
@@ -472,39 +472,97 @@ Responsibilities, one line each:
arithmetic and a dead agent never shrinks a fleet total. arithmetic and a dead agent never shrinks a fleet total.
- `sources/gateway.py` — the same snapshot projected onto the **connection topology** of the one - `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` 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 frontends and `nginx` `listen`s into **`listeners`** (one per listen port, carrying the
`server_name` domains and the live ESTABLISHED count + distinct client addresses) and haproxy `server_name` domains and the live ESTABLISHED count + distinct client addresses — the key was
`server` lines, concrete `proxy_pass` targets and the `$var`→`map` entries into **outbound** `inbound` until phase 30 task 01 renamed it, because "a port a config names" is the thing the
pipes, plus the `unmatched` tallies (`inbound` = connections on a listening port no config page draws a trunk for, not a direction) and haproxy
names, `outbound` = connections to something no target claims, `other` = live listeners no and the config-declared routes into **`connections[]`**. **The join is two attributions and
config claims — phase 28's "an unclaimed socket stays visible as one"). `count` semantics are nothing else** (phase 30 task 02 — the operator called the phase-29 version overengineered, and it
the whole honesty of the module: **no DNS** (the spy test pins `socket.getaddrinfo` is never was). Inbound: a socket's **local** port names a configured listener, which is a fact (`/proc`
called — `ipaddress` decides "is this already an address", and it never asks), an exact states it, no config reading needed). Outbound: a socket's remote `(address, port)` **equals a
`(address, port)` match for a target written as an address (`by_port: false`), a port-only target written as an address** in one of the two config files — the only outbound match that
match for a **name** and only when that port has exactly one configured host claims nothing, because the target of a `server` line, a `proxy_pass` or an `upstream` member *is*
(`by_port: true`), and **no attribution at all** when several names share a port (the real the endpoint the gateway opened. `join_sockets()` still partitions the table in **one** pass, so a
`stream.conf` map's sixteen `*.reeselink.com:443`): those pipes carry `connections: null`, the socket classified both ways would be counted twice — which is exactly what `127.0.0.1:8443` is:
sockets go to `unmatched.outbound`, and `unmatched.groups` says once per port how many and the stream `map`'s `default` sends `:443` to that port and the box's own http vhosts listen on it,
among which candidates — picking one would be rule 2, and repeating the port's total on so the loopback hop counts **once as one inbound and once as one outbound**, a pipe on each card,
sixteen pipes reads as sixteen times the traffic. A pipe is `null` (never `0`) whenever the neither claiming the other's number. Everything else is **ignored**: not counted, not totalled,
socket table did not answer or the join could not decide, and publishes **no sample** then, so not warned about. What that retires is the guessing and the bookkeeping it existed to explain —
a chart cannot draw a zero for an unknown. The classification is one exclusive pass the port-only guess for a name target, `unmatched` / `unmatched.groups` / `partial` /
(`join_sockets`), which is what makes "inbound + outbound + unmatched = the socket table" `unmatched_listeners`, and `by_port` with its `MAX_GROUP_HOSTS` chip. A **name** target
hold — the identity task 06 checks against a direct `/proc/net/tcp` read. `supported: false` (`reeseapps-immich.reeselink.com:443`) is therefore never credited: crediting it needs DNS (which
the module still never calls, spy-pinned) and every name here resolves back through this same
router, so the count would be attributed to something the config never said. Two consequences the
page and the tests both depend on: **`live_out` is not the bandwidth** and the socket table never
reconciles to it — a pipe count means "this route has N live connections" and nothing more — and
several `(listen, endpoint)` lines reaching one endpoint carry one count **repeated** with
`shared: true` (never a split nobody measured), which is why `/2`'s totals and the tests sum
**distinct endpoints**. A pipe is `null` (never `0`) whenever the socket table did not answer or no
line reaches its endpoint, and publishes **no sample** then, so a chart cannot draw a zero for an
unknown. `supported: false`
(a 1.1.0 agent, or `--no-proxy`) keeps every key with empty lists and null counts plus (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 `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 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>` proxy is the job) or a `window_seconds` (a count is not a rate). **`connections[]`** (phase 30
and `gateway.out_conns@<host>:<port>` — the port **is** in the outbound name, because this task 01) is the graph the pipe lists never stated: one row per route the two config files
fleet's two haproxy Minecraft backends share `minecraft.reeselink.com` on 25565 and 25566 and actually declare, from `config_connections()`, each carrying its `in.port`, its `out`
two gauges on one name would overwrite each other in silence. `parse_gateway_snapshot()` is the `{host, port, name, kind, selectors, address}` (`kind` is `backend` / `server_name` / `pool` /
`map`, `selectors` the config's own word(s) — a haproxy backend name, an nginx `server_name`, a
stream `map` key, the literal `default` kept verbatim), its own `metric`, and `connections: null`
until task 02 re-points the socket join at it. Four derivations (haproxy `frontend → routes →
backend → server`, an nginx vhost's concrete `proxy_pass` per `listen` port of **its own block**
— which is why `nginx_server_blocks()` replaced the per-port `nginx_listen_ports()` — a
port-less `proxy_pass` naming an `upstream`, and a `proxy_pass $var` fanning out through the
`map` whose `variable` matches), one merge rule (identical `(source, in_port, host, port)` is one
line whose selectors union in first-seen order; **never** merged across `in_port`) and two silent
refusals (a `proxy_pass` with neither target nor variable, and a haproxy frontend that routes by
a fetch expression, which the agent reports as no name — that trunk is drawn alone, because "no
name" and "no routing directive" are one value in this payload). **Name-keyed outbound gauges are
retired** (phase 30 task 02): `gateway.out_conns@<host>:<port>` is written **only** for an endpoint
the config wrote as an address (`out.address` true), because the sole reason a name-keyed gauge
existed was the port-only guess the join no longer makes — such a series would be a chart of a
number that is always `null`. `gateway.in_conns@<port>` is unchanged, and the port **stays** in the
outbound name, because this fleet's two haproxy Minecraft backends share `minecraft.reeselink.com`
on 25565 and 25566 and two gauges on one name would overwrite each other in silence; lines sharing
an endpoint write the same value to the same name (they are one count, not two) and `samples()`
de-duplicates by metric. `parse_gateway_snapshot()` is the
`Result` the poller consumes; there are **no events** (a connection count has no transition `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 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 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 `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 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 `WHY_PREDATES`: an agent that has not replied has not said it has no sections (invariant 3's two
facts staying two sentences). facts staying two sentences). **`warnings()`** (phase 30 task 03) is the payload's other half: the
two config files judged against **themselves**, seven kinds and nothing else —
`haproxy_route_dangling` (a `default_backend`/`use_backend` name no `backend` declares, one row per
frontend→backend line, `subject` `"<frontend>-><backend>"`), `haproxy_backend_orphan` (a `backend`
no frontend routes to, its server count from the config), `haproxy_backend_empty` (a `backend` with
no `server` — deliberately *not* also an orphan: `orphan` needs a server to have nowhere to go),
`nginx_server_unlistened` (a `server` block with a `server_name` and no `listen`, in both its
proxying and non-proxying arms), `nginx_pool_undeclared` (a `proxy_pass` whose value has no port —
the shape task 01's `out.kind: "pool"` already draws no line for — naming no `upstream`; a block
with two such `listen`s is **one** warning, `subject` `"<pool>@<port>+<port>"`, because the repair
is one line), `nginx_variable_unmapped` (a `proxy_pass $var` no `map` defines — the `map` *is* this
fleet's router, so that is a broken route and not a style note) and `nginx_map_route_missing` (an
entry of a **referenced** `map` whose value is not an address). Every row is
`{id, kind, subject, message}` with `id = "<kind>:<subject>"`, `message` complete on its own because
`alerts.py` quotes `warnings[0]` verbatim, `kind` the vocabulary the page groups by and `subject`
the config's own name for the thing, never a paraphrase of the message. Three things it refuses to
do, each pinned from the real capture and not only from a fake section: it never **joins** a warning
to `connections[]` or `listeners[]` (a page indexes one row by its own `id`; a boolean beside a
hundred rows is a second source of truth about the same route), it never guesses a **name
target**'s address (the module still never calls `socket.getaddrinfo`, spy-pinned), and it stays
silent about the three shapes that are legal or unreadable rather than broken — a vhost with a
`listen` and no `proxy_pass` (a `:80` `return 301` is identical here), a frontend whose only routing
is a fetch expression (agent 1.2.0 reports *no name*, the same value "no routing directive" has, so
distinguishing them is an agent change and not a source-level guess), and an entry of a `map` no
`proxy_pass` names (the `$connection_upgrade` map's `upgrade`/`close` are header words, not missing
routes). Like the socket half, a section whose `available` is not `True` is **not judged** — a host
that cannot be read has not been found guilty of anything — so `warnings` is `[]` for an absent,
unreadable or pre-1.2.0 config and the page cannot tell "clean" from "unreadable" by that list
alone; it reads `sections` for that, the same pair `supported`/`why` already form. Order is
deterministic (haproxy kinds in config order, then nginx kinds) and rows are deduplicated by `id`,
because the page keys its DOM on it and the alert quotes the first.
- `sources/llm.py` — gufo + vLLM field maps (`GUFO_SERIES`, `VLLM_SERIES`, - `sources/llm.py` — gufo + vLLM field maps (`GUFO_SERIES`, `VLLM_SERIES`,
`VLLM_HISTOGRAMS`), measured Δ rates **diffed per label set** (`rate_key()` keys the `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 estimator on the resolved name plus its labels; the cell is the Σ of the per-series
@@ -705,9 +763,22 @@ Responsibilities, one line each:
A missing `host:<name>` record suppresses nothing — "I cannot find the host's job" is not evidence 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 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, 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 so 400 connections on an ingress is the job, not a fault. What phase 30 task 03 added is the one
reachability — stale past 3× its own 30 s interval or stopped answering — and 400 connections on non-reachability voice it *can* have without a threshold — **config integrity** — because "a proxy
an ingress is the job, not a fault. config that does not connect to itself" is a structural fact, not a graded number: `alerts.build`
gained a keyword-only `gateway_payload`, and one `warn` row (`id` `gateway:config`, `href`
`/gateway`, `at` the payload's own timestamp so the line does not reset every 30 s) exists exactly
when that payload carries `warnings` **and** its job is configured, `ok` and not stale. One row
whatever the count — seven dangling routes are one config to fix and the page lists all seven —
reading `"{n} proxy config {problem|problems} to address — {first message}"`, with the message
built by `sources/gateway.py` and only quoted here. It is suppressed whenever the job's own
reachability row is on screen (`ok: false` or stale past 3× its interval), because a router that is
not answering is the operator's problem and re-listing warnings from a reading they cannot refresh
is noise beside it; the page keeps showing them beside the last good numbers the whole time. So
`alerts.build`'s signature is now the boundary that keeps the *counts* out: no `severity` reaches
it and no `gateway.*` threshold can be consulted, which is what
`test_a_gateway_payload_with_a_fabricated_verdict_alerts_nothing` re-pins after it replaced the
older "takes no gateway payload at all" wording.
- `store.py` — `sample(source,host,metric,res,ts)` + `event` + `meta`, rollups, pruning. The - `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 `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 named helpers — `get_meta`/`set_meta`/`delete_meta` (the last returns its rowcount, because
@@ -849,10 +920,14 @@ Responsibilities, one line each:
lists — the third is phase 15's, and it writes `meta` rows and nothing else). lists — the third is phase 15's, and it writes `meta` rows and nothing else).
`/api/gateway/*` (phase 29 task 03) is the ingress pair, and it is the same two rules on a `/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 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 `GATEWAY_HOST` entry — the haproxy frontends and the nginx `listen`/`map` echo, the `listeners`
(port, domains, live count, up to `MAX_CLIENTS` client addresses), the outbound pipes (`by_port` (port, domains, live count, up to `MAX_CLIENTS` client addresses), the `connections` graph (one row
naming which half of the match is a socket fact), each pipe's own `metric`, the `unmatched` per route the configs declare, its `in.port` always one of those listeners, its own `metric`,
tallies, and `supported`/`sections`/`why` — with `states` appended and the payload's own `ts` `shared` when more than one line reaches its endpoint), and the `warnings` list (phase 30 task 03:
seven kinds of config that does not connect to itself, each `{id, kind, subject, message}` with
`id = "<kind>:<subject>"`, empty for a pre-1.2.0 agent and for a section that did not answer) —
with `states` appended and the payload's own
`ts`
left alone, because that is when the agent took the reading and it is what the page ages. Three 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** 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 when `GATEWAY_HOST` names no `HOST_TARGETS` entry (unset and wrong-name are both spy-pinned to
@@ -862,7 +937,9 @@ Responsibilities, one line each:
and minus only the `agent_version` nobody published, every count `null` because an agent that has 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` 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 or a `window_seconds`, at any depth (pinned by a recursive walk: a busy proxy is the job and no
Δ was ever measured). `/history` serves the `gateway.in_conns@<port>` / Δ was ever measured). Phase 30 task 02 took `outbound[]`, `unmatched` and `by_port` out of it: the
flat list duplicated the graph with a worse count, and the residual existed only to explain a
guessing rule that no longer runs. `/history` serves the `gateway.in_conns@<port>` /
`gateway.out_conns@<host>:<port>` gauges with `hosts=` defaulting to the ingress **alone** (the `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 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: machines nobody asked to route), and it carries no configured gate because it dials nothing:
@@ -896,20 +973,35 @@ Responsibilities, one line each:
`system.hosts`/`llm.engines` exactly as `/api/system/snapshot` does, so `hosts_total` `system.hosts`/`llm.engines` exactly as `/api/system/snapshot` does, so `hosts_total`
counts the machines there are and `hosts_live` the ones answering. 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, 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, supported, unreachable, listeners, connections, live_in, live_out, map_targets, warnings, states}`
states}`. Task 02 wrote it for programmatic consumers (the page itself reads — twelve keys, `warnings` added by task 03. The middle six were renamed by phase 30 task 02 from
`inbound`/`outbound`/`unmatched`/`unmatched_listeners`/`partial`, because the first pair now counts
a graph and not a flat pipe list and the residual no longer exists; `tests/test_api.py` pins the
set, so a thirteenth field is a decision and not an accident. `listeners` and `connections` are **row counts** ("how many trunks / routes does
this gateway declare", which is what the overview's one line prints) and `live_in`/`live_out` are
the two socket-derived totals; Task 02 wrote it for programmatic consumers (the page itself reads
`/api/gateway/snapshot`, task 03); task 05 gave it one page-side reader, the single line `/` `/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 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 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`, 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` 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 resolves the served `GATEWAY_FIELDS` against a live document). `live_in` sums the listeners and
map's routing entries; every figure is `null` — never `0` — in the arms that did not measure `live_out` the **distinct outbound endpoints** — never the connection lines — because several
(unconfigured, configured-but-never-answered, a 1.1.0/`--no-proxy agent, a silent socket table), `(listen, endpoint)` lines carry one socket count repeated with `shared: true`, and summing the
and `partial: true` says a total had to leave an unknown pipe out (a port whose configured targets lines would book the same sockets once per line and print a router busier than it is (the same
cannot be told apart) rather than pretend to be the whole number. `unmatched` counts rule that keys the outbound gauges by `(address, port)`); `api._endpoint_values()` is the one
*connections* and `unmatched_listeners` counts *ports*, so they are two fields and never one sum, reader of that, and a line with no `metric` (its target is a name the join can never count) is
and nothing in the block carries a `severity`. `/api/config` grew the matching row (`enabled`, absent rather than an unmeasured endpoint holding the total hostage. `map_targets` counts the
stream map's routing entries. Every figure is `null` — never `0` — in the arms that did not
measure (unconfigured, configured-but-never-answered, a 1.1.0/`--no-proxy agent, a silent socket
table), and a total is `null` rather than a part of itself when any countable endpoint is unknown
(`page-gateway.js`'s `sumLive()` mirrors it for the one tally the page paints — the page has no
outbound total of its own, so only the inbound half is summed there). Task 30.03 added the
twelfth field, `warnings` — the **count** of `sources/gateway.warnings()` rows, which is what the
page's `warnings` head tally and the overview's ` · N to address` suffix both read (the overview
prints it only above zero; a permanent `0 to address` is the ink the empty-state rules exist to
avoid). Nothing in the
block carries a `severity`. `/api/config` grew the matching row (`enabled`,
`host`, `url`, `interval_seconds`, `timeout_seconds`), where `url` is the named entry's `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 `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 to a name-only source exactly as it does to a URL-bearing one, because this body is what a browser
@@ -1058,9 +1150,11 @@ Responsibilities, one line each:
to a threshold. `Dash.severityFor(value, warnAt, failAt)` is the documented fallback for to a threshold. `Dash.severityFor(value, warnAt, failAt)` is the documented fallback for
values the API does not rate, and **no page script calls it any more** — `/` was converted values the API does not rate, and **no page script calls it any more** — `/` was converted
in phase 02, `/system` in phase 03, `/llm` in phase 04, `/ups` in phase 05 and `/disks` in phase 02, `/system` in phase 03, `/llm` in phase 04, `/ups` in phase 05 and `/disks`
shipped that way from its first commit, so every colour on all five pages comes from a shipped that way from its first commit, so every colour on those five pages comes from a
`severity` verdict the API sent `severity` verdict the API sent
(`tests/test_api.py` fails if a page script names it again). A value the API should rate (`tests/test_api.py` fails if a page script names it again). The sixth dashboard colours nothing
by verdict at all — `/gateway`'s only colour pair is existence (`--gate-on`/`--gate-off`), and it
carries no `severity` token anywhere. A value the API should rate
gets a row in `severity.py`, never a bound in JS. gets a row in `severity.py`, never a bound in JS.
**The masthead is three rows at every width, the two control rows flush right** (phase 20 task **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 01 made the rows, `TODO.md:5-6`; phase 25 task 02 re-justified them, `TODO.md:4` — "I made a
@@ -1223,7 +1317,10 @@ dashboard family — `host`, `llm`, `ups`, `disks` for the SMART series
`disk.uncorrectable@…`, `disk.percentage_used@…`), and `gateway` for the phase-29 connection `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 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 in the outbound name because two services on one host are two facts and one name would make the
second insert overwrite the first). Those keep their own family instead of second insert overwrite the first; since phase 30 task 02 the outbound gauge is written **only** for
an endpoint the config wrote as an **address**, so no name-keyed `gateway.out_conns@…` name is ever
inserted again — a host that had one before this change keeps the old series until `maintain()` ages
it out, and its chart simply stops growing points). Those keep their own family instead of
sitting beside `host`'s `disk.used_pct@/mount` because the cadences differ thirty-fold and 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". `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. That scheme is the history API's key — treat it as a public interface.
@@ -1260,7 +1357,8 @@ That scheme is the history API's key — treat it as a public interface.
`/usr/local/lib/dashboards-agent/host_agent.py` installed at 13:27, and then its bearer token `/usr/local/lib/dashboards-agent/host_agent.py` installed at 13:27, and then its bearer token
went into `HOST_TARGETS`' URL userinfo — so `host:driveripper` and `disks:driveripper` are went into `HOST_TARGETS`' URL userinfo — so `host:driveripper` and `disks:driveripper` are
**`ok`** and the `live` suite is **11 passed / 0 failed** (it was 9/2 on the missing token that **`ok`** and the `live` suite is **11 passed / 0 failed** (it was 9/2 on the missing token that
morning, and 8/3 on the ConnectError before that). The unit reads morning, and 8/3 on the ConnectError before that; the suite has grown since — `uv run pytest -m
live --live` reads **12 passed / 0 failed** on 2026-10-23). The unit reads
`--host 10.1.0.126 --port 9120 --token-file /etc/dashboards-agent.token` (`640 `--host 10.1.0.126 --port 9120 --token-file /etc/dashboards-agent.token` (`640
root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) and is still in the `disk` root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) and is still in the `disk`
group (`getent group disk` → `disk:x:6:dashboards-agent`) — but it does **not** run as that group (`getent group disk` → `disk:x:6:dashboards-agent`) — but it does **not** run as that
@@ -1518,9 +1616,9 @@ uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
# GET /api/certs/<name>/bundle and writes # GET /api/certs/<name>/bundle and writes
# data/certs/<name>.bundle at 0600 — mints nothing # data/certs/<name>.bundle at 0600 — mints nothing
uv run pytest # hermetic: 1746 passed + 16 skipped (12 live, uv run pytest # hermetic: 1791 passed + 16 skipped (12 live,
# 4 store_perf), no network # 4 store_perf), no network
uv run pytest --cov # coverage gate (99.08%; floor 94%) uv run pytest --cov # coverage gate (98.9%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd uv run pytest -m 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 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 # store, priced against `store_perf.BUDGETS` (the cached
@@ -1639,14 +1737,26 @@ branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only
`test_the_real_drives_answer_every_number_the_disks_page_prints` asserts every number `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 `/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. 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** - **A join is pinned by the rules it applies and the one question it may never ask** (phase 29,
(phase 29). `tests/test_gateway_source.py` runs the captured gateway configs and the recorded simplified by phase 30 task 02). `tests/test_gateway_source.py` runs the captured gateway configs
`/proc/net/tcp{,6}` tables through the **real** `host_agent` parsers, so the join is compared with and the recorded `/proc/net/tcp{,6}` tables through the **real** `host_agent` parsers, so the join
the agent's document and not with a copy of its field names, and then demands the arithmetic: is compared with the agent's document and not with a copy of its field names, and then demands the
inbound + outbound + unmatched equals the socket table's own ESTABLISHED count, a port no config two attributions and nothing beyond them: a socket whose **local** port names a listener is
claims stays visible in `unmatched.other`, several names sharing one port answer `null` rather than inbound, a socket whose remote `(address, port)` equals a configured target is outbound, and
picking a favourite, and `socket.getaddrinfo` is spied so the day the join resolves a name the run **every other socket is ignored** — the capture's `established` (18 that day) and the two totals
fails — deciding "already an address, or a name?" is all that module may ask (rule 2). (9 in, 2 out) are therefore both printed and deliberately never reconciled, because the identity
this suite used to demand (in + out + unmatched = the table) was the arithmetic of a guessing rule.
The rules that must never come back are pinned as absences: no `by_port`/`partial`/`unmatched`
vocabulary in the payload or on the page, no `0` for a pipe the join could not count, and
`socket.getaddrinfo` spied so the day the join resolves a name the run fails — a name is never
credited with a connection (rule 2), which is why 14 of this fleet's 18 live sockets are nobody's
pipe and why **`live_out` is not the bandwidth**. One number in that suite is a **date**, not a fact:
the captured `stream.conf` has 17 `map` entries (so 32 connections and 17 leaves on `:443`), while the
live router answered **18** entries / 33 connections on 2026-10-23 (`data/logs/30-live-verify.out`)
because a domain was added after the capture. That is the designed difference between a fixture and a
reading — folding the drift in means re-running `./scripts/capture-gateway-config.sh` **and**
re-tallying `tests/test_gateway_source.py` in the same commit, never editing a test to match a live
read.
- Anything that needs a failure mode — upsd refusing a command, an engine 500ing, a host - 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`. 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.** - **A verdict the operator sees is pinned on the surface that shows it, in both directions.**
@@ -1881,9 +1991,18 @@ two-panel shape against the served HTML.
Under the fleet table sits the **ingress's one line** (phase 29 task 05): `#fleet-gate`, filled by 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 `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 invents neither a route nor a number (`gateway 10 listeners · 33 connections · 17 live in · 2 live
source did not measure, the shared `.stale` dim when the agent stopped answering, and the reason in out` as the live ingress answered on 2026-10-23 — those four figures are a *reading*, the same way a
its `title` when it is `partial`). `configured: false` renders **nothing at all** — the `disks` listener count is, and only the field names behind them are a contract — since phase 30 task 02
reshaped the block to say what the configs declare as well as what
the sockets are doing, `n/a` for a count the source did not measure, the shared `.stale` dim when
the agent stopped answering). A non-zero `warnings` appends ` · N to address` through its own
one-row table, `GATEWAY_WARNING_FIELD`, and **only** when `Number.isInteger` and `> 0`: a clean
ingress and an ingress that never answered both print nothing, because a permanent `0 to address`
is the ink the empty-state rules exist to avoid and a permanent sentence about checks that did not
fire trains the eye to skip the one that did (phase 30 task 03 — the same file also pins that the
tally is read through the table, so a rename fails there rather than printing nothing forever).
`configured: false` renders **nothing at all** — the `disks`
block's invariant-3 rule, no row and no red one to learn to ignore — and the row is a `<p>` inside 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). the fleet panel because the page is two panels, not three (the pin counts `<section>`s).
@@ -2038,40 +2157,116 @@ repaints only when charge / verdict / state change (`drawGlyph()` keys a `data-d
charge float jitters every `NUT_INTERVAL_SECONDS`, and rebuilding the `<svg>` on that re-announces 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. 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 **The `/gateway` page is the config's own tree, and a line's only claim is a count** (phase 29;
on the payload's own `source` word (`haproxy`, `nginx`, `map`) plus `unmatched`, each shipping joined differently by phase 30 task 02; laid out as trunk→leaf panels by phase 30 task 04): **exactly
`hidden` and appearing on the poll that has pipes for it — a gateway with no nginx in it spends no two panels**, one per config file, in the order `nginx` then `haproxy` (`GATEWAY_CARD_KEYS`), each
panel saying so, and a section that did not answer at all is the page's one `why` line rather than a shipping `hidden` and appearing on the poll that has rows for it — a gateway with no nginx in it
card repeating it in a smaller font. A row is inbound (`:<listen port>`, its `server_name`s as the spends no panel saying so, and a section that did not answer at all is the page's one `why` line
domains, up to `MAX_CLIENTS` client addresses as the footnote) or outbound (the target **as the rather than a card repeating it in a smaller font. There is **no `map` panel and no `unmatched` one**:
config wrote it**, with the SNI name the stream map matched on in front of it when exactly one key the stream `map` is nginx config, so its fan-out is the nginx panel's `:443` leaves
routes there), and its count is `countText()` — a `null` from the join prints `n/a` and never `0` (`out.kind: "map"`, its SNI key as the selector) read beside the port they reach, and a residual was
(rule 2). The pipe is hand-rolled SVG (`role="img"` + its own `aria-label` naming port/target, the left-over of a rule the join stopped applying.
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 Inside a panel the rows are **groups** (`groupsFor()` → `renderGroups()` → `renderTrunk()` +
any depth, and `tests/test_api.py::test_no_gateway_count_is_coloured_by_a_verdict` fails the day a `renderLeaves()`): a *trunk* is one port that file binds (`:<port>`, its live count, up to
`severity`/`ok|warn|fail` token appears in the page or the script (a busy ingress is the job, the `MAX_CLIENTS` client addresses, and its `server_name` count with the whole list on `title`), and the
`net.*` and GPU precedents). A target matched on its socket **port** alone carries a `by port` chip, *leaves* hanging off it are the endpoints that port names, each with the config's own word for the
because that count is the port's and not the target's (`by_port`, and the reason a shared `:443` route above it (a haproxy `server` name, a vhost's `server_name`, a stream `map` key) and its own
fan-out prints `n/a` instead of repeating one number sixteen times). The page head's five tallies are `metric`. The page draws what the config declares and nothing else: the deleted `0`-padding
the socket table's own totals (`listeners`, `established`) beside the totals the pipes carry (`pipes (listeners with no connection, endpoints no line reaches) is gone because a group *is* a listener and
in`, `pipes out`) plus `unmatched`, so the page prints its reconciliation rather than asserting it, an unrendered endpoint has no row to sit in. **A trunk whose config names no target is a fact and not
and the `unmatched` card's three cells stay three (`other` counts **ports**, the other two a gap in the drawing** — this fleet's nginx `:80` is a `return 301`, so its leaf list renders the
**connections**). A label gets its **line**: `.pipe-label` wraps and `.pipe-name` is based on `max-content`, so a short script's own "no target named in the config" rather than an empty box (the payload publishes no row
`:22` sits beside its count and a long target takes the whole column with the `by port` chip, the for it; `sources/gateway.py` refuses to warn about that shape for the same reason). Groups are keyed
config's own `server` name and the count beneath it (task 06 measured the alternative — one `nowrap` on the port, and rows on the connection's own `id`, in per-panel `Map`s (`state.groups`,
line, seven characters of a hostname — and `data/logs/29-task06-card-haproxy.png` is the result). What `state.rows`):
still does not fit a ~150px column is ellipsised and not lost: `renderPipeRow` puts the whole string built once, patched afterwards, reordered with `appendChild` of the existing node and pruned once per
on the element as `title` and `pipeAria` carries the whole endpoint, which is what panel by id, because rebuilding a 17-row fan-out every 30 s re-announces all of it to assistive tech
`test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins along with the two declarations; for a difference of one connection (the `/llm` slot-grid rule). `pruneCards()` wiping a panel's whole
the residual and the options for it are gap 13. Charts follow `/system`: one map is the only bulk clear, for the case where the family itself left the payload.
collapsed `Charts` `<details>` per card, built and
painted only while open, fed by **one** `/api/gateway/history` request per card per refresh naming A count is printed by `countText()`, which answers `null` for anything that is not a finite number —
that card's own gauges, the legend naming pipes because the card heading already says which config **never `0`** (rule 2) — and the two unknowns stay two sentences: a **trunk** whose socket table went
family it is. Every degradation arm uses the payload's own words — the `why` sentence for a quiet prints `n/a` (`setTxt`'s own word), while a **leaf** whose endpoint the config wrote as a
pre-1.2.0/`--no-proxy` agent or a silent section, the `unreachable` dim for a host that has never **name** prints an em dash (`NO_COUNT`) whose `title` says `connections not attributed — the config
answered, and `#gate-empty` (naming `GATEWAY_HOST` and agent **1.2.0**) for `404 {configured: false}` writes a name and nothing here resolves names`. That is the normal look of 31 of this ingress's 33
— and a failed poll re-draws nothing, so the last topology stays on screen. lines, and a column of `n/a` would be twelve identical words competing with the page's one footnote.
The line itself is hand-rolled SVG (`role="img"` + an `aria-label` naming the inbound port, the
endpoint, the selector and the count) and it carries **two independent class pairs** (phase 30 task
05), because the operator's green and the socket count are two different facts:
* `live` / `still` — "≥ 1 ESTABLISHED socket on this pipe right now" — decides **motion only**. One
`pipe-flow` keyframe whose sole property is `stroke-dashoffset`; the live rule declares no `stroke`,
so a busy line and a resting line are the same colour and motion can never become a verdict.
* `on` / `off` — "a proxy config declares this route" — decides **stroke only**, through the sheet's
own existence pair `--gate-on` / `--gate-off` (declared once in `:root` as a substitution of the
theme colours, so both themes follow and no gateway rule names `--ok`). `routeInConfig()` answers
`true` unconditionally, which is honest rather than a stub: `connections[]` **is** the config's
declaration list, so `off` is the styled seam the write phase will fill and never a second state in
use. The base `.pipe-path` declares no `stroke` at all, which is what makes the state class the only
way a colour can arrive.
So the page is **green because the route exists**, never because a number is good: `severity.py` has
no `gateway.*` row, the payload carries no `severity` at any depth, and
`test_no_gateway_count_is_coloured_by_a_verdict` fails the day a `severity`/`ok|warn|fail` token
appears in the page or the script — the one `warn` in the file is the class `renderWarnings` takes
from `alerts.py`, pinned by count. The **switch** at each line's head (`buildSwitch()`) is the
control from the operator's sketch and it is **built, not wired**: a real `<button type="button"
role="switch">` pill with a knob, keyed on `[aria-checked]` (never `:enabled`, which would paint every
one of them off) and coloured from the same existence pair, carrying a full accessible name
(`endpoint, reached by <selector>, in the nginx config, on`) because a colour cannot say that to a
screen reader. It is `aria-disabled="true"` — **never** the `disabled` attribute, which would take the
reading out of the a11y tree — with a `title` naming the missing write path, no listener (the file's
only `addEventListener` calls are the charts `toggle` and `DOMContentLoaded`) and no `:hover` or
`cursor: pointer` anywhere in the map, because invariant 1 means no browser can disable a route:
`haproxy.cfg` and the nginx `server` block are the only switches on this fleet. The one `transition`
is the knob's, and the reduced-motion block names it beside both pipe states.
The branch is four CSS rules and no JavaScript: each `.gate-link` carries its own upper and lower half
of the spine at the gutter's left edge (`::before` / `::after`, each reaching half a leaf gap past its
row so consecutive halves meet), the first and last rows drop theirs so the spine starts at the first
branch and stops at the last with no mask, no measured height and no background-coloured eraser —
which is also why it stays right on the poll that adds a leaf — and `.gate-group.solo`, set by the
script from the group's own leaf count, takes the vertical away so a one-route port runs **straight
across** (every haproxy frontend here). `.gate-group { column-gap: 0 }` is what makes the trunk's
right edge and the spine the same x, so each tick starts *at* its trunk, and at ≤640px the stacked
trunk is reconnected by bringing the first row's upper half back at `top: calc(var(--gate-row-gap) * -1)`.
The head's **four** tallies are the payload's own figures and nothing is summed twice: `ports` =
`listeners.length`, `connections` = `connections.length`, `live` = `sumLive()` over the listener rows
that publish a `metric` (`null` — never `0` — when none measured, dimmed `stale` with a tooltip naming
the port when some did not), and `warnings` = `warnings.length`. `#gate-warnings` sits **above** the
panels and ships `hidden`: a non-empty `warnings` list raises it, rendered through
`ul.alerts#warnings-list` in **the overview's own row vocabulary** (`li.warn` with a `.src` from
`familyWord(kind)` and a `.msg` carrying `warning.message` verbatim) because "a backend no frontend
routes to" is the same kind of sentence as an alert and must not grow a second look; back to clean it
hides and its rows go with it (both directions pinned, and the live ingress is clean, so the panel is
off). Charts follow `/system` exactly: one collapsed `Charts` `<details>` per panel, **no** window
chips (a fixed 24 h named by the chip beside the toggle), the request
fired **once per visible panel per refresh** naming that panel's own gauges and cached so the first
expand paints from data in hand, while the `LineChart` is built and painted only while the disclosure
is open (`drawCardCharts()` returns at `chartsOpen(key)` — a `display:none` container measures 0 px),
and the legend names pipes because the panel heading already says which config file this is. Every
degradation arm uses the payload's own words — the `why` sentence for a pre-1.2.0/`--no-proxy` agent or
a silent section, the `unreachable` dim for a host that has never answered, and `#gate-empty` (naming
`GATEWAY_HOST` and agent **1.2.0**) for `404 {configured: false}` — and a failed poll re-draws nothing,
so the last topology stays on screen. The footer is **three** paragraphs and no more: why a line is
drawn and what its motion means; why a **name** endpoint carries no number; and that the switch is not
wired. The layout is three load-bearing declarations (`minmax(0, 9.5rem) minmax(0, 1fr)` per group so
a fan-out lines its leaves up instead of re-flowing them to the widest target, `minmax(0, …)` on every
track so a 40-character hostname ellipsises instead of growing the card — phase 27's rule — and one
column with the trunk above its leaves at ≤640 px). Task 04 measured that layout with
`data/logs/30-task04-panels.cjs` (transcript `30-task04-panels.out`, screenshots
`gateway-30-task04-{1440,360,reduced}.png`) and task 06 re-measured it, with the switches and the
pipe states in place, in `data/logs/30-connection-map.cjs` (transcript `30-connection-map.out`,
screenshots `30-connection-map-{desktop,light,warnings,predates,phone}.png` — the phase's own pass:
two panels, straight haproxy rows, one branched nginx `:443`, green in both themes, motion only where
a socket is live, dead switches, injected warnings, both degradation arms, 63 checks, 0 px of sideways
document scroll at 1440 px and 360 px). What it printed on 2026-10-23: 10
listeners / 33 connections, nginx 4 ports · 27 routes (`:443` 18 leaves, `:4443` 6, `:8443` 3, `:80`
the refusal sentence), haproxy 6 groups of one, 31 dashes beside 2 numbered lines, every line keeping
its full text on `title`, and **0 px** of sideways document scroll at both widths with the widest
fan-out and both charts open.
An event row names its subject **once**. Every feed (`/system`, `/disks`, `/llm`, `/ups`) renders the 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 message through `Dash.eventText(event)`, which drops the `UpstreamError: ` wrapper and a
@@ -2921,8 +3116,15 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
(`data/logs/27-task02-mobile-audit.out`, screenshots `mobile-27-*.png`; the first run is kept (`data/logs/27-task02-mobile-audit.out`, screenshots `mobile-27-*.png`; the first run is kept
as `27-task02-first-run.out`). Phase 29's completion pass re-ran the same sweep now that 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 `/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 (`data/logs/29-final-mobile-audit.out`) — and phase 30 task 04 re-ran it once that page became two
this audit was written before, and `data/logs/27-task03-control.cjs` proves the new check is trunk→leaf panels: **370 PASS / 0 FAIL / 0 FINDING**, 10 skips, exit 0, with only its `/gateway`
wait selector moved from the deleted `#t-inbound` tally to `#t-ports` (the clip counts inside the
new rows are `data/logs/30-task04-panels.out`, not this audit's business). Phase 30's completion
pass ran it again after the switches went in — **371 PASS / 0 FAIL / 0 FINDING**, 10 skips, nine
served pages, exit 0 (`data/logs/30-final-mobile-audit.out`) — so neither the two-panels rewrite,
nor the switch pill in every leaf row, nor the pipe classes reintroduced a clipped fact or a
sideways document anywhere on the app. So the two declarations
above still hold on a page this audit was written before, and `data/logs/27-task03-control.cjs` proves the new check is
not vacuous by neutralising both declarations and re-creating the whole first-run findings list 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 (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 pins are `test_a_card_header_wraps_its_facts_instead_of_losing_its_tail` and
@@ -2942,39 +3144,52 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
seed one (`tests/test_deploy_scripts.py` keeps it that way). What remains of phase 24 is seed one (`tests/test_deploy_scripts.py` keeps it that way). What remains of phase 24 is
ordinary rotation hygiene, not a missing step. ordinary rotation hygiene, not a missing step.
13. **`/gateway`'s long pipe labels are ellipsised at a desktop width — a column-width question, 13. **`/gateway`'s long endpoint labels are ellipsised on a phone — a column-width question, not a
not a lost fact** (found and half-fixed by phase 29 task 06's live pass, lost fact** (found by phase 29 task 06's live pass, **closed at the desktop width by phase 30
`data/logs/29-task06-live.out`; the picture is `data/logs/29-task06-card-haproxy.png`). task 04's trunk→leaf row and re-measured on 2026-10-23 by task 06's pass**;
`data/logs/30-connection-map.out` is the current measurement, `data/logs/30-task04-panels.out`,
`data/logs/29-task06-live.out` and `data/logs/29-task06-card-haproxy.png` the history).
What the pass found first: at 1440px the outbound name showed **seven characters** **Phase 30 task 04 closed the desktop half by changing the row, not the font.** The two
(`reeseapps-…`) for a 40-character `server_name → target` string. `.pipe-label` was one `nowrap` equal-width pipe columns of phase 29 became a group of `minmax(0, 9.5rem) minmax(0, 1fr)` — a
flex row, so the name shrank to whatever the `by port` chip, the config's own `server` name and trunk that only ever says `:25566 · 4 live`, and a leaf list that gets everything else — so on the
the `flex: none` count left. Wrapping the *text* inside that box was measured and rejected — it live ingress at 1440 px the target column measures **361 px** (391 px when task 04 measured it,
tore one hostname over eleven lines of a word each. What shipped instead is the label getting before task 05's switch pill took 30 px out of the row) and **0 of 33** endpoint labels and
the line to itself: `flex-wrap: wrap` on `.pipe-label` beside `flex: 1 1 max-content` on **0 of 33** selector lines are ellipsised. What used to be the whole gap is now one line in a
`.pipe-name`, so a short label (`:22`) keeps its count on the same line as before and a long one `FINDING` the script prints rather than an assertion, because it is arithmetic about a viewport
is by itself wider than the column, takes the whole column, and lets the chip, the footnote and and not a bug: the longest target here is 42 characters
the count wrap beneath it. That is ~22 characters instead of 7, the config's own `server` name (`reeseapps-brain-of-code.reeselink.com:443`) and it fits.
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 The arm that remains is the **phone**, and it got narrower before it got better: task 05's
`.pipe-columns` is `minmax(0, 1fr) auto minmax(0, 1fr)` inside one of three cards across a **switch** moved into the leaf row and a group stacks to one column at 360 px, so the same labels
1440px viewport, which is a ~150px column, and `reeseapps-brain-of-code.reeselink.com:443` is 41 now clip at **31 of 33** targets and **22 of 33** selectors in a **139 px** column (this phase's
characters. No fact is lost — `renderPipeRow` puts the whole string on the element as `title` and re-run; 27 / 13 in a 177 px column when task 04 measured it, the difference being the switch
`pipeAria` carries the whole endpoint, which is what pill's 34 px and the tick's 26 px taken off the label). Nothing is lost —
`tests/test_api.py::test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins, and the `renderLeaf()` puts the endpoint *and* the config's own selector on the row as `title` and
two declarations above are pinned beside it so the label cannot be squeezed back to seven `leafAria()` carries the whole endpoint on the line, which is what
characters. In order of cost: (a) give the outbound column more of the card than the inbound one `tests/test_api.py::test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins, alongside
(inbound labels are `:22`-shaped, so `minmax(0, .45fr) auto minmax(0, 1.55fr)` buys ~90px); (b) the declarations that decide *how much* it clips (`min-width: 0` on `.gate-target`,
two cards per row for the fan-out, which is the honest layout for a 17-row map but leaves the `minmax(0, 1fr)` on the leaf track, `max-content` on `.pipe-name`, `flex-wrap` on the trunk) so a
third of the viewport empty; (c) accept it — the stream map's rows are read as a set, the future edit cannot squeeze a label back to the seven characters phase 29 measured. In order of cost:
left-hand SNI name is the part that fits, and the count is the number the row is about. The (a) at ≤640 px let the leaf row put the count on the target's second line instead of its own
≤640px arm already stacks the two columns into one, where the label gets the whole card and column, which buys roughly two more characters per label and is a stylesheet-only change;
**16 of 42** clip instead of 28 (a stream-map string here runs to 70 characters, so even a 312px (b) accept it — the fan-out is read as a set, the part that fits is the selector, and the number
column is not enough for the longest few) — measured in the same pass, which prints both counts the row is about is the count. Whoever picks this up re-runs
and demands a `title` on every clipped one. Whoever picks this up re-runs `data/logs/29-task06-live.cjs`: its `data/logs/30-connection-map.cjs` (task 06's pass, which supersedes
FINDING lines are the two counts, and the run fails if a clipped label ever stops carrying its `data/logs/30-task04-panels.cjs`): its two `FINDING` lines are the counts at both widths, and the
full text. run **fails** if any line ever stops carrying its full text — 33 of 33 rows carried a `title`
naming their whole endpoint at 1440 px and at 360 px as this phase closed, which is the assertion
the gap is actually about.
How it got here, kept because the two rejected fixes are the reason the current shape looks the
way it does: at 1440 px phase 29's outbound name showed **seven characters** (`reeseapps-…`) for a
40-character `server_name → target` string, because `.pipe-label` was one `nowrap` flex row and the
name shrank to whatever the `by port` chip (retired since), the config's own `server` name and the
`flex: none` count left. Wrapping the *text* inside that box was measured and rejected — it tore
one hostname over eleven lines of a word each — so task 06 gave the label the line to itself
(`flex-wrap: wrap` plus `flex: 1 1 max-content`), which reached ~22 characters. Task 04 kept both
declarations and removed the reason they were needed: with the trunk in its own track, the leaf's
name is the widest thing in a column that is now wide enough for it.
## Locked decisions (and why) ## Locked decisions (and why)
+101 -47
View File
@@ -10,7 +10,7 @@ file:
| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page | | `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page |
| `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only | | `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only |
| `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) | | `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) |
| `/gateway` | Which domains are coming in on which port, and where the traffic goes — the ingress's listeners, `server_name`s and SNI map joined to its live connections | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry | | `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured endpoints branching off it, each line labelled with the config's own word for the route (`server_name`, `server`, `map` key) and its live connection count | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
runtime; every asset is served by this app. runtime; every asset is served by this app.
@@ -207,8 +207,10 @@ not know its domain or its backend, so the **gateway page** is what joins these
in `sources/gateway.py`, never in the agent: `/snapshot` keeps the three sections as the agent read in `sources/gateway.py`, never in the agent: `/snapshot` keeps the three sections as the agent read
them, `/api/gateway/snapshot` serves the joined topology, and only the counts that join produces are them, `/api/gateway/snapshot` serves the joined topology, and only the counts that join produces are
stored — as the `gateway.in_conns@<port>` / `gateway.out_conns@<host>:<port>` gauges the page stored — as the `gateway.in_conns@<port>` / `gateway.out_conns@<host>:<port>` gauges the page
charts. Nothing in `/api/system/snapshot` grows a proxy or socket field, and no `sample` row is charts (since phase 30 task 02 the outbound gauge exists **only** for an endpoint the config wrote as
written for a config. an address: the name-keyed ones were a chart of the port-only guess the join no longer makes, so they
would be permanently empty). Nothing in `/api/system/snapshot` grows a proxy or socket field, and no
`sample` row is written for a config.
All three parsers are pinned to captures rather than to descriptions, and All three parsers are pinned to captures rather than to descriptions, and
`./scripts/capture-gateway-config.sh` re-records them over the `GATEWAY_SSH` alias (default `./scripts/capture-gateway-config.sh` re-records them over the `GATEWAY_SSH` alias (default
@@ -806,9 +808,9 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
**no row on purpose**: a non-zero counter is an alarm chip on presence, zero is the absence **no row on purpose**: a non-zero counter is an alarm chip on presence, zero is the absence
of a chip and never a green "healthy drive", and an odometer has no bad value. of a chip and never a green "healthy drive", and an odometer has no bad value.
* `alerts.py` — "what is wrong right now", as data. `alerts.build(settings, job_states, * `alerts.py` — "what is wrong right now", as data. `alerts.build(settings, job_states,
host_payloads, engine_payloads, ups_payload, recent_events)` is a pure projection of the host_payloads, engine_payloads, ups_payload, recent_events, *, gateway_payload=None)` is a pure
state the poller already keeps plus the verdicts `severity.py` and the UPS health checks projection of the state the poller already keeps plus the verdicts `severity.py` and the UPS health
already reached — no I/O, no thresholds of its own — and `/api/summary` returns it as checks already reached — no I/O, no thresholds of its own — and `/api/summary` returns it as
`alerts`, worst first: `{id, severity, source, message, href, at}`. A configured source `alerts`, worst first: `{id, severity, source, message, href, at}`. A configured source
that stopped answering is `fail`; one that is stale but still serving its last good value that stopped answering is `fail`; one that is stale but still serving its last good value
is `warn`; an **unconfigured** source never alerts; a dead source is one line rather than is `warn`; an **unconfigured** source never alerts; a dead source is one line rather than
@@ -819,6 +821,13 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
answers* keeps its own voice and links to the page that explains it (`/disks` for a wedged SMART answers* keeps its own voice and links to the page that explains it (`/disks` for a wedged SMART
pass, `/gateway` for a proxy or socket section that went quiet). Staleness is measured in each pass, `/gateway` for a proxy or socket section that went quiet). Staleness is measured in each
source's own interval, so the gateway page goes amber after 90 quiet seconds and not after 30. source's own interval, so the gateway page goes amber after 90 quiet seconds and not after 30.
The keyword-only `gateway_payload` (phase 30 task 03) is the one non-reachability voice this
module has for that source: a non-empty `warnings` list — the two config files not connecting to
themselves — is **one** `warn` row (`N proxy config problem(s) to address — <first message>`,
`href` `/gateway`), because a dangling backend is a structural fact and not a graded number, and
it is emitted only while the job is configured, `ok` and not stale, so a router that has stopped
answering keeps its single reachability line instead of growing a second one about a reading the
operator cannot refresh. No count of connections can produce a row here at all.
* `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`, * `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`,
`/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per `/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per
dashboard (`/api/system/*`, `/api/disks/*`, `/api/gateway/*`, `/api/llm/*`, `/api/ups/*` — dashboard (`/api/system/*`, `/api/disks/*`, `/api/gateway/*`, `/api/llm/*`, `/api/ups/*` —
@@ -860,25 +869,50 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
was, and the key paths of both are pinned with and without targets configured. Since phase 22 was, and the key paths of both are pinned with and without targets configured. Since phase 22
that block lists the **same hosts** `/api/disks/snapshot` does, and `fleet.hosts_total` is that block lists the **same hosts** `/api/disks/snapshot` does, and `fleet.hosts_total` is
that row count, so `/` and `/disks` can never describe two different fleets. `/api/summary` grew a that row count, so `/` and `/disks` can never describe two different fleets. `/api/summary` grew a
`gateway` block the same way in phase 29 (`configured`, `host`, `supported`, `unreachable`, `gateway` block the same way in phase 29, reshaped by phase 30 task 02 (`configured`, `host`,
`inbound`, `outbound`, `map_targets`, `unmatched`, `unmatched_listeners`, `partial`, `states`): `href`, `supported`, `unreachable`, `listeners`, `connections`, `live_in`, `live_out`,
the ingress's own tallies, `null` and never `0` in every arm that did not measure, `partial: true` `map_targets`, `states`) and given `warnings` by task 03: what the configs declare (`listeners`,
when a total had to leave an unattributable pipe out, and no `severity` anywhere — a connection `connections`, `map_targets`, `warnings`) beside what the sockets are doing (`live_in`, `live_out`
count is not a graded number. `/api/config` carries the matching row, including — the latter summed over **distinct endpoints**, since several routes reaching one address is one
count and not two). `null` and never `0` in every arm that did not measure — **except**
`warnings`, which is a config fact and so reads a real `0` when the two files connect to
themselves. No `severity` anywhere: a connection count is not a graded number, and a structural
fault needs no threshold because the hardware of a config file either names a thing that exists or
it does not. The `unmatched` / `unmatched_listeners` / `partial` fields went out with the
guessing rule they existed to explain. `/api/config` carries the matching row, including
`gateway.interval_seconds`/`timeout_seconds` and the named target's redacted `url`. `gateway.interval_seconds`/`timeout_seconds` and the named target's redacted `url`.
`/api/gateway/*` (phase 29 task 03) is that page's pair. `/snapshot` is one host's whole `/api/gateway/*` (phase 29 task 03) is that page's pair. `/snapshot` is one host's whole
topology — the haproxy frontends and the nginx `listen`/`map` echo the pipes were built from, topology — the haproxy frontends and the nginx `listen`/`map` echo the pipes were built from,
the inbound pipes (port, its domains, its live connection count, up to 32 client addresses), the the `listeners` (port, its domains, its live connection count, up to 32 client addresses), the
outbound pipes (target `host:port`, `by_port` naming which half of the match is a socket fact, `connections` graph (phase 30: one row per route the two config files declare, each naming its
the count, the config that named it) each carrying the `metric` it is stored under, the inbound port, its outbound endpoint, and the config's own selector for it — a haproxy backend
`unmatched` tallies, and `supported`/`sections`/`why` for the degradation arms — and the counts name, an nginx `server_name`, a stream `map` key) with its **live count** — the socket table's
reconcile: inbound + outbound + unmatched == `sockets.established`, so the page's numbers are ESTABLISHED rows, inbound by the socket's local port and outbound only where a remote
provable against a direct `/proc/net/tcp` read. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is `(address, port)` equals a target the config wrote as an address. Each row carries the `metric` it
is stored under, and `shared: true` when more than one route reaches its endpoint (one count, shown
twice, never split). `supported`/`sections`/`why` cover the degradation arms. `warnings` (phase 30
task 03) is the list of the two config files **not connecting to themselves** — seven kinds, each
one row of `{id, kind, subject, message}` where `id` is `"<kind>:<subject>"` and the message is the
sentence the page prints: `haproxy_route_dangling`, `haproxy_backend_orphan`,
`haproxy_backend_empty`, `nginx_server_unlistened`, `nginx_pool_undeclared`,
`nginx_variable_unmapped`, `nginx_map_route_missing`. Nothing is inferred to fill a gap, so three
shapes are deliberately **silent**: a vhost that listens and passes nothing (a `return 301` looks
identical here), a frontend that routes by a fetch expression (agent 1.2.0 reports "no name", which
is the same value "no routing directive" has), and an entry of a `map` no `proxy_pass` names (the
`$connection_upgrade` map's `upgrade`/`close` are header words, not missing routes). An unreadable
or absent section warns about nothing — a host that cannot be read has not been found guilty of
anything — and a `warnings: []` is therefore the truth, not a shrug. Non-zero, `/` gets **one**
amber row (`N proxy config problems to address — <first message>`) and this page gets the list. **Nothing reconciles
to `sockets.established` and that is the design**: a connection to a target the config wrote as a
name (most of this fleet's fan-out) or on a port no config names is ignored — not counted, not
totalled, not warned about — so a pipe count means "this route has N live connections" and nothing
more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
a `404 {"configured": false}` answered before any client is built (both arms spy-pinned), a host a `404 {"configured": false}` answered before any client is built (both arms spy-pinned), a host
that has never answered is the `unreachable` stub whose every count is `null`, and a 1.1.0 agent that has never answered is the `unreachable` stub whose every count is `null`, and a 1.1.0 agent
is `supported: false` plus its one `why` sentence — three arms, no red among them (invariant 3). is `supported: false` plus its one `why` sentence — three arms, no red among them (invariant 3).
`/history` serves the `gateway.in_conns@<port>` and `gateway.out_conns@<host>:<port>` gauges — `/history` serves the `gateway.in_conns@<port>` and `gateway.out_conns@<host>:<port>` gauges —
the port in the outbound name because two services on one host are two facts — with `hosts=` the port in the outbound name because two services on one host are two facts, and only ever for an
address endpoint — with `hosts=`
defaulting to the ingress alone, unknown names answering as empty series, and no dial either way defaulting to the ingress alone, unknown names answering as empty series, and no dial either way
because history reads only SQLite. because history reads only SQLite.
* `web/` — six static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/gateway`) plus * `web/` — six static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/gateway`) plus
@@ -927,9 +961,13 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
list reads as an explicit "all clear", and a live problem moves the panel above the list reads as an explicit "all clear", and a live problem moves the panel above the
table. A host that is not answering keeps its row — dimmed, every number `n/a` — so the table. A host that is not answering keeps its row — dimmed, every number `n/a` — so the
fleet never quietly gets smaller and last good values are never mistaken for live ones. fleet never quietly gets smaller and last good values are never mistaken for live ones.
Under that table is the ingress's **one line** (`gateway · 14 inbound · 4 outbound`, linked to Under that table is the ingress's **one line** (`gateway · 10 listeners · 33 connections · 17 live
`/gateway`): the counts are `/api/summary`'s own sums, a count the source did not measure prints in · 2 live out` as the live router answered on 2026-10-23 — a reading, the same way a listener count
`n/a` rather than `0`, and a deployment with no `GATEWAY_HOST` renders no line at all. is, and only the field names behind it are a contract), linked to `/gateway`: every figure is `/api/summary`'s own, a count the source
did not measure prints `n/a` rather than `0`, and a deployment with no `GATEWAY_HOST` renders no
line at all. A non-zero `warnings` appends ` · N to address` and nothing else — `0` and `null` both
print nothing, so a clean ingress never grows a permanent sentence about the checks that did not
fire (phase 30 task 03).
A big colour-coded number is `Dash.hero(el, {...})` on the A big colour-coded number is `Dash.hero(el, {...})` on the
`.hero` / `.hero-bar` styles, coloured by the `severity` in the payload via `.hero` / `.hero-bar` styles, coloured by the `severity` in the payload via
`Dash.severityFrom()` — the only colour vocabulary is `ok` / `warn` / `fail`, and a value `Dash.severityFrom()` — the only colour vocabulary is `ok` / `warn` / `fail`, and a value
@@ -1036,26 +1074,38 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
`No test initiated` or no variable at all — says the same "run a battery test" sentence as `No test initiated` or no variable at all — says the same "run a battery test" sentence as
`unknown`. Only a word the map has never seen stays a warning, printed verbatim so it can `unknown`. Only a word the map has never seen stays a warning, printed verbatim so it can
be added. be added.
`/gateway` answers the topology question `/system` cannot: which sockets on the ingress face `/gateway` answers the topology question `/system` cannot: which ports on the ingress are open,
inbound, where each one's traffic goes, and how many connections are live on each. It is four what routes out of each, and how many connections are live on each. It is **two panels** — one per
cards keyed on what the **config** said a pipe came from — `haproxy`, `nginx`, the `stream map`, config file, `nginx` then `haproxy` — each hidden until a poll actually has rows for it, so a router
and `unmatched` — each hidden until a poll actually has pipes for it, so a router with no nginx with no nginx in it spends no panel saying so. Inside a panel the rows are **groups**: a *trunk* is
in it spends no panel saying so. An inbound row is a listen port with its `server_name`s and the one port that file binds, and hanging off it are the *leaves* — the endpoints it names, the endpoint
client addresses on it; an outbound row is the target **exactly as the config wrote it** (the SNI **exactly as the config wrote it** with the config's own word for the route above it (a `server_name`,
name the stream map matched on in front of it when one key routes there), and a target the join a haproxy `server` name, a stream `map` key). That is the shape of the file, which is why the stream
could only match on its socket **port** carries a `by port` chip, because that count belongs to `map` is not a third panel: its entries are the nginx panel's `:443` leaves, so every SNI domain
the port and not to that service. A pipe animates while it carries at least one live connection reads beside the port it reaches. A trunk that names no target — this fleet's `:80` `return 301` —
and rests at zero, and **nothing on the page is coloured by a verdict** — a busy ingress is the prints "no target named in the config" instead of drawing nothing. Each line is joined to its
job, so `severity.py` rates no `gateway.*` metric and no number gets a green/amber/red tint. A counterparts: nginx's ports **branch** — a dashed spine down the fan-out with a tick into every
count the join did not decide prints `n/a` and never `0`, which is why the fan-out on a port many endpoint — and haproxy's one-`server` frontends run **straight across**, because a port with one
domains share is `n/a` rather than one number repeated as if it were sixteen. The header's five route behind it is not a branch. A line is **green because the config declares the route**, an
tallies print the reconciliation instead of asserting it: what the kernel's socket table reported existence colour of the page's own, and it **animates only while it carries at least one live
(`listeners`, `established`) beside what the pipes carry (`pipes in`, `pipes out`) and what no connection** — motion is the socket, colour is the config, and a resting route keeps the same green
config claims (`unmatched` — ports and connections kept as separate cells). Each card's trend is as a busy one. **Nothing on the page is coloured by a verdict** — a busy ingress is the job, so
`severity.py` rates no `gateway.*` metric and no number gets a green/amber/red tint. A line to an endpoint the config wrote as a **name** prints `—`, never `0`:
counting it would need a DNS lookup and the join never asks one, and the row's tooltip says which of
the two facts it is looking at. The header's four tallies are `ports`, `connections`, `live` and
`warnings`, every one of them the payload's own figure. A `warnings` count above zero raises a panel
above the map with the config-integrity rows (`a backend no frontend routes to`, a `proxy_pass` with
no `upstream` behind it, an SNI entry that reaches no address) in the overview's alert vocabulary;
the live ingress here is clean, so that panel is off. Each panel's trend is
behind a collapsed **Charts** toggle like `/system`'s, one `/api/gateway/history` request per behind a collapsed **Charts** toggle like `/system`'s, one `/api/gateway/history` request per
card per refresh. With no `GATEWAY_HOST` the page is the setup sentence rather than a red panel, panel per refresh. With no `GATEWAY_HOST` the page is the setup sentence rather than a red panel,
and an ingress running an agent older than **1.2.0** says so in the agent's own words with every and an ingress running an agent older than **1.2.0** says so in the agent's own words with every
count `n/a` and nothing coloured. count `n/a` and nothing coloured. Each line's head carries the sketched **switch** — a real
`role="switch"` button, green and to the position because the route is in the config, named in full
for a screen reader (`endpoint, reached by <selector>, in the nginx config, on`) — and it is
**unwired on purpose**: `aria-disabled` with a tooltip that says so, and this app writes to no
upstream, so a route is disabled by editing `haproxy.cfg` or an nginx `server` block and by nothing
in a browser.
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event` The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath. page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
@@ -1237,14 +1287,18 @@ the page never reads it.
from both ends. The input is the **captured** gateway — `gateway_haproxy.cfg`, the nginx main config from both ends. The input is the **captured** gateway — `gateway_haproxy.cfg`, the nginx main config
plus its included documents, and the two `/proc/net/tcp{,6}` captures — run through the real plus its included documents, and the two `/proc/net/tcp{,6}` captures — run through the real
`host_agent` parsers, so the test compares the join against the agent's own document rather than `host_agent` parsers, so the test compares the join against the agent's own document rather than
against a copy of its field names. The assertions are the join's: the pipes carry exactly the ports, against a copy of its field names. The assertions are the join's two rules and the limits around
`server_name`s and targets those documents name; `inbound + outbound + unmatched` equals the socket them: the graph carries exactly the ports, `server_name`s and targets those documents declare (all
table's own ESTABLISHED count, so no connection is counted twice or dropped; a listening port no 32 routes on this capture, as a **set**, so a missing or invented route fails by name); a socket is
config claims stays visible as `unmatched.other`; several names sharing one port produce `null` inbound by its local port and outbound only when its remote `(address, port)` equals a target the
counts and a `unmatched.groups` line rather than one number repeated on sixteen pipes; and **no join config wrote as an address; **every other socket is ignored** — `established`, `live_in` and
ever asks a resolver** — `socket.getaddrinfo` is spied on and fails the test, because deciding `live_out` are all printed and deliberately never reconciled, because a connection to a name, or on
"already an address, or a name?" is the only question that module may ask, and a DNS lookup in the a port nobody configured, is not a fact this page may claim; several routes reaching one endpoint
poll loop would be rule 2 with a round trip attached. carry one count repeated with `shared: true` rather than a split nobody measured (so the totals, in
the source and on `/2`, sum **distinct endpoints**); a name target is never credited, and `0` never
stands for "not counted"; and **no join ever asks a resolver** — `socket.getaddrinfo` is spied on and
fails the test, because deciding "already an address, or a name?" is the only question that module
may ask, and a DNS lookup in the poll loop would be rule 2 with a round trip attached.
`tests/rate_window_contract.py` is the same device for one number rather than a field table: the Δ `tests/rate_window_contract.py` is the same device for one number rather than a field table: the Δ
window each source measures its rates over — `RATE_WINDOW_SECONDS` for the host agents, window each source measures its rates over — `RATE_WINDOW_SECONDS` for the host agents,
+61 -7
View File
@@ -28,12 +28,17 @@ The rules, and the reason each exists:
threshold breaches would pile three more amber lines on top of the red one that already threshold breaches would pile three more amber lines on top of the red one that already
names it. Payload-derived alerts are skipped for a source that is not answering. A job that names it. Payload-derived alerts are skipped for a source that is not answering. A job that
dials an agent another job already named (`AGENT_ECHO_DASHBOARDS`) adds no line either. dials an agent another job already named (`AGENT_ECHO_DASHBOARDS`) adds no line either.
* **No verdict of its own, ever.** Nothing here compares a number to a threshold: the host and * **No graded number of its own, ever.** Nothing here compares a number to a threshold: the host
LLM rules read `severity` dicts and the UPS rule reads `health.checks[]`. That is why a source and LLM rules read `severity` dicts and the UPS rule reads `health.checks[]`. A connection count
whose payload carries no verdicts can only ever alert on reachability — the `gateway` page's is therefore silent here whatever its value — `severity.py` rates none (the `net.*` precedent)
connection counts are such a source (`severity.py` rates none, the `net.*` precedent), and a and 400 connections on an ingress is the job, not a fault.
busy proxy is the job rather than a fault. There is no `gateway` payload rule and none is * **A `gateway` alert is reachability plus config integrity, and nothing else** (phase 30 task 03).
wanted: adding one would put a threshold in this module, which the rule above forbids. The one structural fact the gateway payload carries is its `warnings` list — routes its two
config files declare that do not connect to themselves — and that is a fact, not a graded number:
no threshold, no `severity.py` row, one `warn` row naming how many there are and quoting the
first. It is emitted **only while the job is answering**, so a source that is failing or stale
keeps its single reachability line (the rule above) and never grows a second one about the last
good payload.
* **Bounded and deduped.** One alert per (source, metric) — the `id` is that pair — and at * **Bounded and deduped.** One alert per (source, metric) — the `id` is that pair — and at
most :data:`MAX_ALERTS` of them, the last being a marker that says how many it hid. A most :data:`MAX_ALERTS` of them, the last being a marker that says how many it hid. A
40-disk fleet must not turn the landing page into a log tail. 40-disk fleet must not turn the landing page into a log tail.
@@ -433,6 +438,50 @@ def _ups_alerts(sink: _Sink, payload: Mapping[str, Any],
f"{label}: {detail}" if detail else label, HREF["ups"], at) f"{label}: {detail}" if detail else label, HREF["ups"], at)
def _gateway_alerts(sink: _Sink, settings: Settings, states: Sequence[Mapping[str, Any]],
payload: Mapping[str, Any] | None) -> None:
"""One `warn` row for a proxy config that does not connect to itself.
The row is deliberately *aggregate*: seven dangling routes are one thing to fix (the config),
and seven amber lines on `/` would push every other source off the panel to say it. The count
and the first message travel together so the operator sees the size of it and a sentence they
can act on, and `/gateway` — which the row links to — is where the whole list is drawn.
Three arms emit nothing, and each is a rule from elsewhere rather than a hedge:
* **no payload** — the source is unconfigured or has never answered (invariant 3: a thing
nobody installed has no config to be broken, and a thing that never replied has not been
read);
* **the job is failing or stale** — the reachability row `_source_alerts` already wrote is
this source's only line, and re-listing a stale payload's warnings beside it would be the
"dead source, one alert" rule breaking; an unknown state (no record at all) counts as
answering, the same rule `_answering` keeps;
* **no warnings** — a clean ingress is silence, not a green line.
Nothing here reads a threshold: `warnings` is a list of structural facts the config text
yielded, and `severity.py` has no `gateway.*` row to consult even if this module wanted to.
"""
if payload is None:
return
state = next((row for row in states if row.get("dashboard") == "gateway"), None)
if state is not None:
if not bool(state.get("configured")) or not bool(state.get("ok")):
return # unconfigured (invariant 3) or failing: its own row, or none, says it already
age = state.get("age_seconds")
interval = _interval(settings, "gateway")
if isinstance(age, (int, float)) and age > interval * STALE_FACTOR:
return # stale: the amber "last answered Ns ago" row is this source's only line
rows = [row for row in (payload.get("warnings") or []) if isinstance(row, Mapping)]
if not rows:
return
first = rows[0].get("message") or "see /gateway for the list"
noun = "problem" if len(rows) == 1 else "problems"
at = payload.get("ts") if isinstance(payload.get("ts"), (int, float)) else None
sink.add(WARN, "gateway", "config",
f"{len(rows)} proxy config {noun} to address — {first}",
HREF["gateway"], at)
def _config_alerts(sink: _Sink, settings: Settings) -> None: def _config_alerts(sink: _Sink, settings: Settings) -> None:
for problem in settings.host_target_problems: for problem in settings.host_target_problems:
# a typo'd entry is not a dead host: the operator needs to know it is not being # a typo'd entry is not a dead host: the operator needs to know it is not being
@@ -449,6 +498,7 @@ def build(settings: Settings, hub_states: Sequence[Mapping[str, Any]] = (),
engine_payloads: Sequence[Mapping[str, Any]] = (), engine_payloads: Sequence[Mapping[str, Any]] = (),
ups_payload: Mapping[str, Any] | None = None, ups_payload: Mapping[str, Any] | None = None,
recent_events: Sequence[Mapping[str, Any]] = (), *, recent_events: Sequence[Mapping[str, Any]] = (), *,
gateway_payload: Mapping[str, Any] | None = None,
now: float | None = None) -> list[dict[str, Any]]: now: float | None = None) -> list[dict[str, Any]]:
"""Everything that is wrong right now, worst first. """Everything that is wrong right now, worst first.
@@ -460,7 +510,10 @@ def build(settings: Settings, hub_states: Sequence[Mapping[str, Any]] = (),
Inputs are the same objects `/api/summary` already assembles (job states, last good Inputs are the same objects `/api/summary` already assembles (job states, last good
payloads, the UPS payload, the short event list), so answering costs nothing extra and payloads, the UPS payload, the short event list), so answering costs nothing extra and
the answer cannot disagree with the panels underneath it. the answer cannot disagree with the panels underneath it. `gateway_payload` is keyword-only
and defaulted for that reason: every caller written before phase 30 keeps working, and the
gateway's contribution is the one non-reachability rule this module has for a source whose
numbers it must never grade (see the rule list above).
""" """
sink = _Sink(time.time() if now is None else now) sink = _Sink(time.time() if now is None else now)
_source_alerts(sink, settings, hub_states, recent_events) _source_alerts(sink, settings, hub_states, recent_events)
@@ -470,5 +523,6 @@ def build(settings: Settings, hub_states: Sequence[Mapping[str, Any]] = (),
_engine_alerts(sink, engine, hub_states) _engine_alerts(sink, engine, hub_states)
if ups_payload is not None: if ups_payload is not None:
_ups_alerts(sink, ups_payload, hub_states) _ups_alerts(sink, ups_payload, hub_states)
_gateway_alerts(sink, settings, hub_states, gateway_payload)
_config_alerts(sink, settings) _config_alerts(sink, settings)
return sink.result() return sink.result()
+104 -48
View File
@@ -17,7 +17,7 @@ import logging
import math import math
import re import re
import time import time
from collections.abc import Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from typing import Any from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
@@ -210,43 +210,81 @@ def _gateway_url(settings: Settings) -> str | None:
if target.name == settings.gateway_host), None) if target.name == settings.gateway_host), None)
def _pipe_totals(pipes: Any, counting: bool) -> tuple[int | None, bool]: def _mappings(value: Any) -> list[Mapping[str, Any]]:
"""Σ of a pipe list's connection counts, and whether any pipe's count was unknown. """The mappings inside a payload list, ignoring anything that is not one.
`counting` is the payload's own answer to "did the kernel's socket table reply". When it did `/api/summary` reads a payload this module does not own, and a drift inside one row must not
not, every count is `null` and so is the total, with nothing left to call partial: a `0` there turn the overview's one line into a 500: a non-mapping contributes nothing to a count.
would print an idle gateway over a source that never measured one (`AGENTS.md` rule 2). When it """
did, the sum covers the pipes that carry a number and `partial` says at least one other did not if not isinstance(value, Sequence) or isinstance(value, str | bytes):
(a port whose configured targets cannot be told apart, `sources/gateway.py`) — so a total is return []
never quietly a subtotal, and `partial` only ever qualifies a total that exists. return [row for row in value if isinstance(row, Mapping)]
def _live_total(values: Iterable[Any]) -> int | None:
"""Σ of the numbers in `values`, or `None` when there is not one among them.
`None` is a sentence and not a number: a group with nothing in it, or one in which every count
is `null` (a socket table that never replied, or outbound targets the config wrote entirely as
**names**, which the join can never count), has measured nothing — and a `0` there would print
an idle gateway over a source that never made a measurement (`AGENTS.md` rule 2). A `null` in
a group that does carry numbers contributes nothing rather than a fake zero.
""" """
rows = [pipe for pipe in (pipes or []) if isinstance(pipe, Mapping)]
if not counting:
return None, False
values = [pipe.get("connections") for pipe in rows]
known = [value for value in values known = [value for value in values
if isinstance(value, int) and not isinstance(value, bool)] if isinstance(value, int) and not isinstance(value, bool)]
return sum(known), len(known) != len(values) return sum(known) if known else None
def _endpoint_values(connections: Any) -> list[Any]:
"""One connection count per **distinct** outbound endpoint, in first-seen order.
Several lines can reach one address (`:443 → 10.1.1.9:443` and `:8443 → 10.1.1.9:443`), and
`sources/gateway.py` repeats that endpoint's whole count on each of them with `shared: true`
rather than splitting a number nobody measured. Summing the **lines** would book the same
sockets twice and print a router busier than it is, so the total is taken over the endpoints
the lines name — the same reason the outbound gauges are keyed by `(address, port)`.
"""
values: dict[tuple[Any, Any], Any] = {}
for row in connections if isinstance(connections, Sequence) else ():
if not isinstance(row, Mapping):
continue
out = row.get("out")
if not isinstance(out, Mapping):
continue
values.setdefault((out.get("host"), out.get("port")), row.get("connections"))
return list(values.values())
def _gateway_summary(payload: Mapping[str, Any] | None, settings: Settings) -> dict[str, Any]: def _gateway_summary(payload: Mapping[str, Any] | None, settings: Settings) -> dict[str, Any]:
"""`/api/summary`'s gateway block: the ingress's totals, or a stub of unknowns. """`/api/summary`'s gateway block: the ingress's totals, or a stub of unknowns.
Additive in phase 29. The page charts from `/api/gateway/snapshot`, which has one entry per Additive in phase 29, reshaped by phase 30 task 02 into eleven fields, with `warnings` joined
pipe; this block is the **tally** a programmatic consumer (and the overview's one line, task by task 03 as the twelfth. The page charts
05) reads without walking the pipes. Three arms, and they are three different sentences: from `/api/gateway/snapshot`, which has the rows themselves; this block is the **tally** a
programmatic consumer (and the overview's one line) reads without walking them. Three arms, and
they are three different sentences:
* **unconfigured** (`GATEWAY_HOST` is not a `HOST_TARGETS` name) — `configured: false`, every * **unconfigured** (`GATEWAY_HOST` is not a `HOST_TARGETS` name) — `configured: false`, every
count `null`, `states: []`. There is no job, so there is nothing to age (invariant 3). count `null`, `states: []`. There is no job, so there is nothing to age (invariant 3).
* **configured, never answered** — the stub `system.hosts` and `/api/disks` use: the source is * **configured, never answered** — the stub `system.hosts` and `/api/disks` use: the source is
in the deployment, it has no numbers yet, and `unreachable: true` says which of the two facts in the deployment, it has no numbers yet, and `unreachable: true` says which of the two facts
it is. `host` still names the machine, because that is a config fact and not a measurement. it is. `host` still names the machine, because that is a config fact and not a measurement.
* **answering** — the payload's own tallies, summed without inventing anything: `inbound` / * **answering** — the payload's own figures, summed without inventing anything.
`outbound` are the sums of the pipes that have a count, `partial` says one pipe did not, and `listeners` / `connections` are **row counts** (what the two config files declare, a fact
`map_targets` counts the stream `map`'s routing entries. that does not need a socket), and `live_in` / `live_out` are the connection counts that were
actually measured — `null` when nothing was, never `0`. `map_targets` stays the stream map's
routing-entry count, and `warnings` is the **count** of config-integrity problems
`sources/gateway.warnings()` found — a config fact like the row counts, so `0` there is the
truth ("this ingress's configs connect to themselves") and not a missing measurement. The
list itself is on `/api/gateway/snapshot`; this block is the tally, and `alerts.py` is what
turns a non-empty one into the overview row.
`unmatched`, `unmatched_listeners` and `partial` left this block with the socket join they
described: a connection outside the two config files is no longer reported at all, so there is
no residual to total and no total to call partial.
Nothing here carries a `severity`: a connection count is not a graded number (`severity.py` Nothing here carries a `severity`: a connection count is not a graded number (`severity.py`
rates none, the `net.*` precedent), and an `unmatched` socket is a report and not a fault. rates none, the `net.*` precedent).
`href` is the row's link, from `alerts.py`'s one table of dashboard routes, so the overview's `href` is the row's link, from `alerts.py`'s one table of dashboard routes, so the overview's
line and an alert about the same source cannot spell the page differently (the rule line and an alert about the same source cannot spell the page differently (the rule
@@ -260,15 +298,15 @@ def _gateway_summary(payload: Mapping[str, Any] | None, settings: Settings) -> d
"href": alert_rules.HREF[GATEWAY_DASHBOARD], "href": alert_rules.HREF[GATEWAY_DASHBOARD],
"supported": None, "supported": None,
"unreachable": True, "unreachable": True,
"inbound": None, # row counts (config facts) and live sums (socket facts), kept apart because a gateway can
"outbound": None, # have thirty declared routes and no measurement of any of them
"listeners": None,
"connections": None,
"live_in": None,
"live_out": None,
"map_targets": None, "map_targets": None,
"unmatched": None, # the config-integrity tally; `null` until the agent answers, exactly like the row counts
# `unmatched` is connections no configured pipe claimed; `unmatched_listeners` is a "warnings": None,
# *listener* count (a port this host serves that neither config names). Two units, so two
# fields: adding them into one number would be an aggregate of unlike things.
"unmatched_listeners": None,
"partial": False,
} }
if payload is None: if payload is None:
return block return block
@@ -279,18 +317,21 @@ def _gateway_summary(payload: Mapping[str, Any] | None, settings: Settings) -> d
# A 1.1.0 agent or `--no-proxy`: nothing was read, so "0 map targets" would be a claim # A 1.1.0 agent or `--no-proxy`: nothing was read, so "0 map targets" would be a claim
# about a config nobody looked at. `supported: false` plus nulls is the honest pair. # about a config nobody looked at. `supported: false` plus nulls is the honest pair.
return block return block
sockets = payload.get("sockets") or {} listeners = _mappings(payload.get("listeners"))
counting = (isinstance(sockets.get("established"), int) connections = _mappings(payload.get("connections"))
and not isinstance(sockets.get("established"), bool)) block["map_targets"] = len(_mappings((payload.get("nginx") or {}).get("map_targets")))
block["map_targets"] = len((payload.get("nginx") or {}).get("map_targets") or []) # A count, not the list: `/` needs to know whether there is anything to address and the page
inbound, partial_in = _pipe_totals(payload.get("inbound"), counting) # that lists them reads `/api/gateway/snapshot`. A payload whose `warnings` is missing or
outbound, partial_out = _pipe_totals(payload.get("outbound"), counting) # malformed counts as zero rather than raising — this module does not own the projection, and a
block["inbound"], block["outbound"] = inbound, outbound # drift inside one row must not 500 the overview (see `_mappings`).
block["partial"] = partial_in or partial_out block["warnings"] = len(_mappings(payload.get("warnings")))
unmatched = payload.get("unmatched") or {} # Row counts come from the config and are therefore known whenever the agent answered at all —
block["unmatched"] = (int(unmatched.get("inbound") or 0) + int(unmatched.get("outbound") or 0) # `0` here is the truth ("this host declares no listener"), not a missing measurement, so it is
if counting else None) # the *live* pair below that carries the nulls.
block["unmatched_listeners"] = unmatched.get("other") if counting else None block["listeners"] = len(listeners)
block["connections"] = len(connections)
block["live_in"] = _live_total(row.get("connections") for row in listeners)
block["live_out"] = _live_total(_endpoint_values(connections))
return block return block
@@ -780,8 +821,13 @@ def common_router() -> APIRouter:
recent_events = [event.as_json() for event in ctx.store.recent_events(12)] recent_events = [event.as_json() for event in ctx.store.recent_events(12)]
# "what is wrong right now" is a server-side answer (see alerts.py) so the overview, # "what is wrong right now" is a server-side answer (see alerts.py) so the overview,
# a mobile view, and a notifier later all agree on the same verdicts # a mobile view, and a notifier later all agree on the same verdicts
# `gateway_payload` is keyword-only and travels because a proxy config that does not
# connect to itself is the one non-reachability fact this source may alert on (phase 30
# task 03): `alerts.py` reads its `warnings` list and no count in it, so a busy ingress
# still says nothing and a dangling `use_backend` says one thing, once.
summary_alerts = alert_rules.build(ctx.settings, ctx.hub.states(), hosts, engines, summary_alerts = alert_rules.build(ctx.settings, ctx.hub.states(), hosts, engines,
ups_payload, recent_events) ups_payload, recent_events,
gateway_payload=gateway_payload)
return { return {
"ts": time.time(), "ts": time.time(),
"system": {"enabled": bool(ctx.settings.host_targets), "system": {"enabled": bool(ctx.settings.host_targets),
@@ -1038,20 +1084,30 @@ def gateway_router() -> APIRouter:
@router.get("/snapshot") @router.get("/snapshot")
async def snapshot(ctx: AppContext = Depends(get_context)) -> dict[str, Any]: async def snapshot(ctx: AppContext = Depends(get_context)) -> dict[str, Any]:
"""The one ingress host's pipes: its configs, its live counts, and what nothing claimed. """The one ingress host's connection map: its configs, and what is live on each route.
The payload is `sources/gateway.parse`'s, verbatim and unread by this module: `haproxy`/ The payload is `sources/gateway.parse`'s, verbatim and unread by this module: `haproxy`/
`nginx` are the config the agent read, `inbound` one entry per configured listen port (its `nginx` are the config the agent read, `listeners` one entry per configured listen port
domains, its live connection count, up to `MAX_CLIENTS` client addresses), `outbound` one (its domains, its live connection count, up to `MAX_CLIENTS` client addresses),
entry per configured target (with `by_port` naming which half of the match is a socket `connections` one entry per route the two configs declare (its inbound port, its outbound
fact and which is the config's), `unmatched` the connections and listeners no config endpoint, the config's own selector for it, and a live count **only** where the endpoint is
names, and each pipe's own `metric` so a chart asks for the series it *is*. Three arms written as an address — a name keeps `null`, because this stack resolves nothing), each row
carrying its own `metric` so a chart asks for the series it *is*. There is no outbound pipe
list and no `unmatched` tally since phase 30 task 02: the graph carries the outbound side,
and a socket neither config file describes is ignored rather than reported. Three arms
reach the page and they are three different sentences: `404 {"configured": false}` when reach the page and they are three different sentences: `404 {"configured": false}` when
`GATEWAY_HOST` names no target (answered below, before anything that could open a `GATEWAY_HOST` names no target (answered below, before anything that could open a
socket — invariant 3), `supported: false` for an agent that predates the sections, and socket — invariant 3), `supported: false` for an agent that predates the sections, and
`unreachable: true` for a host that has never answered — the stub, whose every count is `unreachable: true` for a host that has never answered — the stub, whose every count is
`null` because an agent that has not spoken has not reported an idle gateway (rule 2). `null` because an agent that has not spoken has not reported an idle gateway (rule 2).
`warnings` is the config-integrity list (`{"id", "kind", "subject", "message"}` per row):
routes the two files declare that do not connect to themselves — a `use_backend` naming no
`backend`, a `backend` nothing routes to, a `proxy_pass $var` with no `map`. It is a
structural fact read out of config text, never a graded number, and it is empty for a host
whose configs did not answer: an ingress that cannot be read has not been found guilty of
anything (invariant 3).
Nothing here carries a `severity` and nothing carries a `window_seconds`: a connection Nothing here carries a `severity` and nothing carries a `window_seconds`: a connection
count is neither a graded number nor a measurement over a window, so `/gateway` colours count is neither a graded number nor a measurement over a window, so `/gateway` colours
nothing and prints no `Δ` (the `net.*` precedent). nothing and prints no `Δ` (the `net.*` precedent).
File diff suppressed because it is too large Load Diff
+56 -97
View File
@@ -36,31 +36,32 @@
<div class="sub" id="sub">reading the ingress…</div> <div class="sub" id="sub">reading the ingress…</div>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<!-- The socket table's own totals beside the totals the pipes carry, so the page prints its <!-- Four numbers, in the order the page is read: what the two configs **declare** (`ports`,
reconciliation instead of asserting it: `listeners` and `established` are what the `connections`), what the kernel says is **open right now** on those ports (`live`), and
kernel said, and the two rows under the cards add up to them only when every connection what in the configs does not connect to itself (`warnings`). The first two are config
belonged to a configured pipe. No cell is coloured — a connection count is not a graded facts and the third is a socket fact, which is why they are three cells and never one
number (`net.*`'s rule, and `severity.py` has no `gateway.*` row). --> total; `warnings` is `0` when the files route to themselves, because a clean ingress has
been read and found clean. The kernel's own table size is not here: a socket neither file
describes is nobody's pipe (phase 30 task 02), so printing it would invite the eye to
reconcile two numbers that were never meant to add up. No cell is coloured — a connection
count is not a graded number (`net.*`'s rule, and `severity.py` has no `gateway.*` row).
-->
<div class="gate-tallies" id="tallies"> <div class="gate-tallies" id="tallies">
<div class="metric"> <div class="metric">
<div class="label">listeners</div> <div class="label">ports</div>
<div class="value sm" id="t-listeners">n/a</div> <div class="value sm" id="t-ports">n/a</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="label">established</div> <div class="label">connections</div>
<div class="value sm" id="t-established">n/a</div> <div class="value sm" id="t-connections">n/a</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="label">pipes in</div> <div class="label">live</div>
<div class="value sm" id="t-inbound">n/a</div> <div class="value sm" id="t-live">n/a</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="label">pipes out</div> <div class="label">warnings</div>
<div class="value sm" id="t-outbound">n/a</div> <div class="value sm" id="t-warnings">n/a</div>
</div>
<div class="metric">
<div class="label">unmatched</div>
<div class="value sm" id="t-unmatched">n/a</div>
</div> </div>
</div> </div>
</div> </div>
@@ -83,41 +84,29 @@
the version that reads the proxy configs and the kernel's socket table.</p> the version that reads the proxy configs and the kernel's socket table.</p>
</div> </div>
<!-- One card per source family the payload names, `data-gate` keyed on a pipe's own `source` <!-- The config-integrity list, in the overview's own vocabulary (`ul.alerts`, one `li.warn` per
word plus `unmatched`: a pipe is filed by what the config said it came from, not by which row) because "a route that does not connect to itself" is the same kind of sentence
card the page would rather it sat on. Every card ships hidden and appears on the poll that `/`'s Warnings & errors panel prints and should look like it. The rows are
has pipes for it (or a config that answered and named none), so a gateway with no nginx in `sources/gateway.warnings()`' words verbatim — the page never re-derives one — and a clean
it does not spend a panel saying so. --> ingress means this section is **hidden**, not an empty card: a permanent "0 warnings" trains
the eye to skip the one that fires. -->
<section class="card" id="gate-warnings" hidden>
<header><h3>Warnings to address</h3><span class="chip" id="warnings-count"></span></header>
<div class="body"><ul class="alerts" id="warnings-list"></ul></div>
</section>
<!-- Exactly two cards, one per config file: `data-gate` is keyed on the payload's own `source`
word, so a route is filed by the file that declares it and not by which card the page would
rather it sat on. nginx first, because its `:443` stream `map` is the widest fan-out on this
fleet (every SNI domain the router knows hanging off one trunk) and the file the operator
edits most. There is no third card
— the stream `map` is nginx config, so its entries are that card's `:443` leaves, and the
sockets neither file describes are reported nowhere (phase 30 task 02). Every card ships
hidden and appears on the poll that has groups for it (or a config that answered and named
none), so a gateway with no nginx in it does not spend a panel saying so. -->
<section> <section>
<div class="page-head"><h2 style="font-size:.95rem;margin:0">Connection map</h2></div> <div class="page-head"><h2 style="font-size:.95rem;margin:0">Connection map</h2></div>
<div class="grid auto items-start gate-grid" id="gate-cards"> <div class="grid auto items-start gate-grid" id="gate-cards">
<article class="card gate-card" data-gate="haproxy" hidden>
<header>
<span class="dot idle" data-k="dot"></span>
<h3>haproxy</h3>
<span class="chip" data-k="counts"></span>
<span class="spacer"></span>
<span class="faint mono updated"></span>
</header>
<div class="body stack">
<div class="pipe-columns">
<div class="pipe-col in" data-k="in"></div>
<div class="pipe-node" data-k="node" hidden></div>
<div class="pipe-col out" data-k="out"></div>
</div>
<div class="pipe-empty faint" data-k="empty" hidden></div>
<details class="charts-box" data-k="charts">
<summary>Charts<span class="chip charts-window" data-k="charts-window"></span></summary>
<div class="host-charts">
<div class="host-chart">
<div class="chart sm" data-chart="conns"></div>
<div class="legend" data-legend="conns"></div>
</div>
</div>
</details>
</div>
</article>
<article class="card gate-card" data-gate="nginx" hidden> <article class="card gate-card" data-gate="nginx" hidden>
<header> <header>
<span class="dot idle" data-k="dot"></span> <span class="dot idle" data-k="dot"></span>
@@ -127,11 +116,10 @@
<span class="faint mono updated"></span> <span class="faint mono updated"></span>
</header> </header>
<div class="body stack"> <div class="body stack">
<div class="pipe-columns"> <!-- One group per listening port: the trunk on the left, the targets its config names
<div class="pipe-col in" data-k="in"></div> hanging off it on the right. This is the structure of the file, not a summary of it.
<div class="pipe-node" data-k="node" hidden></div> Task 04 ships the rows plainly styled; task 05 draws the line and the switch. -->
<div class="pipe-col out" data-k="out"></div> <div class="gate-map" data-k="map"></div>
</div>
<div class="pipe-empty faint" data-k="empty" hidden></div> <div class="pipe-empty faint" data-k="empty" hidden></div>
<details class="charts-box" data-k="charts"> <details class="charts-box" data-k="charts">
<summary>Charts<span class="chip charts-window" data-k="charts-window"></span></summary> <summary>Charts<span class="chip charts-window" data-k="charts-window"></span></summary>
@@ -145,23 +133,16 @@
</div> </div>
</article> </article>
<!-- The stream map's pipes are `map`-sourced while the listener they are reached through is <article class="card gate-card" data-gate="haproxy" hidden>
an `nginx` one, so this card is the fan-out alone. Joining the `443` listener into it
would be the page inventing a relationship the agent never reported. -->
<article class="card gate-card" data-gate="map" hidden>
<header> <header>
<span class="dot idle" data-k="dot"></span> <span class="dot idle" data-k="dot"></span>
<h3>stream map</h3> <h3>haproxy</h3>
<span class="chip" data-k="counts"></span> <span class="chip" data-k="counts"></span>
<span class="spacer"></span> <span class="spacer"></span>
<span class="faint mono updated"></span> <span class="faint mono updated"></span>
</header> </header>
<div class="body stack"> <div class="body stack">
<div class="pipe-columns"> <div class="gate-map" data-k="map"></div>
<div class="pipe-col in" data-k="in"></div>
<div class="pipe-node" data-k="node" hidden></div>
<div class="pipe-col out" data-k="out"></div>
</div>
<div class="pipe-empty faint" data-k="empty" hidden></div> <div class="pipe-empty faint" data-k="empty" hidden></div>
<details class="charts-box" data-k="charts"> <details class="charts-box" data-k="charts">
<summary>Charts<span class="chip charts-window" data-k="charts-window"></span></summary> <summary>Charts<span class="chip charts-window" data-k="charts-window"></span></summary>
@@ -175,43 +156,21 @@
</div> </div>
</article> </article>
<!-- Not pipes: there is no config to draw these from, only the socket table's leftovers.
`listeners no config names` counts **ports** and the other two count **connections**,
which is why they are three cells and never one sum. -->
<article class="card gate-card" data-gate="unmatched" hidden>
<header>
<span class="dot idle" data-k="dot"></span>
<h3>unmatched</h3>
<span class="spacer"></span>
<span class="faint mono updated"></span>
</header>
<div class="body stack">
<div class="metrics">
<div class="metric">
<div class="label">listeners no config names</div>
<div class="value sm" data-k="um-other">n/a</div>
</div>
<div class="metric">
<div class="label">connections inbound</div>
<div class="value sm" data-k="um-in">n/a</div>
</div>
<div class="metric">
<div class="label">connections outbound</div>
<div class="value sm" data-k="um-out">n/a</div>
</div>
</div>
<ul class="gate-groups" data-k="groups" hidden></ul>
</div>
</article>
</div> </div>
</section> </section>
<!-- Three sentences, one per claim the page is allowed to make: what the drawing means and what
its motion means (so a resting line is not read as a dead one), why a route written as a
domain carries no number (so a dash is not read as a fault), and what the switch does not
yet do. No `stale` claim — a socket table that did not answer is the page's one `why` line. -->
<footer class="pagefoot"> <footer class="pagefoot">
<span class="faint">a pipe animates while it carries at least one live connection and rests <p class="faint">a line is drawn because the config says the route exists, and it animates only
at zero, and is never coloured by a verdict — a busy ingress is the job.</span> while at least one connection is open on it — a resting line is not a dead one, and nothing
<span class="faint">a <code>by port</code> pipe was matched on its socket port here is coloured by a verdict: a busy ingress is the job.</p>
alone: the target is a name, the agent resolves nothing, and that port carries more than one <p class="faint">only an endpoint the config wrote as an <b>address</b> can be matched to a
configured host — so the count is the port's, not this target's.</span> socket — this page resolves nothing, so a line to a <b>name</b> carries no number.</p>
<p class="faint">the switch shows what the config says; toggling a route needs a config write
path and is not wired up yet.</p>
</footer> </footer>
</main> </main>
+197 -75
View File
@@ -27,6 +27,21 @@
--fail-soft: rgba(248, 81, 73, 0.16); --fail-soft: rgba(248, 81, 73, 0.16);
--info: #58a6ff; --info: #58a6ff;
--info-soft: rgba(88, 166, 255, 0.15); --info-soft: rgba(88, 166, 255, 0.15);
/* The gateway's existence pair — deliberately NOT the severity vocabulary. Green means the
route is written in a proxy config; red means it is out of the config. No gateway figure is
graded (`severity.py` has no `gateway.*` row), so nothing here may reach for --ok, --warn or
--fail directly: the pair is one indirection whose name says what it answers. Declared once,
on the root, as a substitution of the verdict tokens — per-theme --ok replacement happens on
the same element, so light and dark both follow without a second declaration (and a second
declaration is exactly how a pair like this drifts out of step with its theme). */
--gate-on: var(--ok);
--gate-off: var(--fail);
/* `/gateway`'s own rhythm, declared rather than repeated: the spine's halves extend by half the
* leaf gap so consecutive rows meet, and a literal `calc(50% + .15rem)` would keep drawing a
* continuous spine the day somebody changed the gap. `--gate-row-gap` is the group's, which is the
* distance a stacked phone trunk sits above its first leaf. */
--gate-leaf-gap: .3rem;
--gate-row-gap: .2rem;
--series-1: #ff8a3d; --series-1: #ff8a3d;
--series-2: #58a6ff; --series-2: #58a6ff;
--series-3: #3fb950; --series-3: #3fb950;
@@ -1260,99 +1275,202 @@ input[type="text"].confirm {
table.data td.san { white-space: normal; } table.data td.san { white-space: normal; }
/* ---- gateway pipes --------------------------------------------------------- /* ---- gateway pipes ---------------------------------------------------------
* `/gateway` (phase 29 task 04): the connection map. One card per source family, and inside each * `/gateway` (phase 30 tasks 04-05): the connection map, as two tables — one per config file — whose
* card two columns of pipes joined by one node — the gateway itself. A pipe is one `<path>` with * rows are the connections that file declares. A group is a trunk (the port the config binds) and
* a travelling dash pattern, so the whole vocabulary here is ink and motion: a connection count * the leaves it names, so the page's structure *is* the file's: nginx's `:443` with the whole fan-out
* is not a graded number, which is why this section draws no coloured verdict on any of it — the * of its `map` entries hanging off it, haproxy's one-target frontends, this fleet's `:80` vhost with
* `net.*` precedent, and the API sends no verdict for a gateway figure at any depth. * its refusal sentence in place of a leaf. (No count belongs in this comment: the router's config
* gains an entry whenever a domain is added, and the page draws whatever the file says.)
* *
* Three layout facts are load-bearing, all of them measured in `data/logs/29-task04-pipes.cjs`: * A pipe is one `<path>` with a travelling dash pattern, so the vocabulary here is ink and motion:
* the node is one rounded rect that does **not** stretch to the taller column (a 17-target * a connection count is not a graded number, which is why this section draws no verdict on any of
* fan-out would otherwise grow a 600 px wall beside a six-pipe side); an empty column collapses * it — the `net.*` precedent, and the API sends no verdict for a gateway figure at any depth. What
* and the node moves to the edge it is joined to (`solo`); and at a phone width the three columns * it does colour with is **existence**: `--gate-on` means the route is written in a proxy config
* stack with the node between them, because 320 px of card has no room for a pipe *and* its * (`sources/gateway.py` drew the row because a config declared it), and `--gate-off` is the
* label, and an unlabelled pipe is a line nobody can read. * not-in-the-config state the same pair owns. Motion means one thing and one thing only: at least
* one ESTABLISHED socket on that pipe right now.
*
* The branch is pure CSS. Each leaf row carries its own half of the spine (`::before` above its
* tick, `::after` below), and the two ends of the list drop theirs, so the spine starts at the
* first branch and stops at the last with no mask, no magic height and no background-coloured
* eraser — and it stays right when a poll adds or removes a leaf, because it is a property of the
* row and not of a measurement. A group with exactly one leaf is not a branch at all: `.solo` takes
* the vertical away and the row reads straight across, which is every haproxy frontend on this
* fleet.
*
* Three layout facts are load-bearing and each is measured, not assumed: the trunk column is a
* fixed-ish `9.5rem` so a 17-row fan-out lines up its leaves instead of re-flowing them to the
* widest target; every track is `minmax(0, …)` because a grid item may not be wider than its column
* by default and a 40-character target would otherwise grow the card (phase 27's rule); and at a
* phone width the group stacks to one column with the trunk above its leaves, because 320 px of card
* has no room for a line *and* its label, and an unlabelled line is one nobody can read.
*/ */
.gate-tallies { display: flex; flex-wrap: wrap; gap: .9rem; align-items: flex-end; } .gate-tallies { display: flex; flex-wrap: wrap; gap: .9rem; align-items: flex-end; }
/* "listeners no config names" is a fact, and `.metric .label` truncates with an ellipsis at a card /* Phase 30 task 02 deleted the card these belonged to: `unmatched` was the only place a
width that cannot fit it — a clipped label is a clipped fact (phase 27's rule), so these wrap. `.metric .label` sat inside a `.gate-card` and the only renderer of a `.gate-groups` list, and
The five header tallies keep the shared truncating rule: their labels are one word each. */ the four header tallies that remain are one word each, so they keep the shared truncating rule.
.gate-card .metric .label { white-space: normal; } A card that grows a long label of its own gets its wrap rule back with it. */
.gate-grid { min-width: 0; } .gate-grid { min-width: 0; }
.pipe-columns {
display: grid; gap: .5rem 1rem; min-width: 0; align-items: center; /* The panel's groups, one under the other in port order. */
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); .gate-map { display: grid; gap: .7rem; min-width: 0; }
/* Trunk on the left, its leaves on the right. The trunk track is a width rather than `auto` so the
* leaves of every group line up vertically — the reading that makes this look like the table it is
* standing in for — and `9.5rem` is the widest trunk sentence this page prints (`:25566 · 4 live`).
*
* `column-gap: 0` is what makes the branch read as one drawing: the trunk's right edge and the
* leaves' left edge are the same x, and that x is where the spine lives. The vertical rhythm is all
* `row-gap`, which the ≤640px block also reaches into. */
.gate-group {
position: relative;
display: grid; row-gap: var(--gate-row-gap); column-gap: 0; min-width: 0; align-items: start;
grid-template-columns: minmax(0, 9.5rem) minmax(0, 1fr);
} }
/* A card with pipes on one side only. The stream map's fan-out is the case that ships: its /* The trunk. `position: relative` because it is the half of the picture the spine is drawn against,
* inbound half is the nginx `443` listener and lives on the nginx card, because the payload never * and `padding-inline-end` keeps the port's own text off the spine at the trunk's right edge. */
* says which listener a map is reached through and the page does not guess one. Two classes, not .gate-trunk {
* one, because *which* side is empty decides which grid track the node belongs in. */ position: relative; min-width: 0; padding-inline-end: .5rem;
.pipe-columns.solo-out { grid-template-columns: auto minmax(0, 1fr); } display: flex; flex-wrap: wrap; align-items: baseline; gap: .05rem .35rem;
.pipe-columns.solo-in { grid-template-columns: minmax(0, 1fr) auto; }
.pipe-col { display: grid; gap: .5rem; min-width: 0; align-content: center; }
.pipe-node {
align-self: center; justify-self: center; text-align: center;
min-width: 6rem; max-width: 9rem; padding: .6rem .45rem;
border: 1px solid var(--border-strong); border-radius: var(--radius);
background: var(--bg-sunken); color: var(--text-muted);
font: 600 11.5px var(--mono); letter-spacing: .04em; overflow-wrap: anywhere;
} }
.pipe-row { display: grid; gap: .12rem; min-width: 0; } /* The leaves carry **no** left padding of their own: the 26 px first column of `.gate-link` *is* the
.pipe-label { * gutter the spine is drawn in and the tick runs across. Padding here instead would push every tick
display: flex; flex-wrap: wrap; align-items: baseline; gap: .05rem .35rem; min-width: 0; * 26 px away from the spine it is supposed to reach. */
.gate-leaves { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--gate-leaf-gap); min-width: 0; }
/* One leaf: the branch gutter, the switch that says the config has it, the animated run, the
* endpoint, the count. `minmax(0, 1fr)` on the target column is what lets a 41-character hostname
* ellipsise instead of widening the card, and the whole string stays on the row's `title` and the
* pipe's `aria-label` when it does (gap 13). */
/* `column-gap: 0` is the whole connection: tick, switch, run and label are drawn end to end, so the
* line reaches both of its counterparts instead of stopping a gap short of each. The run's own
* column (`minmax(2rem, 4.5rem)`) is what separates a switch from a label, and the label keeps a
* little padding of its own so a line ends *at* the text rather than inside it. */
.gate-link {
position: relative;
display: grid; align-items: center; column-gap: 0; row-gap: .2rem; min-width: 0;
grid-template-columns: 26px auto minmax(2rem, 4.5rem) minmax(0, 1fr) auto;
} }
/* A pipe's label is the page's whole answer — which port, which domain, which target — and this row /* Each row carries its own half of the spine, at the gutter's left edge — the trunk's own right
is what makes the answer readable. Task 06 measured it on live data: as one `nowrap` flex line the * edge, since the group sets `column-gap: 0`. Dropping the first row's upper half and the last
name shrank to whatever the `by port` chip, the config's own `server` name and the `flex: none` * row's lower half is the whole start/stop rule: no mask, no measured height, and a leaf added or
count left, which drew seven characters of `reeseapps-gitea.reeselink.com:2222`; wrapping the text * removed by the next poll moves the ends with it. */
inside that squeezed box was worse (eleven lines, a word at a time). So the name claims the line: /* Each half reaches half a leaf gap past its own row, so two adjacent rows meet and the spine reads
`max-content` as its base size means a name longer than the column is alone on its line and gets * as one line rather than a stack of brackets. The ends of the list drop theirs, so this extension
the *whole* column (~22 characters), with the chip, the footnote and the count wrapping beneath it, * never draws past a branch. */
while a short label like `:22` still sits beside its count on one line exactly as before. What is .gate-link::before {
still clipped after that is a target longer than the column, which is a column-width fact content: ""; position: absolute; left: 0;
(AGENTS.md gap 13) and not a lost one: `renderPipeRow` puts the full string on the element as top: calc(var(--gate-leaf-gap) / -2); height: calc(50% + var(--gate-leaf-gap) / 2);
`title`, `pipeAria` carries the whole endpoint, and border-left: 2px dashed var(--border-strong);
`test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins both declarations and both }
strings. The live pass re-counts the clipped labels on every run. */ .gate-link::after {
.pipe-name { content: ""; position: absolute; left: 0; top: 50%;
flex: 1 1 max-content; font-size: 11.5px; min-width: 0; height: calc(50% + var(--gate-leaf-gap) / 2);
border-left: 2px dashed var(--border-strong);
}
.gate-link:first-child::before, .gate-link:last-child::after { display: none; }
/* The tick from that spine to this row's switch, at the row's vertical centre — the same y the two
* halves of the spine meet at, so the junction is a `T` and not two near-misses. */
.gate-elbow { align-self: center; height: 0; border-top: 2px dashed var(--border-strong); }
/* Straight across: a group with exactly one leaf is not a branch, so it draws no vertical at all —
* the tick, the switch and the run land on one horizontal line from the trunk to the endpoint. That
* is every haproxy frontend on this fleet (six frontends, one `server` each), and the class is the
* JS's one leaf-count test rather than a `:only-child` rule because the *refusal* row
* (`.gate-none`) is a second child of the same list and a `:only-child` selector cannot know that. */
.gate-group.solo .gate-link::before, .gate-group.solo .gate-link::after { display: none; }
.gate-switch-slot { display: inline-flex; align-items: center; flex: none; }
/* The switch is a **reading** shaped like a control, and it is the shape the operator sketched: a
* pill with a knob, green when the route is in the config. It is keyed on `[aria-checked]` and
* **never** on `:enabled`, because a switch this app cannot wire (invariant 1: nothing here writes
* to an upstream) must still look green while it is on — styling `:enabled` would paint every one
* of them red by accident. `cursor: not-allowed` says what a hover would otherwise promise, and
* there is no `:hover` rule and no listener anywhere in `page-gateway.js`. `aria-disabled="true"`
* rather than `disabled`, which would drop a real `<button>` out of the a11y tree and make a
* reading invisible to the people who most need the sentence. */
.gate-switch {
width: 34px; height: 18px; padding: 2px; border-radius: 999px; flex: none;
border: 1px solid var(--gate-on); display: inline-flex; align-items: center;
background: color-mix(in srgb, var(--gate-on) 14%, transparent);
cursor: not-allowed;
}
.gate-switch-thumb {
width: 12px; height: 12px; border-radius: 50%; background: var(--gate-on);
transform: translateX(16px); transition: transform .16s ease;
}
.gate-switch[aria-checked="false"] { border-color: var(--gate-off);
background: color-mix(in srgb, var(--gate-off) 14%, transparent); }
.gate-switch[aria-checked="false"] .gate-switch-thumb { transform: translateX(0); }
.gate-target { display: grid; min-width: 0; padding-inline-start: .35rem; }
/* The config's own word for what reaches this endpoint (`server_name`, backend name, `map` key) and
* the trunk's domain count — the page's smallest, most-clippable line, so it is the one that names
* itself in the row's `title`. */
.gate-selector {
font-size: 11px; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.pipe-detail { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* The run from the switch to the endpoint — the part that moves. The base rule carries the shape
/* the count sits at the node end of the label line, so the eye goes from the number straight * and nothing else: no `stroke`, so colour is a claim a state class has to make. `page-gateway.js`
into the pipe that carries it */ * puts `on` on every pipe it draws (drawn ⇒ in the config), which is the only reason any of them
.pipe-count { * are visible at all, and `off` is the styled-but-unreachable half of the pair until a config write
margin-inline-start: auto; padding-inline-start: .5rem; flex: none; * path exists. */
font: 600 12.5px var(--mono);
}
/* The pipe is a grid row of its own, the full width of its column — which is what makes it touch
* the node, since the inbound column is the node's left and the outbound column its right. */
.pipe { display: block; width: 100%; min-width: 0; height: 10px; } .pipe { display: block; width: 100%; min-width: 0; height: 10px; }
.pipe-path { fill: none; stroke: var(--border-strong); stroke-width: 2; stroke-dasharray: 7 9; } .pipe-path { fill: none; stroke-width: 2; stroke-dasharray: 7 9; }
/* One connection is enough to move. The dash travels the pipe's own direction, which is .pipe.on .pipe-path { stroke: var(--gate-on); }
* *towards* the node in the inbound column and *away from* it in the outbound one — the node is .pipe.off .pipe-path { stroke: var(--gate-off); }
* the far end of one and the near end of the other, so one keyframe is the whole direction /* One open connection is enough to move, and motion is the **only** thing `live` claims: the colour
* story and a reversed second one would only be a second thing to keep in step. */ * comes from `on`/`off`, so a busy pipe and a resting one are the same green. The dash runs from the
.pipe.live .pipe-path { stroke: var(--accent); animation: pipe-flow 1.2s linear infinite; } * switch end to the endpoint end. One keyframe for every line on the page, and a reversed second one
/* Zero is a static, faint line rather than an absent one: "this pipe exists and carries nothing" * would only be a second thing to keep in step. */
* is a fact the drawing has to be able to say, and it is a different sentence from `n/a`. */ .pipe.live .pipe-path { animation: pipe-flow 1.2s linear infinite; }
.pipe.still .pipe-path { stroke: var(--text-faint); stroke-dasharray: 3 7; opacity: .55; } /* Zero is a static, tighter line rather than an absent one: "the config routes here and nothing is
* open on it right now" is a fact the drawing has to be able to say, and it is a different sentence
* from the `—` a name target prints. The class says *nothing on it this instant*; it does not say
* "idle route", which is why the colour stays the config's green. */
.pipe.still .pipe-path { stroke-dasharray: 4 8; }
@keyframes pipe-flow { @keyframes pipe-flow {
/* one full dash+gap cycle (7 + 9), so the loop is seamless */ /* one full dash+gap cycle (7 + 9), so the loop is seamless */
from { stroke-dashoffset: 0; } from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -16; } to { stroke-dashoffset: -16; }
} }
/* The trunk's label line and the leaf's: which port, how many are on it, who is on it, and how many
domains it serves. Task 06 measured the alternative on live data — one `nowrap` flex line with a
`flex: none` count squeezed the name to seven characters of a 40-character target, and wrapping
the text inside that squeezed box tore one hostname over eleven lines. So the name claims the
line (`max-content` base size: a name longer than the column is alone on it and gets the whole
column) and the count, clients and domain list wrap beneath. What is still clipped after that is a
target longer than the column, which is a column-width fact (AGENTS.md gap 13) and not a lost one:
the row carries the full string as `title` and the pipe's `aria-label` carries the whole endpoint,
which `test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins. */
.pipe-name {
flex: 1 1 max-content; font-size: 11.5px; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* the count sits at the line end of the trunk, so the eye goes from the number straight into the
lines that carry it; in a leaf row the count is its own grid column and this is just its type */
.pipe-count {
margin-inline-start: auto; padding-inline-start: .5rem; flex: none;
font: 600 12.5px var(--mono);
}
.pipe-clients { font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pipe-clients { font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The sentence a trunk gets when its config names no target — this fleet's `:80` `return 301`. A
refusal is content, so it is the same size as the leaves it stands in for. */
.gate-none { font-size: 11.5px; }
.pipe-empty { font-size: 12px; } .pipe-empty { font-size: 12px; }
.gate-groups { list-style: none; margin: 0; padding: 0; display: grid; gap: .3rem; }
.gate-groups li { display: flex; gap: .45rem; align-items: baseline; min-width: 0; }
.gate-groups .pipe-name { flex: none; }
@media (max-width: 640px) { @media (max-width: 640px) {
.pipe-columns, /* One column, trunk above its leaves: 320 px of card has no room for a line and a label side by
.pipe-columns.solo-out, * side, and an unlabelled line is one nobody can read. DOM order is already trunk-then-leaves, so
.pipe-columns.solo-in { grid-template-columns: minmax(0, 1fr); } * the stack needs nothing but the track list. */
.pipe-node { max-width: none; width: 100%; } .gate-group { grid-template-columns: minmax(0, 1fr); }
/* The trunk is now *above* the fan-out rather than beside it, so the first row's upper half comes
* back and reaches up through the group's `row-gap` — otherwise the spine dangles below a trunk it
* is no longer touching. The reach is `var(--gate-row-gap)` rather than a literal, which is what
* keeps it exact if the group's rhythm ever moves. */
.gate-link:first-child::before {
display: block;
top: calc(var(--gate-row-gap) * -1); height: calc(50% + var(--gate-row-gap));
}
/* The switch and its row never shrink: a 34 px pill that squashed to fit a phone would stop
* reading as the same control the desktop row draws. */
.gate-switch, .gate-switch-thumb { flex: none; }
} }
/* ---- dialog ------------------------------------------------------------- */ /* ---- dialog ------------------------------------------------------------- */
@@ -1419,7 +1537,11 @@ footer.pagefoot {
/* `/gateway`'s pipes, **both states**. The animated rule out-specifies the base one exactly /* `/gateway`'s pipes, **both states**. The animated rule out-specifies the base one exactly
* the way `.is-open` out-specifies `nav.tabs`, so stilling the static state alone would leave * the way `.is-open` out-specifies `nav.tabs`, so stilling the static state alone would leave
* a live pipe flowing for someone who asked for no motion — the same phase-46 lesson the menu * a live pipe flowing for someone who asked for no motion — the same phase-46 lesson the menu
* pair above carries, applied the day a second animated component landed in this sheet. */ * pair above carries, applied the day a second animated component landed in this sheet. The
* switch's knob is the third animated thing on the page and is named with them: a block that
* stills the pipes and leaves the thumb sliding is the same lesson unlearned. */
.pipe.live .pipe-path, .pipe.live .pipe-path,
.pipe.still .pipe-path { animation: none; } .pipe.still .pipe-path { animation: none; }
.gate-switch-thumb { transition: none; }
} }
File diff suppressed because it is too large Load Diff
+30 -16
View File
@@ -96,26 +96,39 @@ function renderAlerts(alerts) {
}).join(''); }).join('');
} }
/* The two figures the overview's ingress line prints, in the order it prints them. Each `key` is /* The four figures the overview's ingress line prints, in the order it prints them. Each `key` is
* a key of `/api/summary`'s `gateway` block, and `tests/test_api.py` resolves this table against * a key of `/api/summary`'s `gateway` block, and `tests/test_api.py` resolves this table against
* the payload the route actually serves (the `SLOT_FIELDS` device): a rename on either side fails * the payload the route actually serves (the `SLOT_FIELDS` device): a rename on either side fails
* there rather than printing `n/a` beside a busy ingress. */ * there rather than printing `n/a` beside a busy ingress. The first two are what the two config
* files **declare** and the last two what the kernel's socket table **answered**, which is why
* they are four fields and never one pair. */
const GATEWAY_FIELDS = [ const GATEWAY_FIELDS = [
{ key: 'inbound', word: 'inbound' }, { key: 'listeners', word: 'listeners' },
{ key: 'outbound', word: 'outbound' }, { key: 'connections', word: 'connections' },
{ key: 'live_in', word: 'live in' },
{ key: 'live_out', word: 'live out' },
]; ];
/* The fifth figure is not in the table above because it prints **only when it is non-zero**: a
* clean ingress has no config to fix, and a permanent ` · 0 to address` would be the ink the
* empty-state rules exist to avoid. `key` is still a key of the `gateway` block (the test resolves
* it against a live document), and a `null` — an ingress that never answered — is not `> 0`, so
* the unknown arm prints nothing rather than a number nobody measured. */
const GATEWAY_WARNING_FIELD = { key: 'warnings', word: 'to address' };
/** /**
* The ingress's one line under the fleet table: the pipe totals `/api/summary` summed, linked to * The ingress's one line under the fleet table: the ingress's own tallies, linked to the page
* the page that draws them. * that draws the map behind them.
* *
* Nothing here is counted in the browser and nothing here is a route. The link is the block's own * Nothing here is counted in the browser and nothing here is a route. The link is the block's own
* `href` (`alerts.py`'s table is where a dashboard's page is written, the same rule the alert rows * `href` (`alerts.py`'s table is where a dashboard's page is written, the same rule the alert rows
* keep), the counts are that block's sums of the pipes, and `n/a` is what a `null` prints: an * keep), the figures are that block's, and `n/a` is what a `null` prints: an agent that has not
* agent that has not answered, one that predates the proxy sections, and a socket table that did * answered, one that predates the proxy sections, and a socket table that did not reply all
* not reply all measured **nothing**, which is not the sentence "0 connections" (rule 2). * measured **nothing**, which is not the sentence "0 connections" (rule 2). A route the config
* `configured: false` renders no line at all — the `disks` block's invariant-3 rule: an ingress * wrote as a name is `null` in the live pair for the same reason — the agent resolves nothing, so
* nobody installed gets no row here and no red one to learn to ignore. * nobody ever counted its sockets. `configured: false` renders no line at all — the `disks`
* block's invariant-3 rule: an ingress nobody installed gets no row here and no red one to learn
* to ignore.
*/ */
function renderGateway(gateway) { function renderGateway(gateway) {
const row = $('#fleet-gate'); const row = $('#fleet-gate');
@@ -128,15 +141,16 @@ function renderGateway(gateway) {
row.hidden = false; row.hidden = false;
// the shared dim for "these are the last good numbers" (invariant 4) // the shared dim for "these are the last good numbers" (invariant 4)
row.classList.toggle('stale', block.unreachable === true); row.classList.toggle('stale', block.unreachable === true);
const why = block.unreachable === true ? `${block.host} is not answering`
: block.partial ? 'a pipe could not be told from another on its port, so the total leaves it out'
: '';
const counts = GATEWAY_FIELDS const counts = GATEWAY_FIELDS
.map(({ key, word }) => `${num(block[key], 0)} ${word}`).join(' · '); .map(({ key, word }) => `${num(block[key], 0)} ${word}`).join(' · ');
// the config-integrity tally, and only when there is one (`null` and `0` both print nothing)
const warn = Number.isInteger(block[GATEWAY_WARNING_FIELD.key])
&& block[GATEWAY_WARNING_FIELD.key] > 0
? ` · ${block[GATEWAY_WARNING_FIELD.key]} ${GATEWAY_WARNING_FIELD.word}` : '';
row.innerHTML = `<a href="${DashCharts.escapeHtml(String(block.href || ''))}">` row.innerHTML = `<a href="${DashCharts.escapeHtml(String(block.href || ''))}">`
+ `${DashCharts.escapeHtml(String(block.host || ''))}</a> ` + `${DashCharts.escapeHtml(String(block.host || ''))}</a> `
+ `${DashCharts.escapeHtml(counts)}`; + `${DashCharts.escapeHtml(counts + warn)}`;
if (why) row.title = why; if (block.unreachable === true) row.title = `${block.host} is not answering`;
} }
/** /**
+172 -14
View File
@@ -7,9 +7,15 @@ are the ones that make the landing page trustworthy rather than merely busy:
* a source nobody configured is **never** an alert (`AGENTS.md` invariant 3); * a source nobody configured is **never** an alert (`AGENTS.md` invariant 3);
* a source that is stale but still serving is amber, not red; * a source that is stale but still serving is amber, not red;
* a dead source is **one** line, not one per stale metric in its last good payload; * a dead source is **one** line, not one per stale metric in its last good payload (and not one
more for the config warnings its last good payload still carries — phase 30 task 03);
* only `fail` and `warn` come through — `ok`, `unknown`, and a missing number are silence; * only `fail` and `warn` come through — `ok`, `unknown`, and a missing number are silence;
* the list dedupes per (source, metric), is bounded, and says how much it hid. * the list dedupes per (source, metric), is bounded, and says how much it hid.
The one non-reachability rule for a source with no graded numbers is the gateway's config-integrity
row: a proxy config that does not connect to itself is a structural fact, so it gets exactly one
amber line naming how many there are and quoting the first, and it is suppressed whenever that
job's own reachability line is on screen.
""" """
from __future__ import annotations from __future__ import annotations
@@ -483,13 +489,154 @@ def test_a_dead_upsd_is_one_alert_and_not_its_stale_cards_too() -> None:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# the gateway page: reachability is the only voice, and a dead agent is still one line # the gateway page: reachability plus config integrity, and a dead agent is still one line
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def gateway_state(**kwargs: Any) -> dict[str, Any]: def gateway_state(**kwargs: Any) -> dict[str, Any]:
"""The health record of the one `gateway` job (the `nut` shape: key `gateway`, label = host).""" """The health record of the one `gateway` job (the `nut` shape: key `gateway`, label = host)."""
return state("gateway", "gateway", "gateway", **kwargs) return state("gateway", "gateway", "gateway", **kwargs)
def gateway_payload(*messages: str, ts: float | None = 1_000.0) -> dict[str, Any]:
"""A gateway payload carrying exactly `messages` as its config warnings, in that order."""
return {"ts": ts, "host": "gateway", "supported": True,
"warnings": [{"id": f"synthetic:{index}", "kind": "synthetic",
"subject": str(index), "message": text}
for index, text in enumerate(messages)]}
def test_config_warnings_are_one_warn_row_naming_the_count_and_the_first_message() -> None:
"""`gateway:config`: the one row a proxy config that does not connect to itself earns.
Phase 30 task 03 changed the documented rule this file used to pin — a `gateway` alert used to
be reachability only. What it is now: one `warn` row, `href` `/gateway`, `at` the payload's own
timestamp (the config did not change between polls, so an alert whose clock reset every 30 s
would be unreadable), the count and the first message in the sentence.
"""
out = alerts.build(Settings(gateway_interval=30.0), [gateway_state()],
gateway_payload=gateway_payload(
'frontend haproxy-22 routes to backend "gone", which no backend '
"block declares",
'backend "old" has 2 servers and no frontend routes to it'),
now=2_000.0)
assert out == [{"id": "gateway:config", "severity": "warn", "source": "gateway",
"href": "/gateway", "at": 1_000.0,
"message": ("2 proxy config problems to address — "
'frontend haproxy-22 routes to backend "gone", which no '
"backend block declares")}]
def test_one_warning_and_seven_produce_exactly_one_row_either_way() -> None:
"""The row is aggregate on purpose: seven dangling routes are one config to fix.
Seven amber lines would push every other source off the overview panel to say one thing, and the
page beside them lists all seven — so the count is the number and the first message is the
sentence, once.
"""
settings = Settings(gateway_interval=30.0)
for count in (1, 2, 7):
out = alerts.build(settings, [gateway_state()],
gateway_payload=gateway_payload(*[f"problem {index}" for index
in range(count)]),
now=2_000.0)
assert len(out) == 1, f"{count} warnings produced {len(out)} rows"
assert out[0]["id"] == "gateway:config" and out[0]["severity"] == "warn"
assert out[0]["href"] == "/gateway" and out[0]["source"] == "gateway"
noun = "problem" if count == 1 else "problems"
assert out[0]["message"] == f"{count} proxy config {noun} to address — problem 0", (
"the count, the noun that matches it, and the first message verbatim")
def test_a_clean_ingress_warns_about_nothing_and_a_missing_payload_does_too() -> None:
"""Both directions of the row, because an absence-only assertion is satisfied by deleting it."""
settings = Settings(gateway_interval=30.0)
assert alerts.build(settings, [gateway_state()],
gateway_payload=gateway_payload(), now=2_000.0) == []
assert alerts.build(settings, [gateway_state()],
gateway_payload={"host": "gateway", "warnings": None},
now=2_000.0) == [], "a malformed list is not a fault either"
assert alerts.build(settings, [gateway_state()], now=2_000.0) == [], (
"no payload at all — unconfigured, or configured and never answered — says nothing")
def test_a_failing_gateway_job_keeps_its_reachability_row_and_gains_no_config_row() -> None:
"""A dead source is one line, and the payload it left behind is not a second one.
Both arms of the rule, in both of its shapes: `ok: false` (the job is down) and stale (`ok: true`
past three of its own polls). In each, the reachability row is what the operator sees, and the
last good payload's warnings are not re-listed beside it — the warnings will be on `/gateway`
when the source answers again, and a second line here would be the invariant the `disks` rule
already keeps.
"""
settings = Settings(gateway_interval=30.0)
broken = gateway_payload('backend "gone" declares no server — it routes nowhere')
down = alerts.build(settings, [gateway_state(ok=False, error="UpstreamError: gateway: timed out")],
gateway_payload=broken, now=2_000.0)
assert [row["id"] for row in down] == ["gateway:gateway:down"], down
assert "timed out" in down[0]["message"]
stale = alerts.build(settings, [gateway_state(age=100.0)], gateway_payload=broken,
now=2_000.0)
assert [row["id"] for row in stale] == ["gateway:gateway:stale"], stale
assert "every 30s" in stale[0]["message"]
# the control: the same payload with a job that IS answering does grow the config row
live = alerts.build(settings, [gateway_state(age=4.0)], gateway_payload=broken, now=2_000.0)
assert [row["id"] for row in live] == ["gateway:config"]
def test_an_unconfigured_gateway_with_a_warning_payload_still_says_nothing() -> None:
"""Invariant 3 with a payload attached: a source nobody installed has no config to be broken."""
out = alerts.build(Settings(gateway_interval=30.0),
[gateway_state(configured=False)],
gateway_payload=gateway_payload("anything"), now=2_000.0)
assert out == []
def test_a_gateway_config_row_survives_a_dead_host_beside_it_and_sorts_after_the_fail() -> None:
"""The config row is the gateway's own voice and the outage row is the host's — both stand.
The echo table suppresses a `gateway` job that failed *because* its agent did; a config warning
is not that, so a healthy host with a broken ingress gets exactly one overview row about the
ingress and nothing else, and a failing host plus a stale config list stays one row too.
"""
settings = Settings(gateway_interval=30.0)
out = alerts.build(settings, [gateway_state(), state("host:gateway", "system", "gateway")],
gateway_payload=gateway_payload("fix me"), now=2_000.0)
assert [row["id"] for row in out] == ["gateway:config"]
host_down = state("host:gateway", "system", "gateway", ok=False, error="unreachable")
out = alerts.build(settings, [gateway_state(ok=False), host_down],
gateway_payload=gateway_payload("fix me"), now=2_000.0)
assert [row["severity"] for row in out] == ["fail"], "the outage is the only line"
def test_a_config_warning_row_is_the_worst_thing_about_a_gateway_never_a_number() -> None:
"""The rule restated after it changed: reachability plus config integrity, no graded number.
Three ways, because deleting a rule satisfies an absence-only assertion:
* `severity.THRESHOLDS` has no `gateway.*` row, so there is no bound for this module to read
even if a threshold were added here (and a busy proxy is the job, the `net.*` case);
* the row's message is built from `warnings` and nothing else — a payload of nothing but counts
yields no row at all;
* `alerts.HREF` still carries `/gateway`, so both voices reach the page that explains them.
"""
assert not [name for name in severity.THRESHOLDS if name.startswith("gateway.")], (
"a connection count is not a graded number")
settings = Settings(gateway_interval=30.0)
counts = {"ts": 1_000.0, "host": "gateway", "listeners": [{"connections": 9_999}],
"connections": [{"connections": 9_999}], "sockets": {"established": 9_999}}
assert alerts.build(settings, [gateway_state()], gateway_payload=counts, now=2_000.0) == []
assert alerts.HREF["gateway"] == "/gateway"
assert alerts.AGENT_ECHO_DASHBOARDS == ("disks", "gateway"), (
"task 03 changed what a gateway may alert ON, not which lines it echoes")
# and a gateway with a payload whose rows are junk is silence, not a crash and not a claim
assert alerts.build(settings, [gateway_state()],
gateway_payload={"warnings": ["not a mapping", 7]},
now=2_000.0) == []
def test_a_stale_gateway_job_is_a_warn_measured_in_its_own_thirty_second_poll() -> None: def test_a_stale_gateway_job_is_a_warn_measured_in_its_own_thirty_second_poll() -> None:
"""The pipes are drawn from a 30 s poll, so 60 s of silence is two healthy polls. """The pipes are drawn from a 30 s poll, so 60 s of silence is two healthy polls.
@@ -539,24 +686,35 @@ def test_an_unconfigured_gateway_source_says_nothing_even_when_its_job_says_down
def test_a_gateway_payload_with_a_fabricated_verdict_alerts_nothing() -> None: def test_a_gateway_payload_with_a_fabricated_verdict_alerts_nothing() -> None:
"""The phase's no-verdict rule, asserted as a rule and not only as an absence. """The no-verdict rule restated for the payload this task added a reader for.
Three ways, because deleting a rule satisfies an absence-only assertion: `alerts.build` now *takes* a gateway payload (phase 30 task 03), so the old signature-only pin
is gone and what replaces it is stronger and narrower: the parameter is keyword-only with a
* `alerts.build` takes no gateway payload at all — its signature is the whole surface, so a default (a positional gateway would be a rule this module grew by accident), it is the ONLY
gateway count can reach an alert only through a threshold added to this module; gateway input, `severity.THRESHOLDS` still has no `gateway.*` row for it to consult, and a
* `severity.THRESHOLDS` has no `gateway.*` row, so no such threshold could be written without payload with `severity` stamped into it stays silent — the row reads `warnings` and nothing
editing the one file that owns thresholds (and a busy proxy is the job, the `net.*` case); else. An answering gateway with a clean list is the all clear.
* the module's `HREF` does carry `/gateway`, so reachability still reaches the overview.
""" """
assert not {"gateway", "gateway_payload"} & set(inspect.signature(alerts.build).parameters) signature = inspect.signature(alerts.build).parameters
gateway_argument = signature["gateway_payload"]
assert gateway_argument.kind is inspect.Parameter.KEYWORD_ONLY, (
"a positional gateway argument would let a caller pass a count where a payload belongs")
assert gateway_argument.default is None
assert not [name for name in signature
if name.startswith("gateway") and name != "gateway_payload"], (
"a second gateway input is a new rule in disguise")
assert not [name for name in severity.THRESHOLDS if name.startswith("gateway.")], ( assert not [name for name in severity.THRESHOLDS if name.startswith("gateway.")], (
"a connection count is not a graded number") "a connection count is not a graded number")
assert alerts.HREF["gateway"] == "/gateway" assert alerts.HREF["gateway"] == "/gateway"
assert alerts.AGENT_ECHO_DASHBOARDS == ("disks", "gateway") assert alerts.AGENT_ECHO_DASHBOARDS == ("disks", "gateway")
# and an answering gateway with nothing else going on is the all clear # a fabricated verdict anywhere in the payload changes nothing: only `warnings` is read
assert alerts.build(Settings(gateway_interval=30.0), [gateway_state()], [], [], None, [], rigged = gateway_payload()
now=2_000.0) == [] rigged["severity"] = {"listeners": "fail", "connections": "fail"}
rigged["listeners"] = [{"connections": 100, "severity": "fail"}]
rigged["connections"] = [{"connections": 100, "severity": "fail"}]
settings = Settings(gateway_interval=30.0)
assert alerts.build(settings, [gateway_state()], gateway_payload=rigged, now=2_000.0) == []
assert alerts.build(settings, [gateway_state()], [], [], None, [], now=2_000.0) == []
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+1030 -232
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -437,7 +437,7 @@ async def test_the_gateway_fetch_reads_the_same_agent_snapshot_as_the_other_two(
assert seen == ["/snapshot"] assert seen == ["/snapshot"]
assert result.payload["host"] == "gateway" assert result.payload["host"] == "gateway"
assert result.payload["supported"] is True assert result.payload["supported"] is True
assert result.payload["inbound"], "the configured pipes arrive with the snapshot" assert result.payload["listeners"], "the configured pipes arrive with the snapshot"
assert result.samples and all(sample.source == "gateway" for sample in result.samples), ( assert result.samples and all(sample.source == "gateway" for sample in result.samples), (
"the gauges are their own sample family, not `host`'s") "the gauges are their own sample family, not `host`'s")
assert result.events == [], "a connection count has no transition to report" assert result.events == [], "a connection count has no transition to report"
File diff suppressed because it is too large Load Diff
+48 -14
View File
@@ -21,9 +21,11 @@ a unit test.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import os import os
import sqlite3 import sqlite3
from collections.abc import Iterator from collections.abc import Iterator
from typing import Any
import httpx import httpx
import pytest import pytest
@@ -257,14 +259,17 @@ async def test_disks_source_charts_the_physical_drives_the_agent_reports(
async def test_the_gateway_source_joins_the_live_ingress(settings: Settings, async def test_the_gateway_source_joins_the_live_ingress(settings: Settings,
agent_tls: dict[str, collectors.AgentTls]) -> None: agent_tls: dict[str, collectors.AgentTls]) -> None:
"""Phase 29: `sources/gateway.py` over the **real** gateway agent, read-only. """Phase 29/30: `sources/gateway.py` over the **real** gateway agent, read-only.
The production path — the same `fetch_snapshot` the poller will run, through the stored The production path — the same `fetch_snapshot` the poller will run, through the stored
mTLS authority — so what fails here is a drift between the agent's sections and the join, mTLS authority — so what fails here is a drift between the agent's sections and the join,
not a typo in a fixture. The assertions are the ones that hold whatever the traffic is: not a typo in a fixture. The assertions are the ones that hold whatever the traffic is:
the agent answers the three sections, every configured frontend is a pipe, and the the agent answers the three sections, every configured frontend and `listen` port is a
inbound/outbound/unmatched tallies **partition** the kernel's ESTABLISHED table, which is trunk, and the inbound count **re-derived here** from the snapshot's own ESTABLISHED rows
what lets the page's numbers reconcile against a direct `/proc/net/tcp` read. equals the sum the payload prints — the identity of phase 30's simplified join, checked
against the kernel rather than against a fixture. The totals are an inequality by design
(`sum(listeners) + sum(endpoints) <= established`): a socket neither config file describes
is counted nowhere, and on this router that is most of the table.
""" """
target = next((item for item in settings.host_targets if item.name == settings.gateway_host), target = next((item for item in settings.host_targets if item.name == settings.gateway_host),
None) None)
@@ -283,16 +288,45 @@ async def test_the_gateway_source_joins_the_live_ingress(settings: Settings,
"needs 1.2.0's haproxy/nginx/sockets sections (AGENTS.md gap 11's rollout)") "needs 1.2.0's haproxy/nginx/sockets sections (AGENTS.md gap 11's rollout)")
assert payload["host"] == target.name assert payload["host"] == target.name
assert payload["sockets"]["established"] == len(snapshot["sockets"]["established"]) assert payload["sockets"]["established"] == len(snapshot["sockets"]["established"])
assert payload["inbound"], "no frontend on either config would leave the page empty" assert payload["listeners"], "no frontend on either config would leave the page empty"
for pipe in payload["inbound"]: assert payload["connections"], "the two configs declare no route at all"
assert isinstance(pipe["connections"], int), "the socket table answered, so a count is known" for trunk in payload["listeners"]:
for group in payload["unmatched"]["groups"]: assert isinstance(trunk["connections"], int), "the socket table answered, so a count is known"
assert group["connections"] > 0 and group["host_count"] > 1, (
"a group is only ever the unresolved fan-out of several names on one port") # the join, re-derived from the agent's own rows rather than from the payload's arithmetic
attributed = sum(pipe["connections"] or 0 for pipe in payload["inbound"] + payload["outbound"]) configured = {trunk["port"] for trunk in payload["listeners"]}
assert attributed + payload["unmatched"]["inbound"] + payload["unmatched"]["outbound"] == \ assert configured, "a gateway with no configured port has nothing to reconcile"
payload["sockets"]["established"], "every socket counted exactly once" inbound = [row for row in snapshot["sockets"]["established"]
assert all(sample.source == gateway.SOURCE for sample in gateway.samples(payload, 0.0)) if ((row.get("local") or {}).get("port")) in configured]
assert sum(t["connections"] for t in payload["listeners"]) == len(inbound), (
"the trunk counts are the ESTABLISHED rows whose local port a config names — no more, "
"no less")
endpoints: dict[tuple[Any, Any], Any] = {}
joinable: set[str] = set()
for route in payload["connections"]:
assert route["in"]["port"] in configured, "a route hangs off no trunk the page draws"
out = route["out"]
assert (route["connections"] is None) is (out["address"] is not True), (
f"{route['id']}: only an endpoint the config wrote as an address can be counted")
if out["address"] is True and isinstance(out["port"], int):
joinable.add(str(route["metric"]))
endpoints.setdefault((out["host"], out["port"]), route["connections"])
else:
assert route["metric"] is None, "a route nobody can count publishes no series"
live_out = sum(value for value in endpoints.values() if isinstance(value, int))
assert sum(t["connections"] for t in payload["listeners"]) + live_out \
<= payload["sockets"]["established"], "the page may under-count the kernel, never exceed it"
# and nothing outside the two config files reached the payload at all — this app's own agent
# port is the case phase 30 deleted the reporting of
text = json.dumps(payload)
assert ":9120" not in text and "@9120" not in text, "the agent's own port leaked into the map"
stored = gateway.samples(payload, 0.0)
assert all(sample.source == gateway.SOURCE for sample in stored)
assert {sample.metric for sample in stored if "out_conns" in sample.metric} <= joinable, (
"only an address endpoint publishes an outbound gauge — a domain-keyed series is the one "
"phase 30 stopped writing, because the figure it carried belonged to the port")
@pytest.mark.parametrize("engine", ["gufo", "vllm"]) @pytest.mark.parametrize("engine", ["gufo", "vllm"])
+23 -5
View File
@@ -15,7 +15,7 @@ import asyncio
import logging import logging
import sqlite3 import sqlite3
import time import time
from collections.abc import Iterator from collections.abc import Callable, Iterator
from typing import Any from typing import Any
import pytest import pytest
@@ -208,6 +208,24 @@ async def test_states_can_be_filtered_to_one_dashboard(store) -> None:
assert [s["key"] for s in hub.states("ups")] == ["nut"] assert [s["key"] for s in hub.states("ups")] == ["nut"]
async def _wait_for(predicate: Callable[[], bool], deadline: float = 10.0) -> bool:
"""Poll `predicate` on the loop until it holds or the deadline passes.
The two maintenance tests below cross a thread boundary (`anyio.to_thread.run_sync`, because a
SQLite write must not stall the event loop), so *when* the first pass lands is a property of the
machine and of whether coverage is tracing every line — a fixed `asyncio.sleep()` fitted to a
quiet laptop is a flake on a loaded one. Waiting for the thing itself is the assertion; the
deadline only stops a broken loop hanging the suite.
"""
loop = asyncio.get_running_loop()
end = loop.time() + deadline
while loop.time() < end:
if predicate():
return True
await asyncio.sleep(0.02)
return predicate()
async def test_maintenance_failure_is_logged_not_swallowed(store, monkeypatch, async def test_maintenance_failure_is_logged_not_swallowed(store, monkeypatch,
caplog: pytest.LogCaptureFixture) -> None: caplog: pytest.LogCaptureFixture) -> None:
"""A broken rollup means every chart past the raw window is empty. Retrying quietly """A broken rollup means every chart past the raw window is empty. Retrying quietly
@@ -220,12 +238,12 @@ async def test_maintenance_failure_is_logged_not_swallowed(store, monkeypatch,
task = asyncio.create_task(hub._maintain_loop(interval=0.02)) task = asyncio.create_task(hub._maintain_loop(interval=0.02))
try: try:
with caplog.at_level(logging.ERROR, logger="dashboards.poller"): with caplog.at_level(logging.ERROR, logger="dashboards.poller"):
await asyncio.sleep(0.15) logged = await _wait_for(lambda: "store maintenance failed" in caplog.text)
finally: finally:
task.cancel() task.cancel()
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await task await task
assert "store maintenance failed" in caplog.text assert logged, "a failed rollup must be logged, never retried in silence"
assert "no such table" in caplog.text, "the log line must carry the real reason" assert "no such table" in caplog.text, "the log line must carry the real reason"
@@ -236,12 +254,12 @@ async def test_maintenance_runs_from_the_hub_and_records_its_pass(store) -> None
"pruned": 0} "pruned": 0}
task = asyncio.create_task(hub._maintain_loop(interval=0.02)) task = asyncio.create_task(hub._maintain_loop(interval=0.02))
try: try:
await asyncio.sleep(0.1) ran = await _wait_for(lambda: bool(calls))
finally: finally:
task.cancel() task.cancel()
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await task await task
assert calls, "the maintenance loop must actually call maintain" assert ran, "the maintenance loop must actually call maintain"
async def test_events_written_by_a_job_land_in_the_store(store) -> None: async def test_events_written_by_a_job_land_in_the_store(store) -> None:
Generated
+99 -91
View File
@@ -35,11 +35,11 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.10.19" version = "2026.10.23"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
] ]
[[package]] [[package]]
@@ -121,71 +121,79 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.16.2" version = "7.17.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, { url = "https://files.pythonhosted.org/packages/4d/e1/722462fe79912ebebc101497bd16c42413cde2dbae771017f1ec0acb84c2/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-22T16:05:09.846Z" },
{ url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, { url = "https://files.pythonhosted.org/packages/b7/74/826b9c09f2ebfea803bccb391d935688a25309bff8e819fc446bc629c108/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11.422Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, { url = "https://files.pythonhosted.org/packages/6d/19/c8514d7f5e0659a96ff2bc7377119dca6d420e2761c5fc69ae37618734f8/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12.761Z" },
{ url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, { url = "https://files.pythonhosted.org/packages/81/74/ef9433ce599f07d8633b4ce6e6d6921505c4ef3f7f9006f8eb66e2c278cf/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95", size = 225787, upload-time = "2026-10-22T16:05:14.394Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, { url = "https://files.pythonhosted.org/packages/d8/66/26e58981940e8b3944c6a9941bec5c7f50840e7009a046712e3f3bcd9e6a/coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85", size = 226019, upload-time = "2026-10-22T16:05:15.798Z" },
{ url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, { url = "https://files.pythonhosted.org/packages/e9/63/fe175310815cc8357b83b773c9b4c20943c269f217dd825a37c4b4bfe324/coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e", size = 226169, upload-time = "2026-10-22T16:05:17.188Z" },
{ url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, { url = "https://files.pythonhosted.org/packages/2c/b0/ac84761f5b4bd226696b0ec4606903d2ef7208abda0aec06c14a4218def4/coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7", size = 258092, upload-time = "2026-10-22T16:05:18.691Z" },
{ url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, { url = "https://files.pythonhosted.org/packages/e9/58/d8bdd65a661913bc12eaaa3e59742b2cb8b94f2e7f78e62184f0a58ca493/coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f", size = 261096, upload-time = "2026-10-22T16:05:20.089Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, { url = "https://files.pythonhosted.org/packages/7b/15/5d82e2f0a75ea149e0a6412e137e1d610538772fdfd7199737db7fe234da/coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33", size = 262080, upload-time = "2026-10-22T16:05:21.568Z" },
{ url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, { url = "https://files.pythonhosted.org/packages/0f/cc/e3ebfd295d1dd47c7bc84a17d73f150465a37ab388dd68fe82b495dc79a0/coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e", size = 264558, upload-time = "2026-10-22T16:05:22.906Z" },
{ url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, { url = "https://files.pythonhosted.org/packages/e0/9d/e2adff76d6544bbc6df7366d74967923c2de37ba889a006bfb875bf5857b/coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274", size = 257855, upload-time = "2026-10-22T16:05:24.343Z" },
{ url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, { url = "https://files.pythonhosted.org/packages/d9/fd/ecd1e9cddf1c36b5d03c438568b1f53e63db84bb3656539d71b454484c7d/coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2", size = 259933, upload-time = "2026-10-22T16:05:25.79Z" },
{ url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, { url = "https://files.pythonhosted.org/packages/40/b6/e17a19dccdbcad494c64691f71c1dba3e55c30625bc29ffbb88ac5a0aa8b/coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76", size = 257972, upload-time = "2026-10-22T16:05:27.185Z" },
{ url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, { url = "https://files.pythonhosted.org/packages/6a/0d/37dac534709a3fa43a937c8b0604839bb44b8670b83d143a4b78fec20d1e/coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048", size = 262271, upload-time = "2026-10-22T16:05:28.619Z" },
{ url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, { url = "https://files.pythonhosted.org/packages/cb/17/7c05186925b4d64a46c271dfd944e3f0c1ad89db9095c31a6df157a75df2/coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c", size = 257604, upload-time = "2026-10-22T16:05:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, { url = "https://files.pythonhosted.org/packages/95/65/c241cdee66a0ff86c68f6d37328e735d10e3d30cd6ff66f356039eb688d2/coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e", size = 259714, upload-time = "2026-10-22T16:05:31.402Z" },
{ url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, { url = "https://files.pythonhosted.org/packages/db/09/e93233fe2405574ce0a31ed201c0cba3a860e085db5d2828bf12b7dc0790/coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657", size = 227914, upload-time = "2026-10-22T16:05:32.78Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, { url = "https://files.pythonhosted.org/packages/d7/77/04979eb217edb94b94afabb4bcc179f60c746f68d3fd0b12688ba9195730/coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709", size = 228543, upload-time = "2026-10-22T16:05:34.118Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, { url = "https://files.pythonhosted.org/packages/a0/38/155f2f2c6769f8c43eca8a5e581443faa0908828650090b97103b4a41376/coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23", size = 228396, upload-time = "2026-10-22T16:05:35.951Z" },
{ url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, { url = "https://files.pythonhosted.org/packages/52/55/d6751572d9445485c4c5a51ec6ae11caa5c11b7ef2448cdccc884f7f54d9/coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c", size = 226486, upload-time = "2026-10-22T16:05:37.363Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, { url = "https://files.pythonhosted.org/packages/2f/a8/82d37760e537bfdb9fdee285fa1e1b45bc153293d23e2331c7befedd1990/coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4", size = 226861, upload-time = "2026-10-22T16:05:38.777Z" },
{ url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, { url = "https://files.pythonhosted.org/packages/24/96/70ec00a9db6d7bdd3f914eba12057360769f5cb78a98456e71fe1937deb5/coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8", size = 266849, upload-time = "2026-10-22T16:05:40.285Z" },
{ url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, { url = "https://files.pythonhosted.org/packages/77/d8/d99be3bfbb208512f039c80b31e404138a86678fc929a17acd5b7135a6ae/coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e", size = 269402, upload-time = "2026-10-22T16:05:41.585Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, { url = "https://files.pythonhosted.org/packages/82/50/6cbe8b5a5060edbd21ce8109c4e900f8b7656e8b72ede6dc4d82dc0b8bfb/coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764", size = 272025, upload-time = "2026-10-22T16:05:42.921Z" },
{ url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, { url = "https://files.pythonhosted.org/packages/bd/05/d3633add01e5518dfdacfdbd1be41cb5c581412dd8bccd663edf6ef7fec8/coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88", size = 273515, upload-time = "2026-10-22T16:05:44.324Z" },
{ url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, { url = "https://files.pythonhosted.org/packages/57/d9/b83387780271bf9ea9f96a3aeb01c1c302c4494a927923c4fa70cd170a3f/coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf", size = 266725, upload-time = "2026-10-22T16:05:45.713Z" },
{ url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, { url = "https://files.pythonhosted.org/packages/e4/be/8e80b29764ae3f4d70d44952eee3f3927eb36897490fe799e400adb7f5da/coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c", size = 269312, upload-time = "2026-10-22T16:05:47.054Z" },
{ url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, { url = "https://files.pythonhosted.org/packages/b8/c9/e702f588fe8d83ca844f133270ad0a49bf650c8cf260551ff6f11a6b6bbf/coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b", size = 266740, upload-time = "2026-10-22T16:05:48.343Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, { url = "https://files.pythonhosted.org/packages/8a/11/ee13ad14b714d75ef07c490b6e43969574a8b8b8c6d67bc741fcbacbf9c3/coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2", size = 271036, upload-time = "2026-10-22T16:05:49.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, { url = "https://files.pythonhosted.org/packages/e2/bc/854e8912c11c54aaf416e74602000589c6efbbe1f251dc90202fdabddcb6/coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c", size = 266158, upload-time = "2026-10-22T16:05:51.141Z" },
{ url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, { url = "https://files.pythonhosted.org/packages/a8/dd/96432830ddf63bc42654de54eee77a0cd6949cc65769bda06f353323f23a/coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f", size = 268088, upload-time = "2026-10-22T16:05:52.92Z" },
{ url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, { url = "https://files.pythonhosted.org/packages/ce/ee/230c6ec065a6629d41cc89323ceb54d37e2650c104f70fc108f4ee7c5051/coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa", size = 228229, upload-time = "2026-10-22T16:05:54.255Z" },
{ url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, { url = "https://files.pythonhosted.org/packages/f0/1d/23eb6aadcb3c00764ff2961c0ff55f5dfa2543856d932b949bbc04fa68e9/coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673", size = 229154, upload-time = "2026-10-22T16:05:55.682Z" },
{ url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, { url = "https://files.pythonhosted.org/packages/3e/f1/7df117e8e016b7a3ee459e833b3456f3610505afefd071f8727702d98439/coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74", size = 228726, upload-time = "2026-10-22T16:05:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, { url = "https://files.pythonhosted.org/packages/9f/a1/f9c24911755f91e6f451ca2e2c1804fbcbe9fecbc3310e341bbbbfa1d85b/coverage-7.17.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:adabc1079ba844b096799ba8638d8df434aca1613f95bb7d5791af320cf05559", size = 227196, upload-time = "2026-10-22T16:05:58.543Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, { url = "https://files.pythonhosted.org/packages/b0/a5/2b665b40c626e6bb40e5e8e23d91170b48b5f2f57186f46b07f2fac42386/coverage-7.17.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:6b4593d2871c4c801656dd5861e759f8f5156febd6b6061b02f6a72d64618b7b", size = 227315, upload-time = "2026-10-22T16:06:00.409Z" },
{ url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, { url = "https://files.pythonhosted.org/packages/3d/8c/22b518d61b930b8a4d62905d046c76a03675ef19b466462011dfb16439a3/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c2c48a36bb970b7265b196032bfd4237c0b373f2b25ef7012d7e905b5b35c19c", size = 225490, upload-time = "2026-10-22T16:06:01.743Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, { url = "https://files.pythonhosted.org/packages/08/5b/d3b141455944e030cd4987f055f8c84a44a1a0b28d81ae93389f44051691/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f8bc2cd4c6aa58be1ca243fb8cddb6c3c42031fe4403991ad9cf2b3baed03d9f", size = 225797, upload-time = "2026-10-22T16:06:03.423Z" },
{ url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, { url = "https://files.pythonhosted.org/packages/03/24/aab2156983f7022361352640c583d08f0238e4cd7463eb921172001b4cfe/coverage-7.17.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cbf0b83ce927de6053279ba61342d9f8892afde1385fcba9144cd05d4023d109", size = 226025, upload-time = "2026-10-22T16:06:04.76Z" },
{ url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, { url = "https://files.pythonhosted.org/packages/87/db/94697b9ccf4ec94cdad07bf94e2a21b9702247cb212d2d5f18d2b9c3d0de/coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:66efc0fb99cee34626fc17d3adac9c3cd816bfdad563bb1e983c3ef432f566d4", size = 226193, upload-time = "2026-10-22T16:06:06.167Z" },
{ url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, { url = "https://files.pythonhosted.org/packages/a0/fd/db1c4ade4219831d590fff5aa5b7d9ceaf84586e8e60da6fff71b6182251/coverage-7.17.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58a304efdf2253fb2ea03d45d0a04cc174a92a0cd5279c28c5bb893ce7410968", size = 258046, upload-time = "2026-10-22T16:06:07.51Z" },
{ url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, { url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c", size = 261735, upload-time = "2026-10-22T16:06:08.861Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, { url = "https://files.pythonhosted.org/packages/64/ad/c1e4367bbd1e7d562d6bc979c82b542f2040544e95a8cd995b694caf0e1a/coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6cdb47a9be2d3313829bed1d296086287682a3ee901ff987b3432b45705b771", size = 262819, upload-time = "2026-10-22T16:06:10.49Z" },
{ url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, { url = "https://files.pythonhosted.org/packages/b5/3a/e8e8509b0940403eab15d86f70022790a01fce3e971a161919c544953f7a/coverage-7.17.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:804fbaf81d09b34e2f8b1159d01e211a50713d743d32fd9b2ca6cf0ae507a66f", size = 265194, upload-time = "2026-10-22T16:06:11.847Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, { url = "https://files.pythonhosted.org/packages/1f/77/d64bbdbb734b1059ebcde2b5df567f142a9948079e51af9b0abf8c9ec17b/coverage-7.17.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2afebb96da9a1bbca142f8cc12320c14e5c215e0a49cded4f191f5a31506a28", size = 258410, upload-time = "2026-10-22T16:06:13.234Z" },
{ url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, { url = "https://files.pythonhosted.org/packages/e2/29/7cf81f08a254532c074eb52cfe6b12bb753d08f2997269c9fac8de359541/coverage-7.17.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd04fd23206be9ad3de13b0b39313094fafa66a1f6baf70f5471c004a9fac47d", size = 260697, upload-time = "2026-10-22T16:06:14.595Z" },
{ url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, { url = "https://files.pythonhosted.org/packages/7f/56/bacea6dee2d5ccb31ab0cfffcee87ca7efa80ff048840209c8013aeaf02e/coverage-7.17.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:5ebfbc87836529a31d25c2dec1262c38eea852ec1c46c0f690a515a7339d9c5b", size = 258649, upload-time = "2026-10-22T16:06:16.265Z" },
{ url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, { url = "https://files.pythonhosted.org/packages/a4/97/fd8a6a63740f9ee177c53b59e2f4d0752369bb0fd154086a1e69aa1fb255/coverage-7.17.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a59c3b470ac3b2432af3598a996affbeb10236cd899a005c1c4e517fcff8b592", size = 262989, upload-time = "2026-10-22T16:06:17.85Z" },
{ url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, { url = "https://files.pythonhosted.org/packages/c1/37/7cd3d3520d45db3788456452b65446182c08f2a7c276f40c9dd11966be02/coverage-7.17.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:7fef12cec4b96f59e09761cbd4fc9bdf4788062d1ae435b920241fc16c8a9549", size = 257955, upload-time = "2026-10-22T16:06:19.563Z" },
{ url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, { url = "https://files.pythonhosted.org/packages/c1/b9/80343a55e07d854927a915cc21a52e350ae1db9ead33f0248507b960785f/coverage-7.17.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a14e8d69464da9cbe1dc29e7116dbd37296d1d51a695327c478e250349085089", size = 260512, upload-time = "2026-10-22T16:06:21.055Z" },
{ url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, { url = "https://files.pythonhosted.org/packages/f3/32/e4c816f0cb3efdc6392bb276ff88359729f9dec605bdd3c1c7a44776f759/coverage-7.17.0-cp315-cp315-win32.whl", hash = "sha256:9a66074231799d8884d267234d49f60c90b43a945dfe069646b72f3058cd4e67", size = 227913, upload-time = "2026-10-22T16:06:22.407Z" },
{ url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, { url = "https://files.pythonhosted.org/packages/43/14/283120142ac281834808584249b89957de997c28573ddd65ae106b622121/coverage-7.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:79cf71e5f9a48877ccfed5baa2c72f45af4feeaa596249e23eb15b473134830e", size = 228532, upload-time = "2026-10-22T16:06:23.756Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, { url = "https://files.pythonhosted.org/packages/b9/bc/83dbc6c3bd58042afaefcb0cfd7d7a2e3076989a2f3d9bc6e6edac819784/coverage-7.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:09e563b49c81cd0a33734e926d7a4a6ef1778d74304cde2dcc3aff8b30ea1036", size = 228393, upload-time = "2026-10-22T16:06:25.163Z" },
{ url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, { url = "https://files.pythonhosted.org/packages/00/ed/9463d1cb1766a2570343a372c2871c7fb4d561e5051f029399f4d57ce947/coverage-7.17.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a5e2e0625dfc789af0b2e10acfc106b352f314fffbd19e6392a7961c8cbe5d9a", size = 226481, upload-time = "2026-10-22T16:06:26.563Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, { url = "https://files.pythonhosted.org/packages/ae/59/6536c7fa87be13236b4184fc395f14d18f622a5b8ef7448a6bfdf5f24ec0/coverage-7.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:194562ab1eff0d85aee8a17ce4c5c0bc1ebf70a629a7145257123156ad100fd6", size = 226876, upload-time = "2026-10-22T16:06:27.921Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, { url = "https://files.pythonhosted.org/packages/b0/bc/4ffae6038f78382c9ab6a4969cb5fc01d50b083d119851085807629abe88/coverage-7.17.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:85682ca89bc4601125dcdff23fa70e6274e13c9ad502fba845e6c274ab2c6569", size = 267241, upload-time = "2026-10-22T16:06:29.356Z" },
{ url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, { url = "https://files.pythonhosted.org/packages/c0/60/cae4b708b4217d33ebf66b9c57f8910a257346b3eaf054a78d73605ddc4c/coverage-7.17.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0d0fac604a962ff546beb038cba9a5a09d88e6e3f01612fb46f7bd4fe5dc8dca", size = 270079, upload-time = "2026-10-22T16:06:30.736Z" },
{ url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, { url = "https://files.pythonhosted.org/packages/a9/84/1225ab04e55a6a2e483e903a5eb0f8f081cb5325df833594534aa288d91c/coverage-7.17.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72edd4f9483ae30cd1ba4f230687840ef7b58a16afb1c9e81131fd6d64655f36", size = 272775, upload-time = "2026-10-22T16:06:32.247Z" },
{ url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, { url = "https://files.pythonhosted.org/packages/94/ad/567f8dd9246a553aa8753732c41f0e0654e98e9a617d6282e64eae0e6aa8/coverage-7.17.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91e487ad07c511da77b574820141a7c2da09bc2a4b4090b5be2424295d100697", size = 274110, upload-time = "2026-10-22T16:06:33.859Z" },
{ url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, { url = "https://files.pythonhosted.org/packages/66/5f/e0ee76895df40601e4aedffb6b8e71ea48e276a629fb4488b92cffffca14/coverage-7.17.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1cabb31531002fef5197de5df3c84730aef01e750e25154b9813a2583435aba2", size = 267775, upload-time = "2026-10-22T16:06:35.193Z" },
{ url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, { url = "https://files.pythonhosted.org/packages/19/6d/d19c6e45e940e5b9ba697b7be9a8e63cef73d90bdea45b670a59df466717/coverage-7.17.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c9ac886242bbd50fb35a2242ba79555a99891e657da33f98b7ca968e3488a350", size = 270268, upload-time = "2026-10-22T16:06:36.597Z" },
{ url = "https://files.pythonhosted.org/packages/27/a3/b2731bb7aef2db800b330603675c3fa58a6497a05d5b5f8625f3b6285f68/coverage-7.17.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d45cdc495671fc99268ff3653e0da4244972e9fc0e1fc3ea9de18781ad28963a", size = 267298, upload-time = "2026-10-22T16:06:37.973Z" },
{ url = "https://files.pythonhosted.org/packages/50/75/d0c90a81b6b6607933109914dc8b0ef0a5f4cacf9482ca868ae773ee5d0b/coverage-7.17.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:12961f738d717a046d4bf3f8d1727eb3ebe2ed5478ff96cd6233d4dfc39323d3", size = 271899, upload-time = "2026-10-22T16:06:39.389Z" },
{ url = "https://files.pythonhosted.org/packages/64/1e/f038f3a2614e9ef839a40a184713aca40b8ed79e626ab5cdc388771045df/coverage-7.17.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1ee0a982b0e1230b3edf768a7dd022bd2377303763895327ac62deb5efacee03", size = 267380, upload-time = "2026-10-22T16:06:40.97Z" },
{ url = "https://files.pythonhosted.org/packages/c9/30/8d636e639b2d8b1934128573402a7caf66c8cfbc794fa02061d84f115c34/coverage-7.17.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:460679f53893081edb94eeed011d26be37228ba649b853fd2aed7e4394fb1f35", size = 268993, upload-time = "2026-10-22T16:06:42.361Z" },
{ url = "https://files.pythonhosted.org/packages/73/7a/219f6b391cb0636242d06c390a8add5af91afa3e92afd0380dbedd34f487/coverage-7.17.0-cp315-cp315t-win32.whl", hash = "sha256:600e121e412cd6bdb52ec105b95c6a04da0c9710d1534f6e64a42db667907802", size = 228216, upload-time = "2026-10-22T16:06:43.714Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3f/514d294319c5eac11494394545de209079f33993e14526c220eab29b2a96/coverage-7.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c3e11b81faafb1f063248735c3e2c5c4183fc1db2c7f46782170dd7760dffcbc", size = 229146, upload-time = "2026-10-22T16:06:45.262Z" },
{ url = "https://files.pythonhosted.org/packages/f6/82/c0d8bb0a67df838fc7bc6f4060ffc404fd1154fd18e624d83e6abc41aeef/coverage-7.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d472ee1c64868a917a47f02ffb4ac8c22b477f7589d57a3eb0c58b1ad4f0137c", size = 228716, upload-time = "2026-10-22T16:06:47.097Z" },
{ url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" },
] ]
[[package]] [[package]]
@@ -313,7 +321,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.153.0" version = "0.162.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -323,9 +331,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0a/69/8461d4c25490b5efe65b2178c857273785bd8b42a5555264b6b4f9bf2ed0/fastapi-0.153.0.tar.gz", hash = "sha256:5dbf5dcf6cf86388326c147566077c56a14c55b0f1bef370195da98e724a503f", size = 489663, upload-time = "2026-10-20T13:47:40.029Z" } sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/41/c2c2c10be9068304a522dd64893916b5b21549af084ea942c26388aaa5b0/fastapi-0.153.0-py3-none-any.whl", hash = "sha256:77fbc9f41d2e5cd81f0c0a20482e627f8ebbb9b9005ac7586e415ee69dfea4ec", size = 146107, upload-time = "2026-10-20T13:47:38.295Z" }, { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" },
] ]
[[package]] [[package]]
@@ -484,11 +492,11 @@ wheels = [
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "3.11" version = "3.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" },
] ]
[[package]] [[package]]
@@ -679,27 +687,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.17.1" version = "0.17.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, { url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, { url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" },
{ url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, { url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" },
{ url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, { url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" },
{ url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, { url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" },
{ url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, { url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" },
{ url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, { url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, { url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" },
{ url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, { url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, { url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, { url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" },
{ url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, { url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, { url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" },
{ url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, { url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" },
{ url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, { url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, { url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" },
{ url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" },
] ]
[[package]] [[package]]