Compare commits

...
2 Commits
Author SHA1 Message Date
ducoterra 5e267737dc phase: 21_page_cleanups
**Phase 21 final verification pass** (all 3 task files already in `complete/`, working tree carries the implementation)

- **Verified** task 01: `#pause`/`Space`/`setPaused`/`_paused` gone — `grep -ri pause src/dashboards/web/` empty, 0 pause hits in all 8 served pages + 10 static assets; re-ran `data/logs/21-task01-nopause.cjs` → 30/30 PASS (hidden tab: 0 data fetches in 22 s, one poll 2 ms after return).
- **Verified** task 02: re-ran `21-task02-gpu-values.cjs` on the live fleet → all PASS (deskwork/driveripper show Model/VRAM/Pool/Temp at 16.3 px; gateway = 5 heroes, slot `hidden`, no `n/a`; hero order unchanged; GPU history chart still builds per host).
- **Verified** task 03: re-ran `21-task03-scroll.cjs` → 14/14 PASS (`class="data scroll"`, scrollWidth 1021 vs clientWidth 292 at 360 px, Rotate/Revoke/bundle reachable and hit-testable; unchanged at 1280 px). Screenshots refreshed in `data/logs/`.
- **Gate**: `uv run pytest` → **1471 passed, 15 skipped**; `--cov --cov-fail-under=94` → **98.94%**; `ruff check src tests agent` clean; `pyright` **0 errors**; `bash .agents/validate.sh` → **EXIT 0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**; `/api/health` all 9 sources `ok`.
- **Docs fixed** (pre-existing + this phase): "all **eight** signed-in pages" was a miscount of `MARKUP_PAGES` (7 = 5 dashboards + `/settings` + `/certs`; `/login` is the 8th, unsigned) → corrected in `README.md`, `AGENTS.md` (×2) and the `tests/test_api.py` docstring/comment; `AGENTS.md` Commands hermetic tally 1470 → **1471**.
- **Docs reviewed, consistent**: Quickstart/commands/flags (`install-agent.sh`, `capture-fixtures.sh`, `dev-server.sh`), the env table vs `config.py`, every documented route vs the served route table, masthead/`/system` GPU/`/certs` scroll descriptions vs code, and all `data/logs/*` + `tests/fixtures/*` references.
- **Deviation**: `validate.sh`'s advisory `fastapi 0.153.0 → 0.156.4` left alone — no dependency was touched this phase, so AGENTS rule 8 ("check after touching dependencies") is not triggered; upgrading here would be an unrelated behaviour change.
- No live-infra changes made (repo-side + browser verification only).
- **Next pending phase**: `22_disks_hide_unconfigured`.
2026-10-21 01:44:52 -04:00
ducoterra 973b7299fe chore(agent): phase roadmap from TODO.md, 3 phases
Append phases 25-27 (masthead control placement, UPS event split, mobile
tables + full mobile audit) derived from TODO.md L3-L7; the TODO file is
cleared, its items now live in .agents/phases/todo/. Protocol B: PLAN.md
and AGENTS.md untouched.

