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