Compare commits

..
3 Commits
Author SHA1 Message Date
ducoterra 95df8b695b phase: 19_ups_events_expandable
**Phase 19 final verification pass — all tasks already complete; everything green, no code changes needed.**

- **Verified (all 4 task files present in `complete/`)**: `details.events-box` disclosure on `/ups` with no `open` attribute and `#events` inside it; lazy fetch (`if (state.events.open) await refreshEvents()`), toggle stale rule on `state.pollMs`, success-path-only `painted`/`fetchedAt`; `charts-box`-shaped CSS with the `> .body` padding restatement; 4 new served-byte pins in `tests/test_api.py`.
- **Criterion 1 (collapsed on load, no first-poll fetch)**: PASS — re-ran `data/logs/19-task04-verify.cjs` against the live dev server (real upsd, 2 UPSes): **28/28 PASS**, `0` `/api/events` requests across 2 closed polls vs 4 snapshot polls; expand → exactly 1 request, 50 rows with `Oct 20, 07:11` timestamps + severity chips + `1–50 of 200` pager.
- **Criterion 2 (cache/stale/poll-while-open)**: PASS — fresh re-open 2→2 requests, stale re-open 2→3, loop refreshes while open, close asks for nothing, failed fetch keeps rows and doesn't stamp the cache.
- **Criterion 3**: `uv run pytest` → **1458 passed, 15 skipped**; `uv run pytest --cov` → **98.94%** (floor 94, unchanged); `uv run ruff check src tests agent` → clean; `uv run pyright` → **0 errors**.
- **Criterion 4**: `bash .agents/validate.sh` → **exit 0** (incl. `node --check` of all page scripts + repo-hygiene gates).
- **Criterion 5**: `data/logs/ups-events-closed.png` (collapsed `▸ POWER EVENTS`, 0 events requests noted in `ups-events-verify.out`) and `ups-events-open.png` (`▾`, bounded 320px list, pager strip) regenerated and inspected this pass.
- **Criterion 6 (no regressions)**: full suite green incl. UPS contract/pager pins; `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**; phase 18's `nav-final-pass.cjs` sweep re-run → **45/45** (this phase's markup/CSS didn't touch the masthead).
- **Documentation**: full README.md (980 L) + AGENTS.md (1992 L) review against the code — verified env templates vs `config.py`, API route table vs `api.py`, installer flags vs `deploy/install-agent.sh`, agent routes vs `host_agent.py`, test/coverage/live tallies, `data/logs` artifacts, and live fleet facts (`HOST_TARGETS` all `http://`, all three agents `version 1.0.0`, nine sources `ok`). AGENTS.md UI conventions and README's events paragraph already carry the disclosure/lazy/stale sentences from tasks 01–04. **Verified consistent — no changes needed.**
- **Notable**: made zero repo edits (working tree holds only this phase's task work). `uv pip list --outdated` couldn't complete (PyPI 503 for the project name) — no dependency moved this pass, so rule 8 is not triggered; validate.sh's earlier advisory still lists `fastapi 0.153.0 → 0.156.3`, deliberately not upgraded in a UI/verification phase.
- **Next pending phase**: `.agents/phases/todo/20_masthead_nav_polish/` (tasks 01–04).
2026-10-20 19:17:22 -04:00
ducoterra f9b1565abb chore(agent): phase roadmap from TODO.md, 5 phases
Convert the 11 TODO items to phases 20-24 (masthead/nav polish,
page cleanups, /disks hiding unconfigured hosts, LLM rate window,
dev mTLS rollout). TODO.md cleared; items now live in
.agents/phases/todo/.

LLM-Generated: true
2026-10-20 18:06:46 -04:00
ducoterra 10f86772ee phase: 18_nav_hamburger
**Phase 18 — collapse the nav bar into a hamburger: complete, all green.**

Verified (criteria):
- ≤640px: the tab links leave the bar, the ☰ is visible, and it opens a dropdown
  under the sticky masthead (`top: 100%`, `z-index: 41`, viewport-wide) with the
  five stacked 40px links; above 640px the one-row desktop masthead is unchanged
  and the button hidden — `data/logs/nav-task04-verify.cjs` 24/24 and
  `data/logs/nav-final-pass.cjs` 45/45; screenshots
  `data/logs/nav-hamburger-{closed,open}-360.png`, `data/logs/nav-masthead-desktop-1280.png` ✓
- The four close paths: the button again, a tab click (navigates and closes),
  Esc (focus back on the opener), and `matchMedia` resize back above the
  breakpoint (drops a stale `.is-open`; `aria-expanded` tracks state on every
  path through the single `setNavMenu()` funnel) ✓
- `uv run pytest` → 1454 passed, 15 skipped; `bash .agents/validate.sh` → exit 0
  (coverage 98.94% ≥ 94, ruff clean, pyright 0 errors, `node --check` + hygiene
  clean); served-page pins: button on all seven tabbed pages and nowhere on
  /login, CSS and binding compared on the 640px literal ✓

Real-device finding (mobile Chrome): the emulated 360px pass was blind to the
system top-edge zone — Android Chrome is edge-to-edge by default, so the sticky
bar started under the status bar and the first-row ☰ sat in a region the device
does not deliver taps to (unreachable, not unbound; the same symptom brain of
reese fixed in production). The ≤640px block now pads the bar by
`env(safe-area-inset-top, 0px)` (the block's `padding-block` value restated on
the top side), verified on the operator's phone; `env()` resolves to 0 on every
non-notch surface, so all other widths render byte-identical. The served-byte
pin's rule loop carries the line; CDP's `safeAreaInsets` override does not feed
`env()`, which is the honest limit of the emulated proof.

Docs: AGENTS.md *UI conventions* masthead entry extended with the three pins,
the drift-comparison, the safe-area rule, and the real-device verification
caveat; README's `web/` bullet names the ≤640px collapse with the desktop
masthead unchanged above the breakpoint.

Deviations: pinned the shipped token shape (`#nav-toggle { display:
var(--nav-toggle-display) }` + the `:root` token) instead of the task file's
literal `.nav-toggle { display: none }` — that literal loses to
`button.ghost { display: inline-flex }` (task 01 measured it); the test
docstring says so, so nobody "restores" the broken shape. `uv pip list
--outdated` still advisory (fastapi 0.153.0 → 0.156.2); this phase moved no
dependency. NOTES.md (emptied) / TODO.md (operator items added) are
harness-side changes — left in the working tree, as in phase 17.

Next pending phase: `.agents/phases/todo/19_ups_events_expandable/`.

LLM-Generated: true
2026-10-20 16:47:10 -04:00
82 changed files with 2056 additions and 19 deletions
@@ -0,0 +1,36 @@
# Phase 20 — Masthead & navigation polish
**Source:** `TODO.md` L4, L5, L6, L10, L11 — "masthead row layout, un-sticky bar, hamburger animation, always-visible admin links"
**Story:** n/a
**Context:** `src/dashboards/web/static/dash.css` (`.masthead` at line 99 — `position: sticky; top: 0; z-index: 40; display: flex; height: var(--header-h)`; `.masthead-right` at line 148 — `margin-inline-start: auto`; `#nav-toggle` at line 190 — the `--nav-toggle-display` token reveal; the ≤640px block at lines 208–280 — masthead wrap, `masthead-right { justify-content: flex-end }`, the `nav.tabs` dropdown at `top: 100%` under the sticky bar, the `env(safe-area-inset-top, 0px)` padding line), `src/dashboards/web/static/app.js` (the `setNavMenu()` funnel at lines 695–722 — four close paths, `matchMedia('(max-width: 640px)')`), `src/dashboards/web/pages/*.html` (the masthead in every page: brand, `#nav-toggle`, `nav.tabs#nav-tabs`, `.masthead-right` holding `#nav-status` + clock + `⚷ Certs` + `⚙ Account` + theme toggle; `certs.html` lacks `⚷ Certs`, `settings.html` lacks `⚙ Account` — the phase-15 "no self-link" rule this phase inverts), `tests/test_api.py` (`test_every_page_links_to_both_admin_pages` at line 999, `test_the_disks_tab_is_in_the_masthead_of_every_page` at line 3913, `test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page` at line 3957)
## Objective
The masthead becomes a three-row bar at **all widths** — row 1 the brand and the five dashboard tabs, row 2 the per-source status indicators left-justified in their own row, row 3 the `⚷ Certs` / `⚙ Account` / `☾ Dark` controls left-justified in their own row under the indicators — the bar stops being sticky (it scrolls away, maximising data screen space), the hamburger menu slides out and back in (180 ms, the `brain_of_reese` pattern, stilled under `prefers-reduced-motion`), and both admin links stay visible on every page including the two admin pages themselves. The served page markup is unchanged except the two added admin links; the tab structure (five links, one `aria-current`), the `#nav-toggle` token reveal, the safe-area padding line and the four close paths all stay exactly as phase 18 pinned them.
## Dependencies
- `19_ups_events_expandable` (todo) — both phases touch `ups.html` (the masthead, the events card); landing 19 first keeps this phase's diff confined to the masthead.
## Tasks
1. `01_two_row_masthead.md` — the three-row left-justified masthead at all widths (CSS-only reflow; the mobile block re-cut for the new rows)
2. `02_unsticky_masthead.md` — the bar scrolls away: `sticky` → `relative`, the dropdown anchor kept
3. `03_hamburger_slide.md` — the 180 ms slide+fade on the ≤640px dropdown, stilled under `prefers-reduced-motion`
4. `04_admin_links_visible.md` — `⚷ Certs` on `/certs`, `⚙ Account` on `/settings`; the "no self-link" pin inverted to "all eight pages link to both"
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the served-page pins (task 04's rewritten admin-link test over all eight non-login pages, the unchanged tab-structure pins at lines 3913/3957, the CSS pins for the row layout and the un-sticky change), the unchanged `setNavMenu` behaviour
- Coverage: **>90%** on new/modified code (the phase is markup + JS + CSS; the gate applies to the changed Python test files)
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. AGENTS.md's masthead paragraph (the phase-18 "specificity ladder" and the `top: 100%`-under-the-sticky-bar wording) and the phase-15 "neither admin page links to itself" sentence (gap 12's "now questioned" item — this phase is the decision) both get rewritten to the new state in the section's voice.
## Completion Criteria
- [ ] at 1280 px and at 360 px, every tabbed page and both admin pages render the three-row masthead: brand + tabs, left-justified indicators row, left-justified controls row under it (screenshots in `data/logs/`, invariant 10)
- [ ] scrolling any page at 1280 px and 360 px leaves the bar behind (no sticky offset), while the mobile dropdown still opens directly under the bar
- [ ] at 360 px the hamburger menu slides out and back in (180 ms); with `prefers-reduced-motion` it snaps
- [ ] `/certs` renders `⚷ Certs` and `/settings` renders `⚙ Account`, before the theme toggle and outside `.tabs`, exactly like the five dashboards
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green (including `node --check` of the modified `app.js`)
- [ ] no behavior change in completed work: the tab structure, the token reveal, the safe-area padding line, the four close paths, the status dots and the theme toggle are all unchanged
@@ -0,0 +1,27 @@
# Task 01 — The three-row left-justified masthead, at all widths
**Phase:** `20_masthead_nav_polish` · **Source:** `TODO.md:5,6` — "The hosts disks engines and UPS indicators should be left justified in their own row" / "The ⚷ Certs ⚙ Account ☾ Dark buttons should be left justified in their own row under the hosts disks engines UPS indicators"
**Story:** n/a
## Objective
The single-row masthead becomes a three-row bar at **every** width: row 1 keeps the brand, `#nav-toggle` and the five dashboard tabs; row 2 is the `#nav-status` indicator row (the per-source dots), left-justified and on its own; row 3 is the `.masthead-right` cluster (clock, `⚷ Certs`, `⚙ Account`, `☾ Dark` — plus the `⏸ Pause` button until phase 21 removes it), left-justified and on its own row under the indicators. The change is CSS-only: no page markup changes in this task, so the served-HTML pins (tab structure, toggle placement) stay green untouched.
## Work
1. `src/dashboards/web/static/dash.css` — `.masthead` (line 99): `display: flex; flex-direction: column; align-items: stretch; gap: .35rem` (replacing the row layout's `align-items: center; gap: 1.1rem`), `height: var(--header-h)` replaced by `min-height: var(--header-h)` (the ≤640px block already carries its own `min-height` — keep the mobile one, the two must not drift apart; see the comment it carries), `position: sticky; top: 0` **left in place in this task** (task 02 removes the stickiness; keep `z-index: 40`), and a positioned wrapper for row 1: the first flex child is the existing `brand` + `#nav-toggle` + `nav.tabs` trio — since the markup is not touched, use CSS `order`/`flex` only: `#nav-status` and `.masthead-right` each get `flex-basis: 100%` (forcing the row break), `nav.tabs` keeps its current inline position on row 1 (it already flows after the toggle). If `flex-basis: 100%` does not force a row break on the `nav` element in the pinned browser, the fallback is a `display: contents` wrapper-free `flex-wrap: wrap` on `.masthead` with the same `flex-basis: 100%` rules — measure in a browser, do not deduce (the phase-18 rule).
2. `src/dashboards/web/static/dash.css` — `#nav-status` (the `gap` container, line 148 neighbourhood): its own row styling — `flex-basis: 100%; display: flex; align-items: center; gap: .55rem; justify-content: flex-start` (left-justified, no `margin-inline-start: auto`).
3. `src/dashboards/web/static/dash.css` — `.masthead-right` (line 148): `margin-inline-start: auto` removed, `flex-basis: 100%; justify-content: flex-start` (left-justified, the TODO's words). The `⏸ Pause` button stays in this row (phase 21 deletes it; do not pre-empt it).
4. `src/dashboards/web/static/dash.css` — the ≤640px block (lines 208–280): re-cut for the new rows — the whole-bar `flex-wrap`/`min-height` machinery of the old wrap is replaced by the mobile half of the same three-row layout: `#nav-toggle` keeps its `--nav-toggle-display: inline-flex` reveal on row 1 (the token ladder stays exactly as pinned), `nav.tabs` stays hidden except `nav.tabs.is-open` (the dropdown), the `masthead-right > *` no-wrap rule and the `env(safe-area-inset-top, 0px)` top padding line (pinned by `tests/test_api.py`'s rule loop) survive verbatim, and the comment at line 203 (which describes the old 360 px wrap) is rewritten to the new three-row reality. The dropdown's `top: 100%` now lands under the **whole bar** (three rows) on mobile — that is the intended look (a menu under the bar, not wedged between rows); verify in a browser at 360 px.
5. `tests/test_api.py` — extend the served-stylesheet pins for the new layout: `.masthead` has `flex-direction: column` (or the `flex-wrap` fallback, whichever shipped) and no fixed `height: var(--header-h)`, `#nav-status` and `.masthead-right` are both `flex-basis: 100%` + `justify-content: flex-start`, and `.masthead-right` no longer declares `margin-inline-start: auto`. The existing pins (the media-query-vs-`matchMedia` comparison, the safe-area line, the token ladder) must stay green — run them before touching the file.
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the new layout pins above run against the **served** bytes; the unchanged pins (tab structure line 3913, toggle line 3957, the safe-area rule loop) stay green
- Coverage: **>90%** on this task's new/modified code (the gate applies to the changed Python test files)
- Browser verification (invariant 10): screenshots of `/`, `/system`, `/certs`, `/settings` at 1280 px and 360 px in `data/logs/` — the three rows, indicators and controls left-justified, the mobile dropdown under the bar
## Completion Criteria
- [ ] at 1280 px and 360 px, the masthead renders three rows in the order brand+tabs / indicators / controls, the last two left-justified, on every tabbed page and both admin pages (screenshots in `data/logs/`)
- [ ] `tests/test_api.py` green including the new layout pins and every pre-existing masthead pin
- [ ] full test suite green
- [ ] no behavior change in completed work: the tabs, the toggle, the dots, the clock, the links and the theme toggle all behave exactly as before
@@ -0,0 +1,27 @@
# Task 02 — The masthead is not sticky
**Phase:** `20_masthead_nav_polish` · **Source:** `TODO.md:10` — "The navbar is only sticky part way down certain pages on mobile? It shouldn't be sticky at all. We want to maximize the amount of screen space for data viewing."
**Story:** n/a
## Objective
The masthead scrolls away with the page. The "sticky part way down" quirk is the fixed-height sticky bar fighting the wrapped rows on mobile; removing the stickiness removes the quirk and the bar's vertical footprint. The ≤640px dropdown (`nav.tabs.is-open` at `top: 100%`) still opens directly under the bar, because the bar stays a **positioned** ancestor — `position: relative` keeps the anchor without the stick.
## Work
1. `src/dashboards/web/static/dash.css` — `.masthead` (line 99): `position: sticky; top: 0` → `position: relative` (keeps the positioned-ancestor role for the dropdown's `top: 100%` and the `left/right: 0` span; `z-index: 40` stays — the dropdown's `z-index: 41` is "one above the masthead" and that sentence stays true).
2. `src/dashboards/web/static/dash.css` — the dropdown comment (line 266: "`position: sticky`, which is a positioned ancestor") rewritten to `position: relative`; any other comment in the file that calls the bar sticky gets the same fix.
3. `tests/test_api.py` — any pin that asserts the `position: sticky` declaration (grep the test files for `sticky`) is updated to assert `position: relative` **and** the absence of `top: 0` on `.masthead`; if no pin named the declaration, add one to the layout pin loop from task 01 so the stickiness cannot quietly return.
4. `src/dashboards/web/static/dash.css` — check the `sticky; top: 0` at line 796 (a **different** element — the event-feed pager strip's own sticky header): it is not the masthead, do not touch it.
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the `.masthead` pin above, the unchanged dropdown pin (the media query still names `top: 100%` under the bar)
- Coverage: **>90%** on this task's new/modified code
- Browser verification (invariant 10): at 1280 px and 360 px, scroll `/system` and `/` past one viewport — the bar is gone from the top of the viewport; at 360 px open the dropdown — it still renders directly under the bar (screenshot pair in `data/logs/`)
## Completion Criteria
- [ ] scrolling any page at any width leaves the masthead behind (no element pinned to the top edge but the pager strip's own header)
- [ ] the mobile dropdown at 360 px still opens directly under the bar (screenshot in `data/logs/`)
- [ ] `tests/test_api.py` green including the new `.masthead` pin
- [ ] full test suite green
- [ ] no behavior change in completed work: the pager strip's sticky header (line 796) and the dropdown's `z-index` stacking are untouched
@@ -0,0 +1,27 @@
# Task 03 — The hamburger menu slides out and back in
**Phase:** `20_masthead_nav_polish` · **Source:** `TODO.md:11` — "I want a nice animation for the hamburger menu - a slide out and slide back in would be perfect. See ~/Project/Personal/brain-of-reese for an example."
**Story:** n/a
## Objective
The ≤640px dropdown (`nav.tabs.is-open`) opens and closes with an 180 ms slide+fade instead of snapping — the exact pattern the operator pointed at in `brain_of_reese`: closed = `visibility: hidden; opacity: 0; transform: translateY(-8px); pointer-events: none` with a `visibility 0s linear 180ms` delayed transition, open = `visibility: visible; opacity: 1; transform: none; pointer-events: auto` with the `visibility` delay dropped, and the whole thing stilled under `prefers-reduced-motion` with **both states named** (the reference project's phase-46 comment: an open-state transition that is not stilled still animates). `app.js`'s `setNavMenu()` funnel and its four close paths are untouched — the animation is pure CSS over the existing class flip.
## Work
1. Reference (read-only): `/var/home/ducoterra/Projects/Personal/brain_of_reese/frontend/assets/styles.css` lines 6960–6976 (the `.app-nav` closed/open transition pair — note the TODO's path `~/Project/Personal/brain-of-reese` is a typo; the project lives at `~/Projects/Personal/brain_of_reese`) and lines 7228–7235 (the `prefers-reduced-motion` block that names **both** `.app-nav` and `.app-nav.is-open`, with the specificity comment: an open rule that is not named keeps animating).
2. `src/dashboards/web/static/dash.css` — inside the ≤640px block (lines 208–280), the `nav.tabs` rule (the dropdown): add the closed-state properties + transition from the reference (adapted: this nav is `position: absolute; top: 100%`, so the slide is `transform: translateY(-8px)` → `none`, same 180 ms ease; keep the existing `visibility: hidden`/`pointer-events: none` closed-state behaviour if it is already there, otherwise add it — measure the current rule first, do not assume). The `nav.tabs.is-open` state gets the visible counterpart with the `visibility 0s` (undelayed) transition.
3. `src/dashboards/web/static/dash.css` — a `@media (prefers-reduced-motion: reduce)` block (the file's first, or merged with an existing one if there is one) that sets `transition: none` on **both** `nav.tabs` and `nav.tabs.is-open` — both states named, per the reference's phase-46 comment.
4. `tests/test_api.py` — extend the served-CSS pin for the mobile block: the `nav.tabs` rule carries an `opacity`/`transform` transition of 180 ms, the `is-open` state carries the visible counterpart, and the reduced-motion block names both states. The existing media-query-vs-`matchMedia` comparison test stays green untouched (the transition is additive; the `640px` literal still lives in exactly the two pinned places).
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the pins above; the unchanged four-close-path behaviour (the `setNavMenu` funnel tests)
- Coverage: **>90%** on this task's new/modified code
- Browser verification (invariant 10): at 360 px, open and close the menu — a CDP check that the computed `transition` runs (a mid-transition screenshot is not required; assert the closed state's `opacity: 0` + `transform: translateY(-8px)` computed values and that a frame after `.is-open` is added shows `opacity > 0`), and with `prefers-reduced-motion: reduce` emulated the menu snaps (computed `transition: none`) — extend the phase-18 verification script pattern (`data/logs/nav-task04-verify.cjs`) rather than hand-asserting
## Completion Criteria
- [ ] at 360 px the menu slides out (fade + 8 px rise) and back in over 180 ms; all four close paths still work
- [ ] under `prefers-reduced-motion: reduce` the menu snaps in both directions
- [ ] `tests/test_api.py` green including the new transition pins and the pre-existing `640px` comparison pin
- [ ] full test suite green
- [ ] no behavior change in completed work: the token reveal, the safe-area line, the dropdown position under the bar and the close paths are untouched
@@ -0,0 +1,27 @@
# Task 04 — Both admin links stay visible on the admin pages
**Phase:** `20_masthead_nav_polish` · **Source:** `TODO.md:4` — "Clicking the certs tab causes the certs button to disappear, then clicking the account tab causes the accounts button to disappear. These should remain visible."
**Story:** n/a
## Objective
Phase 15's deliberate "neither admin page links to itself" rule is inverted by operator decision (it was already "now questioned" in AGENTS.md gap 12): `certs.html` gains the `⚷ Certs` link and `settings.html` gains the `⚙ Account` link, so the masthead's controls are identical on all eight non-login pages. The links sit before the theme toggle and outside `.tabs` exactly as on the five dashboards; the tab structure (five links, no `aria-current` on the admin pages) and the `/login` page (brand + theme toggle only) are untouched.
## Work
1. `src/dashboards/web/pages/certs.html` — the masthead (lines 14–28): insert `<a class="ghost account" href="/certs" title="Agent certificates">⚷ Certs</a>` between the clock and the `⚙ Account` link (the dashboard order is clock → `⚷ Certs` → `⚙ Account` → theme toggle; `settings.html` already holds that order), and rewrite the HTML comment at lines 14–17, which currently justifies the *missing* link, to the new rule (both links everywhere; a self-link is a no-op the operator can click and not be surprised by — the TODO is the decision).
2. `src/dashboards/web/pages/settings.html` — the masthead (lines 14–30): insert `<a class="ghost account" href="/settings" title="Session and password">⚙ Account</a>` between the `⚷ Certs` link and the theme toggle; the comment at lines 12–14 ("It is reached from the `⚙ Account` link in the masthead of every dashboard") gains the "including this one" sentence.
3. `tests/test_api.py` — rewrite `test_every_page_links_to_both_admin_pages` (line 999): the two loops become **one** loop over all eight non-login pages (`*PAGES, SETTINGS_PAGE, CERTS_PAGE`) asserting both links are present, both before `id="theme-toggle"`, and `"/certs"` is not in the `.tabs` markup; the two `assert … not in … "links to itself"` lines are **deleted** (that was the old rule); add one explicit assertion that `login.html` carries **neither** link (the pre-auth surface renders brand + theme toggle only — that sentence in AGENTS.md stays true).
4. `AGENTS.md` — (Documentation & Consistency pass) the phase-15 "neither admin page links to itself, which is why the pinned two-link audit loops over two different tuples" sentence and gap 12's "now questioned" item are resolved to the new state.
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the rewritten `test_every_page_links_to_both_admin_pages`, the unchanged `test_the_disks_tab_is_in_the_masthead_of_every_page` (line 3913 — the tab structure is untouched by this task)
- Coverage: **>90%** on this task's new/modified code
- Browser verification (invariant 10): signed in, click `⚷ Certs` on `/certs` (stays on `/certs`, no navigation loop) and `⚙ Account` on `/settings` (same) — screenshots of both pages' mastheads in `data/logs/`
## Completion Criteria
- [ ] `/certs` renders `⚷ Certs` and `/settings` renders `⚙ Account`, both before the theme toggle and outside `.tabs`
- [ ] all eight non-login pages render both admin links; `/login` renders neither
- [ ] `tests/test_api.py` green with the rewritten test
- [ ] full test suite green
- [ ] no behavior change in completed work: the tab strip, the `aria-current` absence on the admin pages, the clock and the theme toggle are untouched
@@ -0,0 +1,34 @@
# Phase 21 — Page cleanups: pause, GPU values, certs scroll
**Source:** `TODO.md` L7, L9, L12 — "remove the pause button, GPU data as its own values, /certs table horizontal scroll"
**Story:** n/a
**Context:** `src/dashboards/web/pages/{disks,llm,system,ups}.html` line 25 (the `#pause` button, the only one in the served markup — `/`, `/certs` and `/settings` have none), `src/dashboards/web/static/app.js` (lines 587–615 the `poll()` `_paused` plumbing, lines 773–783 the `#pause` binding plus the `Space` shortcut), `src/dashboards/web/static/dash.css` line 203 (the comment naming the pause button), `src/dashboards/web/static/page-system.js` (lines 150–156 `gpuSub()` — the dense sub-line the TODO quotes, lines 164–188 `renderGpuSlot()`, line 72 the `data-k="gpus"` hero slot), `tests/test_api.py` (line 1425 — the six-heroes pin: "exactly six heroes: CPU, MEM, TEMP, DISK, GPU, NET", asserted over the three captures: discrete GPU, APU, no-GPU), `src/dashboards/web/pages/certs.html` line 81 (`<table class="data" id="agents-table">` — no `.scroll` modifier; `dash.css` gives `table.data` a scroll container only with the `.scroll` class), `src/dashboards/web/static/page-certs.js` (lines 55–56 — the table element references)
## Objective
Three small page-level cleanups, one per workstream: the pause button is gone from every page (the hidden-tab skip stays — a background tab idling its polls is not the operator's pause), the `/system` GPU data is split out of the dense sub-line into its own labelled values and a host with no GPU **omits** the slot instead of printing `n/a`, and the `/certs` agents table scrolls horizontally so the right-hand columns are reachable on a phone. No API surface changes; the pins that name the old behaviour (six heroes incl. an `n/a` GPU slot, the unpinned pause button) are rewritten, not left rotting.
## Dependencies
- `20_masthead_nav_polish` (todo) — the pause button lives in the masthead row task 01.01 reflows, and the `/system` hero row is re-measured under the new bar; landing 20 first keeps 21's diff confined to the page bodies.
## Tasks
1. `01_remove_pause_button.md` — delete the button, the `Space` shortcut, the `setPaused`/`_paused` plumbing and the CSS comment; the hidden-tab skip survives
2. `02_gpu_own_values.md` — the dense `gpuSub()` line becomes four labelled values per card; a GPU-less host omits the slot (five heroes, no `n/a`)
3. `03_certs_table_scroll.md` — `#agents-table` gets the `.scroll` modifier so its right-hand columns are reachable at phone width
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the served-page pins: the pause button absent from the four pages (a return of `#pause` fails a test), the six-heroes pin rewritten (GPU-present host: six, GPU-absent host: five with no `data-k="gpus"` slot), the `#agents-table` `.scroll` pin; `tests/test_system_contract.py` + `tests/card_contract.py` stay green (the API fields are unchanged — only the page's reading of them moves)
- Coverage: **>90%** on new/modified code (the phase is markup + JS + CSS; the gate applies to the changed Python test files)
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. AGENTS.md's `/system` paragraph ("a missing number or verdict — gateway's CPU temp, any host's absent GPU — prints `n/a` **uncoloured**") is the one rule this phase inverts for the GPU slot (the CPU-temp `n/a` stays — the TODO quotes the GPU line only); the sentence is split, the GPU half rewritten to the omission rule, and the pause button's mention in the masthead comment dropped.
## Completion Criteria
- [ ] no page serves a `#pause` button or binds the `Space` key; a hidden tab still skips its polls (served-bytes pin + a CDP `visibilitychange` check)
- [ ] `/system`'s GPU slot prints model / VRAM / pool / temp as four labelled values; a GPU-less host (the no-GPU capture, gateway) renders five heroes with no GPU slot and no `n/a` in it (screenshots in `data/logs/`)
- [ ] at 360 px the `/certs` agents table scrolls horizontally to its last column (screenshot in `data/logs/`)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green (including `node --check` of the modified page scripts)
- [ ] no behavior change in completed work: the six-hero order on GPU hosts, the hidden-tab skip, the GPU history charts, the `/certs` row contents and the event pager are all unchanged
@@ -0,0 +1,27 @@
# Task 01 — Remove the pause button
**Phase:** `21_page_cleanups` · **Source:** `TODO.md:7` — "Completely remove the pause button"
**Story:** n/a
## Objective
The `⏸ Pause` control is gone from the served markup, the page scripts and the shared poller: no button, no `Space`-key shortcut, no `setPaused`/`_paused` plumbing. The **hidden-tab** behaviour survives — `document.hidden` still skips a poll while the tab is backgrounded and `visibilitychange` still fires one on return; that is idle hygiene, not the operator's pause, and the TODO names the button only.
## Work
1. `src/dashboards/web/pages/disks.html`, `src/dashboards/web/pages/llm.html`, `src/dashboards/web/pages/system.html`, `src/dashboards/web/pages/ups.html` — delete the line-25 `<button class="ghost" id="pause" type="button" aria-pressed="false">⏸ Pause</button>` from each of the four (the only served occurrences — `/`, `/certs`, `/settings`, `/login` have none; a grep of `web/pages` for `pause` must come back empty when done).
2. `src/dashboards/web/static/app.js` — delete the `#pause` binding block (lines 773–783: the button lookup, the `aria-pressed` paint, the click handler, and the `keydown` `Space` handler with its `event.preventDefault()`); in `poll()` (lines 587–615) delete the `_paused` state, `setPaused()`, the `paused` getter and the `state._paused` checks in `tick()`/`schedule()`/`visibilitychange` — the `document.hidden` guard and the `visibilitychange` refire stay, minus the `&& !state._paused` clause; the docstring at line 587 ("pausing when the tab is hidden or the user hit pause") drops the pause half.
3. `src/dashboards/web/static/dash.css` — line 203's comment (which lists "status dots, pause, `⚷ Certs`, `⚙ Account`, theme" as the 360 px wrap's tenants) drops the pause mention.
4. `tests/test_api.py` — add the served-bytes pin: `id="pause"` is **absent** from all eight served pages (a return of the button fails a test) and `⏸` appears nowhere in the served scripts.
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the absence pin above; the existing poller tests (the `Dash.poll` hidden-tab behaviour, if any pin it — grep `hidden` in the test files first) stay green
- Coverage: **>90%** on this task's new/modified code
- Browser verification (invariant 10): `/` and `/system` — no pause control in the masthead, the `Space` key does nothing on the page body; hide the tab (CDP `Emulation.setDocumentHidden` or the window manager) and confirm the poll loop idles, then refire on return (the phase-18 verification script pattern)
## Completion Criteria
- [ ] `grep -ri pause src/dashboards/web/` comes back empty (code, markup and comments all clean)
- [ ] a hidden tab skips polls and a visible tab fires one on return (verified, not deduced)
- [ ] `tests/test_api.py` green including the new absence pin
- [ ] full test suite green
- [ ] no behavior change in completed work: the range chips, the event pagers, the status dots and every other masthead control behave exactly as before
@@ -0,0 +1,29 @@
# Task 02 — GPU data as its own values; GPU-less hosts omit the slot
**Phase:** `21_page_cleanups` · **Source:** `TODO.md:12` — "The GPU data is wrapped poorly - 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. Boxes without a GPU should not report N/A but instead should omit these values."
**Story:** n/a
## Objective
The `/system` hero card's GPU slot stops stuffing model · VRAM · pool · temp into one small sub-line: each becomes its own labelled value on the card, at a readable size. A host with no GPU (gateway, the no-GPU capture) no longer renders an `n/a` hero slot — the slot is **omitted** and the hero row simply has five. The API is untouched: `gpus[]` keeps every field (`model`, `vram_used_bytes`, `vram_total_bytes`, `mem_used_pct`, `temp_c`), and the per-card history (`gpu.busy_pct@N`, `gpu.temp_c@N`) and the collapsed GPU chart are unchanged.
## Work
1. `src/dashboards/web/static/page-system.js` — replace `gpuSub()` (lines 150–156) with a `gpuValues(gpu)` that renders four labelled cells — **Model** (mono, the `gpu.model` string), **VRAM** (`used / total` via the existing `bytes()` formatter, `n/a` if either side is absent), **Pool** (`mem_used_pct` %), **°C** (`temp_c`) — laid out as a compact `.gpu-values` grid under the busy hero (the hero itself keeps only the `busy_pct` number + its label; colour rules stay: the GPU is uncoloured, phase 06's "a busy GPU is the job" rule — no severity token, no bar). A missing field on a **present** card prints `n/a` in its own cell (rule 2: the field is absent, the card is real); a `null` verdict cell draws faint, the existing `Dash.hero` convention.
2. `src/dashboards/web/static/page-system.js` — `renderGpuSlot()` (lines 164–188): when `gpus` is empty, do **not** render the slot's fallback `n/a` content — render nothing (empty the slot's container and drop its hero styling so the row reflows to five heroes; the `data-k="gpus"` wrapper can stay as an empty, zero-footprint element or be removed — pick one and pin it). The multi-GPU case: one value grid per card, the cards' `GPU i` labels unchanged.
3. `src/dashboards/web/static/dash.css` — the `.gpu-values` grid: a compact 2×2 (or 4×1 on wide) of labelled cells reusing the existing token vocabulary (`.metric`/`.faint`/`.mono` shapes, no new palette — the app's colour rule), sized so a 20-character model string wraps rather than truncates.
4. `tests/test_api.py` — rewrite the six-heroes pin (line 1425): a host whose snapshot carries `gpus` still renders exactly six slots **in the existing order** (CPU, MEM, TEMP, DISK, GPU, NET); a host with no GPU renders exactly five with **no** `data-k="gpus"` slot (or the pinned empty variant) and no `n/a` in the hero row; the `gpu-values` cells (Model / VRAM / Pool / °C) are present in the served script. `tests/test_system_contract.py` and `tests/card_contract.py` stay green untouched (API fields unchanged).
5. `AGENTS.md` — (Documentation & Consistency pass) the `/system` paragraph's "a missing number or verdict — gateway's CPU temp, any host's absent GPU — prints `n/a` **uncoloured**" is split: the CPU-temp half keeps the `n/a` rule, the GPU half becomes the omission rule (the TODO is the decision), and the dense-sub-line description of the GPU slot is rewritten to the four-value layout.
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the rewritten hero pin over the three captures (discrete GPU, APU, no-GPU) **and** over HTTP (the `self_test`-style app harness the contract tests use); `tests/test_system_contract.py` + `tests/card_contract.py` green untouched
- Coverage: **>90%** on this task's new/modified code
- Browser verification (invariant 10): `/system` against the live fleet — deskwork's Strix Halo card shows the four values at readable size (the TODO's quoted string is gone from the sub-line), gateway's card shows five heroes with no GPU slot and no `n/a`; the collapsed GPU charts still draw for the GPU hosts; screenshots in `data/logs/`
## Completion Criteria
- [ ] a GPU host's card shows Model / VRAM / Pool / °C as four labelled values; the dense sub-line string from the TODO is gone
- [ ] a GPU-less host renders five heroes with no GPU slot and no `n/a` in the hero row (served-bytes pin + screenshot)
- [ ] the GPU slot stays uncoloured (no `ok|warn|fail` token in the new markup — the phase-06 rule)
- [ ] `tests/test_api.py` + `tests/test_system_contract.py` + `tests/card_contract.py` green
- [ ] full test suite green
- [ ] no behavior change in completed work: the hero order on GPU hosts, the `gpu.busy_pct@N` history, the GPU chart slot create/destroy and the "More detail" disclosure are untouched
@@ -0,0 +1,27 @@
# Task 03 — The `/certs` agents table scrolls horizontally
**Phase:** `21_page_cleanups` · **Source:** `TODO.md:9` — "In the certs page I can't horizontal scroll in the agents table to see the certs installed on the hosts."
**Story:** n/a
## Objective
The `#agents-table` on `/certs` becomes horizontally reachable at phone width: its right-hand columns (the serial, expiry, `san` list and the Rotate / Revoke / bundle controls) are reachable by scrolling instead of being clipped. The repo's own mechanism is the `.scroll` modifier — `dash.css` gives `table.data` a scroll container **only** with that class (the same rule gap 12 measured on `/`'s fleet table), so this is the class, not a second mechanism. Row contents, the `COLUMNS` contract and every served pin are unchanged.
## Work
1. `src/dashboards/web/pages/certs.html` — line 81: `<table class="data" id="agents-table">` gains the `scroll` modifier (`class="data scroll"`), or is wrapped in `<div class="scroll">` — check which form `dash.css`'s selector actually matches (grep `\.scroll` in the file first; use the one it styles, do not invent a third).
2. `src/dashboards/web/static/dash.css` — if the `.scroll` selector matches `table.data.scroll`, nothing to add; if it matches a `div.scroll > table.data` wrapper, verify the wrapper's max-height/overflow behaviour against the card's padding so the scroll container does not clip the row's own bottom border (measure in a browser, do not deduce). No new token, no new palette.
3. `tests/test_api.py` — the served-HTML pin: `#agents-table` (or its wrapper, whichever shipped) carries the `.scroll` mechanism in the served bytes; the `COLUMNS`-resolution test (the one that reads the served `COLUMNS` table against a live `GET /api/certs` row in both directions) stays green untouched.
4. `src/dashboards/web/static/page-certs.js` — lines 55–56 (`table`/`body` lookups): no change expected — the id and the `tbody` selector are untouched by the wrapper/class; verify the page still renders its rows (a wrapper that breaks `getElementById('agents-table')` would be caught by the existing render path).
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the pin above; the unchanged `COLUMNS` contract test
- Coverage: **>90%** on this task's new/modified code (one class / one wrapper; the gate applies to the changed test file)
- Browser verification (invariant 10): at 360 px on `/certs`, the table's scroll container is reachable — a CDP check that the container's `scrollWidth > clientWidth` and that scrolling to the end exposes the last column (the controls column), plus a screenshot mid-scroll in `data/logs/`; at 1280 px the table renders as before (no scrollbar on a wide viewport with three short rows)
## Completion Criteria
- [ ] at 360 px the `#agents-table` scrolls horizontally and its last column is reachable (screenshot in `data/logs/`)
- [ ] at 1280 px the table is visually unchanged
- [ ] `tests/test_api.py` green including the new pin and the unchanged `COLUMNS` test
- [ ] full test suite green
- [ ] no behavior change in completed work: the row contents, the status chips, the Rotate / Revoke / bundle controls and the `restart_required` outcome line behave exactly as before
@@ -0,0 +1,32 @@
# Phase 22 — /disks hides hosts without smartctl
**Source:** `TODO.md` L13 — "VMs that don't have smartctl enabled disks should not show up. Gateway shouldn't even be a host on the disks page."
**Story:** n/a
**Context:** `src/dashboards/api.py` (`disks_router()` at line 791 — `/api/disks/snapshot` answering one entry per configured host; the `/api/summary` disks block at lines 640–704 — `disks_hosts` built from `job.state.payload or _disks_stub(...)` at line 640, `fleet_disk_summary(disks_hosts)` at line 693; `_disks_stub()` at line 771 — `smartctl_available: None` for a never-answered host), `src/dashboards/sources/disks.py` (`smartctl_available()` at line 142 — the three-way flag: `True` a disk answered, `False` the agent said the binary is not installed, `None` an agent predating the pass or `--no-smart`), `src/dashboards/web/static/page-disks.js` (the per-host cards + the `fleet_disk_summary` header, hidden below two hosts), `tests/test_api.py` (the `/api/disks/*` pins — "one entry per configured host", the `smartctl_available` three-way, "with and without targets configured", the `fleet_disk_summary` key paths)
## Objective
A host whose agent answered `smartctl_available: false` (the binary is not installed — gateway's VM, no smartmontools behind `vda`) is not a host on the `/disks` page at all: it is absent from `/api/disks/snapshot`, from the fleet block and from `/api/summary`'s disks rows, and the fleet header counts only the hosts that are shown. The **three-way** flag itself is unchanged in `sources/disks.py` (the agent still reports it, the `/disks` page's `null`-case why-line still exists for `None` hosts, and an **unreachable** host keeps its dimmed stub row — a dead agent is a different fact from a missing binary, invariant 3). The filter is one API-level decision, so the source, the store and the health records see nothing new.
## Dependencies
- `21_page_cleanups` (todo) — both phases touch the `/disks` page markup (task 21.01 removes the page's `#pause` button); landing 21 first keeps this phase's diff confined to the host list.
## Tasks
1. `01_hide_smartctl_false_hosts.md` — the one API-level visibility gate (explicit `False` only) over `/api/disks/snapshot`, `/api/summary`'s disks block and the fleet summary; the pins rewritten
2. `02_verification.md` — browser + API verification against the live fleet: gateway absent, deskwork/driveripper intact, fleet header consistent, screenshots
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the rewritten `/api/disks/snapshot` pin (one entry per configured host **whose agent can read its drives** — a `smartctl_available: false` payload is absent, a `None` payload is present, an `unreachable` stub is present), the `/api/summary` disks-block pin (fleet counts over the visible hosts, per-host rows for the visible hosts only), the unchanged `smartctl_available` three-way pin in `sources/disks.py`'s own tests (`tests/test_disks.py` or wherever the flag is pinned — grep `smartctl_available` in `tests/` first)
- Coverage: **>90%** on new/modified code
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. AGENTS.md's `sources/disks.py` bullet (the three-way flag's `False` arm — "so the page can say *install smartmontools*") and the `/disks` UI paragraph (the `.banner.info` why-line for `false`) are rewritten: the `false` arm's page-side consequence is now *absence*, and the three-way flag keeps its agent-side meaning; the "gateway answers `smartctl_available: false`" fleet fact gains the "and is therefore not a host on the disks page" clause.
## Completion Criteria
- [ ] `/api/disks/snapshot` and `/api/summary`'s disks block carry no entry for a host whose agent answered `smartctl_available: false`; a `None` host and an `unreachable` stub both remain (pinned both ways in `tests/test_api.py`)
- [ ] the `/disks` page on the live fleet shows deskwork + driveripper only, the fleet header counts 2 hosts, and the per-host cards are unchanged (screenshot in `data/logs/`)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the agent's SMART pass, the `disks:<host>` job's health record, the stored `disk.*` series, the `None`-case why-line and the unreachable stub are all untouched
@@ -0,0 +1,30 @@
# Task 01 — The one visibility gate: `smartctl_available` is `False` only
**Phase:** `22_disks_hide_unconfigured` · **Source:** `TODO.md:13` — "VMs that don't have smartctl enabled disks should not show up. Gateway shouldn't even be a host on the disks page."
**Story:** n/a
## Objective
One API-level helper decides disk-page visibility: a host entry is shown when its payload's `smartctl_available` is **not explicitly `False`** — `True` (a disk answered) and `None` (nobody knows: an agent predating the pass, `--no-smart`, or a never-answered stub) stay, `False` (the agent said the binary is not installed) does not. The gate runs over the assembled per-host list in both endpoints, so `fleet_disk_summary()` (which sums whatever it is handed) sees only the visible hosts and its `hosts_total`/`hosts_live` count exactly the page's rows. `sources/disks.py` keeps the three-way flag verbatim — the agent still reports it and the `None`-case why-line still renders.
- ASSUMPTION: **explicit `False` only** is the hide condition. `None` stays visible (an agent that cannot say is not an agent that said *no* — the three-way flag's whole point), and an `unreachable` stub stays visible (invariant 3's rule: a dead agent must not read as "never configured"; its `smartctl_available` is `None` by `_disks_stub`'s shape anyway). The operator's words — "don't have smartctl **enabled**" / "shouldn't **even be a host**" — are the `false` arm; if a `None` host should also vanish, that is a one-clause change to the helper, not a redesign.
## Work
1. `src/dashboards/api.py` — add one small helper next to `_disks_stub` (line 771): `_disk_visible(host: Mapping) -> bool` — `host.get("smartctl_available") is not False` (the explicit-`False` check, spelled out so a `None` payload passes; a docstring naming the three arms).
2. `src/dashboards/api.py` — `/api/disks/snapshot` (in `disks_router()`, line 791): filter the assembled per-host list through `_disk_visible` before the response is built (the response shape is unchanged — a shorter list, same entry shape).
3. `src/dashboards/api.py` — the `/api/summary` disks block (lines 640–704): `disks_hosts` (line 640) is filtered through the same helper **before** `fleet_disk_summary(disks_hosts)` (line 693) and before the per-host row loop (lines 695–704), so the fleet counts and the rows agree with `/api/disks/snapshot` by construction — one helper, two call sites, no second copy of the predicate.
4. `tests/test_api.py` — rewrite the affected pins: the `/api/disks/snapshot` "one entry per configured host" test gains a `smartctl_available: false` host (a `disks:<host>` job whose payload the test harness holds, the `self_test`-style swap the `nut` job's pin uses) and asserts it is **absent** while a `None` host and an `unreachable` stub are **present**; the `/api/summary` disks-block pin asserts the fleet counts over the visible hosts only (e.g. three configured hosts, one `false` → `hosts_total: 2`); the "without targets configured" leg stays green untouched.
5. `tests/` — locate the `smartctl_available` three-way pin (grep `smartctl_available` in `tests/`; `tests/test_disks.py` or the agent/disks source tests) and confirm it is **untouched** by this change (the flag's computation is unchanged; only the API's reading of it gained a gate).
## Testing & Quality
- Unit/integration: `tests/test_api.py` — the pins above, both directions (a `false` host absent from **both** endpoints; a `None`/`unreachable` host present in both); the unchanged `fleet_disk_summary` unit tests in `tests/` (the function is untouched — it sums what it is handed)
- Coverage: **>90%** on this task's new/modified code
## Completion Criteria
- [ ] `/api/disks/snapshot` and `/api/summary` agree on the visible host set, and neither lists a `smartctl_available: false` host (pinned)
- [ ] a `None` host and an `unreachable` stub both render in both endpoints (pinned)
- [ ] `fleet_disk_summary` counts exactly the visible hosts (pinned)
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] full test suite green
- [ ] no behavior change in completed work: `sources/disks.py`, the `disks:<host>` job, the stored `disk.*` series and the health records are byte-identical in behaviour
@@ -0,0 +1,25 @@
# Task 02 — Live verification: gateway gone, the rest intact
**Phase:** `22_disks_hide_unconfigured` · **Source:** `TODO.md:13` — "VMs that don't have smartctl enabled disks should not show up. Gateway shouldn't even be a host on the disks page."
**Story:** n/a
## Objective
The gate is proven on the live fleet, read-only: the dev server (invariant 10: `./scripts/dev-server.sh restart`, sign in with `testpass`) answers `/api/health` with all nine sources `ok`, the `/disks` page shows deskwork and driveripper only, and every number that used to render still renders. No fleet change is made — this task dials the fleet and screenshots it.
## Work
1. `./scripts/dev-server.sh restart` — the dev server up on http://127.0.0.1:8090; `./scripts/dev-server.sh status` — the nine-source table: `host:driveripper`, `host:deskwork`, `host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`, `llm:vllm` all `ok` (gateway's **agent** still answers — the job is `ok`; only the *page* hides it, which is the point: the `disks:gateway` health record must not be touched by the API filter, and `status` is the proof).
2. Signed-in `GET /api/disks/snapshot`: no `gateway` entry, `deskwork` and `driveripper` present with their `smartctl_available: true` payloads; `GET /api/summary`: the disks block's `hosts_total` is 2, the per-host rows are the two, and the `system` block still lists all three hosts (the filter is `/disks`-scoped only — a `system` host list that lost gateway would be the over-reach the pin must catch).
3. Browser on `/disks` (invariant 10): the fleet header reads the two-host sum; gateway has no card and no why-line; deskwork's `nvme0n1` and driveripper's 16 drives render exactly as before the phase (the live SMART numbers from the fleet record); a screenshot in `data/logs/`.
4. The `None`-arm check: no live host is currently `None` (all three agents post-date the SMART pass), so the `None` arm is proven by the task-01 pins only — state that in the notes rather than fabricating a live case.
## Testing & Quality
- Coverage: n/a (verification task; the gate's coverage is task 01's)
- The read-only rule: no `POST` to the fleet, no `upsd` command, no unit change — a live test that writes is not a test (AGENTS.md testing policy)
## Completion Criteria
- [ ] `./scripts/dev-server.sh status` — nine sources `ok`, including `disks:gateway` (the job is healthy; only the page hides it)
- [ ] `/api/disks/snapshot` has no gateway entry; `/api/summary`'s system block still lists gateway (screenshot or transcript in `data/logs/`)
- [ ] the `/disks` page shows the two live hosts with their pre-phase numbers (screenshot in `data/logs/`)
- [ ] the full suite + `bash .agents/validate.sh` green
@@ -0,0 +1,34 @@
# Phase 23 — LLM rate responsiveness
**Source:** `TODO.md` L8 — "The LLM monitor has a strange delay and a 'ramp up/ramp down' effect that makes it look innacurate. Data reporting should be accurate to as small a delta as possible."
**Story:** n/a
**Context:** `src/dashboards/app.py` line 122 (`build_hub` builds **one** `RateEstimator(span=settings.rate_window)` and hands it to both `host_jobs` (line 124) and `llm_jobs` (line 130)), `src/dashboards/config.py` lines 197–199 (`llm_interval` 2 s, `llm_timeout` 3 s, `rate_window` 10 s), `src/dashboards/collectors.py` line 477 (`llm_jobs(settings, client, rates)` — the estimator is a parameter, so a second one is a wiring change, not a signature redesign), `src/dashboards/rates.py` (line 55 `MIN_WINDOW_SECONDS = 0.5`, line 61 `MAX_WINDOW_RATIO = 2.0`, the phase-13 jump gate — all untouched by this phase), `src/dashboards/web/static/page-llm.js` (line 39 `pollMs: 2000`, the `Δ<window>` labels printed from the payload's `window`), `src/dashboards/sources/llm.py` (the rate cells carry `computed:Δ<series>/Δt` + the honest `Δ<window>`; the window the estimator *used* travels with the value — rule 2), the mechanism the TODO describes (gap 9 / phase 13 in AGENTS.md: a 10 s window over a 2 s poll ramped the hero up over ~5 polls when a slot started and decayed over ~5 polls when it ended — the "ramp up/ramp down" the operator sees; the sub-8× skew family and the stall are **already** closed by phases 13/14 and stay closed: this phase changes the window size, nothing else)
## Objective
The LLM throughput rates are measured over a window as small as the app's own cadence allows — the LLM source gets its **own** `RateEstimator` whose span is the LLM poll interval (2 s), so a slot start reads steady within two polls instead of ramping over ten, while the `host` source keeps the 10 s `RATE_WINDOW_SECONDS` window its 10 s cadence needs (a 2 s window over a 10 s poll would refuse every window — the estimator's floor and ceiling would answer `n/a` forever). The phase-13 protections (the 0.5 s floor, the `2 × span` ceiling, the 8× jump gate with its independent-anchor confirmation) apply to the smaller window unchanged; the `Δ<window>` label prints the smaller window it actually used, so the number always says what it is.
## Dependencies
- — (none) — the phase touches `app.py`/`collectors.py` wiring and the LLM page; it is scheduled after `22_disks_hide_unconfigured` only to keep the fleet-changing phase 24 last.
## Tasks
1. `01_llm_window_per_poll.md` — the second estimator: `build_hub` hands `llm_jobs` a `RateEstimator(span=settings.llm_interval)`, the host keeps the 10 s one
2. `02_window_pins.md` — the pins: LLM rate cells label the 2 s window, host cells keep 10 s, the phase-13 gates still hold over the smaller window
3. `03_live_verification.md` — the live proof: a slot start reads steady within two polls (no 10 s ramp), the `Δ2.0s` label travels with the value, screenshots
## Testing & Quality
- Unit/integration: `tests/test_llm.py` (or wherever the `parse_gufo_metrics`/`parse_vllm_metrics` tests live — grep `rate_window`/`RateEstimator` in `tests/` first) — the window-value pins for both sources, the phase-13 tests (floor refusal, ceiling, jump gate, the stale-anchor tests) run **unchanged** and green; `tests/test_api.py`'s `/api/llm/snapshot` shape pins stay green (the shape is unchanged — only the `window` values move)
- Coverage: **>90%** on new/modified code
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. AGENTS.md's `app.py`/`poller.py` bullets (the "one `RateEstimator`" sentence) and `rates.py`'s docstring context gain the two-estimator fact; gap 9's "the estimator no longer turns a slow fetch into 0.2 s arithmetic" history stays — this phase is the window-size decision on top of it, and the `RATE_WINDOW_SECONDS=10` line in `.env.example` stays (it is the host's knob now, and gains a comment saying so if it has none).
## Completion Criteria
- [ ] `/api/llm/snapshot` rate cells carry a `window` ≈ 2 s (the label prints `Δ2.0s` on the page) and `/api/system/snapshot` rate cells keep ≈ 10 s (pinned both ways)
- [ ] the phase-13 tests (floor, ceiling, jump gate, stale anchor) pass unchanged
- [ ] live: a slot start reads steady within two polls and a slot end decays within two — the 10 s ramp is gone (screenshots + the stored 1 m rollup in `data/logs/`)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the host rates, the estimator's gates, the rollups and the `Δ<window>` labelling are all unchanged in kind
@@ -0,0 +1,28 @@
# Task 01 — The LLM source gets its own 2 s-window estimator
**Phase:** `23_llm_rate_responsiveness` · **Source:** `TODO.md:8` — "The LLM monitor has a strange delay and a 'ramp up/ramp down' effect that makes it look innacurate. Data reporting should be accurate to as small a delta as possible."
**Story:** n/a
## Objective
`build_hub` builds **two** estimators: the host's keeps `RateEstimator(span=settings.rate_window)` (10 s — its 10 s cadence needs the wide window; a 2 s span over a 10 s poll would sit the window-empty fallback against the `2 × span` ceiling and answer `n/a` every poll), and the LLM's is `RateEstimator(span=settings.llm_interval)` (2 s — one poll interval: the smallest window the 2 s cadence can honestly fill). `llm_jobs` (which already takes the estimator as a parameter, `collectors.py` line 477) receives the LLM one; `HostCollector` keeps the 10 s one. No new env var: the LLM window **tracks** `LLM_INTERVAL_SECONDS`, so the one existing knob stays the single source of truth for the LLM cadence-and-window pair.
- ASSUMPTION: **no new env var** — `RATE_WINDOW_SECONDS` (10, `.env.example` line 85) becomes the host's knob and the LLM window is `llm_interval` by construction. A 2 s window is 4× above the 0.5 s `MIN_WINDOW_SECONDS` floor, so the floor never engages on a healthy cadence; the 8× jump gate stays engaged (a real burst from idle still holds one poll for confirmation), and the poll cadence itself is **unchanged** — gufo and vLLM see exactly the same request rate as before (the TODO asks for a smaller *delta*, not more load on the engines).
## Work
1. `src/dashboards/app.py` — `build_hub` (lines 122–130): `rates` becomes `host_rates = RateEstimator(span=settings.rate_window)`; new `llm_rates = RateEstimator(span=settings.llm_interval)`; `host_jobs(..., HostCollector(host_rates), ...)` unchanged in shape; `llm_jobs(settings, client, llm_rates)` takes the new one. The two estimators are keyed on disjoint series names (rule 2's key is the resolved name + label set), so one shared object was never load-bearing — but the docstring/comment at line 122 (if it says "one estimator for all sources") is rewritten to the two-estimator shape.
2. `src/dashboards/collectors.py` — line 477: no signature change expected (the parameter is already there); verify the LLM jobs thread `rates` through to `parse_gufo_metrics`/`parse_vllm_metrics` (they already take it, `sources/llm.py` lines 189/243) — if any LLM path constructs its own estimator, it takes the injected one (one estimator per source, the phase's invariant).
3. `src/dashboards/config.py` — no change expected (the knobs exist); if a comment on `rate_window` (line 199) implies it is the one global knob, it gains the "host's knob; the LLM window is `llm_interval`" clause.
4. `tests/` — the `build_hub`-level test (grep `build_hub` in `tests/` — the `tests/test_api.py` wiring pins) gains one assertion pair: the estimator the LLM jobs receive has `span == settings.llm_interval` and the host collector's has `span == settings.rate_window` (spy the two objects, the way the `agent_tls_map` pin spies its entry).
## Testing & Quality
- Unit/integration: the wiring pin above; the existing LLM parse tests (fixture-driven, `tests/fixtures/`) run with the injected estimator — their assertions on the `window` value, if any hard-code 10.0, are updated to the new 2.0 (grep `10.0`/`window` in `tests/test_llm.py` first); the host parse tests keep 10.0
- Coverage: **>90%** on this task's new/modified code
## Completion Criteria
- [ ] `build_hub` hands the LLM jobs a 2 s-span estimator and the host a 10 s-span one (pinned by the spy)
- [ ] the fixture-driven LLM tests pass with the 2 s window; the host tests keep 10 s
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] full test suite green
- [ ] no behavior change in completed work: the host's measured rates, the estimator's gates and the engines' poll cadence are untouched
@@ -0,0 +1,27 @@
# Task 02 — The pins: 2 s LLM window, 10 s host window, gates intact
**Phase:** `23_llm_rate_responsiveness` · **Source:** `TODO.md:8` — "…Data reporting should be accurate to as small a delta as possible."
**Story:** n/a
## Objective
The window decision is pinned on the surface that shows it, in both directions (the repo's testing-policy rule): a LLM rate cell answers its `window` ≈ the LLM interval with the `Δ<window>` label traveling with the value, a host rate cell keeps ≈ 10 s, and the phase-13 gate tests — the 0.5 s floor refusal, the `2 × span` ceiling, the 8× jump hold with its independent-anchor confirmation, and the stale-scrape-body regression — run **unchanged** and green, because they are estimator properties, not window-size properties.
## Work
1. `tests/test_llm.py` (or the file the LLM parse tests live in — grep first) — the two window pins: (a) a gufo metrics scrape driven through `parse_gufo_metrics` with a `RateEstimator(span=2.0)` (the `build_hub` shape, task 01's) answers the rate cell with a `window` in `(0.5, 2.0]` on a healthy 2 s cadence and the cell's `source` string still reads `computed:Δ<series>/Δt`; (b) the same test shape with `span=10.0` on the host's series (or the existing host fixture tests) keeps its `window` ≈ 10 s — the two sources' windows are **different numbers in the same payload shape**, and a pin that asserts only one of them is the gap this task closes.
2. `tests/test_rates.py` (or wherever the phase-13 tests live) — run them unchanged: the floor test (a sub-0.5 s window answers `(None, dt)`), the ceiling test (`2 × span`), the jump-gate test (≥8× holds one poll, publishes on a *different-anchor* confirmation) and the two stale-body regression tests. If a test constructs its estimator with an explicit `span` that now disagrees with the app's shape, it is **not** edited to match — the estimator's constants are app-independent and the tests' own spans are theirs (the app's shape is task 01's pin's job).
3. `tests/test_api.py` — the `/api/llm/snapshot` shape pins stay green (the shape is unchanged; only `window` values move). If a pin hard-codes a `window` number in the LLM payload, it is the one place it is updated — to a range assertion (`0.5 < window <= 2.0 × llm_interval`), not a literal, so a future cadence change does not rot it.
4. `tests/test_config.py` — confirm `rate_window`'s pin (the 10 s default) stays; no new setting was added, so no new pin is needed (the "no new env var" ASSUMPTION is what the absence of a new setting is).
## Testing & Quality
- Unit/integration: the four pin groups above
- Coverage: **>90%** on this task's new/modified code
## Completion Criteria
- [ ] a LLM rate cell's `window` is pinned to the 2 s shape and its label prints `Δ2.0s` (or the honest sub-window when the floor engages)
- [ ] a host rate cell's `window` is pinned to the 10 s shape
- [ ] the phase-13 gate tests pass unchanged
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] full test suite green
- [ ] no behavior change in completed work: the payload shapes, the `source` strings and the estimator constants are untouched
@@ -0,0 +1,27 @@
# Task 03 — Live verification: the ramp is gone
**Phase:** `23_llm_rate_responsiveness` · **Source:** `TODO.md:8` — "The LLM monitor has a strange delay and a 'ramp up/ramp down' effect that makes it look innacurate."
**Story:** n/a
## Objective
The smaller window is proven on the live engines, read-only: with the dev server up (invariant 10) and a decode workload running on the engines, the `/llm` hero reaches steady state within **two** polls of the slot starting (pre-phase it ramped over ~five) and decays within two of it ending, every value carrying its `Δ2.0s` label, and the stored 1 m rollups staying in the fleet's measured band (gap 9's pass line: anything above ~300 tok/s is arithmetic, not work). No engine is written to — the workload is whatever the operator's engines are doing, or a deliberately short local decode the operator starts; a live test that loads a shared engine is not a test.
## Work
1. `./scripts/dev-server.sh restart`, sign in with `testpass`; `./scripts/dev-server.sh status` — the nine-source table `ok` (a restart also proves the two-estimator wiring survives a real boot, not just the in-memory test store).
2. Signed-in polling of `/api/llm/snapshot` at 2 s (a short script, the `scripts/watch-store-perf.py` pattern): capture the decode tok/s cell's `value` + `window` for ~60 s while a slot is busy, then again for ~30 s after it frees. The pass shape: within two polls of the transition the value is within 8× of steady (the jump gate's own ratio — a held value is the designed behaviour, and a *confirmation* within the next poll is the honest publish; a value still ramping at poll 5 is the pre-phase defect returning).
3. The `Δ` label check (gap 9's rule, restated): **zero** values printed without their `Δ<window>` label; the label reads the window actually used (`Δ2.0s` on a healthy cadence, the honest sub-window — never a bare number — when the floor or the stall engages).
4. The stored-rollup check (the phase-14 rule): the 1 m rollups of `llm.decode_tokens_per_second` for the window under test top out in the fleet's measured band (gap 9: 119.9 gufo / 171.7 vLLM were the real maxima; the 300 tok/s pass line stands) — a 1 m average set by a ramp would be the arithmetic this phase exists to remove.
5. Screenshots of `/llm` mid-burst and post-free in `data/logs/` (invariant 10), plus the captured series as a TSV beside them (the `data/logs/llm-decode-*.tsv` precedent).
## Testing & Quality
- Coverage: n/a (verification task; the pins are task 02's)
- The read-only rule: no `POST` to the fleet, no `upsd` command, no engine write — observing a workload the engines are already doing is the test
## Completion Criteria
- [ ] a slot start reads steady (within 8× of steady) within two polls, and a slot end decays within two — the 10 s ramp is gone (captured series in `data/logs/`)
- [ ] zero values printed without their `Δ<window>` label, the label reading the window actually used
- [ ] the 1 m rollups stay in the measured band (nothing above the 300 tok/s pass line)
- [ ] screenshots in `data/logs/`
- [ ] the full suite + `bash .agents/validate.sh` green
@@ -0,0 +1,36 @@
# Phase 24 — Dev mTLS: no non-mTLS agent, ever
**Source:** `TODO.md` L3 — "The client should never allow non-mtls communication. deskwork and gateway are still non-mtls according to the dashboard. During dev, the dev server should generate certs in a non-committed directory and then distribute them to clients during registration so we never need non-mtls servers."
**Story:** n/a
**Context:** phase 15 (complete) — the durable CA in `meta` (`certs.py`: five `certs.*` rows, the bundle stored verbatim), the agent's TLS mode (`agent/host_agent.py` 1.1.0: `--cert-bundle`, `CERT_OPTIONAL`, the live `# REVOKED` denylist, `agent.tls`/`agent.bundle_rev` in the snapshot), the collector's certificate dial (`collectors.py` `AgentTls` — an `https://` `HOST_TARGETS` entry dials with this app's client certificate), the bundle download route (`GET /api/certs/<name>/bundle`, session-gated, the stored bytes verbatim), the installer's `--cert-bundle` shape check (`deploy/install-agent.sh` lines 270–331 — the python3 validator runs **before** `need_root`, the bundle installs `0640 root:dashboards-agent` at line 385, the `--status` https probe), and the fleet record (AGENTS.md gap 11: no bundle is installed on any host, `HOST_TARGETS` is `http://` for every host, the installed agents run 1.0.0 so **1.1.0 ships together with the bundle, never the bundle alone**; gap 10: driveripper's unit is reproducible only with the repo script's `--as-root --caps CAP_SYS_RAWIO CAP_SYS_ADMIN --force` line — the Oct-18 sudo-granted snapshot must not be run; deskwork's `ops` has no installer grant, so its install goes through the operator's root). `scripts/dev-server.sh` (the dev server: reads `.env.dev`, nohup `dashboards`, `/api/health` readiness probe, `status` subcommand) and `data/` (gitignored — the non-committed directory the TODO names)
## Objective
The dev loop stops producing `http://` agents. On start, the dev server exports every configured host's stored bundle to non-committed `data/certs/` (the CA is already durable in `meta` — "generate" means *materialise what the first boot minted*, never a second authority), and the installer gains `--fetch-bundle` so a client's **registration** — its install — pulls its bundle from the dashboard and installs it in the same run (the operator no longer curls a file across the network by hand). Then the fleet itself moves: agent 1.1.0 + bundle on deskwork, driveripper and gateway, `HOST_TARGETS` flipped to `https://` host by host (a URL flipped before its bundle is installed turns that host red by construction — the order is the safety), leaving the dashboard's nine sources `ok` with three `agent.tls: true` hosts and no `http://` agent URL anywhere.
## Dependencies
- — (none) — the phase builds on complete phase 15 and phase 17; it is scheduled last on purpose, because it is the fleet-changing one.
## Tasks
1. `01_dev_bundle_export.md` — `scripts/dev-certs-export.py` + `dev-server.sh`: the dev server writes every configured host's bundle to `data/certs/<name>.bundle` (`0600`) on start; an unissued host says so and writes nothing
2. `02_installer_fetch_bundle.md` — `install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token>`: registration pulls the bundle from the dashboard, the shape check runs on the fetched bytes before `need_root`, the token never reaches the token file or the unit
3. `03_fleet_rollout.md` — agent 1.1.0 + bundle on the three hosts (deskwork via the operator's root, driveripper with the gap-10 `--force` line, gateway with its sudoers grant), `HOST_TARGETS` flipped per host, the URL never flipped before the bundle
4. `04_verification.md` — the fleet is mTLS: `agent.tls`/`deployed_bundle_rev` agree for all three hosts, nine sources `ok`, no `http://` agent URL, the 16-driver SMART reads still live, screenshots
## Testing & Quality
- Unit/integration: `tests/test_deploy_scripts.py` — the export helper against a `create_app(...)` hosted on a socket (the sandbox pattern the installer tests already use: fake `install`/`chown` where the installer runs); the `--fetch-bundle` legs (a good bundle fetched, installed and byte-identical; a malformed one refused **before any write**; the token absent from every written artefact)
- Coverage: **>90%** on new/modified code
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's)
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. The README's *Protecting an agent, host by host* rollout becomes the `--fetch-bundle` recipe (the manual curl is demoted to the fallback), the dev section gains the `data/certs` export, AGENTS.md's fleet record and gap 11 resolve to "all three hosts mTLS since <date>" with the exact installed units, and the "bearer-token transport is the compatibility path, not the designed one" sentence becomes history. **No token, no PEM and no session value go into any tracked file** — the exported bundles live in gitignored `data/`, the fleet's `HOST_TARGETS` URLs carry no userinfo once flipped (invariant 6), and the task transcript names hosts, not credentials.
## Completion Criteria
- [ ] `./scripts/dev-server.sh restart` leaves `data/certs/<name>.bundle` for every configured host that has an issued bundle, `0600`, byte-identical to the stored row, and a one-line note for any unissued host (pinned + verified)
- [ ] `sudo deploy/install-agent.sh --fetch-bundle <url> --cert-name <host> --session <token> ...` installs a byte-identical bundle in the sandbox, refuses a malformed one before `need_root`, and leaves no token in the token file, the unit or the log (pinned)
- [ ] all three hosts run agent 1.1.0 from an installed bundle, `HOST_TARGETS` is `https://` for every host with no userinfo, and `./scripts/dev-server.sh status` reads nine sources `ok` with `agent.tls: true` and `deployed_bundle_rev == bundle_rev` on all three (screenshots of `/certs` in `data/logs/`)
- [ ] driveripper's 16 drives still read live on `/disks` after the `--force` unit rewrite (the gap-10 unit shape is reproduced exactly)
- [ ] `uv run pytest` green, coverage not down, `ruff` + `pyright` clean
- [ ] `bash .agents/validate.sh` green
- [ ] no behavior change in completed work: the token-mode path, the revocation wiring, the bundle route and the agent's token-mode bytes are all unchanged
@@ -0,0 +1,27 @@
# Task 01 — The dev server exports the bundles to `data/certs/`
**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "During dev, the dev server should generate certs in a non-committed directory and then distribute them to clients during registration…"
**Story:** n/a
## Objective
On start, the dev server materialises the **stored** CA's per-host bundles in non-committed `data/certs/`: one `0600` file per configured host with an issued bundle (`data/certs/<name>.bundle`, byte-identical to the `meta` row the bundle route serves — the CA is durable in `meta`, so "generate" is an export of what the first boot already minted, never a second authority; `data/` is gitignored, so nothing committed ever carries a key). A configured host with **no** issued bundle (the invariant-3 shape: `issued: false`) writes no file and prints one line naming the host and the `/certs` route that issues it. The export is a small standalone script the dev server calls, so the test suite drives it against a `create_app(...)` hosted on a socket without a nohup'd process.
## Work
1. `scripts/dev-certs-export.py` — a standalone helper (stdlib `urllib`, the way `install-agent.sh`'s validator uses `python3`): arguments `--url` (the dev server's base URL), `--password` (the sign-in password — the dev server reads it from the same `.env.dev` it already loads, so the value comes from the environment, never a tracked file), `--names` (the configured host names, space-separated — read from `HOST_TARGETS`' names, the same parsing `parse_targets` does, passed in by the caller rather than re-parsed inside), `--out-dir` (defaults to `<root>/data/certs`). Behaviour: sign in via `POST /api/auth/login` (the public route; the token travels in `Authorization` for the bundle GETs), then per name `GET /api/certs/<name>/bundle` — a `{"configured": true, "issued": false}` answer writes **no file** and prints the one-line note; a configured host that 404s (`{"configured": false}`) is impossible by construction (names come from `HOST_TARGETS`) but is handled as the same no-file line, not a crash; a good bundle is written `<out-dir>/<name>.bundle` at `0600` (created via `os.open`, not `open()`, so the mode is the file's birth mode). The script exits 0 when every name resolved to either a file or the no-issued note, and non-zero on a sign-in failure — a dev server that cannot sign in says so in `dev-server.sh`'s start output, never silently.
2. `scripts/dev-server.sh` — in `start()`, after the readiness probe: call the export (`.venv/bin/python scripts/dev-certs-export.py --url http://127.0.0.1:$PORT --password "$DASH_ADMIN_PASSWORD" --names <the HOST_TARGETS names> --out-dir $ROOT/data/certs`), printing its per-host lines; in `status`, print the `data/certs/` listing (names + sizes) as the "bundles exported" block. The export failure is a **warning line**, not a `start` failure — a bundle export that cannot sign in is a dev-convenience failure, and the server itself is up (the readiness probe already proved it); the one line says what to check.
3. `tests/test_deploy_scripts.py` — the pin: host `create_app(settings with admin_password="testpass", pbkdf2_iterations=1000, one or two configured targets, an in-memory store with an issued bundle for one host and none for the other)` on `127.0.0.1:0` via `uvicorn` (the project's own dependency — `__main__.py`'s pattern), run the script against it, and assert: the issued host's file exists at `0600` and is **byte-identical** to `GET /api/certs/<name>/bundle` from the same app; the unissued host wrote **no file** and the output carries its one-line note; a wrong `--password` exits non-zero and writes nothing.
## Testing & Quality
- Unit/integration: the pin above (the sandbox pattern `tests/test_deploy_scripts.py` already uses for the installer)
- Coverage: **>90%** on this task's new/modified code
- Real-boot check: `./scripts/dev-server.sh restart` on the dev box prints the per-host export lines and `data/certs/` holds the files (the dev box is the test bed — invariant 10)
## Completion Criteria
- [ ] `./scripts/dev-server.sh restart` leaves `data/certs/<name>.bundle` per issued host, `0600`, byte-identical to the stored row; an unissued host is a one-line note and no file (pinned + verified on the dev box)
- [ ] `status` prints the exported-bundles block
- [ ] no bundle, key or PEM is ever written outside `data/` (the gitignored tree) — a grep of the tracked files for the export's output path confirms it lands only under `data/`
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
- [ ] full test suite green
- [ ] no behavior change in completed work: the dev server's start/stop/status semantics, the readiness probe and the CA rows in `meta` are untouched (an export never mints — `certs.read_state` is the request-time read the bundle route uses, and the export dials that route, not `ensure_certs`)
@@ -0,0 +1,29 @@
# Task 02 — `install-agent.sh --fetch-bundle`: registration pulls the bundle
**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "…then distribute them to clients during registration so we never need non-mtls servers."
**Story:** n/a
## Objective
The installer's registration moment — the install — is where the client gets its bundle: `--fetch-bundle <dashboard-url> --cert-name <name> --session <token>` fetches `GET /api/certs/<name>/bundle` from the dashboard **in the install run**, the existing python3 shape check (lines 270–331, which runs before `need_root`) validates the **fetched bytes**, and the rest of the `--cert-bundle` path (the `0640 root:dashboards-agent` install at line 385, the unit's `--cert-bundle` + `RuntimeDirectory` lines, the `--status` https probe) runs on them unchanged. The session token is a **fetch credential only**: it never reaches the agent's token file, the unit, the log or the installed bundle, and the fetch happens before `need_root` (a host whose dashboard refuses the fetch never gets a partial install — the same order the bundle validator already has).
- ASSUMPTION: the session travels on **argv** (`--session <token>`), matching how the operator already signs in for the manual curl the README's rollout documents; the token is visible in the `sudo` process list for the fetch's duration, which is the accepted cost on a single-admin box (the alternative — a `0600` session file — is a second artefact to clean and the operator's muscle memory is the curl recipe). The three flags are **all-or-nothing**: any one without the others is refused with a usage line before `need_root` and before any write.
## Work
1. `deploy/install-agent.sh` — argument parsing (near line 136): `--fetch-bundle) FETCH_URL="$2"; shift 2 ;;`, `--cert-name) CERT_NAME="$2"; shift 2 ;;`, `--session) SESSION_TOKEN="$2"; shift 2 ;;`; the all-or-nothing check (the three set together or not at all; `--fetch-bundle` and a local `--cert-bundle <file>` together is refused — two bundle sources are a contradiction, pick one) in the same pre-`need_root` region the bundle validator occupies (lines 160–331).
2. `deploy/install-agent.sh` — the fetch: a `python3` `urllib` block (the script already requires `python3` for the shape check — no new dependency, no `curl` assumption): `GET $FETCH_URL/api/certs/$CERT_NAME/bundle` with `Authorization: Bearer $SESSION_TOKEN` written to a `mktemp` file; the response body's shape is checked in the same block — a `{"configured": true, "issued": false}` body is refused with its own one-line reason (*"no bundle issued for <name> — issue it from /certs first"*), a `404`/`401`/`403` is refused naming the HTTP status, and only a 200 with a non-empty body falls through to the **existing** shape check (lines 270–331) run on the temp file; on any refusal the temp file is deleted (`trap`-covered, the way the script already cleans its unit temp file) and nothing is written. On success the temp file is handed to the existing `BUNDLE_SRC` path — the `0640` install, the unit lines and the `--status` probe all run unchanged on the fetched bytes.
3. `deploy/install-agent.sh` — token hygiene: `SESSION_TOKEN` is used only in the fetch block; it is never `echo`d (the script's existing log lines name the URL, never the header), never written to `$TOKEN_FILE`/`$CERT_FILE`/the unit, and is `unset` after the fetch; the `--help` block gains the three flags with the one-line recipe (the URL is the dashboard's, the name is the `HOST_TARGETS` name, the session is the signed-in token).
4. `tests/test_deploy_scripts.py` — the pins (the sandbox pattern: fake `install`/`chown`, a local `http.server` thread serving the bundle route's exact bytes — the `application/x-pem-file` attachment body a `create_app` bundle route would serve): (a) a good fetch → the installed `CERT_FILE` is **byte-identical** to the served bytes, `0640`, and the unit carries the `--cert-bundle` line; (b) a served `{"configured": true, "issued": false}` body → refusal with the one-line reason, **no** `install` call (the fake records it), temp file gone; (c) a served 401 → refusal naming the status, no write; (d) the **token absence** pin: the token string appears in no written artefact — not the token file, not the unit, not the script's captured stdout (the test serves the bundle with a token it chose and greps the sandbox for it); (e) the all-or-nothing and the two-bundle-source refusals, both before `need_root` (the sandbox's root check records the call order).
## Testing & Quality
- Unit/integration: the five pin legs above
- Coverage: **>90%** on this task's new/modified code (the gate applies to the changed test file)
## Completion Criteria
- [ ] `sudo deploy/install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token> --bind … --port …` installs a byte-identical bundle in the sandbox, `0640`, with the unit's TLS lines (pinned)
- [ ] a malformed/missing/401 bundle is refused before `need_root` with its own one-line reason and no partial write (pinned)
- [ ] the session token appears in no written artefact — token file, unit, or captured stdout (pinned)
- [ ] `uv run pytest` green, `bash .agents/validate.sh` green
- [ ] full test suite green
- [ ] no behavior change in completed work: the local `--cert-bundle <file>` path, the `--status` probe, the re-run guard (task-17's diff-and-refuse) and the token file's mode are byte-identical in behaviour to before
@@ -0,0 +1,30 @@
# Task 03 — The fleet goes mTLS, host by host
**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "The client should never allow non-mtls communication. deskwork and gateway are still non-mtls according to the dashboard."
**Story:** n/a
## Objective
All three agents run 1.1.0 from an installed bundle, and `HOST_TARGETS` is `https://` for every host. The order per host is the safety (gap 11's rule: **a URL flipped before its bundle is installed turns that host red by construction**): install agent 1.1.0 + bundle → restart the unit → the `--status` CA-only https probe answers → **then** the `HOST_TARGETS` line flips → the dev server restarts → `agent.tls` shows. 1.1.0 ships **together with** the bundle, never the bundle alone (the installed agents are 1.0.0 — gap 11's re-check). Hosts in order: gateway (the trivial one: `(ALL) NOPASSWD: ALL`, a VM with the standard unit), deskwork (no `ops` installer grant — its root half is the operator's action), driveripper (the gap-10 line, the one that must not be the Oct-18 snapshot).
- ASSUMPTION: the executor has **ssh as `ops`** from the dev box (the fleet record's probes ran from this laptop; the repo's dev workflow dials the fleet from here). If ssh is unavailable in the run environment, the task degrades to its written form: the exact command blocks are printed for the operator, the operator applies them, and task 04's verification runs against the applied fleet — the pass criteria are the same either way.
- ASSUMPTION: the flipped URL is **clean `https://<host>:9120` with no userinfo** — the TLS handshake is the auth, the token file line stays in the unit (the rollback path), and invariant 6's "no token in a tracked file" is satisfied by `.env.dev` itself being gitignored with the token now out of the URL. **Rollback** per host = restore the pre-flip URL line from the timestamped backup copy (made in `data/env-backup/` before any flip) and restart the dev server — the bundle stays installed, the unit keeps both its token file and the bundle line.
## Work
1. **Prep (dev box):** `./scripts/dev-server.sh restart` (task 01's export leaves `data/certs/` populated); sign in, note the session token (it lives only in the operator's shell and the task transcript's redaction, never in a file); `cp .env.dev data/env-backup/.env.dev-<timestamp>` (gitignored).
2. **gateway** (`10.1.1.27:9120`, the standard unit): over ssh as `ops` — copy the repo's `agent/host_agent.py` (1.1.0) to `/usr/local/lib/dashboards-agent/host_agent.py`, then `sudo deploy/install-agent.sh --bind 10.1.1.27 --port 9120 --fetch-bundle http://127.0.0.1:8090 --cert-name gateway --session <token>` (the fetch dials the **dev server on the dev box** — the dashboard's bundle route; on a host that cannot reach the dev box, the same flag with the dashboard's LAN URL, or task 01's exported file, is the fallback; the unit gains the `--cert-bundle` + `RuntimeDirectory` lines and keeps its `--token-file` line), `systemctl restart dashboards-agent`, `./deploy/install-agent.sh --status` (the CA-only https probe answers). **Then** flip gateway's `.env.dev` line to `https://10.1.1.27:9120`, `./scripts/dev-server.sh restart`, confirm `host:gateway`/`disks:gateway` `ok` with `agent.tls: true`.
3. **deskwork** (`10.1.0.51:9120`, the operator's root half): `ops` has no installer grant on deskwork (the fleet record), so the executor prints the exact block — the agent-file copy, `sudo deploy/install-agent.sh --bind 10.1.0.51 --port 9120 --fetch-bundle <dashboard url> --cert-name deskwork --session <token>`, the restart, the `--status` probe — for the operator to run as root; the executor then verifies the unit state over ssh (`systemctl is-active`, `systemctl cat`), flips the URL line and confirms `host:deskwork`/`disks:deskwork` `ok` with `agent.tls: true` (if the operator's run and the flip land before the executor's verify, the verify is the check; the command block is the record).
4. **driveripper** (`10.1.0.126:9120`, the gap-10 line): over ssh as `ops` — copy the repo's `agent/host_agent.py` (1.1.0) to `/usr/local/lib/dashboards-agent/host_agent.py`, then the **repo's** script with the gap-10 line exactly: `sudo deploy/install-agent.sh --bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force --fetch-bundle <dashboard url> --cert-name driveripper --session <token>` (the `--force` is what lands the unit change — task 17's guard refuses a differing re-run without it, and the line reproduces the `User=root` + `CapabilityBoundingSet` unit that keeps all 16 SMART reads live; **never** the Oct-18 sudo-granted snapshot — it predates the flags and the guard), `systemctl restart dashboards-agent`, the `--status` probe, **then** flip the URL line and confirm `host:driveripper`/`disks:driveripper` `ok` with `agent.tls: true` and the 16 drives still reading (a `smart: null` row here is the `--force` rewrite having changed the unit — stop and diff the unit against the fleet record's shape before anything else).
5. **Fleet state:** after all three flips, `.env.dev`'s `HOST_TARGETS` has no `http://` agent entry and no userinfo token; the three units still carry their `--token-file` lines (the rollback path); the transcript in `data/logs/` names hosts, ports and revs — no token, no PEM.
## Testing & Quality
- Coverage: n/a (fleet task; the pins are tasks 01–02's)
- The read-only rule applies to the **verification** half: no `upsd` command, no engine write, no `POST /api/ups/command`; the installer's own writes are the change this task exists to make
## Completion Criteria
- [ ] all three units run the 1.1.0 agent file from an installed bundle (`systemctl cat` + `--status` CA-only probe per host, transcript in `data/logs/`)
- [ ] `.env.dev`'s `HOST_TARGETS` is `https://` for all three hosts, no userinfo; the pre-flip backup exists in `data/env-backup/`
- [ ] `./scripts/dev-server.sh status` — nine sources `ok` with `agent.tls: true` on all three (the per-host flips never left the fleet red longer than the host's own restart — the per-host order is the proof)
- [ ] driveripper's 16 drives still read live on `/disks` after the `--force` rewrite
- [ ] the full suite + `bash .agents/validate.sh` green
@@ -0,0 +1,29 @@
# Task 04 — The fleet is mTLS: the end state, verified
**Phase:** `24_dev_mtls_rollout` · **Source:** `TODO.md:3` — "…so we never need non-mtls servers."
**Story:** n/a
## Objective
The end state is proven read-only, the way phase 15's task-05 final pass proved its composition: the dashboard dials all three agents with its client certificate, the issued-vs-deployed revisions agree, nothing non-mTLS is left in the config, and the things the bundle install must not have broken (driveripper's 16 SMART reads, deskwork's GPU row) still work. No live revocation is performed — the revocation wiring is phase 15's e2e proof (`data/logs/verify-15-tls-e2e.py`), and a live revoke of a working fleet is a change, not a check.
## Work
1. `./scripts/dev-server.sh status` — the nine-source table `ok` (`host:driveripper`, `host:deskwork`, `host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`, `llm:vllm`); `disks:gateway` is `ok` with the page hiding gateway (phase 22's gate) — the job healthy, the page scoped.
2. Signed-in `GET /api/certs`: all three rows `status: "active"`, `deployed_bundle_rev == bundle_rev` (the "install the new bundle" sentence is gone for every host), `agent` block from the `system` payload: `agent.tls: true` + `agent.bundle_rev` present on all three (the task-03 passthrough — a token-mode snapshot grows nothing, so the fields' **presence** is the mTLS proof, rule 2's shape).
3. Signed-in `GET /api/system/snapshot` + `GET /api/disks/snapshot`: the `agent` blocks on all three hosts; gateway's row absent from `/disks` (phase 22); driveripper's 16 drives with live SMART numbers (the `--force` unit rewrite is the gap-10 shape — a `smart: null` row here fails the task); deskwork's `nvme0n1` row and its GPU card on `/system` (the 1.1.0 agent file's reads intact).
4. The config: `grep` of `.env.dev` — no `http://` in `HOST_TARGETS`, no `@` userinfo in any target URL; `data/env-backup/` holds the pre-flip copy (the rollback path exists on disk).
5. The negative control, **without touching the fleet**: `data/logs/verify-15-tls-e2e.py`'s transcript (phase 15) is the standing proof that a revoked serial is refused on `/health` too; this task re-runs **none** of it live — it names the transcript as the control and checks only that `tests/test_collectors.py`'s revocation-before-dial pin and `tests/test_api.py`'s `build_hub` wiring pin are green (they run in the suite).
6. Screenshots (invariant 10) in `data/logs/`: `/certs` (the three active rows with matching revs), `/disks` (the two visible hosts, driveripper's 16 live rows), `/system` (deskwork's GPU card) — the "the dashboard says all three are mTLS" proof is a screenshot, not a curl.
## Testing & Quality
- Coverage: n/a (verification task)
- The read-only rule: no `POST` to the fleet, no `upsd` command, no revocation, no unit change — the task reads the fleet and screenshots it
## Completion Criteria
- [ ] nine sources `ok`; `deployed_bundle_rev == bundle_rev` and `agent.tls: true` for all three hosts (pinned by the API checks, shown by the screenshots)
- [ ] `.env.dev` has no `http://` agent URL and no userinfo; the pre-flip backup exists
- [ ] driveripper's 16 SMART reads and deskwork's GPU card are live in the served payloads
- [ ] screenshots of `/certs`, `/disks`, `/system` in `data/logs/`
- [ ] the revocation pins (`tests/test_collectors.py`, `tests/test_api.py`) green in the suite
- [ ] the full suite + `bash .agents/validate.sh` green; AGENTS.md's fleet record and gap 11 updated by the phase's Documentation & Consistency pass to the mTLS end state
@@ -0,0 +1,21 @@
All green. Final state verified.
**Implemented**
- `#nav-toggle` (`class="ghost nav-toggle"`, `type=button`, `aria-expanded=false`, `aria-controls="nav-tabs"`, `aria-label="Toggle navigation"`, `☰`) inserted between `.brand` and `nav.tabs` in all 7 tabbed pages (`index, system, disks, llm, ups, settings, certs`); block is byte-identical across them (md5 match).
- Nav given its id: `<nav class="tabs" id="nav-tabs" aria-label="Dashboards">` on all 7; `login.html` untouched (`grep -c 'id="nav-toggle"'` → 0, confirmed in-browser: no toggle, no tabs).
- `dash.css`: base hide rule `#nav-toggle { display: var(--nav-toggle-display); }` + `--nav-toggle-display: none` in `:root`, with the rationale comment.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → `1451 passed, 15 skipped`, coverage **98.94%** (floor 94), `ruff` All checks passed, `pyright` 0 errors, `node --check` clean.
- `uv run pytest -m live --live` → **11 passed** (real fleet/engines/upsd).
- Browser proof against the restarted dev server (Playwright, 1280/900/641/640/400/320px, all 7 pages): `#nav-toggle` computes `display:none` at every width, and deleting the button changes no masthead geometry → rendering identical to the pre-task markup. Screenshots: `data/logs/nav-task01-{desktop-1280,system-desktop-1280,phone-400,certs-phone-400,login-phone-400}.png`.
**Docs**: no doc impact — nothing renders differently yet; the AGENTS.md *UI conventions* masthead-collapsing entry is the phase's documentation mandate and lands with task 04/the final pass (writing it now would document a dropdown that does not exist).
**Decisions / deviations**
1. The task's literal `.nav-toggle { display: none; }` would **not** have hidden the button: the element carries `class="ghost"`, and `button.ghost { display: inline-flex }` is (0,1,1) vs (0,1,0) — measured a visible 29×29px button in the desktop masthead. Shipped an id-addressed rule instead.
2. The task's orphan-`id` assumption is wrong: `aria-controls="nav-tabs"` anchors `nav-tabs`, and a `.nav-toggle` class rule is invisible to the audit (it searches the comment-stripped corpus for `#nav-toggle`/quoted `nav-toggle`). The id rule satisfies `test_no_page_carries_an_id_nothing_reads`; `:root`-declaring the token also keeps `test_no_stylesheet_rule_uses_an_undeclared_token` green.
3. Token-driven `display` makes task 02's reveal order- and specificity-independent — proven in-browser that both `#nav-toggle {--nav-toggle-display: inline-flex}` and `.nav-toggle {…}` reveal the button at exactly ≤640px, no `!important`.
4. No new tests here (task 01's mandate is that the existing masthead/admin-link/orphan-`id` pins still pass); pre-existing advisory only: `fastapi 0.153.0 → 0.156.0` (dependencies untouched, advisory not a gate).
**Next pending task**: `.agents/phases/todo/18_nav_hamburger/02_dropdown_css.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...........................s............................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
................................................................ssssssss [ 68%]
sss..................................................................... [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................sss............................. [ 98%]
.......................... [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%
1451 passed, 15 skipped, 1 warning in 156.32s (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.0 wheel
@@ -0,0 +1,16 @@
**Implemented** (`.agents/phases/todo/18_nav_hamburger/02_dropdown_css.md`)
- `dash.css`: new `/* ---- mobile nav */` section = the ≤640px block — `.masthead` wraps (`height:auto`, `min-height:var(--header-h)`, `padding-block`), `#nav-toggle` revealed via task 01's `--nav-toggle-display` token (right-aligned, 40px target, `touch-action:manipulation`), `nav.tabs{display:none}`, `nav.tabs.is-open` = the dropdown (`position:absolute; top:100%` full-bleed under the sticky bar, `z-index:41`, `--bg-raised`/`--border`, touch-sized rows), so no menu-panel element and no colour outside the tokens.
- Three rules the browser measurement demanded: `.masthead-right{flex-wrap:wrap;justify-content:flex-end}`, `.masthead-right>*{flex:none;white-space:nowrap}`, `#nav-status{flex-wrap:nowrap}` — without them the bar was 183px tall with the 4 status dots in a vertical column and `⚷ Certs` broken over two lines (pre-phase it was a 58px bar the 118px dot column overflowed, document 45px wider than the viewport).
- Deviations, all commented in the file: `box-shadow:var(--shadow)` not the literal `rgba(0,0,0,.35)` (light theme + "no component hardcodes a colour"); shipped **without** the optional transition (`display:none→flex` cannot animate, so the `prefers-reduced-motion` block needs no new selector); 360px renders 3 rows, not the task's 2 — four labelled dots + Certs/Account/theme cannot fit 320px twice, and nothing was hidden to fake it (93px at 640px).
**Tests / lint / coverage**
- `bash .agents/validate.sh` → exit 0: **1451 passed, 15 skipped**, coverage **98.94 %** (floor 94), ruff "All checks passed", pyright "0 errors", `node --check` clean; advisory only: fastapi 0.153.0→0.156.1 (deps untouched).
- `uv run pytest -m live --live` → **11 passed** (real fleet/engines/upsd).
- Browser proof vs the live dev server (`data/logs/nav-task02-verify.cjs`): at 320/360/414/640 px — toggle visible, tabs hidden, `.is-open` full-width under the bar (`top:100%` = bar bottom), links stacked at 40px, `aria-current` highlighted, content scrolls under, `scrollWidth == viewport`; CSS-parse check confirms all 7 new selectors survive the parser; `/login` still has no toggle/tabs. No-regression control (`nav-task02-noregress.cjs`, shipped CSS vs the block stripped, 7 pages × 6 widths >640px) → **identical masthead layout**.
- Screenshots: `data/logs/nav-task02-{index,system,disks,llm,ups,certs,settings}-360{,-open}.png`, `-index-{320,640,641,700}`, `-system-360-dark{,-open}`, `-login-360`.
**Docs**: no doc changes — the phase assigns the AGENTS.md *UI conventions* masthead entry to its final pass, and the dropdown is unreachable until task 03's binding lands (same call task 01 made).
**For the final pass**: pre-existing horizontal overflow at 641–752px (masthead content measures 752px) — not caused by this task; fixing it would change the desktop masthead the phase pins as unchanged.
**Next pending task**: `.agents/phases/todo/18_nav_hamburger/03_toggle_binding.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...........................s............................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
................................................................ssssssss [ 68%]
sss..................................................................... [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................sss............................. [ 98%]
.......................... [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%
1451 passed, 15 skipped, 1 warning in 154.20s (0:02:34)
== 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.1 wheel
@@ -0,0 +1,13 @@
**Implemented** (`.agents/phases/todo/18_nav_hamburger/03_toggle_binding.md`)
- `app.js` `initChrome()`: the `#nav-toggle` / `#nav-tabs` binding, in the file's `if (element)` guard style — one `setNavMenu(open)` funnel that always sets `.is-open` + `aria-expanded` together, and the four close paths (button again, tab click delegated on the nav, `Esc` with focus returned to the button, `matchMedia('(max-width: 640px)')` false on resize-back so no stale `is-open`). `640px` matches task 02's CSS block; the `if (navToggle && appNav)` guard is what makes `/login` inert. No body marker class, no page/CSS/Python changes.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → **exit 0**: `1451 passed, 15 skipped`, coverage **TOTAL 99 %** (98.94 %, floor 94), ruff "All checks passed", pyright "0 errors", `node --check` clean on all `web/static/*.js`.
- `uv run pytest -m live --live` → **11 passed** (real fleet / engines / upsd).
- Browser pass vs the live dev server (`data/logs/nav-task03-verify.cjs`, Playwright, **77/77 checks**): across all 7 tabbed pages at 360px — closed on load, click opens (dropdown full-width under the bar, 5 links, `aria-expanded=true`), click again closes, Esc closes + focus on `#nav-toggle`, resize to 700 closes and the shrink back stays closed, a tab click closes; no `pageerror` anywhere. `/login`: script loads, no toggle/tabs, only refused request is the designed `GET /api/auth/status` 401. Screenshots `data/logs/nav-task03-{index-360,index-360-open,system-360-open,certs-360-open,index-1280,system-1280,login-360}.png`.
**Docs**: AGENTS.md *UI conventions* gains the masthead-collapsing convention (breakpoint, toggle identity + the `--nav-toggle-display` specificity ladder, the four close paths, `/login` carries no toggle, the literal living in two files); README's `web/` bullet gained the one operator-facing sentence. README page table unchanged, per the phase.
**Decisions / deviations**: (1) the `top: 100%` check allows 1px — that resolves against the masthead's padding box, so the menu's first row is its 1px bottom border. (2) The verification script asserts `/login` as "its only ≥400 request is the auth probe" rather than "zero console errors", because Chromium logs that designed 401. (3) No served-byte pins added here — task 04 owns them; no `mq.addListener` fallback (the task's snippet, and every engine this app ships to supports `addEventListener` on `MediaQueryList`).
**Next pending task**: `.agents/phases/todo/18_nav_hamburger/04_tests_and_verification.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...........................s............................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 63%]
................................................................ssssssss [ 68%]
sss..................................................................... [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
........................................sss............................. [ 98%]
.......................... [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%
1451 passed, 15 skipped, 1 warning in 153.37s (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.1 wheel
@@ -0,0 +1,19 @@
Done. Everything green.
**Implemented**
- `tests/test_api.py` — new phase-18 section with the three served-byte pins: `test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page` (button + all 5 tabs inside `header.masthead` on all 7 pages, brand→toggle→nav→cluster order), `test_the_login_page_carries_no_nav_and_no_toggle` (own unsigned client, since a signed-in `GET /login` is a `303`; also pins that `/login` still loads the shared `app.js` whose guard is its no-toggle contract), `test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint` (token default hidden, ≤640px reveal/`nav.tabs.is-open` dropdown/cluster-wraps, the four close paths + single `setNavMenu` funnel, and the CSS media query **compared** to the JS `matchMedia`).
- Each pin mutation-tested (breakpoint drift, un-hidden button, missing button on `/settings`, toggle added to `/login`, removed resize path) — all fail with named messages.
- Browser verification against the live dev server (9 sources `ok`): `data/logs/nav-task04-verify.cjs` → **24/24 checks** (dropdown under the 2–3-row bar, 5 stacked 40px links, `aria-expanded` truth table, Esc+focus return, tab-click close, resize-back drops stale `is-open`, desktop one-row/no button, no page error); transcript `data/logs/nav-task04-verify.out`; screenshots `data/logs/nav-hamburger-{closed,open}-360.png` + `data/logs/nav-masthead-desktop-1280.png` (read and confirmed, dark theme, live data).
- Orphan-`id` audit in `tests/test_auth_api.py` passed **untouched**.
**Tests / lint** — `uv run pytest`: **1454 passed, 15 skipped**; `bash .agents/validate.sh` → **EXIT 0** (`--cov-fail-under=94` → coverage **98.94 %**, ruff "All checks passed", pyright "0 errors", `node --check` clean); `uv run pytest -m live --live` → **11 passed**. Advisory only (deps untouched): `fastapi 0.153.0 → 0.156.1`.
**Docs** — AGENTS.md *UI conventions*: masthead-collapse entry extended with the three pins and the drift-comparison; README's `web/` bullet now also says the desktop masthead is unchanged above the breakpoint (page table untouched, per the phase).
**Notable decisions / deviations**
1. Pinned the shipped `#nav-toggle { display: var(--nav-toggle-display) }` + `:root` token instead of the task file's literal `.nav-toggle { display: none }` — that literal loses to `button.ghost { display: inline-flex }` (task 01 measured it); the test docstring says so, so nobody "restores" the broken shape.
2. Breakpoint pin compares the two extracted numbers (and pins `640`) rather than grepping `640px` twice.
3. Restored `src/dashboards/web/pages/settings.html`'s task-01 markup after I wiped it with a `git checkout` during a mutation test; byte-identical to the other six (md5 of the button block matches).
4. `NOTES.md` (emptied) / `TODO.md` (+2 items) changed in the working tree during this task from outside it — left untouched for the harness.
**Next pending task**: none in phase 18 (this was the last); next phase dir is `.agents/phases/todo/19_ups_events_expandable/`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
..............................s......................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
...................................................................sssss [ 68%]
ssssss.................................................................. [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
...........................................sss.......................... [ 98%]
............................. [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%
1454 passed, 15 skipped, 1 warning in 157.17s (0:02:37)
== 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.1 wheel
@@ -0,0 +1,12 @@
**Phase 19 final verification pass — all tasks already complete; everything green, no code changes needed.**
- **Verified (all 4 task files present in `complete/`)**: `details.events-box` disclosure on `/ups` with no `open` attribute and `#events` inside it; lazy fetch (`if (state.events.open) await refreshEvents()`), toggle stale rule on `state.pollMs`, success-path-only `painted`/`fetchedAt`; `charts-box`-shaped CSS with the `> .body` padding restatement; 4 new served-byte pins in `tests/test_api.py`.
- **Criterion 1 (collapsed on load, no first-poll fetch)**: PASS — re-ran `data/logs/19-task04-verify.cjs` against the live dev server (real upsd, 2 UPSes): **28/28 PASS**, `0` `/api/events` requests across 2 closed polls vs 4 snapshot polls; expand → exactly 1 request, 50 rows with `Oct 20, 07:11` timestamps + severity chips + `1–50 of 200` pager.
- **Criterion 2 (cache/stale/poll-while-open)**: PASS — fresh re-open 2→2 requests, stale re-open 2→3, loop refreshes while open, close asks for nothing, failed fetch keeps rows and doesn't stamp the cache.
- **Criterion 3**: `uv run pytest` → **1458 passed, 15 skipped**; `uv run pytest --cov` → **98.94%** (floor 94, unchanged); `uv run ruff check src tests agent` → clean; `uv run pyright` → **0 errors**.
- **Criterion 4**: `bash .agents/validate.sh` → **exit 0** (incl. `node --check` of all page scripts + repo-hygiene gates).
- **Criterion 5**: `data/logs/ups-events-closed.png` (collapsed `▸ POWER EVENTS`, 0 events requests noted in `ups-events-verify.out`) and `ups-events-open.png` (`▾`, bounded 320px list, pager strip) regenerated and inspected this pass.
- **Criterion 6 (no regressions)**: full suite green incl. UPS contract/pager pins; `uv run pytest -m live --live` (with `.env.dev`) → **11 passed**; phase 18's `nav-final-pass.cjs` sweep re-run → **45/45** (this phase's markup/CSS didn't touch the masthead).
- **Documentation**: full README.md (980 L) + AGENTS.md (1992 L) review against the code — verified env templates vs `config.py`, API route table vs `api.py`, installer flags vs `deploy/install-agent.sh`, agent routes vs `host_agent.py`, test/coverage/live tallies, `data/logs` artifacts, and live fleet facts (`HOST_TARGETS` all `http://`, all three agents `version 1.0.0`, nine sources `ok`). AGENTS.md UI conventions and README's events paragraph already carry the disclosure/lazy/stale sentences from tasks 01–04. **Verified consistent — no changes needed.**
- **Notable**: made zero repo edits (working tree holds only this phase's task work). `uv pip list --outdated` couldn't complete (PyPI 503 for the project name) — no dependency moved this pass, so rule 8 is not triggered; validate.sh's earlier advisory still lists `fastapi 0.153.0 → 0.156.3`, deliberately not upgraded in a UI/verification phase.
- **Next pending phase**: `.agents/phases/todo/20_masthead_nav_polish/` (tasks 01–04).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
..................................s..................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
.......................................................................s [ 68%]
ssssssssss.............................................................. [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 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%
1458 passed, 15 skipped, 1 warning in 153.66s (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,13 @@
Done — task 01 complete.
- **`src/dashboards/web/pages/ups.html`**: the `Power events` card body moved inside a `<details class="events-box">` with `<summary>Power events</summary>` — no `open` attribute, `#events`/`ul.events`/`loading…` placeholder unchanged, rest of the page byte-identical.
- **`tests/test_api.py::test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`**: served-byte pin (`details.events-box` present, no `open` attr, summary label, `#events` ul + placeholder *inside*, no duplicate `<h3>`, page's other ids intact). Verified it bites: mutations (added `open`, ul moved out, summary renamed) each fail it.
- `bash .agents/validate.sh` → **exit 0**: `uv run pytest --cov --cov-fail-under=94` = **1455 passed, 15 skipped**, coverage **98.94 %** (floor 94, unchanged); `ruff check src tests agent` "All checks passed"; `pyright` "0 errors"; `node --check` of `web/static/*.js` clean.
- **Browser proof (live dev server, `data/logs/19-task01-disclosure.cjs`)**: 10/10 checks PASS — `data/logs/19-ups-events-closed.png` (card paints one 21 px `Power events` summary line; `::details-content` is `content-visibility: hidden`) and `-open.png` (expand draws the bounded 320 px list, 50 rows, date/time rows intact).
- **Docs**: `AGENTS.md` UI-conventions event-feed paragraph gains the `/ups` disclosure sentence (collapsed by default, `#events` stays inside the disclosure, pinned on served bytes); `README.md` `/ups` feed paragraph says the card is a closed `Power events` summary until opened.
- **Decisions / notes**:
- Markup only, per the task file — no JS/CSS touched, so `/ups` still fetches the feed on every poll while closed (that is task 02's lazy gate) and the open body currently has `.body` padding 0 because `.card > .body` no longer matches (task 03's `details.events-box` CSS closes it). Both intermediate states render correctly and every test is green.
- Pinned the summary as the plain word `Power events` (no count chip — an unfetched count would be rule 2's invented number) and no `data-k` attribute, as the task's ASSUMPTIONs state.
- `uv pip list --outdated` advisory (fastapi 0.153→0.156) left untouched: no dependency moved in this task, same handling as phases 15/17/18.
Next pending task: `.agents/phases/todo/19_ups_events_expandable/02_events_lazy_loading.md` (lazy fetch / poll-while-open in `page-ups.js`).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
...............................s........................................ [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
....................................................................ssss [ 68%]
sssssss................................................................. [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 93%]
............................................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%
1455 passed, 15 skipped, 1 warning in 154.08s (0:02:34)
== 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.2 wheel
@@ -0,0 +1,19 @@
Task 02 complete.
**Implemented**
- `src/dashboards/web/static/page-ups.js`: `state.events {open,painted,fetchedAt}`; `refresh()` now does `if (state.events.open) await refreshEvents();` (poll loop feeds the list only while open); `refreshEvents()` stamps `fetchedAt`/`painted` **after** `Dash.eventsPager()` on the success path only (a failure keeps the rows and re-fetches on next open); `toggle` listener bound before `poll()` — first expand fetches, re-expand inside one `state.pollMs` paints the cached rows, older than that re-fetches; header comment documents the lazy rule.
- `tests/test_api.py`: `test_ups_events_are_fetched_only_while_the_disclosure_is_open` — served-script pins for the state shape, the `refresh()` gate (incl. no surviving ungated `await refreshEvents()`), the success-path-only stamp ordering, and the toggle's stale rule bound before the poll starts.
- `data/logs/19-task02-lazy.cjs` (+ `.out`): live browser proof, 21 checks — 0 `/api/events` requests across 2 closed polls, exactly 1 on first expand, loop refreshes while open, stops when closed, stale re-open re-fetches, fresh re-open makes no request (rows + pager footer + scroll identical), failed fetch keeps 50 rows and never moves the stamp. Screenshots: `19-ups-events-card-closed.png` (one summary line), `19-ups-events-card-open.png`, `19-ups-events-{closed,open,fetch-failed}.png`.
**Tests / lint / coverage** (all green)
- `uv run pytest` → 1456 passed, 15 skipped, exit 0
- `uv run pytest -q --cov --cov-fail-under=94` → Total coverage: 98.94% (unchanged)
- `uv run ruff check src tests agent` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- `bash .agents/validate.sh` → exit 0 (incl. `node --check` of `page-ups.js`); only the pre-existing advisory `fastapi 0.153.0 → 0.156.2` (no dependency touched by this task)
- Dev server http://127.0.0.1:8090 still `status: ok` (all 9 sources)
**Docs**: AGENTS.md UI conventions (event-feed paragraph) and README's `/ups` expandable bullet now state the lazy rule — closed = not fetched, first expand fetches, re-open inside one poll interval redraws what is there.
**Decisions**: stale bound = one `state.pollMs` (task's ASSUMPTION); stamp placed after the pager call so a pager throw can't mark an unpainted list as cached. Live infrastructure untouched (repo-side + local browser only).
**Next pending task**: `19_ups_events_expandable/03_disclosure_css.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
................................s....................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
.....................................................................sss [ 68%]
ssssssss................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 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%
1456 passed, 15 skipped, 1 warning in 164.27s (0:02:44)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
fastapi 0.153.0 0.156.2 wheel
@@ -0,0 +1,16 @@
Everything is green. Report:
**Implemented**
- `src/dashboards/web/static/dash.css` — new `details.events-box` family beside `details.charts-box`: `min-width:0`, `> summary` (cursor, `list-style:none`, webkit marker hidden, `::before` `▸` / `[open] ::before` `▾` in `--text-faint`, hover, open-state de-emphasis), all tokens declared in `:root`.
- Shape deviation (deliberate, documented in the block's comment): this `<summary>` *replaces* the card's `header`, so it takes `.card > header`'s exact rhythm — `padding:.6rem .8rem`, the h3's `12.5px/650` uppercase type, `--text-muted`, `var(--border)` bottom rule, `color-mix` header ground — and the caret is a flex item (as in `charts-box`), not the snippet's `position:absolute` (which sits off the text's centre line). No `margin-bottom` when open: `.body` supplies the gap.
- **Defect fixed from task 01**: `.card > .body` is a *child* selector, so once the body moved inside `<details>` it lost its padding. Added `details.events-box > .body { padding: .8rem; }` (restatement, measured equal to another card's).
- No new rules for `ul.events` / `.events-pager` — verified they resolve inside the disclosure (320px bounded scroll, `overscroll-behavior:contain`, flex pager with its 1px top rule, `1–50 of 200`, both arrows).
- `AGENTS.md` UI conventions: the disclosure's styling sentence + the `.card > .body` child-selector gotcha.
**Tests / lint** (`bash .agents/validate.sh`, rc=0): `uv run pytest --cov --cov-fail-under=94` → **1456 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` all JS clean. `uv run pytest -m live --live` → **11 passed**. Browser proof: `data/logs/19-task03-css.cjs` → **24/24 PASS** (`19-task03-css.out`), screenshots `19-task03-{closed,open}.png` + `19-task03-dark-{closed,open}.png` against the live dev server (both palettes; other cards and battery charts unchanged).
**Docs**: AGENTS.md updated; README no change needed (task 02's paragraph already covers the user-visible behaviour).
**Notes**: the served-stylesheet pin is deferred to task 04 by the task file, so I did not pre-empt it. Advisory only: `fastapi 0.153.0 → 0.156.3` available; dependencies untouched by this task, so not pulled.
**Next pending task**: `.agents/phases/todo/19_ups_events_expandable/04_tests_and_verification.md`.
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
................................s....................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
.....................................................................sss [ 68%]
ssssssss................................................................ [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 88%]
........................................................................ [ 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%
1456 passed, 15 skipped, 1 warning in 158.48s (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.3 wheel
@@ -0,0 +1,19 @@
Everything is green. Final state verified:
**Implemented (task 04 — pins + browser verification)**
- `tests/test_api.py::test_the_ups_event_rows_keep_the_date_time_and_severity_shape` — served-script pin of the row template: `<time>` leading with `month/day + 24h` `toLocaleString` options, `sev ${event.severity}` chip carrying the escaped host, message via `escapeHtml(eventText(event))`, conditional `runtime_seconds` footnote, pager + `label: 'Power events'` + empty sentence.
- `tests/test_api.py::test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component` — the served-stylesheet pin task 03 deferred: `▸`/`▾` carets, hidden webkit marker, the header ground/bottom rule (this `<summary>` replaces the card header), open-state de-emphasis with no `margin-bottom`, the `details.events-box > .body` padding restatement (`.card > .body` is a child selector), no verdict/accent token, and `ul.events`/`.events-pager` each styled exactly once.
- Both pins mutation-checked: dropping `<time>`, the severity class, `hour12: false`, raw-message printing, the caret, the `.body` padding, an accent token or a duplicated `ul.events {` each fails exactly one test; sources restored byte-identical.
- Browser proof against the running dev server with **real upsd** (2 UPSes, no simulator): `data/logs/19-task04-verify.cjs` → **28/28 PASS**; `data/logs/ups-events-closed.png` (collapsed, one 39px `Power events` summary, **0** `/api/events` requests across 2×5s polls while 4 snapshot polls ran) and `data/logs/ups-events-open.png` (bounded 320px list, 50 rows with `Oct 20, 07:11` timestamps + severity chips, `1–50 of 200` strip); counts recorded in `data/logs/ups-events-verify.out`. Also proven: 1 request on first expand, loop refreshes while open, close asks for nothing, fresh re-open makes no request (identical rows/pager), stale cache re-fetches, failed fetch keeps rows and doesn't stamp the cache.
- Docs: AGENTS.md UI conventions already carried the disclosure/lazy/stale sentence (tasks 01–03); corrected gap 12 (the two ex-NOTES items are now phases 20 and 24) and the `uv run pytest` tally 1454→1458. README's `/ups` "power events" row plus task 02's expandable paragraph stay accurate — no further README change.
**Tests / lint / coverage**
- `bash .agents/validate.sh` → **exit 0**: `pytest --cov --cov-fail-under=94` = **1458 passed, 15 skipped**, **98.94%** coverage (floor 94, unchanged); `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` all JS clean; repo-hygiene gates clean. Advisory only: `fastapi 0.153.0 → 0.156.3` (no dependency moved this task).
- `uv run pytest -m live --live` → **11 passed** (read-only, real fleet/engines/upsd). Dev server still `status: ok`, nine sources ok.
**Decisions / deviations**
- Task-04's first two named pins already exist from tasks 01/02 (`test_ups_power_events_feed_sits_behind_a_collapsed_disclosure`, `test_ups_events_are_fetched_only_while_the_disclosure_is_open`) with the exact coverage the task describes; I left them untouched (criterion 2 asks for the existing feed pins unmodified) and added only the genuinely missing pins.
- "List renders nothing while shut" is measured with `checkVisibility({contentVisibilityAuto:true})`, not a rect: a closed `<details>` hides via `content-visibility`, which keeps the child's box measurable.
- No live-infrastructure changes; repo-side plus the local dev server only.
**Next pending task:** `.agents/phases/todo/20_masthead_nav_polish/01_two_row_masthead.md` (phase 19 has no remaining task files — this was its last).
@@ -0,0 +1,65 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 24%]
..................................s..................................... [ 29%]
........................................................................ [ 34%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 48%]
........................................................................ [ 53%]
........................................................................ [ 58%]
........................................................................ [ 63%]
.......................................................................s [ 68%]
ssssssssss.............................................................. [ 73%]
........................................................................ [ 78%]
........................................................................ [ 83%]
........................................................................ [ 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%
1458 passed, 15 skipped, 1 warning in 153.15s (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.3 wheel
+77 -2
View File
@@ -739,6 +739,43 @@ Responsibilities, one line each:
`severity` verdict the API sent
(`tests/test_api.py` fails if a page script names it again). A value the API should rate
gets a row in `severity.py`, never a bound in JS.
**The tab nav collapses into a hamburger at ≤640px** (phase 18): the seven tabbed pages each
carry `#nav-toggle` (`class="ghost nav-toggle"`, `type="button"`, `aria-controls="nav-tabs"`,
`aria-label="Toggle navigation"`, `☰`) between `.brand` and `<nav class="tabs" id="nav-tabs">`,
and `/login` carries neither — it renders the brand link and the theme toggle only. Its base
rule is `#nav-toggle { display: var(--nav-toggle-display) }` with `--nav-toggle-display: none`
in `:root`, and that token is a specificity ladder, not decoration: the button also carries
`button.ghost { display: inline-flex }`, so a `.nav-toggle { display: none }` loses and ships a
visible button into the desktop masthead. The `@media (max-width: 640px)` block reveals it
through the token, hides `nav.tabs`, wraps `.masthead` and `.masthead-right` into rows (hiding a
control to make the width work is not the deal), and pads the bar down by
`env(safe-area-inset-top, 0px)` (the block's `padding-block` value restated on the top side so
the two cannot drift), because a real device's top-edge zone — Android Chrome is edge-to-edge
by default, and iOS does the same once the page opts into `viewport-fit=cover` — does not
deliver taps to the page: the first-row button in it is *unreachable*, not unbound, and no
headless emulation has such a zone, so a fully green emulated pass still ships a dead button
(the same symptom brain of reese fixed in production; `env()` resolves to 0 on every non-notch
surface, which render byte-identical, and the dropdown's `top: 100%` follows the taller bar);
it renders `nav.tabs.is-open` as the dropdown:
the `nav` element itself, `position: absolute; top: 100%; z-index: 41` under the sticky bar — no
second menu element, no `body` marker class, no colour outside the tokens. One binding in
`app.js`'s `initChrome()` owns the state through a `setNavMenu(open)` funnel (`.is-open` on the
nav and `aria-expanded` on the button, always together) with **four close paths**: the button
again, a tab click delegated on the nav, `Esc` with focus returned to the button, and
`matchMedia` turning false on resize back above the breakpoint — which drops a stale `.is-open`
so the next shrink starts closed and `aria-expanded` never claims a menu that is not on screen.
The `if (navToggle && appNav)` guard is the whole `/login` story: the shared script loads there
and does nothing. The `640px` literal lives in two files — the stylesheet block and that media
query — and has to move in both, or the menu opens into a `display:none` nav. The contract is
pinned on the **served** bytes by three `tests/test_api.py` pins — the button on all seven pages
and nowhere on `/login`, and one test that **compares** the CSS media query with the JS
`matchMedia` instead of grepping for `640px` twice, so a drift fails with both numbers in the
message — and proven in a browser at 360px by `data/logs/nav-task04-verify.cjs` (the `/` +
`/system` interaction, 24 checks) and `data/logs/nav-final-pass.cjs` (the phase-completion
sweep: all seven tabbed pages at 360px and at 1280px, `/login` at both, 45 checks). The
safe-area line is pinned in the same test's rule loop; it was verified on the operator's
mobile Chrome rather than in an emulator, because CDP's `safeAreaInsets` override does not
feed `env(safe-area-inset-top)` — the one half of this contract a headless pass cannot prove.
Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`,
`gpu.busy_pct@0`, `llm.decode_tokens_per_second`, `ups.charge_pct`), `@stream` for per-device
@@ -960,7 +997,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: 1451 passed + 15 skipped (11 live,
uv run pytest # hermetic: 1458 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
@@ -1353,7 +1390,28 @@ a `WeakMap` keyed by the `<ul>`: it survives a poll (so page 3 is still there tw
later — resetting every refresh would make page 2 unreadable) and resets to page 1 when the
event *set* changes (an operator reading page 3 of yesterday's history must not land on page 3
of a list that just changed). Rows are re-written only when their markup changed, which is
what keeps the reader's scroll position inside the container.
what keeps the reader's scroll position inside the container. The `/ups` feed is also
**expandable** (phase 19, `TODO.md:4`): its card body is a `details.events-box` disclosure with
no `open` attribute, so that card is one `Power events` summary line until the operator opens it
— a closed `<details>` renders no content, which is `/system`'s Charts rule applied to a feed,
and the `#events` id stays *inside* the disclosure so the pager and its bounded scroll keep their
target (pinned on the served bytes). The disclosure is also what makes it **lazy**: `refresh()`
fetches the feed only while `state.events.open` is true, so a card nobody is looking at costs the
poll loop nothing (a hidden-but-still-polled list is the half-measure this is). The first expand
fetches; a re-expand paints the rows already in the `<ul>` while they are younger than one poll
interval (`state.pollMs`, the page's own cadence) and re-fetches only past it — a `<details>`
keeps its children, so a fresh re-open costs no request and loses neither the pager's page nor
the reader's scroll. `painted`/`fetchedAt` are stamped on the success path alone, which is the
invariant-4 shape here: a failed fetch leaves the rows on screen, leaves the stamp where it was,
and the next open re-fetches. Its styling is `details.charts-box`'s shape with one structural
difference: that toggle sits *inside* a card body, while this `<summary>` **replaces** the card's
`header` — so it carries the header's type, padding, sunken ground and bottom rule (a closed card
reads as its heading, an open one puts the feed under the same rule every other card draws) and
no `margin-bottom` when open, because the `.body` below carries that spacing. That is also why
`details.events-box > .body` restates the `.8rem`: `.card > .body` is a **child** selector, so a
body moved inside a disclosure stops being padded until something says so — while `ul.events`
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
`.heroes` grid; `tests/test_api.py` pins the slots and their order on the served script).
@@ -1883,6 +1941,23 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
which is the one route that must not be taken until that snapshot is re-staged from this repo:
the Oct-18 copy predates phase 17's template *and* its re-run guard, so it would rewrite that
unit silently — and no live change was made while pinning phase 16.
12. **The nav collapses on a phone; the page content below it does not** (phase 18's verification
pass, 2026-10-20). At 360px `/`'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, which that table does not carry. Measured as pre-existing by
serving the `git show HEAD:` bytes of both `dash.css` and `pages/index.html` over a live
session: same clipping (a clipped card never un-clips when the document scrolls sideways).
What phase 18 did change is that the document no longer scrolls sideways at a phone width at
all — 360 px where it was 750 px — because the bar that overflowed its fixed 58 px row now
wraps, so nothing is lost and nothing gained: the masthead is complete at 360 px and the
table tail is still out of reach. The fix is a responsive-table pass (`.scroll` on the wide
tables, or a per-host card layout at ≤640px) and it touches pinned page shape on `/`, so it is
a phase rather than a cleanup. The two operator TODO items that sat alongside it are phased
now, in `todo/`: `24_dev_mtls_rollout` (never accepting a non-mTLS agent — fleet gap 11's
rollout, half of it) and `20_masthead_nav_polish` (keeping both `⚙ Account` / `⚷ Certs`
masthead links visible on the admin pages that today do not link to themselves — phase 15's
deliberate rule, which that phase is the decision on).
## Locked decisions (and why)
+13 -5
View File
@@ -609,8 +609,10 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
marker that says how many it hid.
* `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`,
`/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per
dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*`), and the certificate
authority's own surface (`/api/certs`, `/api/certs/<name>/bundle`, `POST …/rotate`, `POST
dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*` — including
`/api/ups/commands`, which answers what may be sent to each UPS right now and why not when it
may not), and the certificate authority's own surface (`/api/certs`,
`/api/certs/<name>/bundle`, `POST …/rotate`, `POST
…/revoke`, `POST /api/certs/root/rotate`). GETs everywhere except `POST /api/ups/command`,
which stays the **only** route that dials anything: the `/api/certs/*` writes are `meta` row
writes through `certs.py`, the same class of write `POST /api/auth/password` is, and a
@@ -640,9 +642,11 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
* `web/` — five static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the three
admin pages (`/login`, `/settings`, `/certs`), one stylesheet, hand-rolled
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). No
framework, no CDN, no bundler, and no narration: a page never explains its own pipeline, so
the words on screen are the ones you act on — a unit, a `Δ10.0s` provenance tag, a
the same agent can answer the 10-second utilisation poll and have a wedged SMART pass). At
≤640px the five tab links leave the bar and come back as a dropdown under it, opened by the `☰`
button (`/login` renders no tabs, so it has no button either), and above it the masthead stays
the one row it has always been. No framework, no CDN, no bundler, and no narration: a page never
explains its own pipeline, so the words on screen are the ones you act on — a unit, a `Δ10.0s` provenance tag, a
`SIMULATED` badge, or the sentence beside an empty panel. The overview (`/`) is exactly two panels — a
**Warnings & errors** list built from `/api/summary`'s `alerts` (each row links to the
dashboard that explains it) and the **Fleet** table — and nothing else: an empty alert
@@ -753,6 +757,10 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
asks `/api/events` for 200 so there is a page 2 to reach, and the endpoint itself is
unchanged. Reading page 3 survives a poll; a refresh that brings new events goes back to
page 1, because page 3 of a list that just changed is not the history you were reading.
The `/ups` feed is also **expandable**: its card is a `Power events` summary line, closed until
you open it, and the list inside is the same bounded, paged window. Closed, it is not fetched
at all; the first expand fetches once, and re-opening inside one poll interval redraws what is
already there instead of asking again.
## Testing
+2 -1
View File
@@ -69,7 +69,8 @@ WEB = Path(__file__).resolve().parent / "web"
# `/settings` is a page and not a dashboard: it is in `PAGES` (so it is a route) and *not* in
# `PUBLIC_PATHS` (so the gate guards it with no extra code — the route-enumeration test in
# `tests/test_auth_api.py` proves that, and `tests/test_api.py` proves the anonymous refusal for
# this page specifically). It carries no `aria-current` tab because it is not one of the four.
# this page specifically). It carries no `aria-current` tab because it is not one of the five
# dashboards (`/`, `/system`, `/disks`, `/llm`, `/ups`).
# `/certs` (phase 15 task 05) is the second of those admin pages, for the same reason: the
# certificate authority is not a fifth dashboard, it is the thing you administer past them.
PAGES = {"/": "index.html", "/system": "system.html", "/disks": "disks.html",
+3 -1
View File
@@ -14,7 +14,9 @@
that marks a tab this page is not would be a lie about the navigation. The masthead
carries the two admin links — `⚙ Account` and `⚷ Certs` — outside `.tabs`, so the tab
structure stays five links deep on every page. -->
<nav class="tabs" aria-label="Dashboards">
<button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/disks">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/disks" aria-current="page">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/" aria-current="page">Overview</a>
<a href="/system">System</a>
<a href="/disks">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/disks">Disks</a>
+3 -1
View File
@@ -13,7 +13,9 @@
<!-- The five dashboards, and no `aria-current`: this page is not one of them, and a tab
marked current that is not in the list would claim a location the page does not have.
It is reached from the `⚙ Account` link in the masthead of every dashboard. -->
<nav class="tabs" aria-label="Dashboards">
<button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/disks">Disks</a>
+3 -1
View File
@@ -10,7 +10,9 @@
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system" aria-current="page">System</a>
<a href="/disks">Disks</a>
+7 -3
View File
@@ -10,7 +10,9 @@
<body>
<header class="masthead">
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
<nav class="tabs" aria-label="Dashboards">
<button class="ghost nav-toggle" id="nav-toggle" type="button"
aria-expanded="false" aria-controls="nav-tabs" aria-label="Toggle navigation">☰</button>
<nav class="tabs" id="nav-tabs" aria-label="Dashboards">
<a href="/">Overview</a>
<a href="/system">System</a>
<a href="/disks">Disks</a>
@@ -71,8 +73,10 @@
</section>
<section class="card" style="margin-top:.85rem">
<header><h3>Power events</h3></header>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
<details class="events-box">
<summary>Power events</summary>
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
</details>
</section>
<footer class="pagefoot">
+42
View File
@@ -680,6 +680,48 @@ const Dash = (() => {
refreshStatus();
setInterval(refreshStatus, 15000);
}
// The hamburger: ≤640px the five tab links live in a dropdown under the masthead
// (`nav.tabs.is-open` in dash.css), opened by `#nav-toggle`. One binding for the seven
// tabbed pages, and a page without either element is a no-op — `/login` loads this file
// with brand link and theme toggle only, so the guard below is what keeps it inert there.
//
// Every state change goes through `setNavMenu()`, so no close path can leave `is-open`
// and `aria-expanded` disagreeing (a button that claims `true` over a menu the CSS hides
// is a control that lies). The four close paths: the button again, a tab link, Esc (with
// focus back on the opener), and resizing back above the breakpoint — the same four the
// dropdown's CSS assumes, and `640px` here is that media query, so the two literals must
// move together or the menu opens into a `display:none` nav.
const navToggle = $('#nav-toggle');
const appNav = $('#nav-tabs');
if (navToggle && appNav) {
const setNavMenu = (open) => {
appNav.classList.toggle('is-open', open);
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
};
navToggle.addEventListener('click', () => setNavMenu(!appNav.classList.contains('is-open')));
// A link click navigates — shut the menu. Delegated on the nav: the five links are
// static markup, so one listener covers all of them and a tab added to the pages is
// already covered.
appNav.addEventListener('click', (event) => {
if (event.target.closest('a')) setNavMenu(false);
});
// Esc closes while the menu is open (document-level — there is no other modal on these
// pages to fight for the key, and a closed menu must not swallow someone else's Esc).
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && appNav.classList.contains('is-open')) {
setNavMenu(false);
navToggle.focus();
}
});
// Resize back to desktop: the inline nav reappears, so the open state is dropped rather
// than left in place. The CSS scopes `.is-open` to ≤640px, so a stuck class paints
// nothing up there — but it would re-open the moment the viewport shrank again, and it
// would leave `aria-expanded` claiming a menu that is not on screen.
const mq = window.matchMedia('(max-width: 640px)');
const onMqChange = () => { if (!mq.matches) setNavMenu(false); };
if (mq.addEventListener) mq.addEventListener('change', onMqChange);
}
}
/** Range buttons (5m … 30d). Calls onChange(seconds, key). */
+168
View File
@@ -41,6 +41,10 @@
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--header-h: 58px;
/* The masthead nav toggle (phase 18): hidden everywhere until the ≤640px block flips it.
* A token rather than a literal `display: none` so the reveal may name the button with any
* selector, in any order — see `#nav-toggle` in the header section. */
--nav-toggle-display: none;
}
[data-theme="light"] {
@@ -171,6 +175,137 @@ button.ghost[aria-pressed="true"] { border-color: var(--accent); color: var(--ac
or the last page is a control that lies about there being more history). */
button.ghost:disabled { opacity: .45; cursor: not-allowed; }
/* The hamburger that swaps the tab nav for a dropdown on phones (phase 18): one per tabbed
* page (`#nav-toggle`, `aria-controls="nav-tabs"`), hidden at every width until the ≤640px
* block reveals it — so the masthead renders exactly as it did before the button existed.
*
* The token is the specificity ladder, not decoration. This element carries the shared `ghost`
* class, whose rule above claims `display: inline-flex` at (0,1,1): a literal
* `.nav-toggle { display: none }` loses to that and ships a visible 29×29 px button into the
* desktop masthead (measured in a browser, not deduced). Addressing the id out-ranks that, and
* reading the value from a token means the reveal can be any selector that matches the button,
* anywhere in this file — a declaration of `--nav-toggle-display` on the element always beats
* the value inherited from `:root` — so `@media (max-width: 640px) { #nav-toggle {
* --nav-toggle-display: inline-flex } }` is the whole reveal, with no `!important`. */
#nav-toggle { display: var(--nav-toggle-display); }
/* ---- mobile nav ---------------------------------------------------------
* The ≤640px half of the hamburger (phase 18): the five tab links leave the bar and come
* back as a dropdown under it, opened by `#nav-toggle`. The state lives entirely in the
* `.is-open` class task 03's binding puts on `#nav-tabs`, so this block renders a verdict
* made elsewhere and owns no behaviour of its own.
*
* Above 640px nothing here applies, which is the point: the desktop masthead is the same
* one row with the inline tabs it always had, and the button stays hidden.
*/
@media (max-width: 640px) {
/* One fixed-height row cannot hold brand + toggle + the whole `masthead-right` cluster
* (status dots, pause, `⚷ Certs`, `⚙ Account`, theme) at 360px, so the bar wraps: brand
* and toggle on the first row, the cluster on the second. `height: auto` rather than a
* new fixed height is what keeps the dropdown's `top: 100%` honest — it pins to the
* bottom of the masthead however many rows that is — and `min-height` stops a page whose
* cluster also wrapped from collapsing to a 2-line bar. */
.masthead {
height: auto;
min-height: var(--header-h);
flex-wrap: wrap;
row-gap: .4rem;
padding-block: .5rem;
/* The bar must not start at y=0 of the layout viewport. On a real phone the viewport
* runs under the status bar (Android Chrome is edge-to-edge by default; iOS does the
* same once the page opts into viewport-fit=cover), and the system top-edge zone
* does not reliably deliver taps to the page — a button in it is unreachable, not
* unbound, and no amount of headless emulation shows it (an emulated viewport has no
* system top edge). Padding by the top safe-area inset pushes the whole bar — the
* first-row #nav-toggle first — into the tappable region; the dropdown's top:100%
* follows the taller bar. env() resolves to 0px on every non-notch surface (desktop,
* headless, non-notched phones), so those render byte-identical. The 0.5rem is the
* padding-block value restated for the one side the inset grows, so the two never
* drift. Same fix brain of reese shipped for the same owner-reported symptom. */
padding-top: calc(.5rem + env(safe-area-inset-top, 0px));
}
/* The cluster needs the same treatment one level down, and these three rules are what a
* 360px phone actually requires — measured on both sides of them. With only the rule above,
* the bar came out 183px tall: the four status dots in a single-file column (`#nav-status`
* carries the shared `.gap` class, which is `flex-wrap: wrap`) and `⚷ Certs` broken over two
* lines, because every item shrank to a fraction of its content. Before this phase the bar
* kept its fixed 58px and that 118px dot column simply overflowed it onto the page below,
* with the document 45px wider than the viewport. 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. The bar lands at ~123px at 360px and ~93px at 640px — three visual
* rows at the narrow end, two at 640px — because four labelled dots plus `⚷ Certs`,
* `⚙ Account` and the theme button do not fit in 320px twice over, and nothing here hides a
* control to make the arithmetic work: a phone gets the same words every other width does. */
.masthead-right { flex-wrap: wrap; justify-content: flex-end; gap: .4rem; }
.masthead-right > * { flex: none; white-space: nowrap; }
#nav-status { flex-wrap: nowrap; }
/* The reveal, through task 01's token: `#nav-toggle` reads its `display` from
* `--nav-toggle-display`, so declaring the token on the element here is the whole flip —
* no selector order to get right, no `!important`, and the `button.ghost` outline it
* inherits stays the only styling it needs. Right-aligned because that is where a thumb
* already is on a phone (brain of reese does the same), and `touch-action` because the
* default double-tap-to-zoom disambiguation makes a first tap feel like a dead button. */
#nav-toggle {
--nav-toggle-display: inline-flex;
align-items: center;
justify-content: center;
margin-inline-start: auto;
min-width: 40px;
min-height: 40px;
padding: 0 .5rem;
font-size: 15px;
touch-action: manipulation;
}
/* The links leave the row. `.is-open` below out-ranks this (two classes to one), so the
* order of the two rules is not load-bearing. */
nav.tabs { display: none; }
/* The nav *is* the dropdown — no menu panel element to keep in sync. `.masthead` is
* `position: sticky`, which is a positioned ancestor, so `top: 100%` lands directly under
* it and `left/right: 0` span the masthead's padding box edge to edge; hence
* `margin-inline-start: 0`, without which the base rule's .4rem over-constrains the box
* and insets the menu's left edge by that much. `z-index: 41` is one above the masthead's
* 40 so the menu paints over the page, which scrolls underneath it.
*
* `box-shadow: var(--shadow)` rather than a literal black: this file's rule is that no
* component hardcodes a colour, and the light theme re-declares the token — a .35-alpha
* black drop is the one thing that looks wrong under a white menu. */
nav.tabs.is-open {
display: flex;
flex-direction: column;
gap: 0;
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-inline-start: 0;
padding: .4rem;
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
z-index: 41;
}
/* Rows, not pills: the base .38rem padding is a desktop tab strip, and a menu item you
* can miss is a menu that does not work with a thumb. `border-radius` is restated because
* the highlight (`:hover`, `[aria-current="page"]`) is now a row, not a chip — both rules
* carry over unchanged, which is why nothing here mentions a colour. */
nav.tabs.is-open a {
padding: .6rem .7rem;
min-height: 40px;
border-radius: var(--radius-sm);
}
}
/* No transition on the dropdown, on purpose: `display: none` → `display: flex` does not
* animate, so an `opacity`/`translateY` here would be a declaration that never runs (running
* it would mean the visibility/`@starting-style` machinery brain of reese carries, which is
* more CSS than a menu of five links deserves). The existing
* `@media (prefers-reduced-motion: reduce)` rule therefore has nothing new to kill. */
/* ---- layout ------------------------------------------------------------- */
main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
@@ -726,6 +861,39 @@ details.charts-box[open] > summary { color: var(--text-muted); margin-bottom: .5
/* the chip is a value, not a heading: it keeps its own casing and size inside the summary */
details.charts-box > summary .chip { text-transform: none; letter-spacing: .02em; }
/* The `/ups` Power events feed is the same kind of disclosure and shut by default, which is
what lets `page-ups.js` fetch nothing while nobody is looking at it. It is not `charts-box`
copied for one structural reason: that toggle sits *inside* a card body, while this
`<summary>` replaces the card's own `header` — so it takes the header's type, padding,
sunken ground and bottom rule (a closed card reads as its heading, and an open one puts the
feed under the same rule every other card draws), with the `▸`/`▾` caret as the summary's
first flex item so the marker rides the text's centre line and says open/closed in a shape
as well as a colour. No `margin-bottom` on the open state: the `.body` below carries the
spacing `charts-box`'s summary has to buy for itself. No animation either, same as
`charts-box` — a summary that snaps is the house style, and `prefers-reduced-motion` holds
by construction. */
details.events-box { min-width: 0; }
details.events-box > summary {
display: flex; align-items: center; gap: .55rem;
list-style: none; cursor: pointer; user-select: none;
font-size: 12.5px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase;
color: var(--text-muted);
padding: .6rem .8rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-raised) 70%, var(--bg-sunken));
}
details.events-box > summary::-webkit-details-marker { display: none; }
details.events-box > summary::before { content: "\25B8"; font-size: 9px; color: var(--text-faint); }
details.events-box[open] > summary::before { content: "\25BE"; }
details.events-box > summary:hover { color: var(--text); }
/* open, the label steps back: the operator is reading the feed now, not the heading */
details.events-box[open] > summary { color: var(--text-faint); }
/* Inside the disclosure the body is a *grandchild* of the card, so the shared
`.card > .body` padding no longer reaches it. This restates that padding rather than
inventing a second one; `ul.events` and `.events-pager` stay untouched — they are
class-based and resolve here exactly as they do on the other three feeds. */
details.events-box > .body { padding: .8rem; }
.host-charts { display: grid; gap: .7rem; }
.host-chart { display: grid; gap: .15rem; min-width: 0; }
/* `sm` is the slot's floor before `LineChart` writes its measured height inline, so an
+37 -2
View File
@@ -9,13 +9,22 @@
* Commands are the only write path in the product. They are offered only when the backend
* says `enabled`, and anything that can cut power requires typing DANGER here *and* the
* server re-checks the same gate — a hidden button is not a safety control.
*
* The power-events list is **collapsed by default** behind a `details.events-box` and lazy:
* a closed `<details>` renders nothing, so while it is shut the page fetches `/api/events`
* not at all (`/system`'s Charts rule — nothing is built for a feed nobody can see). The
* first expand fetches; a re-expand paints what is in hand while it is fresh.
*/
'use strict';
const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash;
const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000,
simulated: false, pending: null };
simulated: false, pending: null,
// The events disclosure: `open` gates the poll path, `painted`/`fetchedAt`
// decide whether an expand has to fetch or can draw the rows already in the
// `<ul>` (a `<details>` keeps its children, so re-opening costs nothing).
events: { open: false, painted: false, fetchedAt: 0 } };
const MARKS = { ok: '✓', warn: '!', fail: '✕', unknown: '?' };
@@ -424,7 +433,10 @@ async function refresh() {
$('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · ` +
`${items.reduce((total, item) => total + (item.advertised_commands || []).length, 0)} commands advertised`;
await refreshCharts(items.map((item) => item.name));
await refreshEvents();
// The feed is only refreshed while its disclosure is open. The list is the longest-lived
// payload on the page (200 events) and the one a glance at "is my load safe" never reads,
// so a shut `<details>` must cost the poll loop nothing.
if (state.events.open) await refreshEvents();
}
async function refreshEvents() {
@@ -445,6 +457,11 @@ async function refreshEvents() {
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span>`,
});
// Stamped only on the success path: a fetch that failed leaves `painted`/`fetchedAt` where
// they were, so the next open re-fetches and a failed poll while open keeps showing the
// rows already in the list (the stale-last-good rule the rest of this page follows).
state.events.fetchedAt = Date.now();
state.events.painted = true;
} catch (err) { /* next poll */ }
}
@@ -468,6 +485,24 @@ document.addEventListener('DOMContentLoaded', async () => {
$('#dialog-run').disabled = event.target.value.trim() !== 'DANGER';
});
// The disclosure is the feed's only fetch trigger besides the poll loop. The native
// `toggle` event fires on close too; closing just records the state so the loop stops.
const eventsBox = $('.events-box');
if (eventsBox) {
state.events.open = eventsBox.open; // false — the markup ships no `open` attribute
eventsBox.addEventListener('toggle', () => {
state.events.open = eventsBox.open;
if (!eventsBox.open) return;
// First expand: `painted` is false — fetch. Re-expand: paint from the data in hand only
// while it is fresh; a cache older than one poll interval is stale and re-fetches. A
// fresh, already-painted list needs nothing: the `<details>` keeps its children, so the
// rows (and the pager's page, in its WeakMap) are already where the operator left them.
if (!state.events.painted || Date.now() - state.events.fetchedAt > state.pollMs) {
refreshEvents();
}
});
}
state.poller = poll(refresh, state.pollMs);
Dash.bindPause(state.poller);
});
+335
View File
@@ -1754,6 +1754,185 @@ def test_the_event_feeds_scroll_instead_of_growing_the_page(client) -> None:
assert '<ul class="events" id="events"' in html, f"{path} lost its feed's id"
def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> None:
"""The `/ups` feed is *expandable*: one `details.events-box`, shut by default.
`TODO.md:4` asks for an expandable list of incidents, and the shut disclosure is the half
that costs something — a collapsed `<details>` renders no content, which is what lets the
page fetch nothing while nobody is looking at the feed (`/system`'s Charts rule). Pinned on
the served bytes because there is no JS test runner (a locked anchor): an `open` attribute
would undo the phase silently, and the `#events` id has to stay *inside* the disclosure or
the pager and the bounded 320px scroll lose their target.
"""
html = _code(client.get("/ups").text)
box = re.search(r'<details class="events-box">(.*?)</details>', html, re.S)
assert box, "the Power events card is no longer a details.events-box disclosure"
assert not re.search(r"<details[^>]*\bopen\b", html), \
"the disclosure ships open — the list is tall again, not expandable"
inner = box.group(1)
assert "<summary>Power events</summary>" in inner, "the summary lost its label"
assert '<ul class="events" id="events"' in inner, "the feed moved outside the disclosure"
assert "loading…" in inner, "the first-expand placeholder is gone"
assert "<h3>Power events</h3>" not in html, "the card heads its own body twice"
# the rest of the page is untouched by the disclosure
for still_there in ('id="ups-cards"', 'id="chart-charge"', 'id="chart-load"',
'id="confirm-dialog"'):
assert still_there in client.get("/ups").text, f"/ups lost {still_there}"
def test_ups_events_are_fetched_only_while_the_disclosure_is_open(client) -> None:
"""A closed Power events card costs the poll loop *nothing* — no fetch, no rows.
Hiding a list that is still fetched every 5 s is the half-measure this pins out: the
disclosure is only worth having if `refresh()` stops asking `/api/events` while it is shut
(the `/system` Charts rule — "nothing is built or drawn while the stack is shut"), and the
expand still has to fill the list, so the toggle needs the two facts a re-open is judged
on: has it ever painted, and how old is what it painted. Pinned on the served script
because there is no JS test runner (a locked anchor).
"""
js = _code(client.get("/static/page-ups.js").text)
# The state the two paths share: closed, nothing painted yet, nothing fetched yet.
decl = js[js.index("const state = {"):js.index("const MARKS")]
assert "events: { open: false, painted: false, fetchedAt: 0 }" in decl, \
"the disclosure lost the state its lazy rule reads"
# The poll path asks only while the card is open.
refresh = js[js.index("async function refresh("):js.index("async function refreshEvents(")]
assert "if (state.events.open) await refreshEvents();" in refresh, \
"the poll loop fetches the event feed while the disclosure is shut"
assert not re.search(r"^\s*await refreshEvents();", refresh, flags=re.M), \
"an ungated refreshEvents() call survived in the poll path"
# …and the fetch stamps its own freshness, on the success path only: a failed fetch must
# leave `painted`/`fetchedAt` alone so the next open re-fetches (invariant 4's shape).
feed = _event_feed_path(js)
assert "state.events.fetchedAt = Date.now();" in feed and "state.events.painted = true;" in feed, \
"a successful fetch does not stamp the cache the re-expand rule depends on"
assert feed.index("Dash.eventsPager(") < feed.index("state.events.fetchedAt = Date.now();"), \
"the cache is stamped before the rows are drawn"
assert feed.index("state.events.painted = true;") < feed.index("} catch (err)"), \
"a failed fetch would mark the list as painted"
# The toggle is bound before the loop starts, so the first expand of the session is wired.
boot = js[js.index("document.addEventListener('DOMContentLoaded'"):]
handler = boot[boot.index("const eventsBox = $('.events-box');"):boot.index("state.poller = poll(")]
assert boot.index("const eventsBox =") < boot.index("state.poller = poll("), \
"the disclosure is wired after the first poll"
assert "state.events.open = eventsBox.open;" in handler, \
"the toggle never learns whether the card is open"
assert "if (!eventsBox.open) return;" in handler, \
"closing the card takes the fetch gate off"
# first expand fetches; a re-expand paints the cache while it is one poll interval old
assert "if (!state.events.painted || Date.now() - state.events.fetchedAt > state.pollMs) {" in handler, \
"the expand lost its stale rule (one poll interval, the page's own cadence)"
assert "refreshEvents();" in handler[handler.index("if (!state.events.painted"):], \
"an open disclosure no longer fetches on a stale or empty cache"
def test_the_ups_event_rows_keep_the_date_time_and_severity_shape(client) -> None:
"""`TODO.md:4`'s other half: an expandable list is only *clear* if each row says when.
The disclosure is the new part of this phase; this is the half that had to survive it
untouched. Every row still opens with a `<time>` carrying a month/day plus a 24-hour clock,
then the UPS name inside the `sev <severity>` chip the stylesheet colours from the payload's
own verdict, then the message through `eventText()` (subject named once) with the runtime
footnote only when the event actually carries one. Pinned on the served script because there
is no JS test runner (a locked anchor): a "tidy-up" of the row template that drops the
timestamp, prints the raw message, or escapes nothing would ship silently otherwise.
"""
js = _code(client.get("/static/page-ups.js").text)
feed = _event_feed_path(js)
at = feed.index("row: (event) =>")
row = feed[at:feed.index("});", at)]
# the date/time: `<time>` first, month + day + hour + minute, 24-hour (the operator reads
# an incident log in the order it happened, and "14:05" on its own is not that)
assert "<time>" in row and "</time>" in row, "the row lost its timestamp element"
assert "new Date(event.ts * 1000).toLocaleString(" in row, \
"the row formats something other than the event's own timestamp"
for option in ("month: 'short'", "day: 'numeric'", "hour: '2-digit'", "minute: '2-digit'",
"hour12: false"):
assert option in row, f"the timestamp no longer asks for {option}"
assert row.index("<time>") < row.index('class="sev'), "the row no longer leads with the date"
# who: the host inside the verdict chip, escaped, and the severity is the API's word
assert 'class="sev ${event.severity}"' in row, \
"the row stopped taking its colour word from the stored severity"
assert "${DashCharts.escapeHtml(event.host)}</span>" in row, \
"the row no longer names the UPS it belongs to, escaped"
# what: the message through the shared subject-said-once cleaner, and escaped
assert "${DashCharts.escapeHtml(eventText(event))}" in row, \
"the message is no longer cleaned and escaped (raw HTML from an upstream is a bug)"
# the runtime footnote is conditional: an event with no runtime says nothing about one
assert "event.data && event.data.runtime_seconds" in row, \
"the runtime footnote is no longer gated on the event carrying a runtime"
assert "duration(event.data.runtime_seconds)} runtime)" in row, \
"the runtime footnote stopped printing the stored seconds as a duration"
# and the renderer is still handed to the shared pager over the bounded list, labelled
assert "Dash.eventsPager($('#events'), data.events, {" in feed, \
"the feed left the shared pager (no window, no page, no accessible name)"
assert "label: 'Power events'" in feed, "the scroll container lost its accessible name"
assert "No power events yet" in feed, "the empty feed no longer says which feed is empty"
def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(client) -> None:
"""The `/ups` summary borrows the card header's rhythm and no new palette (task 03).
Pinned on the served stylesheet because a CSS edit is invisible until someone looks: the
`\\25B8`/`\\25BE` caret pair says open/closed in a **shape** as well as a colour, the webkit
marker is hidden so the browser's own triangle does not double it, and this `<summary>`
carries the card header's ground and bottom rule because it *replaces* that header instead of
sitting inside the body like `charts-box`'s toggle does. The `> .body` padding restatement is
the quiet one: `.card > .body` is a **child** selector, so a body moved inside a disclosure
silently loses its padding and the feed renders flush to the card edge.
What the block must not do is grow a verdict colour (open/closed is not a grading) or
re-state the shared feed rules — `ul.events`' bounded scroll and `.events-pager`'s strip are
class-based and resolve inside the disclosure unchanged, so a second copy here would be a
second thing to keep in sync. The undeclared-token audit itself stays the existing
`test_no_stylesheet_rule_uses_an_undeclared_token`'s job, untouched.
"""
css = _css(client)
start = css.index("details.events-box { ")
block = css[start:css.index(".host-charts {", start)]
assert 'details.events-box > summary::before { content: "\\25B8"' in block, \
"the toggle has no caret shape"
assert 'details.events-box[open] > summary::before { content: "\\25BE"; }' in block, \
"open and closed differ only in colour"
assert "details.events-box > summary::-webkit-details-marker { display: none; }" in block, \
"the browser's own marker doubles the caret"
assert "cursor: pointer" in block and "list-style: none" in block, \
"the summary no longer reads as a control"
# it replaces the card header, so it carries the header's ground and bottom rule
assert "border-bottom: 1px solid var(--border)" in block, \
"a closed card no longer reads as its heading"
assert "color-mix(in srgb, var(--bg-raised) 70%, var(--bg-sunken))" in block, \
"the summary lost the header's sunken ground"
# open: the label steps back, and no margin — the `.body` below carries the spacing
assert "details.events-box[open] > summary { color: var(--text-faint); }" in block, \
"an open summary does not de-emphasise"
assert "margin-bottom" not in block, "the open summary stacks a gap on top of the body's"
# the child-selector gotcha that task 01's markup introduced
assert "details.events-box > .body { padding: .8rem; }" in block, \
"the feed body lost its padding inside the disclosure"
for token in ("var(--ok)", "var(--warn)", "var(--fail)", "var(--accent)"):
assert token not in block, f"the disclosure graded open/closed with {token}"
assert ".sev-" not in block, "the disclosure invented a verdict family"
# one copy of each shared feed rule, class-based, resolving inside the disclosure as elsewhere
assert css.count("ul.events {") == 1, "ul.events is styled twice — the bounded scroll drifted"
assert css.count(".events-pager {") == 1, "the pager strip is styled twice"
# the class this block styles is the class the page renders
assert 'class="events-box"' in client.get("/ups").text, \
"the stylesheet styles a disclosure no page renders"
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
"""New events reset the window; the same events do not.
@@ -3850,6 +4029,162 @@ def test_the_disks_tab_is_in_the_masthead_of_every_page(client) -> None:
< html.index('href="/llm"')), "tab order is machine, drive, engine, power"
# ---- the collapsing masthead nav (phase 18) -------------------------------- #
#
# At ≤640px the five tab links leave the bar and come back as a dropdown under it, opened by
# one `☰` button. There is no JS test runner (a locked anchor), so the phase is pinned the way
# every page surface here is: on the served bytes. Three questions, in the order the pieces
# were built — is the button on every tabbed page and on no others, does the stylesheet still
# hide it above the breakpoint, and do the stylesheet and the binding still agree on the
# breakpoint itself? That last one is the only failure mode no browser check catches: a number
# that drifts in one file opens a menu into a `display: none` nav on the operator's phone.
#: The attributes that make `#nav-toggle` a button and not a mystery glyph: it is not a form
#: control (`type`), it says what it toggles (`aria-controls`), it says what state it is in
#: (`aria-expanded`, which `app.js` keeps honest), and its only content is a `☰`.
NAV_TOGGLE_ATTRS = ('class="ghost nav-toggle"', 'type="button"', 'aria-expanded="false"',
'aria-controls="nav-tabs"', 'aria-label="Toggle navigation"')
def _masthead(html: str) -> str:
"""The served page's own masthead — the block the toggle and the nav have to live in.
Slicing rather than searching the whole document is the point: a button in the footer would
satisfy `id="nav-toggle" in html` and anchor to nothing, because the dropdown's
`top: 100%` resolves against `.masthead`.
"""
start = html.index('<header class="masthead"')
return html[start:html.index("</header>", start)]
def test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page(client) -> None:
"""Seven pages, one identical button, and the nav it controls still carrying five tabs.
The dropdown is the only way to change dashboard on a phone, so a page missing the button is
a page you can enter and leave the tab bar on, and a page whose nav lost its `id` is a button
that toggles nothing. Pinned on all seven (`MARKUP_PAGES`) rather than on `PAGES` because the
two admin pages have the same masthead and therefore the same contract.
"""
for path in MARKUP_PAGES:
header = _masthead(client.get(path).text)
button = re.search(r'<button[^>]*id="nav-toggle"[^>]*>', header, re.S)
assert button, f"{path}'s masthead has no #nav-toggle button"
for attr in NAV_TOGGLE_ATTRS:
assert attr in button.group(0), f"{path}'s nav toggle is missing {attr}"
assert header.count('id="nav-toggle"') == 1, f"{path} has more than one nav toggle"
# The nav is the menu: the id the button names, and all five dashboards inside it.
assert '<nav class="tabs" id="nav-tabs" aria-label="Dashboards">' in header, \
f"{path}'s tab nav has no #nav-tabs for the toggle to control"
nav = header[header.index('id="nav-tabs"'):header.index("</nav>",
header.index('id="nav-tabs"'))]
for tab in PAGES:
assert f'href="{tab}"' in nav, f"{path} collapsed a nav that lost the {tab} tab"
# Order inside the masthead: brand, toggle, nav — and the cluster after them, because
# the ≤640px block right-aligns the button and wraps the cluster onto its own row.
assert (header.index('id="nav-toggle"') < header.index('id="nav-tabs"')
< header.index('class="masthead-right"')), \
f"{path} put the toggle or the nav outside the brand → tabs → cluster row"
def test_the_login_page_carries_no_nav_and_no_toggle(tmp_path: Path) -> None:
"""`/login` is brand link + theme toggle, and the shared binding must be inert there.
No `client` fixture, and that is the whole shape of the test: the signed-in one cannot see
this page at all (a signed-in `GET /login` is a `303` to the dashboard, and following it
audits `/`'s markup under the name `/login`), which is the same redirect trap
`tests/test_auth_api.py::test_the_login_page_carries_no_id_nothing_reads_and_no_dashboard_nav`
documents for the orphan-`id` audit.
Every tab on this page bounces straight back to it, so the page renders no tabs at all; a
stray hamburger would be a control that opens a menu of redirects, and the `640px` block
would reveal it. The last assertion is the other half of the contract: `app.js` still loads
here, which is only safe because `if (navToggle && appNav)` no-ops without the elements.
"""
db = str(tmp_path / "login-nav.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"
html = response.text
finally:
store.close()
for absent in ('id="nav-toggle"', 'id="nav-tabs"', 'class="tabs"',
'aria-controls="nav-tabs"', 'aria-label="Dashboards"'):
assert absent not in html, f"/login renders the collapsed nav's {absent}"
assert '<a class="brand" href="/">' in html and 'id="theme-toggle"' in html, \
"/login lost the two pieces of chrome that do belong on it"
assert 'src="/static/app.js"' in html, (
"/login stopped loading the shared chrome whose guard is its no-toggle contract")
def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> None:
"""Hidden by default, revealed at one breakpoint, in two files that must agree on its value.
The base rule is *not* a literal `.nav-toggle { display: none }` and pinning that literal
would be pinning a bug: the element also carries the shared `ghost` class, whose
`button.ghost { display: inline-flex }` out-ranks a single-class rule and ships a visible
button into the desktop masthead. The shipped shape is an id rule reading a token
(`--nav-toggle-display`, `none` in `:root`), which the ≤640px block flips by re-declaring the
token on the element — so that is what is asserted here, plus the dropdown and the four close
paths the binding owns.
"""
css = _code(_css(client))
assert re.search(r"--nav-toggle-display:\s*none", css), \
"the nav toggle is no longer hidden by default — the desktop masthead grew a ☰"
assert re.search(r"#nav-toggle\s*\{\s*display:\s*var\(--nav-toggle-display\)", css), \
"#nav-toggle stopped reading the token that keeps its reveal order-independent"
nav_css = _code(_css_section(_css(client), "mobile nav"))
media = re.search(r"@media\s*\(max-width:\s*(\d+)px\)", nav_css)
assert media, "the mobile nav block lost its media query"
for rule in ("--nav-toggle-display: inline-flex", # the reveal, through task 01's token
"nav.tabs { display: none; }", # the links leave the row
"nav.tabs.is-open", # …and come back as the menu
"position: absolute",
"top: 100%", # pinned under the sticky masthead
"z-index: 41", # one above the masthead's 40
"background: var(--bg-raised)",
"flex-wrap: wrap", # the two-row bar, cluster still visible
# the safe-area inset keeps the first-row #nav-toggle out of the system
# top-edge zone a real phone's status bar owns (taps there never reach
# the page; headless emulation has no such zone, so only this pin says
# the bar still pads for it):
"padding-top: calc(.5rem + env(safe-area-inset-top, 0px))"):
assert rule in nav_css, f"the ≤640px nav block lost `{rule}`"
# Nothing on the phone masthead may be hidden to make the width work: the cluster wraps.
assert ".masthead-right" in nav_css and "#nav-status" in nav_css, \
"the mobile masthead hid the status cluster instead of wrapping it"
js = _code(client.get("/static/app.js").text)
binding = js[js.index("const navToggle ="):js.index("function rangeControl(")]
assert "if (navToggle && appNav)" in binding, \
"the binding lost the guard that makes it inert on /login"
assert binding.count("classList.toggle('is-open'") == 1, \
"a state change now bypasses setNavMenu(), so is-open and aria-expanded can diverge"
for path in ("navToggle.setAttribute('aria-expanded'",
"navToggle.addEventListener('click'", # close (and open) on the button
"event.target.closest('a')", # close on a tab click, delegated
"event.key === 'Escape'", # close on Esc…
"navToggle.focus()", # …with focus back on the opener
"if (!mq.matches) setNavMenu(false)"): # close on resize back to desktop
assert path in binding, f"the nav binding lost a close path: {path}"
assert "mq.addEventListener" in binding, "the resize-back close was never registered"
js_bp = re.search(r"matchMedia\('\(max-width: (\d+)px\)'\)", js)
assert js_bp, "app.js no longer reads the nav breakpoint through matchMedia"
assert js_bp.group(1) == media.group(1), (
f"the nav breakpoint drifted: CSS hides the tabs below {media.group(1)}px, "
f"app.js closes the menu below {js_bp.group(1)}px")
assert media.group(1) == "640", "the nav breakpoint moved off the 640px the phase shipped"
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
"""`disks` gets its own dot instead of hiding inside `hosts`.