LLM-Generated: true
2026-10-20 23:24:27 -04:00
48 changed files with 1199 additions and 139 deletions
@@ -0,0 +1,34 @@
# Phase 25 — Masthead control placement: badges everywhere, right-justified, clock rightmost
**Source:** `TODO.md` L3–L5 — "hosts disks engines UPS disappear when in the certs/account page" · "I made a mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right justified don the navbar" · "The time should show up as the farthest right item on the navbar"
**Story:** n/a
**Context:** phase 20 (complete) — the masthead is three rows at every width (row 1 brand + `#nav-toggle` + the five tabs, row 2 the `#nav-status` per-source dots, row 3 the `.masthead-right` controls), not sticky (`position: relative`, `z-index: 40`), with the ≤640px dropdown sliding on `nav.tabs.is-open`; phase 18 — the hamburger reveal through `--nav-toggle-display`. The dots are built by `Dash.initChrome()` in `src/dashboards/web/static/app.js` (the `if (nav)` block on `#nav-status`, one chip per `system`/`disks`/`llm`/`ups` dashboard from `/api/health`, refreshed every 15 s), and exist in the markup only of the five dashboard pages — `certs.html` and `settings.html` ship no `#nav-status` (the comment in `settings.html` says a session page "has no fleet reachability to report"), which is what L3 names. The served-CSS pins for the bar are in `tests/test_api.py`: `test_the_masthead_is_three_left_justified_rows_at_every_width` (the two `justify-content: flex-start` declarations, the two `flex-basis: 100%` row breaks, the `min-height`/`gap`), `test_the_mobile_masthead_block_declares_no_second_bar_height`, `test_the_masthead_scrolls_away_and_stays_the_dropdown_anchor`; the one-loop control-order audit is `test_every_page_links_to_both_admin_pages` (clock → `⚷ Certs` → `⚙ Account` → theme toggle on all eight signed-in pages), and the two pins that assert the admin pages have **no** dots are in `test_the_settings_page_is_not_one_of_the_dashboards` and `test_the_certs_page_is_a_page_and_not_a_sixth_dashboard`. `tests/test_auth_api.py` asserts `/login` carries no `#nav-status` — unchanged by this phase.
## Objective
The four per-source badges (hosts · disks · engines · UPS) render on **every** page that renders the masthead — including `/certs` and `/settings` — and the two right-hand rows (the dots, the controls) are right-justified, with the clock the farthest-right item on the bar. The three-row shape at every width is preserved; only the alignment and the control order move, and the clock's position is what L5 asks for.
## Dependencies
- — (none) — the phase builds on complete phase 20 and phase 18; it is scheduled first of the three appended phases on purpose, because the mobile audit phase (27) measures the masthead in the shape this phase leaves it in.
## Tasks
1. `01_status_dots_on_admin_pages.md` — `#nav-status` on `/certs` and `/settings` (markup only — `initChrome` already builds the dots when the container exists); the two no-dots pins flip to dots-present
2. `02_right_justify_masthead_rows.md` — `justify-content: flex-end` on `#nav-status` and `.masthead-right`, the comment block re-worded, the three-rows pin re-pinned to the new alignment
3. `03_clock_rightmost.md` — the clock moves to the end of the control row on all eight signed-in pages; the one-loop order pin updated; the phase's real-browser verification (badges on the admin pages, both rows right-justified, clock rightmost, at 1280 px and 360 px)
## Testing & Quality
- Unit/integration: the served-byte pins in `tests/test_api.py` — the flipped admin-page pins (task 01), the re-pinned row-alignment pin (task 02), the updated control-order pin (task 03); `tests/test_auth_api.py`'s `/login`-has-no-dots pin must stay green untouched
- Coverage: **>90%** on new/modified code (the new code is the pins themselves; the change is CSS and template markup)
- Real-browser check (invariant 10): a `data/logs/25-task03-*.cjs` script in the `20-task01-rows.cjs` convention, run against the restarted dev server at 1280 px and 360 px on all seven tabbed pages plus `/login` (unsigned), with screenshots in `data/logs/`
## Documentation & Consistency
- The final pass updates the now-stale sentences in `AGENTS.md`: "The masthead is three **left-justified** rows at every width" (alignment), "A page with no dots (`/certs`, `/settings`) renders two rows" (gone — all masthead pages carry the dots), "the two links therefore sit on all eight signed-in pages in one order (clock → `⚷ Certs` → `⚙ Account` → theme toggle)" (new order: `⚷ Certs` → `⚙ Account` → theme toggle → clock), and the "left-justified" wording in the `dash.css` header comment. `README.md` is checked for the same sentences; none is expected.
## Completion Criteria
- [ ] `/certs` and `/settings` render the four badges, populated from `/api/health` (pinned + verified in a browser against the live dev fleet)
- [ ] the dots row and the controls row are right-justified at every width; the three-row shape and the ≤640px dropdown are unchanged (pinned + measured in a browser)
- [ ] the clock is the farthest-right item on the bar on all eight signed-in pages (pinned + measured)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the row-break mechanism (`flex-wrap` + the two `flex-basis: 100%`), the non-sticky bar, the dropdown's slide and its four close paths, and the dots' refresh semantics are all untouched
@@ -0,0 +1,27 @@
# Task 01 — The badges render on the admin pages too
**Phase:** `25_masthead_control_placement` · **Source:** `TODO.md:3–4` — "hosts disks engines UPS disappear when in the certs/account page" · "I made a mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right justified don the navbar"
**Story:** n/a
## Objective
`#nav-status` exists in the `.masthead-right` of `/certs` and `/settings`, exactly as it does in the five dashboard pages, so `Dash.initChrome()` builds the four badges (`hosts`/`disks`/`engines`/`UPS`) there without any change to `app.js` — the "disappear when in the certs/account page" symptom is the missing container, and the fix is the container.
## Work
1. `src/dashboards/web/pages/certs.html` — add `<div id="nav-status" class="gap" aria-live="polite"></div>` as the **first** child of `<div class="masthead-right">` (before the `<span class="clock">`), the same position the five dashboard pages use (`index.html:23` is the reference).
2. `src/dashboards/web/pages/settings.html` — the same addition, and delete the `<!-- No `#nav-status`: a session page has no fleet reachability to report, and `Dash.initChrome()` builds the dots only when the container exists. -->` comment — the premise it states is what the operator overruled (the badges report the fleet, and the fleet is the point of the site, on every page).
3. `src/dashboards/web/static/app.js` — **no change** (stated so the executor does not "improve" it): `initChrome()`'s `if (nav)` block already builds the four chips and refreshes them from `/api/health` every 15 s whenever `#nav-status` exists; `setDot`'s `idle` state already covers a dashboard the page's config does not run.
4. `tests/test_api.py` — flip the two pins, one assertion each: in `test_the_settings_page_is_not_one_of_the_dashboards`, `assert 'id="nav-status"' not in html` becomes `in html` (message re-worded: the session page grew the badges the operator asked for); in `test_the_certs_page_is_a_page_and_not_a_sixth_dashboard`, the same flip, and the docstring sentence "no `#nav-status` (a CA page has no fleet reachability to report)" is re-worded to the new fact. `test_the_certs_page_is_a_page_and_not_a_sixth_dashboard`'s other assertions (zero `aria-current`, the tab strip, the head) are untouched, and `tests/test_auth_api.py`'s assertion that **`/login`** has no `#nav-status` is untouched — `/login` still renders the brand link and the theme toggle only.
## Testing & Quality
- Unit/integration: the two flipped pins above; `test_the_header_status_dot_picks_up_the_disks_job` and `test_the_disks_tab_is_in_the_masthead_of_every_page` stay green unchanged
- Coverage: **>90%** on this task's new/modified code (markup + pins)
- The badges populating on the two admin pages is the phase's real-browser check (task 03), run against the live dev fleet so the dots are `ok` green, not grey `idle`
## Completion Criteria
- [ ] `GET /certs` and `GET /settings` (signed in) both contain `<div id="nav-status" class="gap" aria-live="polite">` as the first child of `.masthead-right` (pinned)
- [ ] in a browser, both admin pages show the four badges and they answer the same `ok`/`warn`/`fail` state the dashboard pages show (measured, screenshots in `data/logs/`)
- [ ] `/login` (unsigned) still carries no `#nav-status` (`tests/test_auth_api.py` green)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: `initChrome` is byte-identical, and the badge refresh cadence and `idle` semantics are untouched
@@ -0,0 +1,29 @@
# Task 02 — The dots row and the controls row are right-justified
**Phase:** `25_masthead_control_placement` · **Source:** `TODO.md:4` — "I made a mistake earlier, I did want hosts disks engines UPS badges and certs/account/dark right justified don the navbar"
**Story:** n/a
## Objective
Phase 20 task 01 made the two right-hand rows of the masthead flush left; this task moves them flush **right**. The mechanism is two declarations — `justify-content: flex-end` on `#nav-status` and on `.masthead-right` — and nothing else: the row breaks (the two `flex-basis: 100%` items inside the `flex-wrap: wrap` bar), the `min-height`, the gaps and the ≤640px block all stay exactly as they are, so the bar is still three rows at every width and the phone's wrapped control row still wraps *within itself*.
- ASSUMPTION: the three-row shape is preserved at every width and only the alignment changes. A return to the old single-row bar is not the ask — the row breaks are what make the bar fit at 360 px (phase 18/20 measured that), and the operator's word is "right justified in the navbar", not "one row".
## Work
1. `src/dashboards/web/static/dash.css` — two declarations: `#nav-status`'s `justify-content: flex-start` becomes `flex-end`, and `.masthead-right`'s `justify-content: flex-start` becomes `flex-end`. `margin-inline-start: auto` stays **absent** from `.masthead-right` (the old single-row right-pinning is gone; `flex-end` on a full-basis row is the alignment now).
2. `src/dashboards/web/static/dash.css` — re-word the comments that state the old alignment, so a future reader is not sent to a left-justified bar that no longer exists: the header comment block (the "three left-justified rows at every width" sentence, and its row-2/row-3 descriptions), the `Row 3:` comment above `.masthead-right` (which currently cites the old `TODO.md` word for `flex-start` — the new sentence states the operator overruled it: the badges and the cluster are right-justified), the `Row 2:` comment above `#nav-status`, and the ≤640px block's comment "Row 3 alignment is the base rule's `flex-start`, restated nowhere" (now `flex-end`). The `#nav-toggle { margin-inline-start: auto }` line in the mobile block is **unchanged**: the hamburger sits at the right of row 1 (brand + toggle + tabs), which is where a phone's thumb already is — it was never part of the right-justified cluster.
3. `tests/test_api.py` — rename `test_the_masthead_is_three_left_justified_rows_at_every_width` to `test_the_masthead_is_three_rows_right_justified_at_every_width` and change its two alignment assertions from `== "flex-start"` to `== "flex-end"` (the dots line and the cluster line), re-wording the assertion messages and the docstring ("two of them flush left" → "two of them flush right"). Every other assertion in the test stays: `flex-wrap: wrap`, the `flex-direction: row` check, the no-`height`/`min-height: var(--header-h)` pair, the gap, the two `flex-basis: 100%` row breaks, and the "no `margin-inline-start` on the cluster" guard — the row-break mechanism is exactly what must survive the re-justify.
4. `tests/test_api.py` — `test_the_mobile_masthead_block_declares_no_second_bar_height` and `test_the_masthead_scrolls_away_and_stays_the_dropdown_anchor` stay green **unchanged** (neither reads `justify-content`); confirm they do not need a word change in their docstrings before moving on.
## Testing & Quality
- Unit/integration: the re-pinned alignment pin above; the two untouched mobile/sticky pins as the guard that the row mechanism survived
- Coverage: **>90%** on this task's new/modified code (CSS + pin)
- The measured half (the rows actually render right-justified) is the phase's real-browser check (task 03)
## Completion Criteria
- [ ] the served stylesheet reads `justify-content: flex-end` on both `#nav-status` and `.masthead-right`, with `margin-inline-start` still absent from the cluster (pinned)
- [ ] the ≤640px block still declares `#nav-status { flex-wrap: nowrap; }` and the wrapping cluster, and still declares no second bar height (existing pins green)
- [ ] in a browser at 1280 px and 360 px, the dots row and the controls row are flush with the bar's right padding edge and the bar is still three rows (measured, screenshots in `data/logs/`)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: the dropdown slide, the hamburger reveal and the dots' content are untouched
@@ -0,0 +1,30 @@
# Task 03 — The clock is the farthest-right item, and the phase's browser proof
**Phase:** `25_masthead_control_placement` · **Source:** `TODO.md:5` — "The time should show up as the farthest right item on the navbar"
**Story:** n/a
## Objective
On a right-justified control row, the farthest-right item is the **last** one in markup order, so the clock moves from the first control (before `⚷ Certs`) to the last control (after the theme toggle). The new order on all eight signed-in pages is `⚷ Certs` → `⚙ Account` → `☾ Dark` → clock. This is the phase's verification task as well: the layout change is CSS + markup, so the served-byte pins cannot say the rows actually render right-justified — this task measures them in a real browser (invariant 10).
- ASSUMPTION: "farthest right" is achieved by markup order (the clock is the last child of `.masthead-right`), not by a CSS `order` declaration — the one-loop order pin compares markup, and a `order:` property the pin cannot see is a drift waiting to happen.
## Work
1. The seven page templates that render `.masthead-right` — `src/dashboards/web/pages/index.html`, `system.html`, `disks.html`, `llm.html`, `ups.html`, `settings.html`, `certs.html` — move the line `<span class="clock" id="clock">--:--:--</span>` from its current position (first control, before the Certs link) to **after** the `<button … id="theme-toggle">` element, so the control row reads `⚷ Certs` → `⚙ Account` → `☾ Dark` → clock. The `<div id="nav-status">` stays the first child (it breaks onto row 2 before the controls). All seven templates must end byte-identical in the control-row region apart from the page-specific comment — `test_every_page_links_to_both_admin_pages` is a one-loop audit precisely because the eight markups are supposed to be the same.
2. `src/dashboards/web/static/dash.css` — re-word the header comment's row-3 line ("row 3 the `.masthead-right` controls (clock, `⚷ Certs`, `⚙ Account`, theme)") to the new order. No declaration changes.
3. `tests/test_api.py` — in `test_every_page_links_to_both_admin_pages`: update the docstring's order sentence (clock → Certs → Account → theme toggle ⇒ `⚷ Certs` → `⚙ Account` → theme toggle → clock); keep the two existing `before_theme` assertions (Certs and Account still sit before the theme toggle, Certs before Account — both true in the new order); add the new one: `html.index('id="clock"') > html.index('id="theme-toggle")` on every page (the clock is the last control — on a `flex-end` row that is the farthest-right item). The `/login` half of the test (no links, no toggle) is untouched.
4. `data/logs/25-task03-verify.cjs` — the browser proof, in the `20-task01-rows.cjs` convention (`NODE_PATH=<playwright node_modules> node data/logs/25-task03-verify.cjs`, `DASH_BASE`/`DASH_PW` env, `check()` accumulator, non-zero exit on any failure): sign in, then on all seven tabbed pages at **1280 px** and **360 px** — (a) group the bar's children into rows by bounding box and assert the three-row shape; (b) the `#nav-status` row and the controls row are flush with the bar's right padding edge (their right edges equal the bar's content right edge within 1 px, and they are **not** flush left — the old layout's right edge was far from the bar's); (c) within the controls row, the clock's right edge is the rightmost of the four controls and the order reads Certs, Account, theme, clock; (d) `#nav-status` on `/certs` and `/settings` is populated (four chips, none `idle`-grey against the live dev fleet — the task 01 check). `/login` (unsigned) at both widths: brand + theme toggle only, no dots, no clock, no horizontal overflow. Screenshots `data/logs/masthead-25-<page>-<width>.png` (the seven tabbed pages at both widths; the script names them, the pass line is every `check()` green).
## Testing & Quality
- Unit/integration: the extended `test_every_page_links_to_both_admin_pages` (the order pin), with `test_the_masthead_is_three_rows_right_justified_at_every_width` (task 02) and the two task-01 flipped pins as the surrounding contract
- Coverage: **>90%** on this task's new/modified code (markup + pin + script)
- Real-browser check: the `.cjs` pass above against the restarted dev server (`./scripts/dev-server.sh restart` first, per invariant 10), the dev fleet answering so the badges are green
## Completion Criteria
- [ ] on all eight signed-in pages the control row reads `⚷ Certs` → `⚙ Account` → `☾ Dark` → clock in the markup, with the clock after the theme toggle (pinned)
- [ ] the `.cjs` pass is all-green at 1280 px and 360 px: three rows, both right-hand rows flush right, clock rightmost, badges populated on `/certs` and `/settings` (screenshots in `data/logs/`)
- [ ] `/login` (unsigned) is unchanged: no badges, no clock, no links (existing `tests/test_auth_api.py` pin green)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] `AGENTS.md`'s now-stale masthead sentences are updated (the phase overview's Documentation & Consistency list) and `README.md` checked
@@ -0,0 +1,34 @@
# Phase 26 — UPS events: power events collapsible, connection errors always visible
**Source:** `TODO.md` L6 — "The phase I ran earlier collapsed all the events in the UPS tab. That wasn't the point. I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
**Context:** phase 19 (complete) put the **whole** `/ups` event feed behind one collapsed `details.events-box` (no `open` attribute, lazy: `refresh()` fetches the feed only while `state.events.open`, the first expand fetches, a fresh re-expand paints from the `<ul>`'s kept children, `painted`/`fetchedAt` stamped on the success path alone, the `Dash.eventsPager` window of 50 inside the bounded 320px scroll). The operator's complaint is that the classification is wrong, not that a collapse exists: a poll failure like `NUT server NutError: cannot reach upsd … (timed out after 4s)` is hidden behind a summary nobody opens, while the power events it was meant to tuck away are the only ones the operator actually wants collapsed. The feed's **kinds** are fixed and known: the poller (`collectors.py:490` — `Job("nut", "ups", "NUT server", …)`, events under the dashboard name `ups`) writes `unreachable` / `reachable` / `recording` / `store-error` (`poller.py:127/157/164/190` — the quoted `NutError` line is the `unreachable` message); the `NutMonitor` transition bookkeeping writes `status` (a state change lands at `collectors.py:393`) and `test` (the self-test verdict, `collectors.py:409`); `api.py` writes `action` for every command sent through `POST /api/ups/command` (the fixed-text `action` events, `api.py:999/1014`). The served-byte pins for the current shape are in `tests/test_api.py`: `test_ups_power_events_feed_sits_behind_a_collapsed_disclosure` (the `details.events-box`, no `open`, `#events` inside), `test_ups_events_are_fetched_only_while_the_disclosure_is_open` (the lazy gate — **this phase inverts it on purpose**), `test_the_ups_event_rows_keep_the_date_time_and_severity_shape` (the row shape), `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component` (the `details.events-box` CSS shape — unchanged by this phase). The page is `src/dashboards/web/static/page-ups.js` (`refresh()` around line 439, `refreshEvents()` around line 445, the disclosure's toggle handler in the `DOMContentLoaded` block) over the card in `src/dashboards/web/pages/ups.html:75-78`.
## Objective
`/ups` renders **two** lists from the one unchanged feed fetch: the power events — state transitions (including the power-loss transitions), self-test verdicts and sent commands — stay behind the collapsed `details.events-box` disclosure, and the connection/error events (`unreachable`/`reachable`/`recording`/`store-error`) are a card that is **always visible** (hidden only while it has no rows to show). The split is page-side, keyed on the event's `kind`; `/api/events` gains no vocabulary.
## Dependencies
- — (none) — the phase builds on complete phase 19; it is scheduled before `27_mobile_tables_and_audit` on purpose, because that phase's mobile audit captures `/ups` in the shape this phase leaves it in (two cards, one of them hidden-when-empty).
## Tasks
1. `01_page_side_event_split.md` — the `kind` split in `page-ups.js` (power kinds `status`/`test`/`action`, everything else errors) and the removal of the phase-19 lazy gate: the feed is fetched on every poll, because a visible list that ages is worse than one more 200-event fetch per interval
2. `02_render_both_lists_paged.md` — the always-visible `#events-errors` card (above the disclosure, hidden while empty) and both lists paged through the shared `Dash.eventsPager`
3. `03_repin_and_browser_verify.md` — the pin re-pins (the disclosure stays, the lazy gate inverts, the row shape extends to the error list, the kind set is pinned so a silent upstream rename breaks a test) and the phase's real-browser verification
## Testing & Quality
- Unit/integration: the re-pinned and new pins in `tests/test_api.py` (task 03); the split itself is pinned on the served script (the kind set) and the served markup (the error card, `hidden`, not inside a `<details>`)
- Coverage: **>90%** on new/modified code
- Real-browser check (invariant 10): a `data/logs/26-task03-*.cjs` script in the `20-task01-rows.cjs` convention, run against the restarted dev server: the disclosure shut by default, the error card's visibility matching the feed's error-kind rows, both lists' rows matching a direct `/api/events?source=ups&limit=200` split, screenshots in `data/logs/`
## Documentation & Consistency
- The final pass updates the now-stale sentences in `AGENTS.md`: the event-feed paragraph's lazy-fetch block ("`refresh()` fetches the feed only while `state.events.open` is true, so a card nobody is looking at costs the poll loop nothing" and the first-expand/fresh-re-expand sentences) is re-written to the new fact (the one fetch serves two lists, always; the disclosure gates only the power list's visibility), and the pin description of `test_ups_events_are_fetched_only_while_the_disclosure_is_open` is re-pointed at its inverted successor. `README.md` is checked for the same sentence; none is expected.
## Completion Criteria
- [ ] a poll failure (e.g. `NUT server NutError: cannot reach upsd …`) is visible on `/ups` without opening anything, and the power events stay behind the collapsed disclosure (pinned + verified in a browser)
- [ ] the error card is hidden while it has no rows and appears with its first error row; both lists share the one `/api/events?source=ups` fetch and the shared pager (pinned)
- [ ] a failed fetch leaves both lists on their last good rows (the stale-last-good rule) — pinned where phase 19 pinned its
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the `details.events-box` CSS shape, the pager's page/scroll semantics, and the row's time/host/severity shape are untouched
@@ -0,0 +1,28 @@
# Task 01 — The feed splits on `kind`, and the lazy gate comes out
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6` — "I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
## Objective
`page-ups.js` splits the one unchanged feed fetch into two lists: the **power** events — `status` (the state transitions, including the power-loss transitions the operator names), `test` (the self-test verdicts) and `action` (the commands sent through the confirm dialog) — and **everything else** (the poller's `unreachable`/`reachable`/`recording`/`store-error`, the quoted `NutError` line included), which is the always-visible error list. The phase-19 lazy gate goes out with the split: a visible list that ages is worse than one more 200-event fetch per interval, so `refresh()` fetches on every poll.
- ASSUMPTION: "power events" is the **known** set of three kinds (`status`/`test`/`action`); any other kind is an error-row by default, because the operator's complaint is that errors must never be the hidden list, and a new kind landing in the hidden list would be the same bug re-shipped.
## Work
1. `src/dashboards/web/static/page-ups.js` — at the top of the file, `const POWER_EVENT_KINDS = new Set(["status", "test", "action"]);` with a comment naming the three writers (`collectors.py:393` `status`, `collectors.py:409` `test`, `api.py` `action` — so a rename on the source side is visible at the site of the drift). In `refreshEvents()`: after the one `fetchJSON(\`/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups\`)` (the call itself is **unchanged** — no `page`/`limit`/`kind` vocabulary, the phase-19 rule), `const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind));` and `const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind));`.
2. `src/dashboards/web/static/page-ups.js` — in `refresh()`, replace `if (state.events.open) await refreshEvents();` with `await refreshEvents();` and re-word the comment above it (the "a shut `<details>` must cost the poll loop nothing" rationale is what the operator overruled: the error list is visible, so the feed is needed whether or not the disclosure is open). In the `DOMContentLoaded` block, delete the `eventsBox` toggle handler's fetch logic — with an always-fresh list the `toggle` event no longer needs to do anything, and `state.events` (with its `open`/`painted`/`fetchedAt` fields) is deleted entirely: the disclosure now gates only the power list's **visibility** (the native `<details>` behaviour), and a failed fetch leaves both lists on their last good rows through `refreshEvents()`'s existing `try { … } catch (err) { /* next poll */ }` — the success-path stamps go away with the state they stamped, and the stale-last-good rule is the `catch` block, which stays.
3. `src/dashboards/web/static/page-ups.js` — the split is the **only** change to the fetch path: the `row` renderer, `eventText` handling and the `runtime_seconds` footnote stay byte-identical (task 02 points them at both lists), and the `state` object loses `events` with no other field touched.
## Testing & Quality
- Unit/integration: the split and the gate removal are pinned on the served script in task 03 (`tests/test_api.py`) — the kind set, the unconditional `refreshEvents()` call, the deleted `state.events`
- Coverage: **>90%** on this task's new/modified code
- The behavioural proof (a real `unreachable` row visible without opening the disclosure) is the phase's real-browser check (task 03)
## Completion Criteria
- [ ] `page-ups.js` defines `POWER_EVENT_KINDS = new Set(["status", "test", "action"])` and `refresh()` calls `refreshEvents()` unconditionally (pinned on the served script)
- [ ] `state.events` and the toggle handler's fetch logic are gone; a failed fetch leaves both lists on their last good rows (pinned where phase 19 pinned its)
- [ ] the `/api/events` call is byte-identical to the phase-19 one (no new query vocabulary — pinned)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: the poll cadence, the confirm dialog, the hero row and the charts are untouched
@@ -0,0 +1,31 @@
# Task 02 — The error card: always visible, hidden only when empty
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6` — "I wanted the power loss events in a collapsible list, not events like \"NUT server NutError: cannot reach upsd at 10.1.0.126:3051 (timed out after 4s)\" in a collapsible list."
**Story:** n/a
## Objective
The error list is a card of its own, **above** the power-events card, built from the existing vocabulary (`section.card`, `ul.events`, the pager's strip) with no new CSS component: visible from first paint while it has rows, `hidden` while it has none, and paged by the same `Dash.eventsPager` the power list uses (its `WeakMap` state is keyed by the `<ul>`, so the two lists page independently — the pager's existing contract).
- ASSUMPTION: the error section sits **above** the Power events disclosure — the visibility complaint is about *seeing* the errors, and the collapsible list keeps its current position in the card stack.
- ASSUMPTION: the error card is `hidden` while it has zero rows — a permanent empty "Connection & errors" card is the noise the page's empty-state rules exist to avoid, and its first `unreachable` row is what makes it appear.
## Work
1. `src/dashboards/web/pages/ups.html` — above the existing `<section class="card" style="margin-top:.85rem">` (the disclosure card, `ups.html:74-79`), add the error card: `<section class="card" id="ups-errors-card" hidden>` with `<header><h3>Connection &amp; errors</h3><span class="spacer"></span><span class="faint">upsd reachability and this process's own failures</span></header>` and `<div class="body"><ul class="events" id="events-errors"><li class="muted">loading…</li></ul></div>`. The `hidden` attribute is the resting state (before the first successful fetch there are no rows); `style="margin-top:.85rem"` moves to the **error** card's position only in the sense that the disclosure card keeps its existing attribute — a `hidden` card is `display: none`, so the disclosure card's own `margin-top` still gives the gap to whatever is above it when the error card is absent, and no rule is needed for the two-cards case beyond the disclosure card's existing attribute (verify in the browser that the gap is `.85rem` in both states; if the hidden card's own margin would double it, drop the error card's `margin-top` and let the stack's own gap rule do the work — the page's sections carry the shared `.85rem` gap).
2. `src/dashboards/web/static/page-ups.js` — in `refreshEvents()`, after the split (task 01): render the power list exactly as today — `Dash.eventsPager($('#events'), power, { label: 'Power events', … })` with its existing `empty` sentence and `row` renderer — and the error list — `Dash.eventsPager($('#events-errors'), errors, { label: 'Connection & errors', empty: '<li class="muted" style="grid-template-columns:1fr">No connection errors — upsd is answering and this process is recording.</li>', row: <the same `row` function as the power list, extracted to one const so the two lists cannot drift> })`; then `$('#ups-errors-card').hidden = errors.length === 0;`. The `loading…` seed row in the error card is overwritten on the first successful fetch and never shows, because the card is `hidden` until that fetch lands with rows (a first fetch with zero error rows keeps it hidden — no flash).
3. `src/dashboards/web/static/dash.css` — **no new rules**: the card, the `ul.events` bounded 320px scroll and the `.events-pager` strip are all existing vocabulary (the phase-19 rule a second opinion about the driver's word is the bug — the same applies to a second style for a second list). If step 1's gap verification needs a rule, the only allowed one is a margin on `#ups-errors-card` itself, pinned in task 03.
4. The `row` renderer's `runtime_seconds` footnote stays on both lists (an `action` row's runtime is the power list's; an error row simply has no `event.data.runtime_seconds` and prints none — no branch added).
## Testing & Quality
- Unit/integration: task 03's pins — the error card in the served markup (`hidden`, not inside a `<details>`), the kind set, the single `row` const, the `hidden = errors.length === 0` line
- Coverage: **>90%** on this task's new/modified code
- The two-cards layout, the gap in both states and the error card's first-appear are the phase's real-browser check (task 03)
## Completion Criteria
- [ ] `/ups` shows the `Connection & errors` card above the Power events disclosure, paged, sharing the row shape (pinned + measured in a browser)
- [ ] the card is `hidden` while the feed has no non-power kinds and appears with its first error row, the gap to the disclosure card being `.85rem` in both states (measured)
- [ ] both lists page independently (pager state per `<ul>`) and a failed fetch leaves both on their last good rows (pinned)
- [ ] no new CSS selector is introduced for the error card (pinned)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: the disclosure's CSS shape and the power list's empty sentence are untouched
@@ -0,0 +1,28 @@
# Task 03 — The pins re-pin, and the browser proves the split
**Phase:** `26_ups_event_split` · **Source:** `TODO.md:6`
**Story:** n/a
## Objective
The phase's pins: the disclosure shape stays pinned (the power list is still behind the collapsed `details.events-box`), the lazy-gate pin inverts to its replacement (the fetch is no longer gated on the disclosure), the row-shape pin extends to the error list, and the kind set is pinned on the served script so a silent rename on the source side breaks a test before an operator sees a misclassified row. Then the real-browser verification (invariant 10).
## Work
1. `tests/test_api.py` — `test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`: keep every existing assertion (one `details.events-box`, no `open` attribute, `#events` inside it) and extend: `id="events-errors"` is present in the served page and is **not** inside any `<details>` (the error list must not be collapsible — the operator's whole complaint), and the error card carries the `hidden` attribute in the template.
2. `tests/test_api.py` — `test_ups_events_are_fetched_only_while_the_disclosure_is_open` becomes its inversion, renamed `test_ups_events_are_fetched_on_every_poll` (the phase-19 lazy gate is what the operator overruled; the old test failing is the point of this phase): the served script's `refresh()` region (the same `boot.index("…")` slice the old test uses) shows `refreshEvents()` called **unconditionally**, and the served script carries no `state.events` and no `toggle`-handler fetch. The stale-last-good pin (a failed fetch leaves the rows on screen) moves onto the `refreshEvents()` `catch` block, the way phase 19 pinned it.
3. `tests/test_api.py` — `test_the_ups_event_rows_keep_the_date_time_and_severity_shape`: the existing `#events` assertions stay, and the same shape (the `<time>` element, the host chip with its `sev` class, the `eventText`-cleaned message, the optional runtime footnote) is asserted for `#events-errors` through the shared `row` const. New pin: the served script defines `POWER_EVENT_KINDS` as exactly `status`, `test`, `action` (a set literal read out of the script text — a kind added to or removed from the set is a classification the operator did not make). New pin: `$('#ups-errors-card').hidden = errors.length === 0` (or its exact equivalent line) — the hidden-when-empty behaviour is the one state this page may claim about a list it cannot see.
4. `tests/test_api.py` — `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component`: unchanged, and extended with one guard — no new CSS selector for the error card in the served stylesheet (the card is `section.card` + `ul.events` + `.events-pager` vocabulary only; a second style for a second list is the phase-19 anti-pattern).
5. `data/logs/26-task03-verify.cjs` — the browser proof, in the `20-task01-rows.cjs` convention (`DASH_BASE`/`DASH_PW`, `check()` accumulator, non-zero exit on failure), against the restarted dev server (`./scripts/dev-server.sh restart` first): sign in; fetch `/api/events?source=ups&limit=200` **directly** and split by the three power kinds; load `/ups` and assert — (a) the disclosure is shut by default and the power list renders only on expand; (b) the error card is visible if and only if the direct fetch has non-power-kind rows; (c) each list's DOM row count and first row's text match the direct-fetch split (the `eventText` cleaning applied); (d) a failed-fetch leg is not needed against live hardware — the stale-last-good rule is pinned in step 2's pin instead. Screenshots `data/logs/ups-event-split-<state>.png` (the error card visible with rows, and hidden when the feed has none — if the live feed has no error rows, the hidden state is the one measured, and the visible state is measured by the DOM from the direct-fetch split, not by staging rows).
## Testing & Quality
- Unit/integration: the pins of steps 1–4
- Coverage: **>90%** on this task's new/modified code
- Real-browser check: step 5's `.cjs` pass, screenshots in `data/logs/`
## Completion Criteria
- [ ] the four pin groups above are green, with `test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component` passing unchanged in its original assertions
- [ ] the `.cjs` pass is all-green against the live dev server (screenshots in `data/logs/`)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] `AGENTS.md`'s now-stale lazy-fetch sentences are updated (the phase overview's Documentation & Consistency list) and `README.md` checked
@@ -0,0 +1,33 @@
# Phase 27 — Mobile: every table scrolls, and the whole app is audited at phone width
**Source:** `TODO.md` L7 — "None of the tables are horizontally scrollable on mobile. Fix that and make sure everything works on mobile. Do a full mobile compatible audit."
**Story:** n/a
**Context:** AGENTS.md gap 12 (measured on 2026-10-20): at 360 px, `/`'s fleet table measures 450–510 px inside a 320 px `section.card`, and `.card { overflow: hidden }` clips the tail columns (`WORST DISK`, `GPU BUSY`, `TEMP`, `UP`) with no scroll container to reach them — `table.data` only scrolls with its `.scroll` modifier (`dash.css:884`: `display: block; overflow: auto; max-height: 320px`), which **no page currently uses** (phase 21's `.scroll` on the `/certs` table is still in `todo/` and has not landed). Gap 12 names the fix as "a responsive-table pass (`.scroll` on the wide tables, or a per-host card layout at ≤640px)" and records it as a phase, not a cleanup, because it touches pinned page shape on `/`. The table inventory is fixed and small: two tables in static markup (`index.html:61` `#fleet-table`, `certs.html:85` `#agents-table`) and five in JS-built markup (`page-system.js:194` the GTt table, `:257` the filesystems table, `:269`/`:274` the two top-process tables, all inside the "More detail" disclosure; `page-ups.js:278` the raw NUT-variables table, inside its disclosure). Phase 18's standing guarantee — the document no longer scrolls sideways at a phone width (360 px where it was 750 px) — must survive this phase, so the fix is **inside** the tables, never the document. The ≤640px breakpoint is the repo's mobile boundary (the hamburger, the bar's row-breaks). The browser-proof convention is the `data/logs/*.cjs` scripts (`20-task01-rows.cjs` is the template: `NODE_PATH=<playwright node_modules> node …`, `DASH_BASE`/`DASH_PW`, a `check()` accumulator, non-zero exit on failure, screenshots in `data/logs/`). Phase 26 (scheduled before this one) is what the audit will capture on `/ups`: two cards, the error one hidden-when-empty.
## Objective
Every `table.data` on every page scrolls **horizontally inside its own container** when it is wider than the card (a phone's 320 px), with no new document-level sideways scroll, and the whole app — all eight signed-in pages plus `/login` — is audited at 360 px (1280 px as the control) by a re-runnable script whose pass is the phase's proof.
## Dependencies
- `26_ups_event_split` (todo) — the audit captures `/ups` in the shape 26 leaves it in (the two cards and the hidden-when-empty error card are what the 360 px pass must not clip); running 27's audit before 26 lands would measure the pre-split single card.
## Tasks
1. `01_scrollable_tables_on_mobile.md` — the one ≤640px rule that makes every `table.data` horizontally scrollable (horizontal-only, no height cap), pinned on the served CSS
2. `02_mobile_audit_script.md` — `data/logs/27-task02-mobile-audit.cjs`: the re-runnable sweep of all nine pages at 360 px and 1280 px — no horizontal document scroll, no clipped table (the gap-12 failure mode), the masthead intact, screenshots
3. `03_fix_mobile_issues_found.md` — run the audit, fix every finding, re-run until every page passes both widths; the final screenshot set is the phase's proof
## Testing & Quality
- Unit/integration: the served-CSS pin (task 01); the audit script itself is the integration evidence (tasks 02–03) — the repo has no JS test runner, so a `.cjs` pass with screenshots is the established browser-proof shape (invariant 10)
- Coverage: **>90%** on new/modified code (the new code is the pin; the change is one CSS rule)
- Real-browser check (invariant 10): the task-02 script, run against the restarted dev server, is the phase's verification — not an add-on to it
## Documentation & Consistency
- The final pass resolves AGENTS.md **gap 12**: the clipping paragraph becomes history ("the gap-12 clipping is closed by `27_mobile_tables_and_audit`: every `table.data` scrolls horizontally inside its own container at ≤640px, measured by `data/logs/27-task02-mobile-audit.cjs`"), the "a phase rather than a cleanup" sentence goes with it, and the mobile-nav paragraphs that quote the 360 px document width are checked for consistency with the audit's no-document-scroll assertion. `README.md` is checked for the same sentence; none is expected.
## Completion Criteria
- [ ] at 360 px, every `table.data` wider than its container scrolls horizontally inside the container — the gap-12 fleet-table clipping is gone (pinned + measured by the audit)
- [ ] the audit pass is all-green on all nine pages at 360 px and 1280 px: no horizontal document scroll (phase 18's guarantee intact), masthead intact, screenshots in `data/logs/`
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: at 1280 px the tables render exactly as before (no scrollbar where the table fit, no layout shift)
@@ -0,0 +1,30 @@
# Task 01 — Every table scrolls horizontally at the mobile breakpoint
**Phase:** `27_mobile_tables_and_audit` · **Source:** `TODO.md:7` — "None of the tables are horizontally scrollable on mobile. Fix that…"
**Story:** n/a
## Objective
One ≤640px rule makes **every** `table.data` horizontally scrollable inside its own card when it is wider than the card, with no per-table markup change: the five JS-built tables and the two static ones all carry the class already, so a class-level rule reaches all seven at once. The mechanism is the one the existing `.scroll` modifier proves — `display: block` keeps the table's internal layout in an anonymous box, `overflow-x: auto` gives a scrollbar only where the table overflows.
- ASSUMPTION: the rule is **horizontal-only** (`overflow-x`, not `overflow: auto`) and carries **no** `max-height` — the `.scroll` modifier's 320px height cap is its vertical-use contract, and a phone getting a height-capped table on top of a horizontal one is two new failure modes bought to fix one.
- ASSUMPTION: the rule lives in the existing `@media (max-width: 640px)` block (the repo's mobile boundary, the same block the hamburger and the bar's gap live in), applied to `table.data` generally rather than adding the `.scroll` class per table — the per-table class path is what gap 21 does for one table, and seven hand-placed classes are the drift this phase exists to avoid. The all-widths `table.data.scroll` rule (`.css:884`) is untouched.
- ASSUMPTION: the `.scroll`-shape pass is taken over gap 12's alternative (a per-host card layout at ≤640px) — a re-layout of `/`'s fleet table into cards touches pinned page shape far more than an overflow rule does, and the operator's word is "horizontally scrollable", which is the `.scroll` shape.
## Work
1. `src/dashboards/web/static/dash.css` — inside the existing `@media (max-width: 640px)` block (the "mobile nav" section, beside the `.masthead`/`#nav-status` mobile rules), add: `table.data { display: block; overflow-x: auto; }` with a comment stating the contract: horizontal-only, no height cap, every `table.data` (static or JS-built) at the phone breakpoint, the document itself never scrolling sideways (phase 18's guarantee) and the all-widths `table.data.scroll` rule untouched.
2. `src/dashboards/web/static/dash.css` — verify the rule reaches the tables inside disclosures: the "More detail" tables (`page-system.js:194/257/269/274`) and the NUT-variables table (`page-ups.js:278`) sit in `display: none` containers until expanded; `overflow-x: auto` on a `display: block` table inside a revealed `details` is the same box model (no extra rule needed — but the audit's task-02 expanded-disclosure leg is what proves it, so do not skip expanding those two disclosures in the script).
3. `tests/test_api.py` — pin the served CSS the way `test_the_mobile_masthead_block_declares_no_second_bar_height` reads its block: in the ≤640px block, `table.data` declares `display: block` **and** `overflow-x: auto`, and declares **no** `max-height` (the height cap must not migrate into the mobile rule). The all-widths `table.data.scroll` rule's declarations are asserted unchanged in the same test.
## Testing & Quality
- Unit/integration: the served-CSS pin above
- Coverage: **>90%** on this task's new/modified code (one rule + pin)
- The behavioural proof (the gap-12 table actually scrolls at 360 px) is the task-02 audit
## Completion Criteria
- [ ] the ≤640px block declares `table.data { display: block; overflow-x: auto }` with no `max-height`, pinned on the served stylesheet
- [ ] the `table.data.scroll` rule (`.css:884`) is byte-identical to before
- [ ] at 360 px in a browser, `/`'s fleet table scrolls horizontally inside its card (the tail columns `WORST DISK`/`GPU BUSY`/`TEMP`/`UP` reachable) and the document does not scroll sideways (measured by the task-02 audit)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] no behavior change in completed work: at 1280 px no table shows a scrollbar and no layout shifts (measured by the audit's control width)
@@ -0,0 +1,32 @@
# Task 02 — The re-runnable mobile audit script
**Phase:** `27_mobile_tables_and_audit` · **Source:** `TODO.md:7` — "…make sure everything works on mobile. Do a full mobile compatible audit."
**Story:** n/a
## Objective
`data/logs/27-task02-mobile-audit.cjs` — the full mobile compatibility audit as a re-runnable script in the repo's established convention (`20-task01-rows.cjs` is the template: `NODE_PATH=<playwright node_modules> node data/logs/27-task02-mobile-audit.cjs`, `DASH_BASE`/`DASH_PW` env with the dev defaults, a `check(name, ok, detail)` accumulator, non-zero exit on any failure). It sweeps **all nine pages** — the eight signed-in pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/settings`, `/certs`) plus `/login` (unsigned, the only anonymous page) — at **360 px** (the phone width gap 12 measured at) with **1280 px** as the control, and its all-green pass is the phase's proof.
## Work
1. `data/logs/27-task02-mobile-audit.cjs` — setup: sign in at `DASH_BASE` (the `20-task01-rows.cjs` sign-in pattern, `testpass` default), then per page × width, the check set:
- **No horizontal document scroll** (phase 18's standing guarantee — this phase must not break it): `document.documentElement.scrollWidth <= window.innerWidth + 1` and `document.body.scrollWidth <= window.innerWidth + 1`.
- **No clipped table** (the gap-12 failure mode, the one this phase exists for): for every `table.data`, if `el.scrollWidth > el.clientWidth + 1` then the computed `overflow-x` is `auto` or `scroll` (the table scrolls **inside** its own container); a wide table whose `overflow-x` is `visible` (clipped by the card's `overflow: hidden`) fails with the table's id. At 1280 px the same check runs as the control (a desktop table that overflows — none should — would fail there too).
- **Every element fits the viewport**: a generic overflow scan — no element with `getBoundingClientRect().right > window.innerWidth + 1` other than a `table.data` inner box already accounted for (a hero row, chart or card spilling off the phone is the "make sure everything works on mobile" half the table check does not name).
- **Masthead intact** (360 px leg): the three-row shape by bounding-box grouping (the `20-task01-rows.cjs` `PROBE()`), every control's box inside the viewport, and the ≤640px dropdown still opening directly under the bar (open `#nav-toggle`, measure `nav.tabs`'s top against the bar's bottom, close it).
- **Expanded-disclosure leg** (360 px): on `/system` expand one host's "More detail" `details` and on `/ups` open the NUT-variables disclosure, re-run the table check on the newly revealed tables (the `display: none` containers of task 01's step 2), then close them.
- **`/ups` two-card leg** (360 px, phase 26's shape): the error card (when present) and the disclosure card both fit, the error card's `hidden` state matching the feed as task 26 pinned.
- Screenshots: `data/logs/mobile-27-<page>-<width>.png` per page × width (nine × two = eighteen, plus the two expanded-disclosure states), each `check()` line printed and accumulated.
2. `data/logs/27-task02-mobile-audit.cjs` — the 1280 px control leg doubles as the "no behavior change" proof: the same element-fits and table checks at desktop width, so a rule that shifts a desktop table (a spurious `display: block` at 1280 px, a layout shift) fails the control as loudly as the phone leg fails the phone.
3. The script exits non-zero on any failure and prints the failure lines (the `20-task01-rows.cjs` convention); it is **run twice** as part of this task: once as written (expecting the pre-task-03 findings, if any, to surface) and once after task 03's fixes — the first run's output is the findings list task 03 works from, and both runs' output is the phase's evidence. (The script is in gitignored `data/logs/` — evidence, not a tracked pin; the tracked pin is task 01's served-CSS one.)
## Testing & Quality
- Unit/integration: the script's check set is the integration evidence; task 01's served-CSS pin is the tracked half
- Coverage: **>90%** on new/modified code (the script is evidence, not shipped code — no coverage line of its own, the pin carries the gate)
- Real-browser check: this **is** the real-browser check (invariant 10), run against the restarted dev server (`./scripts/dev-server.sh restart` first) with the live fleet answering so the tables have real rows
## Completion Criteria
- [ ] the script exists, runs in the documented one-liner, signs in, sweeps nine pages × two widths, and exits non-zero on any failure
- [ ] its check set covers: document scroll, per-table clipping (the gap-12 mode), the generic element-fits scan, the masthead + dropdown at 360 px, the two expanded-disclosure legs, and the `/ups` two-card leg
- [ ] the first run's output is the findings list (task 03's input) and its screenshots are in `data/logs/`
- [ ] `uv run pytest` green, `ruff` + `pyright` clean (the script is `node --check`-clean — `bash .agents/validate.sh`'s node check covers the `web/static` scripts, and this one is checked by hand as part of the run)
@@ -0,0 +1,33 @@
# Task 03 — Fix every finding until the audit is all-green at both widths
**Phase:** `27_mobile_tables_and_audit` · **Source:** `TODO.md:7` — "…make sure everything works on mobile. Do a full mobile compatible audit."
**Story:** n/a
## Objective
The audit's first run (task 02) is the findings list; this task closes it. Every finding is a CSS/markup fix — the data layer, the API and the page scripts' logic are out of scope (a table that does not fit is a layout fact, and the fix is the layout, never the data) — and the phase is done when the audit passes **every page at both widths** with the screenshots in `data/logs/`.
## Work
1. Run the task-02 script against the restarted dev server and work the findings list top down. The expected findings, in the order the audit surfaces them:
- **`/` fleet table** (gap 12, the one the phase exists for): task 01's rule is the fix — verify the tail columns (`WORST DISK`, `GPU BUSY`, `TEMP`, `UP`) are reachable by scrolling the table, not the document. If the rule is already in place from task 01 and the table still clips, the card's `overflow: hidden` is clipping the table's **scrollbar track**: the fix is `table.data` keeping `display: block; overflow-x: auto` (task 01's pin) — a `card`-level change is the fallback, and it needs a pin of its own if used.
- **`/certs` agents table**: same rule, same check; note phase 21 (still `todo/`) is scheduled to add the `.scroll` class to this table — this task's rule makes the class redundant at ≤640px but harmless at all widths, so 21 landing later is a no-op collision, not a conflict (state that in the fix note if the table is already covered).
- **`/system` "More detail" tables** (filesystems, top processes, GTt): the expanded-disclosure leg of the audit; a table inside the `details` that still clips gets the same class-level fix (no per-table markup — the class is already there).
- **`/ups` raw NUT-variables table**: same, through its disclosure.
- **Anything the generic element-fits scan names that is not a table** (a hero row, a chart, a card spilling off the phone): fix the specific element — the fix is named per element in the findings note, and a fix that hides a control to make the width work is a refusal (the phase-18 rule: nothing is hidden to make the arithmetic work).
2. `tests/test_api.py` — for every finding whose fix is a **markup** change (an `id` added, a wrapper introduced, a class added to a static table), add the served-byte pin in the same pass; a CSS-only fix is covered by task 01's pin. No pin is added for a fix that only touches `data/logs/` evidence.
3. Re-run the task-02 script until **every check on all nine pages passes at 360 px and 1280 px**; the final run's output and its eighteen-plus screenshots are the phase's proof (the final-run screenshot set supersedes the first run's in `data/logs/`).
## Testing & Quality
- Unit/integration: task 01's served-CSS pin + any new markup pin from step 2
- Coverage: **>90%** on new/modified code
- Real-browser check: the final all-green audit run (invariant 10), the screenshots in `data/logs/`
## Completion Criteria
- [ ] the audit is all-green on all nine pages at 360 px and 1280 px, with the final-run screenshots in `data/logs/`
- [ ] every finding from the first run is closed by a named fix (CSS/markup only) or a stated refusal (a control the operator needs, not hidden)
- [ ] the gap-12 fleet-table clipping is gone: the tail columns reachable by the table's own scrollbar, the document not scrolling sideways (measured)
- [ ] `AGENTS.md` gap 12 is resolved per the phase overview's Documentation & Consistency list, and `README.md` checked
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the 1280 px control leg is all-green (no desktop layout shift, no spurious scrollbars)
@@ -0,0 +1,11 @@
**Phase 21 final verification pass** (all 3 task files already in `complete/`, working tree carries the implementation)
- **Verified** task 01: `#pause`/`Space`/`setPaused`/`_paused` gone — `grep -ri pause src/dashboards/web/` empty, 0 pause hits in all 8 served pages + 10 static assets; re-ran `data/logs/21-task01-nopause.cjs` → 30/30 PASS (hidden tab: 0 data fetches in 22 s, one poll 2 ms after return).
- **Verified** task 02: re-ran `21-task02-gpu-values.cjs` on the live fleet → all PASS (deskwork/driveripper show Model/VRAM/Pool/Temp at 16.3 px; gateway = 5 heroes, slot `hidden`, no `n/a`; hero order unchanged; GPU history chart still builds per host).
- **Verified** task 03: re-ran `21-task03-scroll.cjs` → 14/14 PASS (`class="data scroll"`, scrollWidth 1021 vs clientWidth 292 at 360 px, Rotate/Revoke/bundle reachable and hit-testable; unchanged at 1280 px). Screenshots refreshed in `data/logs/`.
- **Gate**: `uv run pytest` → **1471 passed, 15 skipped**; `--cov --cov-fail-under=94` → **98.94%**; `ruff check src tests agent` clean; `pyright` **0 errors**; `bash .agents/validate.sh` → **EXIT 0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**; `/api/health` all 9 sources `ok`.
- **Docs fixed** (pre-existing + this phase): "all **eight** signed-in pages" was a miscount of `MARKUP_PAGES` (7 = 5 dashboards + `/settings` + `/certs`; `/login` is the 8th, unsigned) → corrected in `README.md`, `AGENTS.md` (×2) and the `tests/test_api.py` docstring/comment; `AGENTS.md` Commands hermetic tally 1470 → **1471**.
- **Docs reviewed, consistent**: Quickstart/commands/flags (`install-agent.sh`, `capture-fixtures.sh`, `dev-server.sh`), the env table vs `config.py`, every documented route vs the served route table, masthead/`/system` GPU/`/certs` scroll descriptions vs code, and all `data/logs/*` + `tests/fixtures/*` references.
- **Deviation**: `validate.sh`'s advisory `fastapi 0.153.0 → 0.156.4` left alone — no dependency was touched this phase, so AGENTS rule 8 ("check after touching dependencies") is not triggered; upgrading here would be an unrelated behaviour change.
- No live-infra changes made (repo-side + browser verification only).
- **Next pending phase**: `22_disks_hide_unconfigured`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...............................................s........................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 38%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 67%]
............sssssssssss................................................. [ 72%]
........................................................................ [ 77%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
............................................................sss......... [ 96%]
.............................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1471 passed, 15 skipped, 1 warning in 156.37s (0:02:36)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.4 wheel
@@ -0,0 +1,14 @@
**Implemented**
- `src/dashboards/web/pages/{system,disks,llm,ups}.html` — deleted the `#pause` button line (only served occurrences; `grep -ri pause src/dashboards/web/` now exits 1).
- `static/app.js` — dropped `_paused`/`paused`/`setPaused` from `Dash.poll()` and the `#pause` binding + `Space` handler; `document.hidden` skip and the `visibilitychange` refire kept. The `T` theme shortcut that shared that listener survives as `Dash.bindShortcuts()` (same four pages call it; deliberately not moved into `initChrome()`, so its reach is unchanged).
- `static/dash.css` — the three comments naming the pause button as a row-3/wrap tenant cleaned.
- `tests/test_api.py` — new section: `PAUSE_ARTIFACTS` absent from all 8 served pages (`/login` via its own anonymous client) + 11 assets; `document.hidden`/`visibilitychange` guards still present and no `event.code`/`'Space'` in the shared script; `T` + the four `Dash.bindShortcuts()` call sites pinned.
- Browser proof `data/logs/21-task01-nopause.cjs` (+ `.out`, `21-task01-masthead-{,system-}{1280,360}.png`, `21-task01-system-live.png`): 30/30 PASS — no control on 7 pages × 2 widths + `/login`; `Space` presses nothing and polls keep running (0→5 fetches/14 s); hidden tab: **0 data fetches in 22 s** (`document.hidden=true`); return fires one poll in 1 ms.
**Gate** — `bash .agents/validate.sh` → EXIT=0: `1466 passed, 15 skipped`, coverage 98.94 % (floor 94, unchanged); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean. `uv run pytest -m live --live` → 11 passed (live fleet untouched).
**Docs** — `README.md`: masthead row-3 list no longer names `⏸ Pause`, plus the "no control stops the polling" sentence and a hidden-tab note on the `POLL_MS_*` row. `AGENTS.md`: same masthead sentence rewritten with the phase-21 rationale.
**Notes/deviations** — the masthead's `/api/health` status-dot interval in `initChrome()` was never gated on hidden/paused, so the browser check filters it out of "data fetches" (documented in the script); pre-existing behaviour, left alone. `validate.sh` prints a pre-existing advisory (`fastapi 0.153.0` vs `0.156.4`); no dependency was touched by this task, so no upgrade. Concurrent working-tree changes not mine: emptied `NOTES.md`, new `TODO.md` items and `todo/25_…`, `todo/26_…`.
**Next pending task:** `21_page_cleanups/02_gpu_own_values.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
..........................................s............................. [ 29%]
........................................................................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 68%]
.......sssssssssss...................................................... [ 72%]
........................................................................ [ 77%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
.......................................................sss.............. [ 97%]
......................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1466 passed, 15 skipped, 1 warning in 158.10s (0:02:38)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.4 wheel
@@ -0,0 +1,13 @@
**Implemented**
- `page-system.js`: `gpuSub()` → `GPU_VALUE_FIELDS` + `gpuValues()` — Model / VRAM / Pool / Temp as four labelled `.metric` cells under a busy-only hero (no `severity`, no bar; model escaped; missing field = `n/a`, never `0 °C`). `renderGpuSlot()` now renders a `.gpu-card` per card and **omits** the slot for a host with no GPU (`data-k="gpus"` ships `hidden`; `[hidden]{display:none!important}` reflows the row to five).
- `dash.css`: `.gpu-card` + `.gpu-values` (`auto-fit minmax(min(150px,100%),1fr)`, `overflow-wrap: anywhere`, no colour tokens); `.hero-slot` comment updated.
- `tests/test_api.py`: new `GPU_VALUE_CELLS` table read out of the served script and resolved against all three captures **and** over HTTP; pins for the four cells, the vanished `gpuSub`/`join(' · ')`, the no-verdict/no-bar grid + CSS wrap, and the omission (hidden slot, no `n/a`, `[hidden]` rule); six-slot order pin kept + `hidden` pin. `card_contract.py`/`test_system_contract.py`: stale "hero footnote" prose only, assertions untouched.
- Browser proof `data/logs/21-task02-gpu-values.cjs` (+ `.out.txt`, `21-task02-gpu-values-{1280,360}.png`, `21-task02-gpu-charts.png`): all checks pass — four values at 16.3px (old sub was 11px), gateway = 5 heroes / no slot / no `n/a`, hero order unchanged, GPU chart still builds per GPU host, no sideways scroll.
**Gate** — `bash .agents/validate.sh` EXIT=0: `1470 passed, 15 skipped`, coverage 98.94 % (floor 94, unchanged); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean. `uv run pytest -m live --live` → **11 passed**.
**Docs** — `AGENTS.md`: the `/system` paragraph split (missing *number* → `n/a` uncoloured; missing *device* → slot omitted), four-labelled-values layout, `GPU_VALUE_FIELDS` device, GPU **chart** slot disambiguation, hermetic tally 1464→1470. `README.md`: same rules in the `/system` page description.
**Decisions/deviations** — Temp's label is the word `Temp` with `°C` in the value (pinned `count("unit: ' °C'") == 1`). `.gpu-values` is `auto-fit` rather than a fixed 2×2: measured hero track is ~180px on the live 3-host fleet at 1280px, so 2 columns re-wrap the TODO's own strings onto 3 lines; wide slots still get 2×2. A missing field on a *present* card now prints `n/a` (the task's rule), replacing phase 06's "print no temperature at all" — the never-`0 °C` half is kept and pinned. Pre-existing `validate.sh` advisory (`fastapi 0.153.0` vs `0.156.4`) left alone: no dependency touched.
**Next pending task:** `21_page_cleanups/03_certs_table_scroll.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
..............................................s......................... [ 29%]
........................................................................ [ 33%]
........................................................................ [ 38%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
........................................................................ [ 67%]
...........sssssssssss.................................................. [ 72%]
........................................................................ [ 77%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
...........................................................sss.......... [ 96%]
............................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1470 passed, 15 skipped, 1 warning in 153.88s (0:02:33)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.4 wheel
@@ -0,0 +1,8 @@
**Task 03 complete — `/certs` agents table scrolls horizontally.**
- **Implemented:** `#agents-table` in `src/dashboards/web/pages/certs.html` gains the repo's own `scroll` modifier (`class="data scroll"`) — the existing `table.data.scroll { display:block; overflow:auto; max-height:320px }` rule in `dash.css` is the mechanism, so no CSS added, no wrapper invented (verified the selector form first). `page-certs.js` needed no change (id/`tbody` selectors intact). New served-bytes pin `tests/test_api.py::test_the_certs_agents_table_scrolls_to_its_last_column` pins the class on the table **and** the `table.data.scroll` rule together (either side renaming silently un-scrolls the table).
- **Browser verification (invariant 10):** `data/logs/21-task03-scroll.cjs` against the live dev server — 14/14 checks PASS: at 360 px the container overflows (scrollWidth 1021 vs clientWidth 292), scrolling exposes the full Actions column (Rotate/Revoke/bundle all inside, `elementFromPoint` hit-tests to the control — no write sent); at 1280 px nothing changed (no h- or v-scrollbar, all columns inside). Screenshots: `data/logs/certs-scroll-360-{start,mid,end}.png`, `certs-scroll-1280.png` (mid-scroll shows the table scrolled to the serial/expiry columns).
- **Tests/lint:** `uv run pytest` → **1471 passed, 15 skipped** (was 1470; +1 new pin); coverage **98.94%** (gate 94%); `ruff` + `pyright` clean; `bash .agents/validate.sh` → **exit 0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**. Pre-existing fastapi 0.153→0.156 advisory is non-blocking and outside this task (no dependencies touched — invariant 8 fires only when deps move).
- **Docs:** README.md *Managing the certificates* — one sentence that the agents table scrolls at phone width; AGENTS.md `/certs` paragraph — the `table.data.scroll` contract, the pinned class+rule pair, and the measuring script. Gap 12's forward reference to this change remains accurate.
- **Decisions:** used the class form (the only one the stylesheet styles); kept the `max-height: 320px` cap as shipped (3 rows ≈ 150 px, verified no vertical scrollbar); no CSS/JS behavior change — row contents, chips, controls, and the `COLUMNS` contract test untouched.
- **Next pending task:** none in `todo/21_page_cleanups` — this was the last task; phase 21 is ready for the harness's completion/validation pass.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...............................................s........................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 38%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 67%]
............sssssssssss................................................. [ 72%]
........................................................................ [ 77%]
........................................................................ [ 82%]
........................................................................ [ 87%]
........................................................................ [ 92%]
............................................................sss......... [ 96%]
.............................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.4-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 176 0 100%
src/dashboards/api.py 436 0 100%
src/dashboards/app.py 185 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
src/dashboards/config.py 120 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 3490 37 99%
Required test coverage of 94% reached. Total coverage: 98.94%
1471 passed, 15 skipped, 1 warning in 155.49s (0:02:35)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.4 wheel
+54 -27
View File
@@ -710,10 +710,11 @@ Responsibilities, one line each:
15's "no self-link" precedent: chrome that changes shape depending on where you already are reads
as a broken button, while a link to the page you are on is a no-op that answers with the page you
were looking at (`data/logs/20-task04-admin-links.cjs` measures exactly that — one document
response, no redirect, the same cluster afterwards). The two links therefore sit on all eight
signed-in pages in one order (clock → `⚷ Certs` → `⚙ Account` → theme toggle), which is what lets
the pinned audit be one loop over all eight instead of two over different tuples; `/login` stays
the one page with neither. Everything on it is
response, no redirect, the same cluster afterwards). The two links therefore sit on all seven
signed-in pages (the five dashboards plus `/settings` and `/certs`) in one order
(clock → `⚷ Certs` → `⚙ Account` → theme toggle), which is what lets
the pinned audit be one loop over `MARKUP_PAGES` instead of two over different tuples; `/login`
is the eighth page and the one with neither. Everything on it is
`GET /api/certs`: the Root CA block, the display-only Dashboard client line (that certificate is
minted with the root and has no route of its own), and one row per host with its status chip,
serial, expiry (local timestamp *and* `Dash.duration` left), issued-vs-deployed `bundle_rev`,
@@ -731,7 +732,12 @@ Responsibilities, one line each:
names every payload key it indexes, and `tests/test_api.py` resolves that table against a live
`GET /api/certs` row in both directions (the `SLOT_FIELDS` device), so a rename on either side
fails a test instead of printing `n/a` beside a healthy fleet. The two CNs are markup, and pinned
to `certs.ROOT_CN`/`certs.CLIENT_CN` the way `/settings` pins its password floor.
to `certs.ROOT_CN`/`certs.CLIENT_CN` the way `/settings` pins its password floor. The agents
table is `table.data.scroll` — the repo's one scroll mechanism (`TODO.md:9`: at phone width the
serial / bundle / `san` / controls tail was clipped by `.card { overflow: hidden }` with no way
to reach it), and the class on the served table and the served `table.data.scroll` rule are
pinned **together** in `tests/test_api.py`, because the mechanism exists only while both name
it (measured at 360 px and 1280 px by `data/logs/21-task03-scroll.cjs`).
`app.js`'s masthead status dots are one per dashboard job —
including `disks`, because the same agent can answer the 10-second utilisation poll and
have a wedged SMART pass, and one dot for both would be right about neither. `app.js` owns the
@@ -748,7 +754,11 @@ Responsibilities, one line each:
gets a row in `severity.py`, never a bound in JS.
**The masthead is three left-justified rows at every width** (phase 20 task 01, `TODO.md:5-6`):
row 1 the brand, `#nav-toggle` and the five tabs; row 2 the `#nav-status` per-source dots; row 3
the `.masthead-right` controls (clock, `⏸ Pause`, `⚷ Certs`, `⚙ Account`, theme). That is CSS and
the `.masthead-right` controls (clock, `⚷ Certs`, `⚙ Account`, theme — phase 21 task 01 deleted
the `⏸ Pause` that used to sit between the clock and the two links, button, `Space` shortcut and
`setPaused`/`_paused` plumbing alike: it froze the numbers on screen without growing a `stale`
label, and a backgrounded tab already idles its polls, which is the only stop this app needs).
That is CSS and
no markup change, which decides the mechanism: `#nav-status` is a **child** of
`.masthead-right`, so there is no wrapper to promote, and the rows are line breaks — `.masthead`
is `flex-wrap: wrap`, and both `#nav-status` and `.masthead-right` carry `flex-basis: 100%` with
@@ -1064,7 +1074,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
./scripts/dev-server.sh logs
uv run pytest # hermetic: 1464 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1471 passed + 15 skipped (11 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (98.94%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
@@ -1480,24 +1490,41 @@ body moved inside a disclosure stops being padded until something says so — wh
and `.events-pager` are class-based and needed no new rule to keep the 320px scroll and the
`1–50 of N` strip working inside it.
The `/system` host card is one row of six heroes — CPU, MEM, TEMP, DISK, GPU, NET (the
The `/system` host card is one row of six hero slots — CPU, MEM, TEMP, DISK, GPU, NET (the
`.heroes` grid; `tests/test_api.py` pins the slots and their order on the served script).
Colour comes from the payload's `severity` dicts and nowhere else; a missing number or
verdict — gateway's CPU temp, any host's absent GPU — prints `n/a` **uncoloured**. NET is
one slot with two lines (`.hero-lines`, `↓` in / `↑` out) and is deliberately colourless:
throughput has no good/bad, so `network` carries no severity. The GPU slot holds one hero
per card whose `.sub` carries model, VRAM used/total, the honest APU `pool` view and the
card's temperature (`gpus[].temp_c` — the agent's max over that card's hwmon labels, the
same value stored as `gpu.temp_c@N`, so the hero and the history cannot disagree), and it is
colourless for the same reason (phase 06): a 95 %-busy card on an LLM server is the job, so
the hero carries no `severity` and no utilisation bar — a bar under a verdictless number
would be a verdict by shape. A card whose driver exposes no temperature input prints **no**
temperature in that footnote rather than `n/a` or `0 °C`: "where you can" is the operator's
wording, and `n/a` is reserved for numbers the card is supposed to have. Everything not
glance-worthy — load averages, clock, disk read/write/busy, uptime, GPU power and the
per-label temp breakdown, filesystems, processes — folds into a collapsed "More detail"
`<details>`: a simpler surface, not a less complete page (the table keeps its Temp column;
this was a promotion, not a de-duplication).
Colour comes from the payload's `severity` dicts and nowhere else, and a missing **number or
verdict** prints `n/a` **uncoloured** — gateway's CPU temp, a driver with no temperature
input, a rate with no second sample yet. What a machine does not **have** is a different
fact and prints nothing at all: the GPU slot ships `hidden` and `renderGpuSlot()` un-hides
it only for a host whose payload carries a card, so gateway's card is **five** heroes with
no GPU slot in the row (`TODO.md:12` — "Boxes without a GPU should not report N/A but
instead should omit these values"), and the sheet's `[hidden] { display: none !important }`
is what takes the wrapper out of the grid so the row reflows instead of leaving a wide gap.
The two rules are one sentence each in the page and one test each; collapsing them into "any
missing thing is `n/a`" is what made a router look broken over the GPU it never had
(invariant 3). NET is one slot with two lines (`.hero-lines`, `↓` in / `↑` out) and is
deliberately colourless: throughput has no good/bad, so `network` carries no severity.
The GPU slot is one busy hero per card plus **four labelled values** under it — **Model**
(mono, `gpus[].model`), **VRAM** (`used / total`), **Pool** (`mem_used_pct`, the honest APU
view a Strix Halo needs because its model lives in GTT) and **Temp** (`gpus[].temp_c` — the
agent's max over that card's hwmon labels, the same value stored as `gpu.temp_c@N`, so the
cell and the history cannot disagree; the per-label breakdown stays in "More detail"). They
replaced one ` · `-joined 11px `.sub` — `TODO.md:12`: "`Radeon 8060S Graphics · VRAM 180.2
MiB / 512.0 MiB · pool 79% · 69 °C` is too much info for such small text and should be its
own values" — and they are `.metric` cells in a `.gpu-values` grid, so a 20-character model
string wraps instead of truncating. The four cells come from one `GPU_VALUE_FIELDS` table,
which `tests/test_api.py` resolves against every capture **and** against the served
`/api/system/snapshot` (`SLOT_FIELDS`' device): a rename on either side fails a test instead
of printing `n/a` beside a healthy fleet. A field the agent did not publish prints `n/a` in
its own cell (the card is real, so it is a number the card is supposed to have) and never
`0 °C`. Nothing in this slot is coloured and nothing draws a bar — no `severity`, no
`ok|warn|fail` token in the markup or the grid's rules (phase 06: a 95 %-busy card on an LLM
server is the job, and a bar under a verdictless number would be a verdict by shape).
Everything not glance-worthy — load averages, clock, disk read/write/busy, uptime, GPU power
and the per-label temp breakdown, filesystems, processes — folds into a collapsed "More
detail" `<details>`: a simpler surface, not a less complete page (the table keeps its Temp
column; this was a promotion, not a de-duplication).
A `Δ<window>` label is a measurement, never a config value: `sources/host.py` sends the window
the estimator actually used as the payload's `window_seconds`, and `/system`'s footnote prints
@@ -1601,8 +1628,8 @@ transition events — and nothing else: the fleet-wide chart stack, the nine-fig
panel and the host/metric detail picker are deleted, along with the `#chart-*` / `#detail-*` ids and
the `select` / `label.field` styling only those pickers used. Each panel stacks its own
`.host-charts` — utilisation (CPU, memory, worst disk), network (in/out), then GPU busy — where
the GPU slot is created and destroyed with `host.gpus`, so gateway shows neither a phantom card
nor a hole. **That stack is collapsed behind a per-host `Charts` `<details>`** (phase 06: "only
the GPU **chart** slot is created and destroyed with `host.gpus` (its own thing from the hero
slot above it, which is hidden instead), so gateway shows neither a phantom card nor a hole. **That stack is collapsed behind a per-host `Charts` `<details>`** (phase 06: "only
visible if expanded… there will be lots of hosts"): the default `/system` view is the hero row,
the summary carries the window label the stack will draw (the chips themselves stay in the page
head, one control per page), and `drawHostCharts()` is the single place that decides whether to
@@ -2025,7 +2052,7 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
shape applied to one table and does not reach this one. Of the two operator TODO items that sat
beside it, `20_masthead_nav_polish` has landed in full (now complete): the bar is three
left-justified rows at every width, it is no longer sticky, the phone menu slides, and both
`⚙ Account` and `⚷ Certs` render on all eight signed-in pages including the two they name
`⚙ Account` and `⚷ Certs` render on all seven signed-in pages including the two they name
(task 04, reversing phase 15's "no self-link" rule at the operator's request). The other,
`24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's rollout, half of it),
is still in `todo/`.
+23 -12
View File
@@ -192,7 +192,9 @@ CA** block (CN, serial, expiry as a local timestamp *and* as time left, and **Ro
the **Dashboard client** line (the one certificate every agent verifies the dial against —
display-only, because it is minted with the root and has no route of its own), and one row per
host with its status chip, serial, expiry, issued-vs-deployed bundle revision, the addresses its
certificate is valid for, and **Rotate** / **Revoke** / **bundle**.
certificate is valid for, and **Rotate** / **Revoke** / **bundle**. The agents table carries the
stylesheet's own `scroll` modifier, so at phone width its right-hand columns — the serial, the
bundle revisions, the addresses and the controls — are reachable by scrolling instead of clipped.
Nothing there is coloured: `severity.py` rates no certificate, so an expiry is a date and a
bundle revision is a count. The one verdict on the page is the API's `status` word (`active` green,
@@ -491,7 +493,7 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
| `DASH_SESSION_HOURS` | session lifetime (default `720` = 30 days) |
| `DASH_COOKIE_SECURE` | `1` marks the session cookie `Secure`; off because dev is `http://127.0.0.1`, and a request that arrived over `X-Forwarded-Proto: https` is treated as secure either way |
| `DASH_PBKDF2_ITERATIONS` | verifier cost (default `600000`). The knob exists for the test suite; leave it alone in a deployment. Raising it on a real box does take effect: the next successful sign-in re-hashes a cheaper stored verifier and keeps the signing key |
| `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` has no `POLL_MS_*` of its own and polls at a fixed 30 s: its payload only moves when the `disks` job re-reads the agents |
| `POLL_MS_SYSTEM` / `POLL_MS_LLM` / `POLL_MS_UPS` | how often the *browser* refetches each page (10 s / 2 s / 5 s) — shown in the page footer. `/disks` has no `POLL_MS_*` of its own and polls at a fixed 30 s: its payload only moves when the `disks` job re-reads the agents. A backgrounded tab idles its polls and fires one on return; nothing an operator presses stops them (phase 21) |
| `DASH_RELOAD` | `1` makes `uv run dashboards` watch the source and restart on a change — development only, and every poll loop restarts with it |
| `DEBUGPY` / `DEBUGPY_PORT` | `DEBUGPY=1` opens a debugpy listener (default 5678) without blocking startup |
| `RETAIN_RAW_HOURS` / `RETAIN_MINUTE_DAYS` / `RETAIN_HOUR_DAYS` | history rollup and pruning |
@@ -644,12 +646,16 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
SVG charts (`charts.js`), and a masthead status dot per dashboard job (`disks` has its own:
the same agent can answer the 10-second utilisation poll and have a wedged SMART pass).
The masthead is **three left-justified rows at every width**: the brand and the five dashboard
tabs, then the per-source indicators on their own row, then the clock / `⏸ Pause` (on the four
pages that poll) / `⚷ Certs` / `⚙ Account` / `☾ Dark` controls on a row under those (a page with no
tabs, then the per-source indicators on their own row, then the clock / `⚷ Certs` / `⚙ Account`
/ `☾ Dark` controls on a row under those (a page with no
indicators — `/login`, `/settings`, `/certs` — renders two, and a control is never hidden to make a
narrower width work). Both admin links are on all eight
signed-in pages, the two pages they name included: the cluster is the same wherever you are, and a
link to the page you are already on is a harmless no-op rather than chrome that vanishes. The bar is **not sticky**: it scrolls away
narrower width work). There is no control that stops the polling (phase 21): the bar used to carry
a `⏸ Pause` button with a `Space` shortcut, and it left the numbers on screen looking current with
no `stale` label on them. Both admin links are on all seven
signed-in pages — the five dashboards plus `/settings` and `/certs`, the two they name included —
and not on `/login`, the eighth page and the only anonymous one: the cluster is the same wherever
you are, and a link to the page you are already on is a harmless no-op rather than chrome that
vanishes. The bar is **not sticky**: it scrolls away
with the page, so every pixel of height goes to the data (it stays a positioned element, which is
what keeps the mobile dropdown anchored under it). At ≤640px the five tab links leave the bar
and come back as a dropdown under the whole of it, opened by the `☰` button (`/login` renders no
@@ -674,11 +680,16 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
charging, dashed outline on battery, a dash through the body when there is no reading) and a
`<title>` / `aria-label` that names it ("apc: Charge 100 percent, on line") unless the number
beside it already does. Each `/system` host is one panel led
by exactly six of them — CPU %, MEM %, TEMP, DISK % (mount underneath), GPU busy (VRAM, the
APU pool and the card's temperature in the footnote — no temperature is printed when the
driver exposes no sensor, never `0 °C`; and likewise never coloured and with no bar: a busy
GPU is the job on an LLM server), and NET on two lines (↓ in / ↑ out, never coloured: a byte
rate has no good/bad). Under the numbers, **each panel charts only its own host** — behind a
by six hero slots — CPU %, MEM %, TEMP, DISK % (mount underneath), GPU busy, and NET on two
lines (↓ in / ↑ out, never coloured: a byte rate has no good/bad). A host with **no GPU**
renders **five**: the GPU slot is omitted rather than filled with `n/a`, because a router is
not broken for the card it never had. A GPU is one busy hero plus **four labelled values**
under it — **Model**, **VRAM** (`used / total`), **Pool** (the honest APU view) and **Temp**
— instead of the dense `model · VRAM · pool · °C` footnote that was 11px mono; a field the
agent did not publish prints `n/a` in its own cell, never `0 °C`, and nothing in the slot is
coloured or barred (a busy GPU is the job on an LLM server). Missing *numbers* still print
`n/a` uncoloured — gateway's CPU temp — which is a different fact from a missing *device*.
Under the numbers, **each panel charts only its own host** — behind a
collapsed **Charts** toggle, because the default view is current stats and a fleet of ten
hosts each drawing three plots is noise: the summary reads `▸ Charts` with the window in it
(`1h`, `24h` …), and opening it is what builds and paints the charts — instantly, from the
+1 -1
View File
@@ -82,7 +82,7 @@
<section class="card" style="margin-top:.85rem" aria-labelledby="agents-title">
<header><h2 id="agents-title">Agents</h2></header>
<div class="body tight">
<table class="data" id="agents-table">
<table class="data scroll" id="agents-table">
<thead>
<tr>
<th>Host</th><th>Status</th><th>Serial</th><th>Expires</th>
-1
View File
@@ -22,7 +22,6 @@
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
-1
View File
@@ -22,7 +22,6 @@
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
-1
View File
@@ -22,7 +22,6 @@
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
-1
View File
@@ -22,7 +22,6 @@
<div class="masthead-right">
<div id="nav-status" class="gap" aria-live="polite"></div>
<span class="clock" id="clock">--:--:--</span>
<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>
<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>
<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>
<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">☾ Dark</button>
+21 -24
View File
@@ -583,36 +583,35 @@ const Dash = (() => {
}
/**
* Poll `fn` every `ms`, skipping overlapping runs and pausing when the tab is hidden
* or the user hit pause. Errors are reported, never swallowed silently.
* Poll `fn` every `ms`, skipping overlapping runs and idling while the tab is hidden.
* Errors are reported, never swallowed silently.
*
* The hidden-tab skip is idle hygiene rather than something the operator holds: a backgrounded
* tab asks for nothing, and `visibilitychange` fires one poll on return. There is no
* operator-facing way to stop the loop any more (phase 21 task 01, `TODO.md:7`): the control
* that did left the numbers on screen looking current with no `stale` label on them, and
* `Space` tripped it from anywhere on the page.
*/
function poll(fn, ms) {
let stopped = false;
let timer = null;
let inFlight = false;
const state = {
get paused() { return state._paused; },
_paused: false,
interval: ms,
setInterval(next) { state.interval = next; if (timer) { clearTimeout(timer); tick(); } },
setPaused(next) {
state._paused = next;
if (next) { if (timer) clearTimeout(timer); timer = null; }
else if (!timer) tick();
},
stop() { stopped = true; if (timer) clearTimeout(timer); },
runNow() { tick(); },
};
async function tick() {
if (stopped || inFlight || state._paused || document.hidden) { schedule(); return; }
if (stopped || inFlight || document.hidden) { schedule(); return; }
inFlight = true;
try { await fn(); } finally { inFlight = false; schedule(); }
}
function schedule() {
if (stopped || state._paused) return;
if (stopped) return;
timer = setTimeout(tick, state.interval);
}
document.addEventListener('visibilitychange', () => { if (!document.hidden && !state._paused) tick(); });
document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); });
tick();
return state;
}
@@ -769,25 +768,23 @@ const Dash = (() => {
toastTimer = setTimeout(() => { host.remove(); host = null; }, 10000);
}
/** Pause button + Space, wired to a poll handle. */
function bindPause(poller) {
const button = $('#pause');
if (!button || !poller) return;
const paint = () => {
button.setAttribute('aria-pressed', poller.paused ? 'true' : 'false');
button.textContent = poller.paused ? '▶ Paused' : '⏸ Pause';
};
button.addEventListener('click', () => { poller.setPaused(!poller.paused); paint(); });
/**
* The one keyboard shortcut the dashboard pages offer: `T` toggles the theme.
*
* It stayed a per-page call rather than moving into `initChrome()`, which every page runs: its
* reach is what it was before phase 21 task 01 deleted the `Space` binding that shared this
* listener (`TODO.md:7`), and widening `T` to `/login` is not that task. The input guard is
* what keeps a keystroke typed into a field from being a shortcut.
*/
function bindShortcuts() {
document.addEventListener('keydown', (event) => {
const tag = (event.target && event.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || event.metaKey || event.ctrlKey) return;
if (event.code === 'Space') { event.preventDefault(); poller.setPaused(!poller.paused); paint(); }
if (event.key === 't' || event.key === 'T') {
const theme = $('#theme-toggle');
if (theme) theme.click();
}
});
paint();
}
/** The "last poll" line every card header shows. */
@@ -818,7 +815,7 @@ const Dash = (() => {
}
return { $, $$, fetchJSON, poll, fmtBytes, bytes, rate, pct, num, duration, age, setTxt,
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindPause,
setValue, setBar, setDot, chip, initChrome, rangeControl, toast, bindShortcuts,
staleness, markStale, severityFor, severityFrom, hero, ageSeconds, naText,
eventText, eventsPager, EVENT_PAGE_SIZE, EVENT_FEED_LIMIT,
// phase 11: the session this browser holds, the one call that fills it in, and the
+26 -5
View File
@@ -100,7 +100,7 @@ a:hover { text-decoration: underline; }
* — "the hosts disks engines and UPS indicators … left justified in their own row", "the
* ⚷ Certs ⚙ Account ☾ Dark buttons … in their own row under" them): row 1 the brand,
* `#nav-toggle` and the five tabs; row 2 the `#nav-status` reachability dots; row 3 the
* `.masthead-right` controls (clock, `⏸ Pause`, `⚷ Certs`, `⚙ Account`, theme).
* `.masthead-right` controls (clock, `⚷ Certs`, `⚙ Account`, theme).
*
* No markup changed to get them, and that decides the mechanism. `#nav-status` is the first
* child of `.masthead-right`, so there is no wrapper to promote: in a `flex-wrap: wrap`
@@ -173,7 +173,7 @@ nav.tabs a[aria-current="page"] { background: var(--accent-soft); color: var(--a
* what pinned this cluster to the right edge of the old single-row bar; in a row of its own it
* pushes against nothing, and `justify-content: flex-start` is the TODO's word for where the
* buttons go. `flex-basis: 100%` is the row break (see `.masthead` above) and `flex-wrap` is
* what a phone gets when clock + pause + two links + theme will not share a line — phase 18's
* what a phone gets when clock + two links + theme will not share a line — phase 18's
* rule, and no control is ever hidden to make the arithmetic work. */
.masthead-right {
flex-basis: 100%;
@@ -281,8 +281,8 @@ button.ghost:disabled { opacity: .45; cursor: not-allowed; }
* is `flex-wrap: wrap`) and `⚷ Certs` broke over two lines, because every item shrank to a
* fraction of its content. So the cluster may wrap, its items may neither shrink nor break a
* label, and the dots stay the one line that makes them a readout instead of a stack. Row 3
* still wraps *within itself*: `⏸ Pause`, `⚷ Certs`, `⚙ Account` and the theme button do not
* fit in 320px once over on the narrowest phones, and nothing here hides a control to make
* still wraps *within itself*: `⚷ Certs`, `⚙ Account` and the theme button do not fit in
* 320px once over on the narrowest phones, and nothing here hides a control to make
* the arithmetic work — a phone gets the same words every other width does. The row
* alignment is the base rule's `flex-start`, restated nowhere: phase 20 replaced the
* right-justified cluster, so this block only tightens the gap. */
@@ -737,8 +737,29 @@ main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
display: grid; gap: .9rem 1rem;
grid-template-columns: repeat(auto-fit, minmax(min(118px, 100%), 1fr));
}
/* a slot that holds more than one hero (one per GPU card) without breaking the row */
/* a slot that holds more than one thing (one GPU card per physical card) without breaking
the row; `hidden` — which `renderGpuSlot()` sets for a host with no card — beats the
`display` here through the `[hidden]` rule at the end of this sheet */
.hero-slot { display: grid; gap: .7rem; min-width: 0; align-content: start; }
/* One GPU: the busy hero, then its four labelled values (phase 21 task 02 — the dense
`model · VRAM · pool · °C` footnote in 11px mono is what TODO.md:12 asked for values of).
`[hidden]` at the end of this sheet is what removes a GPU-less host's slot from the
`.heroes` row entirely, so the row reflows to five rather than showing a wide `n/a`. */
.gpu-card { display: grid; gap: .3rem; min-width: 0; }
/* The four values: `.metric` shapes (label above value) at the same `.sm` size every other
secondary number on this page uses, in two columns on a wide slot and one on a narrow
one. `auto-fit`, not a fixed pair of tracks, because this grid lives inside one hero
track and the track's width is the fleet's business, not the stylesheet's. No colour
vocabulary at all (phase 06 has no `gpu.*` threshold row); `overflow-wrap` is the half
of TODO.md:12 that a 20-character model string wraps instead of truncating. The 150px floor
is measured, not taste: a hero track is ~180px wide on a three-host fleet at 1280px, and a
20-character model string in mono at `.sm` needs ~170px, so a narrower floor would put the
TODO's own string back on three lines. Wide slots get their 2×2; narrow ones stack 4×1. */
.gpu-values {
display: grid; gap: .3rem .6rem; min-width: 0;
grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
}
.gpu-values .value { overflow-wrap: anywhere; }
/* Network is one hero with two lines: in and out are one wire seen in two directions, and
two big numbers side by side read as a competition. It is deliberately colourless —
a byte rate has no good/bad, so no verdict ever paints here (severity.py has no net.*
+1 -1
View File
@@ -557,5 +557,5 @@ document.addEventListener('DOMContentLoaded', async () => {
['90d', 7776000]]);
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
Dash.bindPause(state.poller);
Dash.bindShortcuts();
});
+1 -1
View File
@@ -1033,5 +1033,5 @@ document.addEventListener('DOMContentLoaded', async () => {
});
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
Dash.bindPause(state.poller);
Dash.bindShortcuts();
});
+85 -37
View File
@@ -32,18 +32,23 @@ const state = {
// ---- host cards -----------------------------------------------------------
//
// Six heroes per host, in one fixed order: CPU, MEM, TEMP, DISK, GPU, NET. Every colour
// Six hero slots per host, in one fixed order: CPU, MEM, TEMP, DISK, GPU, NET. Every colour
// comes from the payload's `severity` dicts — `severity.py` owns the thresholds, this file
// compares no number to any bound — and `Dash.hero()` prints `n/a` with **no** colour
// class when the number or its verdict is missing, so gateway's absent CPU sensor cannot
// borrow last poll's green.
//
// The GPU slot is the one that can leave the row: a host with no card renders **five**
// heroes, not a sixth of `n/a`s (`renderGpuSlot`, TODO.md:12). What it does render is the
// busy hero plus four labelled values per card — Model / VRAM / Pool / Temp — because the
// dense ` · `-joined footnote they replaced was "too much info for such small text".
//
// `data-sev` names the verdict each slot paints; tests/test_api.py pins the six slots and
// their order on the served bytes. Two slots carry none on purpose. NET: `network` has no
// `severity` (a byte rate has no good/bad). GPU: `severity.THRESHOLDS` has no `gpu.*` row
// either — these are LLM servers, a 95 %-busy card is the job, so the GPU hero prints its
// numbers uncoloured at every level and carries no utilisation bar (phase 06; a bar under
// a verdictless number would be a verdict by shape).
// a verdictless number would be a verdict by shape), and neither does any of its four cells.
//
// Per-core usage is not plotted here or anywhere on this page — the series is still
// stored as `cpu.usage_pct@coreN`, the page just stopped drawing a pattern nobody reads.
@@ -69,7 +74,7 @@ function hostCard(name) {
<div class="hero hero-bar" data-k="mem" data-sev="memory.severity.used_pct"></div>
<div class="hero" data-k="temp" data-sev="cpu.severity.temp_c"></div>
<div class="hero hero-bar" data-k="disk" data-sev="disks.severity.worst_used_pct"></div>
<div class="hero-slot" data-k="gpus"></div>
<div class="hero-slot" data-k="gpus" hidden></div>
<div class="hero" data-k="net">
<div class="label">Net</div>
<div class="hero-lines">
@@ -134,59 +139,102 @@ function memorySub(mem) {
return `${bytes(mem.used_bytes, 1)} of ${bytes(mem.total_bytes, 0)}`;
}
/** Model, VRAM used / total, the honest APU pool view, and the card's temperature:
* `Radeon AI PRO R9700 · VRAM 30.5 GB / 31.9 GB · 43 °C`.
/** The four labelled values under one GPU's busy hero: **Model**, **VRAM**, **Pool**,
* **Temp** — `TODO.md:12`: "`Radeon 8060S Graphics · VRAM 180.2 MiB / 512.0 MiB · pool 79%
* · 69 °C` is too much info for such small text and should be its own values."
*
* A Strix Halo's model lives in GTT, so "VRAM 3%" and "pool 78%" are both true and the card
* shows both, not whichever flatters the machine.
* So each fact gets a label and a value of its own instead of one ` · `-joined sentence at
* 11px, and the model string wraps rather than truncating (the phase's readability complaint
* *is* the model string). Nothing was invented for this card: the four cells read
* `gpus[].model`, `vram_used_bytes`/`vram_total_bytes`, `mem_used_pct` and `temp_c` — the
* same paths `tests/card_contract.py` pins, the same `gpus[].temp_c` (the agent's own max
* over the card's hwmon labels, phase 06) that the `gpu.temp_c@N` history series stores, and
* the per-label breakdown still lives in "More detail".
*
* The temperature rides here because it is the one number worth glancing at *while* a card
* is busy (phase 06: "Pull GPU temp for the system resources tab where you can"). It is
* `gpus[].temp_c`, the agent's own max over the card's hwmon labels — no new field, no new
* metric name, and the per-label table in "More detail" still carries the breakdown.
* A card whose agent cannot read a sensor (an APU build with no hwmon input) prints no
* temperature at all: `n/a` is for numbers the card is supposed to have, and `0 °C` would
* be a cool GPU that does not exist. */
function gpuSub(gpu) {
const bits = [gpu.model || 'GPU'];
bits.push(gpu.vram_used_bytes == null || gpu.vram_total_bytes == null
? 'VRAM n/a'
: `VRAM ${bytes(gpu.vram_used_bytes, 1)} / ${bytes(gpu.vram_total_bytes, 1)}`);
if (gpu.mem_used_pct != null) bits.push(`pool ${num(gpu.mem_used_pct, 0)}%`);
if (gpu.temp_c != null) bits.push(`${num(gpu.temp_c, 0)} °C`);
return bits.join(' · ');
* A Strix Halo's model lives in GTT, so "VRAM 32 %" and "pool 79 %" are both true and the
* card shows both, not whichever flatters the machine.
*
* A field the agent did not publish prints `n/a` in its own cell — the card is real, so the
* number is one it is supposed to have (rule 2), and `0 °C` would be a cool GPU that does
* not exist. An absent *card* is the other fact and is handled by `renderGpuSlot()`, which
* omits the whole slot. No colour: `severity.THRESHOLDS` has no `gpu.*` row, so these cells
* print in plain ink at 0 % and at 100 % alike (phase 06). */
const GPU_VALUE_FIELDS = [
{ label: 'Model', field: 'model', mono: true },
// `31.3 GiB / 31.9 GiB`: used without total is the number that already has its own
// percentage on the card, so this cell is the only one that reads two keys.
{ label: 'VRAM', field: 'vram_used_bytes', total: 'vram_total_bytes' },
// the honest APU view — a Strix Halo keeps the model in GTT, so this and VRAM differ
{ label: 'Pool', field: 'mem_used_pct', unit: '%', digits: 0 },
// the agent's max over this card's hwmon labels, i.e. `gpu.temp_c@N` re-spoken
{ label: 'Temp', field: 'temp_c', unit: ' °C', digits: 0 },
];
/** Render the table above for one card: four `.metric` cells, `n/a` faint where the agent
* published nothing. No severity class on any of them (phase 06). */
function gpuValues(gpu) {
return GPU_VALUE_FIELDS.map((cell) => {
let text;
if (cell.total) {
text = gpu[cell.field] == null || gpu[cell.total] == null
? null
: `${bytes(gpu[cell.field], 1)} / ${bytes(gpu[cell.total], 1)}`;
} else if (gpu[cell.field] == null) {
text = null;
} else if (cell.unit) {
text = `${num(gpu[cell.field], cell.digits)}${cell.unit}`;
} else {
// the one free-text cell on the card, and it comes from a PCI device name
text = DashCharts.escapeHtml(String(gpu[cell.field]));
}
const missing = text == null;
return `<div class="metric"><div class="label">${cell.label}</div>`
+ `<div class="value sm${cell.mono ? ' mono' : ''}${missing ? ' is-null na' : ''}">${
missing ? 'n/a' : text}</div></div>`;
}).join('');
}
/** The GPU slot of the hero row: one hero per card, or the honest no-GPU state. A box
* with no card is not a box at 0% busy, and gateway must not look broken for it.
* No `severity` and no `bar` on purpose (phase 06): utilisation and VRAM carry no verdict
* on an LLM server, so the number renders uncoloured at 0 % and at 100 % alike. */
/** The GPU slot of the hero row: one busy hero plus its four labelled values per card.
*
* A host with **no** GPU renders nothing at all (`TODO.md:12` — "Boxes without a GPU should
* not report N/A but instead should omit these values"): the wrapper is emptied and
* `hidden`, which `[hidden] { display: none !important }` in `dash.css` takes out of the
* `.heroes` grid so the row reflows to five. A big `n/a` in the sixth slot made gateway
* look broken over hardware it never had, which is invariant 3's "unconfigured is not
* broken" on the one page that had not learned it yet. The wrapper element itself stays —
* the hero row's markup order remains one static list, a host whose agent starts reporting
* a card needs the slot to grow into, and an empty hidden element is zero footprint.
*
* No `severity` and no `bar` anywhere in here on purpose (phase 06): utilisation and VRAM
* carry no verdict on an LLM server, so every number in this slot renders uncoloured. */
function renderGpuSlot(cell, gpus) {
if (cell.dataset.count !== String(gpus.length)) {
cell.innerHTML = '';
for (let i = 0; i < Math.max(gpus.length, 1); i += 1) {
for (let i = 0; i < gpus.length; i += 1) {
const row = document.createElement('div');
row.className = 'hero';
row.className = 'gpu-card';
row.innerHTML = '<div class="hero"></div><div class="gpu-values"></div>';
cell.appendChild(row);
}
cell.dataset.count = String(gpus.length);
}
cell.hidden = gpus.length === 0;
const rows = Array.from(cell.children);
if (!gpus.length) {
Dash.hero(rows[0], { label: 'GPU', value: null, sub: 'No GPU reported by this agent' });
return;
}
gpus.forEach((gpu, i) => {
Dash.hero(rows[i], {
Dash.hero(rows[i].querySelector('.hero'), {
label: gpus.length > 1 ? `GPU ${i}` : 'GPU',
value: gpu.busy_pct, unit: '%', digits: 0,
sub: gpuSub(gpu),
});
// `gpuValues()` is four cells of text; rebuilding them is cheap and the hero above
// still patches in place, which is the part with the tabular-numeral layout to keep.
const values = rows[i].querySelector('.gpu-values');
const markup = gpuValues(gpu);
if (values.innerHTML !== markup) values.innerHTML = markup;
});
}
/** What the GPU hero left out, in "More detail": power and both memory pools, plus the
* per-label temperature breakdown behind the single number in the hero's footnote. */
/** What the four labelled values left out, in "More detail": power and both memory pools,
* plus the per-label temperature breakdown behind the single number in the GPU's Temp cell. */
function renderGpuExtra(cell, gpus) {
if (!gpus.length) { cell.innerHTML = ''; return; }
cell.innerHTML = `
@@ -605,5 +653,5 @@ document.addEventListener('DOMContentLoaded', async () => {
});
state.poller = poll(async () => { await refresh(); await refreshEvents(); }, state.pollMs);
Dash.bindPause(state.poller);
Dash.bindShortcuts();
});
+1 -1
View File
@@ -504,5 +504,5 @@ document.addEventListener('DOMContentLoaded', async () => {
}
state.poller = poll(refresh, state.pollMs);
Dash.bindPause(state.poller);
Dash.bindShortcuts();
});
+7 -4
View File
@@ -35,8 +35,10 @@ utilisation — the *absence* of the dict is pinned below, the way ``network``'s
``gpus[].temp_c`` is on the card because the number worth reading *while* a card is busy is
how hot it is (phase 06, TODO.md L9). It is the agent's **max over that card's hwmon
labels** — the same max the agent already computes and the same value stored as
``gpu.temp_c@N`` — so the hero prints a measurement rather than a new field, and the value
may honestly be ``None`` on a box whose driver exposes no temperature input.
``gpu.temp_c@N`` — so the card prints a measurement rather than a new field, and the value
may honestly be ``None`` on a box whose driver exposes no temperature input (phase 21 task
02 made it one of the card's four labelled values, and the GPU-less host that has no card at
all prints no values at all — see ``GPU_VALUE_CELLS`` in ``tests/test_api.py``).
"""
from __future__ import annotations
@@ -63,8 +65,9 @@ GPU_METRICS: tuple[str, ...] = (
"vram_used_pct",
# the honest APU view: vram + GTT, because a Strix Halo keeps the model in GTT
"mem_used_pct",
# the hero's footnote: the agent's max over this card's hwmon labels (None is legal —
# a driver with no temperature input must not be rendered as 0 °C)
# the Temp cell of the card's four labelled values (phase 21 task 02): the agent's max
# over this card's hwmon labels. None is legal — a driver with no temperature input is
# `n/a` in that cell and must never be rendered as 0 °C.
"temp_c",
)
+292 -17
View File
@@ -26,14 +26,17 @@ from fastapi.testclient import TestClient
import store_perf
from auth_client import sign_in
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
from card_contract import HOST_SHAPES, assert_card_contract
from dashboards import alerts, auth, certs
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range
from dashboards.app import create_app
from dashboards.collectors import UpstreamError
from dashboards.config import Settings, Target
from dashboards.rates import RateEstimator
from dashboards.severity import classify
from dashboards.sources import nut
from dashboards.sources.disks import SMART_SERIES, DisksCollector, parse_disks_snapshot
from dashboards.sources.host import HostCollector, parse_host_snapshot
from dashboards.sources.llm import (
HISTORY_METRICS,
parse_gufo_metrics,
@@ -999,7 +1002,8 @@ def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> N
def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
"""`⚙ Account` and `⚷ Certs` are the only way to the two admin pages, so they are everywhere.
One loop over all eight signed-in pages, because the controls are now the *same* on all of
One loop over all seven signed-in pages (`MARKUP_PAGES`), because the controls are now the *same*
on all of
them: clock → `⚷ Certs` → `⚙ Account` → theme toggle, in `.masthead-right` and **outside**
`.tabs` (the tab strip's structure — five dashboards, one of them current — is pinned by
`test_the_disks_tab_is_in_the_masthead_of_every_page`, and a sixth entry with no
@@ -1015,7 +1019,7 @@ def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
certs_open = '<a class="ghost account" href="/certs"'
# the opening tag **and** its label: the claim is the cluster does not change shape between
# pages, and `/certs` shipped a theme toggle missing its `aria-pressed` for exactly the
# reason nothing ever compared the eight markups
# reason nothing ever compared the seven markups
account = f'{account_open} title="Session and password">⚙ Account</a>'
certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>'
theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">'
@@ -1099,6 +1103,25 @@ def test_the_certs_page_carries_the_authority_and_the_agents_table(client) -> No
assert "/api/certs/root/rotate" in js
def test_the_certs_agents_table_scrolls_to_its_last_column(client) -> None:
"""`#agents-table` carries the `.scroll` modifier, and the stylesheet still styles it.
`TODO.md:9`: at phone width the serial, expiry, `san` and controls columns were clipped by
`.card { overflow: hidden }` with no way to reach them. `dash.css` turns `table.data` into
a scroll container **only** through the `scroll` class, so the class and the rule are one
mechanism and are pinned together — the phase-18 idiom of comparing the two places a
contract lives, because a rename on either side silently un-scrolls the table.
"""
html = client.get(CERTS_PAGE).text
assert '<table class="data scroll" id="agents-table">' in html, \
"the agents table lost its .scroll modifier — the controls column is unreachable at 360px"
css = client.get("/static/dash.css").text
start = css.index("table.data.scroll {")
rule = css[start:css.index("}", start)]
assert "overflow: auto" in rule, "table.data.scroll no longer scrolls — the class names nothing"
def test_the_certs_page_prints_the_names_the_authority_mints(client) -> None:
"""The CN of each certificate is markup, so the literal is pinned to `certs.py`.
@@ -1427,42 +1450,212 @@ def test_system_page_colours_from_the_payload_not_from_js_thresholds(client) ->
assert "hero-bar" not in gpu_slot, "the GPU hero rebuilt the bar variant of .hero"
def test_gpu_hero_footnote_prints_the_temperature_the_agent_read(client) -> None:
"""TODO.md L9: the GPU temperature is glanceable on the hero row, not only in the table.
#: Every cell of the `/system` GPU card's value grid, in render order, with the `gpus[]`
#: key(s) it prints. This is the `SLOT_FIELDS` device applied to the four values phase 21
#: task 02 created: the table is read out of the served `GPU_VALUE_FIELDS` and resolved
#: against every captured host shape *and* against `/api/system/snapshot`, so a rename on
#: either side fails a test instead of printing `n/a` beside a healthy fleet — and so the
#: dense ` · `-joined sub-line the TODO complained about cannot come back.
GPU_VALUE_CELLS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Model", ("model",)),
("VRAM", ("vram_used_bytes", "vram_total_bytes")),
("Pool", ("mem_used_pct",)),
("Temp", ("temp_c",)),
)
`gpus[].temp_c` is the agent's max over that card's hwmon labels (`sources/host.py`,
pinned by `tests/test_system_contract.py`), so the hero needed no new field and the
`gpu.temp_c@N` history series kept its name. The null guard *is* the behaviour, so that
is what the assertion pins (there is no JS test runner — a locked anchor): a card whose
driver exposes no temperature prints none, because `n/a` is for numbers the card is
supposed to have and a `0 °C` would be a cool GPU that does not exist.
def _gpu_value_fields(js: str) -> list[tuple[str, tuple[str, ...]]]:
"""The served `GPU_VALUE_FIELDS` table as `(label, payload keys)` rows."""
table = re.search(r"const GPU_VALUE_FIELDS = \[(.*?)\n\];", js, re.S)
assert table, "page-system.js no longer has a GPU_VALUE_FIELDS table"
rows: list[tuple[str, tuple[str, ...]]] = []
for entry in re.findall(r"\{[^{}]*\}", table.group(1)):
label = re.search(r"label: '(\w+)'", entry)
field = re.search(r"field: '(\w+)'", entry)
assert label and field, f"a GPU value cell names no label or field: {entry!r}"
total = re.search(r"total: '(\w+)'", entry)
rows.append((label.group(1),
(field.group(1), total.group(1)) if total else (field.group(1),)))
return rows
def test_gpu_values_are_four_labelled_cells_not_a_dense_sub_line(client) -> None:
"""`TODO.md:12` — "… is too much info for such small text and should be its own values."
The GPU slot used to print `Radeon 8060S Graphics · VRAM 180.2 MiB / 512.0 MiB · pool
79% · 69 °C` as one `.sub` line in 11px mono. It is now four labelled cells, and the
pin is bidirectional: the table has the four rows in order, and the joined-sentence
builder that produced the TODO's quoted string is gone (`gpuSub` and its
`bits.join(' · ')` are the thing that made the card unreadable).
Two invariants ride on the same slice. The model string is upstream text (a PCI device
name), so it leaves through `DashCharts.escapeHtml`; and the temperature keeps its
null guard — `0 °C` would be a cool GPU that does not exist (phase 06).
"""
js = client.get("/static/page-system.js").text
sub = js[js.index("function gpuSub("):js.index("function renderGpuSlot(")]
assert "gpu.temp_c" in sub, "the GPU hero stopped printing the temperature"
assert "if (gpu.temp_c != null)" in sub, "the temperature lost its null guard"
assert sub.count("°C") == 1, (
"a second °C in gpuSub is a branch printing a temperature nobody measured"
)
assert _gpu_value_fields(js) == list(GPU_VALUE_CELLS), \
"the GPU value grid is no longer Model / VRAM / Pool / Temp over those payload keys"
# the °C rides in the value (the label is the word, as on the /disks card and the GPU
# detail table), and it appears exactly once in the table: one cell, one unit.
assert js.count("unit: ' °C'") == 1, "the Temp cell's unit is not the one °C on the card"
body = js[js.index("function gpuValues("):js.index("function renderGpuSlot(")]
assert "GPU_VALUE_FIELDS.map(" in body, "gpuValues() stopped rendering the table"
assert "escapeHtml" in body, "the Model cell prints an upstream string unescaped"
assert "== null" in body, "a cell lost its null guard (missing must print n/a, not 0)"
# the dense sub-line the TODO quoted is gone, container and all
assert "function gpuSub(" not in js, "gpuSub() came back"
assert "join(' · ')" not in js, "the ` · `-joined GPU footnote came back"
# "More detail" keeps the per-label breakdown: a surface change, not a de-duplication
extra = js[js.index("function renderGpuExtra("):js.index("function renderHostCard(")]
assert "Temp" in extra and "gpu.temp_c" in extra, "GPU detail lost its temperature column"
def test_gpu_value_cells_resolve_against_every_captured_host(
agent_snapshots: dict[str, dict[str, Any]]) -> None:
"""Every key the four cells print exists on every captured card — discrete GPU and APU.
`tests/card_contract.py` owns the payload table; this resolves the *page's* table
against the same three captures, which is where a rename would otherwise be caught by
an operator looking at `n/a` next to a healthy machine. `temp_c` may legitimately be
`None` (a driver with no hwmon input) — present is the contract, and the cell prints
`n/a` for it, never `0 °C`.
"""
for name in HOST_SHAPES:
payload = parse_host_snapshot(name, agent_snapshots[name], HostCollector(
RateEstimator(span=60.0)), now=1_000.0).payload
assert_card_contract(payload)
for gpu in payload["gpus"]:
for label, keys in GPU_VALUE_CELLS:
for key in keys:
assert key in gpu, f"{name}: the {label!r} cell has no {key!r} key"
if not payload["gpus"]:
# gateway: the omission's input. An empty list is a *fact* about the box, and
# `renderGpuSlot()` turns that fact into an absent slot rather than an `n/a`.
assert name == "gateway", "a capture lost its GPU row"
def test_the_gpu_value_cells_are_served_for_the_hosts_that_have_a_card(
tmp_path: Path, agent_snapshots: dict[str, dict[str, Any]]) -> None:
"""The same four keys over HTTP, on the body `/system` actually renders.
Parsing in-process proves the source; this proves the endpoint. One app with all three
hosts injected (dead ports, so no poll can overwrite what the test put there) answers
with the discrete-GPU card, the APU card, and gateway's empty list — the exact union
`renderGpuSlot()` branches on: cards present means four labelled values per card, `[]`
means no slot at all.
"""
db = str(tmp_path / "gpu-cells.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2,
host_targets=tuple(
Target(name, DEAD_URL) for name in HOST_SHAPES)), store)
test_client = TestClient(app)
test_client.__enter__()
try:
sign_in(test_client)
hub = app.state.ctx.hub
for name in HOST_SHAPES:
hub.jobs[f"host:{name}"].state.payload = parse_host_snapshot(
name, agent_snapshots[name], HostCollector(RateEstimator(span=60.0)),
now=1_000.0).payload
hosts = {row["name"]: row for row in test_client.get("/api/system/snapshot").json()["hosts"]}
assert set(hosts) == set(HOST_SHAPES)
for name, row in hosts.items():
for gpu in row["gpus"]:
for label, keys in GPU_VALUE_CELLS:
for key in keys:
assert key in gpu, f"{name}: /api/system/snapshot has no {key!r} " \
f"for the {label!r} cell"
assert hosts["gateway"]["gpus"] == [], \
"gateway must answer [] — that is what makes the page omit the slot"
assert hosts["driveripper"]["gpus"] and hosts["deskwork"]["gpus"]
finally:
test_client.__exit__(None, None, None)
store.close()
def test_a_host_with_no_gpu_omits_the_gpu_slot_instead_of_printing_n_a(client) -> None:
"""`TODO.md:12` — "Boxes without a GPU should not report N/A but instead should omit
these values."
gateway has no card, and the sixth hero slot used to say so with a big faint `n/a` plus
the sentence "No GPU reported by this agent" — invariant 3's complaint in the one place
the page had not learned it. `renderGpuSlot()` now empties the slot and sets `hidden`,
and `[hidden] { display: none !important }` at the end of `dash.css` is what takes the
wrapper out of the `.heroes` grid so the row reflows to five. Pinned on the served
bytes (no JS runner — a locked anchor) and measured in a browser by
`data/logs/21-task02-gpu-values.cjs`.
The wrapper element itself stays in `hostCard()`: the hero row remains one static list
of six `data-k` slots (pinned by the test below), a host whose agent starts reporting a
card has something to grow into, and an empty hidden element is zero footprint.
"""
js = client.get("/static/page-system.js").text
slot = js[js.index("function renderGpuSlot("):js.index("function renderGpuExtra(")]
assert "cell.hidden = gpus.length === 0" in slot, \
"the GPU slot no longer hides itself when the host has no card"
assert "No GPU reported" not in js, "the no-GPU n/a sentence came back"
assert "n/a" not in slot, "the GPU slot grew its own n/a fallback again"
# and the row itself keeps the six static slots, so a GPU host still has somewhere to go
assert "display: none !important" in client.get("/static/dash.css").text, \
"[hidden] stopped beating .hero-slot's display, so a hidden slot would still " \
"occupy a hero track"
def test_gpu_value_grid_ships_no_verdict_and_wraps(client) -> None:
"""The four new cells colour nothing and let a long model string wrap.
Phase 06's rule extends to them: `severity.THRESHOLDS` has no `gpu.*` row, the payload
carries no `gpus[].severity`, so neither the markup nor the grid may reach for
`--ok`/`--warn`/`--fail` or a `severity` option (a busy GPU on an LLM server is the job).
`overflow-wrap` is the other half of `TODO.md:12` — the readability complaint *is* the
20-character model string, and truncating it would keep the complaint.
"""
js = client.get("/static/page-system.js").text
# word-boundary on purpose: the docstring in this slice is allowed to *say* "severity",
# the code is not allowed to pass one (or a bar, or a verdict class) to anything
markup = js[js.index("const GPU_VALUE_FIELDS"):js.index("function renderGpuSlot(")]
verdicts = re.findall(r"\b(ok|warn|fail|severity:|bar:|hero-bar)\b", markup)
assert not verdicts, f"the GPU value grid grew a verdict: {verdicts}"
css = client.get("/static/dash.css").text
rule = re.search(r"\.gpu-values \{([^}]*)\}", css)
assert rule, "/system lost its .gpu-values grid"
assert "auto-fit" in rule.group(1), ".gpu-values stopped adapting to the hero track"
wrap = re.search(r"\.gpu-values \.value \{([^}]*)\}", css)
assert wrap and "overflow-wrap" in wrap.group(1), \
"a long GPU model string truncates instead of wrapping"
for token in ("--ok", "--warn", "--fail"):
assert token not in rule.group(1) + (wrap.group(1) if wrap else ""), \
f"the GPU value grid coloured itself with {token} (severity.py rates no GPU)"
def test_system_host_card_is_six_heroes_without_per_core(client) -> None:
"""Each host card leads with exactly six heroes: CPU, MEM, TEMP, DISK, GPU, NET.
"""Each host card ships six hero slots in order: CPU, MEM, TEMP, DISK, GPU, NET.
Asserted on the served script because there is no JS test runner (a locked anchor).
The two dense `.metrics` rows and the per-core heat bars are exactly what made the
card unreadable, and "no per-core anywhere on /system" is a promise a stray
`renderCores` call would silently break — the series stays stored, the page just
stopped drawing it (PLAN.md *Dropped visuals*).
Six **slots**, not six visible heroes: the GPU slot ships `hidden` and
`renderGpuSlot()` un-hides it only for a host whose payload has a card, so gateway's
card renders five (pinned by the test above and measured in the browser by
`data/logs/21-task02-gpu-values.cjs`). The static order below is what makes that a
one-line rule instead of a re-flowing template.
"""
js = client.get("/static/page-system.js").text
card = js[js.index("function hostCard("):js.index("function renderHostCard(")]
slots = re.findall(r'<div class="(?:hero|hero-slot)[^"]*" data-k="([^"]+)"', card)
assert slots == ["cpu", "mem", "temp", "disk", "gpus", "net"], \
"the hero row is no longer the six numbers in the agreed order"
assert re.search(r'data-k="gpus"[^>]*hidden', card), \
"the GPU slot is visible before the payload says this host has a card"
# per-core visualisation is gone from the card code entirely
assert "cores" not in card, "a per-core visual came back to the card"
assert "renderCores" not in js and "core_usage" not in js
@@ -4459,6 +4652,88 @@ def test_the_menu_snaps_in_both_directions_under_reduced_motion(client) -> None:
"the named open rule stopped stilling the transition"
# ---- the masthead with no polling control (phase 21 task 01) -------------- #
#
# `TODO.md:7` — "Completely remove the pause button". The control was four things: a masthead
# button on the four dashboards, a `Space` binding, the `setPaused`/`_paused` plumbing inside
# `Dash.poll()`, and a stylesheet comment naming it as a row-3 tenant. A browser is the only
# thing that can prove the operator no longer sees a button, but the served bytes are what ship
# it, so those are what fail a test when someone puts it back.
#
# The **hidden-tab** skip is deliberately *not* part of the removal and is pinned on the same
# page for that reason: a backgrounded tab asking for nothing is idle hygiene rather than a
# decision the operator made, and a poll loop "simplified" back to an unconditional `setTimeout`
# would be invisible until a laptop battery complained.
#: Every spelling the deleted control ever had, in markup, script or stylesheet.
PAUSE_ARTIFACTS = ('id="pause"', 'name="pause"', "⏸", "▶ Paused",
"bindPause", "setPaused", "_paused")
def test_no_page_serves_a_button_that_stops_the_polls(client, tmp_path: Path) -> None:
"""Eight pages, eleven assets, zero pause control.
`/login` needs its own anonymous `TestClient` for the reason every other login-page audit
gives: this `client` is signed in, and a signed-in `GET /login` is a `303` to the dashboard,
which would audit `/`'s bytes and call them `/login`. The other seven are read as themselves.
"""
bodies = {path: client.get(path).text for path in MARKUP_PAGES}
db = str(tmp_path / "no-pause-login.sqlite3")
store = SampleStore(db)
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
pbkdf2_iterations=FAST_PBKDF2), store)
try:
with TestClient(app) as anon:
response = anon.get("/login")
assert response.status_code == 200, \
"a signed-out /login must be the form, not a bounce"
bodies["/login"] = response.text
finally:
store.close()
assert len(bodies) == 8, "the audit lost a page; every served page owes this check"
for path, html in bodies.items():
for artifact in PAUSE_ARTIFACTS:
assert artifact not in html, f"{path} serves the removed control again: {artifact!r}"
for asset in ASSETS:
body = client.get(asset).text
for artifact in PAUSE_ARTIFACTS:
assert artifact not in body, \
f"{asset} still carries the removed control: {artifact!r}"
def test_the_poll_loop_still_idles_on_a_hidden_tab_and_binds_no_key_for_the_old_control(
client,
) -> None:
"""What survives the deletion, pinned beside what must not come back.
The first block is the hidden-tab behaviour the phase keeps (`document.hidden` skips the run
and reschedules; `visibilitychange` fires one poll on return). The second is the `Space`
binding: no `event.code` check may live in the shared script, because a key that stops the
numbers with no control on screen to say so is the failure mode this task exists to retire.
The third is the half of the old listener that was never about pausing — `T` still toggles
the theme, on the same four pages it always did.
"""
app_js = client.get("/static/app.js").text
loop = app_js[app_js.index("function poll(fn, ms) {"):app_js.index(" // ---- chrome")]
assert "if (stopped || inFlight || document.hidden) { schedule(); return; }" in loop, \
"a hidden tab no longer skips its poll — the idle-hygiene guard is not the button"
assert "document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); })" in loop, \
"returning to a backgrounded tab no longer fires a poll — the page would sit on old numbers"
assert "event.code" not in app_js, \
"a key-code binding is back in the shared script — the only one it carried was the deleted control's"
assert "'Space'" not in app_js, "the deleted control's keyboard shortcut came back"
assert "event.key === 't' || event.key === 'T'" in app_js, \
"`T` stopped toggling the theme: the shortcut that shared the deleted binding survives"
for name in ("page-system.js", "page-disks.js", "page-llm.js", "page-ups.js"):
assert "Dash.bindShortcuts();" in client.get(f"/static/{name}").text, \
f"{name} no longer opts in to the theme shortcut it always had"
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
"""`disks` gets its own dot instead of hiding inside `hosts`.
+8 -5
View File
@@ -150,8 +150,9 @@ def test_the_discrete_gpu_capture_supplies_real_numbers(
assert gpu["busy_pct"] == pytest.approx(snapshot["gpus"][0]["busy_pct"])
assert gpu["vram_used_pct"] == pytest.approx(
gpu["vram_used_bytes"] / gpu["vram_total_bytes"] * 100.0)
# the hero's footnote is the agent's own max over this card's hwmon labels (phase 06),
# so the card and the `gpu.temp_c@N` history series can never disagree
# the Temp cell is the agent's own max over this card's hwmon labels (phase 06, printed
# as one of the card's four labelled values since phase 21 task 02), so the card and the
# `gpu.temp_c@N` history series can never disagree
assert gpu["temp_c"] == max(snapshot["gpus"][0]["temps"].values())
# one scrape is not a rate: usage and throughput are honestly n/a until the second
@@ -243,10 +244,12 @@ def test_the_apu_reports_vram_and_the_whole_addressable_pool(
def test_a_gpu_with_no_temperature_input_reports_none_not_zero(agent_snapshot: dict[str, Any],
) -> None:
"""Phase 06 put `gpus[].temp_c` on the hero footnote, so its absence must stay honest.
"""Phase 06 put `gpus[].temp_c` on the card and phase 21 task 02 gave it its own Temp
cell, so its absence must stay honest.
A driver exposing no hwmon temperature has to come back as `None` (the page prints no
temperature at all) rather than `0 °C`, the same rule
A driver exposing no hwmon temperature has to come back as `None` (the cell prints `n/a`,
the same faint `n/a` every other unmeasured value gets — the *host* with no card at all
is the other fact, and that card is omitted) rather than `0 °C`, the same rule
`tests/test_host_source.py::test_missing_sensors_stay_missing` holds for the flat sensor
list — and no `gpu.temp_c@N` sample may be written for a number nobody measured.
"""