Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95df8b695b | ||
|
|
f9b1565abb | ||
|
|
10f86772ee |
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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 @@
|
|||||||
|
ok
|
||||||
@@ -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/`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
+13
@@ -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`).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
+19
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
+16
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
+19
@@ -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).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -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
|
||||||
@@ -739,6 +739,43 @@ Responsibilities, one line each:
|
|||||||
`severity` verdict the API sent
|
`severity` verdict the API sent
|
||||||
(`tests/test_api.py` fails if a page script names it again). A value the API should rate
|
(`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.
|
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`,
|
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
|
`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 status # per-source ok/DOWN — unauthenticated: /api/health
|
||||||
./scripts/dev-server.sh logs
|
./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
|
# 4 store_perf), no network
|
||||||
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
||||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
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
|
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
|
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
|
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
|
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).
|
`.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:
|
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
|
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.
|
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)
|
## Locked decisions (and why)
|
||||||
|
|
||||||
|
|||||||
@@ -609,8 +609,10 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
|||||||
marker that says how many it hid.
|
marker that says how many it hid.
|
||||||
* `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`,
|
* `api.py` — the JSON every page reads: `/api/config`, `/api/health`, `/api/events`,
|
||||||
`/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per
|
`/api/history`, `/api/metrics`, `/api/summary`, one `snapshot` + `history` pair per
|
||||||
dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*`), and the certificate
|
dashboard (`/api/system/*`, `/api/disks/*`, `/api/llm/*`, `/api/ups/*` — including
|
||||||
authority's own surface (`/api/certs`, `/api/certs/<name>/bundle`, `POST …/rotate`, `POST
|
`/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`,
|
…/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
|
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
|
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
|
* `web/` — five static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`) plus the three
|
||||||
admin pages (`/login`, `/settings`, `/certs`), one stylesheet, hand-rolled
|
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:
|
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
|
the same agent can answer the 10-second utilisation poll and have a wedged SMART pass). At
|
||||||
framework, no CDN, no bundler, and no narration: a page never explains its own pipeline, so
|
≤640px the five tab links leave the bar and come back as a dropdown under it, opened by the `☰`
|
||||||
the words on screen are the ones you act on — a unit, a `Δ10.0s` provenance tag, a
|
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
|
`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
|
**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
|
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
|
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
|
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.
|
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
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# `/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
|
# `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
|
# `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
|
# `/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.
|
# certificate authority is not a fifth dashboard, it is the thing you administer past them.
|
||||||
PAGES = {"/": "index.html", "/system": "system.html", "/disks": "disks.html",
|
PAGES = {"/": "index.html", "/system": "system.html", "/disks": "disks.html",
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
that marks a tab this page is not would be a lie about the navigation. The masthead
|
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
|
carries the two admin links — `⚙ Account` and `⚷ Certs` — outside `.tabs`, so the tab
|
||||||
structure stays five links deep on every page. -->
|
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="/">Overview</a>
|
||||||
<a href="/system">System</a>
|
<a href="/system">System</a>
|
||||||
<a href="/disks">Disks</a>
|
<a href="/disks">Disks</a>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
|
<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="/">Overview</a>
|
||||||
<a href="/system">System</a>
|
<a href="/system">System</a>
|
||||||
<a href="/disks" aria-current="page">Disks</a>
|
<a href="/disks" aria-current="page">Disks</a>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
|
<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="/" aria-current="page">Overview</a>
|
||||||
<a href="/system">System</a>
|
<a href="/system">System</a>
|
||||||
<a href="/disks">Disks</a>
|
<a href="/disks">Disks</a>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
|
<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="/">Overview</a>
|
||||||
<a href="/system">System</a>
|
<a href="/system">System</a>
|
||||||
<a href="/disks">Disks</a>
|
<a href="/disks">Disks</a>
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
<!-- The five dashboards, and no `aria-current`: this page is not one of them, and a tab
|
<!-- 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.
|
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. -->
|
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="/">Overview</a>
|
||||||
<a href="/system">System</a>
|
<a href="/system">System</a>
|
||||||
<a href="/disks">Disks</a>
|
<a href="/disks">Disks</a>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
|
<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="/">Overview</a>
|
||||||
<a href="/system" aria-current="page">System</a>
|
<a href="/system" aria-current="page">System</a>
|
||||||
<a href="/disks">Disks</a>
|
<a href="/disks">Disks</a>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<a class="brand" href="/"><span class="brand-mark">◆</span> Homelab</a>
|
<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="/">Overview</a>
|
||||||
<a href="/system">System</a>
|
<a href="/system">System</a>
|
||||||
<a href="/disks">Disks</a>
|
<a href="/disks">Disks</a>
|
||||||
@@ -71,8 +73,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card" style="margin-top:.85rem">
|
<section class="card" style="margin-top:.85rem">
|
||||||
<header><h3>Power events</h3></header>
|
<details class="events-box">
|
||||||
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
|
<summary>Power events</summary>
|
||||||
|
<div class="body"><ul class="events" id="events"><li class="muted">loading…</li></ul></div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer class="pagefoot">
|
<footer class="pagefoot">
|
||||||
|
|||||||
@@ -680,6 +680,48 @@ const Dash = (() => {
|
|||||||
refreshStatus();
|
refreshStatus();
|
||||||
setInterval(refreshStatus, 15000);
|
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). */
|
/** Range buttons (5m … 30d). Calls onChange(seconds, key). */
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
--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;
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
--header-h: 58px;
|
--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"] {
|
[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). */
|
or the last page is a control that lies about there being more history). */
|
||||||
button.ghost:disabled { opacity: .45; cursor: not-allowed; }
|
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 ------------------------------------------------------------- */
|
/* ---- layout ------------------------------------------------------------- */
|
||||||
main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
|
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 */
|
/* 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; }
|
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-charts { display: grid; gap: .7rem; }
|
||||||
.host-chart { display: grid; gap: .15rem; min-width: 0; }
|
.host-chart { display: grid; gap: .15rem; min-width: 0; }
|
||||||
/* `sm` is the slot's floor before `LineChart` writes its measured height inline, so an
|
/* `sm` is the slot's floor before `LineChart` writes its measured height inline, so an
|
||||||
|
|||||||
@@ -9,13 +9,22 @@
|
|||||||
* Commands are the only write path in the product. They are offered only when the backend
|
* 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
|
* 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.
|
* 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';
|
'use strict';
|
||||||
|
|
||||||
const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash;
|
const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash;
|
||||||
|
|
||||||
const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000,
|
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: '?' };
|
const MARKS = { ok: '✓', warn: '!', fail: '✕', unknown: '?' };
|
||||||
|
|
||||||
@@ -424,7 +433,10 @@ async function refresh() {
|
|||||||
$('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · ` +
|
$('#foot-note').textContent = `poll ${Math.round(state.pollMs / 1000)}s · ` +
|
||||||
`${items.reduce((total, item) => total + (item.advertised_commands || []).length, 0)} commands advertised`;
|
`${items.reduce((total, item) => total + (item.advertised_commands || []).length, 0)} commands advertised`;
|
||||||
await refreshCharts(items.map((item) => item.name));
|
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() {
|
async function refreshEvents() {
|
||||||
@@ -445,6 +457,11 @@ async function refreshEvents() {
|
|||||||
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
|
`${event.data && event.data.runtime_seconds ? ` <span class="faint mono">(${
|
||||||
duration(event.data.runtime_seconds)} runtime)</span>` : ''}</span>`,
|
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 */ }
|
} catch (err) { /* next poll */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +485,24 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
$('#dialog-run').disabled = event.target.value.trim() !== 'DANGER';
|
$('#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);
|
state.poller = poll(refresh, state.pollMs);
|
||||||
Dash.bindPause(state.poller);
|
Dash.bindPause(state.poller);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
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:
|
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
|
||||||
"""New events reset the window; the same events do not.
|
"""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"
|
< 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:
|
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
|
||||||
"""`disks` gets its own dot instead of hiding inside `hosts`.
|
"""`disks` gets its own dot instead of hiding inside `hosts`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user