Files
dashboards/.agents/phases/complete/31_gateway_route_readability/00_phase.md
T
ducoterra 25fd74b7c5 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`.
2026-10-25 05:32:30 -04:00

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-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.