**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`.
14 KiB
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-offpair, the unwired switch, theconnections[]/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.cjsmust still exit 0 with/gatewayin 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) and33_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
01_the_whole_route_is_green.md—dash.css+page-gateway.js:on/offon the row, the spine/tick/run/switch coloured from one class, base borderstransparent, the verdict-vocabulary pin restated.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,→ counterpartsecond),title/aria-labelcarry both, the—footer sentence rewritten,GATEWAY_PAGE_FIELDSand the clip pin updated rather than relaxed.03_the_trunk_moves_on_a_real_number.md—page-gateway.js+dash.css: the trunk's own short pipe,live/still/neither fromlisteners[].connections, its accessible sentence naming the port, reduced-motion named.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, anN of Mchip, Escape to clear, no fetch.05_docs_and_live_verify.md—AGENTS.md(the/gatewayUI-conventions paragraph, the metric-naming/sources/gateway.pypassages 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_verdictkeeps every "no verdict vocabulary here" assertion and gains "the connectors are coloured from--gate-on/--gate-offand their base colour istransparent";test_a_pipe_moves_only_while_it_carries_a_connectionis 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_motiongains the trunk pipe;test_a_clipped_pipe_label_keeps_its_full_text_on_the_elementis strengthened (both ends now, not just the target);test_an_unattributed_endpoint_prints_a_dash_and_never_a_zerois unchanged in claim. - New pins:
test_the_whole_route_takes_one_existence_class(the<li>carrieson/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 isselectors-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(nofetchJSONin the filter path, warnings panel untouched,nullcounts 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=94green) and thatnode --checkpasses on the changed script (.agents/validate.shruns it). - Integration: the page renders from a fixture-driven dev store in every degradation arm the payload can produce (
unreachable,supported: false, per-sectionavailable: 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.shis green, coverage floor included.
Documentation & Consistency
- The final pass reviews
README.mdandAGENTS.mdin 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/gatewayUI-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.mdimmediately 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-oncolour in dark and light theme; no grey dashed segment remains anywhere in the connection map; measured, not asserted in prose. - The nginx
:443fan-out reads asbrain.reeseapps.comon line 1 and→ reeseapps-brain-of-reese.reeselink.com:443on line 2 for all of its leaves, and typingstackexpectedinto#gate-filterleaves only the fivestackexpected*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
0for; with the socket-table arm stubbed tonullit draws green, motionless, and prints no0.prefers-reduced-motion: reducestops every animated thing on the page including the trunk pipe and the switch thumb. grep -n "border-strong" src/dashboards/web/static/dash.cssshows no gateway connector rule;grep -n "severity\|--ok\|--warn\|--fail"over the gateway CSS section andpage-gateway.jsstill finds only the warnings-panel.warnrow class thatalerts.pyowns.- No
addEventListenerfor the switch exists;routeInConfig()still returnstrue;git difffor this phase touches no file undersrc/dashboards/exceptweb/— the verb-enumeration pin still lists/api/ups/commandas the only upstream write. uv run pytest(incl.--cov≥ 94 floor),uv run ruff check src tests agent,uv run pyright,node --checkon the changed script,bash .agents/validate.sh— all green.data/logs/31-*.pngscreenshots at 1440 px and 360 px (dark, light, filtered, and each degradation arm) anddata/logs/31-*.outtranscripts;data/logs/27-task02-mobile-audit.cjsstill exits 0 across all nine pages with 0 FAIL / 0 FINDING.GET /api/gateway/snapshoton the live ingress unchanged byte-for-byte from before the phase (proof the phase was page-only), and./scripts/dev-server.sh statusstill reads all ten sourcesok.- 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//certsrender as before; the gateway charts, warnings panel, staleness dim and404 {configured:false}arm behave exactly as phase 29/30 left them.