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