phase: 31_gateway_route_readability
**Phase 31 verification complete — all 5 tasks already in `complete/`; this was the final verification pass.**
Verified / changed
- Re-ran every phase-31 artifact live and green: `verify-31-page-only.py` 6 PASS/0 FAIL; browser pass `31-route-readability.cjs` **64 PASS / 0 FAIL** (exit 0); `27-task02-mobile-audit.cjs` **371 PASS / 0 FAIL / 0 FINDING** (exit 0); rebuilt `data/logs/31-route-readability.out` as the 4-part transcript.
- Added the 4 degradation-arm screenshots the criterion asked for but the pass lacked (`31-gateway-{unconfigured,unreachable,no-nginx,sockets-quiet}.png`) — 12 frames now, one per arm.
- Grep criteria: no gateway connector rule uses `border-strong`; no verdict token in the gateway CSS section or `page-gateway.js` beyond the `alerts.py`-owned `.warn`; exactly 4 `addEventListener` calls (none for the switch); `routeInConfig()` returns `true`; **no `.py` under `src/dashboards/` changed**.
Tests / lint (all 100% green)
- `uv run pytest` → **1796 passed, 16 skipped**; `--cov` → **98.94%** (floor 94); `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `bash .agents/validate.sh` → **exit 0**.
- `uv run pytest -m live --live` → **12 passed / 0 failed**; `./scripts/dev-server.sh status` → all **10 sources ok**.
Completion criteria: colour (33/33 rows every segment `--gate-on`, dark `rgb(63,185,80)` + light `rgb(26,127,55)`, zero grey dashes) ✔ · inbound-first rows + filter (33/33 line 1 = `out.selectors`, line 2 = `→ host:port · kind`; `stackexpected` → **6** rows, not the overview's "five" — the capture declares 6 such routes; tallies byte-identical `10/33/16/0`, zero requests) ✔ · trunk motion truth table + `null` arm prints `n/a` never `0` + reduced-motion stills 43 paths and every knob ✔ · screenshots/transcripts ✔ · payload page-only ✔ (literal byte-diff of a live socket table can never pass; answered by the no-`.py`-diff + shape-equality proof, as recorded in the transcript) · no behaviour change elsewhere ✔.
Docs: fixed 6 inconsistencies — two identifiers split across line breaks (`test_the_pipe_animation_stills_in_both_states_under_reduced_motion`, `32_gateway_resolved_target_liveness`), stale "sixteen routes" count, stale "four machines/hosts" in README **and** AGENTS (fleet is three), "three fields"→"three field types", gap-14 headline wording; updated the pass's screenshot list.
Deviations: `uv pip list --outdated` shows one advisory (`starlette 1.9.0 → 1.10.0`) — not upgraded, this phase moved no dependency (rule 8 / DoD apply "if deps moved"). `TODO.md` carries two new operator disk requests, left untouched.
Next pending phase: `32_gateway_resolved_target_liveness`.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Phase 31 — The gateway route is green end to end, and the row says *which domain*
|
||||
|
||||
**Source:** operator request, 2026-10-23 (post-phase-30 review of `/gateway`) — "The only animated line is `:443 -> 127.0.0.1:4443`. I can't tell which `reeseapps.com` or `stackexpected.com` addresses flow to their reeselink counterparts (very important). Why are the dotted lines on the left grey? Shouldn't those be green too? They're active."
|
||||
**Story:** n/a
|
||||
**Context:** `src/dashboards/web/static/dash.css` (the `gateway pipes` section: `.pipe-path`, `.gate-link::before`/`::after`, `.gate-elbow`, `.gate-trunk`, `.gate-target`, `.gate-selector`, `.pipe-name`), `src/dashboards/web/static/page-gateway.js` (`leafRow`/`renderLeaf`/`groupElement`/`renderTrunk`/`selectorText`/`leafTitle`/`leafAria`/`endpointText`), `src/dashboards/web/pages/gateway.html` (the two panels, the four tallies, the three footer paragraphs), `src/dashboards/sources/gateway.py` (**read-only for this phase** — `connections[].out.{host,port,name,kind,selectors,address}`, `listeners[].{connections,clients,domains,metric}`), `tests/test_api.py` (the gateway page pins: `GATEWAY_PAGE_FIELDS`, `test_a_clipped_pipe_label_keeps_its_full_text_on_the_element`, `test_a_pipe_moves_only_while_it_carries_a_connection`, `test_the_pipe_animation_stills_in_both_states_under_reduced_motion`, `test_no_gateway_count_is_coloured_by_a_verdict`, `test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero`, `test_every_pipe_is_an_accessible_image_built_from_the_payload`), `data/logs/27-task02-mobile-audit.cjs` (the nine-page sweep that must still exit 0).
|
||||
|
||||
## Objective
|
||||
|
||||
`/gateway` answers the operator's actual question — *"which inbound domain flows to which counterpart?"* — from the row itself, and **every route a config declares is green from its trunk to its endpoint** instead of grey for two thirds of its length. The spine and the tick stop being structural ink and take the same existence pair as the run and the switch; the leaf row leads with the config's **inbound** word (`brain.reeseapps.com`) and puts the counterpart (`reeseapps-brain-of-reese.reeselink.com:443`) under it; the trunk draws its own short line that moves on the one live number the kernel genuinely reports for that **port** (`listeners[].connections`), so the page is not dead ink while a name target waits for phase 32; and one filter box finds a domain among the 33 routes without inventing a taxonomy.
|
||||
|
||||
This phase touches **no Python**: the payload already carries every string and every number these four changes need. `sources/gateway.py` is deliberately left byte-identical so phase 32's join rewrite (`todo/32_gateway_resolved_target_liveness`) lands on an unchanged surface.
|
||||
|
||||
## Scope decisions taken with the operator (binding for this phase)
|
||||
|
||||
| # | Decision | Consequence |
|
||||
|---|----------|-------------|
|
||||
| 1 | **Colour is still existence, and the whole route carries it.** | `.gate-link::before`, `.gate-link::after` and `.gate-elbow` take `--gate-on` / `--gate-off`, not `--border-strong`. `on`/`off` moves onto the `<li class="gate-link">` so **one** class paints every segment of one row (spine halves, tick, run, switch) and a row can never show a green run hanging off a grey tick. The base rules declare `transparent`, so a row that is built and never rendered still draws nothing — the `.pipe-path`-declares-no-`stroke` rule, restated for borders. `severity.py` still has no `gateway.*` row; no verdict token enters this section. |
|
||||
| 2 | **Motion stays the per-row run's claim and nothing else.** | The spine segment between two rows is **shared** by both leaves, so a dash travelling down it cannot be attributed to one route — animating it would put a per-route claim on a per-list structure. Motion therefore remains "≥ 1 ESTABLISHED socket on this pipe right now", carried by `.pipe.live`, and the CSS comment says why the connectors get colour and not motion. |
|
||||
| 3 | **The row reads inbound → counterpart, both verbatim.** | `.gate-target`'s two lines swap rank: line 1 is the config's inbound word (`out.selectors`, falling back to `out.kind`), line 2 is `endpointText(out)` with a leading `→`. Neither string is edited, truncated in data, or reordered by the page; `title` and the pipe's `aria-label` carry **both** in full (gap 13). |
|
||||
| 4 | **The trunk moves on the port's own real number.** | The trunk draws a short `.pipe.pipe-trunk` that is `live` when `listeners[].connections >= 1` and `still` when it is `0`, and gets **no** motion class when the socket table did not answer (`null` — never treated as `0`, never printed as `0`). Its `aria-label` says `N connections arrived on :PORT in this reading`: it is a fact about the port, and it never implies which leaf carries them. |
|
||||
| 5 | **The filter is a client-side eye, not a query.** | One `<input type="search" id="gate-filter">` over rows **already in the payload**: matches on the inbound word(s), the target, the port and the haproxy `server` name; hides non-matching rows and the groups left empty; re-applies inside the existing patch pass (no rebuild, no re-fetch, no new endpoint); Escape clears it; the warnings panel and the header tallies are **never** filtered. |
|
||||
| 6 | **The page draws no zones.** | No grouping, sorting or colouring derived from a domain suffix. `reeseapps.com` vs `stackexpected.com` is the operator's own typing into the filter, not a payload key — a page that split by suffix would be inventing a fact the two config files never stated (rule 2). |
|
||||
| 7 | **No Python, no payload, no agent, no write.** | Phase 31 is CSS + page JS + markup + tests + docs. The verb-enumeration pin still finds `/api/ups/command` as the app's only upstream write, `routeInConfig()` still answers `true` unconditionally, and the switch stays unwired — **phase 33 is what wires it**, and this phase must not half-start it. |
|
||||
| 8 | **The `—` keeps its meaning and loses its mystery.** | The footer's second paragraph says what a dash is *in the operator's terms* (this endpoint is written as a name and the page matches sockets by address) instead of restating the join's internals. Phase 32 removes the reason; until then one honest sentence, not a column of unexplained dashes. |
|
||||
|
||||
**Out of scope:** any change to `sources/gateway.py`, `api.py`, `poller.py`, `alerts.py` or `agent/host_agent.py`; DNS resolution and any live count on a **name** endpoint (phase 32); any config write, toggle handler, `POST` route or privileged helper (phase 33); the `--gate-*` pair's definition (it stays a substitution of `--ok`/`--fail` in `:root`); window chips (the page keeps one fixed 24 h window); any new dependency.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `30_gateway_connection_map` (complete) — the two panels, the trunk→leaf rows, the `--gate-on`/`--gate-off` pair, the unwired switch, the `connections[]`/`listeners[]` payload this phase re-labels and re-colours. This phase **corrects** that phase's decision-1 rendering (the connectors were left `--border-strong`) and restates its colour pins; it deletes no claim.
|
||||
- `29_gateway_dashboard_page` (complete) — the page shell, the tallies, the chart disclosures, the degradation arms.
|
||||
- `27_mobile_tables_and_audit` (complete) — `data/logs/27-task02-mobile-audit.cjs` must still exit 0 with `/gateway` in its nine-page sweep, at 360 px and 1280 px, after the row re-rank and the filter's arrival.
|
||||
- **Forward, not blocking:** `32_gateway_resolved_target_liveness` (name → address resolution, so the leaf lines animate) and `33_gateway_route_switching` (the config-write path that gives the switch a handler). Both are written after this one; nothing here pre-judges their shape beyond decision 7.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_the_whole_route_is_green.md` — `dash.css` + `page-gateway.js`: `on`/`off` on the row, the spine/tick/run/switch coloured from one class, base borders `transparent`, the verdict-vocabulary pin restated.
|
||||
2. `02_the_inbound_word_leads_the_row.md` — `page-gateway.js` + `dash.css` + `gateway.html`: the leaf's two lines swap rank (inbound word first, `→ counterpart` second), `title`/`aria-label` carry both, the `—` footer sentence rewritten, `GATEWAY_PAGE_FIELDS` and the clip pin updated rather than relaxed.
|
||||
3. `03_the_trunk_moves_on_a_real_number.md` — `page-gateway.js` + `dash.css`: the trunk's own short pipe, `live`/`still`/neither from `listeners[].connections`, its accessible sentence naming the port, reduced-motion named.
|
||||
4. `04_find_a_domain.md` — `gateway.html` + `page-gateway.js` + `dash.css`: `#gate-filter`, client-side match set, groups emptied by the filter collapse to their own "no route matches" state, an `N of M` chip, Escape to clear, no fetch.
|
||||
5. `05_docs_and_live_verify.md` — `AGENTS.md` (the `/gateway` UI-conventions paragraph, the metric-naming/`sources/gateway.py` passages if their wording about colour and labels went stale, gap 13) + `README.md`, the browser pass at 1440 px and 360 px with screenshots, the mobile-audit re-run, and a read-only live check against the real ingress.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Served-byte pins in `tests/test_api.py`, **restated rather than deleted** wherever a class or line-rank moves: `test_no_gateway_count_is_coloured_by_a_verdict` keeps every "no verdict vocabulary here" assertion and gains "the connectors are coloured from `--gate-on`/`--gate-off` and their base colour is `transparent`"; `test_a_pipe_moves_only_while_it_carries_a_connection` is restated to cover the trunk pipe's *different* source of truth (`listeners[].connections`) without letting it imply a leaf; `test_the_pipe_animation_stills_in_both_states_under_reduced_motion` gains the trunk pipe; `test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` is strengthened (both ends now, not just the target); `test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero` is unchanged in claim.
|
||||
- **New** pins: `test_the_whole_route_takes_one_existence_class` (the `<li>` carries `on`/`off`; CSS colours spine halves, tick, run and switch from it; no connector rule reads `--border-strong`), `test_the_leaf_row_leads_with_the_inbound_word` (line 1 is `selectors`-derived, line 2 is the endpoint; the served field table resolves both), `test_the_trunk_pipe_is_an_accessible_image_of_its_port`, `test_the_filter_matches_only_payload_text_and_costs_no_request` (no `fetchJSON` in the filter path, warnings panel untouched, `null` counts never match a query as text `"null"`).
|
||||
- No new Python, so coverage cannot rise: the gate is that it does **not** fall (`--cov-fail-under=94` green) and that `node --check` passes on the changed script (`.agents/validate.sh` runs it).
|
||||
- Integration: the page renders from a fixture-driven dev store in every degradation arm the payload can produce (`unreachable`, `supported: false`, per-section `available: false/null`), with the filter empty, matching, and matching nothing.
|
||||
- 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: the `/gateway` UI-conventions paragraph in **both** files (the row now reads inbound word → counterpart; the connectors are existence-coloured; the trunk's line moves on the port's own count; the filter exists and what it may not do), the claim that the page's footer is three paragraphs (only if task 02 changes the count — it must not silently), and **gap 13** (the clipped-endpoint-label gap: task 02 shortens the primary line to the inbound word and task 04 makes long targets findable, so the gap is either closed with measurements or restated honestly with the new clip counts).
|
||||
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] On live data at 1440 px: **every** drawn route — spine halves, tick, run, switch — computes to the `--gate-on` colour in dark *and* light theme; no grey dashed segment remains anywhere in the connection map; measured, not asserted in prose.
|
||||
- [ ] The nginx `:443` fan-out reads as `brain.reeseapps.com` on line 1 and `→ reeseapps-brain-of-reese.reeselink.com:443` on line 2 for all of its leaves, and typing `stackexpected` into `#gate-filter` leaves only the five `stackexpected*` routes visible with the panel tallies unchanged.
|
||||
- [ ] The trunk pipe moves on a port the kernel reports ≥ 1 ESTABLISHED socket for and sits still on one it reports `0` for; with the socket-table arm stubbed to `null` it draws green, motionless, and prints no `0`. `prefers-reduced-motion: reduce` stops every animated thing on the page including the trunk pipe and the switch thumb.
|
||||
- [ ] `grep -n "border-strong" src/dashboards/web/static/dash.css` shows no gateway connector rule; `grep -n "severity\|--ok\|--warn\|--fail" ` over the gateway CSS section and `page-gateway.js` still finds only the warnings-panel `.warn` row class that `alerts.py` owns.
|
||||
- [ ] No `addEventListener` for the switch exists; `routeInConfig()` still returns `true`; `git diff` for this phase touches **no** file under `src/dashboards/` except `web/` — the verb-enumeration pin still lists `/api/ups/command` as the only upstream write.
|
||||
- [ ] `uv run pytest` (incl. `--cov` ≥ 94 floor), `uv run ruff check src tests agent`, `uv run pyright`, `node --check` on the changed script, `bash .agents/validate.sh` — all green.
|
||||
- [ ] `data/logs/31-*.png` screenshots at 1440 px and 360 px (dark, light, filtered, and each degradation arm) and `data/logs/31-*.out` transcripts; `data/logs/27-task02-mobile-audit.cjs` still exits 0 across all nine pages with 0 FAIL / 0 FINDING.
|
||||
- [ ] `GET /api/gateway/snapshot` on the live ingress unchanged byte-for-byte from before the phase (proof the phase was page-only), and `./scripts/dev-server.sh status` still reads all ten sources `ok`.
|
||||
- [ ] 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; the gateway charts, warnings panel, staleness dim and `404 {configured:false}` arm behave exactly as phase 29/30 left them.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Task 01 — One existence class paints the whole route: spine, tick, run and switch go green together
|
||||
|
||||
**Phase:** `31_gateway_route_readability` · **Source:** operator request 2026-10-23 — "Why are the dotted lines on the left grey? Shouldn't those be green too? They're active."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Every segment of a connection row — the vertical spine halves, the tick out to the switch, the animated run, and the switch pill — is coloured by the **same** existence class on the same row. Phase 30 shipped the `--gate-on`/`--gate-off` pair and applied it only to `.pipe-path`, leaving `.gate-link::before`, `.gate-link::after` and `.gate-elbow` on `var(--border-strong)`, so a route that the config declares green arrived as two thirds grey plumbing. This task closes that gap and pins it so the pair cannot drift back apart.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/dash.css` — the `gateway pipes` section only.
|
||||
- Base rules: **geometry, no colour.** A connector that has not been given a state draws nothing, which is the `.pipe-path`-declares-no-`stroke` rule restated for borders (an SVG with no `stroke` is invisible; a border's default colour is `currentColor`, so `transparent` has to be written down).
|
||||
```css
|
||||
.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 transparent;
|
||||
}
|
||||
.gate-link::after {
|
||||
content: ""; position: absolute; left: 0; top: 50%;
|
||||
height: calc(50% + var(--gate-leaf-gap) / 2);
|
||||
border-left: 2px dashed transparent;
|
||||
}
|
||||
.gate-elbow { align-self: center; height: 0; border-top: 2px dashed transparent; }
|
||||
```
|
||||
- State rules, one pair per family, driven by the **row's** class so one paint pass covers the row:
|
||||
```css
|
||||
/* The whole route is one claim, so one class paints all of it. `page-gateway.js` writes
|
||||
`on`/`off` on the <li>, and every segment of the row — spine halves, tick, the run inside
|
||||
its <svg>, the switch — reads the existence pair from that one class. A rule here may
|
||||
never reach for --border-strong (structure ink) or for --ok/--warn/--fail (a verdict):
|
||||
phase 30 decision 1 says green means "written in a proxy config", and a grey dashed
|
||||
segment beside a green run contradicts it on every row of the page. */
|
||||
.gate-link.on { --gate-line: var(--gate-on); }
|
||||
.gate-link.off { --gate-line: var(--gate-off); }
|
||||
.gate-link.on::before, .gate-link.off::before,
|
||||
.gate-link.on::after, .gate-link.off::after { border-left-color: var(--gate-line); }
|
||||
.gate-link.on .gate-elbow, .gate-link.off .gate-elbow { border-top-color: var(--gate-line); }
|
||||
```
|
||||
The `--gate-line` indirection is the mechanism: `.gate-switch` / `.gate-switch-thumb` / `.pipe.on` keep their existing declarations (so phase 30's pins on those selectors stay green), and the connectors get the same value from the row without a second pair of tokens in `:root`. If the executor prefers four explicit rules to one custom property, that is fine — the pinned contract is "the row's class colours the connectors", not the technique.
|
||||
- The **`live`/`still`** rules are untouched, and the section comment gains one sentence for decision 2: motion is the per-row run's claim because the spine segment between two rows is *shared* by both leaves — animating it would put a per-route claim on a per-list structure. Colour is what the shared segment is allowed to say.
|
||||
- `@media (max-width: 640px)`: the first row's restored `::before` inherits the colour from the same rule; the block gains nothing (no colour is restated per width — that is how the pair drifts).
|
||||
2. `src/dashboards/web/static/page-gateway.js`
|
||||
- `renderLeaf(li, conn)` writes the state onto the row as well as the `<svg>`:
|
||||
```js
|
||||
li.classList.toggle('on', on);
|
||||
li.classList.toggle('off', !on);
|
||||
```
|
||||
beside the existing four `svg.classList.toggle` calls, in the same pass, from the same `on` — one reading, two targets, no chance for a green run under a grey tick.
|
||||
- `leafRow()` builds the `<li class="gate-link">` with **neither** class, and its comment keeps `buildPipe()`'s sentence: a row built and never rendered draws nothing rather than drawing a confident green line.
|
||||
- `renderGroups()` must clear the classes wherever it clears a row: rows are removed wholesale (`rows.get(id).remove()`), so no extra pruning is needed — verify by reading, and say so in the comment rather than adding dead code.
|
||||
3. `tests/test_api.py`
|
||||
- **Restate** `test_no_gateway_count_is_coloured_by_a_verdict`: keep every existing 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, the one legitimate `.warn` being the warnings panel's `ul.alerts` row from `alerts.py`) and add that the connectors read `--gate-on`/`--gate-off` (directly or through `--gate-line`) and that **no connector rule names `--border-strong`** — with a message quoting the offender.
|
||||
- **New** `test_the_whole_route_takes_one_existence_class`:
|
||||
- served CSS contains rules selecting `.gate-link.on`/`.gate-link.off` that reach `::before`, `::after` and `.gate-elbow`;
|
||||
- the base `.gate-link::before` / `::after` / `.gate-elbow` rules declare `transparent` (so an unrendered row is invisible, not `currentColor`);
|
||||
- served JS sets `on`/`off` on the row element and on the `<svg>`, in the same function, off the same value;
|
||||
- `.gate-switch`'s colour still comes from `--gate-on` / `[aria-checked="false"]` (phase 30's pin, asserted again here so the two halves of the row cannot drift to two vocabularies).
|
||||
- `test_the_gateway_page_is_exactly_two_connection_panels_and_a_warnings_list`, `test_a_pipe_moves_only_while_it_carries_a_connection`, `test_the_pipe_animation_stills_in_both_states_under_reduced_motion` and the orphan-`id` audit must pass **unchanged** — this task adds no `id` and removes no element.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the served-CSS/served-JS pins above; the behavioural proof is task 05's browser pass (computed `border-left-color` / `border-top-color` of a spine, a tick and a run, in both themes).
|
||||
- Coverage: no Python changes; `node --check src/dashboards/web/static/page-gateway.js` clean; the coverage floor must not fall.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `grep -n "border-strong" src/dashboards/web/static/dash.css` returns no line inside the `gateway pipes` section.
|
||||
- [ ] In a browser on live data, at 1440 px: a `.gate-link::before`, a `.gate-link::after`, a `.gate-elbow` and a `.pipe-path` all compute to the same green (`--gate-on`) on the same row, in dark **and** light theme; a row given `off` in devtools turns all four red together.
|
||||
- [ ] A row built without a state class (temporarily strip `on` in devtools) shows no dashed ink at all.
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `node --check` clean; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the `/gateway` conventions sentence about what the colour covers — the phase's final pass re-checks both project-wide).
|
||||
- [ ] No behaviour change in completed work: switch stays unwired, `routeInConfig()` still returns `true`, no Python touched, other pages byte-identical.
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Task 02 — The row leads with the inbound domain and puts its counterpart under it
|
||||
|
||||
**Phase:** `31_gateway_route_readability` · **Source:** operator request 2026-10-23 — "I can't tell which `reeseapps.com` or `stackexpected.com` addresses flow to their reeselink counterparts (very important)."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Both ends of every route are legible on the row, in the operator's reading order: **line 1 is the config's inbound word** — the `server_name`/`map` key the operator types into a browser (`brain.reeseapps.com`), or the haproxy backend name, or the literal `default` — and **line 2 is the counterpart it flows to** (`→ reeseapps-brain-of-reese.reeselink.com:443 · map`). Phase 30 drew the target on the primary line and demoted the inbound domain to an 11px faint qualifier, which is exactly backwards from the question the page exists to answer.
|
||||
|
||||
No payload change: both strings are already in `connections[].out` (`selectors`, `kind`, `host`, `port`).
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/page-gateway.js`
|
||||
- Replace `selectorText(out)` with two pure text helpers, each returning the config's own text and nothing derived:
|
||||
```js
|
||||
/** Line 1: the config's own word(s) for who reaches this endpoint, verbatim and in full. */
|
||||
function inboundText(out) {
|
||||
const words = ((out || {}).selectors || []).join(' ');
|
||||
return words || (out || {}).kind || null;
|
||||
}
|
||||
|
||||
/** Line 2: the endpoint the config routes to, marked as the destination, plus its kind. */
|
||||
function counterpartText(out) {
|
||||
const kind = (out || {}).kind;
|
||||
return `${ARROW}${endpointText(out)}${kind ? ` · ${kind}` : ''}`;
|
||||
}
|
||||
```
|
||||
`ARROW = '→'` is a page constant. All selectors stay on line 1 joined by a space exactly as phase 30 joined them — a merged connection (two vhosts claiming one `(in_port, endpoint)`) therefore loses nothing, and the page never picks a "best" name (rule 2: the config's list is the fact, one of them is not).
|
||||
- `leafRow()` keeps the DOM shape — `.gate-target` with two spans — but the second span becomes `.pipe-end mono` (from `.gate-selector faint`) and keeps `hidden` while there is nothing to print. `.pipe-name` remains the *primary line of its row* (the trunk prints its port there, the leaf prints the inbound word), so every layout rule phase 27/30 earned about ellipsis and `max-content` keeps working untouched.
|
||||
- `renderLeaf()`:
|
||||
- `.pipe-name` ← `inboundText(conn.out)`;
|
||||
- `.pipe-end` ← `counterpartText(conn.out)`, `hidden = inbound === null` handling unchanged in spirit (a row with no selector still shows its endpoint as line 1 rather than printing `null` — state which arm that is in the comment: `connections[]` always carries `host`, so line 1 falling back to `kind` is the only empty case);
|
||||
- `leafTitle(conn)` returns the whole pair — `"<all selectors> → host:port"` (plus the kind and, when `shared`, the sharing sentence) — so whatever CSS clips, `title` carries both ends in full;
|
||||
- `leafAria(conn, on)` gains the same pair in words (`"connection from :4443 to nextcloud.reeselink.com:11000, reached by nextcloud.reeseapps.com, …"`) and keeps every existing fact: selectors, `server` name, `shared`, the state word, and `connections not attributed` vs `N live connections` (never `0`).
|
||||
- `endpointText()` is unchanged and stays the single place a target is spelled.
|
||||
2. `src/dashboards/web/static/dash.css`
|
||||
- Add `.pipe-end` beside `.gate-selector`, with the same truncation trio (`min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap`) and `font-size: 11px`, **mono** because both ends are hostnames and they should align down a fan-out. `.gate-selector` keeps its rule — the trunk's `N names` line still uses it.
|
||||
- `.pipe-name` keeps its `flex: 1 1 max-content` behaviour unchanged (gap 13's measurement: the name claims the line; a longer-than-column value alone gets the whole column). Update its comment: the leaf's name is now the inbound domain, which is the shorter of the two strings on this fleet — say so, and say the counterpart below it is the one that clips, with `title`/`aria-label` carrying it whole.
|
||||
- Nothing else changes: no new tokens, no new widths, and the `minmax(0, …)` tracks stay exactly as they are (phase 27's rule that a hostname may not widen the card).
|
||||
3. `src/dashboards/web/pages/gateway.html`
|
||||
- Footer paragraph 2 is rewritten to the operator's terms and nothing else changes in the file. It must say: a dash means this endpoint is written in the config as a **name** and the page matches sockets by **address**, so the route is drawn and counted nowhere — not that the route is idle. One sentence, no pipeline narration:
|
||||
> `a dash means the endpoint is written in the config as a name; the page matches sockets by address and resolves nothing, so this route is drawn but not counted.`
|
||||
- The card comments above `.gate-map` gain one line: the leaf's first line is the inbound name, its second is the counterpart (so the next reader does not re-swap them).
|
||||
4. `tests/test_api.py`
|
||||
- **Strengthen** `test_a_clipped_pipe_label_keeps_its_full_text_on_the_element`: the `title` assertion now demands **both** ends appear in full in the row's `title` *and* in the pipe's `aria-label` (inbound selector and `host:port`), not just the target. Its message names gap 13.
|
||||
- **New** `test_the_leaf_row_leads_with_the_inbound_word`:
|
||||
- served JS assigns `inboundText(...)` to `.pipe-name` and `counterpartText(...)` to `.pipe-end`, and neither call site passes `endpointText(out)` to the primary line;
|
||||
- `inboundText` reads `out.selectors` and falls back to `out.kind`, never to `out.host`;
|
||||
- every path the two helpers index resolves against a live `GET /api/gateway/snapshot` connection row (the `GATEWAY_PAGE_FIELDS` device, extended with `connections[].out.selectors`, `connections[].out.kind`, `connections[].out.host`, `connections[].out.port` so the table resolves in **both** directions and a rename on either side fails by name).
|
||||
- `test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero` stays green unchanged (the dash lives in `.pipe-count`, which this task does not touch).
|
||||
- `test_the_gateway_page_prints_every_field_the_projection_publishes` and the "footer is three paragraphs" claim stay true (paragraph count unchanged; only wording moved).
|
||||
- The flavour-text audit (`test_no_page_narrates_its_own_pipeline`) must still pass: the new footer sentence names an operator-facing cause and consequence, not the app's design — if the audit objects, shorten the sentence rather than adding the test to an allow-list.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: served-bytes pins above; a fixture-driven render (`gateway_client`) asserting a `:443` map row's line 1 is `brain.reeseapps.com` and line 2 contains `reeseapps-brain-of-reese.reeselink.com:443` for the captured configs — the actual complaint, asserted on the actual capture.
|
||||
- Integration: `unreachable` / `supported: false` / `available: null` arms still render, with the `:80` refusal sentence untouched.
|
||||
- Coverage: **>90%** on modified paths (no new Python); `node --check` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] On live data: every nginx `:443` leaf's first line is a `reeseapps.com` / `stackexpected.com` / `wellsfamilycounseling.reeseapps.com` / `default` value from `stream.conf`, and its second line is the `*.reeselink.com:443` counterpart; every haproxy leaf's first line is its backend name and its second is `minecraft.reeselink.com:25565`-style.
|
||||
- [ ] `reeseapps-brain-of-code.reeselink.com:443` (42 characters, this fleet's longest) is either fully visible or ellipsised with the whole string in `title` **and** in the pipe's `aria-label` — measured at 1440 px and 360 px.
|
||||
- [ ] The `:80` trunk still prints `no target named in the config`; no row prints `null`, `undefined` or an empty primary line.
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `node --check` clean; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md updated for the row's reading order (the `/gateway` conventions paragraph in both) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: counts, dash semantics, chart disclosure, warnings panel and the switch are untouched.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Task 03 — The trunk draws its own line and moves on the one live number the kernel really reports for that port
|
||||
|
||||
**Phase:** `31_gateway_route_readability` · **Source:** operator request 2026-10-23 — "The only animated line is `:443 -> 127.0.0.1:4443`."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Each group's **trunk** carries a short line of its own that animates while its port has an open connection, because `listeners[].connections` **is** a real measurement — the ESTABLISHED sockets whose *local* port that config names — and it is the only live figure this page can honestly print until phase 32 resolves name endpoints. Today the trunk prints `9 live` as text while drawing no line and moving nowhere, so on the live ingress exactly one pipe animates and the page reads as dead ink beside a busy router.
|
||||
|
||||
This is a **port** claim and never a leaf claim: the trunk's line says "connections are open on `:4443` right now", never "these are `nextcloud`'s". That distinction is carried in its `aria-label`, its `title`, and a pin.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/page-gateway.js`
|
||||
- `buildPipe(trunk = false)` gains one argument and, when true, adds `pipe-trunk` to the `<svg>`'s class list and nothing else — one path, `M0 5 H100`, `viewBox="0 0 100 10"`, `preserveAspectRatio="none"`, `vector-effect="non-scaling-stroke"`, **no** state class at build time (the `buildPipe` comment's rule: a pipe built and never rendered draws nothing).
|
||||
- `groupElement(port)` keeps its `innerHTML` template and appends the trunk pipe as the **last** child of `.gate-trunk`, after `.pipe-count` — the count already carries `margin-inline-start: auto`, so `9 live` and its stub land together at the trunk's right edge, which is the same x the leaf spine starts at (`column-gap: 0`). Appending via DOM (not the template string) is required and commented: an `<svg>` needs `createElementNS`, which is why `buildPipe()` builds it.
|
||||
- `renderTrunk(el, group)`:
|
||||
```js
|
||||
const svg = el.querySelector('.pipe-trunk');
|
||||
const count = listener.connections;
|
||||
const measured = typeof count === 'number' && isFinite(count);
|
||||
// The trunk's `live` comes from the port, not from any leaf: `/proc` states how many
|
||||
// ESTABLISHED sockets have this listen port as their local end, and that is the only
|
||||
// live figure this page can claim about a trunk. It never says which route carries them.
|
||||
svg.hidden = group.listener == null; // no listener row ⇒ no reading ⇒ no line
|
||||
svg.classList.toggle('on', group.listener != null);
|
||||
svg.classList.toggle('off', false);
|
||||
svg.classList.toggle('live', measured && count > 0);
|
||||
svg.classList.toggle('still', !measured || count === 0);
|
||||
svg.setAttribute('role', 'img');
|
||||
setTxt(..., trunkAria(group)); // written only through the helper below
|
||||
svg.title = <the same sentence>;
|
||||
```
|
||||
`on` is honest here for the same reason it is on a leaf: `listeners[]` contains **only** ports a config names (`sources/gateway.py` builds it from haproxy `bind`s and nginx `listen`s), so a trunk row existing *is* the existence fact. The `group.listener == null` arm is the one case where a group was made from a connection whose port no listener carries — hide the pipe rather than draw a claim about a port nobody read (that arm cannot happen with the captured configs; say so in the comment so the next reader does not delete the guard).
|
||||
- **New** `trunkAria(group)` — one sentence, every word from the payload, three arms and no number invented:
|
||||
- `":4443 — 9 connections are open on this port right now"`
|
||||
- `":4443 — no connections are open on this port right now"`
|
||||
- `":4443 — connections on this port are unknown: the socket table did not answer"`
|
||||
Never `"0"` in the unknown arm, and never a leaf's endpoint in any arm.
|
||||
- `renderGroups()` needs no change beyond calling the existing `renderTrunk`; `.solo` stays exactly as it is (it governs the leaf spine, not the trunk stub).
|
||||
2. `src/dashboards/web/static/dash.css`
|
||||
- One rule beside `.pipe`:
|
||||
```css
|
||||
/* The trunk's own stub: the port's live count drawn as a line instead of only as words. It is
|
||||
short and `flex: none` because it is a reading, not a run — the trunk has no endpoint to
|
||||
travel to, and a full-width line here would read as a route. Colour and motion come from the
|
||||
same classes the leaf run uses, so a port with traffic moves and an idle one does not. */
|
||||
.pipe-trunk { width: 26px; height: 10px; flex: none; }
|
||||
```
|
||||
No new token, no new keyframe: `.pipe.on .pipe-path` colours it and `.pipe.live .pipe-path` animates it, which is also why the file's one `@media (prefers-reduced-motion: reduce)` block covers it for free — assert that rather than restating selectors.
|
||||
- The `@media (max-width: 640px)` block gains nothing: the trunk is already a full-width stacked row there, and `flex: none` keeps the stub 26 px at every width.
|
||||
3. `tests/test_api.py`
|
||||
- **New** `test_the_trunk_pipe_is_an_accessible_image_of_its_port`: served JS builds a `.pipe.pipe-trunk` inside `.gate-trunk` through `buildPipe(true)`; `trunkAria` reads `listeners[].connections` and **only** that; its three arms print the strings above (asserted from the literal, so a paraphrase that drops "on this port" fails); `null` yields the unknown arm and no `live` class; the pipe is `hidden` when the group has no listener row.
|
||||
- **Restate** `test_a_pipe_moves_only_while_it_carries_a_connection` to cover both pipes and say out loud that they read from **two different payload paths**: a leaf from `connections[].connections`, a trunk from `listeners[].connections`, and `null` moves neither. Its message must state the trunk's claim is about a port and never about a leaf — that sentence is the reason the test exists.
|
||||
- `test_the_pipe_animation_stills_in_both_states_under_reduced_motion` gains the assertion that the reduced-motion rule is written as `.pipe.live .pipe-path` / `.pipe.still .pipe-path` (a descendant selector, which is what covers `.pipe-trunk` without a second declaration), plus that `.pipe-trunk` is created by `buildPipe` so it can never grow a different class shape.
|
||||
- `test_every_pipe_is_an_accessible_image_built_from_the_payload` now finds **two** pipe builders and must assert both set `role="img"` and a non-empty `aria-label`/`title` derived from payload text — extended, not skipped.
|
||||
- `test_no_port_the_two_configs_do_not_name_appears_anywhere` (payload pin) stays green untouched.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above, plus a fixture render asserting `:443` animates and a port whose payload count is `0` does not, and that the unknown arm prints `n/a` in `.pipe-count` **and** the unknown sentence in `aria-label` — both directions of "never `0`".
|
||||
- Integration: with `sockets.available: null` stubbed into the fixture, every trunk is green, motionless, and reads `n/a`; no trunk anywhere on the page prints `0 live`.
|
||||
- Coverage: **>90%** on modified paths (no new Python); `node --check` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] On live data: every port the kernel reports ≥ 1 ESTABLISHED socket on (`:443`, `:4443`, `:8443`, `:22`, … on the day) has a **moving** green stub at its trunk's right edge, and every port reporting `0` has a static one. Measured at 1440 px and 360 px with a screenshot each.
|
||||
- [ ] The stub's `aria-label` names the port and never an endpoint; reading three trunks aloud with a screen reader (or dumping `aria-label`s) yields the three pinned sentences and nothing else.
|
||||
- [ ] Stubbed `sockets.available: null`: stubs are green, static, and print `n/a` — **0** occurrences of `0 live` in the rendered page (asserted with a `grep` of the DOM in the browser pass).
|
||||
- [ ] The stub does not push the trunk's `:port` text or its `N names` line off the trunk: no clipping, no sideways document scroll at 360 px, and the leaf spine still starts at the trunk's right edge (measured).
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `node --check` clean; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md updated for the trunk's line and what it may claim (the `/gateway` conventions paragraph) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: leaf counts, the dash semantics, `.solo`, the charts and the switch are untouched.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Task 04 — One filter box finds a domain among the routes, client-side, and queries nothing
|
||||
|
||||
**Phase:** `31_gateway_route_readability` · **Source:** operator request 2026-10-23 — "I can't tell which `reeseapps.com` or `stackexpected.com` addresses flow to their reeselink counterparts (very important)."
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
A search field above the connection map narrows the two panels to the routes whose **payload text** contains what the operator typed — `brain`, `stackexpected`, `4443`, `minecraft`, `keycloak.reeselink.com` — because 33 rows in config order is not a lookup, and the operator's question is always about one domain. It is an eye and not a query: it hides rows the payload already sent, fires no request, re-derives no fact, and never touches the tallies or the warnings panel.
|
||||
|
||||
This is also the phase's answer to "group by zone, then": the page does **not** sort, split or colour by domain suffix (`reeseapps.com` vs `stackexpected.com` is a fact no config file states — rule 2). The operator types the suffix; the page hides the rest.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/pages/gateway.html` — the "Connection map" `.page-head` only:
|
||||
```html
|
||||
<div class="page-head">
|
||||
<h2 style="font-size:.95rem;margin:0">Connection map</h2>
|
||||
<div class="spacer"></div>
|
||||
<input type="search" id="gate-filter" class="gate-filter" autocomplete="off" spellcheck="false"
|
||||
placeholder="filter by domain, endpoint or port"
|
||||
aria-label="Filter routes by domain, endpoint or port">
|
||||
<span class="chip faint" id="gate-filter-count" hidden aria-live="polite"></span>
|
||||
</div>
|
||||
```
|
||||
and one `<div class="pipe-empty faint" data-k="no-match" hidden></div>` inside each card's `.body.stack`, above the `Charts` disclosure, for the panel-level empty result. The card comments gain one sentence: the filter hides rows and is not a fetch (so the next reader does not "optimise" it into a query parameter).
|
||||
2. `src/dashboards/web/static/dash.css`
|
||||
- Add `input[type="search"]` to the existing shared rule (`input[type="text"], input[type="password"]` in the buttons/inputs section) rather than writing a one-off box: the control is then the same field the `/settings` and `/login` forms draw. No `type="search"` exists on any other page today, so the addition is inert everywhere else — pin that.
|
||||
- `.gate-filter { max-width: 18rem; min-width: 0; }` — `min-width: 0` because it sits in a flex `.page-head` and phase 27's rule (a flex/grid item may not be wider than its track) is exactly what stops a phone-width overflow; add `width: 100%` inside the ≤640 px block so the field is full-width there and the document still measures 0 px of sideways scroll.
|
||||
- `.gate-filter-count` needs nothing beyond `.chip`; no new colour token anywhere (a filter is not a verdict).
|
||||
3. `src/dashboards/web/static/page-gateway.js`
|
||||
- `state.filter = ''`, and a single pure matcher over **payload strings only** — never `textContent`, because a label the CSS ellipsised must still match its whole string:
|
||||
```js
|
||||
/** One row's haystack: every string the payload sent about it, and nothing else. */
|
||||
function haystack(conn) {
|
||||
const out = conn.out || {};
|
||||
return [String((conn.in || {}).port ?? ''), out.host, out.name, out.kind,
|
||||
...(out.selectors || [])].filter((s) => typeof s === 'string' && s)
|
||||
.join(' ').toLowerCase();
|
||||
}
|
||||
```
|
||||
`filter` + the `typeof s === 'string'` test are the guard that a `null` host or a missing `kind` never becomes the literal text `"null"` and matches a query for `null`.
|
||||
- The trunk matches on its own payload (`:${port}`, `domains[]`, `clients[]`); a group is visible when the trunk matches **or** any leaf matches; when only the trunk matched, **all** its leaves stay visible (typing `4443` asks for the port, not for one route).
|
||||
- Visibility is applied **inside the existing patch pass** (`renderLeaves()` sets `li.hidden`, `renderGroups()` sets the group's `hidden` and the card's `[data-k="no-match"]`), so:
|
||||
- a poll cannot undo the filter (the patch re-applies it every refresh),
|
||||
- clearing it repaints from `state.snapshot` with no network round trip,
|
||||
- phase 30's build-once/patch-after rule holds: rows are hidden, never removed, so `state.rows` keeps its identity and a row that reappears keeps its `<svg>` and its `aria-label`.
|
||||
- The `input` handler is `state.filter = value.trim(); repaintFromSnapshot();` where `repaintFromSnapshot()` re-runs the render pass on `state.snapshot` and issues **no** `fetchJSON`. A `keydown` handler for `Escape` sets `state.filter = ''`, empties the field, and repaints (a `type="search"` clear button fires `input` in Chrome; Escape-on-some-browsers does not, and a filter you cannot leave with the keyboard is a trap on a page whose other shortcuts are all keys).
|
||||
- `#gate-filter-count` reads `12 of 33 routes` **only while the filter is non-empty** (`hidden` otherwise — a permanent tally is the ink the empty-state rules exist to avoid) and counts **leaf routes**, not groups. `[data-k="no-match"]` prints `no route matches “<query>”` and only when the panel's groups are all hidden by the filter — never when the panel is hidden for `sections` reasons.
|
||||
- `renderHeader()` and `renderWarnings()` are untouched and must not gain a single reference to `state.filter`: `ports`, `connections`, `live` and `warnings` describe the ingress, not the operator's current view of it.
|
||||
4. `tests/test_api.py`
|
||||
- **New** `test_the_filter_matches_only_payload_text_and_costs_no_request`:
|
||||
- served JS: `haystack` indexes `in.port`, `out.host`, `out.name`, `out.kind`, `out.selectors` and nothing else; it drops non-strings (so `null` cannot match `"null"`);
|
||||
- the filter path contains no `fetchJSON` and no call to `refresh()`;
|
||||
- `renderHeader` and `renderWarnings` contain no `state.filter` reference, and `#gate-filter-count` is the only tally the filter moves;
|
||||
- visibility is set on rows/groups inside `renderLeaves`/`renderGroups` (the patch pass), not by removing nodes from `state.rows`.
|
||||
- **New** `test_the_filter_field_is_pinned_markup_and_the_shared_input_rule_covers_it`: `#gate-filter` exists on the served page with its `aria-label`, `input[type="search"]` is in the shared input rule's selector list, and **no other served page** contains `type="search"` (so widening that rule cannot restyle another page by accident).
|
||||
- `test_no_page_carries_an_id_nothing_reads` (and the same audit run unsigned on `/login` in `tests/test_auth_api.py`) must stay green — both new `id`s (`#gate-filter`, `#gate-filter-count`) are read by the served script, and the `[data-k="no-match"]` element is reached through `querySelector`, not an `id`.
|
||||
- `test_the_gateway_page_is_exactly_two_connection_panels_and_a_warnings_list` stays green: the filter sits in the `Connection map` head, and the pin's `<section>` count must not change (assert it in the task's own run, not by editing the pin's count).
|
||||
- The flavour-text audit stays green: `placeholder` and the no-match sentence are functional text; nothing on the page narrates that a filter exists or how it works.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; a fixture render asserting `brain` leaves exactly the `brain.reeseapps.com` + `brain-of-code.reeseapps.com` rows visible with their `:443` trunk shown, `4443` leaves that trunk's routes all visible, `zzzz` hides every group and shows both panels' no-match sentences, and clearing restores all 33 rows without a second history request (spy on the client).
|
||||
- Integration: the filter survives a poll that adds a route (the new row is filtered on the pass that creates it), and survives the panel-level arms (`supported: false` → no groups → filter shows nothing to filter, no crash).
|
||||
- Coverage: **>90%** on modified paths (no new Python); `node --check` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] On live data at 1440 px: typing `stackexpected` leaves only the `stackexpected.com`/`gitea.stackexpected.com`/`keycloak…`/`nextcloud…`/`hedgedoc…` map leaves and the two haproxy/`stackexpected-gitea` routes visible, the tallies still read the unfiltered ingress (`ports`, `connections`, `live`, `warnings` unchanged), and the chip reads `N of 33 routes`.
|
||||
- [ ] Clearing the field (button or Escape) restores every row with **no** new request — verified by an empty network log between the keystroke and the repaint.
|
||||
- [ ] A query that matches nothing prints the no-match sentence in each visible panel and never a blank card, never a stray `:80` trunk.
|
||||
- [ ] At 360 px: the field is full-width, the document measures 0 px of sideways scroll, and no group is clipped (the mobile audit stays at 0 FAIL / 0 FINDING).
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `node --check` clean; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md updated: the `/gateway` paragraph names the filter and states the refusal (no zone grouping by suffix) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: tallies, warnings panel, charts, switch, dash semantics untouched.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Task 05 — Docs, the browser measurement, and a read-only pass over the real ingress
|
||||
|
||||
**Phase:** `31_gateway_route_readability` · **Source:** phase completion pass (AGENTS.md definition of done: UI changes are verified in a real browser with a screenshot, and `README.md`/`AGENTS.md` are re-read whole).
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Prove tasks 01–04 on live data with measurements rather than prose, carry the new rules into `AGENTS.md` and `README.md`, restate or close gap 13 with the phase's own clip counts, and confirm the phase really was page-only by showing `GET /api/gateway/snapshot` byte-identical and every other source still `ok`.
|
||||
|
||||
## Work
|
||||
|
||||
1. `data/logs/31-route-readability.cjs` — a new Playwright pass (same house shape as `data/logs/30-connection-map.cjs`: `NODE_PATH=<playwright node_modules>`, `DASH_BASE`/`DASH_PW` overridable, signs in with `DASH_ADMIN_PASSWORD`, exits non-zero on any FAIL). Checks, each printed with the number it measured:
|
||||
- **existence colour**: on the nginx and haproxy panels, for a sample of at least 5 rows including a `.solo` haproxy row and a branched `:443` leaf — computed `borderLeftColor` of `::before`/`::after` (via `getComputedStyle(el, '::before')`), `borderTopColor` of `.gate-elbow`, computed `stroke` of `.pipe-path`, and the switch's `borderTopColor` all equal each other and equal `--gate-on`'s computed value; and **no** element in `.gate-map` computes a dashed border in `--border-strong`'s value. Both themes (toggle through `#theme-toggle`).
|
||||
- **row order**: every leaf's line 1 resolves to a value that appears in that row's payload `out.selectors` and line 2 contains `out.host` + `out.port`; the two strings are both present in `title` and in the pipe's `aria-label`. Reports the clip counts (which lines are ellipsised) at 1440 px and 360 px — these are gap 13's new numbers.
|
||||
- **trunk motion**: for each group, `getComputedStyle(trunkPath).animationName` is `pipe-flow` iff `listeners[].connections >= 1`, and `none` for a `0`/`null` port; assert the rendered DOM contains **zero** occurrences of `0 live`; assert every `.pipe-trunk[aria-label]` names its own port and contains no hostname.
|
||||
- **filter**: type `stackexpected` → assert the visible leaf set equals the payload rows whose haystack contains it, the four header tallies are unchanged from before the keystroke, and **no** network request leaves the page (CDP `request` listener empty between keystroke and repaint); press Escape → full set back, still zero requests.
|
||||
- **reduced motion**: with `prefers-reduced-motion: reduce` emulated, nothing in `.gate-map` animates (leaf runs, trunk stubs, switch thumb).
|
||||
- **degradation arms**: inject/stub each and check the page still says the payload's own words and colours nothing red — `unreachable: true`, `supported: false` with `why`, `sections: {nginx: false}`, `sockets.available: null`, and `404 {configured: false}` (the setup block).
|
||||
- **no sideways scroll**: `document.scrollingElement.scrollWidth === clientWidth` at 1440 px and 360 px with the widest fan-out expanded and both charts open.
|
||||
- Screenshots: `data/logs/31-gateway-{1440,360,light,filtered,no-match,trunk-live,predates,phone}.png`; transcript `data/logs/31-route-readability.out`.
|
||||
2. Re-run `data/logs/27-task02-mobile-audit.cjs` (the nine-page sweep, `/gateway` included) and record the tally in the transcript — it must be **0 FAIL / 0 FINDING**, exit 0. The `#gate-filter` field and the trunk stub are the two new things it measures; if it finds a clipped fact, fix the layout rather than the audit.
|
||||
3. `AGENTS.md` — update, in full, everything this phase made stale:
|
||||
- the `/gateway` UI-conventions paragraph: the row's reading order (inbound word → `→ counterpart`), the fact that **one** `on`/`off` class on the `<li>` paints spine, tick, run and switch, why motion stays the per-row run's claim (the spine segment is shared between two leaves), what the trunk stub is and the exact limit of its claim (a port, never a leaf), and the filter — what it matches, that it costs no request, and that it never moves the tallies or the warnings panel.
|
||||
- the sentence about the branch ("The branch is four CSS rules and no JavaScript…") now has to say the connectors are existence-coloured from `--gate-on`/`--gate-off` with a `transparent` base, not structural ink.
|
||||
- **gap 13**: restate it with this phase's measured clip counts at both widths. If the inbound-first line means nothing clips at 1440 px any more, close the gap with the measurement and say what remains at 360 px; do not delete it on the strength of the filter.
|
||||
- the `countText()` / dash sentence: the footer now explains the dash in operator terms — reflect that, and note that phase 32 removes the reason for it.
|
||||
- any claim that the page's footer is three paragraphs (task 02 changed wording, not count — verify, don't assume).
|
||||
4. `README.md` — the `/gateway` table row and the gateway prose section: the page answers "which inbound domain flows where", the filter exists, the trunk line is a port reading. Keep the "unconfigured is not red" and "agent 1.2.0" statements intact (this phase changes neither).
|
||||
5. The read-only live proof:
|
||||
- `GET /api/gateway/snapshot` over a signed-in session, before the phase (stash it at task 01's start) and after task 04 — **byte-identical**, printed as a `diff` that must be empty. This is the pinned proof the phase is page-only and cannot have moved a payload contract.
|
||||
- `./scripts/dev-server.sh status` → all ten sources `ok`, `gateway` included.
|
||||
- `git diff --stat` shows changes under `src/dashboards/web/` and `tests/` only — no `src/dashboards/*.py`, no `agent/`, no `scripts/`.
|
||||
6. Record the follow-on intent in `AGENTS.md`'s gap list **without** pre-writing their design: one line each that name-endpoint liveness (resolution, agent 1.3.0) and route enable/disable (a second gated upstream write, which amends invariant 1 and needs the operator's `PLAN.md` sign-off) are the next two phases. No implementation detail beyond what this phase's decisions 2 and 7 already state.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: this task **is** the integration pass — every claim in tasks 01–04 gets a measured line in the transcript, and the transcript plus the screenshots are the artifacts the phase's Completion Criteria name.
|
||||
- Coverage: no code change expected; if the browser pass forces a fix, that fix goes through the same pins as the task it belongs to, and the affected test is named in the transcript.
|
||||
- The whole suite, `ruff`, `pyright`, `node --check` and `bash .agents/validate.sh` green at the end of the phase.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `data/logs/31-route-readability.out` exists, exits 0, and contains the computed-colour line for at least 5 rows in both themes, the clip counts at both widths, the trunk-motion truth table, the filter's zero-request proof, and the reduced-motion pass.
|
||||
- [ ] The eight `data/logs/31-gateway-*.png` screenshots exist, including one `filtered` showing a `stackexpected` result set and one `phone` at 360 px.
|
||||
- [ ] `data/logs/27-task02-mobile-audit.cjs` exits 0 with 0 FAIL / 0 FINDING and its tally recorded (`/gateway` in the sweep).
|
||||
- [ ] The before/after `diff` of the live `/api/gateway/snapshot` is empty, and `./scripts/dev-server.sh status` lists ten `ok` sources.
|
||||
- [ ] `git diff --stat` for the phase touches only `src/dashboards/web/`, `tests/`, `README.md`, `AGENTS.md`, and `data/logs/` (gitignored, present on the box).
|
||||
- [ ] `AGENTS.md` and `README.md` describe the page as it now renders, gap 13 restated with measurements, and a full read of both files finds no other inconsistency (fix it in the same commit, including pre-existing ones).
|
||||
- [ ] `uv run pytest` **100% passing** (every test in the project), coverage floor green, `uv run ruff check src tests agent` + `uv run pyright` clean, `bash .agents/validate.sh` green.
|
||||
- [ ] No behaviour change in completed work: `/`, `/system`, `/disks`, `/llm`, `/ups`, `/settings`, `/certs`, `/login` render as before.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Phase 32 — Names become addresses where they are actually dialled: every route gets a real live count
|
||||
|
||||
**Source:** operator request, 2026-10-23 — "The only animated line is `:443 -> 127.0.0.1:4443`." + the decision taken in that conversation: **the agent resolves the names it reads**, because the resolution nginx's `resolver 10.1.0.1` and haproxy's `default-server init-addr last,libc,none` perform happens *on the gateway*, and a laptop- or dashboard-side lookup is a different vantage answering a different question.
|
||||
**Story:** n/a
|
||||
**Context:** `agent/host_agent.py` (1.2.0 → **1.3.0**: the `haproxy`/`nginx` parsers that already produce every target name, the `sockets` reader, the `AGENT_VERSION` + `/schema` contract, the `_smart_lock`/`_smart_refreshing`/`_refresh_smart` cache-and-background-pass precedent, `--no-proxy`/`--no-smart`/`--root`), `src/dashboards/sources/gateway.py` (`_connection()`'s `address`/`joinable`/`metric` rule, `join_sockets()`, `endpoint_of()`, `samples()`), `src/dashboards/collectors.py` (`ADDON_ERROR_PREFIXES` — the cross-module contract AGENTS.md says a new add-on section must satisfy **in the same commit**), `scripts/capture-gateway-config.sh` (the six fixtures + its "no privilege, no copy tool, a bad read is never a fixture" rules + the RFC 5737/3849 address scrub), `tests/test_gateway_source.py`, `tests/test_agent.py`, `tests/fixtures/gateway_*`, `data/logs/verify-28-final.py` (the side-by-side additivity control this phase copies).
|
||||
|
||||
## Objective
|
||||
|
||||
`agent/host_agent.py` **1.3.0** resolves every target name the two proxy configs declare — with `socket.getaddrinfo`, i.e. the same libc resolution haproxy itself uses, on the machine that opens the connections — and publishes the address set, the vantage and the timestamp per name. `sources/gateway.py` then joins the kernel's ESTABLISHED sockets through those addresses, so `brain.reeseapps.com → reeseapps-brain-of-reese.reeselink.com:443` carries a **number** and its line **moves**, instead of a permanent em dash on 31 of 33 routes.
|
||||
|
||||
This is a measurement with provenance, not a guess with better marketing: the payload says *which* addresses, *how* they were obtained (`via: "libc"`), and *when*; a name that does not resolve right now publishes no addresses, gets no gauge, and prints the dash with the reason. `sources/gateway.py` still never calls a resolver — that spy pin survives this phase on purpose, because a dashboard resolving through its own network would be measuring a different box's DNS than the one routing the traffic.
|
||||
|
||||
## Scope decisions taken with the operator (binding for this phase)
|
||||
|
||||
| # | Decision | Consequence |
|
||||
|---|----------|-------------|
|
||||
| 1 | **Resolution happens on the gateway, in the agent.** | `host_agent.py` gains a `resolver` section. `sources/gateway.py` keeps its `socket.getaddrinfo` spy pin (fails if the module ever resolves), and `tests/test_gateway_source.py` gains the matching assertion that the only resolver in the system is the agent's. The dashboard's own network vantage is not the routing vantage; a lookup from here would be a fact about *this* box's `resolv.conf`. |
|
||||
| 2 | **libc, and the vantage is printed.** | `via: "libc"` on every entry (what `getaddrinfo` is), plus the nginx config's own `resolver` directive captured verbatim as **`resolver.config_directive`** (`"10.1.0.1"` on this fleet) so the page can name both and an operator can see a divergence. It lives inside the new section rather than inside `nginx` so that `nginx` stays byte-stable and the phase's additivity control remains a strict top-level key comparison with no exception list. The agent does **not** hand-roll UDP DNS queries to that resolver: stdlib only, no new dependency, and a divergence is a config finding for the operator rather than a second resolver implementation to maintain. |
|
||||
| 3 | **Only names the configs wrote are ever resolved.** | The name set is derived from the parsed `haproxy` `server` targets and the parsed `nginx` `proxy_pass`/`upstream`/`map` targets — nothing else, ever. Not a name from `/proc`, not a client address, not anything a request supplied. Pinned by asserting the spied `getaddrinfo` calls are exactly the config's target set. |
|
||||
| 4 | **Bounded, cached, never on the request path after the first pass.** | `RESOLVE_MAX_NAMES = 64` (the excess is **one** line naming the count — the include/smart budget shape), `RESOLVE_CACHE_SECONDS = 300`, one single-flight background refresher (the `_smart_refreshing` idiom), a per-pass budget so a black-holing resolver cannot hold `/snapshot`. A wedged lookup must never blow `HOST_TIMEOUT_SECONDS` and turn `/system` red over an add-on it never draws — the exact lesson SMART learned. |
|
||||
| 5 | **Three-way `available`, and `--no-resolve` removes the key in silence.** | `true` resolved what it was asked, `false` no proxy sections to read from, `null` the pass could not run — each non-`true` arm exactly one `resolver:` line (the pinned-wording contract shared with `smartctl:`/`haproxy:`/`nginx:`/`sockets:`). `--no-resolve` is `--no-smart`'s precedent: the key is **absent** ("not collected"), which stays distinguishable from "collected and empty". |
|
||||
| 6 | **A resolved name is joinable; an unresolved one is not.** | `_connection()`'s `metric`/`joinable` test becomes `address is True or resolved-address-set is non-empty`. Name-keyed gauges (`gateway.out_conns@<host>:<port>`) come back **because they now have measurements** — the AGENTS.md passage that retired them said a name-keyed series "would be a chart of a number that is always `null`", and this phase is the reason that stopped being true. The port stays in the name (`:25565`/`:25566` on one host are two services). |
|
||||
| 7 | **The shared-endpoint rule now has a real case and stays exactly as written.** | Two names resolving to the same `(address, port)` are one count **repeated** on both lines with `shared: true` — never halved, never attributed to one of them. `samples()` keeps de-duplicating by metric, and `live_out` in `/api/summary` keeps summing **distinct endpoints** (`api._endpoint_values()`). |
|
||||
| 8 | **Nothing moves that the operator did not ask about.** | `warnings()` is unchanged (resolution is not a config-integrity question); `/api/summary`'s `gateway` block gains **no** thirteenth key; `listeners[]` and the inbound join are untouched; the switch stays unwired and unwire-able (phase 33), and `routeInConfig()` keeps returning `true`. |
|
||||
|
||||
**Out of scope:** any config write, any new sample *family* (only the meaning of `gateway.out_conns@…` widens), any change to `/api/health` or `/api/config`, any `severity` (a count is not graded), the enable/disable path (phase 33), and re-bundling or re-issuing certificates (the agent moves in place on its existing bundle, exactly as phase 28 moved the gateway 1.1.0 → 1.2.0).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `31_gateway_route_readability` (todo, **must complete first**) — the row that reads inbound → counterpart and the green end-to-end route are what a live count paints. Its payload-purity guarantee (`sources/gateway.py` untouched) is what makes this phase's rewrite land cleanly.
|
||||
- `30_gateway_connection_map` (complete) — `connections[]`, the join, `endpoint_of()`, `shared`, the gauge names, `warnings()`.
|
||||
- `28_gateway_agent_proxy` (complete) — the parsers whose target set this phase resolves, the fixture capture script and its scrub rule, and `tests/test_agent.py`'s fake-tree `--root` machinery the resolver tests reuse.
|
||||
- `24_dev_mtls_rollout` / the fleet record — the rollout is phase 28 task 05's procedure on one host (`gateway`), on its existing bundle, with `systemctl restart` and a read-only probe first.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_the_agent_resolves_the_names_it_reads.md` — agent 1.3.0: the `resolver` section (`available`, `via`, `config_directive`, entries), `RESOLVE_MAX_NAMES`/`RESOLVE_CACHE_SECONDS`/pass budget, the single-flight background pass, three-way `available`, `--no-resolve`, `/schema`, `AGENT_VERSION`.
|
||||
2. `02_resolution_ground_truth.md` — `capture-gateway-config.sh` records the resolver document and the two config files' `resolver` line; the RFC-5737/3849 scrub extended to it; the 1.2.0-vs-1.3.0 **additivity control** (`data/logs/verify-32-additive.py`, copied from `verify-28-final.py`).
|
||||
3. `03_join_through_the_address_the_name_became.md` — `sources/gateway.py`: `out.resolved`/`via`/`resolved_ts`, the widened `joinable`/`metric` rule, the address-set match, `shared` unchanged, `null` refusals, and `collectors.ADDON_ERROR_PREFIXES` gaining `resolver:` **in this commit**.
|
||||
4. `04_the_page_says_which_unknown_it_is.md` — `page-gateway.js` + `gateway.html`: the dash splits into "the name did not resolve in this reading" vs "resolved and nothing is open", the `libc`/timestamp provenance on `title`, footer paragraph 2 rewritten, tallies unchanged.
|
||||
5. `05_roll_the_gateway_to_1_3_0.md` — read-only probe first, then the phase-28 procedure on `gateway` (re-stage, install, restart, verify both ways), the live join numbers recorded, and the fleet record updated with the new version spread.
|
||||
6. `06_docs_and_live_verify.md` — `AGENTS.md` (the agent bullet, `sources/gateway.py`, `collectors.py`, the metric-naming passage, the `/gateway` UI paragraph, the fleet record, gap 13) + `README.md`, the browser pass with screenshots, the mobile-audit re-run, the full gate.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **Hermetic by construction.** No test dials real DNS: `tests/test_agent.py` monkeypatches `socket.getaddrinfo` and asserts (a) it is called with **exactly** the parsed target names and ports and nothing else, (b) the cache means a second `/snapshot` costs zero lookups, (c) a pass over `RESOLVE_MAX_NAMES` reports one line and keeps `available: true`, (d) a raising/timeout-simulating resolver yields `status`-bearing entries and never fails `/snapshot` or `/health`.
|
||||
- **Real captures for shape.** `tests/fixtures/gateway_resolver.json` (documentation addresses, verbatim names) plus the existing six fixtures drive `tests/test_gateway_source.py`: every captured name route gets a count when the recorded `/proc/net/tcp{,6}` holds a socket to its recorded address; `minecraft.reeselink.com:25565` and `:25566` stay two distinct gauges; two names → one endpoint is one count twice with `shared: true`; an entry with no addresses publishes `connections: null`, **no** metric, and no sample.
|
||||
- **The spy pins are restated, not relaxed.** `socket.getaddrinfo` stays forbidden in `sources/gateway.py` (spy) and `0` becomes "no sample", never "an empty chart" — a pipe whose name resolved to nothing publishes no point.
|
||||
- **Live (`-m live --live`)**: one new test reads the real gateway and asserts every `map` entry whose `resolver` status is `ok` carries a finite count **or** `0`, and that none of them is `null` — the operator's complaint, pinned against the box. Skips when `GATEWAY_HOST` is not a target.
|
||||
- Coverage **>90%** on `agent/host_agent.py`, `sources/gateway.py`, `collectors.py`; the whole suite passes 100% 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 **including pre-existing ones**. Specifically: AGENTS.md's agent bullet gains the `resolver` section (and its `--no-resolve`, its bounds, its three-way `available`), its `sources/gateway.py` bullet's join paragraph is rewritten (the join now has two address sources: written-as-address and resolved), the **"no name-keyed `gateway.out_conns@…` name is ever inserted"** sentence in the metric-naming passage is replaced with the widened rule and *why it changed*, the `collectors.ADDON_ERROR_PREFIXES` list gains `resolver` (the "prefix in the same commit" rule), the `/gateway` UI paragraph's dash sentence splits in two, and the **fleet record** moves `gateway` to 1.3.0 with the deskwork/driveripper spread and what the spread costs.
|
||||
- `README.md`: the `/gateway` table row and the gateway prose now describe counted name routes, the resolution vantage, and the capture script's new outputs.
|
||||
- Tasks update both files immediately when their own changes are user-visible.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `GET /schema` on the gateway agent answers `agent_version` **1.3.0** with a `resolver` block (its `available` flag named) and `--no-resolve` listed; `tests/test_agent.py` pins the constant and the schema text.
|
||||
- [ ] `GET /api/gateway/snapshot` on the live ingress: **0** `connections[]` rows with `connections: null` among those whose target resolved; `brain.reeseapps.com → reeseapps-brain-of-reese.reeselink.com:443` carries a finite count; `socket.getaddrinfo` is still never called anywhere under `src/dashboards/` (spied) and every address in `out.resolved` names `via: "libc"` with a `resolved_ts`.
|
||||
- [ ] Two names resolving to one `(address, port)` draw one count repeated with `shared: true` and **one** stored sample; `/api/summary`'s `gateway.live_out` still equals the sum over **distinct** endpoints (pinned before and after).
|
||||
- [ ] A stubbed `nxdomain` for one name publishes `out.resolved: null`, no `metric`, no sample, and the page prints the "did not resolve in this reading" sentence — never `0`, never a silent blank.
|
||||
- [ ] `--no-resolve` on a loopback agent removes the `resolver` key entirely and `/api/gateway/snapshot` behaves exactly as phase 30/31 (names uncounted, `supported: true`); a 1.2.0 agent on any host behaves identically (the spread is harmless — pinned by a fixture without a `resolver` key).
|
||||
- [ ] `/system` is not `degraded` on a host whose agent reports `resolver: nxdomain …` — the `ADDON_ERROR_PREFIXES` pin (invariant 3), asserted both ways.
|
||||
- [ ] `data/logs/verify-32-additive.py` proves the 1.3.0 document equals the 1.2.0 document key-for-key **plus** `resolver`, and 1.3.0 with `--no-resolve` equals it exactly.
|
||||
- [ ] `uv run pytest` (incl. `--cov` ≥ 94), `uv run pytest -m live --live`, `uv run ruff check src tests agent`, `uv run pyright`, `node --check`, `bash .agents/validate.sh` — all green; `uv pip list --outdated` empty (no dependency moved, and this phase must not add one).
|
||||
- [ ] `data/logs/32-*.png` at 1440 px and 360 px (counted routes, an unresolved arm, both themes) + `data/logs/32-*.out`; `data/logs/27-task02-mobile-audit.cjs` exits 0 with 0 FAIL / 0 FINDING.
|
||||
- [ ] `./scripts/dev-server.sh status` lists all ten sources `ok`, `gateway` included, with `host:gateway`/`disks:gateway` unaffected by their host's agent upgrade.
|
||||
- [ ] 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, `/settings`, `/certs`, `/login`, the warnings panel, the unwired switch and every non-gateway source render and behave as phase 31 left them.
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# Task 01 — The agent resolves the names its own configs declare, off the request path
|
||||
|
||||
**Phase:** `32_gateway_resolved_target_liveness` · **Source:** operator decision 2026-10-23 — "the agent resolves the names it reads"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`agent/host_agent.py` goes **1.2.0 → 1.3.0** and gains one additive top-level section, `resolver`: for every `(name, port)` the two parsed proxy configs write as a target, the libc address set, its status, the vantage and a timestamp — obtained on the machine that dials, bounded, cached, and refreshed on a background thread so a slow or black-holing resolver can never hold `/snapshot` open. Everything the agent published before is byte-identical; `--no-resolve` removes the key in silence.
|
||||
|
||||
## Work
|
||||
|
||||
1. `agent/host_agent.py`
|
||||
- Constants beside the proxy section's bounds, each with a comment naming what it protects:
|
||||
```python
|
||||
AGENT_VERSION = "1.3.0"
|
||||
RESOLVE_MAX_NAMES = 64 # a gateway's config-declared targets, with room to grow
|
||||
RESOLVE_CACHE_SECONDS = 300.0 # a LAN target moves when the operator moves it, not every poll
|
||||
RESOLVE_PASS_BUDGET_SECONDS = 3.0 # a black-holing resolver must not hold /snapshot open
|
||||
RESOLVE_VIA = "libc" # getaddrinfo, i.e. the resolution haproxy's libc path performs
|
||||
```
|
||||
- **New pure helper** `target_names(haproxy: Mapping | None, nginx: Mapping | None) -> list[tuple[str, int | None]]`
|
||||
Derived **only** from the parsed sections: haproxy `backends[].servers[].target` (in config order), then nginx `upstreams` members, `server`-block `proxy_pass` targets, and `maps[].entries[].target`, deduplicated keeping first-seen order. An IP literal is kept as a target with `addresses` set without any lookup (it is already an address, and the lookup would be a round trip to say so) and marked `status: "literal"`. A name from `/proc`, from a request, or from anywhere else must never enter this list — that is decision 3 and it is pinned by asserting the spied call set equals this function's output.
|
||||
- **The pass, following the SMART idiom exactly** (`_smart_lock` / `_smart_cache` / `_smart_refreshing` / `_refresh_smart`):
|
||||
`_resolver_lock`, `_resolver_cache: dict[str, dict[str, Any]]` keyed on `f"{name}:{port}"` (a name on two ports is two targets — `minecraft.reeselink.com` on `25565` **and** `25566` is on this fleet today), `_resolver_refreshing: bool`, `_resolver_errors: list[str]`.
|
||||
```python
|
||||
def resolver_pass(self, targets) -> tuple[dict[str, dict[str, Any]], list[str]]:
|
||||
"""One bounded libc lookup per declared target. Never raises out of this function."""
|
||||
```
|
||||
For each target: `socket.getaddrinfo(name, port, proto=socket.SOCK_STREAM)`, collect the unique `sockaddr[0]` values, keep both families, sort address-family-stable (v4 ascending then v6 ascending) so the payload is deterministic across polls. `socket.gaierror` with `EAI_NONAME`/`EAI_NODATA` → `status: "nxdomain"`, any other `gaierror`/`OSError` → `status: "error"`, both with `addresses: []`. After each lookup compare elapsed against `RESOLVE_PASS_BUDGET_SECONDS`; on exhaustion stop and append **one** line — `resolver: pass budget 3.0s exhausted, 41 of 58 targets looked up` — mirroring the smart pass-budget wording the agent already prints (phase 16 pins that family of tests; match its shape so the next reader has one idiom, not two).
|
||||
- Entry shape (and the whole section):
|
||||
```python
|
||||
"resolver": {
|
||||
"available": True | False | None,
|
||||
"via": "libc",
|
||||
"config_directive": "10.1.0.1" | None, # nginx's own `resolver` line, verbatim arguments
|
||||
"ts": <wall clock of the pass>,
|
||||
"entries": [{"name": …, "port": …, "status": "ok|nxdomain|error|literal",
|
||||
"addresses": […], "ts": <per-entry, when it was answered>}],
|
||||
"errors": [ …at most one line per arm… ],
|
||||
}
|
||||
```
|
||||
`config_directive` lives **here** rather than inside the `nginx` section on purpose: it keeps the `nginx` document byte-stable so phase 32's additivity control stays a strict top-level key comparison instead of an exception list. Read it from the nginx config text the agent already has (`resolver` directive, arguments joined by a space, `None` when the file has none) — no second file read.
|
||||
- `available` is the three-way answer the other add-ons use: `true` the pass ran, `false` there is nothing to resolve because the proxy sections were not read, `null` a pass that could not run — and **exactly one `resolver:` line per non-`true` arm**, the missing/no-proxy case saying **not found** (the pinned cross-module wording the dashboard reads).
|
||||
- Cache policy: the **first** pass runs inline (the SMART precedent, bounded by the budget); an expired cache afterwards refreshes on a single-flight daemon `threading.Thread` while the request is served the previous result; a failed refresh keeps the previous entries and retries on the next expiry instead of hammering DNS. `resolver.entries[].ts` is always the time that entry was actually answered, so a served-from-cache entry cannot pretend to be current.
|
||||
- `--no-resolve` (argparse, no env default — `--no-smart`/`--no-proxy` precedent): the key is **absent** from `snapshot()` ("not collected", which stays distinguishable from "collected and empty"), silently, and `--once` prints the same absence. `--no-proxy` also removes it (nothing to resolve on a box that collects no proxy) — say so in one comment.
|
||||
- `AGENT_VERSION` bump, the module docstring's `/schema` block gains the `resolver` section with its `available` flag and `--no-resolve`, and the status page's path list is unchanged (this task adds no path — task 02 of phase 33 is what adds a POST).
|
||||
2. `tests/test_agent.py`
|
||||
- `test_the_agent_version_is_1_3_0_and_the_schema_names_the_resolver_section` (the constant + the `/schema` text names `resolver`, its `available`, and `--no-resolve`).
|
||||
- `test_only_the_targets_the_configs_declare_are_resolved` — monkeypatch `socket.getaddrinfo`, run a snapshot against the fake-tree configs, assert the `(name, port)` pairs it was called with are **exactly** `target_names(...)`' output for the same tree, in config order, with no duplicates, and that no call carries an address from `/proc/net/tcp`.
|
||||
- `test_the_resolver_cache_means_one_lookup_per_target` — two snapshots, one call set; expiry triggers a background refresh and the first of the two responses is served from cache with its original per-entry `ts`.
|
||||
- `test_a_slow_resolver_cannot_hold_the_snapshot_open` — a `getaddrinfo` stub that sleeps: the pass stops at the budget, prints the one budget line naming the counted and total figure, `available` stays `true`, `/snapshot` and `/health` still answer.
|
||||
- `test_nxdomain_is_a_status_and_never_a_failure` — one raising name yields `status: "nxdomain"`, `addresses: []`, other entries intact, snapshot `ok`.
|
||||
- `test_no_resolve_removes_the_key_in_silence` and `test_a_host_with_no_proxy_sections_says_so_once`.
|
||||
- `test_the_resolver_lines_never_fail_the_snapshot` — every arm, plus the existing "no section can fail `/snapshot`" family extended to cover the new one.
|
||||
- An IP-literal target is reported `status: "literal"` with its address and **no** lookup.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; hermetic by monkeypatching `socket.getaddrinfo` — **no test resolves a real name**.
|
||||
- Coverage **>90%** on the new agent code; `uv run ruff check agent` + `uv run pyright` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Run against a fake tree built the way `tests/test_agent.py::nginx_tree()` builds one (the captured `gateway_nginx_main.conf` + `conf.d` + `stream.d` under `<tmp>/etc/nginx/`, with `--haproxy-config tests/fixtures/gateway_haproxy.cfg`): `python3 agent/host_agent.py --once --no-smart --root <tmp> --haproxy-config tests/fixtures/gateway_haproxy.cfg --nginx-config <tmp>/etc/nginx/nginx.conf` prints a `resolver` block with `available`, `via: "libc"`, `config_directive: "10.1.0.1"` and one entry per declared target — and prints **no** `resolver` key with `--no-resolve` added.
|
||||
- [ ] `grep -n 'AGENT_VERSION = ' agent/host_agent.py` reads `1.3.0` and the `/schema` docstring names `resolver`.
|
||||
- [ ] Two consecutive `--once` runs with the stubbed resolver perform one lookup set, not two.
|
||||
- [ ] `uv run pytest -k agent` **100% passing**; the whole suite green; coverage not down.
|
||||
- [ ] README.md/AGENTS.md updated for the new agent section and flag (this task's own user-visible surface) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: every pre-1.3.0 section is byte-identical for the same `--root` (formally proven in task 02's control).
|
||||
@@ -0,0 +1,52 @@
|
||||
# Task 02 — Ground truth for the resolution, and a control that proves 1.3.0 is only additive
|
||||
|
||||
**Phase:** `32_gateway_resolved_target_liveness` · **Source:** AGENTS.md invariant 7 ("verify against the deployed upstream; never guess a metric name") + the phase-28 additivity precedent
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Two things this phase must be able to point at: a **captured** resolution document from the real gateway, recorded through the same no-privilege, never-a-fixture-on-a-bad-read discipline `scripts/capture-gateway-config.sh` already keeps; and a **control** that proves agent 1.3.0's snapshot equals 1.2.0's key-for-key plus `resolver` — so the upgrade of a live ingress is provably additive rather than "the page still looks fine".
|
||||
|
||||
## Work
|
||||
|
||||
1. `scripts/capture-gateway-config.sh`
|
||||
- After the existing config captures, derive the target set **from the repo's own parser**, never by re-implementing one:
|
||||
```python
|
||||
# The name list is what the agent will ask for, from the configs we just captured — one
|
||||
# source of truth. If `target_names()` changes, this capture follows it, and a fixture
|
||||
# that lists a name the agent no longer resolves is a fixture that fails a test.
|
||||
from agent.host_agent import target_names
|
||||
```
|
||||
(`bash` calls `uv run python - <<'PY'`, same interpreter the tests use.)
|
||||
- One **world-readable, unprivileged** read per name, over the same `$GATEWAY_SSH` alias, which is libc resolution *on the box that dials* — the exact vantage `via: "libc"` claims:
|
||||
`ssh "$GATEWAY_SSH" getent ahostsv4 <name>` then `getent ahostsv6 <name>`. No `sudo`, no `dig`/`host` assumption, no copy tool.
|
||||
- `config_directive` is read from the **already captured** nginx text (the `resolver` directive's arguments) — no extra round trip, and the fixture then matches what the agent will read out of the same bytes.
|
||||
- Write `tests/fixtures/gateway_resolver.json` in the agent's own entry shape, sorted in the agent's order, with `ts` set to a fixed capture constant so the fixture is byte-stable, and:
|
||||
- **names verbatim** — the domains are the ingress map, exactly as the config fixtures keep them;
|
||||
- **addresses scrubbed by the socket table's rule**: keep RFC 1918 / ULA / loopback, rewrite any address `ipaddress` calls globally routable into documentation space (RFC 5737 / RFC 3849), first-seen order, same spelling, **idempotent** — invariant 6, the same reason `/proc/net/tcp` is scrubbed (a global address is not this repo to publish);
|
||||
- a `nxdomain` is recorded as a **status**, not dropped.
|
||||
- Failure discipline, unchanged in spirit and pinned: a failed `ssh`, a missing `getent`, or **every** name failing → exit 1 having written nothing (a broken vantage is not a fixture); a half-written file is removed. One name failing among successes is a fact and is kept.
|
||||
- `--help`/usage comment updated in the same edit (the script's header is the documentation `README.md` points at).
|
||||
2. `tests/fixtures/gateway_resolver.json` — the capture itself, committed.
|
||||
3. `data/logs/verify-32-additive.py` (copy the shape of `data/logs/verify-28-final.py`)
|
||||
- `git stash`-safe pattern: load `agent/host_agent.py` from `git show HEAD:agent/host_agent.py` into a temp file and import it side by side with the working tree's, via `importlib.util.spec_from_file_location` under distinct module names.
|
||||
- Build one fake tree (`nginx_tree`-shaped) plus the haproxy capture, monkeypatch `socket.getaddrinfo` in **both** modules to the same deterministic stub, call `snapshot()` on both with identical arguments, and demand:
|
||||
- `new == old | {"resolver": …}` — every pre-1.3.0 key equal, `resolver` the only addition;
|
||||
- `new --no-resolve == old` **exactly**;
|
||||
- no key removed, renamed, or reordered-meaning-changed anywhere, printed as a diff on failure.
|
||||
- `config_directive` lives inside `resolver` (not inside `nginx`) precisely so this comparison stays a strict top-level key test with no exception list — record that reason in the script's header so nobody later "tidies" the field into the nginx section and weakens the control.
|
||||
- Transcript: `data/logs/32-additive.out`, exit non-zero on any difference.
|
||||
4. `tests/test_deploy_scripts.py` (or the capture-script test module that already covers this script)
|
||||
- Extend the existing pins: the script names `getent` and no `sudo`; it derives the name list by importing `target_names` (so the capture cannot out-ask the agent); a forced-`ssh`-failure run exits 1 and leaves the existing fixture byte-identical; a re-run over the same inputs is idempotent; the new fixture carries no globally routable address (`test_the_gateway_fixtures_carry_no_routable_endpoint`-style walk, extended to JSON).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; `tests/test_agent.py` and `tests/test_gateway_source.py` both consume `gateway_resolver.json` (task 03 wires the second).
|
||||
- Coverage: shell + fixtures, so the gate is the existing script pins plus no Python regression; `bash -n scripts/capture-gateway-config.sh` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `./scripts/capture-gateway-config.sh` run on a box with the `gateway-ops` key writes `tests/fixtures/gateway_resolver.json` and exits 0; the file has one entry per name `target_names()` derives from the captured configs, `via: "libc"`, `config_directive: "10.1.0.1"`, and no globally routable address anywhere in it.
|
||||
- [ ] Killing the alias (or `GATEWAY_SSH=nope`) exits non-zero with every existing fixture byte-identical (`sha256sum` before/after, printed).
|
||||
- [ ] `uv run python data/logs/verify-32-additive.py` exits 0 and its transcript shows the two documents equal plus `resolver`, and the `--no-resolve` arm equal exactly.
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md's capture section and AGENTS.md's invariant-7/fixture prose name the new capture output (this task's user-visible change) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: the six existing fixtures are unchanged unless the live router's config genuinely moved, in which case that drift is recorded in the transcript and `tests/test_gateway_source.py`'s tallies are re-counted in the same commit (never edited to match a live read).
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Task 03 — The join counts a name route through the address the gateway itself dials
|
||||
|
||||
**Phase:** `32_gateway_resolved_target_liveness` · **Source:** operator complaint — "the only animated line is `:443 -> 127.0.0.1:4443`"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`sources/gateway.py` reads the agent's `resolver` section and widens the one rule that decided a route could never be counted: an endpoint is joinable when the config wrote an **address** *or* when the gateway's own libc resolution published one for the name. `connections[]` grows three provenance fields, name-keyed gauges start carrying measurements, and the two rules that made phase 30 honest stay exactly where they were — the dashboard still never resolves, and a pipe nobody measured still publishes **no** sample.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/sources/gateway.py`
|
||||
- New pure reader `resolution_index(snapshot) -> dict[tuple[str, int | None], Mapping[str, Any]]`, keyed on `(name, port)` from `snapshot["resolver"]["entries"]`, ignoring entries with no `addresses` (a `nxdomain` contributes an empty set, never a `None` address). Absent `resolver` section (a 1.2.0 agent, or `--no-resolve`) → `{}`, which is exactly today's behaviour and must be pinned with a fixture that has no `resolver` key at all.
|
||||
- New pure pass `apply_resolution(connections, index, via, ts)` — called between `config_connections()` and the socket join — writing on each connection's `out`:
|
||||
- `resolved`: the address list, or `null` when nothing published one (never `[]`, so the page's "did not resolve" arm is one `is null` test rather than a length check that confuses empty with unknown);
|
||||
- `via`: `"libc"` (the agent's string, copied, never invented here) and `resolved_ts`: that entry's own timestamp;
|
||||
- `joinable`-driven `metric`, widened:
|
||||
```python
|
||||
# A line is countable when the kernel can name its far end: either the config wrote an
|
||||
# address, or the gateway's own libc resolution — the one that opens this connection —
|
||||
# published one. Phase 30 retired name-keyed gauges because they could only ever be
|
||||
# empty; this is the fact that made that untrue. The NAME keeps the config's spelling so
|
||||
# the stored series is the same one phase 29/30 would have written.
|
||||
```
|
||||
`metric` stays `gateway.out_conns@<host>:<port>` with the **config's** host spelling (history continuity, and `:25565`/`:25566` stay two gauges), and stays `null` for a line whose address is unknown.
|
||||
- `endpoint_of(connection)` → returns a **list** of `(address, port)` keys: the written address, or every resolved address. `join_sockets()` builds its `endpoints` map by pointing **every** key at the **same** counter row, so a multi-homed name is one endpoint with several spellings in the kernel's table, and two lines reaching one endpoint still share one counter (`shared: true`, unchanged).
|
||||
- **Address comparison is normalised, spelling is not.** Match with `ipaddress.ip_address(...)` on both sides so `/proc`'s `inet_ntop` output, an IPv4-mapped `::ffff:10.1.2.64`, and a resolver's `10.1.2.64` are the same address; the **payload** keeps publishing whatever the config and the agent wrote, verbatim. No string equality anywhere in the match path — that is the bug class where a route resolves and still never counts.
|
||||
- `_skeleton()` gains `"resolver": {"available": None, "via": None, "entries": []}` so `parse` and `stub` keep one key set (the parity pin), and `parse()` fills `available`/`via` from the section while `entries` carries the entries the index was built from — the page reads provenance from the document it was drawn from, not from a second call.
|
||||
- `samples()`: unchanged logic, now wider in effect (a resolved name route emits its gauge). A `null` count still publishes nothing. De-duplication by metric stays, so a shared endpoint costs one insert.
|
||||
- `supported()` — a 1.3.0 snapshot must answer `supported: true` (its three proxy sections are what the check reads); add the assertion rather than assuming it.
|
||||
- `warnings()` untouched. The `/api/summary` block grows **no** field.
|
||||
2. `src/dashboards/collectors.py` — **the same commit**, per AGENTS.md's cross-module rule for a new add-on section:
|
||||
- `ADDON_ERROR_PREFIXES` gains `resolver`, so a `resolver: … nxdomain …` or `resolver: pass budget … exhausted` line is an *unconfigured* report on `/system`, not `degraded`, and never an overview WARN. Invariant 3, both directions pinned: a host with that line stays out of `alerts`, and a `nginx:`/`smart:` line still behaves as before.
|
||||
3. `tests/test_gateway_source.py`
|
||||
- `test_a_name_route_counts_through_the_address_the_gateway_published` — captured configs + `gateway_resolver.json` + the recorded `/proc/net/tcp{,6}`: a socket whose remote is `10.1.2.64:443` lands on `brain.reeseapps.com → reeseapps-brain-of-reese.reeselink.com:443`'s line, with `out.via == "libc"` and a `resolved_ts`.
|
||||
- `test_the_match_is_an_address_not_a_string` — the same address in `::ffff:` / leading-zero / uppercase spellings on either side still matches; the payload keeps verbatim spelling.
|
||||
- `test_two_names_one_endpoint_is_one_count_shared` and `test_two_ports_on_one_host_are_two_gauges` (`minecraft` `25565`/`25566`).
|
||||
- `test_an_unresolved_name_publishes_no_metric_and_no_sample` — `connections: null`, `metric: null`, zero samples, never `0`.
|
||||
- `test_the_dashboard_still_never_resolves` — `socket.getaddrinfo` spied and asserted uncalled through the whole `parse()` path, **and** a new comment naming where the resolver lives and why (vantage). This pin is restated, not relaxed.
|
||||
- `test_a_snapshot_without_a_resolver_section_is_phase_30_verbatim` — the 1.2.0 fixture: identical payload to today's except `resolver`/`out.resolved`/`via`/`resolved_ts` being absent/`null`, and `supported` still `true`.
|
||||
- The counted-once identity and the `sum(listeners) + sum(endpoints) <= established` inequality are restated with the widened match set, including the `127.0.0.1:4443` loopback hop counting once inbound **and** once outbound on two different pipes.
|
||||
- `tests/test_api.py`: the "no name-keyed `gateway.out_conns@…` series is ever inserted" pin is **rewritten** to the new rule (an endpoint whose address is known — written or resolved — gets its gauge; an unresolved name never does), and `test_the_summary_gateway_block_is_the_payloads_sums_not_the_pages_arithmetic` gains the resolved case so `live_out` still sums **distinct endpoints** (`api._endpoint_values()`), never lines.
|
||||
- `tests/test_live_fleet.py` (marked `live`): against the real ingress, every `connections[]` row whose resolver status is `ok` has a finite `connections` (a number, `0` allowed) — never `null`. Skips when `GATEWAY_HOST` is not a configured target.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit + fixture as above; `tests/test_gateway_source.py` keeps running the **real** `host_agent` parsers over the captures so the join is compared with the agent's document, not with a copy of its field names.
|
||||
- Coverage **>90%** on `sources/gateway.py` and `collectors.py`; whole suite 100%; `uv run ruff check src tests agent` and `uv run pyright` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Against the captures: the number of `connections[]` rows with a non-null `connections` rises from 2 (the two loopback targets) to every row whose name resolved in `gateway_resolver.json`, and `tests/test_gateway_source.py` prints that count so the change is visible rather than implied.
|
||||
- [ ] `grep -rn "getaddrinfo" src/dashboards/` finds nothing; `grep -n "resolver" src/dashboards/collectors.py` shows `ADDON_ERROR_PREFIXES` carrying it.
|
||||
- [ ] `GET /api/gateway/snapshot` on a fixture-driven store shows `out: {…, address: false, resolved: ["10.1.2.64"], via: "libc", resolved_ts: …, …}` with a finite `connections` on that row, and `null`/`null` on an injected `nxdomain` row.
|
||||
- [ ] The stored series for one shared endpoint is **one** insert per poll (asserted by counting rows), and `/api/summary`'s `gateway.live_out` equals the sum over distinct endpoints.
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md updated for the widened join rule and the metric-naming passage in the same commit (the "no name-keyed series" sentence is replaced, with the reason) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: `warnings()`, `/api/summary`'s key set, the inbound join, `/system`'s `degraded` for the other add-on prefixes, and every non-gateway source are untouched.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Task 04 — The dash now says *which* unknown it is, and the resolution rides on the tooltip
|
||||
|
||||
**Phase:** `32_gateway_resolved_target_liveness` · **Source:** phase-31 decision 8 ("the `—` keeps its meaning and loses its mystery") meeting this phase's new fact
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Two facts that used to share one glyph separate: **"the gateway could not resolve this name in this reading"** and **"resolved, and nothing is open on it right now"**. The first keeps the em dash with a sentence that names the cause; the second prints its `0`. Every counted line carries where its address came from (`libc`) and when, on `title` and in the `aria-label` — provenance the operator needs to explain a number, kept off the ink because the page's copy rule is *would you act differently without it?*
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/page-gateway.js`
|
||||
- Replace the single `NO_COUNT_WHY` with the two sentences the payload can now distinguish, chosen from data and never guessed:
|
||||
```js
|
||||
const UNRESOLVED_WHY = 'no number: the gateway could not resolve this name in this reading';
|
||||
const IDLE_TITLE = (n) => `${n} open connection(s) on this endpoint`
|
||||
+ (resolved ? ` · address from ${via}, resolved ${age}` : '');
|
||||
```
|
||||
`age` comes from `Dash.duration(Dash.ageSeconds(conn.out.resolved_ts))`; `null`/absent prints nothing rather than `NaN`, and `via` is the payload's word (`libc`) copied, not paraphrased.
|
||||
- `leafAria(conn, on)`: the unknown arm becomes `'connections not attributed: the gateway could not resolve this name in this reading'`; the counted arm keeps its existing facts and adds nothing else. A measured `0` keeps its own sentence (`0 open connections`) — never the unresolved wording, because it is a measurement.
|
||||
- `countText()` and the `—` glyph are unchanged; a `0` continues to print `0`.
|
||||
- **Filter amendment** (phase 31 task 04's `haystack`): include `conn.out.resolved` addresses, so `10.1.2.64` finds `brain.reeseapps.com`. They are payload strings, the filter is still an eye, and this is the one case where matching an address helps the operator — pinned with the rest of the haystack contract.
|
||||
- `cardStreams()` is untouched: a line with no `metric` still draws no series, and the chart legend still names only pipes that have points.
|
||||
2. `src/dashboards/web/pages/gateway.html`
|
||||
- Footer paragraph 2 is rewritten to the new truth and stays **one** paragraph (the file's three-paragraph shape is pinned in `tests/test_api.py`):
|
||||
> `a dash means the gateway could not resolve that name in this reading — hover a counted line for the address it matched and when that answer was taken.`
|
||||
- The card comment above `.gate-map` gains one line: `out.resolved` is the agent's libc answer and is why a name line can carry a number.
|
||||
- The `#gate-empty` setup block keeps naming agent **1.2.0** as the minimum (a 1.3.0 agent is not required for the page to work, only for name routes to count) — do not bump it here; the version floor is `sources/gateway.supported()`.
|
||||
3. `tests/test_api.py`
|
||||
- `GATEWAY_PAGE_FIELDS` gains `connections[].out.resolved`, `connections[].out.via`, `connections[].out.resolved_ts`, resolved against a live document in both directions (the existing device), so a rename on the Python side fails by name here.
|
||||
- **New** `test_an_unresolved_name_and_an_idle_route_say_two_different_sentences`: served JS picks the sentence from `out.resolved === null`, the two strings differ, neither is `n/a`, and a `0` is described as a measurement.
|
||||
- `test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero` stays green with its claim intact and its docstring amended to name the new cause (restated, not deleted).
|
||||
- **New** `test_a_counted_line_names_where_its_address_came_from`: the `title` builder reads `out.via` and `out.resolved_ts` through the shared age helpers, and a missing `resolved_ts` yields no age fragment rather than `NaN`/`undefined`.
|
||||
- The filter pin from phase 31 (`test_the_filter_matches_only_payload_text_and_costs_no_request`) is extended: the haystack includes `out.resolved` and still contains no `fetchJSON`, no DOM text, and no `"null"` matchable value.
|
||||
- `test_no_page_narrates_its_own_pipeline` must still pass on the new footer sentence (it names an operator-facing cause and a place to look, not the app's design); if it objects, shorten the sentence rather than touching the audit's allow-list.
|
||||
- The degradation-arm pin gains a case: `resolver: {available: null}` — no red, no extra ink, `why` still the page's one sentence.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: served-bytes pins above + a fixture render for each of the three arms (resolved+live, resolved+idle, unresolved) asserting the glyph, the `title`, and the `aria-label` differ exactly as specified.
|
||||
- Integration: with the resolver section stubbed to `available: null`, every name line keeps the unresolved sentence and the page stays uncoloured; no line anywhere prints `NaN`, `undefined` or `null`.
|
||||
- Coverage: no new Python in this task; `node --check` clean; floor does not fall.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] On live data: a counted name line's `title` reads `… · address from libc, resolved <age>`; an unresolved line (stub one) prints the em dash with the resolution sentence; a resolved line with no socket prints `0`.
|
||||
- [ ] Reading five lines' `aria-label`s aloud (or dumping them) yields three distinct claims and no invented number.
|
||||
- [ ] Typing `10.1.2.64` (or any address from the live `resolver` section) into `#gate-filter` reveals exactly the route(s) that resolve to it, with zero network requests.
|
||||
- [ ] `grep -c "<p" ` on the served footer is still 3 and `test_no_page_narrates_its_own_pipeline` is green.
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `node --check` clean; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md's `/gateway` dash sentence updated (this task's user-visible change) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: tallies, warnings panel, chart disclosure, switch, phase 31's colour and row order untouched.
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Task 05 — Roll the gateway to 1.3.0, read-only first, exactly as phase 28 moved it to 1.2.0
|
||||
|
||||
**Phase:** `32_gateway_resolved_target_liveness` · **Source:** AGENTS.md's fleet record (the rollout discipline that kept `host:gateway`/`disks:gateway` `ok` through phase 28) + the standing rule that a path grant is only as current as the file it points at
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
One host moves: `gateway` runs agent **1.3.0** on its existing bundle and its existing unit, and the live page gets real counts on its name routes. Nothing else moves — no `HOST_TARGETS` edit, no certificate, no URL flip, no other host (a 1.1.0 `deskwork`/`driveripper` is unaffected, and a 1.2.0 gateway left running after a failed attempt is a working gateway). The transcript is the artifact.
|
||||
|
||||
## Work
|
||||
|
||||
1. **Read-only probe first** — `data/logs/verify-32-task05-probe.py` (copy `data/logs/verify-28-task04.py`), printing and asserting:
|
||||
- the CA-only `--status`-style probe against `https://10.1.1.27:9120` answers `version` **1.2.0** on `/health`, and still refuses `/snapshot` with `client certificate required`;
|
||||
- the dashboard's own mTLS dial (`collectors.fetch_snapshot` with `certs.agent_tls_map` over the stored authority — the `verify-28-task05.py` idiom, never a hand-rolled `client.get`) reads every pre-1.3.0 section with live numbers and **no** `resolver` key: the control that makes the install provably additive before anything is touched;
|
||||
- `/api/health` reads `host:gateway`, `disks:gateway` `ok` before the change, and the `gateway` job's payload counts how many rows carry a number (the before figure).
|
||||
2. **Re-stage, then install** over the `gateway-ops` key, as `ops`:
|
||||
- `scp agent/host_agent.py deploy/install-agent.sh → /home/ops/agent-install/…` (the directory is `ops`-owned; no root), leaving the previous files beside the grant as `host_agent.py.20261023-backup` / `install-agent.sh.20261023-backup`, which sudo will not run;
|
||||
- read the live unit back (`systemctl cat dashboards-agent`) and run the **same** arguments it carries:
|
||||
`sudo /home/ops/agent-install/deploy/install-agent.sh --bind 10.1.1.27 --port 9120`
|
||||
— no `--cert-bundle` (the bundle on disk, not the flag, is what puts the TLS lines into the render — the phase-28 finding, quoted in the transcript), no `--as-root`/`--caps` (that is `driveripper`'s shape, not this host's);
|
||||
- expect the re-run guard to stay silent (identical render). `hashsum.sha256` the unit before and after and record both; a differing unit means the run did something this phase did not plan, so stop and report rather than `--force`ing;
|
||||
- `sudo systemctl restart dashboards-agent` (the file changed; `enable --now` would not restart it), then `systemctl is-active`.
|
||||
3. **Verify both ways, read-only**:
|
||||
- the CA-only probe answers `version 1.3.0` and still refuses `/snapshot`;
|
||||
- the dashboard's mTLS read shows `agent_version: "1.3.0"`, `resolver.available: true` with an entry per declared target, `haproxy`/`nginx`/`sockets` and every pre-1.3.0 section intact with live numbers, and `/api/gateway/snapshot` answering a **finite** `connections` on every row whose resolver status is `ok` (the after figure, printed beside the before figure);
|
||||
- `./scripts/dev-server.sh status` → all ten sources `ok`; `/system` shows no new `degraded` on any host (the `resolver:` prefix, invariant 3);
|
||||
- a failure at any step is a rollback, not a fix-forward: copy `host_agent.py.20261023-backup` back to `/usr/local/lib/dashboards-agent/host_agent.py` and restart — the unit, the bundle and the URL never changed, so that *is* the whole rollback. Record which arm happened.
|
||||
4. `data/logs/NOTES-32-rollout.md` — sha256 of what was staged where, the unit hash before/after, the before/after "rows with a number" counts, the live `resolver` entry table, the `nxdomain` list if any, the screenshots' names, and the rollback command. The AGENTS.md fleet record's version spread is task 06's edit; this note is the evidence it cites.
|
||||
5. **Do not** touch `deskwork` or `driveripper`. Their 1.1.0 agents have no proxy sections and nothing to resolve; the spread costs nothing because 1.3.0 is additive, and task 02's control is the proof of that claim.
|
||||
|
||||
## Testing & Quality
|
||||
- This task changes no repo code; its quality gate is that the suite is still green afterwards and the two probe scripts exit 0 with their transcripts saved (`data/logs/32-task05-probe.out`, `data/logs/32-task05-verify.out`).
|
||||
- `uv run pytest -m live --live` afterwards: the new live pin (every resolved name route carries a number, never `null`) passes rather than skips.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `systemctl is-active dashboards-agent` is `active` on `gateway`, and the CA-only probe answers **1.3.0**.
|
||||
- [ ] The unit's sha256 is identical before and after the install run, and the guard printed no diff.
|
||||
- [ ] `/api/gateway/snapshot` on the live ingress: `resolver.available: true`, and **0** `connections[]` rows with `connections: null` among the rows whose resolver status is `ok`; the before/after counts are in the transcript.
|
||||
- [ ] `host:gateway` and `disks:gateway` read `ok` across the whole exercise, and no other host changed `degraded` state.
|
||||
- [ ] `data/logs/NOTES-32-rollout.md` exists with the hashes, the two probe transcripts, and the rollback command named.
|
||||
- [ ] `uv run pytest` **100% passing**; `uv run pytest -m live --live` green (the gateway pin passing, not skipping).
|
||||
- [ ] No repo file changed except `data/logs/` (gitignored).
|
||||
@@ -0,0 +1,47 @@
|
||||
# Task 06 — Docs that match the counted page, the browser measurement, and the full gate
|
||||
|
||||
**Phase:** `32_gateway_resolved_target_liveness` · **Source:** AGENTS.md definition of done (browser verification against live data, plus a full `README.md`/`AGENTS.md` consistency pass)
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Prove the counted page in a browser on the upgraded ingress, carry the resolution facts into the two documents that outlive this conversation, and close the phase with every gate green — including the ones that only bite if something drifted (dependency freshness, the mobile audit, the live suite).
|
||||
|
||||
## Work
|
||||
|
||||
1. `data/logs/32-counted-routes.cjs` — a Playwright pass over the live page (house shape: `NODE_PATH=<playwright node_modules>`, `DASH_BASE`/`DASH_PW`, sign in with `DASH_ADMIN_PASSWORD`, exit non-zero on FAIL, print every measured number):
|
||||
- **motion where the complaint said there was none**: count the animating elements on load (`getComputedStyle(path).animationName === 'pipe-flow'`) and assert the set is non-empty **and** that at least one of them is a **name** endpoint (a `connections[]` row whose `out.address` is `false`); record the counts per panel;
|
||||
- **provenance**: for 5 sampled rows, `title` contains `address from libc` and an age fragment, and `aria-label` states a number where the payload has one and the resolution sentence where it does not;
|
||||
- **no invented zero**: with a stubbed `resolver: {available: null}` and with `sockets.available: null`, no line prints `0` that the payload did not measure, and the trunk prints `n/a`;
|
||||
- **the filter still costs nothing**: query by an IP from the `resolver` section and by a domain, asserting zero network requests and unchanged tallies;
|
||||
- **colour is still existence**: every drawn route — spine, tick, run, switch — computes to `--gate-on` in both themes, and a row given `off` in devtools turns all four together (phase 31's contract, re-asserted after this phase's DOM/provenance changes);
|
||||
- **reduced motion**: nothing animates under emulated `prefers-reduced-motion: reduce`;
|
||||
- **degradation arms**: `unreachable`, `supported: false`, `sections {nginx: false}`, `resolver {available: null}`, `404 {configured:false}` — each in the payload's own words, none red;
|
||||
- **no sideways scroll** at 1440 px and 360 px with the widest fan-out expanded and both charts open;
|
||||
- screenshots `data/logs/32-gateway-{1440,360,counted,light,unresolved,filtered,reduced,phone}.png`, transcript `data/logs/32-counted-routes.out`.
|
||||
2. Re-run `data/logs/27-task02-mobile-audit.cjs` (nine pages, both widths) — exit 0, **0 FAIL / 0 FINDING**, tally recorded in the transcript.
|
||||
3. `AGENTS.md` — update every passage this phase made stale, in full:
|
||||
- the agent bullet: the `resolver` section (its `available` three-way and one-line-per-arm rule, `via: "libc"`, `config_directive`, `RESOLVE_MAX_NAMES`/`RESOLVE_CACHE_SECONDS`/pass budget, the single-flight background pass and *why* it is off the request path, `--no-resolve` removing the key in silence, and the never-resolve-anything-but-config-targets rule), `AGENT_VERSION` **1.3.0**, and the `/schema` addition;
|
||||
- `sources/gateway.py`'s join paragraph: two address sources (written-as-address and resolved), `out.resolved`/`via`/`resolved_ts`, normalised-not-string matching, the shared rule's now-real case, and the sentence that the dashboard **still** never resolves with the vantage reason;
|
||||
- the metric-naming passage: replace "no name-keyed `gateway.out_conns@…` name is ever inserted" with the widened rule and the reason it changed, keeping the note that an old name-keyed series stops growing rather than being rewritten;
|
||||
- `collectors.py`'s `ADDON_ERROR_PREFIXES` list gains `resolver`, beside the "a new add-on section adds its prefix here in the same commit" rule;
|
||||
- the `/gateway` UI-conventions paragraph: the dash now has two meanings and one glyph, provenance lives on `title`, the filter matches resolved addresses;
|
||||
- the **fleet record**: `gateway` runs **1.3.0**, `deskwork`/`driveripper` remain **1.1.0**, what the spread costs (nothing, with the additivity control named), the ten sources `ok` re-verified, and a pointer to `data/logs/NOTES-32-rollout.md`;
|
||||
- **gap list**: add the two follow-on lines (name-route switching = phase 33, amends invariant 1 and needs `PLAN.md` sign-off) and re-check gap 13's clip counts against this phase's measurements.
|
||||
4. `README.md` — the `/gateway` table row (name routes are counted through the gateway's own libc resolution), the gateway prose (the resolver section, its bounds, `--no-resolve`, what a dash means now), the capture-script section (its new output and its scrub rule), and the agent-version references wherever the page's minimum and the fleet's actual are named — kept as two different facts, not one number.
|
||||
5. Freshness and the whole gate:
|
||||
`uv sync --python 3.14 && uv pip list --outdated` → empty (this phase adds **no** dependency; if `cryptography` or anything else moved, that is a separate decision);
|
||||
`uv run pytest`, `uv run pytest --cov`, `uv run pytest -m live --live`, `uv run ruff check src tests agent`, `uv run pyright`, `bash .agents/validate.sh`.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: this task **is** the live proof of the phase's headline claim; every number in the Completion Criteria is printed by `32-counted-routes.out` or a probe transcript, never asserted in prose.
|
||||
- Coverage: the floor is met on the phase's modified Python (`sources/gateway.py`, `collectors.py`, `agent/host_agent.py`); the run is recorded.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `data/logs/32-counted-routes.out` exits 0 and prints: animating-element counts per panel with at least one **name** endpoint among them, the five provenance `title`s, the zero-invention arms, the filter's zero-request proof, the both-themes colour equality, the reduced-motion pass, and 0 px sideways scroll at both widths.
|
||||
- [ ] The eight `data/logs/32-gateway-*.png` screenshots exist, including `counted` (name routes with numbers) and `phone`.
|
||||
- [ ] `data/logs/27-task02-mobile-audit.cjs` exits 0 with 0 FAIL / 0 FINDING, tally recorded.
|
||||
- [ ] `uv pip list --outdated` is empty; `uv run pytest` and `uv run pytest --cov` green above the floor; `uv run pytest -m live --live` green; `ruff` + `pyright` clean; `bash .agents/validate.sh` green.
|
||||
- [ ] `grep -n "1.3.0" AGENTS.md` shows the agent bullet **and** the fleet record; `grep -n "no name-keyed" AGENTS.md README.md` finds nothing (the retired sentence is gone, not contradicted); `resolver` appears in `ADDON_ERROR_PREFIXES`' documented list.
|
||||
- [ ] A full read of `README.md` and `AGENTS.md` finds no inconsistency anywhere in either file, including pre-existing ones — fixed in this phase's commits.
|
||||
- [ ] No behaviour change in completed work: `/`, `/system`, `/disks`, `/llm`, `/ups`, `/settings`, `/certs`, `/login` render as before and the unwired switch stays unwired (phase 33 wires it).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Phase 33 — The switch is wired: turning one route off, and back on, without ever weakening the box
|
||||
|
||||
**Source:** operator request, 2026-10-23 — "Also write the phase to disable and enable services. I want to be able to turn off and on connections as needed in the event of security concerns or upgrades." + the two decisions taken with them in that conversation: **enforcement is a config edit, validated and reloaded, by a narrow root helper on the gateway** (so a disabled route stays disabled across a reload *and* a reboot), and **one switch per connection line, as already drawn**.
|
||||
**Story:** n/a
|
||||
**Context:** `deploy/install-agent.sh` (the unit template, whose `NoNewPrivileges=true` / `ProtectSystem=strict` / `SystemCallFilter=@system-service` / empty `CapabilityBoundingSet` this phase **does not touch**, the `--force` re-run guard, `deploy/agent-sudoers` as the precedent for a path-pinned grant), `agent/host_agent.py` (1.3.0 → **1.4.0**: `do_GET`-only `Handler`, `_certificate_allows`, the `haproxy`/`nginx` parsers that must learn the disabled marker), `src/dashboards/sources/gateway.py` (`connections[]`, `config_connections`, `warnings()`' seven kinds), `src/dashboards/api.py` (`/api/ups/command`'s four-gate shape and the `certs_router()`'s audit-event rule), `src/dashboards/collectors.py` (`AgentTls`, `fetch_gateway_snapshot`, the revoke-before-dial rule), `src/dashboards/config.py` (`GATEWAY_*` block), `src/dashboards/web/static/page-gateway.js` (`buildSwitch`, `routeInConfig`, `renderLeaf`), `tests/test_api.py` (the verb-enumeration pins), `tests/test_auth_api.py` (the served-route walk), `tests/test_deploy_scripts.py` (the fake-root sandbox the helper's tests extend).
|
||||
|
||||
## Objective
|
||||
|
||||
`/gateway`'s switch becomes a control: **disable** `brain.reeseapps.com` and the nginx `stream` map entry stops routing it, persistently, with the config validated and the proxy reloaded and the page showing that exact route **red** until it is re-enabled. One new write route, `POST /api/gateway/route`, gated seven times, with the write itself performed off-box by a **root helper on the gateway that the agent reaches over a systemd-activated unix socket** — because the agent's own unit forbids privilege (`NoNewPrivileges=true`) and this phase is not going to weaken the sandbox of a read-only collector to give it a root shell.
|
||||
|
||||
This amends AGENTS.md invariant 1 from *one* exception to **two**, and that is the phase's real risk: a bug here is an outage on the fleet's only ingress. Everything below exists to make the blast radius one line of one file, and to make the app refuse rather than improvise whenever it cannot see what it is doing.
|
||||
|
||||
## Scope decisions taken with the operator (binding for this phase)
|
||||
|
||||
| # | Decision | Consequence |
|
||||
|---|----------|-------------|
|
||||
| 1 | **Invariant 1 becomes "two exceptions".** | `POST /api/ups/command` and `POST /api/gateway/route`. Both: session, CSRF/origin on a cookie write, an env gate (`NUT_ALLOW_ACTIONS` / `GATEWAY_ALLOW_WRITES`, default **off**), a typed `DANGER`, and every gate re-checked server-side on every request. `PLAN.md`'s anchor table records a read-only app, so this phase's overview carries the sign-off request; the phase never edits `PLAN.md`. |
|
||||
| 2 | **`DANGER` is required for both verbs.** | Enabling is not the safe direction: applying either change re-validates the file and **reloads the proxy**, and a reload touches every route on the box. A word that means "I know this affects the ingress" has to mean it both ways. |
|
||||
| 3 | **The agent keeps its sandbox; the privilege lives outside it.** | No sudo from the agent, no setuid, no change to `NoNewPrivileges`/`ProtectSystem`/`SystemCallFilter`. The write peer is `dashboards-routectl.socket` + `dashboards-routectl@.service` (systemd socket activation, `Accept=yes`, `User=root`, `SocketUser=dashboards-agent`, `SocketGroup=dashboards-agent`, `SocketMode=0600`): the agent connects to a socket it is *filemode-permitted* to connect to, and no other uid on the box can even open it, and the helper proves the peer uid with `SO_PEERCRED`. Access control is a file mode plus the mTLS client certificate that already authorises the agent's other paths — not a capability the agent process can be tricked into. |
|
||||
| 4 | **Only an id crosses a wire. Never a path, never a line, never text.** | The body is `{id, action, danger?, dry_run?}` where `id` is the payload's own `connections[].id`. The helper re-parses the configs itself, maps that id to the one line it declared, and refuses anything it cannot map. There is no `file`, no `line`, no `pattern`, no `command` anywhere in the contract, and the argv to the helper is fixed with no shell. |
|
||||
| 5 | **A route is off in the way that actually stops traffic for its proxy.** | haproxy: the `server` line is commented with a `# dashboards:off … was: <verbatim original>` marker. nginx vhost: the `proxy_pass` line is commented the same way. nginx **stream `map` entry**: commenting it would fall through to the map's `default` (`127.0.0.1:4443`) and **mis-route** the domain instead of blocking it, so the helper rewrites that entry's value to a sink (`127.0.0.1:44450`) and keeps the original in the marker; the sink is a `server` block the helper maintains in `/etc/nginx/stream.d/00-dashboards-sink.conf`, which the existing `stream { include /etc/nginx/stream.d/*.conf; }` glob already loads — so **no hand edit of the main config is ever required**. The map's `default` entry is **refused**: it is the fallback for every unmapped name, not a service. |
|
||||
| 6 | **Validate, restore on failure, reload — never restart.** | Write the new bytes, run `nginx -t` / `haproxy -c -f …`; on failure put the previous bytes back, do **not** reload, and answer `applied: false, validated: false, restored: true`. On success `systemctl reload <unit>` — reload, because a restart cuts every Minecraft session and every in-flight upload on an ingress that is doing its job. A `reload` failure is reported with the unit's own status and the file is left in the applied state (the config is correct; the reload is the operator's next action, said out loud). |
|
||||
| 7 | **Self-describing marker + drift refusal, not silent trust.** | The disabled line carries the original verbatim after `was:`, so a human can restore it by eye and the helper can restore it mechanically. A sidecar `/var/lib/dashboards-routectl/state.json` (`0600`, root) records actor, timestamp and the sha256 of what was written. Enabling restores only if the current line still matches what the helper wrote; otherwise it **refuses** and reports `drift` — a file an operator hand-edited is theirs to fix, not the app's to guess at. |
|
||||
| 8 | **Refuse rather than act blind.** | The write is refused when: writes are not enabled; the id is not in the **current** payload; the route is already in that state (`changed: false`, no reload); either config section is not `available: true`; the `gateway` job is `ok: false` or stale beyond 3× its interval; the `host:<GATEWAY_HOST>` system job is not `ok`; the agent is revoked (refused before any socket, the existing rule); the throttle is tripped. That is the UPS "refused while on battery" rule, translated: do not rewrite a config you cannot currently read end to end. |
|
||||
| 9 | **`off` finally means something the payload said.** | The agent's parsers learn the marker and report the marked `server`/`proxy_pass`/`map` entry with `disabled: true`, so the route stays in the graph as a declaration and `connections[].enabled` becomes **`false`**. `routeInConfig()` stops being a literal, the `off` styling phase 30 built becomes reachable, and `warnings()` learns the marker: a backend emptied by a marker is not `haproxy_backend_empty` (the operator chose that; the page shows it red), while a backend with no `server` line at all still is. |
|
||||
| 10 | **A live route's count is never stamped.** | Disabling does not write `0` into the payload. The sockets drain on the reload and the next poll reports what `/proc` says. Invariant: no code path in this phase writes a count. |
|
||||
| 11 | **The audit is one event, fixed in shape, with no config text in it.** | `gateway`-source events `route-disabled` / `route-enabled` / `route-denied`: verb, id, the session's actor name, `applied`/`validated`/`reloaded`/`drift`, and the proxy's own one-line error when there was one. The unified diff is returned to **the caller's response** (it is what the confirm dialog shows) and never stored — events are served to a browser and `/api/events` is a feed, not a config archive. |
|
||||
| 12 | **The overview is told, because "something is switched off" is not a page-only fact.** | `/api/summary`'s `gateway` block gains **one** field, `disabled` (an int count of `enabled is false` rows, `null` in the arms that did not measure) — the thirteenth key, restated in the pinned key set with its reason. A notifier has to learn about a deliberate outage without scraping a page, and it prints nothing at `0` (the `warnings` rule: a permanent `0 switched off` is ink that trains the eye). |
|
||||
| 13 | **One route, one file, one reload per request.** | A request touches exactly one config file and reloads exactly one proxy. There is no batch verb, no "disable all to this host", no `dry_run` that writes. If a security incident needs five routes off, that is five reviewed requests, each with its own event. |
|
||||
|
||||
**Out of scope:** editing anything a marker cannot express (adding/removing vhosts, certificates, `bind` lines, the map's `default`), haproxy runtime `disable server` via a stats socket (a runtime-only state that a reload silently undoes — decision 5 is persistent instead), disabling an entire **host** or **port**, per-user authorisation (one admin, gap 6), a scheduler ("off at 02:00"), and any change to the agent's unit security directives or to another host's agent (`gateway` alone runs 1.4.0; `deskwork`/`driveripper`'s 1.1.0 agents have no routes and are unaffected).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `32_gateway_resolved_target_liveness` (todo, **must complete first**) — agent 1.3.0's resolver section and the counted name routes are what a switched-off line reads against: `off` + a draining count to `0` is the proof the switch worked, and without resolution a disabled route and a never-counted route look identical.
|
||||
- `31_gateway_route_readability` (todo) — the row that names the inbound domain, the green end-to-end route, and the `off` styling this phase finally reaches.
|
||||
- `30_gateway_connection_map` (complete) — `connections[].id` as the vocabulary, the switch markup and its a11y contract, `routeInConfig()` as the named seam.
|
||||
- `15_agent_mtls` / `24_dev_mtls_rollout` (complete) — the verified-client-certificate gate the new POST path inherits, and the revoke-before-dial rule this phase's collector reuses.
|
||||
- `11_admin_login` (complete) — session, CSRF, origin checks, `LoginThrottle` (the `route:<address>` bucket is its third key shape).
|
||||
- **Operator actions, not repo work:** the `dashboards-agent`-to-helper socket grant lands through the units (no sudoers line needed), but installing units on a host and running the first live drill are the operator's — and the drill **stops** unless they are present. `PLAN.md`'s anchor sign-off for invariant 1 is theirs too.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_the_root_helper_edits_exactly_one_route.md` — `deploy/dashboards-routectl.py`: `show`/`list`/`diff`/`disable`/`enable`/`verify`, the marker with `was:`, the sink file it manages, validate-restore-reload, drift refusal, `--serve` over a systemd-passed socket, `--root` for tests.
|
||||
2. `02_the_privileged_peer_is_a_socket_not_a_sudo_line.md` — the two systemd units + `install-agent.sh --with-routectl`; the proof that the agent's `NoNewPrivileges=true`/`ProtectSystem=strict` unit is unchanged and can still reach the socket.
|
||||
3. `03_the_agent_accepts_one_write.md` — agent **1.4.0**: `POST /route` behind the verified client certificate **and** `--allow-writes`, body validation, one fixed peer call, plus the parsers reporting `disabled` for a marked line; `/schema` and the additivity control.
|
||||
4. `04_the_source_publishes_enabled_and_the_warnings_learn_the_marker.md` — `connections[].enabled`, `off` reachable in the payload, `warnings()` marker-aware, stub/skeleton parity, the one new `/api/summary` field (`gateway.disabled`), and no count ever written.
|
||||
5. `05_the_api_gates_the_write_ten_times.md` — `GATEWAY_ALLOW_WRITES`, `DASH_READ_ONLY`, `collectors.post_route`, `POST /api/gateway/route`, the ten-gate order, the throttle, the events, `/api/config.gateway.writes_enabled`, and the verb-enumeration + served-route pins restated (this is where invariant 1 is amended in `AGENTS.md`).
|
||||
6. `06_the_switch_obeys.md` — `page-gateway.js` + `gateway.html` + `dash.css`: `DANGER` confirm with the diff from a `dry_run` first, patch from the response, `routeInConfig()` reading `enabled`, the writes-disabled arm that keeps the switch honest, footer paragraph 3.
|
||||
7. `07_rehearse_then_drill_on_the_live_ingress.md` — the fake-tree rehearsal, then the live install on `gateway` and a drill on the one named-safe route, restore, verify, `data/logs/NOTES-33-switching.md`. Stops, correctly, without the operator present.
|
||||
8. `08_docs_and_live_verify.md` — `AGENTS.md` (invariant 1, the agent bullet, `sources/gateway.py`, `api.py`, `collectors.py`, `config.py`'s block, the `/gateway` UI paragraph, the fleet record, gap list) + `README.md` (including the operator's sudo/units steps), the browser pass, the mobile audit, the full gate.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- **The helper is tested unprivileged, against a fake tree, with the proxy commands stubbed.** `tests/test_routectl.py` runs the real helper in a `tmp_path` tree (`--root`) with `nginx`/`haproxy`/`systemctl` faked on `PATH`, and pins: one line changed and nothing else in the file (byte-diff assertion); `was:` restores verbatim; a failing validator restores the exact previous bytes and reloads nothing; `default` refused; an unknown id refused; a second disable `changed: false`; the sink file created on first use, left alone if an operator's file is there, removed only when it wrote it and nothing is disabled; drift refused rather than restored; `SO_PEERCRED` refused for a non-`dashboards-agent` uid in the fake-peer test; no `shell=True` anywhere (`grep` pin, the installer's precedent).
|
||||
- **The agent's write path is proven against a fake peer, not a real one.** `tests/test_agent.py` runs the real handler over a TLS loopback port (the phase-15 harness) plus a unix-socket stub: `POST /route` without a client certificate is 403 `client certificate required`; with `--allow-writes` absent it is 403 `writes are not enabled on this agent`; a body with an unexpected key, a bad id, or a missing `danger` is refused **before** the peer is touched (asserted by the stub logging nothing); `GET` behaviour is byte-identical to 1.3.0 (the additivity control re-run).
|
||||
- **The API gates are proven twice, as the UPS ones are.** `tests/test_api.py` walks every gate with spies: writes-disabled refuses without dialling; a stale `gateway` job refuses; a revoked agent refuses without a socket; `DANGER` is required for `disable` **and** `enable`; a right request produces one peer call, one event, and the response's `applied`/`validated`/`reloaded` triple. `tests/test_auth_api.py`'s served-route walk demands a session for `/api/gateway/route`, and the CSRF pin is extended to it on the **wire** (a scripted helper records nothing without the header, once with it) — a 403 after the write is an outage with an error message attached.
|
||||
- **Live, opt-in, and reversible:** `-m live --live` gains `test_the_switch_round_trip_on_one_named_route` which is **skipped unless `--allow-live-writes` is passed as well**, names the route in the skip reason, and always restores in a `finally`. The default live suite stays read-only (AGENTS.md's rule survives this phase; the drill belongs to task 07 with the operator watching).
|
||||
- Coverage **>90%** on `deploy/dashboards-routectl.py`, `agent/host_agent.py`, `sources/gateway.py`, `collectors.py`, `api.py`; the whole suite 100%; `bash .agents/validate.sh` green.
|
||||
|
||||
## Documentation & Consistency
|
||||
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it will stand after this phase and fixes every inconsistency found, **including pre-existing ones**. This phase's specific list: **invariant 1** rewritten to two exceptions with the seven gates named (and the sentence "hiding a button is not a safety control" kept, because it is the reason the switches are gated server-side rather than greyed in JS); the agent bullet's `POST /route` and the `disabled` marker; the `sources/gateway.py` bullet's `enabled`/`off` rules and the marker-aware `warnings()`; the `api.py` bullet's write lists (fleet writes are now two); the `collectors.py` bullet (`post_route`, the revocation check it shares); `config.py`'s `GATEWAY_ALLOW_WRITES` row and README's configuration table; the `/gateway` UI-conventions paragraph (the switch is wired, what `off` looks like, what the writes-disabled arm says); the fleet record (gateway **1.4.0**, its unit's security directives unchanged, the two new systemd units, `NOTES-33-switching.md`); and the gap list (name-route switching **closed**, plus the honest limits: one admin, no scheduler, no per-host kill).
|
||||
- `README.md` gains the operator's install steps for the helper units and the exact `--with-routectl` invocation, plus the recovery command for the state file (`systemctl status dashboards-routectl.socket`, `routectl list`, `routectl verify`).
|
||||
- Tasks update both files immediately when their own changes are user-visible.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] With `GATEWAY_ALLOW_WRITES=1`, `/gateway` disables `brain.reeseapps.com`: the confirm dialog shows the **diff from a `dry_run`**, asks for `DANGER`, and the row turns **red** (`off`) on the poll after the reload while its live count drains to `0` on its own — no code wrote that zero.
|
||||
- [ ] `ssh gateway-ops 'sudo /usr/local/sbin/dashboards-routectl.py list'` names exactly that route; `verify` reports no drift; the marked line contains the original verbatim after `was:`; the map entry points at the sink and `stream.d/00-dashboards-sink.conf` exists and is owned by the helper's header.
|
||||
- [ ] Enabling restores the original bytes exactly (byte-compare against a pre-drill copy), the row turns green, and `list` is empty afterwards. **The live ingress ends the phase in the state it started in.**
|
||||
- [ ] Every gate refuses with its own sentence and **without** touching a peer: writes disabled, no session, no CSRF, wrong `Origin`, missing `DANGER`, unknown id, already-in-state, section unreadable, `gateway` job stale, `host:gateway` not `ok`, agent revoked. Each is a test, and each test asserts the stub peer logged nothing.
|
||||
- [ ] The agent's unit is unchanged: `systemctl cat dashboards-agent` still shows `NoNewPrivileges=true`, `ProtectSystem=strict`, `SystemCallFilter=@system-service`, and the same sha256 the phase recorded before installing the units — the sandbox was not traded for the feature.
|
||||
- [ ] A failing validator (`nginx -t` stubbed to exit 1) restores the previous bytes, reloads nothing, answers `applied: false, validated: false, restored: true`, and the page's line never turned `off`.
|
||||
- [ ] `POST` inventory: the app now has exactly two upstream writes (`/api/ups/command`, `/api/gateway/route`) and the enumeration pin lists them as such; `AGENTS.md` invariant 1 says **two** and names both gates; `/api/health` gained nothing.
|
||||
- [ ] `uv run pytest` (with `--cov` ≥ 94), `uv run ruff check src tests agent deploy`, `uv run pyright`, `node --check`, `bash .agents/validate.sh` all green; `uv run pytest -m live --live` green **without** `--allow-live-writes` (the round-trip test skipped by default, its reason naming the route it would touch).
|
||||
- [ ] `data/logs/33-*.png` (switch off in both themes, the `DANGER` dialog with its diff, the writes-disabled arm, 360 px) and `data/logs/33-*.out`; `data/logs/27-task02-mobile-audit.cjs` exits 0 with 0 FAIL / 0 FINDING; `data/logs/NOTES-33-switching.md` records the drill, the hashes and the restore.
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass), including the operator's steps for undoing all of this (`--without-routectl`, remove the units, re-enable every route).
|
||||
- [ ] No behaviour change in completed work: the other five dashboards, `/settings`, `/certs`, `/login`, the UPS command path and its four gates, and every non-gateway source behave exactly as phase 32 left them.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Task 01 — The root helper edits exactly one line, proves it validates, and reloads or gives up
|
||||
|
||||
**Phase:** `33_gateway_route_switching` · **Source:** operator decision 2026-10-23 — "Edit the config + validate + reload, via a narrow root helper on the gateway"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`deploy/dashboards-routectl.py` — a stdlib-only root program that turns one `connections[].id` off or back on in one of the two proxy configs, validates the file with the proxy's own checker, reloads that one proxy, and refuses everything else. It is the phase's only writer, it has no network listener of its own (task 02 hands it a systemd-activated socket), and it can be run by hand on the gateway by the operator to see exactly what the dashboard would do.
|
||||
|
||||
The helper **imports the agent's config parser by file path** and never re-implements a rule: two parsers reading one config is two opinions about the same file, which is the failure mode this repo has pinned against since phase 28. `agent/host_agent.py` is world-readable at `/usr/local/lib/dashboards-agent/host_agent.py`, so the helper imports it with `importlib` and refuses to run at all if it cannot.
|
||||
|
||||
## Work
|
||||
|
||||
1. `agent/host_agent.py` — the shared grammar only (no version bump here; `AGENT_VERSION` moves in task 03):
|
||||
```python
|
||||
ROUTE_MARKER = "# dashboards:off" # the only marker this app writes into a proxy config
|
||||
ROUTE_WAS = "was:" # `<marker> <id> was: <original line verbatim>`
|
||||
def marker_for(route_id: str, original: str) -> str: ...
|
||||
def split_marker(line: str) -> tuple[str, str] | None: # (route_id, original) or None
|
||||
def config_line_index(haproxy_text, haproxy_path, nginx_docs) -> dict[str, dict[str, Any]]:
|
||||
"""route id -> {"file", "line", "original", "kind", "disabled"} for every declared target."""
|
||||
```
|
||||
`config_line_index` walks the parsed declarations **with their source lines**, so the id → (file, line, verbatim text) map is derived by the same parser that produced the graph, and a marked line is reported as the same id with `disabled: true`. It is pure over text; the file reading stays in the callers (`Collector` for the agent, the helper for the writes), both resolved under `--root`.
|
||||
2. `deploy/dashboards-routectl.py` (new, `0755`, stdlib, no third-party import, no `shell=True`)
|
||||
- **Arguments** (all default to the deployed paths, all overridable for the fake tree):
|
||||
`--root /` · `--haproxy-config /etc/haproxy/haproxy.cfg` · `--nginx-config /etc/nginx/nginx.conf` · `--state /var/lib/dashboards-routectl/state.json` · `--sink-conf /etc/nginx/stream.d/00-dashboards-sink.conf` · `--sink-address 127.0.0.1:44450` · `--agent-module /usr/local/lib/dashboards-agent/host_agent.py` · `--nginx-unit nginx` · `--haproxy-unit haproxy` · `--apply` (without it every write verb is a dry run).
|
||||
- **Verbs**, one JSON object to stdout, non-zero exit on refusal:
|
||||
`list` · `show --id ID` · `diff --id ID --action disable|enable` · `disable|enable --id ID [--apply]` · `verify` (marker/hash vs file → drift report) · `--serve` (read one JSON request from the systemd-passed socket, answer one JSON line).
|
||||
- **Per-kind edit, exactly as decision 5** (the three arms and the `default` refusal are this file's core; a fourth arm is a new decision, not a fall-through):
|
||||
| id kind | action | why |
|
||||
|---|---|---|
|
||||
| haproxy `server` | comment the line with `ROUTE_MARKER` + `was:` | the backend stops receiving; the marker keeps the original restorable by eye and by machine |
|
||||
| nginx vhost `proxy_pass` | comment the line the same way | the vhost keeps `listen` + `server_name` and gains no target — a shape `warnings()` already treats as quiet (the `:80 return 301` arm), so "off" does not read as "broken" |
|
||||
| nginx stream `map` entry | **rewrite the value** to the sink address, marker + `was:` on the line, ensure the sink file exists | commenting would fall through to the map's `default` (`127.0.0.1:4443`) and **mis-route the domain instead of blocking it** |
|
||||
| nginx `map` `default` | **refuse** — `{"applied": false, "refused": "map-default"}` | it is the fallback for every unmapped SNI, not a service |
|
||||
- **The sink file**: created on first need with a `# managed by dashboards-routectl — do not edit` header and a stream `server` block that listens on `127.0.0.1:44450` and returns a short refusal string; loaded by the existing `stream { include /etc/nginx/stream.d/*.conf; }` glob, so **the main config is never edited**. If a file already exists at that path without the header, the helper refuses (an operator's file is not its own to overwrite). It deletes the file only when it wrote it **and** nothing is disabled, and never removes it while any map entry still points at the sink.
|
||||
- **Write protocol** (order is the safety property, so it is written as numbered steps in the file):
|
||||
1. re-read the file; index it; map `id` → line (unknown id → refuse, nothing written);
|
||||
2. already in the requested state → `{"applied": false, "changed": false}` and **no reload**;
|
||||
3. `--apply` absent → answer with the unified `diff` only;
|
||||
4. write to `mkstemp` in the same directory, `fsync`, `os.replace`;
|
||||
5. run `nginx -t` or `haproxy -c -f <that file>`; on failure `os.replace` the previous bytes back and answer `{"applied": false, "validated": false, "restored": true}` with the checker's own first line — **and reload nothing**;
|
||||
6. `systemctl reload <unit>`; a failure is reported as `reloaded: false` with the unit's status line, leaving the applied file in place (the config is right; the reload is the operator's next action and the answer says so);
|
||||
7. re-read and re-index to prove the marker is live; record state.
|
||||
- **Never `restart`** — the docstring says why in one sentence: a restart cuts every live connection through the fleet's only ingress, and a reload does not.
|
||||
- **State sidecar**: `0600`, root, `{id: {"actor", "at", "file", "sha256", "action"}}`, written with `os.open(..., O_CREAT, 0o600)` so the mode is its birth mode (the `dev-certs-export.py` idiom). `enable` restores **only** when the current line hashes to what the helper wrote; otherwise `{"applied": false, "drift": true, "hint": "…"}` and no byte changes.
|
||||
- **`--serve`**: `socket.fromfd(3, AF_UNIX, SOCK_STREAM)` (or the `LISTEN_FDS`/stdin idioms, chosen once and documented), read one newline-delimited JSON request, `getsockopt(SO_PEERCRED)` and **refuse any uid that is not the agent's** (`--allow-uid`, default `dashboards-agent`, resolved with `pwd.getpwnam`), dispatch one verb, write one JSON line, exit. One request per activation: systemd spawns the instance per connection (`Accept=yes`), so there is no accept loop, no thread, no timeout policy to invent.
|
||||
- **Nothing in this file takes a path, a line number, a pattern or a command from the wire.** `--serve`'s accepted keys are exactly `{id, action, dry_run}` and every other key is a refusal. Assert it structurally in the tests.
|
||||
- Every subprocess call is a fixed argv list (`subprocess.run([...])`, no `shell=True`), and the checker/unit names come from **flags the operator installed**, never from the request.
|
||||
3. `tests/test_routectl.py` (new) — real helper, fake tree (`--root`), fake `nginx`/`haproxy`/`systemctl` scripts on `PATH` that record their argv and answer to order:
|
||||
- `test_disabling_one_route_changes_exactly_one_line` — unified diff of the whole file is one hunk, one line; every other byte identical (assert on the bytes, not on a count).
|
||||
- `test_a_disabled_map_entry_points_at_the_sink_and_keeps_its_original` — the sink file exists with its header, the marked line carries `was: <verbatim>`, and re-enabling restores the file byte-identical to the pre-drill capture.
|
||||
- `test_the_map_default_is_refused_and_nothing_is_written`, `test_an_unknown_id_is_refused`, `test_disabling_a_disabled_route_changes_nothing_and_reloads_nothing` (the fake `systemctl` log stays empty).
|
||||
- `test_a_failing_validator_restores_the_previous_bytes` — byte-identical restore, `validated: false`, `restored: true`, zero reload calls, non-zero exit.
|
||||
- `test_a_failing_reload_says_so_and_leaves_the_file_applied`, and `test_reload_is_never_a_restart` (grep the source for `restart` in a call position, the installer's `--status` style pin).
|
||||
- `test_the_helper_imports_the_agents_parser_and_refuses_without_it` — point `--agent-module` at a non-file: exit non-zero, no write, and the message names the path (a helper that silently skipped the parser would be a second opinion about the config).
|
||||
- `test_state_is_0600_and_drift_is_refused` — mode at birth; hand-edit the marked line and `enable` refuses with `drift: true` and no byte change.
|
||||
- `test_serve_refuses_a_peer_that_is_not_the_agent_uid`, `test_serve_accepts_exactly_id_action_dry_run`, `test_no_shell_and_no_path_from_the_wire` (structural greps over the file, the `certs.py`/`auth.py` precedent).
|
||||
- `test_the_sink_file_is_left_alone_when_someone_elses_wrote_it`, `test_the_sink_is_removed_only_when_it_is_empty_of_our_routes`.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the whole matrix above, hermetic and unprivileged, over the **captured** `gateway_haproxy.cfg` / nginx tree so the edits are pinned against real config bytes rather than a toy file.
|
||||
- Coverage **>90%** on `deploy/dashboards-routectl.py` and the new `agent/host_agent.py` functions; `uv run ruff check agent deploy` + `uv run pyright` clean; `python3 -m py_compile` on the helper (it must run on a host's Python 3.10, so no 3.14-only syntax — pin that with a `# requires: python >=3.10` header and a test that greps for `match `/`typing.Self`-style 3.11+ syntax if the repo has such a pin; otherwise assert `ast` parses with `feature_version=(3,10)`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python deploy/dashboards-routectl.py --root <tmp> … diff --id nginx:443->reeseapps-brain-of-reese.reeselink.com:443` prints a one-hunk diff and writes nothing; with `--apply` it edits, validates, reloads (against the fakes) and answers `{"applied": true, "validated": true, "reloaded": true}`.
|
||||
- [ ] Re-enabling leaves the tree byte-identical to its pre-drill `sha256sum` for **both** config files.
|
||||
- [ ] `grep -n "shell=True\|restart" deploy/dashboards-routectl.py` finds neither in a call position; `grep -n "def \|argparse" ` shows no `--file`/`--line`/`--pattern` option anywhere.
|
||||
- [ ] `uv run pytest tests/test_routectl.py -q` 100% passing; whole suite green; coverage not down.
|
||||
- [ ] README.md/AGENTS.md note the helper's existence and its verbs (this task's user-visible surface — the operator can run it by hand) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: `/snapshot`'s document is unchanged by this task (the `AGENT_VERSION` bump and the `disabled` field are task 03's, with the additivity control).
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# Task 02 — The privileged peer is a socket systemd activates, not a privilege the agent is given
|
||||
|
||||
**Phase:** `33_gateway_route_switching` · **Source:** the agent unit's `NoNewPrivileges=true` + `ProtectSystem=strict` (read from `deploy/install-agent.sh`), which this phase must not weaken to get a write
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Install the helper on the gateway as a **socket-activated root service** the agent may connect to and nothing else: `dashboards-routectl.socket` owned by the agent user at `0660`, `dashboards-routectl@.service` running as root with `Accept=yes`. The agent's own unit keeps every one of its security directives — the phase's central safety claim is that a read-only collector stayed read-only in its sandbox and talked to a peer outside it.
|
||||
|
||||
## Work
|
||||
|
||||
1. `deploy/dashboards-routectl.socket` (new)
|
||||
```ini
|
||||
[Socket]
|
||||
ListenStream=/run/dashboards-routectl.sock
|
||||
SocketUser=dashboards-agent
|
||||
SocketGroup=dashboards-agent
|
||||
SocketMode=0600
|
||||
NoDelay=yes
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
```
|
||||
`0600` + `SocketUser=dashboards-agent` is the whole access control at the file layer: only that uid can connect, everyone else gets `EACCES` before any code of ours runs. The peer check in task 01's `--serve` is the **second** look, not the first.
|
||||
2. `deploy/dashboards-routectl@.service` (new)
|
||||
```ini
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/sbin/dashboards-routectl.py --serve \
|
||||
--allow-uid dashboards-agent --state /var/lib/dashboards-routectl/state.json
|
||||
User=root
|
||||
StandardInput=socket
|
||||
StandardOutput=socket
|
||||
StateDirectory=dashboards-routectl
|
||||
ProtectSystem=read-only # it writes two config files by absolute path; see ReadWritePaths
|
||||
ReadWritePaths=/etc/haproxy /etc/nginx/stream.d
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=yes
|
||||
NoNewPrivileges=true
|
||||
SystemCallFilter=@system-service
|
||||
```
|
||||
Three notes the file must carry as comments: the helper needs `/etc/haproxy` and `/etc/nginx/stream.d` writable and **nothing else** (`/etc/nginx/conf.d` is deliberately absent — vhost edits land there, so the *vhost* arm is listed in `ReadWritePaths` too and that addition is a decision recorded in the file, not smuggled in); `NoNewPrivileges=true` here as well, because it gets its privilege from being started by systemd as root, not from escalation; and `Accept=yes` on the template means systemd spawns one instance per connection with the socket on stdin, which is why the helper handles exactly one request and exits.
|
||||
- **`ReadWritePaths` must list every directory whose file the helper replaces** (`os.replace` needs write on the directory): `/etc/haproxy`, `/etc/nginx/conf.d`, `/etc/nginx/stream.d`. Getting this wrong is a `EROFS` in production, so the live drill in task 07 is the test that matters and the units file names it.
|
||||
3. `deploy/install-agent.sh` — one new flag, `--with-routectl`, off by default, and its inverse `--without-routectl`:
|
||||
- installs `dashboards-routectl.py` to `/usr/local/sbin/` (`0755`, root) and the two units to `/etc/systemd/system/`, `daemon-reload`, `systemctl enable --now dashboards-routectl.socket`;
|
||||
- **changes nothing about the agent's own unit** — assert that structurally: the rendered `dashboards-agent.service` bytes are identical with and without the flag (`tests/test_deploy_scripts.py`), because the temptation to "help" the agent reach the socket by relaxing it is exactly what this task exists to close;
|
||||
- verifies reachability from the agent's own uid before reporting success: `sudo -u dashboards-agent python3 -c 'import socket; s=socket.socket(socket.AF_UNIX); s.connect("/run/dashboards-routectl.sock")'` — a socket the agent cannot connect to is a feature that fails at 03:00 during an incident, so this check is part of the install, and its output is printed;
|
||||
- `--without-routectl` disables and removes the units and the helper, leaves `/etc` and the state directory alone, and reminds the operator to re-enable any route still off (`routectl list` output printed first).
|
||||
- The re-run guard applies as it does to the unit: a differing installed helper is a diff + exit 1 unless `--force`.
|
||||
4. `deploy/gateway-routectl-README.md` (new, short) — the operator's page: what the two units are, how to see them (`systemctl status dashboards-routectl.socket`, `journalctl -u 'dashboards-routectl@*'`), how to run the helper by hand on the box, how to undo it all, and the sentence that **no sudoers line is involved**: the grant is a file mode systemd enforces.
|
||||
5. `tests/test_deploy_scripts.py` — extend the fake-root sandbox:
|
||||
- `test_with_routectl_installs_helper_and_units_and_leaves_the_agent_unit_byte_identical`;
|
||||
- `test_the_socket_is_0600_and_owned_by_the_agent_user` (the fake `install`/`chown`/`chmod` log, the precedent that proved the bundle's `0640`);
|
||||
- `test_the_helper_is_installed_0755_root_and_its_state_dir_is_created`;
|
||||
- `test_without_routectl_removes_the_units_and_not_the_config_or_the_state`;
|
||||
- `test_no_new_sudoers_line_exists` (the `deploy/agent-sudoers` file is unchanged, and nothing in the new units grants sudo);
|
||||
- `test_the_install_checks_the_agent_can_reach_the_socket` — a fake where that connect fails makes the install exit non-zero rather than reporting a success nobody can use.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; the units are also parsed (`systemd-analyze verify` is not available in the sandbox, so the assertions are on the directive lines, and task 07 runs the real `systemctl` on the gateway).
|
||||
- Coverage: the installer is shell; the gate is the sandbox tests plus `bash -n`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `bash -n deploy/install-agent.sh` clean; `grep -n "NoNewPrivileges\|ProtectSystem\|SystemCallFilter" deploy/install-agent.sh` shows the agent unit's values unchanged from before this task (the guard against "fixing" reachability by weakening the sandbox), and `git diff` for this task shows no edit inside the agent unit template block.
|
||||
- [ ] `uv run pytest tests/test_deploy_scripts.py -q` 100% passing, including the byte-identical agent-unit assertion; whole suite green; coverage not down.
|
||||
- [ ] The two unit files list exactly the paths the helper writes and no others, with the `os.replace`-needs-directory reason stated in a comment.
|
||||
- [ ] README.md/AGENTS.md gain the two unit names and the "no sudoers line" sentence (this task's user-visible surface) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: an install **without** `--with-routectl` is byte-identical to an install before this phase, on every host.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task 03 — Agent 1.4.0: one POST path, behind the certificate and a flag, plus the marker read back as `disabled`
|
||||
|
||||
**Phase:** `33_gateway_route_switching` · **Source:** the agent's `do_GET`-only `Handler` and `_certificate_allows` (phase 15), and phase 30's `routeInConfig()` seam
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`host_agent.py` goes **1.3.0 → 1.4.0** with two additions and no other change: the two config parsers report a marker-authored line as a declared target with **`disabled: true`**, and one new path, `POST /route`, which is reachable only with a verified client certificate **and** an agent started with `--allow-writes`, forwards exactly `{id, action, dry_run}` to the task-02 socket, and answers with the helper's JSON. Every `GET` behaviour stays byte-identical, proven by the additivity control.
|
||||
|
||||
## Work
|
||||
|
||||
1. `agent/host_agent.py`
|
||||
- Parsers: each haproxy `server` node and each nginx `proxy_pass` / `upstream` member / `map` entry gains `"disabled": <bool>` — `false` for an ordinary line, `true` for one carrying `ROUTE_MARKER` (read with `split_marker`). Additive: no existing key changes meaning, and a route that is off **stays in the document** because "the config declares this and the operator switched it off" is a different fact from "the config never said it" (rule 2).
|
||||
- **New path** in `Handler`:
|
||||
```python
|
||||
def do_POST(self) -> None: # the BaseHTTPRequestHandler API name — keep it
|
||||
if self.path.split("?", 1)[0].rstrip("/") != "/route":
|
||||
self._send(405, {"ok": False, "detail": "no such write path"})
|
||||
return
|
||||
# the certificate check first, exactly as do_GET does it, revoked serial included;
|
||||
# then the flag; then the body; then the peer. Nothing else in this method writes.
|
||||
```
|
||||
Order, and each refusal is its own sentence: no bundle / no verified certificate → `403 client certificate required` (and `403 client certificate revoked` for a withdrawn serial, on this path too); agent started **without** `--allow-writes` → `403 writes are not enabled on this agent`; body not a JSON object, longer than `_ROUTE_MAX_BODY = 4096`, carrying a key outside `{id, action, dry_run}`, an `action` outside `{disable, enable}`, or an `id` longer than 256 chars → `422` with a fixed message and **no peer call**; no `--routectl-socket` configured → `503 no route helper on this host`.
|
||||
- Peer call: one newline-delimited JSON request over `AF_UNIX`, `settimeout(ROUTE_TIMEOUT_SECONDS = 30)` (the helper may run `nginx -t` and a reload), one JSON line back, oversized or unparsable replies are `502 route helper answered something unreadable` and **never** retried — a retry of a write is a second reload. Any `OSError` → `502` with the errno word, no traceback text, no path echoed beyond the flag's own configured value.
|
||||
- **No token path ever gets this**: on the plain-`http` bearer-token arm, `POST /route` is refused with `403 writes require a certificate` — the same reasoning phase 15 applied to reads (the handshake is the identity), and this app's fleet is mTLS anyway.
|
||||
- `--allow-writes` (default off) and `--routectl-socket /run/dashboards-routectl.sock`; both appear in the `/schema` doc block. `--status` and `--uninstall` are unaffected.
|
||||
- `log_message`: the POST line prints the path and the status only — never the body, never the id (an id is a hostname, and the agent's log line convention is that query strings stay out).
|
||||
- `AGENT_VERSION = "1.4.0"`; `/schema` names `POST /route`, `--allow-writes`, `--routectl-socket`, and the `disabled` field on the proxy sections; the status page lists the write path with its refusal sentence so a human with a browser is not surprised by a 405.
|
||||
2. `tests/test_agent.py` — the TLS loopback harness (phase 15) plus a unix-socket stub peer that logs what it received:
|
||||
- `test_the_agent_version_is_1_4_0_and_the_schema_names_the_write_path`;
|
||||
- `test_a_post_without_a_client_certificate_is_refused` and `test_a_revoked_serial_is_refused_on_the_write_path_too` (the denylist is not a read-side rule);
|
||||
- `test_writes_off_refuses_before_the_peer_is_touched` (stub logs nothing);
|
||||
- `test_the_body_vocabulary_is_id_action_dry_run_exactly` — a key added, a wrong `action`, a 5 KB body, a non-object: all refused, all with the peer idle;
|
||||
- `test_a_good_request_reaches_the_peer_once_and_the_answer_is_passed_through` (including `applied:false` staying `false` — the agent invents no success), `test_the_peer_is_called_once_even_when_it_times_out`, `test_an_unreadable_peer_answer_is_502_and_is_not_retried`;
|
||||
- `test_token_mode_cannot_write`; `test_every_other_path_is_405`;
|
||||
- `test_a_marked_server_is_reported_disabled_and_stays_in_the_document` (haproxy + all three nginx arms), `test_an_unmarked_config_has_disabled_false_everywhere`.
|
||||
- **Additivity control re-run**: `data/logs/verify-33-additive.py` (from task 02 of phase 32) extended to demand 1.4.0's `GET /snapshot` equals 1.3.0's with `disabled` added at the target level and nothing else, and equal *exactly* on an unmarked config.
|
||||
3. `src/dashboards/collectors.py` — a `post_route(tls, target, body)` beside `fetch_gateway_snapshot`, reusing the same `AgentTls` material and the **same revoke-before-socket check**, and redacting the URL in every error string it produces (invariant 6: those strings become events).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the matrix above; hermetic (a stub unix socket, no helper, no root, no proxy binaries).
|
||||
- Coverage **>90%** on the new agent code and `collectors.post_route`; `ruff` + `pyright` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `python3 agent/host_agent.py --help` lists `--allow-writes` and `--routectl-socket`; `/schema` names `POST /route` and `disabled`.
|
||||
- [ ] Against the fake peer: the full refusal matrix answers without a peer call, and the success path calls it **exactly once** and passes the JSON through unaltered.
|
||||
- [ ] `uv run python data/logs/verify-33-additive.py` exits 0: 1.4.0 equals 1.3.0 plus `disabled`, and equals it exactly on unmarked configs.
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md note the write path and its two agent-side gates (this task's user-visible surface) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: `GET /snapshot`, `/health`, `/info`, `/schema` and the TLS matrix behave exactly as 1.3.0; `deskwork`/`driveripper` are untouched.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Task 04 — `enabled` joins the payload, `off` becomes reachable, and the warnings stop calling a chosen outage a fault
|
||||
|
||||
**Phase:** `33_gateway_route_switching` · **Source:** phase 30 decision 1 ("red is the **off** state, styled now, unreachable until the write path exists")
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`connections[].enabled` carries the config's own state (`true` declared and active, `false` declared and marker-off), so `routeInConfig()` stops being a literal and the red styling phase 30 built has a referent. And `warnings()` learns the marker: an empty backend, a targetless vhost or a sink-pointed map entry **caused by the operator's own switch** is not a config-integrity fault, while the same shape caused by a hand-deleted line still is. No count is ever written by this phase.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/sources/gateway.py`
|
||||
- `_connection()` gains `"enabled": True`; `config_connections()` sets it from the agent's per-target `disabled` flag (`enabled = not disabled`), and the merge rule keeps its meaning: **a line is enabled only if every claim that merged into it is enabled** — a union of selectors with one disabled line is two lines, so if a merge would mix states the source must not silently union them; state which arm wins and test it (recommended: split into two rows keyed by `enabled`, because "one of these selectors is off" is two facts).
|
||||
- `_skeleton()`/`stub()` gain the key so the parity pin keeps holding; `enabled` is `None` in the stub (nobody read the config), `true`/`false` in an answer — never `false` in an arm that did not read it.
|
||||
- `warnings()` marker-awareness, per kind, and each is a decision to write down rather than a suppression to hope nobody notices:
|
||||
| kind | new rule |
|
||||
|---|---|
|
||||
| `haproxy_backend_empty` | not raised when the backend's only servers are `disabled: true` (the operator emptied it from `/gateway`); still raised when it has no `server` line at all |
|
||||
| `haproxy_route_dangling` / `haproxy_backend_orphan` | unchanged — a switch never renames or removes a `backend`/`use_backend`, so a dangling name is still a broken config |
|
||||
| `nginx_server_unlistened` | unchanged (a disabled `proxy_pass` does not remove a `listen`) |
|
||||
| `nginx_pool_undeclared` / `nginx_variable_unmapped` | unchanged |
|
||||
| `nginx_map_route_missing` | a map entry pointing at the **sink address** is not a missing route; it is a switched-off route, and the sink address is recognised from the payload's own `resolver`/config text rather than hard-coded in the source (a literal in two places is how this drifts) |
|
||||
- The **socket join** is unchanged in code and in ethics: a disabled haproxy `server` has no live sockets after the reload because they drain, and a disabled map entry's sockets to the sink are the sink's own loopback pair. Nothing stamps `0`, nothing zeroes a count, and the `shared` rule is untouched.
|
||||
- `samples()` publishes the gauge for an `enabled: false` line only if the join measured a real count for it (usually `0`, and `0` measured is a legitimate measurement — the distinction is *measured*, never *assumed*).
|
||||
- No `severity` anywhere, still: `off` is a state, not a verdict. The recursive no-verdict walk in `tests/test_api.py` must stay green without an exception for `enabled`.
|
||||
2. `src/dashboards/api.py` — `/api/gateway/snapshot` is otherwise unchanged, but the `gateway` block in `/api/summary` is the one place this phase **does** add a field, because the overview's one line is where "something is switched off" belongs and a page-only fact would be invisible to a notifier: `disabled` (int, count of `enabled is false` rows, `null` in the arms that did not measure). That makes thirteen keys — restate the pinned key set in the same commit, with the reason, and keep `warnings` as a count beside it.
|
||||
3. `tests/test_gateway_source.py`
|
||||
- `test_a_marker_off_route_is_declared_and_disabled` — a config tree with one marked haproxy server + one marked vhost `proxy_pass` + one sink-pointed map entry yields three rows, each `enabled: false`, all their selectors intact, every other row `enabled: true`.
|
||||
- `test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it` and `test_the_same_shape_without_a_marker_is_still_a_warning` — both directions for each of the two affected kinds, run over the **captured** configs plus a marker-injected copy of them (never a toy file).
|
||||
- `test_the_sink_address_is_recognised_not_hard_coded` — a config that writes a different sink port still reads as switched-off, because the sink is identified from the marker+config, not from `44450`.
|
||||
- `test_a_disabled_line_still_publishes_only_a_measured_count` — with no sockets, `connections: 0` only when the table answered; `null` when it did not, and no sample in the second case.
|
||||
- `test_the_stub_and_the_answer_share_one_key_set` gains `enabled`; `test_the_gateway_snapshot_carries_no_verdict_and_no_window` unchanged (extended walk).
|
||||
4. `tests/test_api.py` — the `/api/summary` `gateway` block key set grows `disabled` with its reason in the test docstring; `test_the_overview_line_reads_the_gateway_block_it_claims` learns the new field (and that `0` prints nothing — a permanent `0 switched off` is the ink the empty-state rules exist to avoid, the `warnings` rule restated).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: every rule above from real captures plus injected markers; both directions for each suppression.
|
||||
- Coverage **>90%** on `sources/gateway.py` and `api.py`; whole suite 100%.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `GET /api/gateway/snapshot` on a fixture with three markers shows three rows with `enabled: false`, their selectors and endpoints intact, and `warnings == []` for the marker-caused shapes.
|
||||
- [ ] Removing the markers from the same tree brings the corresponding warnings back (the suppression is marker-caused, not shape-caused) — asserted as one test pair.
|
||||
- [ ] `/api/summary`'s `gateway.disabled` is `3` on that fixture, `0` on the clean capture, `null` on the stub, and the overview line prints nothing for `0`.
|
||||
- [ ] No `severity` key at any depth, still (the recursive walk passes unmodified).
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md's `/gateway` paragraph names `enabled`/`off` and the marker-aware warnings rule — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: the socket join, inbound counts, chart gauges and the phase-32 provenance fields are untouched.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Task 05 — One route, ten gates: `POST /api/gateway/route` and the amendment of invariant 1
|
||||
|
||||
**Phase:** `33_gateway_route_switching` · **Source:** operator request — "turn off and on connections as needed in the event of security concerns or upgrades" · **Shape borrowed from:** `/api/ups/command`'s four gates and `/api/certs/*`'s fixed-text audit events
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
The app's **second and last** upstream write. `POST /api/gateway/route {id, action, danger?, dry_run?}` refuses in nine places before it dials anything, and in the tenth it forwards one reviewed request through `collectors.post_route` and answers with the helper's own triple. `GATEWAY_ALLOW_WRITES` defaults to **off**, `DASH_READ_ONLY` still refuses everything, and `AGENTS.md` invariant 1 is rewritten in this commit to name two exceptions — because a change to a written invariant that leaves the document describing the old one is the bug, not the omission.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/config.py` — two settings beside the existing `GATEWAY_*` block, each with the reason in its comment:
|
||||
- `GATEWAY_ALLOW_WRITES: bool` (default `False`, the same truthy rule as `NUT_ALLOW_ACTIONS`);
|
||||
- `GATEWAY_TIMEOUT_SECONDS` **unchanged** (reads), plus `GATEWAY_WRITE_TIMEOUT_SECONDS: float = 35.0` — deliberately longer than the agent's own 30 s peer timeout so the dashboard never times out first and leaves the operator guessing whether the reload happened.
|
||||
2. `src/dashboards/collectors.py` — `post_route(tls, target, body, timeout)` beside `fetch_snapshot`: the same `AgentTls` client (built per dial, the phase-15 rule), the same **revocation check before any socket opens**, `Authorization` never used on the certificate path, and every error message run through `strip_userinfo` (those strings reach events and `/api/health`-adjacent payloads — invariant 6).
|
||||
3. `src/dashboards/api.py` — the route, and the gate order is the design, so it is written in the function as numbered comments and mirrored in the tests:
|
||||
| # | Gate | Refusal |
|
||||
|---|---|---|
|
||||
| 1 | session (middleware) | `303`/`401`, unchanged |
|
||||
| 2 | `Origin` / `Sec-Fetch-Site` | `403`, unchanged |
|
||||
| 3 | this session's `X-CSRF-Token` on a cookie write | `403`, unchanged |
|
||||
| 4 | `DASH_READ_ONLY` | `403 "this dashboard is read-only (DASH_READ_ONLY)"` |
|
||||
| 5 | `GATEWAY_ALLOW_WRITES` | `403 "route switching is disabled — set GATEWAY_ALLOW_WRITES=1 to enable it"` |
|
||||
| 6 | throttle `route:<address>` on `ctx.login_throttle` | `429` + `Retry-After` (its third key shape; the per-process caveat stays gap 7) |
|
||||
| 7 | body: `id` non-empty string ≤ 256, `action` in `{disable, enable}`, `dry_run` optional bool, **no other key** | `422`, and it is a `422` rather than a `400` for the same reason the login body is |
|
||||
| 8 | `id` exists in the **current** payload (`409` if the job has never answered) and is not already in the requested state (`409 {changed: false}`) | never a guess about a route it cannot see |
|
||||
| 9 | freshness: `gateway` job `ok` and not stale beyond 3× its interval, `host:<GATEWAY_HOST>` `ok`, `sections.haproxy`/`sections.nginx` both `true`, the agent's `version >= 1.4.0` | `409` naming which one — "do not rewrite a config you cannot currently read end to end" |
|
||||
| 10 | `danger == "DANGER"` for **both** verbs | `422` naming the word, the UPS wording's sibling |
|
||||
Then one `collectors.post_route`, and the response is the helper's object plus `host`, `id`, `action`, `dry_run`, and `hint` sentences owned by `api.py` (the `restart_required` precedent: the prose lives in the route, so the page prints what the route said).
|
||||
- **Events**, one per call, `source="gateway"`, `subject=<the route's host>`, `kind` in `{route-disabled, route-enabled, route-denied}`, severity `warn` for a change and `error` for a refusal-that-matters, message fixed-shape: `disable brain.reeseapps.com → reeseapps-brain-of-reese.reeselink.com:443: applied=true validated=true reloaded=true (admin)`. **No diff, no file path, no config text** — `/api/events` is a feed a browser reads, and a config's bytes are not a log line. A `route-denied` from gates 4–10 writes the event with the gate named and **nothing** about which check the caller came closest to passing.
|
||||
- `/api/config`'s `gateway` row gains `writes_enabled` (bool) so the page can render the honest arm instead of assuming (rule 2), and nothing else in that body changes.
|
||||
4. `tests/test_api.py`
|
||||
- `test_the_gateway_surface_is_gets_only_and_the_write_stays_the_ups_one` is **rewritten**, not deleted: the verb enumeration now lists exactly two fleet writes — `/api/ups/command` and `/api/gateway/route` — beside the session writes and the certificate-authority writes, and the test still fails if a third appears anywhere.
|
||||
- `test_each_gate_refuses_without_touching_the_peer`, parameterised over all ten with spies: `collectors.post_route` swapped for a recorder, and each refusal asserts the recorder is empty **and** the event is what the gate named. The CSRF half is proven on the wire in `tests/test_auth_api.py` the way the UPS one is: a scripted helper records nothing without the header and once with it.
|
||||
- `test_danger_is_required_to_enable_as_well_as_disable`, `test_read_only_refuses_and_names_the_variable`, `test_a_route_that_is_already_off_says_so_and_dials_nothing`, `test_a_stale_gateway_or_dead_host_job_refuses_the_write`, `test_a_revoked_agent_refuses_the_write_without_a_socket`, `test_the_write_timeout_is_longer_than_the_agents_peer_timeout` (the ordering, not the numbers).
|
||||
- `test_no_diff_or_config_text_reaches_an_event` — a successful write whose helper answer carries a `diff` field: the event message contains no `diff`, no `--- `/`+++ ` line, no path, and no `was:` marker.
|
||||
- `test_the_writes_enabled_flag_reaches_the_page_config`.
|
||||
5. `tests/test_auth_api.py` — the served-route walk (`walk_routes`) now sees `POST /api/gateway/route` and demands a session; the public-surface list stays exactly four entries; the CSRF-on-the-wire test gains this endpoint.
|
||||
6. `AGENTS.md` — **in the same commit**: invariant 1 rewritten from "exactly one exception" to "exactly two, and both are gated on purpose", naming the two endpoints and the gate list, keeping the sentences that make it work ("Hiding a button is not a safety control", "the UI confirmation … `DANGER` for anything that can cut power" now extended: *or that can cut a route through the fleet's only ingress*). Also: `api.py`'s bullet (its three write lists become four facts and now include the gateway route), `collectors.py`'s bullet (`post_route`), `config.py`'s paragraph (the two new settings), and the `/gateway` UI-conventions sentence about the unwired switch is **removed** — it becomes the wired description in task 06, and the two must not both exist.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: every gate in both directions; the wire-level CSRF proof; the event-shape proof; the verb enumeration restated.
|
||||
- Coverage **>90%** on `api.py` and `collectors.py`; the whole suite 100%; `ruff` + `pyright` clean.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `curl` against the dev server with writes off answers the `GATEWAY_ALLOW_WRITES` sentence; with it on and no `DANGER` answers the `DANGER` sentence; with both and a live fake agent it answers `applied: true` and the event line is in `/api/events?source=gateway`.
|
||||
- [ ] `grep -n "DASH_READ_ONLY" src/dashboards/api.py` shows it refusing on **both** write paths.
|
||||
- [ ] The verb-enumeration test lists exactly two fleet writes and fails on a third by name; `walk_routes` covers the new POST; `PUBLIC_PATHS` is still four entries.
|
||||
- [ ] `/api/config` serves `gateway.writes_enabled` and the page's own arm is task 06's.
|
||||
- [ ] `AGENTS.md` invariant 1 names two exceptions and their gates in the same commit as the code that made it true.
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `bash .agents/validate.sh` green.
|
||||
- [ ] No behaviour change in completed work: `/api/ups/command`'s four gates behave exactly as before (the suite's existing UPS pins are untouched), and no read route changed shape.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Task 06 — The switch works, asks for `DANGER` with the diff on screen, and never claims a change it didn't get
|
||||
|
||||
**Phase:** `33_gateway_route_switching` · **Source:** the operator's sketch (phase 30) + this phase's decision that a route off is a config edit with a reload behind it · **Shape borrowed from:** `page-ups.js`'s typed-`DANGER` confirm and `dash.css`'s `dialog` + `input[type="text"].confirm`
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Clicking a switch on `/gateway` shows the **exact one-line diff** the gateway would apply, asks for `DANGER`, applies it, and paints the row from the **answer** — not from hope. Where writes are disabled the switch stays visibly unwired and says which variable would change that, because `/gateway` must look the same on a deployment that chose not to have this feature as it did before the feature existed.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/page-gateway.js`
|
||||
- `routeInConfig(conn)` becomes a reading, not a literal: `return conn.enabled !== false;` — with the comment rewritten: `off` is now reachable, it means "declared in the config, switched off by the operator", and `enabled === undefined` (a pre-1.4.0 agent, or the stub arm) still means "on", so an old agent's page never goes red.
|
||||
- `buildSwitch(conn)` splits into two arms, driven by `/api/config`'s `gateway.writes_enabled` (never inferred):
|
||||
- **writes off** → today's markup exactly: `aria-disabled="true"`, `cursor: not-allowed`, `title` = `Route switching is off — set GATEWAY_ALLOW_WRITES=1 on the dashboard to enable it.` (the sentence from `api.py`, copied into a constant that `tests/test_api.py` compares against the route's refusal so the two cannot drift);
|
||||
- **writes on** → a real control: no `aria-disabled`, `aria-checked` from `enabled`, a full accessible name (`endpoint, reached by <selectors>, in the <source> config, on|off, activate to switch it off`), `title` naming what activating does (edits the file, validates, reloads the proxy).
|
||||
- **The flow**, one async handler, and the order is the safety:
|
||||
1. `POST /api/gateway/route` with `{id, action, dry_run: true}` → `diff` + `hint`;
|
||||
2. open a real `<dialog>` (`showModal()`) whose body is: the row's inbound word and endpoint, the config's own `selectors`, the diff in a labelled `<pre>`, the route's `hint`, and a `input[type="text"].confirm` asking for `DANGER`;
|
||||
3. submit only when the typed word is exactly `DANGER` (client-side check of the *client's* convenience; the server re-checks, always);
|
||||
4. switch `aria-busy="true"`, `POST` with `danger: "DANGER"`, `dry_run: false`;
|
||||
5. paint from the **response**: `applied: true` → the row's `enabled` flips on the next poll (and immediately, optimistically **only** for the switch's own `aria-checked`, with `aria-busy` cleared and a note that the config is applied and the page re-reads); `applied: false` → nothing flips, the refusal prints verbatim through `textContent`;
|
||||
6. `await refresh()` so the numbers come from the agent's next reading rather than from the click.
|
||||
- No optimistic colour, ever: the page does not turn a line green or red because a button was pressed. `pending` is the only state a click may create, and `aria-busy` is how it says so.
|
||||
- A `429`/`409`/`403`/`422` prints the server's `detail` + `hint` in the dialog (`role="alert"`), leaves the dialog open so the operator can read it, and returns focus to the confirm field — the `/login` lesson about a form that eats its own error.
|
||||
2. `src/dashboards/web/pages/gateway.html`
|
||||
- One `<dialog id="gate-switch-dialog" aria-labelledby="gate-switch-title" aria-modal="true">` with the four slots the script fills, shipped `hidden`/closed; no per-row dialogs (the same rows are patched, so one dialog is the whole surface, and a dialog per row is 33 focus targets in the tab order).
|
||||
- Footer paragraph 3 becomes the truth and stays **one** paragraph:
|
||||
> `the switch edits that route in its own config file, validates it with the proxy's own checker and reloads that proxy — nothing is restarted, and no route goes off unless GATEWAY_ALLOW_WRITES is set on the dashboard.`
|
||||
- The `#gate-empty` setup block is untouched.
|
||||
3. `src/dashboards/web/static/dash.css`
|
||||
- `#gate-switch-dialog pre` — mono, `overflow-x: auto`, `max-height` bounded, added/removed lines tinted from the **existing** tokens only (`.diff-add`/`.diff-del` deriving from `--gate-on`/`--gate-off`, the phase-30 existence pair, so no new colour enters the map and the diff reads as the same red/green the rows do);
|
||||
- `button.gate-switch:not([aria-disabled="true"]):hover` and `cursor: pointer` — added **only** under that selector, so a writes-off switch never grows a hover affordance it cannot honour;
|
||||
- any new transition (dialog, knob while `aria-busy`) is named in the file's one `@media (prefers-reduced-motion: reduce)` block — naming one and missing the other is the phase-18 lesson, and there is a test for it;
|
||||
- at ≤640 px the dialog stays inside the viewport (`width: calc(100% - 2rem)` is already the shared rule) and the diff scrolls in its own box, never the document (phase 27's rule).
|
||||
4. `tests/test_api.py`
|
||||
- `test_the_switch_is_built_and_explicitly_unwired` is **replaced** by `test_the_switch_is_wired_only_when_the_deployment_enables_writes`: with `writes_enabled: false` the served script keeps `aria-disabled="true"`, the `GATEWAY_ALLOW_WRITES` sentence, and no listener; with it true the listener exists, `aria-disabled` is gone, and the accessible name states the state and the consequence. The old test's *intent* survives: a dead-looking-live control still fails.
|
||||
- `test_a_toggle_asks_for_a_preview_then_the_word_then_sends_once` — the served script issues `dry_run: true` before any `DANGER` send, requires the exact word, and sends exactly once per activation (no retry loop in the handler).
|
||||
- `test_the_row_is_painted_from_the_answer_not_the_click` — no code path sets `off`/`on` other than the payload/`applied` pass; `applied: false` leaves `aria-checked` untouched.
|
||||
- `test_the_switch_sentence_matches_the_routes_refusal` — the writes-off `title` and `api.py`'s `GATEWAY_ALLOW_WRITES` refusal are the same string, read from both files.
|
||||
- `test_no_gateway_count_is_coloured_by_a_verdict` gains the reachable-`off` fact and keeps every assertion: the diff tints are `--gate-*`, no `.ok/.warn/.fail` or `--ok/--warn/--fail` enters the gateway section, and the warnings panel's `.warn` stays the one exception.
|
||||
- `test_the_pipe_animation_stills_in_both_states_under_reduced_motion` extended to the new transitions (knob busy state, dialog) — and it still asserts there is **one** such block in the file.
|
||||
- `test_the_gateway_page_is_exactly_two_connection_panels_and_a_warnings_list` and the footer-paragraph pin stay green (three paragraphs, one dialog, `<section>` count unchanged).
|
||||
- The orphan-`id` audit must find `#gate-switch-dialog` referenced by the script; every slot inside it is `[data-k=…]`, not an `id`.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the pins above; a fixture render for each arm (writes on/off, `enabled: false` from the payload, `applied: false` from the response) asserting class, `aria-checked`, `aria-busy`, `title` and the accessible name.
|
||||
- Integration: the dev server against a stub agent (the `tests/conftest.py` fake) answering `applied: true` then a payload with the row `enabled: false` — the row turns red **because of the payload**, and the second read of the switch's name says `off`.
|
||||
- Coverage: `node --check` clean; floor does not fall.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] With `GATEWAY_ALLOW_WRITES=0` the page is indistinguishable from phase 32's: `aria-disabled`, the `GATEWAY_ALLOW_WRITES` sentence, no click effect, no hover affordance.
|
||||
- [ ] With it `=1` (and the fake agent): clicking shows the one-line diff and the `DANGER` field; the wrong word sends nothing; the right word sends once; the row's colour changes on the payload that follows and never before.
|
||||
- [ ] A stubbed `applied: false, validated: false, restored: true` leaves every row exactly as it was and prints the route's own sentence.
|
||||
- [ ] Tab order: dialog opens with focus in the confirm field, Escape closes it without a request, focus returns to the switch. At 360 px the dialog and its diff stay inside the viewport and the document scrolls no sideways pixels.
|
||||
- [ ] `uv run pytest` **100% passing**, coverage not down; `node --check` and `bash .agents/validate.sh` green.
|
||||
- [ ] README.md/AGENTS.md's `/gateway` paragraph describes the wired switch and both arms (replacing the "unwired" sentence task 05 deleted — the two must not coexist) — the phase's final pass re-checks both project-wide.
|
||||
- [ ] No behaviour change in completed work: tallies, warnings panel, charts, filters, colour semantics, and the other seven pages.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Task 07 — Install the helper, prove it from both sides, and drill one named route with the operator watching
|
||||
|
||||
**Phase:** `33_gateway_route_switching` · **Source:** AGENTS.md's rollout discipline (phases 24 and 28) applied to the one change in this repo that can take the fleet's ingress down
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`gateway` runs agent **1.4.0** with the socket-activated helper, and one route is switched off and back on **for real**, with the live ingress ending the task in the state it started in. The drill is not a formality: it is the only proof that `ReadWritePaths`, `SocketMode`, `SO_PEERCRED`, `nginx -t` and the reload all line up on a machine, which no sandbox can tell us. **If the operator is not present in the session, this task stops after step 3 and reports — that is the correct outcome, not a failure**, and ntfy is not a way to ask (it is outbound-only).
|
||||
|
||||
## Work
|
||||
|
||||
1. **Backups first, hashed.** On `gateway-ops` as root:
|
||||
`cp -a /etc/haproxy /root/dashboards-33-backup-<utc>/haproxy && cp -a /etc/nginx /root/dashboards-33-backup-<utc>/nginx`, then `sha256sum` every file that the helper can edit (`haproxy.cfg`, `nginx.conf`, `conf.d/*.conf`, `stream.d/*.conf`) into the transcript. This is the restore point for every later step.
|
||||
2. **Sandbox rehearsal on the box** (writes nothing real): run the helper with `--apply` against a copy tree (`--root /tmp/dashboards-33-rehearsal`) with the *real* `nginx -t` and `haproxy -c` binaries on `PATH`, for each of the three marker arms and the `default` refusal. Record the three diffs. A checker that fails on a copied tree for path reasons is expected and is *not* the rehearsal's point — the arms and the restore byte-equality are.
|
||||
3. **Install, read-only-verified first**:
|
||||
- re-stage `/home/ops/agent-install/` (repo `agent/host_agent.py` **1.4.0**, `deploy/install-agent.sh`, `deploy/dashboards-routectl.py`, the two unit files), leaving `*.20261023-backup` copies sudo will not execute;
|
||||
- `systemctl cat dashboards-agent` → record its sha256 and the four security directives verbatim;
|
||||
- `sudo /home/ops/agent-install/deploy/install-agent.sh --bind 10.1.1.27 --port 9120 --allow-writes --with-routectl` (arguments read back off the unit; **no** `--cert-bundle`, the bundle on disk is what drives the TLS lines — the phase-28 finding);
|
||||
- the guard's diff is inspected before `--force`: the only legitimate change to the agent unit is the added `--allow-writes` in `ExecStart` — if `NoNewPrivileges`, `ProtectSystem`, `PrivateDevices`, `SystemCallFilter` or `CapabilityBoundingSet` moved, **stop** and report: the sandbox was not traded for this feature;
|
||||
- `sudo systemctl restart dashboards-agent`; `systemctl is-active` for it and for `dashboards-routectl.socket`;
|
||||
- reachability both ways: as `dashboards-agent`, connect to `/run/dashboards-routectl.sock` and call `list` (must answer `[]`); as any other non-root uid, connect and get `EACCES` (the mode is the first gate and the peer check is the second);
|
||||
- read-only probes: CA-only `--status` answers **1.4.0**; the dashboard's mTLS read shows `agent_version 1.4.0`, every section intact with live numbers, `disabled: false` everywhere, and `/api/gateway/snapshot` rendering identically to phase 32's.
|
||||
4. **Preconditions for the drill — all checked, all recorded, any failure stops it:**
|
||||
- the operator has said go, in this session, in words;
|
||||
- `nut` is not on battery and no UPS event is in the last hour (the same "don't do the risky thing during a power event" rule the UPS commands keep);
|
||||
- `gateway`, `host:gateway`, `disks:gateway` all `ok` and fresh; `warnings == []`;
|
||||
- `nginx -t` and `haproxy -c -f /etc/haproxy/haproxy.cfg` pass on the box before anything is touched;
|
||||
- `routectl list` is empty (no half-finished state from an earlier attempt);
|
||||
- the drill target is **`iperf3-5201`** (`haproxy:5201->web.reeselink.com:5201`) — a benchmark service with no human dependency. **Never** the auth, git, alerts or file routes: not `keycloak*`, `gitea*`, `forgejo*`, `ntfy*`, `nextcloud*`, `immich*`, `brain*`, `keycloak`, or any `map` entry an operator would notice within a minute. If even `iperf3` is in doubt, the drill does not run and the task reports that instead.
|
||||
5. **The drill, in order, with the page open:**
|
||||
1. `dry_run` through `POST /api/gateway/route` → the diff, and the page's dialog shown;
|
||||
2. apply with `DANGER`; the helper answers `applied/validated/reloaded`;
|
||||
3. prove it four independent ways: `routectl list` names the route; the marked line is in `haproxy.cfg` with `was:`; `haproxy -c` still passes; `curl -sv --resolve <host>:25565:127.0.0.1` (or the drill port) from the box connects and is refused/closed rather than proxied;
|
||||
4. the page: the row is **red** within one poll, its live count drains to `0` on its own, no other row changed, and `/api/summary`'s `gateway.disabled` is `1`;
|
||||
5. re-enable through the page; `routectl list` empty; `sha256sum` of every file equal to step 1's capture, byte-for-byte;
|
||||
6. the page green again, `disabled` back to `0`, and `journalctl -u 'dashboards-routectl@*'` showing exactly the two activations.
|
||||
6. **Abort rules, written where they can be followed:** if any step fails, restore from the step-1 backup (`cp -a` back, `haproxy -c`, reload), re-read the page, and write down what happened. Never leave a marked line behind without saying so in the transcript's first line. Never run two drills at once, never run one while the `gateway` job is stale, and never re-run an apply to "make sure" — the helper is the place idempotence lives, and the answer tells you which state you are in.
|
||||
7. `data/logs/NOTES-33-switching.md`: the hashes before and after, the unit diff that was guarded, the socket's owner/mode, both probe transcripts, the four proofs, the `journalctl` window, the screenshots' names, and — if the drill did not run — **exactly** which precondition stopped it, so the next operator can finish it deliberately.
|
||||
8. `tests/test_live_fleet.py`: `test_the_switch_round_trip_on_one_named_route`, marked `live` **and** gated on a separate `--allow-live-writes` flag, skipped by default with a reason naming the route it would touch, restoring in a `finally`. The default live suite stays read-only.
|
||||
|
||||
## Testing & Quality
|
||||
- This task's code change is the one opt-in live test; everything else is procedure plus transcript.
|
||||
- After it: `uv run pytest`, `uv run pytest -m live --live` (green, round-trip **skipped**), `./scripts/dev-server.sh status` (ten sources `ok`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `systemctl cat dashboards-agent` shows the four security directives **identical** to step 3's capture, with `--allow-writes` as the only added argument, and its pre/post sha256 pair is in the note.
|
||||
- [ ] `dashboards-routectl.socket` is `active`, owned `dashboards-agent:dashboards-agent` mode `0600`; a non-agent uid gets `EACCES`; the agent's own uid gets a JSON answer.
|
||||
- [ ] The drill's four proofs are in the transcript, plus the byte-identical `sha256sum` restore of every editable config file, and `routectl list` is empty at the end.
|
||||
- [ ] `/gateway` showed the row red and its count draining, and returned to green — screenshotted at both states.
|
||||
- [ ] `uv run pytest -m live --live` passes with the round-trip **skipped** by default; `uv run pytest -m live --live --allow-live-writes` passes too, if and only if the drill ran.
|
||||
- [ ] `data/logs/NOTES-33-switching.md` exists and its first line states the final state of the ingress (`all routes enabled`, or exactly what is off and why).
|
||||
- [ ] No repo file changed except the opt-in live test and `data/logs/`.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Task 08 — Docs that describe a dashboard which can change the fleet, the browser pass, the full gate
|
||||
|
||||
**Phase:** `33_gateway_route_switching` · **Source:** AGENTS.md definition of done + the fact that this phase changed an invariant, so the documents are part of the change rather than a summary of it
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
`README.md` and `AGENTS.md` describe the write path honestly — what it touches, what refuses, how to turn it off entirely, and how to recover a route by hand at 03:00 when the dashboard is the thing that is broken. Then the browser pass measures the wired page in both arms, the mobile audit re-runs, and every gate is green.
|
||||
|
||||
## Work
|
||||
|
||||
1. `AGENTS.md` — the full consistency pass, with these named as the passages this phase makes stale if left:
|
||||
- **invariant 1** — two exceptions, both gated, the gate list, and the reason a typed word survives in the design (`Hiding a button is not a safety control`);
|
||||
- `agent/host_agent.py` bullet — **1.4.0**, the marker grammar (`ROUTE_MARKER`, `split_marker`, `config_line_index`), `disabled` on every target, `POST /route` behind certificate + `--allow-writes`, the single peer call and its no-retry rule;
|
||||
- the new deploy surface — `deploy/dashboards-routectl.py` (verbs, marker + `was:`, validate-restore-reload, never restart, the sink file, drift refusal), the two units, `--with-routectl`/`--without-routectl`, and the sentence that the agent's unit lost no security directive and gained no sudoers line;
|
||||
- `sources/gateway.py` bullet — `enabled`, the merge rule with mixed states, the marker-aware warnings table, the `/api/summary` `disabled` field;
|
||||
- `api.py` bullet — the two fleet writes, `/api/gateway/route`'s gate order, the fixed-shape audit events with no config text, `/api/config.gateway.writes_enabled`;
|
||||
- `collectors.py` bullet — `post_route` and the shared revoke-before-socket rule;
|
||||
- `config.py` paragraph + README's table — `GATEWAY_ALLOW_WRITES` (default off) and `GATEWAY_WRITE_TIMEOUT_SECONDS` (and why it exceeds the agent's own timeout);
|
||||
- the `/gateway` UI-conventions paragraph — the switch is wired, the preview-then-`DANGER`-then-paint-from-the-answer flow, `off`'s appearance, the writes-disabled arm's sentence, the diff box scrolling in its own container;
|
||||
- the **fleet record** — `gateway` at **1.4.0** with the helper units installed, the unit's directives re-read, the drill result and `NOTES-33-switching.md`;
|
||||
- **gap list** — name-route switching **closed**, and the honest limits written as gaps: one admin (gap 6 unchanged, so anyone with the session can switch a route), no scheduler, no per-host kill switch, no runtime haproxy state (a hand reload cannot undo a marker), and `GATEWAY_ALLOW_WRITES` off by default means the feature is invisible on a fresh deployment until it is deliberately enabled.
|
||||
2. `README.md` + both tracked env templates — `.env.example` and `.env.dev.example` gain `GATEWAY_ALLOW_WRITES` with its default and a comment naming exactly what enabling it authorises, and `tests/test_auth_docs.py` gains the pin (the same device that keeps the auth settings declared in the templates), because a write gate no template names is a write gate nobody finds — and never a value, only the declaration. Then the operator-facing half: the `/gateway` table row (routes can be switched off **and on**), a short *Switching a route* subsection (what the dialog does, what `DANGER` means here, that the edit is persistent across reload and reboot, and the three recovery commands: `routectl list`, `routectl verify`, `routectl enable --id …` on the box), the *Enabling it* subsection (`GATEWAY_ALLOW_WRITES=1`, `--with-routectl`, the two units, how to undo everything), and the configuration table's two new rows. Also: the sentence that a route off is a **deliberate outage** and appears in the event feed, so an operator does not mistake it for an incident.
|
||||
3. `data/logs/33-switching.cjs` — Playwright pass (house shape, exit non-zero on FAIL, prints what it measured):
|
||||
- writes-**off** arm: `aria-disabled`, the `GATEWAY_ALLOW_WRITES` sentence, a click does nothing (no request on the wire), no hover affordance;
|
||||
- writes-**on** arm against the stubbed agent: the dialog opens with the diff and focus in the confirm field; the wrong word sends nothing; the right word sends exactly one request; the row's colour changes only after the payload says `enabled: false`; `applied: false` leaves the row untouched and prints the route's sentence; Escape closes with no request and returns focus;
|
||||
- `off` colour contract: spine halves, tick, run, switch and diff tint all compute to the same `--gate-off` value in dark **and** light, and an `on` row is fully `--gate-on` — no fifth colour, no verdict token anywhere in the section;
|
||||
- reduced motion: nothing animates, including the dialog and the busy knob;
|
||||
- 360 px: the dialog and its `<pre>` stay inside the viewport, the diff scrolls in its own box, and the document scrolls zero sideways pixels;
|
||||
- screenshots `data/logs/33-gateway-{writes-off,dialog,danger,off,off-light,refused,phone,phone-dialog}.png`, transcript `data/logs/33-switching.out`.
|
||||
4. Re-run `data/logs/27-task02-mobile-audit.cjs` (nine pages, both widths): exit 0, **0 FAIL / 0 FINDING**, tally recorded — the dialog is new markup and the audit is what catches a fact painted past a card edge.
|
||||
5. The whole gate, recorded in the transcript:
|
||||
`uv run pytest`, `uv run pytest --cov`, `uv run pytest -m live --live`, `uv run ruff check src tests agent deploy`, `uv run pyright`, `bash .agents/validate.sh`, `uv pip list --outdated` (empty — no dependency is added by this phase, and `deploy/dashboards-routectl.py` is stdlib on a host's Python 3.10).
|
||||
6. `./scripts/dev-server.sh status` with writes **off** → ten sources `ok` and no change; with `GATEWAY_ALLOW_WRITES=1` and the fake agent → still ten `ok`, and a write through `curl` round-trips.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: this task is the phase's proof that the documents and the shipped page agree; every completion-criteria number comes from `33-switching.out`, `NOTES-33-switching.md` or a gate's own output.
|
||||
- Coverage: floor green with the phase's new modules included; no `# pragma: no cover` added to reach it.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `grep -n "one exception\|exactly one write" AGENTS.md` finds nothing; invariant 1 names **two** endpoints and their gates, and the `/gateway` paragraph no longer says the switch is unwired (the two statements must not coexist).
|
||||
- [ ] `grep -n "GATEWAY_ALLOW_WRITES" README.md AGENTS.md .env.example .env.dev.example` — declared in both templates with its default and a comment saying what enabling it authorises, and documented in both documents.
|
||||
- [ ] `data/logs/33-switching.out` exits 0 with the both-arms results, the `--gate-off`/`--gate-on` equalities in both themes, the reduced-motion pass and 0 px sideways scroll at 360 px; the eight screenshots exist.
|
||||
- [ ] `data/logs/27-task02-mobile-audit.cjs` exits 0, 0 FAIL / 0 FINDING, tally recorded.
|
||||
- [ ] All five gates green, `uv pip list --outdated` empty, and the live suite green with the round-trip skipped by default.
|
||||
- [ ] A full read of `README.md` and `AGENTS.md` finds no inconsistency anywhere, including pre-existing ones, fixed in this phase's commits.
|
||||
- [ ] No behaviour change in completed work: the UPS write path and its four gates, the other five dashboards, `/settings`, `/certs`, `/login`, and every read route's payload are exactly as phases 31–32 left them.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
**Phase 31 verification complete — all 5 tasks already in `complete/`; this was the final verification pass.**
|
||||
|
||||
Verified / changed
|
||||
- Re-ran every phase-31 artifact live and green: `verify-31-page-only.py` 6 PASS/0 FAIL; browser pass `31-route-readability.cjs` **64 PASS / 0 FAIL** (exit 0); `27-task02-mobile-audit.cjs` **371 PASS / 0 FAIL / 0 FINDING** (exit 0); rebuilt `data/logs/31-route-readability.out` as the 4-part transcript.
|
||||
- Added the 4 degradation-arm screenshots the criterion asked for but the pass lacked (`31-gateway-{unconfigured,unreachable,no-nginx,sockets-quiet}.png`) — 12 frames now, one per arm.
|
||||
- Grep criteria: no gateway connector rule uses `border-strong`; no verdict token in the gateway CSS section or `page-gateway.js` beyond the `alerts.py`-owned `.warn`; exactly 4 `addEventListener` calls (none for the switch); `routeInConfig()` returns `true`; **no `.py` under `src/dashboards/` changed**.
|
||||
|
||||
Tests / lint (all 100% green)
|
||||
- `uv run pytest` → **1796 passed, 16 skipped**; `--cov` → **98.94%** (floor 94); `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `bash .agents/validate.sh` → **exit 0**.
|
||||
- `uv run pytest -m live --live` → **12 passed / 0 failed**; `./scripts/dev-server.sh status` → all **10 sources ok**.
|
||||
|
||||
Completion criteria: colour (33/33 rows every segment `--gate-on`, dark `rgb(63,185,80)` + light `rgb(26,127,55)`, zero grey dashes) ✔ · inbound-first rows + filter (33/33 line 1 = `out.selectors`, line 2 = `→ host:port · kind`; `stackexpected` → **6** rows, not the overview's "five" — the capture declares 6 such routes; tallies byte-identical `10/33/16/0`, zero requests) ✔ · trunk motion truth table + `null` arm prints `n/a` never `0` + reduced-motion stills 43 paths and every knob ✔ · screenshots/transcripts ✔ · payload page-only ✔ (literal byte-diff of a live socket table can never pass; answered by the no-`.py`-diff + shape-equality proof, as recorded in the transcript) · no behaviour change elsewhere ✔.
|
||||
|
||||
Docs: fixed 6 inconsistencies — two identifiers split across line breaks (`test_the_pipe_animation_stills_in_both_states_under_reduced_motion`, `32_gateway_resolved_target_liveness`), stale "sixteen routes" count, stale "four machines/hosts" in README **and** AGENTS (fleet is three), "three fields"→"three field types", gap-14 headline wording; updated the pass's screenshot list.
|
||||
|
||||
Deviations: `uv pip list --outdated` shows one advisory (`starlette 1.9.0 → 1.10.0`) — not upgraded, this phase moved no dependency (rule 8 / DoD apply "if deps moved"). `TODO.md` carries two new operator disk requests, left untouched.
|
||||
|
||||
Next pending phase: `32_gateway_resolved_target_liveness`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
== 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.8-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%
|
||||
1796 passed, 16 skipped, 1 warning in 164.04s (0:02:44)
|
||||
== 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):
|
||||
starlette 1.9.0 1.10.0 wheel
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
**Implemented (task 01 — whole route is green)**
|
||||
- `dash.css` `gateway pipes`: base `.gate-link::before`/`::after`/`.gate-elbow` now `2px dashed transparent` (geometry, no colour); four new rules colour spine halves + tick from the **row's** `on`/`off` via `--gate-on`/`--gate-off`; zero `--border-strong` left in the section; header/mobile comments state decision 2 (shared spine may colour, never move).
|
||||
- `page-gateway.js`: `renderLeaf` toggles `on`/`off` on the `<li>` beside the `<svg>`, same value, same pass; `leafRow()` ships no state class; `renderGroups` comment records why rows need no class pruning; header docstring restated.
|
||||
- `tests/test_api.py`: restated `test_haproxy_draws_straight_across_and_nginx_branches` (transparent base borders), extended `test_no_gateway_count_is_coloured_by_a_verdict` (connectors read the pair; `--border-strong` absent; all six connector selectors present), **new** `test_the_whole_route_takes_one_existence_class` (row+svg from one value, `transparent` base, switch `[aria-checked]` re-pinned).
|
||||
- Browser proof on the live ingress: `data/logs/31-task01-green.cjs` → `31-task01-green.out` **27 PASS / 0 FAIL** — 48 spine halves + 33 ticks + 33 runs + 33 switches all `rgb(63,185,80)` dark and `rgb(26,127,55)` light, 81 dashed segments 0 grey, `off` moves all four to red, stateless row computes `rgba(0,0,0,0)`/`stroke:none`, 360 px restored half green, 0 px sideways scroll; screenshots `31-task01-{dark,light,phone}.png`.
|
||||
|
||||
**Verification**
|
||||
- `uv run pytest` → **1792 passed, 16 skipped**; `bash .agents/validate.sh` → exit 0, coverage **98.94%** (floor 94), ruff clean, pyright 0 errors, `node --check` clean.
|
||||
- `uv run pytest -m live --live` → **12 passed**; `./scripts/dev-server.sh status` → all **10 sources ok**; mobile audit re-run **371 PASS / 0 FAIL / 0 FINDING** (`data/logs/31-task01-mobile-audit.out`).
|
||||
- No Python changed — `git status` shows only `web/static/dash.css`, `web/static/page-gateway.js`, `tests/test_api.py`, `AGENTS.md`, `README.md` (so `/api/gateway/snapshot` is byte-identical); switch still unwired, `routeInConfig()` still `true`.
|
||||
|
||||
**Docs**: AGENTS.md `/gateway` conventions (colour covers the whole row, motion stays on the run, `transparent` base rules, no per-width colour) and README's `/gateway` paragraph.
|
||||
|
||||
**Decisions/deviations**: used the four explicit state rules rather than the `--gate-line` custom property (a new token would need a `:root` default for `test_no_stylesheet_rule_uses_an_undeclared_token` and would duplicate the `transparent` default); left the pre-existing `starlette 1.9.0→1.10.0` advisory untouched (phase decision 7 forbids dependency/Python changes).
|
||||
|
||||
**Next pending task**: `31_gateway_route_readability/02_the_inbound_word_leads_the_row.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
== 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.8-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%
|
||||
1792 passed, 16 skipped, 1 warning in 165.46s (0:02:45)
|
||||
== 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):
|
||||
starlette 1.9.0 1.10.0 wheel
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
**Implemented** (task 31/02 — page-only, no Python touched)
|
||||
- `page-gateway.js`: replaced `selectorText()` with `inboundText()` (`out.selectors` joined → fallback `out.kind`, never `out.host`) and `counterpartText()` (`→ host:port · kind`); added `ARROW`/`SHARED_WHY` constants; `renderLeaf()` now writes the inbound word to `.pipe-name` (endpoint only as the null-inbound fallback, so the primary line is never empty) and the counterpart to a new `.pipe-end mono` span; `leafTitle()` carries **both ends** + kind + sharing sentence; `leafAria()` unchanged in facts, now sharing one `SHARED_WHY` string.
|
||||
- `dash.css`: new `.pipe-end` rule (11px, mono class, same truncation trio) beside `.gate-selector`; `.gate-selector` comment now scopes it to the trunk; `.pipe-name` comment restated for the new rank/gap 13.
|
||||
- `gateway.html`: footer paragraph 2 rewritten to the operator's dash sentence (still exactly 3 paragraphs); card comments above both `.gate-map`s record the reading order.
|
||||
- `tests/test_api.py`: strengthened `test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` (both ends whole in `title` *and* `aria-label`, no string-cutting in the helpers, longest-endpoint control over the live capture); new `test_the_leaf_row_leads_with_the_inbound_word` (helper sources, call sites, `GATEWAY_PAGE_FIELDS` both directions, `brain.reeseapps.com → …:443`, haproxy backend names, `default` verbatim, no empty primary line, `:80` refusal intact) + `rendered_leaf()` second-reading mirror. Negative controls confirmed both tests fail on reverted code.
|
||||
|
||||
**Verification**: `bash .agents/validate.sh` exit 0 — `uv run pytest --cov --cov-fail-under=94` **1793 passed, 16 skipped**, coverage **98.94%**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean. Live browser pass `data/logs/31-task02-inbound-row.cjs` → `31-task02-inbound-row.out`: **21/21 PASS**, 33/33 rows read inbound→counterpart, no console errors/non-GET, 0 px sideways scroll at 360 px; screenshots `31-task02-gateway-{1440,360}.png`. `27-task02-mobile-audit.cjs` re-run → exit 0, 0 FAIL / 0 FINDING (`31-task02-mobile-audit.out`). `dev-server.sh status`: all ten sources `ok`.
|
||||
|
||||
**Docs**: README `/gateway` table row + UI-conventions paragraph and AGENTS.md table row + `/gateway` conventions paragraph now state line 1 = inbound word, line 2 = `→ host:port · kind`, both carried whole; AGENTS.md test count 1791→1793. Also corrected a stale claim in both files (haproxy's inbound word is the `backend` name, not the `server` name).
|
||||
|
||||
**Decisions/deviations**
|
||||
- Arrow is rendered `→ host:port` (one space) rather than the task snippet's `→host:port`, matching the phase's completion-criterion rendering and the live transcript; `ARROW = '→'` stays as specified.
|
||||
- `GATEWAY_PAGE_FIELDS` already named all four `connections.out.*` paths, so I pinned the two-directional resolution in the new test + documented the second use instead of duplicating entries.
|
||||
- Task said the fleet's longest endpoint is `reeseapps-brain-of-code.reeselink.com:443`; the capture's longest is actually `reeseapps-brain-of-reese.reeselink.com:443` (42 chars) — pinned by length+family so either neighbour satisfies the control.
|
||||
- gap 13 measurement for task 05: 1440 px → 0 clipped; 360 px → 19/33 inbound and 33/33 counterpart lines ellipsised, every one keeping full text in `title` + `aria-label`.
|
||||
- Left phase 30's `data/logs/30-connection-map.cjs` untouched (its `sels` FINDING still reads `.gate-selector`, absent from leaf rows now — it still exits 0; gap 13's restatement is task 05's).
|
||||
|
||||
**Next pending task**: `.agents/phases/todo/31_gateway_route_readability/03_the_trunk_moves_on_a_real_number.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
== 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.8-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%
|
||||
1793 passed, 16 skipped, 1 warning in 179.17s (0:02:59)
|
||||
== 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):
|
||||
starlette 1.9.0 1.10.0 wheel
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
**Task 03 complete — all green.**
|
||||
|
||||
**Implemented**
|
||||
- `page-gateway.js`: `buildPipe(trunk = false)` adds `pipe pipe-trunk` (one builder, one class shape for both lines); `groupElement()` appends the stub as `.gate-trunk`'s last child via DOM (`createElementNS`, commented); new `trunkAria(group)` — three pinned sentences, indexes only `listeners[].connections`; `renderTrunk()` patches `hidden`/`on`/`off`/`live`/`still` + `role="img"` + `aria-label`/`title`, with `measured` separating `0` from `null`; file-header bullet now says motion has two payload paths (leaf `connections[]`, trunk `listeners[]`) and never implies a leaf.
|
||||
- `dash.css`: `.pipe-trunk { width: 26px; height: 10px; flex: none; margin-inline-start: auto; }` — no new token, no new keyframe; motion/colour still come from `.pipe.on`/`.pipe.live`.
|
||||
- `tests/test_api.py`: new `test_the_trunk_pipe_is_an_accessible_image_of_its_port` (+ `rendered_trunk` mirror) covering both arms over the capture and over real `sockets_silent`; `test_a_pipe_moves_only_while_it_carries_a_connection` restated for the two payload paths with "trunk ≠ leaf" absences; reduced-motion pin now demands descendant selectors and forbids `.pipe-trunk` styling motion; the accessible-image pin now asserts two pipe builders, one `createElementNS`, `role`+`aria`+`title` on both.
|
||||
- Browser pass `data/logs/31-task03-trunk.cjs` → `31-task03-trunk.out` (35 PASS / 0 FAIL, exit 0) with `31-task03-trunk-{1440,360,light,sockets-quiet,reduced}.png`: `:443`/`:4443` run, the 8 idle ports sit still, all green `--gate-on` in dark *and* light, same colour either way; every `aria-label` is one of the three sentences and names no endpoint; socket-quiet arm is green, motionless, `n/a`, **0** occurrences of `0 live`; stub flush at the trunk's right edge (= the spine x) even on wrapped trunks, no clipping, 0 px sideways scroll at 360 px; reduced motion stills every stub with no `.pipe-trunk` rule.
|
||||
- `data/logs/27-task02-mobile-audit.cjs` re-run: **371 PASS / 0 FAIL / 0 FINDING**, exit 0 (`data/logs/31-task03-mobile-audit.out`).
|
||||
|
||||
**Commands / results:** `uv run pytest` → **1794 passed, 16 skipped**; `uv run pytest --cov --cov-fail-under=94` → **98.94%** (floor met, not down); `uv run ruff check src tests agent` → all checks passed; `uv run pyright` → 0 errors; `node --check src/dashboards/web/static/page-gateway.js` → clean; `bash .agents/validate.sh` → **exit 0**. `git diff` touches **no** Python under `src/dashboards/` except `web/`; `routeInConfig()` still returns `true`; no switch listener.
|
||||
|
||||
**Docs:** README `/gateway` conventions paragraph and AGENTS.md (the groups paragraph + the `live`/`still` bullet) now state the trunk's own line, its port-only claim and its three sentences.
|
||||
|
||||
**Deviations (2, both measured):** added `margin-inline-start: auto` to `.pipe-trunk` — without it a trunk whose text wraps (`:80` live) dropped the dash alone at the trunk's far-left, stranded from the spine; it costs ~6 px of slack on one-line trunks. `aria-label` is set with `setAttribute` (the sketch's `setTxt` writes `textContent`, which an `<svg>` has no use for). Left alone: `starlette 1.9.0 → 1.10.0` advisory (pre-existing, `validate.sh` treats it as advisory; this task moves no dependencies).
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/31_gateway_route_readability/04_find_a_domain.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
== 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.8-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%
|
||||
1794 passed, 16 skipped, 1 warning in 165.70s (0:02:45)
|
||||
== 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):
|
||||
starlette 1.9.0 1.10.0 wheel
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
**Phase 31 task 04 (`04_find_a_domain.md`) is complete and green.**
|
||||
|
||||
## What shipped
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/dashboards/web/pages/gateway.html` | `#gate-filter` (`type="search"`, `autocomplete="off"`, `spellcheck="false"`, `aria-label`) + `#gate-filter-count` chip in the *Connection map* head; `[data-k="no-match"]` sentence in each panel; the **fourth** footer paragraph |
|
||||
| `src/dashboards/web/static/dash.css` | `input[type="search"]` joined the shared field rule; `.gate-filter { max-width: 18rem; min-width: 0 }` + `width: 100%` at ≤640px; no `:hover`/`cursor`/`transition`, no new token |
|
||||
| `src/dashboards/web/static/page-gateway.js` | `state.filter`/`state.row`; `haystack()` / `trunkHaystack()` / `leafShown` / `trunkShown`; `renderPanels()` (the one repaint the poll, the keystroke and `Escape` all share); `li.hidden` by index in the patch pass; per-panel no-match; `N of M routes` chip |
|
||||
| `tests/test_api.py` | `test_the_filter_narrows_the_map_without_asking_anything`, `test_the_filter_field_is_markup_the_sheet_and_nobody_else_uses`; the switch pin's `addEventListener` set became an allow-list that still forbids `click`; footer pin 3 → 4 |
|
||||
| `README.md` / `AGENTS.md` | the filter named in both `/gateway` rows and paragraphs, the refusal to group/sort/colour by suffix, footer=4, the four real bindings, test count 1791 → 1796 |
|
||||
|
||||
## Verification
|
||||
|
||||
- `bash .agents/validate.sh` → **EXIT=0**: **1796 passed, 16 skipped**, coverage **98.94%** (floor 94), ruff clean, pyright 0 errors, `node --check` clean, hygiene clean. Only advisory: `starlette 1.9.0 → 1.10.0` (pre-existing; this task moves no dependency).
|
||||
- `data/logs/31-task04-filter.cjs` → `31-task04-filter.out` **34 PASS / 0 FAIL** on the live ingress (33 routes): `stackexpected` → 6 of 33 with only `nginx:443` + `haproxy:2000` standing; case-insensitive; tallies `10 / 33 / 12 / 0` identical either side of every keystroke; **zero** requests across five keystroke states; `Escape` restores all 33; a client address keeps `:443`'s whole 18-leaf fan-out; `no-such-domain-anywhere` hides 10/10 groups and prints the sentence in both panels; the same filtered view after a real 30 s poll; 360 px → 320 px field, **0 px** sideways scroll. Screenshots `31-task04-filter-{stackexpected,no-match,cleared,360}.png`.
|
||||
- `data/logs/27-task02-mobile-audit.cjs` → `31-task04-mobile-audit.out`: **371 PASS / 0 FAIL / 0 FINDING**, 10 skips, exit 0.
|
||||
- Report written: `.agents/reports/31_gateway_route_readability/31_gateway_route_readability__04_find_a_domain.a1.md` (+ `.status` = `ok`, + `.a1.validate`).
|
||||
- Dev server is up; `/api/gateway/snapshot` untouched — no Python changed (`git diff` under `src/dashboards/` is `web/` only).
|
||||
|
||||
## Two things I need your answer on
|
||||
|
||||
1. **Phase bookkeeping** (rule 4 — I won't edit `.agents/phases/todo/` unprompted): tick the Completion-Criteria boxes in `04_find_a_domain.md` and move it to `.agents/phases/complete/31_gateway_route_readability/` like tasks 01–03, tick only, or leave the phase tree alone?
|
||||
2. **Commit?** HEAD is still `phase: 30_gateway_connection_map`, so phase 31 tasks 01–04 are all uncommitted. Options: leave it, one phase-31-so-far commit, or task 04 only (all with `--no-gpg-sign` + `LLM-Generated: true`).
|
||||
|
||||
Next in the phase is `05_docs_and_live_verify.md` (the project-wide README/AGENTS pass, gap 13's re-measurement with the new row + filter, and the read-only live check).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
== 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.8-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%
|
||||
1796 passed, 16 skipped, 1 warning in 169.82s (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):
|
||||
starlette 1.9.0 1.10.0 wheel
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
Phase 31 task 05 is complete. Final state:
|
||||
|
||||
**Implemented**
|
||||
- `data/logs/31-route-readability.cjs` — read-only live browser pass, **64 checks / 0 FAIL**: computed `--gate-on` on every segment of all 33 rows in **both** themes (`rgb(63,185,80)` / `rgb(26,127,55)`) with **no** dashed border computing `--border-strong`; gap‑13 clip counts at 1440 px (0/33 + 0/33, 361 px column) and 360 px (19/33 + 33/33, 139 px) with 33/33 rows carrying both ends whole; the trunk truth table plus a deterministic arm (7 sockets on `:443`, every leaf count removed → trunk moves, 0/18 leaves do); filter = **zero requests** with byte-identical tallies; reduced motion stills 43 paths + every knob; all degradation arms; 0 px sideways scroll at both widths; **zero non-GET requests**.
|
||||
- `data/logs/verify-31-page-only.py` (6/0) — the payload half, explained at the head of the transcript: a literal before/after byte diff of a live socket snapshot can never pass, so it proves the guarded question instead (no `.py` differs from the phase‑30 commit; 571 leaf paths with 0 non-volatile differences across a real poll; key set equal to `30-connection-map.out`).
|
||||
- Transcript `data/logs/31-route-readability.out` (4 parts, 222 lines) + 8 screenshots + `31-task05-mobile-audit.out` (audit **371 PASS / 0 FAIL / 0 FINDING**, exit 0).
|
||||
- **Real defect found and fixed**: `svg.title = aria` assigned a property `SVGElement` does not reflect — no tooltip ever painted. Now `setAttribute('title', aria)` (leaf + trunk), pinned in `tests/test_api.py`.
|
||||
- Docs: AGENTS `/gateway` paragraph (phase‑31 framing, measurement paragraph, dash→phase 32, `setAttribute`), gap 13 restated with the measured numbers, new gaps **14/15** (one line each for `32_gateway_resolved_target_liveness` and `33_gateway_route_switching`); README dash/what-runs-today paragraph (**agent 1.2.0**, no resolution, no switching); `gateway.html` footer comment three→four; stale "17-row fan-out" counts corrected to 18.
|
||||
|
||||
**Results**: `uv run pytest` **1796 passed, 16 skipped**, coverage **98.92%** (floor 94) · `ruff` **All checks passed** · `pyright` **0 errors** · `node --check web/static/*.js` clean · `bash .agents/validate.sh` **exit 0** · live suite **12 passed, 0 failed** · dev server restarted, all **10 sources ok**.
|
||||
|
||||
**Notes/deviations**: `validate.sh` prints one advisory (`starlette 1.9.0 → 1.10.0`) — not upgraded, this phase touched no dependency (rule 8 applies to dependency changes); `NOTES.md` left empty (this project's log is AGENTS.md + `data/logs/`); the phase dir stays in `todo/` for the harness and the task file is unmodified. Next pending: none in phase 31 — the harness may move it to `complete/`, after which phase 32 is the next work item.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
== 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.8-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%
|
||||
1796 passed, 16 skipped, 1 warning in 167.52s (0:02:47)
|
||||
== 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):
|
||||
starlette 1.9.0 1.10.0 wheel
|
||||
@@ -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` | 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 |
|
||||
| `/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 routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) over the counterpart it routes to, plus its live socket count, with one filter box above the map that hides the routes whose own labels do not contain what was typed | 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`.
|
||||
@@ -373,8 +373,8 @@ Responsibilities, one line each:
|
||||
30 s, `GATEWAY_TIMEOUT_SECONDS` 10 s, health and events under the dashboard name `gateway` — but
|
||||
**one** job rather than one per host (the `nut` shape), dialling the `HOST_TARGETS` entry
|
||||
`GATEWAY_HOST` names with its `label` set to that host's name, because the page is about the single
|
||||
ingress and a record per machine would ask four hosts a question only one can answer. It exists
|
||||
only when that name is a target: an unconfigured source has no task that could look red
|
||||
ingress and a record per machine would ask every host a question only one of them can answer. It exists only
|
||||
when that name is a target: an unconfigured source has no task that could look red
|
||||
(invariant 3), and it is handed no `RateEstimator` because a count is not a rate (rule 2).
|
||||
The job list is built once (`app.build_hub()`) because it is wired twice — production and
|
||||
the injected-store test path — and two hand-written copies drifting is how a source ends up
|
||||
@@ -1616,7 +1616,7 @@ 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: 1791 passed + 16 skipped (12 live,
|
||||
uv run pytest # hermetic: 1796 passed + 16 skipped (12 live,
|
||||
# 4 store_perf), no network
|
||||
uv run pytest --cov # coverage gate (98.9%; floor 94%)
|
||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||
@@ -2158,7 +2158,8 @@ charge float jitters every `NUT_INTERVAL_SECONDS`, and rebuilding the `<svg>` on
|
||||
an accessible name for a difference no eye can see.
|
||||
|
||||
**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
|
||||
joined differently by phase 30 task 02; laid out as trunk→leaf panels by phase 30 task 04; made
|
||||
readable — whole-row colour, inbound-first rows, a moving trunk, a filter — by phase 31): **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
|
||||
@@ -2169,10 +2170,37 @@ 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
|
||||
`MAX_CLIENTS` client addresses, its `server_name` count with the whole list on `title`, and — since
|
||||
phase 31 task 03 — a **short `.pipe.pipe-trunk` of its own**, appended through the DOM as the trunk's
|
||||
last child because an `<svg>` needs `createElementNS` and cannot come out of `groupElement()`'s static
|
||||
`innerHTML` template), and the *leaves* hanging off it are the routes that port's config declares,
|
||||
each with its own `metric`. That stub is the port's live count drawn as a line: `renderTrunk` puts
|
||||
`live` on it when `listeners[].connections >= 1`, `still` when it is `0` **or** when it is `null` —
|
||||
never `live` for an unknown, and never a `0` in `.pipe-count` for one either — and `on` always, because
|
||||
`listeners[]` contains only ports a config names, so the row existing *is* the existence fact. Its
|
||||
claim is **the port and never a leaf**: `trunkAria()` writes
|
||||
`:4443 — 9 connections are open on this port right now` / `… no connections are open …` /
|
||||
`… connections on this port are unknown: the socket table did not answer` into both `aria-label` and
|
||||
`title` — the latter with `setAttribute`, because `title` is a reflected IDL attribute of
|
||||
`HTMLElement` and **not** of `SVGElement`, so `svg.title = …` is a JavaScript property no tooltip ever
|
||||
reads (task 05's browser pass found the served code doing exactly that) — and it indexes nothing but
|
||||
`listeners[].connections`: no `endpointText`, no `out.*`, no client address, because attributing one
|
||||
port's sockets to one of the routes hanging under it is a claim `sources/gateway.py` does not make.
|
||||
`margin-inline-start: auto` on `.pipe-trunk` is measured, not polish: the trunk wraps, and
|
||||
`.pipe-count`'s own auto margin flushes only the item that *starts* a
|
||||
line, so without it a two-line trunk (`:80` on the live ingress) dropped the dash alone at the far
|
||||
left — ink stranded on the opposite side of the trunk from the spine.
|
||||
**A leaf reads inbound first** (phase 31 task 02, correcting phase 30's rank): line 1 is
|
||||
`.pipe-name` and carries the config's word for **who reaches** the endpoint — a vhost's
|
||||
`server_name`, a stream `map` key (the literal `default` verbatim), or a haproxy `backend` name —
|
||||
from `inboundText()`, which joins **every** `out.selectors` entry, falls back to `out.kind`, and
|
||||
never to `out.host`; line 2 is `.pipe-end` and carries `counterpartText()`'s
|
||||
`→ host:port · kind`, the counterpart it flows to. Both lines are mono so a fan-out of eighteen rows reads
|
||||
down its columns, `.pipe-name` stays the primary line of its row for the trunk too (a trunk prints
|
||||
its port there), and neither helper cuts a string: CSS clips, and `leafTitle()` / `leafAria()` carry
|
||||
**both ends whole** (gap 13). Phase 30 had the two reversed, which buried the domain the operator
|
||||
types into a browser under the address they never type. 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
|
||||
@@ -2181,7 +2209,8 @@ for it; `sources/gateway.py` refuses to warn about that shape for the same reaso
|
||||
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
|
||||
panel by id, because rebuilding a fan-out the size of this ingress's `:443` 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.
|
||||
|
||||
@@ -2191,20 +2220,44 @@ quiet prints `n/a` (`setTxt`'s own word), while a **leaf** whose endpoint the co
|
||||
**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 footer's second paragraph is the same sentence in the operator's terms rather than the join's —
|
||||
*a dash means the endpoint is written in the config as a name; the page matches sockets by address and
|
||||
resolves nothing, so this route is drawn but not counted* — because a column of dashes needs one
|
||||
explanation on the page and not thirty-two on the rows, and because it is a **name** that is the reason
|
||||
and not a fault. `32_gateway_resolved_target_liveness` is what removes the reason; until then the dash
|
||||
stays unexplained-looking by design rather than borrowing the trunk's `n/a` word.
|
||||
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:
|
||||
endpoint, the selector and the count) and the row carries **two independent class pairs** (phase 30
|
||||
task 05, moved onto the `<li>` by phase 31 task 01), 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.
|
||||
* `live` / `still` — "≥ 1 ESTABLISHED socket on this pipe right now" — decides **motion only**, and it
|
||||
stays on the `<svg>` alone. 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. Motion may not spread: the spine segment between two rows is **shared** by
|
||||
both leaves, so a dash travelling down it could not be attributed to one route, and animating it
|
||||
would put a per-route claim on a per-list structure. There are **two** moving pipes and they read two
|
||||
different payload paths (phase 31 task 03): a leaf's run from `connections[].connections` and a
|
||||
trunk's stub from `listeners[].connections`. One `.pipe` component serves both — `buildPipe(trunk)`
|
||||
adds `pipe-trunk` and nothing else — which is also why the file's single
|
||||
`@media (prefers-reduced-motion: reduce)` block stills the stub without naming it: the stilling rules
|
||||
are descendant selectors (`.pipe.live .pipe-path`), and
|
||||
`test_the_pipe_animation_stills_in_both_states_under_reduced_motion` pins that no `.pipe-trunk` rule
|
||||
may style `animation`/`transition` itself and quietly step outside that block.
|
||||
* `on` / `off` — "a proxy config declares this route" — decides **colour for the whole row**, 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`). `renderLeaf` toggles
|
||||
it on the `<li>` **and** the `<svg>` off the same value in the same pass, and four rules carry it to
|
||||
the two spine halves and the tick, so a route is green from its trunk to its endpoint — phase 30
|
||||
coloured `.pipe-path` only and the operator read that as two thirds of every route being inactive
|
||||
(`test_the_whole_route_takes_one_existence_class` pins the row class, the four connector rules and
|
||||
that no gateway rule names the structure token any more). `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. Colour
|
||||
still only ever *arrives* from a state class: the base `.pipe-path` declares no `stroke` and the base
|
||||
`.gate-link::before` / `::after` / `.gate-elbow` rules declare their border `transparent` — an SVG's
|
||||
default stroke is `none` for free, a border's default colour is `currentColor`, so "a row built and
|
||||
never rendered draws nothing" has to be written down for a border.
|
||||
|
||||
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
|
||||
@@ -2217,18 +2270,23 @@ one of them off) and coloured from the same existence pair, carrying a full acce
|
||||
(`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:
|
||||
`addEventListener` calls are exactly four — the charts' `toggle`, `DOMContentLoaded`, and the filter's
|
||||
`input`/`keydown` — and `test_the_switch_is_built_and_explicitly_unwired` pins that list as an
|
||||
allow-list whose missing member is the point: **no `click` is bound anywhere on this page**) 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
|
||||
The branch is four CSS rules of geometry plus four of colour, 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). The ≤640px block restates the
|
||||
reaching half's geometry and **no colour**, because a per-width copy of the existence pair is how a
|
||||
pair drifts from its theme. `.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)`.
|
||||
|
||||
@@ -2250,9 +2308,80 @@ and the legend names pipes because the panel heading already says which config f
|
||||
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
|
||||
so the last topology stays on screen. The footer is **four** paragraphs and no more: why a line is
|
||||
drawn and what its motion means; why a **name** endpoint carries no number; that the switch is not
|
||||
wired; and what the filter box does to the map and what it leaves alone — the sentence is there because
|
||||
the field is the one control that could be read as a query, and it is not one.
|
||||
|
||||
**`#gate-filter` is an eye, not a query** (phase 31 task 04). 33 routes in config order is not a
|
||||
lookup, and the operator's question is always about one domain, so one field above the panels hides the
|
||||
rows whose **own payload text** does not contain what was typed: `in.port`, `out.host`, `out.name`,
|
||||
`out.kind` and every `out.selectors` entry for a route (`haystack()`), `:${port}` plus
|
||||
`listeners[].domains` and `.clients` for a trunk (`trunkHaystack()`). Nothing else is readable and
|
||||
nothing else is matched — no DNS, no re-ranking, no rule about `.reeselink.com`, and no page-level
|
||||
split by domain suffix, because "`reeseapps.com` vs `stackexpected.com`" is a fact neither config file
|
||||
states and grouping by it would be inventing a taxonomy the ingress never declared (rule 2); the
|
||||
operator types the suffix and the page hides the rest. A **trunk** that matches keeps its whole
|
||||
fan-out: typing `4443` asks for the port, and its routes are the answer. Visibility is applied inside
|
||||
the patch pass (`renderLeaves` sets `li.hidden` by index into `group.connections`, `renderGroups` sets
|
||||
`el.hidden` and the panel's `[data-k="no-match"]`), so a poll cannot undo the filter, a hidden row
|
||||
keeps its identity and its `<svg>`, and only the *config* removing a route ever deletes a node. The
|
||||
repaint is `renderPanels(state.snapshot, state.row)` — **no** `fetchJSON` and no `refresh()` on the
|
||||
filter path, which is what the type of the feature is (`refresh()` would add a history pair per
|
||||
keystroke and change nothing), `Escape` clears the field because a filter you cannot leave with the
|
||||
keyboard is a trap on a page whose other shortcuts are all keys, `#gate-filter-count` reads
|
||||
`N of M routes` (leaf routes, both panels, and hidden while the field is empty), and
|
||||
`renderHeader()`/`renderWarnings()` hold no reference to `state.filter` at all: `ports`,
|
||||
`connections`, `live` and `warnings` describe the ingress, not the operator's current view of it, and
|
||||
the warnings panel is a different claim rather than a shorter version of this one. The field is the
|
||||
page's own markup wearing the sheet's shared field skin (`input[type="search"]` joined the
|
||||
`input[type="text"], input[type="password"]` rule — the only three field types the app has), it is
|
||||
`autocomplete="off" spellcheck="false"` with an `aria-label`, `.gate-filter` caps it at `18rem` on a
|
||||
desktop and goes `width: 100%` at ≤640px, and it declares no `:hover`, no `cursor` and no
|
||||
`transition` because the connection map owns one interactive-looking thing (the unwired knob) and this
|
||||
is not it. `data/logs/31-task04-filter.cjs` measured it on the live ingress (transcript
|
||||
`31-task04-filter.out`, screenshots `31-task04-filter-{stackexpected,no-match,cleared,360}.png`):
|
||||
`stackexpected` → 6 of 33 rows with `nginx:443` and `haproxy:2000` the only trunks standing, a client
|
||||
address → that port's whole fan-out, `no-such-domain-anywhere` → every group hidden with both panels
|
||||
saying `no route matches "…"` and neither the tallies (`10 / 33 / 12 / 0`, byte-identical either side
|
||||
of every keystroke) nor the warnings panel moving, **zero** requests across five keystroke states, the
|
||||
same filtered view after a real 30 s poll as before it, and 0 px of sideways document scroll at 360 px
|
||||
where the field is 320 px wide. `34 PASS / 0 FAIL`.
|
||||
|
||||
Phase 31's own pass measured the whole map rather than its parts, and is the pin that the three
|
||||
readability changes landed together: `data/logs/31-route-readability.cjs` (transcript
|
||||
`31-route-readability.out`, which opens with `data/logs/verify-31-page-only.py`'s payload proof,
|
||||
screenshots `31-gateway-*.png`, twelve frames — 1440, 360, light, filtered, no-match, trunk-live,
|
||||
phone, and one per degradation arm: predates, unconfigured, unreachable, no-nginx, sockets-quiet) —
|
||||
signs in and reads **computed** colour, not classes — every segment of all 33 rows (both spine
|
||||
halves, the tick, the run, the knob) computes to `--gate-on`, `rgb(63, 185, 80)` in dark and `rgb(26, 127, 55)` after the real
|
||||
theme toggle, and **no dashed border anywhere inside `.gate-map` computes `--border-strong`**
|
||||
(`rgb(51, 59, 77)` dark / `rgb(195, 202, 214)` light — the grey plumbing the operator read as
|
||||
inactivity), with **0** boxes in the map carrying a verdict class in either theme. The motion truth
|
||||
table comes out of the DOM against the payload's own numbers: every trunk the kernel reported ≥ 1 for
|
||||
animated, every one it reported 0 for drew a motionless line, and each `0 live` string corresponded to
|
||||
a port the payload carried 0 for — a reading of traffic, never a stored fact. A second, deterministic
|
||||
arm settles the direction of the claim: with `:443` handed 7 sockets and **every** leaf's count set to
|
||||
`null`, the trunk animated and 0 of its 18 leaves did, so a port's number provably does not leak into
|
||||
its fan-out. The arm where the socket table
|
||||
answers nothing draws **ten motionless `n/a` stubs** reading `connections on this port are unknown: the
|
||||
socket table did not answer`, with **no** `0 live` ink anywhere, every leaf keeping its `—`, the header's
|
||||
`live` tally `n/a` — and no panel taking the shared `stale` class, because the configs *did* answer. It
|
||||
then holds that the **leaf** keeps its own claim: with a trunk live and the leaf under it at `—`, the
|
||||
trunk animates and that leaf does not, because a port's sockets are not one route's. The filter pass is
|
||||
zero requests across five keystroke states with the four tallies byte-identical either side of every
|
||||
keystroke and `Escape` clearing the same way; `prefers-reduced-motion: reduce` stills all 43 paths (10 of
|
||||
them trunk stubs, through the block's descendant selectors) and every knob; the degradation arms each
|
||||
show their own sentence (`404 {configured: false}`, a `supported: false` agent, a silent socket section,
|
||||
a never-answered host); and the document scrolls **0 px** sideways at 1440 px and at 360 px with both
|
||||
charts open and the widest fan-out on screen. It also found, and fixed, the one defect of the phase that no class-based check could see:
|
||||
`svg.title = aria` assigned a JavaScript property and produced **no tooltip**, because `title` is a
|
||||
reflected IDL attribute of `HTMLElement` and not of `SVGElement` — the two map tooltips are
|
||||
`setAttribute('title', …)` calls now, which is what the browser can show and what
|
||||
`tests/test_api.py` demands. Its mobile-audit sibling is `31-task05-mobile-audit.out` (the
|
||||
`27-task02-mobile-audit.cjs` sweep, 371 checks, 0 failures, 0 findings, exit 0).
|
||||
|
||||
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
|
||||
@@ -3145,9 +3274,10 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
ordinary rotation hygiene, not a missing step.
|
||||
|
||||
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`,
|
||||
lost fact, and the filter is what a clipped label used to block** (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
|
||||
after phase 31 re-ranked the row on 2026-10-26**; `data/logs/31-route-readability.out` is the
|
||||
current measurement, `data/logs/30-connection-map.out`, `data/logs/30-task04-panels.out`,
|
||||
`data/logs/29-task06-live.out` and `data/logs/29-task06-card-haproxy.png` the history).
|
||||
|
||||
**Phase 30 task 04 closed the desktop half by changing the row, not the font.** The two
|
||||
@@ -3160,26 +3290,35 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
and not a bug: the longest target here is 42 characters
|
||||
(`reeseapps-brain-of-code.reeselink.com:443`) and it fits.
|
||||
|
||||
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.
|
||||
**Phase 31 kept the desktop half closed and moved the phone's clipping to where it belongs.** Its
|
||||
pass (`data/logs/31-route-readability.cjs`, both widths, both themes) reads **0 of 33** inbound
|
||||
lines and **0 of 33** counterpart lines ellipsised at 1440 px in the same **361 px** leaf column,
|
||||
with the longest inbound word 35 characters and the longest counterpart line 50 — the row got a
|
||||
second line and a `→ … · kind` wrapper and still fits. At **360 px** a group stacks and the column
|
||||
is **139 px**, so the two lines now clip at **19 of 33** inbound and **33 of 33** counterpart.
|
||||
That is the intended direction of the trade: phase 31 task 02 made line 1 the config's own short
|
||||
word for *who reaches* the endpoint (`server_name`, `map` key, `backend` name) and demoted the long
|
||||
`host:port` to line 2, so what still fits is the part an operator reads and what clips is the part
|
||||
they read once. Nothing is lost: `renderLeaf()` puts **both** ends whole on the row's `title` and
|
||||
`leafAria()`/`leafTitle()` carry them on the line, which is what
|
||||
`tests/test_api.py::test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins, and the
|
||||
browser pass measured 33 of 33 rows carrying their whole pair at both widths — as a **painted**
|
||||
tooltip now, because that pass found `svg.title = aria` had been assigning a property
|
||||
`SVGElement` does not reflect and showing nothing, and the fix (`setAttribute`) is what makes
|
||||
"nothing is lost" true in a browser rather than only in the DOM. The declarations that decide *how
|
||||
much* clips stay pinned (`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. What changed the gap's *cost* is task 04's filter:
|
||||
the reason a clipped label mattered was that a long target could not be **found**, and typing
|
||||
`brain` now hides 31 of 33 rows without one request, matching whole text CSS never showed. In order
|
||||
of cost for what is left: (a) at ≤640 px let the leaf row put the count on the counterpart'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
|
||||
inbound word, and the number the row is about is the count. Whoever picks this up re-runs
|
||||
`data/logs/31-route-readability.cjs` (which supersedes `data/logs/30-connection-map.cjs`): its two
|
||||
`FINDING` lines are the counts at both widths, and the run **fails** if any row ever stops carrying
|
||||
its full pair — 33 of 33 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
|
||||
@@ -3191,6 +3330,26 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
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.
|
||||
|
||||
14. **A route whose endpoint the config writes as a *name* is drawn and never counted —
|
||||
`32_gateway_resolved_target_liveness` is the phase that removes the reason** (recorded by phase
|
||||
31 task 05, which wrote no design for it). Today 31 of this ingress's 33 routes end in a
|
||||
`server_name`/`map` target and so carry an em dash where a live count would go, because
|
||||
`sources/gateway.py` matches sockets by
|
||||
address and resolves nothing (rule 2 by choice, and pinned as an absence). The follow-on phase is
|
||||
the one that makes such a target answer with its liveness and its live connections anyway — the
|
||||
agent reporting resolved targets on the box that owns the config, which is an `AGENT_VERSION` bump
|
||||
to **1.3.0** and therefore an agent + source + pin change, not a page change. Its acceptance line
|
||||
is that a dash disappears from `/gateway` for a reason and not by hiding the column.
|
||||
|
||||
15. **`/gateway`'s switches are knobs with no write behind them — `33_gateway_route_switching` is the
|
||||
phase that decides whether they ever get one** (recorded by phase 31 task 05, no design written).
|
||||
Enabling or disabling a route is a second gated upstream write path in front of the proxy configs,
|
||||
which **amends invariant 1** (today `POST /api/ups/command` is the app's only upstream write, and
|
||||
the route enumeration in `tests/test_api.py` is the pin that says so) and needs the operator's
|
||||
sign-off in `.agents/PLAN.md` **before** it is written as a phase. Until that decision exists the
|
||||
knobs stay `aria-disabled`, unbound, and explained in the footer; the `PATCH`-shaped endpoint, the
|
||||
config writer and the rollback are that phase's work and none of it is here.
|
||||
|
||||
## Locked decisions (and why)
|
||||
|
||||
- **SQLite, not Prometheus/Grafana.** Handful of boxes, 1–10s resolution, one file the
|
||||
|
||||
@@ -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 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 |
|
||||
| `/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 routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) on line 1 over the counterpart it routes to on line 2 (`→ host:port`), plus its live connection count — and one filter box above the map that hides the routes whose own labels do not contain what you typed, asking nothing of the server | 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.
|
||||
@@ -732,7 +732,7 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
`alerts.py` is why one dead agent is still one line on the overview rather than two. The gateway
|
||||
page's job (phase 29) is the **third** reader of that same `/snapshot`, and one job for the whole
|
||||
deployment rather than one per host — `GATEWAY_HOST` names the single ingress, and a job per host
|
||||
would ask four machines a question only one can answer — on its own 30 s cadence with no
|
||||
would ask every machine a question only one of them can answer — on its own 30 s cadence with no
|
||||
`RateEstimator`, because a connection count is not a rate. And it never quietly *stops*: the task
|
||||
is that source's only poller, so a SQLite write that gets refused is reported (`ok: false`, the
|
||||
real reason, the numbers aging, one line in the event log) instead of freezing the source under a
|
||||
@@ -1078,21 +1078,54 @@ more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
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
|
||||
one port that file binds, and hanging off it are the *leaves* — the routes that port's config names,
|
||||
each reading **inbound first**: line 1 is the config's own word for who reaches the endpoint (a
|
||||
`server_name`, a haproxy `backend` name, a stream `map` key, the literal `default`), and line 2 is
|
||||
the counterpart it flows to, **exactly as the config wrote it**, marked `→ host:port` with the kind
|
||||
of route after it. Both ends are verbatim, and both are carried whole on the row's tooltip and the
|
||||
line's accessible name whichever one CSS has to ellipsise. 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
|
||||
route behind it is not a branch. A route is **green because the config declares it**, an existence
|
||||
colour of the page's own, and that green covers **every segment of the row** — spine, tick, line and
|
||||
switch all read one class on the row, so no part of an active route is drawn as grey plumbing — while
|
||||
the line **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. The trunk carries a
|
||||
**short line of its own** for that reason, and it reads from a *different* field: a leaf moves on its
|
||||
endpoint's count, the trunk's stub moves on `listeners[].connections` — the sockets whose **local**
|
||||
end is that port, which `/proc` states whether or not the route's target is a resolvable address. So
|
||||
a busy port is visibly busy even when every leaf under it carries a `—`, which is the normal look of
|
||||
this fleet's `:443`. That line's claim is about **the port and never about one route**: its
|
||||
accessible name and tooltip say `:4443 — 9 connections are open on this port right now` (or
|
||||
`no connections…`, or `connections on this port are unknown: the socket table did not answer`) and
|
||||
name no endpoint, and the unknown arm prints `n/a` where the count is and no motion at all — never
|
||||
`0`, and never a dash, because "the socket table went quiet" is a different fact from "this target
|
||||
is a name".
|
||||
Above the map, one **filter box** finds a domain among 33 routes (`stackexpected`, `brain`, `4443`,
|
||||
`minecraft`) because the rows are in config order and the question is always about one name. It hides
|
||||
what the payload already sent and asks for **nothing**: no request, no re-derivation, no new route.
|
||||
It matches the strings the config wrote — the inbound word(s), the endpoint, the port, the haproxy
|
||||
`server` name, and a trunk's own domains and client addresses — case-insensitively, so a label CSS
|
||||
ellipsised still matches its whole text. A trunk that matches keeps its **whole fan-out**: typing
|
||||
`4443` asks for the port, and every route hanging off it is the answer. `Escape` clears it, a chip
|
||||
beside the field counts `N of M routes`, and a query that matches nothing leaves both panels standing
|
||||
with `no route matches "…"` in each. What it never touches is the header's four tallies, the warnings
|
||||
panel, or the charts — those describe the ingress, not the operator's current view of it — and it
|
||||
never **groups, sorts or colours** by domain suffix: `reeseapps.com` versus `stackexpected.com` is a
|
||||
fact neither config file states, so the operator types the suffix and the page hides the rest rather
|
||||
than inventing a zone no proxy declared.
|
||||
**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. Expect that on most of this ingress's routes —
|
||||
31 of its 33 are written as domains — and read it as *drawn, not counted* rather than as a fault; the
|
||||
footer says the same thing once, in plain terms, because a dash per row needs one explanation and not
|
||||
thirty-two. 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;
|
||||
@@ -1105,7 +1138,11 @@ more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
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.
|
||||
in a browser. Giving either of those two dashes or knobs a real backing store is deliberately **not**
|
||||
documented here as a thing you can have: name-based liveness needs the agent to report resolved
|
||||
targets (a future agent version), and switching a route needs a second gated write path in front of
|
||||
the proxy configs, which is a change to this app's read-only promise rather than a feature. What runs
|
||||
today is **agent 1.2.0**, no name resolution, and no switching.
|
||||
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.
|
||||
|
||||
@@ -105,7 +105,20 @@
|
||||
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>
|
||||
<!-- The title, and the one control this page has. The field is an **eye, not a query**: it hides
|
||||
rows the payload already sent, fires no request, and never re-sorts, splits or colours the
|
||||
map by domain suffix — so it needs no server, no endpoint and no re-dial. It moves nothing
|
||||
else either: the four tallies above are the payload's figures and stay standing beside an
|
||||
empty result, and the warnings panel is a different claim (phase 30 task 03) rather than a
|
||||
shorter version of this one. `Escape` clears it. -->
|
||||
<div class="page-head">
|
||||
<h2 style="font-size:.95rem;margin:0">Connection map</h2>
|
||||
<div class="spacer"></div>
|
||||
<input type="search" id="gate-filter" class="gate-filter" autocomplete="off" spellcheck="false"
|
||||
placeholder="filter by domain, endpoint or port"
|
||||
aria-label="Filter routes by domain, endpoint or port">
|
||||
<span class="chip faint" id="gate-filter-count" hidden aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="grid auto items-start gate-grid" id="gate-cards">
|
||||
<article class="card gate-card" data-gate="nginx" hidden>
|
||||
<header>
|
||||
@@ -118,9 +131,16 @@
|
||||
<div class="body stack">
|
||||
<!-- 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. -->
|
||||
A leaf reads inbound first: line 1 is the config's word for who reaches the endpoint
|
||||
(`server_name`, `map` key, `default`) and line 2 is the counterpart it flows to, so do
|
||||
not swap them back — the question this page answers is which domain goes where. -->
|
||||
<div class="gate-map" data-k="map"></div>
|
||||
<div class="pipe-empty faint" data-k="empty" hidden></div>
|
||||
<!-- The third sentence a panel can need, and the only one the operator causes: this file
|
||||
declares routes, none of them answers what is in the filter. `data-k="empty"` above
|
||||
stays for the panel with no routes at all, so a filtered-out map never claims nginx
|
||||
named nothing. -->
|
||||
<div class="pipe-empty faint" data-k="no-match" 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">
|
||||
@@ -142,8 +162,13 @@
|
||||
<span class="faint mono updated"></span>
|
||||
</header>
|
||||
<div class="body stack">
|
||||
<!-- Same group shape as the nginx panel, same reading order: line 1 is the inbound word
|
||||
(here the haproxy `backend` name), line 2 the `server` address it routes to. -->
|
||||
<div class="gate-map" data-k="map"></div>
|
||||
<div class="pipe-empty faint" data-k="empty" hidden></div>
|
||||
<!-- Same third sentence, same reason, per panel: each config file answers for its own
|
||||
routes, so a filter matching only nginx leaves do not make haproxy look empty. -->
|
||||
<div class="pipe-empty faint" data-k="no-match" 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">
|
||||
@@ -159,18 +184,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Three sentences, one per claim the page is allowed to make: what the drawing means and what
|
||||
<!-- Four 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. -->
|
||||
domain carries no number (so a dash is not read as a fault), what the switch does not yet do,
|
||||
and what the filter box does to the map and what it leaves alone. No `stale` claim — a socket
|
||||
table that did not answer is the page's one `why` line. -->
|
||||
<footer class="pagefoot">
|
||||
<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">a dash means the endpoint is written in the config as a <b>name</b>; the page
|
||||
matches sockets by <b>address</b> and resolves nothing, so this route is drawn but not
|
||||
counted.</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>
|
||||
<p class="faint">the filter box hides the routes whose own labels do not contain what you typed —
|
||||
domain, <code>server_name</code>, <code>map</code> key, endpoint or port. It asks for nothing:
|
||||
the tallies, the warnings and the charts above it are still the whole ingress, not the filtered
|
||||
part of it.</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1287,8 +1287,10 @@ table.data td.san { white-space: normal; }
|
||||
* 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.
|
||||
* not-in-the-config state the same pair owns — and it colours **every** segment of the row, spine
|
||||
* and tick included, because one route is one claim and a green run hanging off a grey branch says
|
||||
* two things about it. 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
|
||||
@@ -1356,26 +1358,50 @@ table.data td.san { white-space: normal; }
|
||||
/* 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. */
|
||||
/* These three rules are **geometry and nothing else**, and each declares its border `transparent`
|
||||
* instead of a colour. An SVG path with no `stroke` is invisible, which is the rule `.pipe-path`
|
||||
* keeps above; a border's default colour is `currentColor`, so being invisible-by-default has to be
|
||||
* written down for a border. A row `renderLeaf` never gave a state class is therefore blank ink
|
||||
* rather than a confident line in whatever colour the text happens to be. */
|
||||
.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);
|
||||
border-left: 2px dashed transparent;
|
||||
}
|
||||
.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);
|
||||
border-left: 2px dashed transparent;
|
||||
}
|
||||
.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); }
|
||||
.gate-elbow { align-self: center; height: 0; border-top: 2px dashed transparent; }
|
||||
/* 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; }
|
||||
/* **One class, the whole route.** `page-gateway.js` writes `on`/`off` on the `<li>` itself, in the
|
||||
* same pass and off the same value it colours the `<svg>` with, and these four rules are what that
|
||||
* class buys: the two halves of the spine, the tick out to the switch, the run inside its `<svg>`
|
||||
* (`.pipe.on .pipe-path`) and the switch pill (`.gate-switch[aria-checked]`) all read the existence
|
||||
* pair. Before phase 31 the connectors were the only parts left in structural ink, so a route the
|
||||
* config declared arrived green for its last third and grey for the rest of its length.
|
||||
*
|
||||
* No rule here may reach for structure ink or for a verdict colour: `--gate-on` means *written in a
|
||||
* proxy config* and `--gate-off` means the opposite, which is the one claim this page is allowed to
|
||||
* paint (`severity.py` has no `gateway.*` row and the payload carries no `severity` at any depth).
|
||||
* Colour is also the **only** thing a shared segment is allowed to say. The spine between two rows
|
||||
* belongs to both leaves, so a dash travelling down it could not be attributed to one route; motion
|
||||
* therefore stays the per-row run's claim (`.pipe.live`, from that row's own connection count) and
|
||||
* the spine says nothing about traffic at all.
|
||||
*/
|
||||
.gate-link.on::before, .gate-link.on::after { border-left-color: var(--gate-on); }
|
||||
.gate-link.off::before, .gate-link.off::after { border-left-color: var(--gate-off); }
|
||||
.gate-link.on .gate-elbow { border-top-color: var(--gate-on); }
|
||||
.gate-link.off .gate-elbow { border-top-color: var(--gate-off); }
|
||||
.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
|
||||
@@ -1399,13 +1425,24 @@ table.data td.san { white-space: normal; }
|
||||
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`. */
|
||||
/* The trunk's domain count (`16 names`, with the whole list on `title`) — the page's smallest line,
|
||||
* and the one the leaf no longer uses: a leaf's qualifier became its primary line (phase 31 task 02).
|
||||
* It keeps the truncation trio because a fan-out of sixteen `server_name`s is the widest thing its
|
||||
* trunk prints. */
|
||||
.gate-selector {
|
||||
font-size: 11px; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
/* A leaf's **second** line: the counterpart the inbound word above it flows to, marked `→` and
|
||||
* tailed with the config's word for the route kind. Mono because both ends are hostnames and a
|
||||
* 17-row fan-out has to read down its columns (the `mono` class on the span carries the family, the
|
||||
* same way `.pipe-name` gets it). Same 11px truncation trio as `.gate-selector` because this is now
|
||||
* the **longer** of the row's two strings on this fleet — gap 13 measures which of them clips — and
|
||||
* `title` plus the pipe's `aria-label` carry the whole pair whatever CSS hides. */
|
||||
.pipe-end {
|
||||
font-size: 11px; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
/* The run from the switch to the endpoint — the part that moves. The base rule carries the shape
|
||||
* 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
|
||||
@@ -1425,20 +1462,44 @@ table.data td.san { white-space: normal; }
|
||||
* 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; }
|
||||
/* The trunk's own stub: that port's live count drawn as a line instead of only as words. It is short
|
||||
* and `flex: none` because it is a *reading*, not a run — a trunk has no endpoint to travel to, and a
|
||||
* full-width line here would read as a route the config never drew. Colour and motion come from the
|
||||
* same two rules a leaf's run uses (`.pipe.on .pipe-path`, `.pipe.live .pipe-path`), which is why the
|
||||
* one `@media (prefers-reduced-motion: reduce)` block at the foot of this file stills it for free:
|
||||
* one component with two sources of truth, not a second component to remember. Declared after `.pipe`
|
||||
* because both are single-class selectors and this one has to win the `width`.
|
||||
*
|
||||
* `margin-inline-start: auto` is the third declaration and it is measured, not decorative. The trunk
|
||||
* wraps (phase 27's fix for a long label), and `.pipe-count`'s own auto margin flushes only the item
|
||||
* that *starts* a line — so with the stub as the trunk's last child, a trunk whose text wrapped
|
||||
* (`:80` on the live ingress: `:80`, `0 live`, `1 name`, then the stub) dropped the dash alone at the
|
||||
* far LEFT of a second line, ink stranded on the opposite side of the trunk from the spine it belongs
|
||||
* to. An auto margin on the stub costs nothing when the trunk fits one line (the free space merely
|
||||
* splits between the two of them, a few pixels apart) and puts the stub at the trunk's right edge —
|
||||
* the x the leaf spine is drawn at — on every trunk that does not. */
|
||||
.pipe-trunk { width: 26px; height: 10px; flex: none; margin-inline-start: auto; }
|
||||
@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. */
|
||||
/* The **primary line of its row**, for both kinds of row: the trunk prints its port here (`:25566`)
|
||||
and the leaf prints its inbound word (`brain.reeseapps.com`, `default`, `gitea-2222`). Keeping one
|
||||
class for "the line that leads" is what lets the measurements below apply to both.
|
||||
|
||||
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.
|
||||
|
||||
Phase 31 task 02 put the inbound domain here and the target on `.pipe-end` below it. On this fleet
|
||||
that makes the primary line the **shorter** of the two (`brain.reeseapps.com` against
|
||||
`reeseapps-brain-of-code.reeselink.com:443`), so what clips now is the counterpart line, not the
|
||||
answer. Either way the clip is a column-width fact (AGENTS.md gap 13) and not a lost one: the row
|
||||
carries both ends whole as `title` and the pipe's `aria-label` names both, 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;
|
||||
@@ -1454,6 +1515,13 @@ table.data td.san { white-space: normal; }
|
||||
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; }
|
||||
/* The one field on the page, and the one thing above the map it can hide rows from. It takes the
|
||||
sheet's shared field skin (`input[type="search"]`, misc section) and adds only its measure:
|
||||
`max-width` so it is not a second title on a wide screen, `min-width: 0` so it shrinks inside the
|
||||
flex page head instead of growing it. Nothing else, on purpose — no `:hover`, no `cursor`, no
|
||||
`transition`: the only control this page can move is the knob of a switch it cannot wire, and a
|
||||
field dressed like a button is how a read-only page gets clicked as if it were not. */
|
||||
.gate-filter { max-width: 18rem; min-width: 0; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* One column, trunk above its leaves: 320 px of card has no room for a line and a label side by
|
||||
@@ -1463,7 +1531,9 @@ table.data td.san { white-space: normal; }
|
||||
/* 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. */
|
||||
* keeps it exact if the group's rhythm ever moves. **No colour is restated here**: the restored
|
||||
* half takes its ink from the `.gate-link.on`/`.off` rules above, and a per-width copy of the
|
||||
* existence pair is exactly how one would drift from its theme. */
|
||||
.gate-link:first-child::before {
|
||||
display: block;
|
||||
top: calc(var(--gate-row-gap) * -1); height: calc(50% + var(--gate-row-gap));
|
||||
@@ -1471,6 +1541,11 @@ table.data td.san { white-space: normal; }
|
||||
/* 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; }
|
||||
/* The field takes the head's whole width here: this ingress routes 33 domains, and a filter you
|
||||
* cannot see your own typing in is a filter that does not get used. `max-width: none` lifts the
|
||||
* desktop cap rather than fighting it, and the row it sits in needs nothing — `.page-head`
|
||||
* already wraps. */
|
||||
.gate-filter { width: 100%; max-width: none; }
|
||||
}
|
||||
|
||||
/* ---- dialog ------------------------------------------------------------- */
|
||||
@@ -1506,12 +1581,15 @@ dialog footer { padding: .7rem .9rem; border-top: 1px solid var(--border); displ
|
||||
|
||||
.stale { opacity: .62; }
|
||||
|
||||
/* One rule for the app's two text fields — `/ups`'s `DANGER` box and `/login`'s password —
|
||||
* which are the same control, and a second near-copy is how one of them stops matching the
|
||||
* theme. `/system`'s host/metric pickers were the last `select` on the app and left with the
|
||||
* shared charts, along with the `label.field` caption that sat above them. Focus needs nothing
|
||||
* here: `:focus-visible` at the top of this file already draws the accent outline. */
|
||||
input[type="text"], input[type="password"] {
|
||||
/* One rule for the app's three text fields — `/ups`'s `DANGER` box, `/login`'s password and
|
||||
* `/gateway`'s route filter — which are the same control, and a second near-copy is how one of them
|
||||
* stops matching the theme. `/system`'s host/metric pickers were the last `select` on the app and left
|
||||
* with the shared charts, along with the `label.field` caption that sat above them. Focus needs nothing
|
||||
* here: `:focus-visible` at the top of this file already draws the accent outline. `type="search"` is
|
||||
* named rather than the box being left UA-styled because Chrome gives a search field its own
|
||||
* rounded, inset look and its own background, which is one field on one page in a palette the theme
|
||||
* does not own. */
|
||||
input[type="text"], input[type="password"], input[type="search"] {
|
||||
background: var(--bg-sunken); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm); padding: .3rem .45rem; font: 500 12.5px var(--sans);
|
||||
}
|
||||
|
||||
@@ -16,15 +16,24 @@
|
||||
|
||||
Three rules keep the drawing honest:
|
||||
|
||||
* **A line exists because a config says so, and its colour is that fact.** Every row is a route the
|
||||
file declares, whether or not a socket is open on it, so a resting line is not a dead one — it is
|
||||
the same green. `--gate-on` means "written in a proxy config" and `--gate-off` means the opposite;
|
||||
neither is a verdict, because `severity.py` has no `gateway.*` row and the payload carries no
|
||||
`severity` at any depth. Motion means exactly one other thing: at least one ESTABLISHED socket on
|
||||
that pipe right now.
|
||||
* **The label is the config's own text.** A bind keeps its port, a `server_name` keeps its domain,
|
||||
a `proxy_pass` keeps the address it was written with, a stream route keeps the SNI key the `map`
|
||||
matched on — the payload's `selectors`, printed verbatim.
|
||||
* **A line exists because a config says so, and its colour is that fact — from trunk to endpoint.**
|
||||
Every row is a route the file declares, whether or not a socket is open on it, so a resting line is
|
||||
not a dead one — it is the same green, spine halves, tick, run and switch alike: `renderLeaf` puts
|
||||
`on`/`off` on the `<li>` and every segment of the row reads `--gate-on`/`--gate-off` from that one
|
||||
class. Neither word is a verdict, because `severity.py` has no `gateway.*` row and the payload
|
||||
carries no `severity` at any depth. Motion means exactly one other thing — at least one ESTABLISHED
|
||||
socket right now — and it is read from **two payload paths**, because a line here is about either a
|
||||
route or a port: a leaf's run moves on `connections[].connections` (the sockets at that route's
|
||||
endpoint), and the trunk's own short stub moves on `listeners[].connections` (the sockets whose
|
||||
*local* end is that port). The trunk's figure is the only live fact this page can claim about a
|
||||
port, and it never says which leaf carries them. The spine between two rows is shared by every leaf
|
||||
of its port, so it colours and does not move.
|
||||
* **The label is the config's own text, and the row reads inbound first.** A bind keeps its port, a
|
||||
`server_name` keeps its domain, a `proxy_pass` keeps the address it was written with, a stream route
|
||||
keeps the SNI key the `map` matched on — the payload's `selectors`, printed verbatim. So a leaf's
|
||||
line 1 is the word the operator types into a browser (`brain.reeseapps.com`) and line 2 is the
|
||||
counterpart it flows to (`→ reeseapps-brain-of-reese.reeselink.com:443`), because the question this
|
||||
page exists to answer is *which domain goes where*, not what address a socket carries.
|
||||
* **`—` means nobody could count it, and there is exactly one reason.** The config wrote its target
|
||||
as a **name** — `reeseapps-brain.reeselink.com:443` against the `10.1.1.106:443` `/proc` reports
|
||||
— and nothing in this stack resolves one to the other. It is never `0` (rule 2), and it is not
|
||||
@@ -38,6 +47,11 @@
|
||||
Charts follow the `/system` discipline: one collapsed `<details>` per card, painted only while
|
||||
open, fed by **one** `/api/gateway/history` request per card per refresh naming every gauge that
|
||||
card could draw.
|
||||
|
||||
One field above the panels narrows them to the routes whose own payload text answers what the
|
||||
operator typed. It is an **eye, not a query**: it hides rows the payload already sent, fires no
|
||||
request, sorts nothing by domain suffix, and moves neither the header tallies nor the warnings list.
|
||||
A filter that cost a round trip would be a filter the operator cannot type into.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
@@ -58,6 +72,8 @@ const state = {
|
||||
charts: new Map(), // card key -> LineChart, built on first expand only
|
||||
history: new Map(), // card key -> { range, response } from the last snapshot pass
|
||||
snapshot: null, // last payload, so a toggle paints without fetching
|
||||
row: null, // the `gateway` job's own health row, same reason
|
||||
filter: '', // the filter field, verbatim and trimmed; '' means "no filter"
|
||||
poller: null,
|
||||
/* Half the job's interval: an age stamp cannot sit at "updated 29s" for a whole cycle, and
|
||||
* nothing on the page claims a fresher reading than the agent took. */
|
||||
@@ -93,6 +109,12 @@ const NO_COUNT_WHY = 'not attributed: the config names this endpoint as a name,
|
||||
/** The sentence a trunk with nothing hanging off it gets instead of an empty leaf list. */
|
||||
const NO_TARGET = 'no target named in the config';
|
||||
|
||||
/** Marks the row's second line as the destination rather than as another name to read. */
|
||||
const ARROW = '→';
|
||||
|
||||
/** What a `shared` endpoint means, in the one sentence both the tooltip and the `aria-label` say. */
|
||||
const SHARED_WHY = 'this endpoint is shared with another route';
|
||||
|
||||
/** The measured count as text; the unknown is `null` so `setTxt` prints `n/a` and not `0`. */
|
||||
function countText(value) {
|
||||
return typeof value === 'number' && isFinite(value) ? num(value, 0) : null;
|
||||
@@ -132,6 +154,51 @@ function groupsFor(snapshot, key) {
|
||||
return Array.from(byPort.values()).sort((a, b) => a.port - b.port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one route is *called*, as one lower-cased string, and nothing the payload did not say.
|
||||
*
|
||||
* The filter's whole knowledge of a leaf: the inbound port, the endpoint's host and its `server`
|
||||
* name, the config's word for the kind of route, and every selector it carries (a `server_name`, a
|
||||
* stream `map` key, a `backend` name). No DNS, no inference, no substring of a *value* — a route
|
||||
* matches because of what it is called, never because of how busy it is. Typing `stackexpected`
|
||||
* finds the five routes that word appears in, and typing `4443` finds that port; a query can never
|
||||
* invent a route the two config files do not declare, because there is no other text to match.
|
||||
*/
|
||||
function haystack(conn) {
|
||||
const out = conn.out || {};
|
||||
return [String((conn.in || {}).port ?? ''), out.host, out.name, out.kind, ...(out.selectors || [])]
|
||||
.filter((s) => typeof s === 'string' && s).join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for a trunk: the port as the page prints it, then the listener's own domains and the
|
||||
* client addresses `/proc` named beside them.
|
||||
*
|
||||
* A trunk is matched by **itself**, not by its leaves: `:4443` is a port an operator types when they
|
||||
* want that port and everything hanging off it, and `nextcloud.reeseapps.com` is a name the listener
|
||||
* row carries even on a poll where no route under it matched.
|
||||
*/
|
||||
function trunkHaystack(group) {
|
||||
const listener = group.listener || {};
|
||||
return [`:${group.port}`, ...(listener.domains || []), ...(listener.clients || [])]
|
||||
.filter((s) => typeof s === 'string' && s).join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
/** Is the filter up at all? An empty one hides nothing, which is the same answer as "no filter". */
|
||||
function filtering() {
|
||||
return state.filter.length > 0;
|
||||
}
|
||||
|
||||
/** Does this leaf answer the filter? `haystack` and the needle are both lower-cased. */
|
||||
function leafShown(conn) {
|
||||
return haystack(conn).includes(state.filter.toLowerCase());
|
||||
}
|
||||
|
||||
/** Does the trunk answer it on its own, in which case every leaf under it is part of the answer? */
|
||||
function trunkShown(group) {
|
||||
return trunkHaystack(group).includes(state.filter.toLowerCase());
|
||||
}
|
||||
|
||||
/** The endpoint a line names, spelled the way the config wrote it (`host`, `host:port`). */
|
||||
function endpointText(out) {
|
||||
const host = (out || {}).host || '?';
|
||||
@@ -140,28 +207,43 @@ function endpointText(out) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The line's second row: the config's own word(s) for what reaches this endpoint, then its kind.
|
||||
* Line 1: the config's own word(s) for who reaches this endpoint, verbatim and in full.
|
||||
*
|
||||
* `selectors` is a haproxy backend name, a vhost's `server_name`s, a stream `map` key, or the
|
||||
* literal `default`; `kind` is which of those it is. Both are the document's text, joined and never
|
||||
* paraphrased — which is the whole reason one target can be read two ways (`:4443 → keycloak`
|
||||
* reached as `keycloak.reeseapps.com` and as `admin.keycloak.reeseapps.com`) without the page
|
||||
* having to know what a vhost is.
|
||||
* `selectors` is a vhost's `server_name`(s), a stream `map` key (or the literal `default`), or a
|
||||
* haproxy backend name; `kind` says which of those it is and is the fallback when a row names no
|
||||
* selector at all. Never `out.host` — the target is line 2's job, and putting it here is what made
|
||||
* the domain the operator actually types the smallest thing on the row.
|
||||
*
|
||||
* Every selector is joined with a space, so a merged connection (two vhosts claiming one
|
||||
* `(in_port, endpoint)`) shows **both** names; the page never picks a "best" one, because the
|
||||
* config's list is the fact and one of them is not (rule 2).
|
||||
*/
|
||||
function selectorText(out) {
|
||||
const selectors = ((out || {}).selectors) || [];
|
||||
const kind = (out || {}).kind || null;
|
||||
const words = selectors.join(' ');
|
||||
if (!words) return kind;
|
||||
return kind ? `${words} · ${kind}` : words;
|
||||
function inboundText(out) {
|
||||
const words = ((out || {}).selectors || []).join(' ');
|
||||
return words || (out || {}).kind || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line 2: the endpoint the config routes to, marked as the destination, plus its kind.
|
||||
*
|
||||
* The arrow is the only mark this page puts on either string, and it costs a character: it says
|
||||
* "this is where the name above it goes", which is the one relation the row claims. The `· kind`
|
||||
* tail is the config's own word for the route (`map`, `server_name`, `backend`), which is how a
|
||||
* `map` leaf and a vhost leaf reaching the same address stay distinguishable when both lines are
|
||||
* hostnames. Neither half is edited, reordered or truncated here — CSS clips, and `leafTitle` plus
|
||||
* `leafAria` carry the whole pair.
|
||||
*/
|
||||
function counterpartText(out) {
|
||||
const kind = (out || {}).kind;
|
||||
return `${ARROW} ${endpointText(out)}${kind ? ` · ${kind}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The accessible sentence for one line — every figure in it came from the payload.
|
||||
*
|
||||
* The label is ellipsised by CSS at a desktop width (AGENTS.md gap 13), so this is the version that
|
||||
* never truncates: the whole endpoint, every selector, the inbound port, and whether the count is
|
||||
* a measurement or a `—`.
|
||||
* Both of the row's lines are ellipsised by CSS at a phone width (AGENTS.md gap 13), so this is the
|
||||
* version that never truncates: the whole endpoint, **every** inbound selector, the inbound port, and
|
||||
* whether the count is a measurement or a `—`.
|
||||
*/
|
||||
function leafAria(conn, on) {
|
||||
const out = conn.out || {};
|
||||
@@ -169,7 +251,7 @@ function leafAria(conn, on) {
|
||||
const selectors = out.selectors || [];
|
||||
if (selectors.length) bits.push(`reached by ${selectors.join(', ')}`);
|
||||
if (out.name) bits.push(`server ${out.name}`);
|
||||
if (conn.shared === true) bits.push('this endpoint is shared with another route');
|
||||
if (conn.shared === true) bits.push(SHARED_WHY);
|
||||
// The state word is what the green *means*. A reader who cannot see the colour gets the same
|
||||
// answer it gives, and it is an existence sentence, never a verdict: "in the config", not "good".
|
||||
bits.push(on ? 'in the config' : 'not in the config');
|
||||
@@ -178,19 +260,37 @@ function leafAria(conn, on) {
|
||||
return bits.join(', ');
|
||||
}
|
||||
|
||||
/** The tooltip text for a line: the endpoint and its selectors, the whole string however CSS clips it. */
|
||||
/**
|
||||
* The tooltip text for a line: **both ends in full** — every inbound word, the arrow, the endpoint,
|
||||
* the route kind, and the sharing sentence when the endpoint is shared.
|
||||
*
|
||||
* Whichever of the two lines CSS clips (gap 13), `title` carries the whole pair, because the row's
|
||||
* answer is "this domain goes to this counterpart" and half of it is no answer.
|
||||
*/
|
||||
function leafTitle(conn) {
|
||||
const text = endpointText(conn.out);
|
||||
const words = selectorText(conn.out);
|
||||
return words ? `${text} — ${words}` : text;
|
||||
const out = conn.out || {};
|
||||
const inbound = inboundText(out);
|
||||
const kind = out.kind;
|
||||
const pair = `${inbound ? `${inbound} ` : ''}${ARROW} ${endpointText(out)}`
|
||||
+ (kind ? ` · ${kind}` : '');
|
||||
return conn.shared === true ? `${pair} — ${SHARED_WHY}` : pair;
|
||||
}
|
||||
|
||||
/** The line itself: hand-rolled SVG — one path, `role="img"`, an `aria-label` of its own. */
|
||||
function buildPipe() {
|
||||
/**
|
||||
* The line itself: hand-rolled SVG — one path, `role="img"`, an `aria-label` of its own.
|
||||
*
|
||||
* One builder for both lines on this page — a leaf's run and a trunk's short stub — so the two can
|
||||
* never grow different class shapes, different geometries, or one state vocabulary between them.
|
||||
* `trunk` adds `pipe-trunk` and **nothing else**: same `viewBox`, same `M0 5 H100` path, same
|
||||
* `preserveAspectRatio="none"`, same `vector-effect="non-scaling-stroke"`. What differs is the width
|
||||
* (26 px in the stylesheet rather than the column) and which payload path paints it, which is what
|
||||
* `renderTrunk` and `renderLeaf` each own.
|
||||
*/
|
||||
function buildPipe(trunk = false) {
|
||||
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||
// No `on` here: the state classes are the payload pass's (`renderLeaf`), so a row that is built and
|
||||
// then never rendered draws nothing rather than drawing a confident green line.
|
||||
svg.setAttribute('class', 'pipe');
|
||||
// No `on` here: the state classes are the payload pass's (`renderLeaf` / `renderTrunk`), so a row
|
||||
// that is built and then never rendered draws nothing rather than drawing a confident green line.
|
||||
svg.setAttribute('class', trunk ? 'pipe pipe-trunk' : 'pipe');
|
||||
svg.setAttribute('viewBox', '0 0 100 10');
|
||||
svg.setAttribute('preserveAspectRatio', 'none');
|
||||
svg.setAttribute('focusable', 'false');
|
||||
@@ -269,7 +369,11 @@ function switchAria(conn, on) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One leaf row, built once: elbow, switch, line, target, count.
|
||||
* One leaf row, built once: elbow, switch, line, the two labels, count.
|
||||
*
|
||||
* `.pipe-name` is the **primary line of its row** for both kinds of row — the trunk prints its port
|
||||
* there, the leaf prints its inbound word — which is what lets every layout rule phase 27/30 earned
|
||||
* about `max-content` and ellipsis keep working for one selector instead of two.
|
||||
*
|
||||
* Static markup only — every string the payload owns is assigned through `textContent` afterwards,
|
||||
* because a `server_name`, a `proxy_pass` and a client address are upstream text and this page is
|
||||
@@ -277,6 +381,9 @@ function switchAria(conn, on) {
|
||||
*/
|
||||
function leafRow() {
|
||||
const li = document.createElement('li');
|
||||
// `gate-link` and nothing else, for the same reason `buildPipe()` ships a bare `.pipe`: the state
|
||||
// classes are the payload pass's, so a row built and then never rendered draws no dashed ink at all
|
||||
// rather than a confident green route nobody read from a config.
|
||||
li.className = 'gate-link';
|
||||
const elbow = document.createElement('span');
|
||||
elbow.className = 'gate-elbow';
|
||||
@@ -288,11 +395,14 @@ function leafRow() {
|
||||
target.className = 'gate-target';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'pipe-name mono';
|
||||
const selector = document.createElement('span');
|
||||
selector.className = 'gate-selector faint';
|
||||
selector.hidden = true;
|
||||
// Line 2: the counterpart the inbound word on line 1 flows to. Mono like line 1 because both ends
|
||||
// are hostnames and a fan-out of seventeen `*.reeselink.com` entries (the captured
|
||||
// `stream.conf`; the live router answers one more today) has to read down its columns.
|
||||
const counterpart = document.createElement('span');
|
||||
counterpart.className = 'pipe-end mono';
|
||||
counterpart.hidden = true;
|
||||
target.appendChild(name);
|
||||
target.appendChild(selector);
|
||||
target.appendChild(counterpart);
|
||||
const count = document.createElement('span');
|
||||
count.className = 'pipe-count';
|
||||
li.appendChild(elbow);
|
||||
@@ -311,14 +421,23 @@ function renderLeaf(li, conn) {
|
||||
if (li.title !== title) li.title = title;
|
||||
const on = routeInConfig(conn);
|
||||
|
||||
// The row reads **inbound → counterpart**: line 1 is the config's word for who reaches this
|
||||
// endpoint (a `server_name`, a stream `map` key, the literal `default`, a haproxy backend name),
|
||||
// line 2 is the target it flows to. Phase 30 had them the other way round, which buried the domain
|
||||
// the operator types into a browser under the address they never type.
|
||||
//
|
||||
// `connections[]` always carries a `host` and always a `kind`, so `inboundText` coming back `null`
|
||||
// is the arm with **neither** selectors nor kind — a row `sources/gateway.py` does not publish. If
|
||||
// one ever arrives, the endpoint takes line 1 and line 2 hides: the primary line never prints
|
||||
// `null`, and the target is never printed twice on one row.
|
||||
const inbound = inboundText(conn.out);
|
||||
const name = li.querySelector('.pipe-name');
|
||||
const text = endpointText(conn.out);
|
||||
if (name.textContent !== text) name.textContent = text;
|
||||
const primary = inbound == null ? endpointText(conn.out) : inbound;
|
||||
if (name.textContent !== primary) name.textContent = primary;
|
||||
|
||||
const selector = li.querySelector('.gate-selector');
|
||||
const words = selectorText(conn.out);
|
||||
selector.hidden = words == null;
|
||||
if (words != null) setTxt(selector, words);
|
||||
const counterpart = li.querySelector('.pipe-end');
|
||||
counterpart.hidden = inbound == null;
|
||||
if (inbound != null) setTxt(counterpart, counterpartText(conn.out));
|
||||
|
||||
// A `—` is not a measurement of zero and the tooltip says which of the two it is, so the column
|
||||
// of dashes down a `:443` fan-out reads as "these targets are names" rather than "idle".
|
||||
@@ -336,6 +455,13 @@ function renderLeaf(li, conn) {
|
||||
// busy one from looking healthy: neither number is graded, and `severity.py` has no `gateway.*`.
|
||||
svg.classList.toggle('live', live);
|
||||
svg.classList.toggle('still', !live);
|
||||
// …and the existence pair goes on the **row** as well as the `<svg>`, off the same value in the
|
||||
// same pass. The spine halves (`.gate-link::before`/`::after`) and the tick (`.gate-elbow`) take
|
||||
// their colour from `li.on`/`li.off`, so writing it here rather than only on the pipe is what makes
|
||||
// a row green from its trunk to its endpoint — and what makes "a green run hanging off a grey
|
||||
// branch" unrepresentable rather than a thing that happens between two class writes.
|
||||
li.classList.toggle('on', on);
|
||||
li.classList.toggle('off', !on);
|
||||
svg.classList.toggle('on', on);
|
||||
svg.classList.toggle('off', !on);
|
||||
svg.setAttribute('role', 'img');
|
||||
@@ -352,7 +478,19 @@ function renderLeaf(li, conn) {
|
||||
if (sw.getAttribute('aria-label') !== swAria) sw.setAttribute('aria-label', swAria);
|
||||
}
|
||||
|
||||
/** One group: the trunk on the left, its leaf list on the right. Static markup only. */
|
||||
/**
|
||||
* One group: the trunk on the left, its leaf list on the right. Static markup only.
|
||||
*
|
||||
* The trunk's stub is appended through the DOM rather than written into the template because an
|
||||
* `<svg>` needs `createElementNS`: markup parsed as HTML in an HTML document gives an unknown element
|
||||
* and no path at all. That is the same reason `buildPipe()` builds the leaf's line, and it is why the
|
||||
* template stays free of payload text while this line stays free of anything but a class —
|
||||
* `renderTrunk` gives it the state and the sentence afterwards.
|
||||
*
|
||||
* Last child of `.gate-trunk`, after `.pipe-count`: the count carries `margin-inline-start: auto`, so
|
||||
* `9 live` and its stub land together at the trunk's right edge — the same x the leaf spine is drawn
|
||||
* at, because the group sets `column-gap: 0`.
|
||||
*/
|
||||
function groupElement(port) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'gate-group';
|
||||
@@ -365,20 +503,48 @@ function groupElement(port) {
|
||||
<span class="gate-selector faint" hidden></span>
|
||||
</div>
|
||||
<ul class="gate-leaves"></ul>`;
|
||||
wrap.querySelector('.gate-trunk').appendChild(buildPipe(true));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the trunk's stub says out loud: a fact about **the port**, and never about a route.
|
||||
*
|
||||
* Three arms, every word from `listeners[].connections`:
|
||||
*
|
||||
* `:4443 — 9 connections are open on this port right now`
|
||||
* `:4443 — no connections are open on this port right now`
|
||||
* `:4443 — connections on this port are unknown: the socket table did not answer`
|
||||
*
|
||||
* The unknown arm says `unknown` and never `no`: a socket table that did not answer has not reported
|
||||
* an idle router (rule 2). And no arm names an endpoint — `listeners[]` is keyed on a port, so the
|
||||
* sockets it counts belong to the trunk as a whole, and attributing one to `nextcloud` or to the
|
||||
* `default` map entry would be a claim this payload cannot make.
|
||||
*/
|
||||
function trunkAria(group) {
|
||||
const port = `:${group.port}`;
|
||||
const count = (group.listener || {}).connections;
|
||||
if (typeof count !== 'number' || !isFinite(count)) {
|
||||
return `${port} — connections on this port are unknown: the socket table did not answer`;
|
||||
}
|
||||
const howMany = count === 0 ? 'no' : String(count);
|
||||
const verb = count === 1 ? 'connection is' : 'connections are';
|
||||
return `${port} — ${howMany} ${verb} open on this port right now`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trunk: the port, how many connections are open on it, who is on it, and how many domains.
|
||||
*
|
||||
* The live count is `n/a` and not `—` here on purpose: a trunk's count comes from the socket table,
|
||||
* and a socket table that did not answer is a different fact from a target nobody can count. The
|
||||
* domain list is a count with the whole list on `title` — `:443` serves sixteen names on this fleet
|
||||
* and the sentence they make is the page's, not the operator's reading material.
|
||||
* and a socket table that did not answer is a different fact from a target nobody can count. The same
|
||||
* figure is what moves the trunk's stub, so the words and the line are one reading of one field.
|
||||
* The domain list is a count with the whole list on `title` — `:443` serves sixteen names on this
|
||||
* fleet and the sentence they make is the page's, not the operator's reading material.
|
||||
*/
|
||||
function renderTrunk(el, group) {
|
||||
const listener = group.listener || {};
|
||||
const port = `:${group.port}`;
|
||||
const count = listener.connections;
|
||||
const name = el.querySelector('.pipe-name');
|
||||
if (name.textContent !== port) name.textContent = port;
|
||||
setTxt(el.querySelector('.pipe-count'),
|
||||
@@ -404,21 +570,57 @@ function renderTrunk(el, group) {
|
||||
names.hidden = words == null;
|
||||
if (words != null) setTxt(names, words);
|
||||
names.title = domains.join(', ');
|
||||
|
||||
// The trunk's own short line, painted from the **port's** count and nothing else. `/proc` states how
|
||||
// many ESTABLISHED sockets have this listen port as their local end, and that is the only live
|
||||
// figure available for a trunk: the leaves' counts arrive by endpoint, and on this fleet almost
|
||||
// every endpoint is a name the page cannot resolve — which is exactly why a trunk with traffic has
|
||||
// to move anyway (the operator's complaint: "The only animated line is `:443 -> 127.0.0.1:4443`.").
|
||||
//
|
||||
// `on` is honest here for the same reason it is on a leaf: `listeners[]` holds only ports one of the
|
||||
// two configs names, so a trunk row existing *is* the existence fact.
|
||||
const svg = el.querySelector('.pipe-trunk');
|
||||
const measured = typeof count === 'number' && isFinite(count);
|
||||
// A group made from a connection whose port no listener row carries has no reading at all, so it
|
||||
// draws no line rather than a claim about a port nobody read. `sources/gateway.py` pins that arm
|
||||
// shut — every connection's `in.port` came from a config that also produced a listener — so this is
|
||||
// unreachable with the captured configs, and it stays because "unreachable today" is not
|
||||
// "impossible", and the alternative is drawing a confident green stub for nothing.
|
||||
svg.hidden = group.listener == null;
|
||||
svg.classList.toggle('on', group.listener != null);
|
||||
svg.classList.toggle('off', false);
|
||||
svg.classList.toggle('live', measured && count > 0);
|
||||
svg.classList.toggle('still', !measured || count === 0);
|
||||
svg.setAttribute('role', 'img');
|
||||
const aria = trunkAria(group);
|
||||
if (svg.getAttribute('aria-label') !== aria) svg.setAttribute('aria-label', aria);
|
||||
// `setAttribute`, never `svg.title = aria`: `title` is a reflected IDL attribute of `HTMLElement`
|
||||
// and **not** of `SVGElement`, so assigning it to an `<svg>` makes a JavaScript property that no
|
||||
// tooltip ever reads (phase 31 task 05 measured exactly that — the stub had an accessible name and
|
||||
// no hover sentence). Same reason the accessible name goes in with `setAttribute` above.
|
||||
if (svg.getAttribute('title') !== aria) svg.setAttribute('title', aria);
|
||||
}
|
||||
|
||||
/**
|
||||
* One group's leaf list, patched by connection id.
|
||||
*
|
||||
* Keyed on `conn.id` (`source:port->host:port`) rather than rebuilt, so a 17-row fan-out is not
|
||||
* Keyed on `conn.id` (`source:port->host:port`) rather than rebuilt, so a fan-out the size
|
||||
* * of this ingress's `:443` is not
|
||||
* re-created every 30 s: rebuilding a row re-announces it to assistive tech for a difference of one
|
||||
* connection, which is the slot-grid lesson. `appendChild` of the existing node is what reorders.
|
||||
* A group with no connection gets the refusal sentence instead of an empty list, keyed so it goes
|
||||
* away the poll a target appears.
|
||||
*
|
||||
* The filter is applied **here**, on the row the patch pass already holds: `li.hidden`, never
|
||||
* `li.remove()`. A row the query hides is still a row the payload declares — it keeps its identity in
|
||||
* `state.rows`, its `on`/`off` class and its place in the DOM, so clearing one character cannot lose
|
||||
* a route, and the next poll re-patches the very same node. Removal is `renderGroups`' job and belongs
|
||||
* to a route leaving the config, which is the only difference this page is allowed to draw.
|
||||
*/
|
||||
function renderLeaves(listEl, key, group) {
|
||||
function renderLeaves(listEl, key, group, shown) {
|
||||
const rows = state.rows.get(key);
|
||||
const wanted = new Set();
|
||||
group.connections.forEach((conn) => {
|
||||
group.connections.forEach((conn, index) => {
|
||||
const id = conn.id == null ? `n:${group.port}:${endpointText(conn.out)}` : String(conn.id);
|
||||
wanted.add(id);
|
||||
let li = rows.get(id);
|
||||
@@ -426,6 +628,10 @@ function renderLeaves(listEl, key, group) {
|
||||
li = leafRow();
|
||||
rows.set(id, li);
|
||||
}
|
||||
// `shown` is the group's own verdict (`null`: everything stays; otherwise one boolean per
|
||||
// connection, in `group.connections` order). The refusal sentence below is the trunk's line, so
|
||||
// it follows the trunk and never the query.
|
||||
li.hidden = shown !== null && !shown[index];
|
||||
listEl.appendChild(li);
|
||||
renderLeaf(li, conn);
|
||||
});
|
||||
@@ -456,6 +662,7 @@ function renderGroups(card, key, groups) {
|
||||
if (!state.rows.has(key)) state.rows.set(key, new Map());
|
||||
const wantedGroups = new Set();
|
||||
const wantedLeaves = new Set();
|
||||
let standing = 0;
|
||||
groups.forEach((group) => {
|
||||
const id = `g:${group.port}`;
|
||||
wantedGroups.add(id);
|
||||
@@ -464,9 +671,16 @@ function renderGroups(card, key, groups) {
|
||||
el = groupElement(group.port);
|
||||
built.set(id, el);
|
||||
}
|
||||
// A trunk stands when it answers the query itself or any one of its routes does; it hides when
|
||||
// neither does. `shown === null` is "the trunk matched, so its whole fan-out is the answer".
|
||||
const trunk = filtering() ? trunkShown(group) : false;
|
||||
const shown = trunk ? null : group.connections.map(leafShown);
|
||||
const visible = !filtering() || trunk || shown.some(Boolean);
|
||||
el.hidden = !visible;
|
||||
if (visible) standing += 1;
|
||||
box.appendChild(el); // an existing node moves: that is the reorder
|
||||
renderTrunk(el.querySelector('.gate-trunk'), group);
|
||||
renderLeaves(el.querySelector('.gate-leaves'), key, group)
|
||||
renderLeaves(el.querySelector('.gate-leaves'), key, group, shown)
|
||||
.forEach((leafId) => wantedLeaves.add(leafId));
|
||||
// One leaf is not a branch. `.solo` takes the spine's two halves away so the row runs straight
|
||||
// across from the trunk to its endpoint — every haproxy frontend on this fleet, which binds a
|
||||
@@ -480,12 +694,24 @@ function renderGroups(card, key, groups) {
|
||||
built.get(id).remove();
|
||||
built.delete(id);
|
||||
});
|
||||
// Rows leave whole (`remove()` **and** `rows.delete()`), which is also why nothing here has to
|
||||
// clear `on`/`off`: a row the payload dropped is gone from the DOM and from the map, so it cannot
|
||||
// keep a stale state class, and a row that stays is re-patched from `routeInConfig` every pass.
|
||||
// Clearing the classes on the way out would be dead code with a class list to keep in step.
|
||||
const rows = state.rows.get(key);
|
||||
Array.from(rows.keys()).forEach((id) => {
|
||||
if (wantedLeaves.has(id)) return;
|
||||
rows.get(id).remove();
|
||||
rows.delete(id);
|
||||
});
|
||||
// The panel's own "nothing here" sentence, and it is only ever the *filter's* nothing: a group list
|
||||
// the query hid whole. A panel with no groups at all is `renderCard`'s `[data-k="empty"]` case, and
|
||||
// a section that did not answer never reaches this function inside a visible card — three facts, one
|
||||
// sentence each, so a blank panel always says which of them blanked it.
|
||||
const none = card.querySelector('[data-k="no-match"]');
|
||||
const stranded = filtering() && groups.length > 0 && standing === 0;
|
||||
none.hidden = !stranded;
|
||||
if (stranded) setTxt(none, `no route matches “${state.filter}”`);
|
||||
}
|
||||
|
||||
// ---- the warnings panel -------------------------------------------------------
|
||||
@@ -597,6 +823,9 @@ function renderCard(card, snapshot, row) {
|
||||
const answered = (snapshot.sections || {})[definition.section] === true;
|
||||
card.hidden = !answered;
|
||||
if (answered) setTxt(card.querySelector('[data-k="empty"]'), definition.empty);
|
||||
// Nothing to hide, so nothing can be hidden *by* the filter: the empty sentence is the panel's
|
||||
// whole story and the no-match line stays off it, whatever is in the field.
|
||||
card.querySelector('[data-k="no-match"]').hidden = true;
|
||||
return;
|
||||
}
|
||||
card.hidden = false;
|
||||
@@ -807,9 +1036,10 @@ async function refresh() {
|
||||
const states = {};
|
||||
(snapshot.states || []).forEach((row) => { states[row.dashboard || row.key] = row; });
|
||||
const row = states.gateway;
|
||||
state.row = row;
|
||||
renderHeader(snapshot);
|
||||
renderWarnings(snapshot);
|
||||
CARDS.forEach((definition) => renderCard(state.cards.get(definition.key), snapshot, row));
|
||||
renderPanels(snapshot, row);
|
||||
|
||||
const visible = CARDS.filter((definition) => !state.cards.get(definition.key).hidden)
|
||||
.map((definition) => definition.key);
|
||||
@@ -817,6 +1047,74 @@ async function refresh() {
|
||||
await Promise.all(visible.map((key) => renderCardCharts(key, snapshot)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint both panels and the filter's tally from a payload in hand. The poller's pass and the
|
||||
* filter's pass are this one function, so a keystroke cannot drift from a poll.
|
||||
*
|
||||
* It is not a fetch and must never become one: `/api/gateway/snapshot` has already answered, the
|
||||
* rows are in the DOM, and `renderGroups` only has to hide some of them. A filter that re-dialled the
|
||||
* agent would be a filter that costs the ingress a poll per character — and would be a lie about what
|
||||
* it did, because the answer cannot change between two keystrokes.
|
||||
*/
|
||||
function renderPanels(snapshot, row) {
|
||||
CARDS.forEach((definition) => renderCard(state.cards.get(definition.key), snapshot, row));
|
||||
renderFilterCount(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* `N of M routes`, beside the field, and only while the field holds something.
|
||||
*
|
||||
* M is every route the two configs declare (`connections.length`, the same figure the `connections`
|
||||
* tally prints) and N is the leaf routes the filter leaves standing across **both** panels — leaf
|
||||
* routes, not groups, because a trunk is a port and the question the field answers is "how many of my
|
||||
* routes?". Both are read from the payload rather than from the DOM, so the number is the filter's own
|
||||
* verdict and not a count of pixels; and it is hidden when the filter is down, because `33 of 33`
|
||||
* beside an empty field is ink about nothing.
|
||||
*/
|
||||
function renderFilterCount(snapshot) {
|
||||
const chip = $('#gate-filter-count');
|
||||
const total = (snapshot.connections || []).length;
|
||||
if (!filtering()) {
|
||||
chip.hidden = true;
|
||||
setTxt(chip, '');
|
||||
return;
|
||||
}
|
||||
let standing = 0;
|
||||
CARDS.forEach((definition) => {
|
||||
groupsFor(snapshot, definition.key).forEach((group) => {
|
||||
if (trunkShown(group)) {
|
||||
standing += group.connections.length;
|
||||
return;
|
||||
}
|
||||
standing += group.connections.filter((conn) => leafShown(conn)).length;
|
||||
});
|
||||
});
|
||||
setTxt(chip, `${standing} of ${total} routes`);
|
||||
chip.hidden = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The field, and the two events it answers to. Nothing else listens on this page but the panels'
|
||||
* `toggle`, and the switch beside every route still has no handler at all.
|
||||
*
|
||||
* `input` is one event for typing, pasting and the browser's own clear button, and its whole cost is
|
||||
* `renderPanels` over `state.snapshot`. `Escape` clears it, because a field you have to select-and-
|
||||
* delete to get out of is a field that gets left set. Neither calls `refresh()`.
|
||||
*/
|
||||
function initFilter() {
|
||||
const field = $('#gate-filter');
|
||||
field.addEventListener('input', (event) => {
|
||||
state.filter = event.target.value.trim();
|
||||
if (state.snapshot) renderPanels(state.snapshot, state.row);
|
||||
});
|
||||
field.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
field.value = '';
|
||||
state.filter = '';
|
||||
if (state.snapshot) renderPanels(state.snapshot, state.row);
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the group, row and chart state of every family that left the payload. */
|
||||
function pruneCards(visible) {
|
||||
CARDS.forEach((definition) => {
|
||||
@@ -837,6 +1135,7 @@ function pruneCards(visible) {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
state.cards = cardElements();
|
||||
initFilter();
|
||||
try {
|
||||
const config = await fetchJSON('/api/config');
|
||||
const gateway = config.gateway || {};
|
||||
|
||||
+833
-26
@@ -5842,6 +5842,10 @@ def test_the_gateway_surface_is_gets_only_and_the_write_stays_the_ups_one(
|
||||
#: `nginx.map_targets.*` / `haproxy.frontends.*` (neither is a row any more: the fan-out arrives as
|
||||
#: `connections[]` carrying its own `out.kind`, which
|
||||
#: `test_the_stream_map_is_rendered_inside_nginx_and_nowhere_else` pins), and added `warnings.*`.
|
||||
#: Phase 31 task 02 put the four `connections.out.*` paths to a second use: they are exactly what the
|
||||
#: leaf row's two text helpers index (`inboundText` → `selectors` then `kind`, `counterpartText` →
|
||||
#: `host` + `port`), so `test_the_leaf_row_leads_with_the_inbound_word` resolves the same tuple the
|
||||
#: page reads against a live document and a rename fails by name rather than printing `undefined`.
|
||||
GATEWAY_PAGE_FIELDS = (
|
||||
"host", "supported", "why", "agent_version",
|
||||
"listeners.port", "listeners.source", "listeners.domains", "listeners.connections",
|
||||
@@ -6034,6 +6038,29 @@ def test_every_pipe_is_an_accessible_image_built_from_the_payload(client) -> Non
|
||||
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
||||
for fact in ("listener.connections", "listener.clients", "listener.domains"):
|
||||
assert fact in trunk, f"the trunk stopped saying {fact}"
|
||||
# Phase 31 task 03: the trunk now draws a line as well as printing words, so it is an image with
|
||||
# a name of its own. **Two** pipe builders' outputs must carry one shape, because the moment one
|
||||
# of the two lines loses its `role` or its sentence it is an unnamed stroke beside a named one.
|
||||
assert js.count("createElementNS(SVG_NS, 'svg')") == 1, \
|
||||
"a second place now constructs an `<svg>`, so the leaf's run and the trunk's stub can "\
|
||||
"drift apart in geometry, class shape or accessibility"
|
||||
assert "buildPipe(true)" in js and "li.appendChild(buildPipe())" in js, \
|
||||
"one of the two lines is no longer built by the one builder"
|
||||
stub = js[js.index("function buildPipe("):js.index("function routeInConfig(")]
|
||||
assert "'pipe pipe-trunk' : 'pipe'" in stub, \
|
||||
"the trunk's stub no longer shares `.pipe` with the leaf's run, so its states are its own"
|
||||
assert "trunkAria(" in trunk, "the trunk's line has no accessible sentence of its own"
|
||||
assert "svg.setAttribute('role', 'img')" in trunk, \
|
||||
"the trunk's line is an unnamed shape: a screen reader gets the panel and none of its ports"
|
||||
# Phase 31 task 05 measured this in a browser: `title` is a reflected IDL attribute of
|
||||
# `HTMLElement` and **not** of `SVGElement`, so `svg.title = …` invents a JavaScript property no
|
||||
# tooltip ever reads. The trunk's sentence has to go on as an attribute or the port's number is
|
||||
# ink with no explanation on hover — which is what the served code had been doing.
|
||||
assert "svg.setAttribute('title', aria)" in trunk, \
|
||||
"the trunk's tooltip is no longer a real attribute: on an `<svg>` an assignment to `.title` " \
|
||||
"is a property no hover reads"
|
||||
assert "svg.title =" not in trunk, \
|
||||
"`svg.title = …` looks like a tooltip and is not one on an SVGElement"
|
||||
# upstream text reaches the page through textContent and title, and nothing else …
|
||||
assert "innerHTML" not in rendered
|
||||
assert "textContent" in js and "escapeHtml" not in js, \
|
||||
@@ -6056,6 +6083,15 @@ def test_a_pipe_moves_only_while_it_carries_a_connection(client) -> None:
|
||||
* `on` / `off` — "a proxy config declares this route" — decides **stroke only**
|
||||
(`test_a_connection_is_green_because_it_is_in_the_config`).
|
||||
|
||||
Phase 31 task 03 added a **second** pipe — the trunk's short stub — and with it a second source of
|
||||
truth, which is the part worth pinning. A leaf reads `connections[].connections` (the sockets at
|
||||
that route's endpoint); a trunk reads `listeners[].connections`, which `/proc` states as the
|
||||
ESTABLISHED sockets whose **local** end is that listen port. The trunk's number is therefore a
|
||||
claim about a **port** and **never about a leaf**: `:4443` moving does not mean `nextcloud` has
|
||||
traffic, and that is why the trunk's sentence names the port and no endpoint
|
||||
(`test_the_trunk_pipe_is_an_accessible_image_of_its_port`). Both pipes use the same two class pairs
|
||||
and neither may borrow the other's colour, motion or accessible name.
|
||||
|
||||
The keyframe's only property stays `stroke-dashoffset`: the dash travelling is the whole
|
||||
animation, and animating a colour would put a number's judgement inside a line. And
|
||||
`vector-effect="non-scaling-stroke"` stays in the script: the pipe is stretched to the card's
|
||||
@@ -6088,6 +6124,27 @@ def test_a_pipe_moves_only_while_it_carries_a_connection(client) -> None:
|
||||
"the live test is no longer the connection count itself"
|
||||
assert 'vector-effect' in js
|
||||
|
||||
# …and the trunk's stub, whose `live` comes from a **different payload path** and may only ever
|
||||
# come from that one. A leaf answers "is a socket open on this route?"; a trunk answers "is a
|
||||
# socket arriving on this port?" — the port's number belongs to the trunk as a whole, so crediting
|
||||
# it to one of the leaves under it would be a claim `listeners[]` cannot make.
|
||||
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
||||
assert "const count = listener.connections" in trunk, \
|
||||
"the trunk no longer reads its own `listeners[].connections` field"
|
||||
assert "const measured = typeof count === 'number' && isFinite(count)" in trunk, \
|
||||
"the trunk no longer separates `0` (measured, idle) from `null` (not measured)"
|
||||
assert "svg.classList.toggle('live', measured && count > 0)" in trunk, (
|
||||
"the trunk's motion is no longer the port's own count — either it animates on an unknown "
|
||||
"(a `null` read as traffic) or it stopped animating on a busy port at all")
|
||||
assert "svg.classList.toggle('still', !measured || count === 0)" in trunk, \
|
||||
"the trunk has no static state, so an unknown reads as motion"
|
||||
for leaf_field in ("conn.connections", "endpointText", "out.selectors", "connections.shared"):
|
||||
assert leaf_field not in trunk, (
|
||||
f"the trunk reads {leaf_field} — a trunk's line is a fact about a **port** and may never "
|
||||
"be derived from a leaf's endpoint, or one stub would move for one route out of sixteen")
|
||||
assert "const live = typeof conn.connections === 'number'" in js, \
|
||||
"the leaf's motion no longer keys on its own endpoint count"
|
||||
|
||||
|
||||
def test_a_connection_is_green_because_it_is_in_the_config(client) -> None:
|
||||
"""`--gate-on` is the whole colour story, and it answers *does the config say so*.
|
||||
@@ -6135,6 +6192,507 @@ def test_a_connection_is_green_because_it_is_in_the_config(client) -> None:
|
||||
assert "const on = routeInConfig(conn)" in js
|
||||
|
||||
|
||||
def test_the_whole_route_takes_one_existence_class(client) -> None:
|
||||
"""One class on the `<li>` paints the spine, the tick, the run **and** the switch.
|
||||
|
||||
The operator's complaint was about ink they could see: *"Why are the dotted lines on the left
|
||||
grey? Shouldn't those be green too? They're active."* Phase 30 applied the existence pair to
|
||||
`.pipe-path` alone, so every route drew its last third in the config's green and the rest of its
|
||||
length in structural grey — a picture that contradicted the page's own claim on all 33 rows.
|
||||
Phase 31 task 01 moves the state onto the **row**: `renderLeaf` toggles `on`/`off` on the `<li>`
|
||||
off the same value it colours the `<svg>` with, and four rules reach the two halves of the spine
|
||||
and the tick out to the switch from that one class.
|
||||
|
||||
Two rules keep that from being a colour change with no contract behind it:
|
||||
|
||||
* The base connector rules declare `transparent`. An SVG path with no `stroke` is invisible for
|
||||
free; a border's default colour is `currentColor`, so "a row built and never rendered draws
|
||||
nothing" has to be **written down** for a border the way the missing `stroke` does for a path.
|
||||
* The switch keeps taking its colour from `[aria-checked]` (phase 30's pin, asserted again here).
|
||||
The row is now painted by two selectors — `.gate-link.on` and `[aria-checked]` — both driven
|
||||
from one value, and a pin on only one of them is how the two halves of a row end up disagreeing.
|
||||
"""
|
||||
section = _code(_css_section(_css(client), "gateway pipes"))
|
||||
rules = [(" ".join(sel.split()), body)
|
||||
for sel, body in re.findall(r"([^{}]+)\{([^}]*)\}", section)]
|
||||
|
||||
def painted(selector: str) -> list[str]:
|
||||
"""Bodies of the rules whose selector **list** names `selector` exactly."""
|
||||
return [body for sel, body in rules
|
||||
if selector in [part.strip() for part in sel.split(",")]]
|
||||
|
||||
for segment in (".gate-link.on::before", ".gate-link.on::after", ".gate-link.on .gate-elbow"):
|
||||
bodies = painted(segment)
|
||||
assert bodies, f"nothing paints {segment} from the row's `on` class — that segment of the " \
|
||||
"route is still structural ink, which is the defect this task was written to close"
|
||||
assert "var(--gate-on)" in " ".join(bodies), \
|
||||
f"{segment} takes its colour from somewhere other than the existence pair"
|
||||
for segment in (".gate-link.off::before", ".gate-link.off::after", ".gate-link.off .gate-elbow"):
|
||||
bodies = painted(segment)
|
||||
assert bodies, f"nothing paints {segment} from the row's `off` class, so the pair is half a " \
|
||||
"vocabulary for the connectors and a route could never be drawn out of the config"
|
||||
assert "var(--gate-off)" in " ".join(bodies), \
|
||||
f"{segment} takes its colour from somewhere other than the existence pair"
|
||||
|
||||
# Geometry, no colour: a row the payload pass never reached must draw no dashed ink at all.
|
||||
for selector, prop in ((".gate-link::before", "border-left"),
|
||||
(".gate-link::after", "border-left"),
|
||||
(".gate-elbow", "border-top")):
|
||||
value = _declarations(section, selector).get(prop, "")
|
||||
assert value.endswith("transparent"), (
|
||||
f"{selector} declares {value!r} instead of a dashed border whose colour is "
|
||||
"`transparent` — an uncoloured border is `currentColor`, so an unrendered row would draw "
|
||||
"a line in whatever colour its text happens to be")
|
||||
assert "--border-strong" not in section, (
|
||||
"a gateway connector still reads the structure token: the row's green has to run from its "
|
||||
"trunk to its endpoint, and one grey segment of it says the route is not in the config")
|
||||
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
leaf = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
||||
assert "const on = routeInConfig(conn)" in leaf, \
|
||||
"the row's state no longer comes from the one reading of the payload"
|
||||
for target in ("li", "svg"):
|
||||
assert f"{target}.classList.toggle('on', on)" in leaf, \
|
||||
f"`on` is no longer written on the {target}, so part of the route colours itself "\
|
||||
"without the payload saying the route exists"
|
||||
assert f"{target}.classList.toggle('off', !on)" in leaf, \
|
||||
f"`off` is not written on the {target} beside `on` — one row could show both states"
|
||||
assert leaf.index("const on = routeInConfig(conn)") < leaf.index(
|
||||
f"{target}.classList.toggle('on', on)"), (
|
||||
f"the {target}'s state is set before the payload is read")
|
||||
row = js[js.index("function leafRow("):js.index("function renderLeaf(")]
|
||||
assert "li.className = 'gate-link'" in row
|
||||
assert "classList" not in row, \
|
||||
"a row now ships a state class before any payload reached it, so an unrendered leaf draws a "\
|
||||
"confident route"
|
||||
|
||||
# …and the two halves of the row that already had the pair still take it the same way, so the
|
||||
# connectors joining them cannot arrive on a third vocabulary.
|
||||
assert _declarations(section, ".pipe.on .pipe-path").get("stroke") == "var(--gate-on)", \
|
||||
"the run no longer colours from the pair the connectors do"
|
||||
assert "var(--gate-on)" in _declarations(section, ".gate-switch").get("border", ""), \
|
||||
"the switch's on-state border is no longer the existence pair"
|
||||
assert "var(--gate-off)" in _declarations(
|
||||
section, '.gate-switch[aria-checked="false"]').get("border-color", ""), \
|
||||
"the switch's off state has no colour, so `[aria-checked]` is decoration again"
|
||||
|
||||
|
||||
def rendered_trunk(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""What one trunk prints, claims and animates, recomputed from the payload as a **second** reading.
|
||||
|
||||
The `rendered_leaf` idiom, for the same reason: there is no JS runner (a locked anchor), so the
|
||||
sentence and the two classes are re-derived here from the literals the served-byte pins below
|
||||
assert verbatim. `renderTrunk` prints `N live` / `n/a` in `.pipe-count` and `trunkAria` says the
|
||||
port's sentence; a rename or a paraphrase on either side therefore fails a pin **and** moves what
|
||||
this mirror prints. Nothing here reads a leaf: the trunk's whole claim is about its port.
|
||||
"""
|
||||
port = f":{row['port']}"
|
||||
count = row["connections"]
|
||||
measured = isinstance(count, (int, float)) and not isinstance(count, bool)
|
||||
if not measured:
|
||||
aria = f"{port} \u2014 connections on this port are unknown: the socket table did not answer"
|
||||
else:
|
||||
how_many = "no" if count == 0 else str(count)
|
||||
verb = "connection is" if count == 1 else "connections are"
|
||||
aria = f"{port} \u2014 {how_many} {verb} open on this port right now"
|
||||
return {"count": "n/a" if not measured else f"{count} live",
|
||||
"aria": aria,
|
||||
"live": bool(measured and count > 0),
|
||||
"still": not measured or count == 0,
|
||||
# a trunk row exists because a config names the port, which is the existence fact
|
||||
"on": True}
|
||||
|
||||
|
||||
def test_the_trunk_pipe_is_an_accessible_image_of_its_port(client, gateway_client,
|
||||
gateway_snapshot) -> None:
|
||||
"""Every trunk draws a short line that moves on **its port's** count and says so in one sentence.
|
||||
|
||||
The operator's complaint (2026-10-23): *"The only animated line is `:443 -> 127.0.0.1:4443`."*
|
||||
On the live ingress almost every leaf endpoint is a **name** the page cannot resolve, so its count
|
||||
is a dash and its line is still — while the kernel states, for the port itself, how many
|
||||
ESTABLISHED sockets have it as their local end. That figure is a real measurement, and it is the
|
||||
only live number this page can print until name resolution lands, so the trunk draws it as a line.
|
||||
|
||||
The claim has to stay a **port** claim. `:4443` moving does not mean `nextcloud`'s route carries
|
||||
those sockets, so the sentence names the port and no endpoint, in all three arms:
|
||||
|
||||
:4443 \u2014 9 connections are open on this port right now
|
||||
:4443 \u2014 no connections are open on this port right now
|
||||
:4443 \u2014 connections on this port are unknown: the socket table did not answer
|
||||
|
||||
and the unknown arm never prints `0` — neither in the sentence nor in `.pipe-count` (rule 2). The
|
||||
three blocks below are the three ways that goes wrong: the pipe stops being a named image; the
|
||||
sentence paraphrases "on this port" away or names an endpoint; or the unknown arm is treated as
|
||||
zero and a silent router starts looking like an idle one.
|
||||
"""
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
|
||||
# (1) the stub is built by the one pipe builder and lands inside the trunk, after the count.
|
||||
group = js[js.index("function groupElement("):js.index("function trunkAria(")]
|
||||
assert "wrap.querySelector('.gate-trunk').appendChild(buildPipe(true))" in group, (
|
||||
"the trunk no longer draws a line, or draws it somewhere other than the trunk itself \u2014 "
|
||||
"an `<svg>` cannot come from the `innerHTML` template, which is why it is appended")
|
||||
assert group.index("pipe-count") < group.index("buildPipe(true)"), \
|
||||
"the stub is no longer the count's neighbour at the trunk's right edge"
|
||||
|
||||
# (2) the sentence: three arms, every word pinned, and nothing but `listeners[].connections`.
|
||||
aria = js[js.index("function trunkAria("):js.index("function renderTrunk(")]
|
||||
assert "`${port} \u2014 connections on this port are unknown: the socket table did not answer`" in aria, \
|
||||
"the unknown arm no longer says `unknown` \u2014 it must not read as an idle port (rule 2)"
|
||||
assert "open on this port right now" in aria, \
|
||||
"the measured arms stopped naming the port as the subject of the sentence"
|
||||
assert "'no'" in aria and "'connection is'" in aria and "'connections are'" in aria, \
|
||||
"the measured arms stopped saying 0 as `no` and 1 as `connection is`"
|
||||
assert "(group.listener || {}).connections" in aria, \
|
||||
"the sentence no longer reads the port's own figure"
|
||||
for leaf_field in ("conn.", "out.", "endpointText", "selectors", "host", "clients"):
|
||||
assert leaf_field not in aria, (
|
||||
f"the trunk's sentence reaches for {leaf_field} \u2014 the sockets a port counts belong to "
|
||||
"the trunk as a whole, and naming a route would credit them to one leaf")
|
||||
|
||||
# (3) the classes, and the `null` arm that must not move and must not print `0`.
|
||||
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
||||
assert "svg.hidden = group.listener == null" in trunk, \
|
||||
"a group with no listener row now draws a stub for a port nobody read"
|
||||
assert "svg.classList.toggle('on', group.listener != null)" in trunk, \
|
||||
"the stub stopped taking its colour from the existence fact the trunk row itself is"
|
||||
assert "svg.classList.toggle('off', false)" in trunk, \
|
||||
"`off` is no longer cleared on the trunk, so a re-used group could keep a stale state"
|
||||
|
||||
# (4) the fixture render, both directions of the rule, over the captured ingress and over the
|
||||
# socket table going quiet (`sockets_silent`, which is what the agent's unreadable
|
||||
# /proc/net/tcp{,6} actually produces).
|
||||
answered = gateway_client.get("/api/gateway/snapshot").json()
|
||||
listeners = answered["listeners"]
|
||||
assert listeners, "the capture names no port, so this pin would prove nothing"
|
||||
endpoints = {f"{c['out']['host']}:{c['out']['port']}" for c in answered["connections"]}
|
||||
busy = [row for row in listeners if row["connections"]]
|
||||
idle = [row for row in listeners if row["connections"] == 0]
|
||||
assert busy and idle, (
|
||||
f"the capture reports {sorted(r['port'] for r in busy)} busy and "
|
||||
f"{sorted(r['port'] for r in idle)} idle \u2014 both arms need a port in them to be pinned")
|
||||
for row in listeners:
|
||||
printed = rendered_trunk(row)
|
||||
assert printed["live"] == bool(row["connections"]), \
|
||||
f":{row['port']} animates on something other than its own non-zero count"
|
||||
assert printed["live"] != printed["still"], \
|
||||
f":{row['port']} is both moving and still \u2014 the two arms are one class each"
|
||||
assert printed["aria"].startswith(f":{row['port']} \u2014"), \
|
||||
"the sentence no longer leads with the port it is about"
|
||||
for endpoint in endpoints:
|
||||
assert endpoint not in printed["aria"], (
|
||||
f"the trunk of :{row['port']} names the endpoint {endpoint} \u2014 a port's count is "
|
||||
"not that route's traffic")
|
||||
# both directions of "never `0` for an unknown"
|
||||
assert (printed["count"] == "n/a") == (row["connections"] is None), \
|
||||
f":{row['port']} prints {printed['count']!r} for a count of {row['connections']!r}"
|
||||
# …and the other half of the same honesty: a port the table *did* answer with nothing on it
|
||||
# says so. `0` is a measurement here, not an absence, and it is the reason the trunk prints
|
||||
# `n/a` for the unknown instead of one word for both facts.
|
||||
if row["connections"] is not None:
|
||||
assert printed["count"] == f"{row['connections']} live", \
|
||||
f":{row['port']} hid a count the socket table actually reported"
|
||||
assert rendered_trunk(busy[0])["live"], "a port the kernel reports traffic on sits still"
|
||||
assert not rendered_trunk(idle[0])["live"], "an idle port animates \u2014 motion became a guess"
|
||||
|
||||
silent = gateway_source.parse(gateway_snapshot("sockets_silent"), "gateway")
|
||||
quiet = silent["listeners"]
|
||||
assert quiet and all(row["connections"] is None for row in quiet), \
|
||||
("`sockets_silent` no longer yields null listener counts, so this no longer exercises the "
|
||||
"arm the page has to render as `n/a`")
|
||||
for row in quiet:
|
||||
printed = rendered_trunk(row)
|
||||
assert printed["on"] and not printed["live"] and printed["still"], (
|
||||
f":{row['port']} is not green-and-motionless with the socket table quiet \u2014 the "
|
||||
"configs are still drawn and nothing is claimed about traffic")
|
||||
assert printed["count"] == "n/a", \
|
||||
f":{row['port']} prints {printed['count']!r} instead of `n/a` when nobody measured"
|
||||
assert printed["count"] != "0 live", "an unanswered socket table became a claimed zero"
|
||||
assert "unknown" in printed["aria"] and "did not answer" in printed["aria"], \
|
||||
f":{row['port']}'s sentence hides that the reading is missing"
|
||||
assert not any(rendered_trunk(row)["count"] == "0 live" for row in quiet), \
|
||||
"a page with no socket table printed `0 live` somewhere"
|
||||
|
||||
|
||||
def _leaf_haystack(row: dict[str, Any]) -> str:
|
||||
"""The one string the filter can see about one route, as `page-gateway.js` composes it."""
|
||||
out = row.get("out") or {}
|
||||
port = (row.get("in") or {}).get("port")
|
||||
parts = ["" if port is None else str(port), out.get("host"), out.get("name"), out.get("kind"),
|
||||
*(out.get("selectors") or [])]
|
||||
return " ".join(p for p in parts if isinstance(p, str) and p).lower()
|
||||
|
||||
|
||||
def _trunk_haystack(port: int, listener: dict[str, Any]) -> str:
|
||||
"""The same for a trunk: the port as the page prints it, then its domains and clients."""
|
||||
parts = [f":{port}", *(listener.get("domains") or []), *(listener.get("clients") or [])]
|
||||
return " ".join(p for p in parts if isinstance(p, str) and p).lower()
|
||||
|
||||
|
||||
def filter_view(snapshot: dict[str, Any], query: str) -> dict[str, Any]:
|
||||
"""What the page leaves standing for one query, derived as a **second** reading.
|
||||
|
||||
There is no JS runner (a locked anchor), so this is `rendered_leaf`'s idiom: the rules are
|
||||
re-applied here from the payload, and the served-byte pins in the test below are what keep the
|
||||
mirror honest — `haystack` indexes `in.port`, `out.host`, `out.name`, `out.kind`, `out.selectors`;
|
||||
`trunkHaystack` indexes `:${port}`, `domains`, `clients`; a trunk that matches keeps **all** its
|
||||
leaves; a group stands when it or one of its routes answers. A rename on either side therefore
|
||||
fails a pin *and* changes what this mirror prints.
|
||||
|
||||
Returns the panels' worth of groups plus the two numbers the count chip prints, both over leaf
|
||||
routes: `standing` and `total`.
|
||||
"""
|
||||
needle = query.strip().lower()
|
||||
groups: list[dict[str, Any]] = []
|
||||
standing = 0
|
||||
for key in ("nginx", "haproxy"): # the order `CARDS` declares
|
||||
by_port: dict[int, dict[str, Any]] = {}
|
||||
for row in snapshot["listeners"]:
|
||||
if row["source"] != key:
|
||||
continue
|
||||
by_port.setdefault(row["port"], {"panel": key, "port": row["port"],
|
||||
"listener": None,
|
||||
"connections": []})["listener"] = row
|
||||
for row in snapshot["connections"]:
|
||||
if row["source"] != key:
|
||||
continue
|
||||
by_port.setdefault(row["in"]["port"],
|
||||
{"panel": key, "port": row["in"]["port"], "listener": None,
|
||||
"connections": []})["connections"].append(row)
|
||||
|
||||
for port in sorted(by_port):
|
||||
group = by_port[port]
|
||||
trunk_hit = needle in _trunk_haystack(port, group["listener"] or {})
|
||||
leaves = []
|
||||
for row in group["connections"]:
|
||||
shown = trunk_hit or needle in _leaf_haystack(row)
|
||||
leaves.append({"id": row["id"], "hidden": not shown})
|
||||
if shown:
|
||||
standing += 1
|
||||
groups.append({"panel": key, "port": port, "trunk_hit": trunk_hit,
|
||||
"hidden": bool(needle) and not trunk_hit
|
||||
and not any(not leaf["hidden"] for leaf in leaves),
|
||||
"leaves": leaves})
|
||||
return {"groups": groups, "standing": standing, "total": len(snapshot["connections"])}
|
||||
|
||||
|
||||
def test_the_filter_narrows_the_map_without_asking_anything(client, gateway_client) -> None:
|
||||
"""One field, four rules, and not one extra request.
|
||||
|
||||
The operator's complaint is a reading-speed one: *"I want to find a domain."* This ingress routes
|
||||
33 of them off two config files, and a page that draws all 33 every 30 s makes that a scan. The
|
||||
field is an **eye, not a query**, and the four halves of that sentence are the four ways the
|
||||
feature could go wrong:
|
||||
|
||||
* **It matches payload text and nothing else.** `in.port`, `out.host`, `out.name`, `out.kind`,
|
||||
`out.selectors` for a route, `:${port}` + `domains` + `clients` for a trunk. No DNS (the module
|
||||
still resolves no name), no re-ranking, no rule about `.reeselink.com` — a query can never
|
||||
invent a route, because there is no other text to match.
|
||||
* **It costs nothing.** The repaint runs on `state.snapshot`; a keystroke that re-fetched would be
|
||||
a poll per character and a lie about what changed.
|
||||
* **It hides rows, it does not remove them.** `li.hidden` in the patch pass, so a route the query
|
||||
hides keeps its identity, its state classes and its place for the next poll.
|
||||
* **It moves nothing else.** The four header tallies are the payload's figures, the warnings panel
|
||||
is a different claim, and the charts follow the panel: beside a filtered map the header still
|
||||
says what the whole ingress declares and carries.
|
||||
"""
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
|
||||
# (1) the matcher's whole vocabulary: five payload fields for a route, three for a trunk.
|
||||
hay = js[js.index("function haystack("):js.index("function trunkHaystack(")]
|
||||
assert "String((conn.in || {}).port ?? '')" in hay, \
|
||||
"the inbound port is no longer searchable — `4443` is the second thing an operator types"
|
||||
for field in ("out.host", "out.name", "out.kind", "out.selectors"):
|
||||
assert field in hay, f"the filter no longer sees {field}"
|
||||
assert "toLowerCase()" in hay, "the match became case-sensitive on a page of lowercase domains"
|
||||
for absent in ("conn.connections", "severity", "metric", "getaddrinfo", "split(",
|
||||
"endsWith", "startsWith", "slice("):
|
||||
assert absent not in hay, (
|
||||
f"the matcher reached for {absent} — it may read what the payload calls a route, never "
|
||||
"what it reports about it, and never resolve a name")
|
||||
trunk = js[js.index("function trunkHaystack("):js.index("function filtering(")]
|
||||
for field in ("group.port", "listener.domains", "listener.clients"):
|
||||
assert field in trunk, f"the trunk no longer answers for {field}"
|
||||
|
||||
# (2) the repaint path: the payload in hand, no network, and no re-entry into the poll.
|
||||
panels = js[js.index("function renderPanels("):js.index("function pruneCards(")]
|
||||
assert "fetchJSON" not in panels and "refresh(" not in panels, (
|
||||
"the filter path dials something — it is an eye over `state.snapshot`, and a keystroke must "
|
||||
"cost no request (nor may it re-enter `refresh()`, which fetches a history pair per panel)")
|
||||
wiring = js[js.index("function initFilter("):js.index("function pruneCards(")]
|
||||
assert "state.filter = event.target.value.trim()" in wiring, \
|
||||
"the filter stopped taking its needle verbatim-and-trimmed from the field"
|
||||
assert "event.key !== 'Escape'" in wiring, \
|
||||
"Escape no longer clears the field, so getting out of a filter means backspacing it"
|
||||
assert "fetchJSON" not in wiring and "refresh(" not in wiring
|
||||
count = js[js.index("function renderFilterCount("):js.index("function initFilter(")]
|
||||
assert "`${standing} of ${total} routes`" in count, \
|
||||
"the chip stopped counting leaf routes the way the panels draw them"
|
||||
assert "chip.hidden = true" in count, \
|
||||
"the count stays on screen beside an empty field — ink about nothing"
|
||||
|
||||
# (3) the tallies and the warnings list never move, because neither is a route.
|
||||
for name, end in (("renderHeader", "async function refresh("),
|
||||
("renderWarnings", "function dotFor(")):
|
||||
body = js[js.index(f"function {name}("):js.index(end)]
|
||||
for leak in ("state.filter", "filtering(", "haystack(", "leafShown(", "trunkShown("):
|
||||
assert leak not in body, (
|
||||
f"{name} now reads {leak} — the header's four figures and the warnings count are the "
|
||||
"payload's own and must keep saying what the whole ingress declares")
|
||||
|
||||
# (4) rows are hidden, not removed, inside the patch pass that already holds them.
|
||||
leaves = js[js.index("function renderLeaves("):js.index("function renderGroups(")]
|
||||
assert "li.hidden = shown !== null && !shown[index]" in leaves, (
|
||||
"a leaf is no longer hidden by index into the group's own connection list — the one form "
|
||||
"that cannot credit a count to the wrong row")
|
||||
refusal = leaves[leaves.index("const emptyId"):]
|
||||
assert ".hidden" not in refusal, (
|
||||
"the trunk's refusal sentence is being hidden by a route's rules — it belongs to the trunk, "
|
||||
"so this fleet's `:80` is found by typing `80` and by nothing a route says")
|
||||
assert ".remove()" not in leaves, \
|
||||
"the filter deleted a row; removal belongs to a route leaving the config"
|
||||
groups = js[js.index("function renderGroups("):js.index("function familyWord(")]
|
||||
assert "el.hidden = !visible" in groups, "a trunk is no longer hidden as a whole"
|
||||
assert "const shown = trunk ? null : group.connections.map(leafShown)" in groups, (
|
||||
"a trunk that answered the query no longer keeps its whole fan-out — typing `4443` asks for "
|
||||
"the port, and every route on it is part of the answer")
|
||||
assert "const visible = !filtering() || trunk || shown.some(Boolean)" in groups, (
|
||||
"a group is no longer standing on \"the trunk or one of its routes\", which is the one rule "
|
||||
"that decides whether a port disappears from the map")
|
||||
assert "none.hidden = !stranded" in groups and \
|
||||
"const stranded = filtering() && groups.length > 0 && standing === 0" in groups, (
|
||||
"the panel's no-match sentence is no longer keyed on all three of: the filter is up, there "
|
||||
"were groups, and the query hid every one of them")
|
||||
assert '[data-k="no-match"]' in groups and 'no route matches' in groups, \
|
||||
"a panel whose every group is hidden now says nothing at all"
|
||||
|
||||
# (5) the capture, through the mirror: the four answers the field has to give.
|
||||
payload = gateway_client.get("/api/gateway/snapshot").json()
|
||||
total = len(payload["connections"])
|
||||
assert total > 10, "the capture routes too little for a filter to prove anything"
|
||||
|
||||
everything = filter_view(payload, "")
|
||||
assert everything["standing"] == total and not any(
|
||||
leaf["hidden"] for group in everything["groups"] for leaf in group["leaves"]), \
|
||||
"an empty query hides something — it must be the same page as no query at all"
|
||||
assert not any(group["hidden"] for group in everything["groups"]), \
|
||||
"an empty query hid a trunk"
|
||||
|
||||
brain = filter_view(payload, "brain")
|
||||
shown = [leaf["id"] for group in brain["groups"] for leaf in group["leaves"]
|
||||
if not leaf["hidden"]]
|
||||
assert brain["standing"] == len(shown) > 1, \
|
||||
"`brain` matched nothing but the brain routes"
|
||||
assert all("brain" in _leaf_haystack(next(c for c in payload["connections"]
|
||||
if c["id"] == leaf_id))
|
||||
for leaf_id in shown), "`brain` showed a route it does not appear in"
|
||||
assert all(c["id"] in shown for c in payload["connections"]
|
||||
if "brain" in _leaf_haystack(c)), "`brain` lost a route it appears in"
|
||||
assert any(group["port"] == 443 and not group["hidden"] for group in brain["groups"]), \
|
||||
"the trunk the brain routes hang off was hidden with the rest of its fan-out"
|
||||
assert brain["standing"] < total, "nothing was filtered out at all"
|
||||
|
||||
port4443 = filter_view(payload, "4443")
|
||||
for group in port4443["groups"]:
|
||||
if group["port"] == 4443:
|
||||
assert not group["hidden"] and all(
|
||||
not leaf["hidden"] for leaf in group["leaves"]), \
|
||||
":4443 did not keep its whole fan-out — typing a port asks for the port"
|
||||
else:
|
||||
assert group["hidden"], f":{group['port']} survived a query for :4443"
|
||||
|
||||
nothing = filter_view(payload, "no-such-domain-anywhere")
|
||||
assert nothing["standing"] == 0 and all(group["hidden"] for group in nothing["groups"]), \
|
||||
"a query that matches nothing left a row or a trunk on the page"
|
||||
|
||||
# (6) a trunk answers for **itself**: a client address is the listener's own payload, and typing
|
||||
# it is a question about the port, so every route under it stays as part of the answer.
|
||||
client_row = next((row for row in payload["listeners"] if row["clients"]), None)
|
||||
assert client_row, "the captured socket table names no client, so this arm cannot be exercised"
|
||||
by_port = filter_view(payload, client_row["clients"][0])
|
||||
home = next(group for group in by_port["groups"] if group["port"] == client_row["port"])
|
||||
assert home["trunk_hit"] and not home["hidden"], \
|
||||
f"typing {client_row['clients'][0]!r} did not match :{client_row['port']}'s own row"
|
||||
assert all(not leaf["hidden"] for leaf in home["leaves"]), \
|
||||
"a trunk that matched lost leaves of its fan-out"
|
||||
|
||||
|
||||
def test_the_filter_field_is_markup_the_sheet_and_nobody_else_uses(client) -> None:
|
||||
"""The field is pinned markup, on one page, wearing the sheet's one field skin.
|
||||
|
||||
Three absences carry as much as the two presences. **No other page ships `type="search"`**: the
|
||||
shared rule is what makes a field match the theme, and a page growing its own styled box is how a
|
||||
control stops following a theme change. The base rule is `max-width: 18rem; min-width: 0` and the
|
||||
≤640 px block makes it `width: 100%` — a 33-name ingress is exactly the case for typing into the
|
||||
field on a phone, and a field that keeps 18 rem of cap there is a field with two characters of
|
||||
visible text. And `.gate-filter` carries no `:hover`, no `cursor` and no `transition`: the
|
||||
connection map owns one interactive-looking thing (the unwired switch's knob) and this is not it.
|
||||
"""
|
||||
css = _css(client)
|
||||
shared = _declarations(css, 'input[type="text"], input[type="password"], input[type="search"]')
|
||||
for prop in ("background", "color", "border", "border-radius", "padding", "font"):
|
||||
assert shared.get(prop), f"the shared field rule no longer declares {prop}"
|
||||
assert shared.get("border") == "1px solid var(--border)", \
|
||||
"the field skin drifted from the sheet's own border token"
|
||||
|
||||
section = _code(_css_section(css, "gateway pipes"))
|
||||
base = _declarations(section, ".gate-filter")
|
||||
assert base.get("max-width") == "18rem", \
|
||||
f"the field is capped at {base.get('max-width')!r} rather than 18rem"
|
||||
assert base.get("min-width") == "0", \
|
||||
"without `min-width: 0` the field cannot shrink, so a long placeholder grows the page head"
|
||||
|
||||
# The phone override lives in the gateway section's own ≤640 px block and says only that one thing.
|
||||
mobile = section[section.index("@media (max-width: 640px)"):]
|
||||
reach = re.search(r"\.gate-filter\s*\{([^}]*)\}", mobile)
|
||||
assert reach, "the field is not full-width at the phone break — 33 domains want a real field"
|
||||
wide = _declarations(mobile, ".gate-filter")
|
||||
assert wide.get("width") == "100%", f"the phone rule reads {wide} rather than `width: 100%`"
|
||||
assert wide.get("max-width") == "none", (
|
||||
f"{wide} lifts the width but not the 18rem cap, so the field is still 288 px on a 320 px page")
|
||||
|
||||
for selector, body in re.findall(r"([^{}]*\.gate-filter[^{}]*)\{([^}]*)\}", _code(css)):
|
||||
assert ":hover" not in selector, f"the filter grew a hover affordance: {selector.strip()}"
|
||||
assert "cursor" not in body and "transition" not in body, (
|
||||
f"the field advertises itself as a pointer control: {body.strip()}")
|
||||
|
||||
pages = {path: client.get(path).text for path in (*MARKUP_PAGES, "/login")}
|
||||
html = pages[GATEWAY_PAGE]
|
||||
head = html[html.index("Connection map"):]
|
||||
field = next(tag for tag in re.findall(r"<input[^>]*>", head) if 'id="gate-filter"' in tag)
|
||||
chip = next(tag for tag in re.findall(r"<span[^>]*>", head) if 'id="gate-filter-count"' in tag)
|
||||
for attr in ('type="search"', 'class="gate-filter"', 'autocomplete="off"', 'spellcheck="false"'):
|
||||
assert attr in field, f"the filter field lost {attr}"
|
||||
assert 'aria-label="Filter routes by domain, endpoint or port"' in field, \
|
||||
"the field has no accessible name — a placeholder is a hint, not a label"
|
||||
assert "placeholder=" in field, "the field no longer says what it matches on"
|
||||
assert 'class="chip faint"' in chip and "hidden" in chip \
|
||||
and 'aria-live="polite"' in chip, f"the count chip is {chip}"
|
||||
for other, page in pages.items():
|
||||
if other != GATEWAY_PAGE:
|
||||
assert 'type="search"' not in page, f"{other} grew a second search field"
|
||||
|
||||
# one no-match sentence per panel, and it ships hidden like every conditional sentence here
|
||||
for key in ("nginx", "haproxy"):
|
||||
card = html[html.index(f'data-gate="{key}"'):]
|
||||
card = card[:card.index("</article>")]
|
||||
assert card.count('data-k="no-match"') == 1, \
|
||||
f"the {key} panel has no single no-match sentence"
|
||||
assert 'data-k="no-match" hidden' in card, f"the {key} no-match sentence ships visible"
|
||||
assert 'data-k="empty"' in card, \
|
||||
f"the {key} panel lost the sentence for a config that names no port — the filter's " \
|
||||
"sentence is a different fact"
|
||||
|
||||
# the footer says what the field does and does not touch, once
|
||||
foot = " ".join(html[html.index("<footer class="):].split())
|
||||
assert "hides the routes whose own labels do not contain what you typed" in foot, \
|
||||
"the page stopped saying out loud that the filter asks for nothing"
|
||||
assert "the tallies, the warnings and the charts above it are still the whole ingress" in foot, \
|
||||
"the sentence no longer names the three things a filter must not quietly narrow"
|
||||
for junk in ("no page narrates", "every value is scraped", "filters live, nothing is stored"):
|
||||
assert junk not in foot, f"the footer grew flavour text: {junk}"
|
||||
|
||||
|
||||
def test_haproxy_draws_straight_across_and_nginx_branches(client) -> None:
|
||||
"""The geometry the operator asked for, in four rules and zero JavaScript.
|
||||
|
||||
@@ -6157,8 +6715,12 @@ def test_haproxy_draws_straight_across_and_nginx_branches(client) -> None:
|
||||
before = _declarations(section, ".gate-link::before")
|
||||
after = _declarations(section, ".gate-link::after")
|
||||
for name, rule in (("::before", before), ("::after", after)):
|
||||
assert rule.get("border-left") == "2px dashed var(--border-strong)", \
|
||||
f"the spine's {name} half lost its dashed border"
|
||||
# `transparent`, not a colour: this rule is the branch's *geometry*, and the ink arrives from
|
||||
# the row's state class (`test_the_whole_route_takes_one_existence_class`). A colour here would
|
||||
# be a row drawing a confident line before any payload said the route exists.
|
||||
assert rule.get("border-left") == "2px dashed transparent", (
|
||||
f"the spine's {name} half no longer declares a dashed, colourless border — geometry "
|
||||
"belongs on the row and colour belongs to `.gate-link.on`/`.off`")
|
||||
assert rule.get("position") == "absolute" and rule.get("left") == "0", \
|
||||
f"the spine's {name} half no longer sits in the row's gutter"
|
||||
assert before.get("top") == "calc(var(--gate-leaf-gap) / -2)", \
|
||||
@@ -6175,8 +6737,10 @@ def test_haproxy_draws_straight_across_and_nginx_branches(client) -> None:
|
||||
"`--gate-leaf-gap` is no longer declared in `:root`"
|
||||
assert _declarations(section, ".gate-link:first-child::before, .gate-link:last-child::after").get(
|
||||
"display") == "none", "the fan-out grew a spine past its first and last branch"
|
||||
assert "2px dashed" in _declarations(section, ".gate-elbow").get("border-top", ""), \
|
||||
"the tick from the spine to the switch is gone, so a leaf no longer reaches its trunk"
|
||||
elbow = _declarations(section, ".gate-elbow").get("border-top", "")
|
||||
assert "2px dashed" in elbow and elbow.endswith("transparent"), (
|
||||
"the tick from the spine to the switch is gone, or coloured itself, so a leaf no longer "
|
||||
"reaches its trunk in the same ink as its run")
|
||||
|
||||
solo = _declarations(
|
||||
section, ".gate-group.solo .gate-link::before, .gate-group.solo .gate-link::after")
|
||||
@@ -6221,6 +6785,14 @@ def test_the_pipe_animation_stills_in_both_states_under_reduced_motion(client) -
|
||||
Phase 30 task 05 added the third name, and it is not decoration: the switch's knob slides on a
|
||||
`transition` of its own, and a block that stills the pipes while the thumb keeps sliding is the
|
||||
phase-18 lesson half-applied — the operator asked for no motion, not for less of it.
|
||||
|
||||
Phase 31 task 03 added a **second pipe** (the trunk's stub) and deliberately added no fourth name.
|
||||
That works only because of how the two names are written, so it is pinned rather than assumed: the
|
||||
selectors are **descendant** (`.pipe.live .pipe-path`), and the stub is built by the same
|
||||
`buildPipe(trunk)` with the same `pipe pipe-trunk` class list and the same `.pipe-path` child. A
|
||||
rule that reaches `.pipe.live .pipe-path` therefore reaches a trunk's path too. The failure this
|
||||
prevents is the quiet one: someone gives `.pipe-trunk .pipe-path` its own `animation` to make the
|
||||
stub "nicer", and the block below stops covering it.
|
||||
"""
|
||||
code = _code(_css(client))
|
||||
block = re.search(r"@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{(.*?)\n\}",
|
||||
@@ -6236,16 +6808,36 @@ def test_the_pipe_animation_stills_in_both_states_under_reduced_motion(client) -
|
||||
"the named rules stopped stilling the animation"
|
||||
assert _declarations(block.group(1), ".gate-switch-thumb").get("transition") == "none", \
|
||||
"the switch's knob still slides when the operator asked for no motion"
|
||||
# The two names are descendant selectors, which is the whole reason a second pipe needs no third:
|
||||
# `.pipe.live` is on the `<svg>` (leaf or trunk) and `.pipe-path` is its child in both cases.
|
||||
assert all(name.endswith(" .pipe-path") for name in named), (
|
||||
f"the stilling rules are {sorted(named)} — a rule aimed at `.pipe.live` alone would not "
|
||||
"reach the `<path>` that carries the `animation`, and would not reach a trunk's either")
|
||||
|
||||
# …and every animated thing on the page is in this block, counted rather than trusted: a third
|
||||
# `transition` added to the map later has to be named here, not assumed to be covered.
|
||||
animated = {".pipe.live .pipe-path", ".pipe.still .pipe-path", ".gate-switch-thumb"}
|
||||
section = _code(_css_section(_css(client), "gateway pipes"))
|
||||
transitioned = {" ".join(sel.split()) for sel, body in
|
||||
re.findall(r"([^{}]+)\{([^}]*)\}", _code(_css_section(_css(client),
|
||||
"gateway pipes")))
|
||||
re.findall(r"([^{}]+)\{([^}]*)\}", section)
|
||||
if "transition:" in body or "animation:" in body}
|
||||
assert transitioned <= animated, (
|
||||
f"{sorted(transitioned - animated)} animates in the gateway map without being stilled")
|
||||
|
||||
# The trunk's stub must earn that coverage rather than assume it: it exists because `buildPipe`
|
||||
# makes it (one class shape for both lines) and it owns no motion declaration of its own.
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
assert "function buildPipe(trunk = false)" in js and "'pipe pipe-trunk'" in js, \
|
||||
"`.pipe-trunk` is no longer produced by the one pipe builder, so it is a second component "\
|
||||
"with a class shape this block does not reach"
|
||||
stub_rules = [body.strip() for sel, body in re.findall(r"([^{}]+)\{([^}]*)\}", section)
|
||||
if ".pipe-trunk" in sel]
|
||||
assert stub_rules, "the stylesheet lost the trunk stub's rule"
|
||||
for body in stub_rules:
|
||||
assert "animation" not in body and "transition" not in body, (
|
||||
f"the trunk stub styles motion itself ({body!r}), which the stilling block above does "
|
||||
"not name — motion belongs on `.pipe.live .pipe-path` and nowhere else")
|
||||
|
||||
|
||||
def test_no_gateway_count_is_coloured_by_a_verdict(client) -> None:
|
||||
"""The pipes and switches colour from **existence**, and the verdict vocabulary stays out.
|
||||
@@ -6275,6 +6867,18 @@ def test_no_gateway_count_is_coloured_by_a_verdict(client) -> None:
|
||||
section = _code(_css_section(_css(client), "gateway pipes"))
|
||||
for token in ("--ok", "--warn", "--fail", ".ok", ".warn", ".fail", "severity"):
|
||||
assert token not in section, f"the gateway stylesheet section paints with {token}"
|
||||
# Phase 31 task 01 extended that single vocabulary to the **whole** row, and the two halves of
|
||||
# this assertion are the same claim read two ways: every connector takes the existence pair, and
|
||||
# nothing in this section any longer has a reason to name the structure token. `--border-strong`
|
||||
# was where the spine and the tick lived while only the run went green, so its absence here *is*
|
||||
# the statement "a route is green from its trunk to its endpoint, or grey for all of it".
|
||||
assert "--border-strong" not in section, (
|
||||
"a gateway connector reads structure ink again — the spine halves, the tick, the run and the "
|
||||
"switch are one existence claim and must take `--gate-on`/`--gate-off` together")
|
||||
for connector in (".gate-link.on::before", ".gate-link.on::after", ".gate-link.on .gate-elbow",
|
||||
".gate-link.off::before", ".gate-link.off::after",
|
||||
".gate-link.off .gate-elbow"):
|
||||
assert connector in section, f"the row's state class no longer paints {connector}"
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
for helper in ("severityFrom", "severityFor", ".severity"):
|
||||
assert helper not in js, f"/gateway grades a number with {helper}"
|
||||
@@ -6365,12 +6969,14 @@ def test_the_page_reports_no_card_and_no_cell_for_sockets_the_configs_do_not_nam
|
||||
|
||||
|
||||
def test_the_page_says_where_a_line_comes_from_and_where_its_count_cannot(client) -> None:
|
||||
"""Three footnotes, one per claim the page is allowed to make, and not one more.
|
||||
"""Four footnotes, one per claim the page is allowed to make, and not one more.
|
||||
|
||||
Why the page draws a line at all (the config declares the route) and what its motion means (a
|
||||
socket is open this instant, so a resting line is not a dead one); why an endpoint written as a
|
||||
**name** carries no number (nothing here resolves one, so a dash is not a fault); and what the
|
||||
switch does **not** do (toggling a route needs a config write path this app does not have).
|
||||
**name** carries no number (nothing here resolves one, so a dash is not a fault); what the switch
|
||||
does **not** do (toggling a route needs a config write path this app does not have); and — since
|
||||
phase 31 task 04 — what the filter box does to the map and what it leaves alone (the tallies, the
|
||||
warnings and the charts keep describing the whole ingress, because the field asks for nothing).
|
||||
Each lives in the footer exactly once — the page does not repeat a why-line per panel, and it
|
||||
does not need to: an uncounted line prints `—` and the footnote is the sentence that explains it
|
||||
(rule 2).
|
||||
@@ -6381,8 +6987,8 @@ def test_the_page_says_where_a_line_comes_from_and_where_its_count_cannot(client
|
||||
"the no-DNS rule is the page's single footnote about a missing count — printed once")
|
||||
assert "animates" in footer, "the animation rule lost its footnote"
|
||||
band = footer[:footer.index("</footer>")]
|
||||
# Three paragraphs and nothing else: one sentence per claim, in one ink band under the panels.
|
||||
assert band.count("<p") == 3, f"the footer is {band.count('<p')} paragraphs, not three"
|
||||
# Four paragraphs and nothing else: one sentence per claim, in one ink band under the panels.
|
||||
assert band.count("<p") == 4, f"the footer is {band.count('<p')} paragraphs, not four"
|
||||
assert "<span" not in band, "the footer grew a second element inside a sentence"
|
||||
assert "the config says the route exists" in band, "the page stopped saying what a line means"
|
||||
assert "not wired up" in band and "write" in band, \
|
||||
@@ -6466,8 +7072,43 @@ def test_the_gateway_page_keeps_its_last_drawing_when_a_poll_fails(client) -> No
|
||||
"and the reason has to be printed where the operator is already looking"
|
||||
|
||||
|
||||
def test_a_clipped_pipe_label_keeps_its_full_text_on_the_element(client) -> None:
|
||||
"""The ellipsis is only honest while the whole string is on the element, and pinned so.
|
||||
def rendered_leaf(row: dict[str, Any]) -> dict[str, str]:
|
||||
"""The two lines and the two long strings one leaf row prints, derived as a **second** reading.
|
||||
|
||||
There is no JS runner (a locked anchor), so this is `endpoint_sums`' idiom: the figure is
|
||||
recomputed here from the payload, and the served-byte pins are what keep the mirror honest —
|
||||
`inboundText` joins `out.selectors` and falls back to `out.kind`, `endpointText` is `out.host`
|
||||
+ `:` + `out.port`, `counterpartText` is `ARROW` + endpoint + ` · out.kind`, and `leafTitle` /
|
||||
`leafAria` compose those same two from the literals asserted verbatim below. A rename on either
|
||||
side therefore fails a pin **and** changes what this mirror prints, which is the pair of
|
||||
guarantees a page with no test runner can actually make.
|
||||
"""
|
||||
out = row["out"]
|
||||
host = out["host"] or "?"
|
||||
port = out["port"]
|
||||
endpoint = f"{host}{'' if port is None else f':{port}'}"
|
||||
kind = out["kind"]
|
||||
inbound = " ".join(out["selectors"]) or kind
|
||||
pair = (f"{inbound} " if inbound else "") + f"→ {endpoint}" + (f" · {kind}" if kind else "")
|
||||
shared = " — this endpoint is shared with another route" if row["shared"] is True else ""
|
||||
bits = [f"connection from :{row['in']['port']} to {endpoint}"]
|
||||
if out["selectors"]:
|
||||
bits.append("reached by " + ", ".join(out["selectors"]))
|
||||
if out["name"]:
|
||||
bits.append(f"server {out['name']}")
|
||||
if row["shared"] is True:
|
||||
bits.append("this endpoint is shared with another route")
|
||||
bits.append("in the config")
|
||||
bits.append("connections not attributed" if row["connections"] is None
|
||||
else f"{row['connections']} live connections")
|
||||
return {"line1": inbound,
|
||||
"line2": f"→ {endpoint}" + (f" · {kind}" if kind else ""),
|
||||
"title": pair + shared,
|
||||
"aria": ", ".join(bits)}
|
||||
|
||||
|
||||
def test_a_clipped_pipe_label_keeps_its_full_text_on_the_element(client, gateway_client) -> None:
|
||||
"""The ellipsis is only honest while **both ends** are on the element, and pinned so.
|
||||
|
||||
Phase 29 task 06 measured 28 of 42 labels ellipsised at 1440 px and found the old fix (letting the
|
||||
label wrap) worse than the clip — the flex line squeezed the name to seven characters of a
|
||||
@@ -6476,17 +7117,29 @@ def test_a_clipped_pipe_label_keeps_its_full_text_on_the_element(client) -> None
|
||||
endpoint labels clip at 1440 px (a 391 px target column) and **27 of 33** do at 360 px (177 px), so
|
||||
what remains is a phone column-width fact (AGENTS.md gap 13) and not a lost one.
|
||||
|
||||
What makes the ellipsis a truncation and not a lost fact is that the row carries the whole string
|
||||
— endpoint *and* the config's own selector — as its `title`, and the line's `aria-label` carries
|
||||
the full endpoint whatever the eye can see. Both halves are pinned here, because the day either
|
||||
one goes the ellipsis starts eating the page's answer in silence. Task 06 re-counts the clipped
|
||||
labels on the new row shape.
|
||||
Phase 31 task 02 made that sentence twice as important, because the row now prints two strings and
|
||||
both are the answer: `brain.reeseapps.com` on line 1 and
|
||||
`→ reeseapps-brain-of-reese.reeselink.com:443` on line 2. So the claim is strengthened from "the
|
||||
target survives the clip" to **every inbound word and the whole `host:port` appear in full in the
|
||||
row's `title` *and* in the pipe's `aria-label`**, for every route the capture declares. Three
|
||||
things make that true rather than aspirational: neither text helper cuts a string (no `slice`, no
|
||||
`substring`, no ellipsis character — an ellipsis is CSS's job and CSS does not touch `title`),
|
||||
`leafTitle` composes `inboundText` with `endpointText`, and `leafAria` names the endpoint and every
|
||||
selector. The payload half then runs that over the real rows, longest name included.
|
||||
"""
|
||||
css = _code(_css(client))
|
||||
assert _declarations(css, ".pipe-name").get("text-overflow") == "ellipsis", \
|
||||
".pipe-name no longer truncates, so re-measure the wrap decision this pin documents"
|
||||
assert _declarations(css, ".gate-selector").get("text-overflow") == "ellipsis", \
|
||||
"the selector line no longer truncates — the row's tooltip is what makes that honest"
|
||||
"the trunk's name line no longer truncates — its `title` is what makes that honest"
|
||||
# …and the leaf's counterpart line, which is now the longer of the row's two strings and so the
|
||||
# one that actually clips. Same trio, same reason: a truncation is only honest while `title` and
|
||||
# the `aria-label` below carry the whole thing.
|
||||
end = _declarations(css, ".pipe-end")
|
||||
assert end.get("text-overflow") == "ellipsis", \
|
||||
".pipe-end no longer truncates — re-measure which of the row's two lines clips (gap 13)"
|
||||
assert end.get("min-width") == "0" and end.get("white-space") == "nowrap", \
|
||||
".pipe-end lost part of the truncation trio, so a long counterpart grows the card"
|
||||
# …and the two declarations that decide *how much* it truncates. Task 06 measured the old page
|
||||
# showing seven characters of a 40-character target, because a `nowrap` flex row shrinks the name
|
||||
# to what the chip, the footnote and the `flex: none` count leave. `max-content` as its base size
|
||||
@@ -6504,15 +7157,155 @@ def test_a_clipped_pipe_label_keeps_its_full_text_on_the_element(client) -> None
|
||||
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
row = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
||||
assert "const text = endpointText(conn.out)" in row, \
|
||||
"the label and the tooltip must be built from one string, not two"
|
||||
assert "const inbound = inboundText(conn.out)" in row, \
|
||||
"the row no longer builds both of its strings from one pair of helpers"
|
||||
assert "li.title = title" in row, "a clipped connection line lost the full text on the element"
|
||||
title = js[js.index("function leafTitle("):js.index("function buildPipe(")]
|
||||
assert "endpointText(conn.out)" in title and "selectorText(conn.out)" in title, \
|
||||
"the row's tooltip stopped carrying the endpoint and the config's own selector"
|
||||
assert "inboundText(out)" in title and "endpointText(out)" in title, \
|
||||
"the row's tooltip stopped carrying one of the row's two ends — half a route is no answer"
|
||||
aria = js[js.index("function leafAria("):js.index("function buildPipe(")]
|
||||
assert "endpointText(out)" in aria, \
|
||||
"the accessible sentence stopped carrying the whole endpoint a clipped label truncates"
|
||||
assert "endpointText(out)" in aria and "selectors.join" in aria, \
|
||||
"the accessible sentence stopped carrying the whole endpoint or every inbound word"
|
||||
# Nothing in the row's text helpers may cut a string. The CSS ellipsis is honest only because the
|
||||
# data behind it is whole, and a `slice(0, 30)` in `counterpartText` would eat the tooltip too.
|
||||
helpers = js[js.index("function endpointText("):js.index("function buildPipe(")]
|
||||
for cutter in ("slice(", "substring(", "substr(", "trim(", "\u2026"):
|
||||
assert cutter not in helpers, (
|
||||
f"a leaf's text helpers cut strings with {cutter!r} — truncation belongs to CSS so the "
|
||||
"`title` and the `aria-label` can carry the whole route (gap 13)")
|
||||
|
||||
# …and the claim over every route the captured ingress actually declares, longest name first:
|
||||
# `reeseapps-brain-of-code.reeselink.com:443`, 41 characters, is this fleet's longest endpoint and
|
||||
# the one the 17-row `:443` fan-out clips at a phone width.
|
||||
rows = gateway_client.get("/api/gateway/snapshot").json()["connections"]
|
||||
assert rows, "the captured ingress declares no routes, so this pin would prove nothing"
|
||||
longest = max(rows, key=lambda r: len(f"{r['out']['host']}:{r['out']['port']}"))
|
||||
longest_text = f"{longest['out']['host']}:{longest['out']['port']}"
|
||||
# 42 characters, which is the figure AGENTS.md gap 13 measures the phone clip against. The name
|
||||
# itself is pinned as a family rather than a string because this fleet has two 41/42-character
|
||||
# neighbours (`…-brain-of-reese` and `…-brain-of-code`) and which one is longest is a fact about
|
||||
# `stream.conf`, not a contract; the **length** is the thing the clip depends on.
|
||||
assert len(longest_text) == 42 and longest["out"]["host"].startswith("reeseapps-brain-of-"), (
|
||||
f"the fleet's longest endpoint is now {longest_text!r} ({len(longest_text)} chars) — gap 13's "
|
||||
"clip measurement and gap 13's prose both have to be re-read")
|
||||
for conn in rows:
|
||||
printed = rendered_leaf(conn)
|
||||
whole = f"{conn['out']['host']}:{conn['out']['port']}"
|
||||
for surface in ("title", "aria"):
|
||||
assert whole in printed[surface], (
|
||||
f"{whole} is not whole in the row's {surface} — gap 13: whatever CSS clips, the "
|
||||
"element has to carry the full counterpart")
|
||||
for selector in conn["out"]["selectors"]:
|
||||
assert selector in printed["title"] and selector in printed["aria"], (
|
||||
f"the inbound word {selector!r} is missing from a surface that is supposed to survive "
|
||||
"the clip — the row's answer is *which domain goes where*, so both ends are needed")
|
||||
|
||||
|
||||
def test_the_leaf_row_leads_with_the_inbound_word(client, gateway_client) -> None:
|
||||
"""Line 1 is the domain the operator types; line 2 is the counterpart it flows to.
|
||||
|
||||
The operator's complaint (2026-10-23): *"I can't tell which `reeseapps.com` or
|
||||
`stackexpected.com` addresses flow to their reeselink counterparts (very important)."* Phase 30
|
||||
drew the **target** on the primary line and demoted the inbound `server_name` / `map` key to an
|
||||
11px faint qualifier, which is exactly backwards from the question the page exists to answer. So
|
||||
the row re-ranked: `.pipe-name` is the config's inbound word and `.pipe-end` is
|
||||
`→ host:port · kind` underneath it.
|
||||
|
||||
Three pins keep that from being a cosmetic swap that quietly invents something:
|
||||
|
||||
* `inboundText` reads `out.selectors` and falls back to `out.kind` — **never** `out.host`, because
|
||||
picking the target as the "best name" is a fact the two config files never stated (rule 2);
|
||||
* all the selectors are joined, so a merged connection (two vhosts claiming one
|
||||
`(in_port, endpoint)`) shows both rather than the page choosing one;
|
||||
* every field the two helpers index is in `GATEWAY_PAGE_FIELDS` **and** resolves on a live
|
||||
`GET /api/gateway/snapshot` row, in both directions, so a rename in `sources/gateway.py` fails
|
||||
this test by name instead of printing `undefined` beside a healthy ingress.
|
||||
|
||||
The last block is the complaint itself, asserted on the capture: the `brain.reeseapps.com` leaf of
|
||||
nginx's `:443` reads that domain on line 1 and
|
||||
`reeseapps-brain-of-reese.reeselink.com:443` on line 2, every haproxy leaf leads with its `backend`
|
||||
name, and no row's primary line is empty, `null` or `undefined`.
|
||||
"""
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
|
||||
# (1) the two text helpers, and the one field each may never read.
|
||||
head = js[:js.index("function countText(")]
|
||||
assert "const ARROW = '\u2192'" in head, "the destination marker is no longer a page constant"
|
||||
inbound = js[js.index("function inboundText("):js.index("function counterpartText(")]
|
||||
assert "(out || {}).selectors" in inbound and ".join(' ')" in inbound, \
|
||||
"line 1 stopped printing every selector the config wrote"
|
||||
assert "(out || {}).kind" in inbound, \
|
||||
"a row with no selector has no fallback, so its primary line would be empty"
|
||||
assert "host" not in inbound and "port" not in inbound, \
|
||||
"line 1 reaches for the target again — the counterpart is line 2's job and nothing else"
|
||||
counterpart = js[js.index("function counterpartText("):js.index("function leafAria(")]
|
||||
assert "${ARROW} ${endpointText(out)}" in counterpart, \
|
||||
"line 2 stopped marking the endpoint as the destination"
|
||||
assert "\u00b7 ${kind}" in counterpart, \
|
||||
"line 2 lost the config's own word for the route kind, so a `map` leaf and a vhost leaf "\
|
||||
"reaching one address became indistinguishable"
|
||||
|
||||
# (2) the call sites: line 1 is the inbound word, line 2 the counterpart, and the primary line
|
||||
# is never handed the endpoint as its *first* choice.
|
||||
row = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
||||
assert "const inbound = inboundText(conn.out)" in row
|
||||
assert "const primary = inbound == null ? endpointText(conn.out) : inbound" in row, \
|
||||
"a row with no selector would print an empty primary line instead of its endpoint"
|
||||
assert "name.textContent = primary" in row
|
||||
assert not re.search(r"name\.textContent\s*=\s*endpointText", row), \
|
||||
"the endpoint is back on the primary line — that is the reading order this task replaced"
|
||||
assert "setTxt(counterpart, counterpartText(conn.out))" in row, \
|
||||
"line 2 is no longer the counterpart"
|
||||
assert "counterpart.hidden = inbound == null" in row, \
|
||||
"the hidden rule no longer keys on the fallback, so a row could print its target twice"
|
||||
markup = js[js.index("function leafRow("):js.index("function renderLeaf(")]
|
||||
assert "counterpart.className = 'pipe-end mono'" in markup, \
|
||||
"line 2 is no longer mono, so the two hostname columns stopped aligning down a fan-out"
|
||||
assert "target.appendChild(name)" in markup and "target.appendChild(counterpart)" in markup, \
|
||||
"the two lines are no longer both children of the target column"
|
||||
|
||||
# (3) the field table resolves in both directions for what the two helpers index.
|
||||
needed = ("connections.out.selectors", "connections.out.kind",
|
||||
"connections.out.host", "connections.out.port")
|
||||
for path in needed:
|
||||
assert path in GATEWAY_PAGE_FIELDS, (
|
||||
f"the leaf row indexes {path.removeprefix('connections.')} but `GATEWAY_PAGE_FIELDS` "
|
||||
"does not name it, so a rename in `sources/gateway.py` would print `undefined` here")
|
||||
payload = gateway_client.get("/api/gateway/snapshot").json()
|
||||
rows = payload["connections"]
|
||||
assert rows, "the capture routes nothing, so the field table would resolve over an empty list"
|
||||
for path in needed:
|
||||
found, _values = _payload_field(payload, path)
|
||||
assert found, f"/gateway's row reads {path}, which the payload does not publish"
|
||||
|
||||
# (4) the complaint, on the capture: `brain.reeseapps.com` → its reeselink counterpart.
|
||||
brain = [conn for conn in rows if conn["out"]["selectors"] == ["brain.reeseapps.com"]]
|
||||
assert brain, "the captured stream `map` no longer routes brain.reeseapps.com"
|
||||
printed = rendered_leaf(brain[0])
|
||||
assert printed["line1"] == "brain.reeseapps.com", \
|
||||
f"line 1 reads {printed['line1']!r}, not the inbound domain the operator reads for"
|
||||
assert "reeseapps-brain-of-reese.reeselink.com:443" in printed["line2"], \
|
||||
f"line 2 reads {printed['line2']!r} — the counterpart is what the row exists to show"
|
||||
assert brain[0]["in"]["port"] == 443 and brain[0]["source"] == "nginx", \
|
||||
"that leaf no longer hangs off the stream `map`'s port in the nginx panel"
|
||||
haproxy = [conn for conn in rows if conn["source"] == "haproxy"]
|
||||
assert haproxy, "the capture's haproxy routes are gone, so this half proves nothing"
|
||||
for conn in haproxy:
|
||||
assert rendered_leaf(conn)["line1"] == conn["out"]["selectors"][0], \
|
||||
"a haproxy leaf no longer leads with its `backend` name"
|
||||
assert any(rendered_leaf(c)["line1"] == "default" for c in rows), \
|
||||
"the map's own word for unmapped SNI was paraphrased off the primary line"
|
||||
for conn in rows:
|
||||
line1 = rendered_leaf(conn)["line1"]
|
||||
assert line1 and line1 not in ("null", "undefined", "None"), (
|
||||
f"a row's primary line prints {line1!r} — every route has an inbound word, and an empty "
|
||||
"primary line is the bug the fallback exists to prevent")
|
||||
|
||||
# (5) the trunk is untouched by the re-rank: it still leads with its port and still refuses in
|
||||
# the script's own sentence when its config names no target (this fleet's `:80` `return 301`).
|
||||
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
||||
assert "name.textContent !== port" in trunk, "the trunk no longer prints its port as line 1"
|
||||
assert "no target named in the config" in js, "the `:80` refusal sentence is gone"
|
||||
|
||||
|
||||
def test_the_switch_is_built_and_explicitly_unwired(client) -> None:
|
||||
@@ -6575,10 +7368,24 @@ def test_the_switch_is_built_and_explicitly_unwired(client) -> None:
|
||||
for fact in ("endpointText(conn.out)", "selectors", "config", "on ? 'on' : 'off'"):
|
||||
assert fact in label, f"the switch's name stopped saying {fact}"
|
||||
|
||||
# The events this page listens for, as an allow-list rather than a count: `toggle` is the charts
|
||||
# disclosure, `DOMContentLoaded` is the boot, and the two phase-31 names belong to the filter
|
||||
# field and to nothing else. **`click` is not on the list** and that is the switch's pin — the day
|
||||
# a route can be toggled, some code has to dial a config write, and invariant 1 says this app has
|
||||
# no such thing.
|
||||
bound = set(re.findall(r"addEventListener\('(\w+)'", js))
|
||||
assert bound <= {"toggle", "DOMContentLoaded"}, (
|
||||
assert bound <= {"toggle", "DOMContentLoaded", "input", "keydown"}, (
|
||||
f"the page now binds {sorted(bound)} — a switch that could be clicked would need a config "
|
||||
"write path this app does not have (invariant 1)")
|
||||
assert "click" not in bound, \
|
||||
"a click handler appeared on /gateway: nothing on this page may write to the fleet"
|
||||
filter_body = js[js.index("function initFilter("):js.index("function pruneCards(")]
|
||||
for event in ("input", "keydown"):
|
||||
assert f"addEventListener('{event}'" in filter_body, (
|
||||
f"the filter no longer binds {event} in its own function, so the allow-list above is "
|
||||
"hiding a listener somewhere it cannot be read")
|
||||
assert js.count(f"addEventListener('{event}'") == 1, \
|
||||
f"{event} is bound in more than one place on this page"
|
||||
assert not re.search(r"gate-switch[^\n]*\)\s*\.?\s*addEventListener", js), \
|
||||
"a listener was bound to the switch itself"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user