diff --git a/.agents/phases/todo/05_ups_hero_battery_glyph/00_phase.md b/.agents/phases/complete/05_ups_hero_battery_glyph/00_phase.md similarity index 100% rename from .agents/phases/todo/05_ups_hero_battery_glyph/00_phase.md rename to .agents/phases/complete/05_ups_hero_battery_glyph/00_phase.md diff --git a/.agents/phases/todo/05_ups_hero_battery_glyph/01_ups_hero_data.md b/.agents/phases/complete/05_ups_hero_battery_glyph/01_ups_hero_data.md similarity index 100% rename from .agents/phases/todo/05_ups_hero_battery_glyph/01_ups_hero_data.md rename to .agents/phases/complete/05_ups_hero_battery_glyph/01_ups_hero_data.md diff --git a/.agents/phases/todo/05_ups_hero_battery_glyph/02_battery_glyph.md b/.agents/phases/complete/05_ups_hero_battery_glyph/02_battery_glyph.md similarity index 100% rename from .agents/phases/todo/05_ups_hero_battery_glyph/02_battery_glyph.md rename to .agents/phases/complete/05_ups_hero_battery_glyph/02_battery_glyph.md diff --git a/.agents/phases/todo/05_ups_hero_battery_glyph/03_ups_hero_row.md b/.agents/phases/complete/05_ups_hero_battery_glyph/03_ups_hero_row.md similarity index 100% rename from .agents/phases/todo/05_ups_hero_battery_glyph/03_ups_hero_row.md rename to .agents/phases/complete/05_ups_hero_battery_glyph/03_ups_hero_row.md diff --git a/.agents/phases/todo/05_ups_hero_battery_glyph/04_verify_real_batteries.md b/.agents/phases/complete/05_ups_hero_battery_glyph/04_verify_real_batteries.md similarity index 100% rename from .agents/phases/todo/05_ups_hero_battery_glyph/04_verify_real_batteries.md rename to .agents/phases/complete/05_ups_hero_battery_glyph/04_verify_real_batteries.md diff --git a/.agents/phases/todo/05_ups_hero_battery_glyph/NOTES-verify.md b/.agents/phases/todo/05_ups_hero_battery_glyph/NOTES-verify.md new file mode 100644 index 0000000..199fd18 --- /dev/null +++ b/.agents/phases/todo/05_ups_hero_battery_glyph/NOTES-verify.md @@ -0,0 +1,109 @@ +# Verification notes — the hero row on real batteries, and the states only the sim can give + +**Date:** 2026-10-17. Real pass: dev tunnels 9121/9122/9123 + upsd forward `3494 → driveripper:3051` +all OK, `dev-server.sh status` six sources `ok`, `/api/ups/snapshot` `simulated: false`. +Simulator pass: the production app (`create_app()` with the real poller, payload builder and +severity table) run in-process on `127.0.0.1:8091` with `NUT_SIMULATE=1` — states driven through +`SimUps.tick()/step()` and `NutMonitor.read()` exactly as the 5-second poll loop drives them. +All screenshots are headless Chromium (the accepted verification browser for phases 02–04), dark +theme, poll paused before each shot; every shot was opened and read, not just written. + +## 1. Real fleet — `data/logs/ups-real-apc.png`, `ups-real-cyberpower.png` + +| | `apc` (Back-UPS RS 1500MS) | `cyberpower` (GX1500U / PR1500LCDRT2U label) | +| --- | --- | --- | +| glyph | full green cell, solid shell, no bolt | same | +| Charge | **100 %** green, `24 V nominal · 27.3 V` | **100 %** green, `24 V nominal · 27.2 V` | +| Load | **48.0 %** green + bar (no `realpower` from this driver → no watt sub) | **14.0 %** green + bar, `127 W of 900 W` | +| Runtime left | **10m** green, `at the current load` | **56m** green, `at the current load` | +| Battery age check | **warn** — "5.3 years — past the 4-year replacement window" | **unknown** — "no battery.mfr.date / battery.date from this driver" (`battery.mfr.date: CPS` did not become a fake age) | +| Battery °C | `n/a`, uncoloured | `n/a`, uncoloured | +| health chip | `degraded` (from the age warn) | `degraded` (from "No test initiated") | +| SIMULATED badge | **absent** (`hidden`, and no banner) — real upsd read | **absent** | + +The two driver disagreements the task calls the point are visibly different on the page: the APC +gets an amber age warning, the CyberPower gets a `?` "unknown" — the page is not rendering a +template. Zero POSTs on the real page. + +## 2. States only `nut_sim.py` can produce — five shots, badge visible in every one + +`NUT_SIMULATE=1`, `NUT_ALLOW_ACTIONS=0`; apc card driven to each state (cyberpower left at 100 % +on line as the contrast). The `SIMULATED` chip is visible on the card header in every shot, and +the full-page shot (`ups-sim-12-low-page.png`) shows the "SIMULATED DATA. NUT_SIMULATE=1" banner +plus the "Command buttons are disabled" info banner. + +| Shot | State proven (API-confirmed before the shot) | +| --- | --- | +| `ups-sim-100-online.png` | 100 % / `ok` green, glyph solid shell **no bolt**, chips `fully charged, online, OL`, health healthy | +| `ups-sim-60-charging.png` | 59 % / `ok` green, glyph fill ~59 % **with bolt**, chips `charging, online, OL CHRG` | +| `ups-sim-35-onbattery.png` | 34 % / **`warn` amber**, glyph dashed shell (on-battery shape), chips `discharging, on battery, OB`, battery V 21.8 V | +| `ups-sim-12-low.png` (+ `ups-sim-12-low-page.png`) | 12 % / **`fail` red**, `OB LB` → chip `low battery[fail]`, glyph red dashed shell nearly empty, runtime `9m` amber, health `at risk`, events `apc OL → OL CHRG → OB → OB LB` | +| `ups-sim-nacharge.png` | `battery.charge`/`battery.runtime` not exported → Charge **`n/a`** and Runtime **`n/a`**, no severity class on either, glyph = grey outline, empty body, no colour; checks say "battery.charge is not exported" / "battery.runtime is not exported (the driver cannot estimate it)"; health chip `unknown` | + +Runtime on the low-charge shots is the model's own estimate (`step()` drain under load), not a +typed-in number; the 99.9 %→`full` and <20 %→`LB` boundaries fired on their own as charge fell. + +## 3. Defects found and fixed while doing this + +1. **The simulator could not produce the "charging" state.** `SimUps.vars()` answered `ol`/`OL` + while the pack was recharging and `ol` even while `OB` near full, so `charger_status == "chg"` + — the only route to the glyph's bolt — was unreachable in dev, and flag and token + contradicted each other. Fixed (`sources/nut_sim.py`): on battery → `OB`(+`LB`)/`dchg`; + on line below float → `OL CHRG`/`chg` (exactly what a real APC prints); ≥99.9 % → `OL`/`full`. + Pinned by `test_a_recharging_simulated_ups_reports_charging_like_a_real_driver` and + `test_a_discharging_simulated_ups_is_never_online`. The `ups-sim-60-charging.png` above was + shot after the fix; that state is now reproducible for anyone (`drain on battery`, come back + on line). +2. **The chip row said "charging" twice** on drivers exporting both `battery.charger.status` and + the legacy flag (made visible by 1). `UpsReading.states()` now emits one chip per fact — the + 2.8 token wins, the subsumed flag is dropped — pinned by + `test_states_never_say_the_same_word_twice` (incl. the `dchg`+`OB DISCHRG` pair and the + flag-only route keeping both facts). + +Both were pre-existing (the old code duplicated `online` the same way for any token+flag pair); +neither changes a stored metric, a severity, or a gate. + +## 4. Dead source — `data/logs/ups-stale-lastgood.png` + +Stopped **only the 3494 forward** (the generated supervisor + its `ssh -N`, the scoped variant of +`dev-tunnels.sh stop`; remote `nut-server` untouched). After 75 s: red banner +`upsd: NutError: cannot reach upsd at 127.0.0.1:3494`, both cards keep their last-good heroes +(100 % / 44.0 % / 12m, 100 % / 14.0 % / 59m — nothing zeroed, nothing `n/a`), dots red, header +`stale 75s`. Restored with `./scripts/dev-tunnels.sh start`; status and `/api/ups/snapshot` back +to answering real upsd (`simulated: false`). + +## 5. The command gate — exercised, cancelled, nothing sent + +On the live page every command button is disabled server-side ("NUT_USER / NUT_PASSWORD must name +a user with `actions` in upsd.users" — AGENTS gap 1, unchanged), so the dialog was exercised on +the simulator run **with `NUT_ALLOW_ACTIONS=1`**, where the buttons are genuinely enabled — no +DOM hacks — and Cancel guarantees nothing is sent: + +- **Quick battery test** (caution) → dialog `risk: caution`, "this UPS is simulated" in the + target line, no DANGER input for a caution command → **Cancel**, dialog closed + (`ups-dialog-caution.png`). +- **Deep battery test** (dangerous) → dialog with `dangerous` fail chip, Run **disabled**, types + `DANGER` → Run enables (`action dangerous`) → **Cancel** + (`ups-dialog-danger-typed.png`). +- Fetch spy recorded **0 POSTs** across the session, and the sim's `ups.test.result` was still + `PASSED (2026-06-14)` afterwards — the command never reached a "driver". + +No live instant command was sent; the operator did not ask for one. (`ups.test.result` on both +real UPSes remains `No test initiated` — still a warn row each.) + +## 6. Note on the event feed during the outage + +The event log in the stale screenshot carries failure/recovery flapping from ~08:33 onward that +predates this task (another process was restarting the forward and probing it — see the +`expected 'BEGIN LIST VAR', got 'CMD apc beeper:enable'` rows at 08:24, which is a raw-socket +probe sharing the port, not the dashboard's client; our poller speaks one request per +connection). Each row is a genuine transition, and the invariant-4 rule (one line per repeated +failure) held during the clean 09:10 outage: exactly one fail line and one recovery line. + +## After the fixes + +`bash .agents/validate.sh` → **EXIT=0**: 533 passed + 8 skipped, coverage **96.98 %** (floor 94), +ruff + pyright clean, `node --check` clean. `uv run pytest -m live --live` → **8 passed** (both +real UPSes through the hero contract, agents, engines). Docs: `AGENTS.md` — sim status semantics, +one-chip-per-fact rule, test count 530→533; no README change needed (`NUT_SIMULATE` wording was +already right). diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.err b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.md b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.md new file mode 100644 index 0000000..e4aff13 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.md @@ -0,0 +1,16 @@ +**Phase 05 final pass — all criteria met; whole project 100 % green.** + +Verified (live upsd + isolated simulator): +- Real `apc`/`cyberpower`: Charge/Load/Runtime are the card's three largest elements (38.4 px vs 15.2 px next), coloured only by API `severity` — live right now 100 % ok / 79.0 % **warn** / 5m **fail**; glyph fill = charge (100/100 viewBox units, `--ok` token), `` "apc: Charge 100 percent, on line"; SIMULATED hidden; battery °C `n/a` uncoloured; every button disabled with its reason; `confirm_text: DANGER` on power-cutting commands; **0 non-GET requests** → `data/logs/ups-verify-live-{card,full}.png`. +- Simulator on `:8091` (own `/tmp` DB, killed after): caution dialog → Cancel, dangerous dialog → Run disabled until `DANGER` → Cancel, **0 POSTs**; one deliberate quick test *to the sim only* flipped the card to `OB` (glyph `stroke-dasharray="7 5"`, "on battery", health "discharging, on battery") then `OL CHRG` (bolt) then `OL` → `ups-verify-sim-{dialog-caution,dialog-danger,onbattery-button,onbattery-page,charging-button}.png`; glyph matrix `ups-glyph-*.png` (100/mid/low/unknown/empty/unrated, dark/light/200 %) present; `grep -c "function battery"` = 1. + +Defects found in earlier work and fixed: +1. **`poller.py` could stop polling in silence** — 4 of 6 dev-server sources had frozen `attempts` (ages 12–38 min) while `/api/health` still said `ok: true`: the store-write half of `run_once` was outside the `try`, and a dead task's exception is never retrieved. Now `_commit()` moves `last_ok` only after the write lands, `_store_refused()` reports it (`ok:false`, real reason, one `store-error` event + one `recording` on recovery, payload kept), and `_loop()` catches anything that escapes and keeps polling — 4 new tests in `tests/test_poller.py`. +2. **`nut_sim.py` (modified by this phase) sat at 83 % coverage**; its whole command path was untested. Added tests (every advertised command implemented, unknown → `ValueError`, alarms → card + `assess()` fail, `NUT_SIMULATE` truthy rule) and fixed two model lies: `beeper.*` answered OK while changing nothing (now publishes `ups.beeper.status` enabled/muted/disabled) and a finished self-test never wrote `ups.test.result` (aborted writes none). → 100 %. +3. `nut_sim.from_env()` was dead code duplicating `app._truthy`; `app.py` now builds the simulator through it, so the enable gate lives once. + +Tests / lint (exact): `bash .agents/validate.sh` → **EXIT=0** — `uv run pytest --cov --cov-fail-under=94` **543 passed, 8 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean; repo hygiene clean. `uv run pytest -m live --live` **with `.env.dev` sourced → 8 passed** (one earlier run: 2 transient `9121/snapshot timed out` against the 4 s `HOST_TIMEOUT`; three later runs 8/8). `uv pip list --outdated` empty. Dev server restarted on the final code: `health ok`, 6/6 sources fresh. + +Docs: AGENTS.md — invariant 4 extended ("nothing may stop polling in silence" + store-refusal semantics), `poller.py`/`nut_sim.py` responsibility lines, 533→543 and 96.9 %→97.9 %, "~97 %"→"~98 %". README.md — poller bullet gained the same guarantee, Testing gained "source `.env.dev` before `-m live --live`" (bare runs skip 7 of 8 live tests, which reads like a pass), Battery-test section gained the `deploy/nut-sudoers` install line and the read-before-write warning. + +Notes/deviations: no live instant command was ever sent to real hardware (the four gates leave them disabled — AGENTS gap 1 unchanged); the on-battery proof was driven through the real button against `NUT_SIMULATE=1`, which is stronger than the NOTES hand-driven states. `NOTES-verify.md` under `todo/` left untouched per instructions. Next phase: **none** — 05 is the last in the PLAN.md roadmap. diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.md.status b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.validate b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.validate new file mode 100644 index 0000000..9b9123f --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__00_phase.a1.validate @@ -0,0 +1,47 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 13%] +........................................................................ [ 26%] +........................................................................ [ 39%] +..................................................ssssssss.............. [ 52%] +........................................................................ [ 65%] +........................................................................ [ 78%] +........................................................................ [ 91%] +............................................... [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% 28 +src/dashboards/alerts.py 175 0 100% +src/dashboards/api.py 193 0 100% +src/dashboards/app.py 107 6 94% 50-51, 56, 58, 60, 127 +src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120 +src/dashboards/config.py 113 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 165 3 98% 192-194, 279 +src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164 +src/dashboards/rates.py 37 1 97% 51 +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/host.py 199 1 99% 147 +src/dashboards/sources/llm.py 150 1 99% 228 +src/dashboards/sources/nut.py 373 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 476, 488, 523, 556, 560, 617-618, 623, 635 +src/dashboards/sources/nut_sim.py 134 0 100% +src/dashboards/store.py 178 2 99% 243-244 +------------------------------------------------------------------ +TOTAL 2151 45 98% +Required test coverage of 94% reached. Total coverage: 97.91% +543 passed, 8 skipped, 1 warning in 47.58s +== 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 == diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.err b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.md b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.md new file mode 100644 index 0000000..cf45708 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.md @@ -0,0 +1,29 @@ +All checks green. Final verification summary: + +``` +uv run pytest → 518 passed, 8 skipped (live) +uv run pytest --cov --cov-fail-under=94 → 96.97% (sources/nut.py 95%) +uv run ruff check src tests agent → All checks passed +uv run pyright → 0 errors +node --check web/static/*.js → clean +uv run pytest -m live --live → 8 passed (real apc + cyberpower, engines, fleet) +bash .agents/validate.sh → EXIT=0 · uv pip list --outdated → empty +``` + +**Implemented** +- `tests/fixtures/nut_apc.vars` / `nut_cyberpower.vars`: verbatim `LIST UPS`/`LIST VAR`/`LIST CMD` replies from the real upsd (device serials deterministically scrubbed), recorded by the extended `scripts/capture-fixtures.sh` (new UPS section + serial scrubber) — no hand-typed variable table. +- `tests/ups_contract.py`: `HERO_FIELDS` / `UPS_SHAPES` / `parse_capture()` (uses the client's own wire splitter) / `assert_ups_hero_contract()` — the `/ups` twin of `card_contract.py`, shared with the live suite. +- `tests/test_ups_contract.py` (33 tests): every hero/glyph path on both real drivers; `battery_temperature` `None` (never 0) + `unknown` check; charge 100/45/15 → ok/warn/fail; runtime 3600/400/200 → ok/warn/fail and absent → `None`; served-JS proof `runtimeText(null)` returns the app's `naText` (`n/a`, not `0s`) and runs before any formatter; glyph state table (`ol`/`full`/`chg`/`dchg`), "full + on line never says charging", both on-battery routes, and a guard that `page-ups.js` never re-parses NUT flags. +- `tests/test_live_fleet.py::test_ups_hero_fields_are_present_on_the_real_batteries` — same table against live upsd, read-only. +- Live check after `dev-server restart`: both `apc` and `cyberpower` serve `severity` for charge/load/runtime, `battery_temperature: null`, `charger_status: null`, `simulated: false`. + +**Defects found and fixed** (`sources/nut.py`) +- `battery_runtime` borrowed `battery.runtime.low` (a shutdown *threshold*) as the estimate — the hero would print a confident "5m left". It is now `battery.runtime` only; the threshold stays readable at `battery_runtime_low`. +- `charger_status` is normalised to the lowercase `CHARGER_STATUS` keys (as `states()`/`on_battery` already compare), so a driver answering `FULL` can't cost the glyph its state; `nut.py`'s protocol docstring now shows the verbatim 2.8.5 framing (`VAR apc ups.status "OL"` — quoting is optional). +- `page-ups.js`: `runtimeText()` returns `Dash.naText` instead of a duplicated literal (verified behaviourally with a throwaway node vm run: `n/a` / `10m` / `1h 32m`; no JS test added, per the locked anchor). + +**Docs**: AGENTS.md (nut.py responsibilities incl. the runtime/charger rules, real-hardware quirks incl. "neither driver publishes `battery.charger.status`", UPS capture fixtures + test-count/command lines, testing-policy bullet for `ups_contract.py`) and README.md (fixture capture commands + UPS contract paragraph) updated. + +**Notable decisions**: fixtures over hand-written vars (rule 7, and the drivers genuinely disagree — `battery.mfr.date: CPS`, `battery.date 2001/09/25` with slashes, only CyberPower exports `output.voltage`); no screenshots — task 01 changes no visuals, browser proof is task 04's mandate. + +**Next pending task**: `05_ups_hero_battery_glyph/02_battery_glyph.md` (`DashCharts.battery()`). diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.md.status b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.validate b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.validate new file mode 100644 index 0000000..26aca82 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__01_ups_hero_data.a1.validate @@ -0,0 +1,47 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 13%] +........................................................................ [ 27%] +........................................................................ [ 41%] +......................................ssssssss.......................... [ 54%] +........................................................................ [ 68%] +........................................................................ [ 82%] +........................................................................ [ 95%] +...................... [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% 28 +src/dashboards/alerts.py 175 0 100% +src/dashboards/api.py 193 0 100% +src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131 +src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120 +src/dashboards/config.py 113 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 139 1 99% 226 +src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164 +src/dashboards/rates.py 37 1 97% 51 +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/host.py 199 1 99% 147 +src/dashboards/sources/llm.py 150 1 99% 228 +src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625 +src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226 +src/dashboards/store.py 178 2 99% 243-244 +------------------------------------------------------------------ +TOTAL 2110 64 97% +Required test coverage of 94% reached. Total coverage: 96.97% +518 passed, 8 skipped, 1 warning in 44.57s +== 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 == diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.err b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.md b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.md new file mode 100644 index 0000000..ca31dd5 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.md @@ -0,0 +1,18 @@ +**Implemented** (task `05_ups_hero_battery_glyph/02_battery_glyph.md`) +- `src/dashboards/web/static/charts.js` — `DashCharts.battery(container, {charge, severity, state, label, decorative})`: horizontal cell (rounded shell + nub) in one `<svg>`; `<rect>` fill = `charge/100` of the interior, clipped to the plate so 0 % leaves an empty shell; clamped 0–100 and never padded (verified: 2 % → 2.00 of 100 units, −5/1000/null → nothing); colour only from `cssVar('--ok'|'--warn'|'--fail')`, unrated measured charge = faint grey at half strength (shape, not verdict), unknown = no fill. +- State shapes readable without colour: solid shell `online`, bolt `charging`, dashed outline `onbattery`, dash pattern *through* the body `unknown`; an unknown state word degrades to `unknown`, never "on line". +- Accessible by default: `role="img"` + `<title>` + `aria-label` ("apc: Charge 100 percent, on line"), label escaped; documented opt-out `decorative: true` → `aria-hidden` (chosen: labelled, since the glyph says what the markup doesn't). +- Scales by container (`viewBox` + `preserveAspectRatio`, measured 210×91.9 = the 128:56 ratio); no animation/transition at all; exported in `window.DashCharts`. +- `dash.css` — only the `.battery` box (`width:100%; max-width:210px`, `svg{width:100%;height:auto}`). +- 6 new static-asset tests in `tests/test_api.py` (export, fill-honesty/clipping, token-only colour, state shapes, a11y, scaling/no-motion). + +**Tests / lint / coverage** +- `uv run pytest` → **524 passed, 8 skipped**; `uv run pytest --cov --cov-fail-under=94` → **96.97 %**; `uv run ruff check src tests agent` → passed; `uv run pyright` → 0 errors; `node --check src/dashboards/web/static/*.js` → clean; `uv run pytest -m live --live` → **8 passed**; `bash .agents/validate.sh` → **EXIT=0**. +- Phase criterion: `curl -s localhost:8090/static/charts.js | grep -c "function battery"` → **1**. +- Browser matrix (live `/ups`, real upsd + synthetic inputs) in `data/logs/`: `ups-glyph-{100-ok-online,60-ok-charging,35-warn-onbattery,12-fail-onbattery,02-fail-onbattery,00-empty-onbattery,unknown,87-unrated-online}.png` + `ups-glyph-matrix-{dark,light}.png` + `ups-glyph-matrix-zoom200-light.png`; every state distinguishable in monochrome, both themes and 200 % zoom checked; all four pages still render live data after the shared-file change. + +**Docs**: AGENTS.md (new UI-conventions paragraph for the glyph: fill-is-charge, state shapes, a11y choice, no animation, page derives `state`) and README.md (`web/` bullet) updated. + +**Decisions**: unrated-but-measured charge draws a grey fill rather than an empty shell ("0 %" ≠ "not rated"); no fill transition added, so `prefers-reduced-motion` holds by construction; no notes file written in `todo/` (harness owns those). Dev server restarted, `/api/health` ok. + +**Next pending task**: `05_ups_hero_battery_glyph/03_ups_hero_row.md`. diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.md.status b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.validate b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.validate new file mode 100644 index 0000000..2267bd7 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__02_battery_glyph.a1.validate @@ -0,0 +1,47 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 13%] +........................................................................ [ 27%] +........................................................................ [ 40%] +............................................ssssssss.................... [ 54%] +........................................................................ [ 67%] +........................................................................ [ 81%] +........................................................................ [ 94%] +............................ [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% 28 +src/dashboards/alerts.py 175 0 100% +src/dashboards/api.py 193 0 100% +src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131 +src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120 +src/dashboards/config.py 113 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 139 1 99% 226 +src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164 +src/dashboards/rates.py 37 1 97% 51 +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/host.py 199 1 99% 147 +src/dashboards/sources/llm.py 150 1 99% 228 +src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625 +src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226 +src/dashboards/store.py 178 2 99% 243-244 +------------------------------------------------------------------ +TOTAL 2110 64 97% +Required test coverage of 94% reached. Total coverage: 96.97% +524 passed, 8 skipped, 1 warning in 46.62s +== 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 == diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.err b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.md b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.md new file mode 100644 index 0000000..4012430 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.md @@ -0,0 +1,16 @@ +All green. Final report: + +**Implemented** (`05_ups_hero_battery_glyph/03_ups_hero_row.md`) +- `page-ups.js`: `upsCard()` now opens with `.ups-hero` = battery glyph (`data-k="glyph"`) + three `.hero` slots — **Charge %**, **Load %**, **Runtime left** — with `.sub` context (`24 V nominal · 27.3 V`, `128 W of 900 W` only when both `realpower` figures exist, `at the current load`); line in / output / battery V / battery A / battery °C demoted to a visible-but-muted `.ups-electrical` row (not deleted). States chips, `SIMULATED` badge, health checks, command groups, NUT dump and the whole `#confirm-dialog`/`DANGER` flow untouched. +- `renderCard()`: three `Dash.hero()` calls coloured only from `severity.charge_pct|load_pct|battery_runtime`; **`severityFor()` deleted from this file** (no page script calls it anywhere now); runtime prose still only from `runtimeText()`, unmeasured → `null` so `hero()` prints faint, uncoloured `n/a`. +- `glyphState()` (on_battery → `chg` → `dchg`/`rec` → online tokens → an `ok`-rated `states[]` chip → `unknown`) + `drawGlyph()`, which repaints only when charge/verdict/state change and passes `decorative: true`. `dash.css`: `.ups-hero` (glyph column 112–156px, 140px hero track floor, stacks under 620px) and `.ups-electrical`. +- Tests: 6 new in `tests/test_api.py` (hero shape/order + `data-sev` hooks, payload-only colour, no bar on charge, secondary row demoted/muted/`n/a` faint, glyph-state mapping, repaint-only-on-change, row layout/stack) and `test_severity_for_is_kept_as_the_documented_fallback` rewritten to fail if any page re-invents a threshold. +- Browser (live apc + cyberpower, both themes, both real and synthetic states): `data/logs/ups-hero-row-{live,states}-{dark,light}.png`, `ups-hero-row-narrow.png`, `ups-hero-row-danger-dialog.png`. Measured: no clipped/overlapping numbers, glyph node identical across two polls, zero card/hero layout shift, `Battery °C` = faint `n/a` on both UPSes; dialog verified Cancel (caution) and the `DANGER` gate (Run disabled until typed) with a fetch spy recording **0 POSTs**. + +**Verification**: `bash .agents/validate.sh` EXIT=0 → `530 passed, 8 skipped`, coverage `96.97%` (floor 94), `ruff` All checks passed, `pyright` 0 errors, `node --check` clean; `uv run pytest -m live --live` → `8 passed`; `uv pip list --outdated` empty. + +**Docs**: AGENTS.md — `severityFor` now "no page script calls it any more", new `/ups` hero-row convention (no bar on charge, sub rules, demoted electrical row, glyph-state mapping incl. "`full` is on line, not charging", repaint key, `decorative: true`), test count 518→530. README.md — `/ups` card described in the `web/` bullet. + +**Decisions/deviations**: task text said `charger_status in ('dchg',) → 'charging'` — that's discharging, so `chg` charges and `dchg`/`rec` draw the on-battery shell; because **neither real driver publishes `battery.charger.status`**, the online case falls back to a state chip the API rated `ok` (`nut.states()` reserves `ok` for on-line/fully-charged) rather than re-parsing `ups.status` in JS (banned by `tests/test_ups_contract.py`). Documented remainder: a ≥10 h runtime (`10h 05m`, 161px) can reach past its 140px track into the row's right padding — it's the last slot, so it never lands on another number, and shortening the pinned prose was rejected. Dialog wiring was exercised with the buttons' `disabled` attribute removed client-side: the live API gates every command off (AGENTS gap 1 — no `actions` user in `upsd.users`), so no live config was touched and no command sent. Pre-existing, untouched: the shared masthead overflows below ~620px on every page (verified identically on `/system`). Mid-verification the dev server's UPS source alternated twice between real upsd and `NUT_SIMULATE=1` (another process); the final screenshots were re-taken after confirming `simulated=false`, `24 V nominal`, `battery_temperature=null`. + +**Next pending task**: `05_ups_hero_battery_glyph/04_verify_real_batteries.md`. diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.md.status b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.validate b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.validate new file mode 100644 index 0000000..0c2aa65 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__03_ups_hero_row.a1.validate @@ -0,0 +1,47 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 13%] +........................................................................ [ 26%] +........................................................................ [ 40%] +..................................................ssssssss.............. [ 53%] +........................................................................ [ 66%] +........................................................................ [ 80%] +........................................................................ [ 93%] +.................................. [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% 28 +src/dashboards/alerts.py 175 0 100% +src/dashboards/api.py 193 0 100% +src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131 +src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120 +src/dashboards/config.py 113 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 139 1 99% 226 +src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164 +src/dashboards/rates.py 37 1 97% 51 +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/host.py 199 1 99% 147 +src/dashboards/sources/llm.py 150 1 99% 228 +src/dashboards/sources/nut.py 368 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 466, 478, 513, 546, 550, 607-608, 613, 625 +src/dashboards/sources/nut_sim.py 122 21 83% 141, 167-185, 226 +src/dashboards/store.py 178 2 99% 243-244 +------------------------------------------------------------------ +TOTAL 2110 64 97% +Required test coverage of 94% reached. Total coverage: 96.97% +530 passed, 8 skipped, 1 warning in 44.89s +== 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 == diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.err b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.md b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.md new file mode 100644 index 0000000..a20985a --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.md @@ -0,0 +1,17 @@ +All green, environment restored. Final report: + +**Implemented (`05_ups_hero_battery_glyph/04_verify_real_batteries.md`)** +- Verified both real UPSes (`data/logs/ups-real-apc.png`, `ups-real-cyberpower.png`, both read): apc 100 %/48.0 %/10m green, age 5.3 y **warn**, temp `n/a`; cyberpower 100 %/14.0 % (`127 W of 900 W`)/56m, age **unknown** (`mfr.date: CPS` never became an age); `SIMULATED` absent on real reads. +- Five simulator states screenshotted with `NUT_SIMULATE=1` + `NUT_ALLOW_ACTIONS=0`, `SIMULATED` badge visible in each: `ups-sim-100-online/60-charging(bolt)/35-onbattery(amber)/12-low(OB LB, red)/nacharge(n/a, uncoloured, no fake runtime)` + full-page `ups-sim-12-low-page.png` (banner + transition events). +- **Defects found & fixed:** (1) `nut_sim.py` never reported `chg`/`CHRG` (and claimed `ol` while `OB`), making the glyph's bolt state unreachable in dev — now on-line-below-float = `OL CHRG`/`chg`, on-battery = `OB`/`dchg`; (2) `UpsReading.states()` said the same word twice (charger token + subsumed flag) — now one chip per fact. 3 new pinning tests in `tests/test_nut.py`. +- Stale path: stopped only the 3494 forward → `ups-stale-lastgood.png` shows last-good heroes + `stale 75s` + red dot/banner, nothing zeroed; forward restored, all sources ok. +- DANGER gate: exercised with genuinely-enabled buttons on the sim run (`NUT_ALLOW_ACTIONS=1` — Cancel-only, no command sent): caution dialog + Cancel, dangerous dialog Run-disabled→type `DANGER`→enabled→Cancel; **0 POSTs**, sim `ups.test.result` untouched (`ups-dialog-caution.png`, `ups-dialog-danger-typed.png`). +- Wrote `.agents/phases/todo/05_ups_hero_battery_glyph/NOTES-verify.md` (each shot ↔ state, defect writeup, note on pre-existing port-3494 probe events). + +**Tests/lint:** `bash .agents/validate.sh` → EXIT=0 (`533 passed, 8 skipped`; coverage 96.98 % ≥ 94; ruff clean; pyright 0 errors; `node --check` clean). `uv run pytest -m live --live` → 8 passed. No deps moved. + +**Docs:** AGENTS.md — nut_sim status semantics (`chg` reachable, `OB` never `ol`), one-chip-per-fact in `states()`, test count 530→533. README: no change needed (`NUT_SIMULATE` wording already accurate). + +**Deviations:** the dialog pass ran with `NUT_ALLOW_ACTIONS=1` (sim only) because with `0` the buttons are legitimately disabled and cannot open the dialog; five-state pass kept `0` as instructed. No live instant command sent. + +**Next pending task:** none in phase 05 — task 04 was the phase's last; harness may now complete/commit the phase. diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.md.status b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.md.status new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.md.status @@ -0,0 +1 @@ +ok diff --git a/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.validate b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.validate new file mode 100644 index 0000000..4631e8d --- /dev/null +++ b/.agents/reports/05_ups_hero_battery_glyph/05_ups_hero_battery_glyph__04_verify_real_batteries.a1.validate @@ -0,0 +1,47 @@ +== uv run pytest --cov (hermetic: no network, live tests skip) == +........................................................................ [ 13%] +........................................................................ [ 26%] +........................................................................ [ 39%] +..................................................ssssssss.............. [ 53%] +........................................................................ [ 66%] +........................................................................ [ 79%] +........................................................................ [ 93%] +..................................... [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% 28 +src/dashboards/alerts.py 175 0 100% +src/dashboards/api.py 193 0 100% +src/dashboards/app.py 109 6 94% 50-51, 56, 58, 60, 131 +src/dashboards/collectors.py 142 5 96% 97-98, 102-103, 120 +src/dashboards/config.py 113 0 100% +src/dashboards/debug.py 14 0 100% +src/dashboards/poller.py 139 1 99% 226 +src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164 +src/dashboards/rates.py 37 1 97% 51 +src/dashboards/severity.py 49 0 100% +src/dashboards/sources/__init__.py 0 0 100% +src/dashboards/sources/host.py 199 1 99% 147 +src/dashboards/sources/llm.py 150 1 99% 228 +src/dashboards/sources/nut.py 373 20 95% 123, 137, 143, 155, 161, 173, 181, 186, 257-258, 260, 476, 488, 523, 556, 560, 617-618, 623, 635 +src/dashboards/sources/nut_sim.py 124 21 83% 152, 178-196, 237 +src/dashboards/store.py 178 2 99% 243-244 +------------------------------------------------------------------ +TOTAL 2117 64 97% +Required test coverage of 94% reached. Total coverage: 96.98% +533 passed, 8 skipped, 1 warning in 45.33s +== 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 == diff --git a/AGENTS.md b/AGENTS.md index 65f5900..9ecb90f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,12 +52,19 @@ page still renders. `404 {"configured": false}` **without dialling**, and the page says "not configured". A red panel for a server the operator never installed destroys the signal from the panels that matter. -4. **One dead source must not blink another.** Each source is its own asyncio task with its - own timeout and health record (`poller.py`). A failed poll keeps the last good payload and - marks it stale in the UI instead of zeroing it — on `/` too, where a host that has never - answered still gets a fleet row (`unreachable: true`, dimmed, every number `n/a`) because - dropping the row would turn a dead agent into "never configured". Transitions write - events; repeated failure writes one line, not 500. +4. **One dead source must not blink another — and nothing may stop polling in silence.** Each + source is its own asyncio task with its own timeout and health record (`poller.py`). A + failed poll keeps the last good payload and marks it stale in the UI instead of zeroing it — + on `/` too, where a host that has never answered still gets a fleet row + (`unreachable: true`, dimmed, every number `n/a`) because dropping the row would turn a dead + agent into "never configured". Transitions write events; repeated failure writes one line, + not 500. **The task is the source's only poller, so no exception may end it**: a store that + refuses an otherwise good poll is a *reported* failure (`ok: false`, the real reason in + `error`, `last_ok` frozen so the page ages the numbers, one `store-error` event then one + `recording` event on recovery), and anything that escapes the poll body is logged and the + loop continues. Four dev-server sources once froze behind an unretrieved SQLite exception + while `/api/health` went on calling them `ok` — a frozen source that looks healthy is the + one failure mode worse than an outage. 5. **No CDN, no build step, no internet at runtime.** Every asset is served by this app; charts are hand-rolled SVG (`web/static/charts.js`). The engines live on a network with no outbound access — a CDN reference is a request that never comes back. @@ -107,7 +114,10 @@ Responsibilities, one line each: rates so the agent never remembers who polled it last. - `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real env var always wins. Defaults are safe except the sources, which default to unset. -- `poller.py` — job loop, health records, stale/last-good semantics, transition events. +- `poller.py` — job loop, health records, stale/last-good semantics, transition events. The + success path records (payload → samples → events) and only *then* moves `last_ok`, so a + refused SQLite write ages the source and alerts instead of leaving it current with charts + that have silently stopped drawing. Events are stored under the **dashboard** name (`system`/`llm`/`ups`), not the sample source (`host`) — a page filtering `/api/events` by `host` gets an empty feed forever (found on `/system` during phase 03 browser verification). @@ -121,11 +131,26 @@ Responsibilities, one line each: `mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`. - `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not a mystery score), `COMMANDS` catalog with risk tiers, `available_commands()` filtering, - kelvin-vs-celsius normalisation. + kelvin-vs-celsius normalisation, and `charger_status` normalised to the `CHARGER_STATUS` + keys (the same lowercase token `states()` and `on_battery` already compare), and `states()` + publishes one chip per fact — a driver exporting both `battery.charger.status` and the + legacy flag that says the same word gets "charging, online", not "charging, online, + charging". `battery_runtime` + comes from `battery.runtime` **only** — `battery.runtime.low` is a shutdown threshold, not + time left, so it lives at `battery_runtime_low` and never becomes the hero's "runtime left". - `sources/nut_sim.py` — a **labelled** simulator so the UPS page is developable without a battery. It must never be the default, and every payload it produces says `simulated`. Its modelled `battery.mfr.date` is computed relative to today (not a fixed string that - ages), so a fresh simulator reads healthy and amber keeps meaning something. + ages), so a fresh simulator reads healthy and amber keeps meaning something. Its status + follows the real drivers: a below-float pack on mains reports `OL CHRG`/`chg` (the glyph's + bolt state is keyed on that token, and the model has to be able to produce it) and a battery + being drawn from is `OB`/`dchg`, never `ol`. Every command it advertises is implemented and + visible in a variable a real driver publishes — the beeper ones set `ups.beeper.status` + (`enabled`/`muted`/`disabled`), and a test window that ends writes `ups.test.result: PASSED` + while an aborted one writes nothing; the load/shutdown commands answer `OK` without modelling + an outlet, because a faked power cut in the history is indistinguishable from a real one. + `from_env()` is the single place the `NUT_SIMULATE` truthy rule lives — `app.py` builds the + monitor through it, so the assembly and the model cannot disagree about what is enabled. - `prom.py` — Prometheus text-exposition parser (in-house; only for engines we don't own). - `rates.py` — Δ/window estimator, keyed by resolved series name. - `severity.py` — the only place thresholds live: `THRESHOLDS`/`INVERTED` per metric, @@ -167,10 +192,11 @@ Responsibilities, one line each: `Dash.hero(el, {label, value, unit, digits, severity, sub, bar})` renders/patches a `.hero` big number (plus an optional `.bar`) from that class — it never compares a number to a threshold. `Dash.severityFor(value, warnAt, failAt)` is the documented fallback for - values the API does not rate; converting its remaining call sites is phase 05's job - (`/` was converted in phase 02, `/system` in phase 03 and `/llm` in phase 04 — those pages - colour only from the `severity` verdicts the API sends, and no `severityFor(` call survives - in their scripts). + values the API does not rate, and **no page script calls it any more** — `/` was converted + in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in phase 05, so every + colour on all four pages comes from a `severity` verdict the API sent + (`tests/test_api.py` fails if a page script names it again). A value the API should rate + gets a row in `severity.py`, never a bound in JS. 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 @@ -197,9 +223,14 @@ That scheme is the history API's key — treat it as a public interface. `ups.description`, the operator's label — the driver's own `identity.model` answers `GX1500U`, so the description is what identifies the box). Real-hardware quirks the parsers already handle: `battery.mfr.date` is the useful date - (`battery.date` is often an unset placeholder like `2001-09-25`), CyberPower reports - `battery.mfr.date: CPS` (not a date → `unknown`, never a fake age), neither driver reports - a battery temperature, and `ups.test.result: No test initiated` is the factory state on both. + (`battery.date` is often an unset placeholder — `2001/09/25`, slashes and all, on the APC), + CyberPower reports `battery.mfr.date: CPS` (not a date → `unknown`, never a fake age), + **neither driver reports a battery temperature or the NUT 2.8 `battery.charger.status`** + (so the battery glyph's state has to come from `on_battery`, and `charger_status` must stay + optional), and `ups.test.result: No test initiated` is the factory state on both. + Both UPSes are recorded verbatim (`LIST UPS`/`LIST VAR`/`LIST CMD`, serials scrubbed) as + `tests/fixtures/nut_<ups>.vars` by `./scripts/capture-fixtures.sh`, and the wire quotes a + field only when it must — `VAR apc ups.status "OL"`, not three quoted fields. - Host access is via ssh keys as `ops`. Passwordless sudo exists but is command-scoped; the set we rely on is documented in `deploy/nut-sudoers` (additions are listed there for the work that is still blocked). @@ -216,11 +247,11 @@ cp .env.dev.example .env.dev # dev-only: ssh tunnels + live targets ./scripts/dev-server.sh status # per-source ok/DOWN ./scripts/dev-server.sh logs -uv run pytest # hermetic: 485 passed + 7 live-skipped, no network -uv run pytest --cov # coverage gate (96.9%; floor 94%) +uv run pytest # hermetic: 543 passed + 8 live-skipped, no network +uv run pytest --cov # coverage gate (97.9%; floor 94%) uv run pytest -m live --live # opt-in: real fleet / engines / upsd uv run ruff check src tests agent && uv run pyright -./scripts/capture-fixtures.sh # re-record ground truth +./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS captures) bash .agents/validate.sh # the phase gate: the four checks above (pytest # --cov --cov-fail-under=94, ruff, pyright, plus a @@ -253,7 +284,14 @@ service here. `test_every_host_serves_the_hero_card_contract` asserts the same table against the live agents — so a rename fails a test, and a card that shows `n/a` cannot be "fixed" by inventing a field. `network` carries no `severity` on purpose (throughput has no good/bad), - and the contract pins its absence. + and the contract pins its absence. `tests/ups_contract.py` is the same device for the `/ups` + hero row and battery glyph (`HERO_FIELDS`, `UPS_SHAPES`, `assert_ups_hero_contract`): + `tests/test_ups_contract.py` drives it from `tests/fixtures/nut_<ups>.vars` — verbatim + `LIST UPS`/`LIST VAR`/`LIST CMD` replies from the two real UPSes — and + `test_ups_hero_fields_are_present_on_the_real_batteries` drives it against live upsd. The + captures are what make the driver disagreements testable: one of them answers + `battery.mfr.date: CPS`, neither exports `battery.temperature` or + `battery.charger.status`. - `live` (`tests/test_live_fleet.py`) re-runs the production code paths against the real fleet, engines, and upsd. Skipped unless `--live`; each test skips when its source is unset, so partial fleet availability is normal. **Live tests must not write** — a battery @@ -261,7 +299,7 @@ service here. - The agent's real-`/proc` test (`tests/test_agent.py`) touches only the box running pytest, so it stays in the default run and is *not* marked `live`. - Coverage gate: 80% is the repo floor, the phase gate in `.agents/validate.sh` enforces - 94% (`--cov-fail-under`), and ~97% is where we are; keep it there. New/modified Python + 94% (`--cov-fail-under`), and ~98% is where we are; keep it there. New/modified Python still has to clear >90% on its own. API tests use `create_app()` with an in-memory DB, never a real target, and never a real upstream: `/api/ups/command`'s send is proven against a scripted `nut.NutClient` (`ScriptedNutClient` in `tests/test_api.py`) because the live @@ -318,6 +356,45 @@ of one queued request averages to 0.14 and an axis fitted to that draws a full-h a queue nobody was in — `/llm` passes `minSpan: 1` on sessions and queued so its axis is never finer than one request. +A battery is `DashCharts.battery(container, {charge, severity, state, label})` — one inline SVG +whose **fill *is* the charge**: 1 viewBox unit per percent, clamped to 0–100 and never rounded up +(a 2 % cell must look nearly empty, because a reassuring battery above a flat one is a lie), +clipped to the plate so 0 % leaves an empty shell rather than a stray pixel. Colour comes only from +the payload `severity` token; a measured charge with no verdict draws faint grey at half strength +(`sparkline`'s rule — a shape is not a verdict) and never an empty shell ("0 %" and "not rated" are +different sentences). Every state has a **shape** as well as a colour, so the glyph survives +monochrome: solid shell `online`, bolt `charging`, dashed outline `onbattery`, no fill with a dash +pattern *through* the body `unknown`; a state word it does not know degrades to `unknown`, never to +a confident "on line". Accessible **by default** — `role="img"` + `<title>` + `aria-label` built +from the inputs ("apc: Charge 100 percent, on line"), because the glyph carries information the +markup does not say in words — and a page that prints that sentence next to it passes +`decorative: true` to make the `<svg>` `aria-hidden` rather than say it twice. No animation and no +`ResizeObserver`: one `viewBox` + `preserveAspectRatio` scaled by `.battery` in `dash.css`, so the +hero row gets a big cell on a desktop and a small one on a phone. + +**The `/ups` card is led by the glyph and three heroes** (phase 05): the battery on the left, then +**Charge %**, **Load %** and **Runtime left** on `.heroes` at the shared 2.4rem, coloured from +`severity.charge_pct` / `severity.load_pct` / `severity.battery_runtime` and nothing else. Charge +carries **no** `.bar` — the glyph beside it is that value drawn as a shape, and one value drawn +twice in one row is the pattern this app avoids (Load keeps its bar; nothing else draws it). Its +`.sub` lines are the context each number needs and nothing more: `24 V nominal · 27.3 V` for the +pack, `128 W of 900 W` when the driver publishes both `realpower` figures (a wattage inferred from +a percent against another model's rating would be an invented number), `at the current load` for +runtime. Runtime prose is `runtimeText()`'s (`1h 32m`, `10m 21s`) and an estimate the driver never +made goes into `Dash.hero()` as `null`, so it prints faint and uncoloured rather than as `0m`. The +readings the hero row displaced — line in, output, battery volts and amps, battery °C — stay on the +card, demoted to `.ups-electrical`: muted, `.95rem`, never folded away, because they are what you +reach for *while* the UPS is on battery, and battery °C reads `n/a` on both real drivers here. +`state` is derived in the page from `on_battery` → `charger_status` (`chg` charges; `dchg`/`rec` are +the server's own on-battery tokens; `full` is **on line, not charging** — a rested floating battery +reports it) with one fallback: a state chip the API rated `ok`, because `nut.states()` reserves +`ok` for on-line/fully-charged and neither real driver here publishes `battery.charger.status` at +all. `ups.status` itself is never re-parsed in JS (`tests/test_ups_contract.py`). `/ups` passes +`decorative: true`, because the charge hero and the state chips already say the sentence. The glyph +repaints only when charge / verdict / state change (`drawGlyph()` keys a `data-drawn` triple): the +charge float jitters every `NUT_INTERVAL_SECONDS`, and rebuilding the `<svg>` on that re-announces +an accessible name for a difference no eye can see. + An event row names its subject **once**. Every feed (`/system`, `/llm`, `/ups`) renders the message through `Dash.eventText(event)`, which drops the `UpstreamError: ` wrapper and a *leading* duplicated subject the way `alerts._clean_error()` does for the overview — `poller.py` diff --git a/README.md b/README.md index d86c264..764f039 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,9 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ── * `poller.py` — every source gets its own asyncio task, its own health record, and its own error handling. A dead host keeps its last good values and is labelled stale; it never - blanks or blocks another source. + blanks or blocks another source. And it never quietly *stops*: the task is that source's only + poller, so a SQLite write that gets refused is reported (`ok: false`, the real reason, the + numbers aging, one line in the event log) instead of freezing the source under a green dot. * `store.py` — raw samples roll up to 1-minute and 1-hour averages and the raw rows are pruned, so a year of history is a few hundred MB rather than a disk emergency. * `severity.py` — the single green/amber/red table (`THRESHOLDS`, plus `INVERTED` for the @@ -162,7 +164,13 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ── A big colour-coded number is `Dash.hero(el, {...})` on the `.hero` / `.hero-bar` styles, coloured by the `severity` in the payload via `Dash.severityFrom()` — the only colour vocabulary is `ok` / `warn` / `fail`, and a value - with no verdict renders `n/a` with no colour at all. Each `/system` host is one panel led + with no verdict renders `n/a` with no colour at all. A battery is + `DashCharts.battery(el, {charge, severity, state, label})`: one inline SVG whose fill *is* the + charge — clamped to 0–100, never rounded up, clipped to the plate so 0 % is an empty shell — + coloured only by the payload verdict, with a shape per state (solid shell on line, bolt + charging, dashed outline on battery, a dash through the body when there is no reading) and a + `<title>` / `aria-label` that names it ("apc: Charge 100 percent, on line") unless the number + beside it already does. Each `/system` host is one panel led by exactly six of them — CPU %, MEM %, TEMP, DISK % (mount underneath), GPU busy (VRAM and the APU pool in the footnote), and NET on two lines (↓ in / ↑ out, never coloured: a byte rate has no good/bad). Under the numbers, **each panel charts only its own host**: utilisation @@ -183,16 +191,23 @@ store.py ── SQLite (WAL): sample(source,host,metric,res,ts) + event ── while non-zero; the token counter is a footnote. The four trend charts sit below the panels at half height, and one `/api/llm/history` request per refresh feeds all of it. With a single engine configured the totals card hides and the page is one clean half — never a red - panel for a server you do not run. + panel for a server you do not run. Each UPS card (`/ups`) opens with its battery glyph and + three big numbers — **charge %**, **load %**, **runtime left** — coloured from the card's + `severity` dict, with the electrical readings (line in, output, battery volts and amps, + battery °C) kept below them in smaller muted type because they are what you read *while* the + UPS is on battery. Health checks, the battery-test buttons (with their `DANGER` + confirmation) and the NUT variable dump are unchanged, just second. ## Testing ```bash uv run pytest # unit + API tests, fully mocked, no network uv run pytest --cov # coverage gate +set -a; . ./.env.dev; set +a # the live suite reads the same env as the app uv run pytest -m live --live # optional: real fleet / engines / upsd uv run ruff check src tests agent && uv run pyright # lint + types -./scripts/capture-fixtures.sh # re-record ground-truth captures +./scripts/capture-fixtures.sh # re-record ground truth: host agents + UPS replies +./scripts/capture-fixtures.sh --with-llm # ... and the engines' /metrics while they are up ``` Test philosophy: **real captures as fixtures, mocks for behaviour.** Anything that must @@ -200,15 +215,23 @@ match an upstream (`/proc` layouts, vLLM metric names, NUT protocol framing) is against a verbatim capture in `tests/fixtures/`; behaviour that needs a failure — upsd refusing a command, an engine returning 500, a host rebooting — is a fake. The `live` tests (`tests/test_live_fleet.py`) are the bridge: they SKIP unless you pass `--live`, each one -also skips when its source is unset, they drive the production code paths (`collectors.*`, -`NutMonitor.collect`) against whatever hardware is reachable, and they never write — a -battery test on live hardware is not a unit test. +also skips when its source is unset (**source `.env.dev` first** — run bare and they all skip +quietly, which reads exactly like a pass), they drive the production code paths +(`collectors.*`, `NutMonitor.collect`) against whatever hardware is reachable, and they never +write — a battery test on live hardware is not a unit test. One more file is worth knowing about: `tests/card_contract.py` is the table of `/api/system/snapshot` paths the system page's per-host cards are allowed to read. It is checked against all three captured host shapes (discrete GPU, APU, no GPU), over HTTP, and again against the live fleet, so a card can never be "fixed" by inventing a number the agent -does not report. +does not report. `tests/ups_contract.py` does the same job for the UPS page's hero row and +battery glyph (`charge %`, `load %`, `runtime left`, `battery_voltage`, the glyph's +`charger_status` + `on_battery`, the card header), against the two real UPSes — recorded +verbatim as `tests/fixtures/nut_<ups>.vars` by `./scripts/capture-fixtures.sh`, with the +device serials scrubbed — and again against live upsd. The captures earn their place: the +CyberPower answers `battery.mfr.date: CPS`, the APC's `battery.date` is a factory +placeholder, and **neither** publishes `battery.temperature` or `battery.charger.status`, so +a hand-written variable table would have tested nobody's memory. ## Battery-test buttons @@ -229,6 +252,11 @@ not `actions = -`: instcmds = test.battery.start.quick test.battery.start.deep test.battery.stop ``` +`deploy/nut-sudoers` is the ruleset that lets the `ops` account read (and scoped-write) those +files: `sudo install -m 0440 -o root -g root deploy/nut-sudoers /etc/sudoers.d/dashboards-nut`. +Read `upsd.users` before you write it — `upsmon` authenticates from the same file, so +overwriting it blind can take the box down at the wrong moment. + Bind upsd to loopback (`LISTEN 127.0.0.1` in `upsd.conf`) and reach it with an ssh forward (`DEV_FORWARDS`) or a tailnet address. `NUT_SIMULATE=1` stands in for upsd during development; everything it produces is badged `SIMULATED`, and it is never the default. diff --git a/scripts/capture-fixtures.sh b/scripts/capture-fixtures.sh index 624bb6e..04d38ae 100755 --- a/scripts/capture-fixtures.sh +++ b/scripts/capture-fixtures.sh @@ -35,6 +35,76 @@ for entry in "${HOSTS[@]}"; do fi done +# --- UPSes (NUT upsd) --------------------------------------------------------- +# The two real UPSes disagree in ways a hand-written VAR table cannot: the APC's +# `battery.date` is the factory placeholder `2001/09/25` (its `battery.mfr.date` is the real +# one), the CyberPower answers `battery.mfr.date: CPS`, and neither exports +# `battery.temperature` or `battery.charger.status`. `tests/test_ups_contract.py` pins the +# UPS hero row against these replies, so they are recorded from the wire, not typed from +# memory (AGENTS.md rule 7). Skipped when NUT_HOST is unset, so a laptop with no upsd +# reachable still re-records the rest. +if [ -n "${NUT_HOST:-}" ]; then + if python3 - "$FIX" "$NUT_HOST" "${NUT_PORT:-3493}" <<'PY' +import pathlib +import socket +import sys + +fix, host, port = pathlib.Path(sys.argv[1]), sys.argv[2], int(sys.argv[3]) +HEADER = ("# Verbatim upsd reply (LIST UPS / LIST VAR / LIST CMD), recorded by\n" + "# ./scripts/capture-fixtures.sh — one `VAR`/`CMD` line per protocol line.\n" + "# Volatile values (ups.load, battery.runtime, the voltages) move every poll,\n" + "# so a diff here on re-capture is expected; the contract asserts shape, units\n" + "# and which variables the drivers omit, never those numbers.") + + +class Upsd: + """Raw socket on purpose: the fixture is the wire, not a parse of it.""" + + def __init__(self, host: str, port: int) -> None: + self.handle = socket.create_connection((host, port), timeout=8).makefile("rwb") + + def line(self) -> str: + raw = self.handle.readline() + if not raw: + raise EOFError("upsd closed the connection") + return raw.decode(errors="replace").rstrip("\r\n") + + def ask(self, command: str) -> list[str]: + self.handle.write((command + "\n").encode()) + self.handle.flush() + first = self.line() + if not first.startswith("BEGIN LIST"): + raise RuntimeError(f"{command!r} -> {first!r}") + rows: list[str] = [] + while True: + row = self.line() + if row.startswith("END LIST"): + return rows + rows.append(row) + + +upsd = Upsd(host, port) +for row in upsd.ask("LIST UPS"): + if not row.startswith("UPS"): + continue + name = row.split()[1].strip('"') + body = [HEADER, row, + *upsd.ask(f'LIST VAR "{name}"'), + *upsd.ask(f'LIST CMD "{name}"')] + path = fix / f"nut_{name}.vars" + path.write_text("\n".join(body) + "\n") + print(f" ✓ nut_{name}.vars ({len(body) - 1} protocol lines)") +PY + then + : # per-UPS lines above + else + echo " ✗ nut_*.vars — upsd at $NUT_HOST:${NUT_PORT:-3493} unreachable (scripts/dev-tunnels.sh start?)" + failed=1 + fi +else + echo " (skipped UPS captures — NUT_HOST unset; see .env.dev.example)" +fi + if [ "${1:-}" = "--with-llm" ]; then if [ ${#ENGINES[@]} -eq 0 ]; then echo " ! no GUFO_BASE_URL / VLLM_BASE_URL set (see .env.dev) — nothing to capture" @@ -60,10 +130,12 @@ if grep -rlE "Authorization|Bearer |://[^/]*:[^/@]*@" "$FIX" >/dev/null 2>&1; th failed=1 fi -# Two things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which -# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>) and the -# FQDN a host thinks it has. They are replaced deterministically so re-capturing does not -# churn the fixture, and the tests keep the same shapes to assert against. +# Three things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which +# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>), the +# FQDN a host thinks it has, and a UPS's `ups.serial`/`device.serial` (which identifies the +# physical unit, and is echoed back in the card's variable dump). They are replaced +# deterministically so re-capturing does not churn the fixture, and the tests keep the same +# shapes to assert against. python3 - "$FIX" <<'PY' import pathlib import re @@ -73,11 +145,15 @@ uuid_re = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ SYNTHETIC = "00000000-0000-4000-8000-" # already-scrubbed markers: re-running must not churn fix = pathlib.Path(sys.argv[1]) changed = [] +# upsd quotes a field only when it has to, so both `VAR "apc" "ups.serial" "x"` and +# `VAR apc ups.serial "x"` are real wire shapes; the value is always quoted. +serial_re = re.compile(r'(^VAR\s+\S+\s+"?(?:device|ups)\.serial"?\s+")([^"\n]*)(")', re.M) for path in sorted(fix.iterdir()): - if path.suffix not in (".json", ".txt"): + if path.suffix not in (".json", ".txt", ".vars"): continue text = path.read_text() seen: dict[str, str] = {} + serials: dict[str, str] = {} def swap(match: re.Match[str]) -> str: value = match.group(0) @@ -91,6 +167,18 @@ for path in sorted(fix.iterdir()): # "hostname": "gw.lab.example.com" -> "gw": the dashboard's host key is the target name # from HOST_TARGETS, never whatever the box calls itself text = re.sub(r'("hostname": "[^"]*?)\.[^"]*(")', r"\1\2", text) + + def scrub_serial(match: re.Match[str]) -> str: + value = match.group(2) + body, pad = value.rstrip(), value[len(value.rstrip()):] + if body.startswith("SCRUBBEDSERIAL"): + return match.group(0) + if body not in serials: + serials[body] = f"SCRUBBEDSERIAL{len(serials) + 1}" + # trailing spaces are a real quirk of the apc driver — keep the padding verbatim + return match.group(1) + serials[body] + pad + match.group(3) + + text = serial_re.sub(scrub_serial, text) if text != path.read_text(): path.write_text(text) changed.append(path.name) diff --git a/src/dashboards/app.py b/src/dashboards/app.py index 9244398..ac802cf 100644 --- a/src/dashboards/app.py +++ b/src/dashboards/app.py @@ -26,7 +26,7 @@ from .config import Settings from .poller import Hub from .rates import RateEstimator from .sources.host import HostCollector -from .sources.nut_sim import NutSimulator +from .sources.nut_sim import from_env as simulator_from_env from .store import SampleStore WEB = Path(__file__).resolve().parent / "web" @@ -47,7 +47,7 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[ host_collector = HostCollector(rates) nut_monitor: NutMonitor | None = None if settings.nut_enabled: - simulator = NutSimulator() if _truthy(os.environ.get("NUT_SIMULATE")) else None + simulator = simulator_from_env(os.environ.get("NUT_SIMULATE")) nut_monitor = NutMonitor(settings, simulator=simulator) hub = Hub(store) @@ -64,10 +64,6 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[ return ctx, store, hub.start -def _truthy(value: str | None) -> bool: - return (value or "").strip().lower() in ("1", "true", "yes", "on") - - def create_app(settings: Settings | None = None, store: SampleStore | None = None) -> FastAPI: """`store` is for tests: when supplied it is not created and not closed here.""" settings = settings or DEFAULT_SETTINGS @@ -88,7 +84,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non hub = Hub(store) nut_monitor = None if settings.nut_enabled: - simulator = NutSimulator() if _truthy(os.environ.get("NUT_SIMULATE")) else None + simulator = simulator_from_env(os.environ.get("NUT_SIMULATE")) nut_monitor = NutMonitor(settings, simulator=simulator) for job in host_jobs(list(settings.host_targets), client, HostCollector(rates), settings.host_interval): diff --git a/src/dashboards/poller.py b/src/dashboards/poller.py index d901eba..12655e3 100644 --- a/src/dashboards/poller.py +++ b/src/dashboards/poller.py @@ -9,6 +9,10 @@ Rules this enforces, because violating them is what makes dashboards untrustwort (`stale 12s` in the UI) instead of showing zeros or `n/a` everywhere. * **Transitions are events, spam is not.** ok→fail and fail→ok write to the event log; 500 consecutive failures write one line. +* **The loop is the source's only poller, so nothing may kill it.** A bookkeeping failure — a + SQLite write refused by lock contention, a full disk, anything — is a *reported* failure, + never a dead task: four dev-server sources once stopped advancing `attempts` while + `/api/health` went on calling them `ok`, and only the growing `age_seconds` said so. """ from __future__ import annotations @@ -92,6 +96,7 @@ class Job: self.grace_failures = grace_failures self.state = SourceState(key=key, dashboard=dashboard, label=label, configured=enabled) self._down_logged = False + self._store_down_logged = False self._stop = asyncio.Event() self._task: asyncio.Task[None] | None = None self._inflight = False @@ -127,9 +132,24 @@ class Job: finally: self._inflight = False - self.state.ok = True - self.state.successes += 1 + try: + self._commit(store, result, now, was_ok) + except Exception as exc: # noqa: BLE001 - a refusing store must not kill the loop + self._store_refused(store, exc, now) + return False + return True + + def _commit(self, store: SampleStore, result: Result, now: float, was_ok: bool) -> None: + """Record a good poll: payload, samples, its events, then mark the source good. + + That order is the point. `last_ok` moves only once the write landed, so a source whose + history is being refused ages out on the page (and alerts) instead of looking current + forever with charts that silently stop drawing. + """ self.state.payload = result.payload + self.state.samples_written += store.add_many(result.samples) + for event in result.events: + store.log_event(event) # Recovery is news only if we ever said it was down: on a fresh start every source # would otherwise log "answering again", which reads like an outage happened. if self._down_logged or (not was_ok and self.state.failures): @@ -139,18 +159,51 @@ class Job: + (f" after {count} failure{'s' if count != 1 else ''}" if count else ""))) self._down_logged = False + if self._store_down_logged: + self._store_down_logged = False + store.log_event(Event(now, self.dashboard, self.label, "recording", + "info", f"{self.label} is recording history again")) + self.state.ok = True + self.state.successes += 1 self.state.consecutive_failures = 0 self.state.last_ok = now self.state.error = None - self.state.samples_written += store.add_many(result.samples) - for event in result.events: - store.log_event(event) - return True + + def _store_refused(self, store: SampleStore, exc: Exception, now: float) -> None: + """Bookkeeping for a store that refused an otherwise good poll. + + The upstream answered, so the freshest payload we have is kept — but the source is not + `ok` and its age keeps growing: history that is not landing means every chart past the + raw window is quietly empty, and a green dot over that is the failure this app exists + to avoid. Nothing here may raise: the caller's task *is* this source's poller, so the + event is attempted, and if the store is what broke, that failure is logged instead. + """ + self.state.ok = False + self.state.failures += 1 + self.state.consecutive_failures += 1 + self.state.error = f"{type(exc).__name__}: {exc} (the poll succeeded; the store refused it)" + logger.exception("job %s could not record a good poll", self.key) + if self._store_down_logged: + return + self._store_down_logged = True + try: + store.log_event(Event(now, self.dashboard, self.label, "store-error", "error", + self.state.error)) + except Exception: + # The store is what is broken; say it out loud rather than die trying to say it. + logger.exception("job %s could not even log that the store refused it", self.key) async def _loop(self, store: SampleStore) -> None: while not self._stop.is_set(): started = time.perf_counter() - await self.run_once(store) + try: + await self.run_once(store) + except Exception: + # `run_once` handles its own failures, so escaping here is a bug. Letting it + # propagate would freeze the source with a healthy-looking health record and no + # further polls, which is exactly how this guard was earned. + logger.exception("job %s raised outside its own error handling; still polling", + self.key) remaining = self.interval - (time.perf_counter() - started) try: await asyncio.wait_for(self._stop.wait(), timeout=max(0.2, remaining)) diff --git a/src/dashboards/sources/nut.py b/src/dashboards/sources/nut.py index 384a968..57376b8 100644 --- a/src/dashboards/sources/nut.py +++ b/src/dashboards/sources/nut.py @@ -14,15 +14,20 @@ Verified against NUT 2.8.5 (`/usr/bin/upsc` on driveripper). Protocol shape: -> LIST UPS <- BEGIN LIST UPS - <- UPS "apc" : APC Smart-UPS + <- UPS apc "APC Back-UPS RS 1500MS (USB)" <- END LIST UPS - -> LIST VAR apc + -> LIST VAR "apc" <- BEGIN LIST VAR apc - <- VAR "ups.status" "OL" + <- VAR apc ups.status "OL" <- END LIST VAR apc - -> LIST CMD apc ; <- BEGIN LIST CMD apc / CMD test.battery.start.quick / END LIST CMD apc + -> LIST CMD apc ; <- BEGIN LIST CMD apc / CMD apc test.battery.start.quick / END LIST CMD apc bad -> ERR ACCESS-DENIED (auth or permission problem) ok -> OK ... + +Those lines are verbatim NUT 2.8.5 (`tests/fixtures/nut_apc.vars`, recorded by +`scripts/capture-fixtures.sh`): upsd quotes a field only when it has to — a value with a +space always is, a bare name usually is not — which is why `_split_fields` honours quotes +instead of splitting on whitespace. """ from __future__ import annotations @@ -300,7 +305,10 @@ VAR_MAP: dict[str, tuple[str, ...]] = { "battery_current": ("battery.current",), "battery_temperature": ("battery.temperature",), "battery_type": ("battery.type",), - "battery_runtime": ("battery.runtime", "battery.runtime.low"), + # `battery.runtime.low` is the *shutdown threshold*, not time remaining. Listing it here + # made a UPS that publishes no estimate read as "5 m left" — the biggest number on the + # card, and an invented one (AGENTS.md invariant 2). It stays readable as itself below. + "battery_runtime": ("battery.runtime",), "battery_runtime_low": ("battery.runtime.low",), "battery_date": ("battery.mfr.date", "battery.date"), "test_result": ("battery.test.result", "ups.test.result"), @@ -386,21 +394,31 @@ class UpsReading: def states(self) -> list[dict[str, str]]: """Human state list. The 2.8 `battery.charger.status` wins over legacy ups.status - when both exist because it distinguishes trim/boost/full/recalibration.""" + when both exist because it distinguishes trim/boost/full/recalibration. + + Winning means the flag the token already says is dropped, not appended beside it: + a driver exporting both `chg` and `OL CHRG` renders "charging, online", not + "charging, online, charging" — a word said twice on a chip row reads as two events. + """ out: list[dict[str, str]] = [] charger = (self.get("charger_status") or "").strip().lower() if charger and charger in CHARGER_STATUS: label, severity = CHARGER_STATUS[charger] out.append({"id": charger, "label": label, "severity": severity}) + seen = {entry["label"] for entry in out} raw = " ".join(self.status_flags) if raw: if raw in STATE_FLAGS: label, severity, detail = STATE_FLAGS[raw] - out.append({"id": raw, "label": label, "severity": severity, "detail": detail}) + if label not in seen: + out.append({"id": raw, "label": label, "severity": severity, "detail": detail}) else: for flag in self.status_flags: if flag in STATE_FLAGS: label, severity, detail = STATE_FLAGS[flag] + if label in seen: + continue + seen.add(label) out.append({"id": flag, "label": label, "severity": severity, "detail": detail}) return out @@ -721,6 +739,9 @@ def payload(reading: UpsReading, *, now: float | None = None, **assess_kwargs: A now = time.time() if now is None else now health = assess(reading, now=now, **assess_kwargs) numbers = {key: reading.number(key) for key in VAR_MAP} + # Normalised to the `CHARGER_STATUS` keys, the way `states()` and `on_battery` already + # read it: a driver that publishes "FULL" must not become a glyph state nobody labelled. + charger = (reading.get("charger_status") or "").strip().lower() or None # charts must not plot 300 "°C"; the raw string stays visible in the variable dump for key in ("battery_temperature", "ups_temperature"): if numbers.get(key) is not None: @@ -738,7 +759,7 @@ def payload(reading: UpsReading, *, now: float | None = None, **assess_kwargs: A }, "ts": reading.ts, "status": reading.get("status"), - "charger_status": reading.get("charger_status"), + "charger_status": charger, "states": reading.states(), "alarms": reading.alarms, "on_battery": reading.on_battery, diff --git a/src/dashboards/sources/nut_sim.py b/src/dashboards/sources/nut_sim.py index 47c92b9..b997419 100644 --- a/src/dashboards/sources/nut_sim.py +++ b/src/dashboards/sources/nut_sim.py @@ -6,11 +6,17 @@ putting a real load on battery. It is never silent about what it is: the payload `simulated: true` and the page shows a watermark on every simulated number. The model is deliberately small and physical rather than random: - * charge sits at float (100%) while on line, drains at a Peukert-ish rate when on battery + * charge climbs back to float while on line — reporting `CHRG`/`chg` until it gets there, + the way a real driver does — and drains at a Peukert-ish rate when on battery * runtime estimates scale with charge and fall off steeply with load (as they do for real VRLA packs, which is why the number jumps around while the load moves) * a quick battery test moves to OB for ~12s and then reports PASSED, so the whole command → state change → event-log path is exercised + * the state a command changes is visible in the variables a real driver publishes — `OB` + plus `ups.test.result`/`ups.test.date` for the tests, `ups.beeper.status` for the beeper + — so the dev page shows an effect instead of only an `OK` toast. The load/shutdown + commands answer `OK` without modelling an outlet: there is no load here to cut, and a + faked power cut in the history is indistinguishable from a real one. """ from __future__ import annotations @@ -18,7 +24,6 @@ from __future__ import annotations import math import time from dataclasses import dataclass, field -from typing import Any from .nut import UpsReading @@ -59,13 +64,21 @@ class SimUps: driver: str = "usbhid-ups" _test_until: float = 0.0 _deep_until: float = 0.0 - _beeper: bool = True + _result_pending: bool = False # a test is running; report its outcome when the window closes + _beeper: str = "enabled" # NUT's `ups.beeper.status`: enabled | muted | disabled _alarms: list[str] = field(default_factory=list) def tick(self, now: float) -> None: """Advance the model by wall-clock time since the last tick.""" # a running self-test pulls the load from the wall, whichever test it is + was_testing = self.on_battery self.on_battery = now < self._test_until or now < self._deep_until + if was_testing and not self.on_battery and self._result_pending: + self._result_pending = False + # A driver writes the outcome when the test window closes, and `ups.test.result` + # is what the "Last self-test" health check reads: without this the simulated pack + # comes back on line while the card still quotes the result it had before the test. + self.test_result = "PASSED" def step(self, dt: float) -> None: dt = max(0.0, min(dt, 60.0)) @@ -93,13 +106,23 @@ class SimUps: def vars(self, now: float) -> dict[str, str]: load_pct = self.load_w / self.nominal_w * 100.0 - flags = ["OL"] if not self.on_battery else (["OB"] + (["LB"] if self.charge < 20 else [])) - if self.charge >= 99.5 and not self.on_battery: - flags.append("CHRG" if self.charge < 99.9 else "") - status = " ".join(part for part in flags if part) - charger = "ol" if not self.on_battery else ("dchg" if self.charge < 99.5 else "ol") - if self.charge >= 99.9 and not self.on_battery: + # Status and charger status must agree with each other *and* with a real driver. + # A below-float pack on mains is charging — APC prints exactly `OL CHRG` there — + # and a battery being drawn from is never "online": `ol`/`full` while `OB` is the + # contradiction `assess()` would have to guess about. Reporting plain `ol`/`OL` + # during the recharge made the page's charging state (the glyph's bolt, keyed on + # `charger_status == "chg"`) unreachable in the simulator, and that is a state the + # operator has to be able to see in dev. + if self.on_battery: + flags = ["OB"] + (["LB"] if self.charge < 20 else []) + charger = "dchg" + elif self.charge >= 99.9: + flags = ["OL"] charger = "full" + else: + flags = ["OL", "CHRG"] + charger = "chg" + status = " ".join(flags) volts = (self.battery_nominal * (0.86 + 0.14 * self.charge / 100.0) if self.on_battery else self.battery_nominal * 1.045) line = 121.4 + 1.1 * math.sin(now / 90.0) @@ -109,6 +132,7 @@ class SimUps: "ups.serial": self.serial, "ups.firmware": self.firmware, "ups.status": status, + "ups.beeper.status": self._beeper, "battery.charger.status": charger, "ups.load": f"{load_pct:.1f}", "ups.realpower": f"{self.load_w:.1f}", @@ -152,23 +176,29 @@ class SimUps: if command == "test.battery.start.quick": self._test_until = now + 12.0 self.on_battery = True + self._result_pending = True self.test_date = time.strftime("%Y-%m-%d", time.gmtime(now)) return "OK Start Quick Battery Test" if command == "test.battery.start.deep": self._deep_until = now + 90.0 self.on_battery = True + self._result_pending = True return "OK Start Deep Battery Test" if command in ("test.battery.stop", "calibrate.stop"): self._test_until = self._deep_until = 0.0 self.on_battery = False + self._result_pending = False # an aborted test reports nothing, not a pass return "OK Stop Test" if command == "test.panel.start": return "OK Start Panel Test" if command == "beeper.enable": - self._beeper = True + self._beeper = "enabled" return "OK Beeper enabled" - if command in ("beeper.disable", "beeper.mute"): - self._beeper = False + if command == "beeper.disable": + self._beeper = "disabled" + return "OK Beeper disabled" + if command == "beeper.mute": + self._beeper = "muted" return "OK Beeper muted" if command == "calibrate.start": self._deep_until = now + 180.0 @@ -221,6 +251,10 @@ class NutSimulator: raise KeyError(f"unknown simulated UPS {ups_name!r}") -def from_env(value: str | None) -> Any: - """`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd).""" +def from_env(value: str | None) -> NutSimulator | None: + """`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd). + + The one place the enable rule lives: `app.py` builds the monitor with this, so the truthy + set cannot drift between the assembly and the model it swaps in. + """ return NutSimulator() if (value or "").strip().lower() in ("1", "true", "yes", "on") else None diff --git a/src/dashboards/web/static/charts.js b/src/dashboards/web/static/charts.js index 61720d3..a194c2f 100644 --- a/src/dashboards/web/static/charts.js +++ b/src/dashboards/web/static/charts.js @@ -471,5 +471,118 @@ function sparkline(container, points, opts) { <path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.4"/>${marks}</svg>${foot}`; } -window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, cssVar, - CHART_COLORS, DEFAULT_FORMAT, escapeHtml }; +/** + * An inline-SVG battery whose fill *is* the charge, for the `/ups` hero row. + * + * `battery(container, {charge, severity, state, label, decorative})` + * - `charge`: 0-100, or `null` when the driver does not publish it. Clamped to the scale, + * and **never rounded up to visible**: 2 % fills 2 % of the interior, because a + * reassuring full-looking battery above a flat cell is a lie (the same rule that makes + * an absent number print `n/a` instead of `0`). + * - `severity`: the payload verdict (`'ok'|'warn'|'fail'`, or a `{severity}` cell) → the + * fill and outline take that theme token through `cssVar()`. Anything else is *no + * verdict*: a muted outline, and a measured charge drawn faint and grey — a shape, not + * a judgement (`sparkline`'s unrated rule, applied to the one thing here that is a + * shape). A measured charge never renders as an empty shell; "0 %" and "not rated" are + * different sentences and only one of them is an alarm. + * - `state`: `'online' | 'onbattery' | 'charging' | 'unknown'` — the page derives it from + * `charger_status` + `on_battery` (see `tests/ups_contract.py`), never by re-parsing + * NUT flags here. Each state has a *shape*, so the glyph reads in monochrome and to a + * screen reader, not only in colour: + * online solid shell + * charging solid shell + a bolt over the plate + * onbattery **dashed** shell outline + * unknown no fill, a dash pattern **through** the body + * - `label`: names this battery ("apc") in the accessible name. No percent text is drawn + * inside the glyph: the hero number next to it already says it, and a card that prints + * one value twice is the pattern this app avoids everywhere else. + * + * Accessible **by default**: `role="img"` + `<title>` + `aria-label` built from the inputs + * ("apc: Charge 100 percent, on line"), which is the choice this component makes — the + * glyph carries information the markup does not repeat in words. A page that prints the + * same sentence beside it passes `decorative: true` to make the `<svg>` `aria-hidden` + * instead of saying it twice; the `<title>` stays for the hover tooltip. + * + * Scales with its container: one `viewBox` + `preserveAspectRatio`, `width:100%` and + * `height:auto` (the `.battery` rule in `dash.css` caps it), so the hero row gets a big + * cell on a desktop and a small one on a phone with no redraw and no ResizeObserver. + * No animation and no transition at all — the fill is re-drawn per render, and a bar that + * slides competes with the number it annotates, so `prefers-reduced-motion` holds by + * construction. Colours are resolved from the tokens at draw time, so a theme switch + * repaints with the next render (every page re-renders on its own poll). + */ +let batterySeq = 0; +const BATTERY_STATES = ['online', 'onbattery', 'charging', 'unknown']; +const BATTERY_STATE_LABELS = { online: 'on line', onbattery: 'on battery', + charging: 'charging', unknown: 'state unknown' }; + +function battery(container, opts) { + const el = typeof container === 'string' ? document.querySelector(container) : container; + if (!el) return null; + const o = Object.assign({ charge: null, severity: null, state: 'unknown', label: null, + decorative: false }, opts || {}); + + // Geometry in viewBox units, chosen so 1 unit of fill == 1 % of charge. + const W = 128; + const H = 56; + const shell = { x: 2, y: 8, w: 110, h: 40, r: 9, stroke: 3 }; + const inner = { x: 7, y: 12, w: 100, h: 32, r: 4 }; + const nub = { x: shell.x + shell.w + 3, y: 20, w: 9, h: 16, r: 3 }; + + const raw = Number(o.charge); + const measured = o.charge != null && isFinite(raw); + const pctValue = measured ? Math.max(0, Math.min(100, raw)) : null; + let sev = o.severity; + if (sev && typeof sev === 'object') sev = sev.severity; + const ink = sev === 'ok' ? cssVar('--ok') + : sev === 'warn' ? cssVar('--warn') + : sev === 'fail' ? cssVar('--fail') : null; + const stroke = ink || cssVar('--text-muted'); + const state = BATTERY_STATES.indexOf(o.state) >= 0 ? o.state : 'unknown'; + + const clipId = `dash-battery-${(batterySeq += 1)}`; + const dashed = state === 'onbattery' ? ' stroke-dasharray="7 5"' : ''; + let fillRect = ''; + if (pctValue != null && pctValue > 0) { + const w = (pctValue / 100) * inner.w; + fillRect = `<rect x="${inner.x}" y="${inner.y}" width="${w.toFixed(2)}" height="${inner.h}"` + + ` fill="${ink || cssVar('--text-faint')}"${ink ? '' : ' opacity=".5"'}/>`; + } + // A dash *through* the body: the cell exists, the reading does not. + const noReading = state === 'unknown' + ? `<line x1="${inner.x + 5}" y1="${inner.y + inner.h / 2}" x2="${inner.x + inner.w - 5}"` + + ` y2="${inner.y + inner.h / 2}" stroke="${cssVar('--text-faint')}" stroke-width="3"` + + ` stroke-linecap="round" stroke-dasharray="5 6"/>` + : ''; + const bolt = state === 'charging' + ? `<path d="M13 2 L3 14 h7 l-1 8 L19 10 h-7 z" transform="translate(43.8 13.6) scale(1.2)"` + + ` fill="${ink || cssVar('--text-muted')}" stroke="${cssVar('--bg-raised')}"` + + ` stroke-width="1.6" stroke-linejoin="round"/>` + : ''; + + const chargePhrase = pctValue == null ? 'Charge unknown' : `Charge ${Math.round(pctValue)} percent`; + const statePhrase = BATTERY_STATE_LABELS[state]; + const spoken = `${chargePhrase}, ${statePhrase}`; + const name = o.label ? `${o.label}: ${spoken}` : spoken; + // `decorative` is the opt-out described above; labelled is the default. + const a11y = o.decorative ? 'aria-hidden="true"' + : `role="img" aria-label="${escapeHtml(name)}"`; + + el.classList.add('battery'); + el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" + preserveAspectRatio="xMidYMid meet" ${a11y}> + <title>${escapeHtml(name)} + + + + + + ${fillRect} + ${noReading}${bolt}`; + return el; +} + +window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, battery, cssVar, + CHART_COLORS, DEFAULT_FORMAT, escapeHtml }; diff --git a/src/dashboards/web/static/dash.css b/src/dashboards/web/static/dash.css index b94965b..00d1c54 100644 --- a/src/dashboards/web/static/dash.css +++ b/src/dashboards/web/static/dash.css @@ -347,6 +347,65 @@ main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; } } .hero .hero-lines .dir { color: var(--text-muted); } +/* ---- battery glyph ------------------------------------------------------- + * `DashCharts.battery()` draws the whole cell in SVG — the charge fill, the charging bolt, + * the on-battery dashes, the "no reading" dash through the body — so the stylesheet owes + * only the box. One `viewBox` scaled to the container's width, capped so the hero row on a + * big monitor does not grow a metre-tall battery; `height: auto` is what preserves the + * aspect ratio (and with it the stroke width) instead of stretching the shell. No + * transition anywhere: the fill is re-drawn per render, and `prefers-reduced-motion` + * therefore holds by construction. + */ +.battery { display: block; width: 100%; max-width: 210px; } +.battery svg { display: block; width: 100%; height: auto; } + +/* ---- UPS hero row ------------------------------------------------------- + * Every UPS card opens with the battery glyph and three verdicts — charge, load, runtime + * left — because those are the three questions a UPS answers (TODO L7). The glyph gets a + * column of its own rather than floating above the numbers: it *is* the charge drawn as a + * shape, so it belongs beside the number it echoes — which is also why the charge hero + * itself carries no `.bar` (`page-ups.js`). + * + * The three numbers are the shared `.heroes` row at the shared 2.4rem, not a UPS-specific + * size: `n/a` and `100 %` and `1h 28m` have to be glanceable in the same way on all four + * pages. Its track floor is 140px rather than the `/system` row's 118px because runtime is + * prose, not a bare percent — measured in this stylesheet's own font, `9h 59m` is 138px and + * a `.hero` number cannot wrap, so the floor is what stops a track clipping it. Below the + * floor the row drops to 2 tracks and then 1, which is what keeps two cards side by side on + * a laptop instead of growing a taller one. The documented remainder: a runtime of 10 h or + * more (`10h 05m`, 161px — a UPS at a percent or two of load) reaches past its track into + * the row's right padding. It is the last slot in the row, so it never lands on another + * number, and `runtimeText()`'s prose is pinned by `tests/test_ups_contract.py` rather than + * shortened to make a column fit. + * + * Under 620px the glyph takes its own row: a 156px battery over three full-width numbers is + * still one glance, and 112px of cell next to three squeezed ones is not. + */ +.ups-hero { + display: grid; align-items: center; gap: .75rem 1rem; + grid-template-columns: minmax(112px, 156px) minmax(0, 1fr); +} +.ups-hero .heroes { + gap: .75rem .9rem; + grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr)); +} +@media (max-width: 620px) { + .ups-hero { grid-template-columns: minmax(0, 1fr); } + .ups-hero .battery { max-width: 156px; } +} + +/* The electrical row under the heroes: line in, output, battery volts and amps, battery °C. + * Still visible rather than folded into the disclosure, because they are the numbers you + * reach for *while* the UPS is on battery — but one step down and in muted ink, so they + * cannot compete with the three verdicts above them. `n/a` stays fainter than the readings + * (both real drivers here report no battery temperature), and a driver that publishes none + * of them leaves an empty grid that takes no space at all. */ +.ups-electrical { gap: .5rem .9rem; } +.ups-electrical .metric .value.sm { font-size: .95rem; color: var(--text-muted); } +/* after the rule above on purpose: an unmeasured value is fainter than a muted reading, and + `.metric .value.is-null` alone is not specific enough to survive here */ +.ups-electrical .metric .value.sm.is-null { font-size: .95rem; color: var(--text-faint); } + /* ---- bars, dots, chips -------------------------------------------------- */ .bar { position: relative; height: 7px; border-radius: 999px; background: var(--bg-sunken); diff --git a/src/dashboards/web/static/page-ups.js b/src/dashboards/web/static/page-ups.js index 74b5f5d..1f46b56 100644 --- a/src/dashboards/web/static/page-ups.js +++ b/src/dashboards/web/static/page-ups.js @@ -1,4 +1,6 @@ -/* UPS dashboard: health assessment, NUT instant commands, history. +/* UPS dashboard: one card per battery — the glyph plus the charge / load / runtime heroes that + * answer "is my load safe right now" — and below them the health assessment, the NUT instant + * commands, and history. * * The health list is deliberately explicit checks rather than a score: every row names the * variable it read and what it saw, because "health: 87" is not actionable. The backend @@ -10,8 +12,7 @@ */ 'use strict'; -const { $, fetchJSON, setTxt, setValue, setBar, setDot, num, duration, poll, severityFor, - eventText } = Dash; +const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash; const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000, simulated: false, pending: null }; @@ -21,9 +22,11 @@ const MARKS = { ok: '✓', warn: '!', fail: '✕', unknown: '?' }; /** * Runtime left as "1h 32m", not "1.5h". The health check next to it spells the same number * out in h+m; two formats for one value invites a misread while the UPS is discharging. + * A runtime nobody measured is `Dash.naText` (`n/a`), never `0s`: "no runtime" and "no + * estimate" are different sentences, and only one of them is a shutdown emergency. */ function runtimeText(seconds) { - if (seconds == null || !isFinite(seconds)) return 'n/a'; + if (seconds == null || !isFinite(seconds)) return naText; if (seconds < 0) return 'on battery'; const total = Math.round(seconds); if (total < 120) return `${total}s`; @@ -32,6 +35,87 @@ function runtimeText(seconds) { return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`; } +/** + * The battery that opens the charge hero: `24 V nominal · 27.3 V`. + * + * The pack's *nominal* voltage is the spec sheet and the measured one is the health signal — + * 27.3 V on a 24 V pack means the float is doing its job, 22 V on line means it is not. One + * number without the other is trivia, and neither is a value the driver does not publish. + */ +function chargeSub(numbers) { + const bits = []; + if (numbers.battery_voltage_nominal != null) bits.push(`${num(numbers.battery_voltage_nominal, 0)} V nominal`); + if (numbers.battery_voltage != null) bits.push(`${num(numbers.battery_voltage, 1)} V`); + return bits.length ? bits.join(' · ') : null; +} + +/** + * `128 W of 900 W` when the driver publishes both numbers, and nothing when it does not. + * + * `ups.load` is a percentage of a rating the UPS may not report (`battery.capacity`-only + * drivers exist), and a watt figure inferred from a percent and a *different* model's rating + * is exactly the invented number this app refuses to print. + */ +function loadSub(numbers) { + if (numbers.realpower == null || numbers.realpower_nominal == null) return null; + return `${num(numbers.realpower, 0)} W of ${num(numbers.realpower_nominal, 0)} W`; +} + +/** + * The state the glyph draws, from fields the API already interpreted. + * + * The order matters, and NUT's flags are subtle, so each branch is spelled out. The parser + * for `ups.status` stays in `sources/nut.py` (`STATE_FLAGS` / `CHARGER_STATUS`) — this + * function reads its *outputs* (`on_battery`, the normalised `charger_status`, the rated + * `states[]`), never the raw flag string (`tests/test_ups_contract.py` pins that). + * + * - `on_battery` → `'onbattery'` (dashed shell). The server derives it from the online / + * on-battery flags and, with neither present, from a discharging charger status — so this + * one field already carries the two UPSes on this fleet, neither of whose drivers exports + * the NUT 2.8 `battery.charger.status` at all. + * - `'chg'` → `'charging'` (the bolt). `'dchg'` / `'rec'` are the server's own on-battery + * tokens, so they draw the same dashed shell when a driver reports one without a status + * the server could read. + * - `'full'` is **not** charging. A rested, floating, fully-charged battery reports it, and + * "charging" is the word an operator reads as "this battery was flat recently". It is the + * same verdict as being on line, and that is what the glyph shows. + * - the online family (`ol`, `ol trim`, `ol boost`) → `'online'`, and so does a state chip + * the API itself rated `ok`: `nut.states()` reserves `ok` for on-line and fully-charged, + * which is what a driver with no rich status still manages to say. + * - nothing known → `'unknown'`: a cell with a dash through it, never a confident "on line". + */ +function glyphState(item) { + if (item.on_battery) return 'onbattery'; + const charger = item.charger_status; + if (charger === 'chg') return 'charging'; + if (charger === 'dchg' || charger === 'rec') return 'onbattery'; + if (charger === 'ol' || charger === 'ol trim' || charger === 'ol boost' || charger === 'full') { + return 'online'; + } + return (item.states || []).some((state) => state.severity === 'ok') ? 'online' : 'unknown'; +} + +/** + * Draw the glyph, but only when something a viewer can see changed. + * + * `battery.charge` is a float that drifts by a tenth of a percent between polls, so + * repainting the `` every 5 s would rebuild the DOM, re-resolve the theme tokens and + * re-announce the accessible name for a difference nobody can see. The container remembers + * the triple it last drew. `decorative: true` because the charge hero beside it and the + * state chips above it already say "Charge 100 percent, on line" — a second copy in the + * accessibility tree is the duplicate the component's docstring warns about. + */ +function drawGlyph(cell, item) { + const numbers = item.numbers || {}; + const state = glyphState(item); + const charge = numbers.charge_pct; + const severity = (item.severity || {}).charge_pct; + const drawn = `${charge === undefined ? '' : charge}|${severity || ''}|${state}`; + if (cell.dataset.drawn === drawn) return; + cell.dataset.drawn = drawn; + DashCharts.battery(cell, { charge, severity, state, label: item.name, decorative: true }); +} + function upsCard(name) { const card = document.createElement('article'); card.className = 'card'; @@ -48,15 +132,15 @@ function upsCard(name) {
-
-
Charge
-
n/a%
-
-
Runtime left
-
n/a
-
Load
-
n/a%
-
+
+
+
+
+
+
+
+
+
Line in
n/aV
Out
@@ -116,19 +200,41 @@ function renderCard(card, item, stateRow) { (item.alarms || []).map((alarm) => `alarm: ${ DashCharts.escapeHtml(alarm)}`).join(''); + // The hero row. Every colour here is a verdict `severity.py` made and `payload()` sent; + // nothing below compares a number to a bound (a JS threshold could disagree with the + // overview's alert for the same value), and a missing number or a missing verdict prints + // `n/a` uncoloured, which is `Dash.hero()`'s contract. const n = item.numbers || {}; - const cell = (key) => q(key); - setValue(cell('charge'), n.charge_pct, 0); - setBar(card.querySelector('[data-bar="charge"]'), n.charge_pct, - n.charge_pct == null ? '' : n.charge_pct >= 95 ? 'ok' : n.charge_pct >= 60 ? 'warn' : 'fail'); - setTxt(cell('runtime'), runtimeText(n.battery_runtime)); - setValue(cell('load'), n.load_pct, 1); - setBar(card.querySelector('[data-bar="load"]'), n.load_pct, severityFor(n.load_pct, 70, 90)); - setValue(cell('line'), n.line_voltage, 1); - setValue(cell('out'), n.output_voltage, 1); - setValue(cell('batt'), n.battery_voltage, 2); - setValue(cell('amps'), n.battery_current, 2); - setValue(cell('temp'), n.battery_temperature, 1); + const verdicts = item.severity || {}; + drawGlyph(q('glyph'), item); + // Charge gets no `.bar`: the glyph beside it *is* this value drawn as a shape, and one + // value drawn twice in the same row is the pattern the rest of this app avoids. + Dash.hero(q('charge'), { + label: 'Charge', value: n.charge_pct, unit: '%', digits: 0, + severity: verdicts.charge_pct, sub: chargeSub(n), + }); + Dash.hero(q('load'), { + label: 'Load', value: n.load_pct, unit: '%', digits: 1, + severity: verdicts.load_pct, bar: true, sub: loadSub(n), + }); + // Runtime prose comes from `runtimeText()` and nowhere else. An estimate the driver never + // made goes in as `null` — not as the string "n/a", which `Dash.hero()` would render like + // a measurement instead of the faint, uncoloured unknown it is. + Dash.hero(q('runtime'), { + label: 'Runtime left', value: n.battery_runtime == null ? null : runtimeText(n.battery_runtime), + severity: verdicts.battery_runtime, + sub: n.battery_runtime == null ? null : 'at the current load', + }); + // Secondary: the electrical readings. They stay visible rather than folded away because + // they are the numbers you reach for once the UPS *is* on battery — line in to see whether + // utility power really went, battery volts and amps to see what the pack is doing. + setValue(q('line'), n.line_voltage, 1); + setValue(q('out'), n.output_voltage, 1); + setValue(q('batt'), n.battery_voltage, 2); + setValue(q('amps'), n.battery_current, 2); + // `n/a`, never `0 °C`: neither UPS on this fleet reports a battery temperature, and a zero + // here would read as a cold, healthy pack (`AGENTS.md` invariant 2). + setValue(q('temp'), n.battery_temperature, 1); const health = item.health || { checks: [] }; q('checks').innerHTML = (health.checks || []).map((check) => ` diff --git a/tests/fixtures/nut_apc.vars b/tests/fixtures/nut_apc.vars new file mode 100644 index 0000000..d30dc56 --- /dev/null +++ b/tests/fixtures/nut_apc.vars @@ -0,0 +1,75 @@ +# Verbatim upsd reply (LIST UPS / LIST VAR / LIST CMD), recorded by +# ./scripts/capture-fixtures.sh — one `VAR`/`CMD` line per protocol line. +# Volatile values (ups.load, battery.runtime, the voltages) move every poll, +# so a diff here on re-capture is expected; the contract asserts shape, units +# and which variables the drivers omit, never those numbers. +UPS apc "APC Back-UPS RS 1500MS (USB)" +VAR apc battery.charge "100" +VAR apc battery.charge.low "10" +VAR apc battery.charge.warning "50" +VAR apc battery.date "2001/09/25" +VAR apc battery.mfr.date "2021/06/24" +VAR apc battery.runtime "648" +VAR apc battery.runtime.low "120" +VAR apc battery.type "PbAc" +VAR apc battery.voltage "27.3" +VAR apc battery.voltage.nominal "24.0" +VAR apc device.mfr "American Power Conversion" +VAR apc device.model "Back-UPS RS 1500MS" +VAR apc device.serial "SCRUBBEDSERIAL1 " +VAR apc device.type "ups" +VAR apc driver.debug "0" +VAR apc driver.flag.allow_killpower "0" +VAR apc driver.name "usbhid-ups" +VAR apc driver.parameter.interrupt_pipe_no_events_tolerance "-1" +VAR apc driver.parameter.pollfreq "30" +VAR apc driver.parameter.pollinterval "2" +VAR apc driver.parameter.port "auto" +VAR apc driver.parameter.productid "0002" +VAR apc driver.parameter.synchronous "auto" +VAR apc driver.parameter.vendorid "051d" +VAR apc driver.state "quiet" +VAR apc driver.version "2.8.5" +VAR apc driver.version.data "APC HID 0.101" +VAR apc driver.version.internal "0.71" +VAR apc driver.version.usb "libusb-1.0.30 (API: 0x0100010C)" +VAR apc input.sensitivity "medium" +VAR apc input.transfer.high "144" +VAR apc input.transfer.low "88" +VAR apc input.voltage "123.0" +VAR apc input.voltage.nominal "120" +VAR apc ups.beeper.status "disabled" +VAR apc ups.delay.shutdown "20" +VAR apc ups.firmware "952.e5 .D" +VAR apc ups.firmware.aux "e5 " +VAR apc ups.load "48" +VAR apc ups.mfr "American Power Conversion" +VAR apc ups.mfr.date "2021/01/08" +VAR apc ups.model "Back-UPS RS 1500MS" +VAR apc ups.productid "0002" +VAR apc ups.realpower.nominal "900" +VAR apc ups.serial "SCRUBBEDSERIAL1 " +VAR apc ups.status "OL" +VAR apc ups.test.result "No test initiated" +VAR apc ups.timer.reboot "0" +VAR apc ups.timer.shutdown "-1" +VAR apc ups.vendorid "051d" +CMD apc beeper.disable +CMD apc beeper.enable +CMD apc beeper.mute +CMD apc beeper.off +CMD apc beeper.on +CMD apc driver.killpower +CMD apc driver.reload +CMD apc driver.reload-or-error +CMD apc driver.reload-or-exit +CMD apc load.off +CMD apc load.off.delay +CMD apc shutdown.default +CMD apc shutdown.reboot +CMD apc shutdown.stop +CMD apc test.battery.start.deep +CMD apc test.battery.start.quick +CMD apc test.battery.stop +CMD apc test.panel.start +CMD apc test.panel.stop diff --git a/tests/fixtures/nut_cyberpower.vars b/tests/fixtures/nut_cyberpower.vars new file mode 100644 index 0000000..ef89a5d --- /dev/null +++ b/tests/fixtures/nut_cyberpower.vars @@ -0,0 +1,79 @@ +# Verbatim upsd reply (LIST UPS / LIST VAR / LIST CMD), recorded by +# ./scripts/capture-fixtures.sh — one `VAR`/`CMD` line per protocol line. +# Volatile values (ups.load, battery.runtime, the voltages) move every poll, +# so a diff here on re-capture is expected; the contract asserts shape, units +# and which variables the drivers omit, never those numbers. +UPS cyberpower "CyberPower PR1500LCDRT2U (USB)" +VAR cyberpower battery.charge "100" +VAR cyberpower battery.charge.low "10" +VAR cyberpower battery.charge.warning "20" +VAR cyberpower battery.mfr.date "CPS" +VAR cyberpower battery.runtime "3350" +VAR cyberpower battery.runtime.low "300" +VAR cyberpower battery.status "100%" +VAR cyberpower battery.type "PbAcid" +VAR cyberpower battery.voltage "27.2" +VAR cyberpower battery.voltage.nominal "24" +VAR cyberpower device.mfr "CPS" +VAR cyberpower device.model "GX1500U" +VAR cyberpower device.serial "SCRUBBEDSERIAL1" +VAR cyberpower device.type "ups" +VAR cyberpower driver.debug "0" +VAR cyberpower driver.flag.allow_killpower "0" +VAR cyberpower driver.name "usbhid-ups" +VAR cyberpower driver.parameter.interrupt_pipe_no_events_tolerance "-1" +VAR cyberpower driver.parameter.pollfreq "12" +VAR cyberpower driver.parameter.pollinterval "2" +VAR cyberpower driver.parameter.port "auto" +VAR cyberpower driver.parameter.productid "0601" +VAR cyberpower driver.parameter.synchronous "auto" +VAR cyberpower driver.parameter.vendorid "0764" +VAR cyberpower driver.state "quiet" +VAR cyberpower driver.version "2.8.5" +VAR cyberpower driver.version.data "CyberPower HID 0.85" +VAR cyberpower driver.version.internal "0.71" +VAR cyberpower driver.version.usb "libusb-1.0.30 (API: 0x0100010C)" +VAR cyberpower input.sensitivity "normal" +VAR cyberpower input.transfer.high "139" +VAR cyberpower input.transfer.low "100" +VAR cyberpower input.voltage "122.0" +VAR cyberpower input.voltage.nominal "120" +VAR cyberpower output.voltage "122.0" +VAR cyberpower ups.beeper.status "disabled" +VAR cyberpower ups.delay.shutdown "60" +VAR cyberpower ups.delay.start "120" +VAR cyberpower ups.firmware "CR01906AC911" +VAR cyberpower ups.load "14" +VAR cyberpower ups.mfr "CPS" +VAR cyberpower ups.model "GX1500U" +VAR cyberpower ups.power "131" +VAR cyberpower ups.power.nominal "1500" +VAR cyberpower ups.productid "0601" +VAR cyberpower ups.realpower "127" +VAR cyberpower ups.realpower.nominal "900" +VAR cyberpower ups.serial "SCRUBBEDSERIAL1" +VAR cyberpower ups.status "OL" +VAR cyberpower ups.test.result "No test initiated" +VAR cyberpower ups.timer.shutdown "-60" +VAR cyberpower ups.timer.start "-60" +VAR cyberpower ups.vendorid "0764" +CMD cyberpower beeper.disable +CMD cyberpower beeper.enable +CMD cyberpower beeper.mute +CMD cyberpower beeper.off +CMD cyberpower beeper.on +CMD cyberpower driver.killpower +CMD cyberpower driver.reload +CMD cyberpower driver.reload-or-error +CMD cyberpower driver.reload-or-exit +CMD cyberpower load.off +CMD cyberpower load.off.delay +CMD cyberpower load.on +CMD cyberpower load.on.delay +CMD cyberpower shutdown.default +CMD cyberpower shutdown.return +CMD cyberpower shutdown.stayoff +CMD cyberpower shutdown.stop +CMD cyberpower test.battery.start.deep +CMD cyberpower test.battery.start.quick +CMD cyberpower test.battery.stop diff --git a/tests/test_api.py b/tests/test_api.py index 16ba5c0..22ed6a5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -575,17 +575,20 @@ def test_hero_reads_the_verdict_instead_of_a_threshold(client) -> None: def test_severity_for_is_kept_as_the_documented_fallback(client) -> None: - """The old call sites still work until the remaining phases convert each page. + """Every page call site is gone now that phase 05 converted `/ups`. - `/` was converted in phase 02, `/system` in phase 03 and `/llm` in phase 04: each - colours itself from the `severity` verdicts the API already sends, so it carries no - threshold of its own. `/ups` is phase 05's job and must keep compiling against the - fallback until it is converted — deleting it early would break that page. + `/` was converted in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in + phase 05, so no page colours anything from a threshold it wrote itself. The function + itself stays: it is the documented fallback `setBar()` uses when a caller names no + verdict, and a page that ever needs a verdict the API does not send should add it to + `severity.py` rather than here — which is what the docstring on it says. """ js = client.get("/static/app.js").text assert "FALLBACK ONLY" in js - assert "severityFor" in client.get("/static/page-ups.js").text, \ - "/ups still uses the fallback until phase 05 converts it" + assert "severityFor" in js, "the documented fallback itself was deleted" + for page in ("page-index.js", "page-system.js", "page-llm.js", "page-ups.js"): + assert "severityFor" not in client.get(f"/static/{page}").text, \ + f"{page} colours something from a threshold of its own" def test_overview_colours_from_the_payload_not_from_js_thresholds(client) -> None: @@ -826,6 +829,256 @@ def test_sparkline_ships_the_fixed_scale_and_verdict_options(client) -> None: assert "transition:" not in body and "animation:" not in body +# ---- the battery glyph (phase 05 task 02) --------------------------------- +# +# `DashCharts.battery()` is the one visual in this app whose *shape* is the data: the fill +# is the charge. That makes the honesty rules heavier here than anywhere else — a fill +# rounded up to look reassuring, a colour invented for an unrated cell, or a state only +# readable as green-vs-red would each turn a glance at the battery into a wrong claim. +# There is no JS test runner (a locked anchor), so the construction is pinned on the served +# bytes and shown in a browser (`data/logs/ups-glyph-*.png`). + +def _battery_js(client: TestClient) -> str: + """The glyph's code as served (state table + body), comments stripped. + + Prose cannot satisfy these assertions, and the phrase table the accessible name is + built from is part of the component, so the slice starts above `function battery`. + """ + charts = client.get("/static/charts.js").text + body = charts[charts.index("const BATTERY_STATES"):charts.index("window.DashCharts")] + assert "function battery(container, opts)" in body + return _code(body) + + +def test_battery_glyph_is_exported_from_the_shared_chart_library(client) -> None: + """The phase's own grep criterion, as a test: exactly one glyph, and it is public.""" + charts = client.get("/static/charts.js").text + assert charts.count("function battery") == 1 + exports = charts[charts.index("window.DashCharts"):] + assert re.search(r"\bbattery\b", exports), "DashCharts.battery is not exported" + # the wrapper the stylesheet sizes, added by the component rather than each caller + assert ".battery {" in _css(client) and ".battery svg" in _css(client) + + +def test_battery_fill_is_the_charge_and_is_never_padded(client) -> None: + """2 % must look nearly empty, and 0 % must leave an empty shell, not a stray pixel.""" + body = _battery_js(client) + # clamped to the scale, and the clamp is the only thing that touches the number: a + # minimum visible width would be a reassuring battery over a flat one + assert "Math.max(0, Math.min(100," in body + assert "(pctValue / 100) * inner.w" in body, "the fill stopped being a fraction of charge" + assert not re.search(r"Math\.max\(\s*[0-9.]*[1-9]\s*,", body), \ + "the charge fill got a minimum width — a small charge would look bigger than it is" + # nothing is drawn for a cell at zero, and the plate it fills is a clip, so a low fill + # cannot leak outside the rounded interior + assert "pctValue > 0" in body + assert " None: + """The verdict paints; an unrated cell draws a grey shape and no colour at all.""" + body = _battery_js(client) + for token in ("cssVar('--ok')", "cssVar('--warn')", "cssVar('--fail')"): + assert token in body, f"the glyph stopped taking its colour from {token}" + # no verdict → muted outline, and a measured charge in faint grey at half strength: a + # shape, not a judgement (an unknown number must never look green) + assert "cssVar('--text-muted')" in body and "cssVar('--text-faint')" in body + assert ' opacity=".5"' in body, "an unrated charge is drawn with the same weight as a rated one" + assert ".sev-" not in body, "the glyph invented a parallel severity vocabulary" + assert "severityFor(" not in body, "the glyph compares the charge to a threshold itself" + + +def test_battery_states_are_readable_without_colour(client) -> None: + """Every state has a *shape*, so the glyph survives monochrome and colour-blindness. + + on battery = dashed outline, charging = bolt, unknown = a dash pattern through the + body, online = a plain solid shell. A word the page did not intend falls back to + `unknown` rather than to a confident "on line". + """ + body = _battery_js(client) + assert 'BATTERY_STATES.indexOf(o.state) >= 0' in body, \ + "an unrecognised state would be drawn as some other state" + assert 'state === \'onbattery\'' in body and 'stroke-dasharray="7 5"' in body, \ + "the on-battery outline is no longer dashed" + assert "state === 'charging'" in body and 'd="M13 2 L3 14' in body, "the charging bolt is gone" + assert "state === 'unknown'" in body and 'stroke-dasharray="5 6"' in body, \ + "an unknown charge no longer draws a dash through the body" + # and the unknown case really is *no fill*: the shape has to say it on its own + assert "pctValue != null" in body + + +def test_battery_glyph_names_itself_and_can_say_it_once(client) -> None: + """`role="img"` + `` built from the inputs, with an explicit `aria-hidden` opt-out.""" + body = _battery_js(client) + assert 'role="img"' in body and "<title>" in body and "aria-label=" in body + for phrase in ("Charge ", " percent", "Charge unknown", "on line", "on battery", + "charging", "state unknown"): + assert phrase in body, f"the accessible name lost {phrase!r}" + # the label is a UPS name from upsd, so it goes through the one escaper + assert body.count("escapeHtml(name)") == 2, "<title> or aria-label stopped escaping the label" + assert 'aria-hidden="true"' in body, "the decorative opt-out for a duplicate hero number is gone" + + +def test_battery_glyph_scales_with_its_container_and_never_animates(client) -> None: + """One `viewBox` + `preserveAspectRatio` for a phone and a 27" monitor; no motion at all.""" + body = _battery_js(client) + assert 'viewBox="0 0 ${W} ${H}"' in body + assert 'preserveAspectRatio="xMidYMid meet"' in body, "the glyph stopped scaling with its box" + assert "ResizeObserver" not in body, "the glyph redraws on resize instead of scaling" + assert "transition" not in body and "animation" not in body + css = _code(_css(client)) + wrapper = re.search(r"\.battery \{([^}]*)\}", css) + inner = re.search(r"\.battery svg \{([^}]*)\}", css) + assert wrapper and inner, "dash.css no longer sizes the glyph box" + assert "width: 100%" in wrapper.group(1) and "max-width" in wrapper.group(1), \ + "the glyph is not capped inside its container" + assert "width: 100%" in inner.group(1) and "height: auto" in inner.group(1), \ + "the svg would stretch instead of keeping the viewBox ratio" + both = wrapper.group(1) + inner.group(1) + assert "transition" not in both and "animation" not in both + + +# ---- the `/ups` hero row (phase 05 task 03) ------------------------------ +# +# The card used to be nine equal `.metric` cells in which charge, load and runtime were the +# same size as the battery amperage. These pin the shape that replaced it on the served +# bytes (a locked anchor: no JS test runner), because the whole point of the row is a size +# and colour hierarchy a later "tidy up" can quietly flatten back to nine equal cells. + +def _ups_card_js(client: TestClient) -> str: + """The card markup as served, from `upsCard()` to `renderCard()`.""" + js = client.get("/static/page-ups.js").text + return js[js.index("function upsCard("):js.index("function renderCard(")] + + +def test_ups_card_opens_with_the_glyph_and_three_heroes(client) -> None: + """Charge / Load / Runtime are the card's three largest elements, next to the glyph. + + `data-sev` names the verdict path each slot colours itself from, the same convention the + `/system` host card uses — so "which number is coloured by what" is readable in the + markup, and a rename that disconnects the page from `/api/ups/snapshot` fails here + instead of shipping a card of `n/a`. + """ + card = _ups_card_js(client) + assert '<div class="ups-hero">' in card, "the hero row left the top of the card" + assert 'data-k="glyph"' in card, "the battery glyph is not part of the hero row" + slots = re.findall(r'<div class="hero[^"]*" data-k="([^"]+)"', card) + assert slots == ["charge", "load", "runtime"], \ + "the hero row is no longer charge / load / runtime in the agreed order" + for hook in ("severity.charge_pct", "severity.load_pct", "severity.battery_runtime"): + assert hook in card, f"the card no longer names {hook} as a colour source" + # the hero row is the first thing in the card body, above the health checks and commands + assert card.index("ups-hero") < card.index('data-k="checks"') + assert card.index("ups-hero") < card.index('data-k="commands"') + + +def test_ups_heroes_take_their_colour_from_the_payload(client) -> None: + """Three `Dash.hero()` calls, three payload verdicts, no JS threshold. + + The charge hero carries **no** `.bar`: the glyph beside it is that same value drawn as a + shape, and one value drawn twice in one row is the pattern this app avoids (load, which + has no other shape, keeps its bar). Runtime prose comes from `runtimeText()` alone, and + a runtime nobody estimated is passed as `null` so `hero()` renders the faint, uncoloured + unknown rather than the string `n/a` styled like a measurement. + """ + js = client.get("/static/page-ups.js").text + body = js[js.index("function renderCard("):js.index("// ---- command dialog")] + assert body.count("Dash.hero(q(") == 3, "the hero row is not three hero numbers" + for verdict in ("verdicts.charge_pct", "verdicts.load_pct", "verdicts.battery_runtime"): + assert verdict in body, f"{verdict} never reaches a hero" + charge = body[body.index("Dash.hero(q('charge')"):body.index("Dash.hero(q('load')")] + assert "bar" not in charge, "charge grew a second shape when the glyph already is one" + load = body[body.index("Dash.hero(q('load')"):body.index("Dash.hero(q('runtime')")] + assert "bar: true" in load, "load lost its bar" + assert "runtimeText(n.battery_runtime)" in body, "runtime prose bypasses runtimeText()" + runtime = body[body.index("Dash.hero(q('runtime')"):] + assert "n.battery_runtime == null ? null" in runtime, \ + "an unmeasured runtime is rendered as a measurement" + assert "severityFor(" not in body + + +def test_ups_secondary_electrical_row_is_demoted_not_deleted(client) -> None: + """Line in, output, battery volts and amps, battery °C: still there, visibly second. + + These are the numbers you reach for *while* the UPS is on battery, so they were demoted + rather than folded into the disclosure — and they stay one step down (smaller, muted) so + the three verdicts remain the largest elements, which is also what keeps the card from + growing so tall that two stop fitting side by side on a laptop. + """ + card = _ups_card_js(client) + assert 'class="metrics ups-electrical"' in card + for key in ("line", "out", "batt", "amps", "temp"): + assert f'data-k="{key}"' in card, f"the electrical row lost {key}" + assert card.index("ups-electrical") > card.index("ups-hero") + css = _code(_css(client)) + rule = re.search(r"\.ups-electrical \.metric \.value\.sm \{([^}]*)\}", css) + assert rule, "dash.css no longer styles the secondary row" + assert "var(--text-muted)" in rule.group(1), "the secondary row is not muted" + sizes = re.findall(r"font-size: ([\d.]+)rem", rule.group(1)) + assert sizes and float(sizes[0]) < 1.3, "the secondary row competes with the heroes" + # `n/a` is fainter than a muted reading — neither real driver reports a battery temperature + null_rule = re.search(r"\.ups-electrical \.metric \.value\.sm\.is-null \{([^}]*)\}", css) + assert null_rule and "var(--text-faint)" in null_rule.group(1), \ + "an unmeasured secondary value is not faint, so it reads like a number" + + +def test_ups_glyph_state_comes_from_the_api_fields(client) -> None: + """`glyphState()` maps `on_battery` + `charger_status` — and `full` is **not** charging. + + The mapping is presentation, so it lives in the page (task 02's assumption), but it is + still a mapping the real drivers exercise: neither UPS here exports + `battery.charger.status`, so the fallback that reads a state the API rated `ok` is what + draws a solid shell on both of them. Nothing here re-reads `ups.status` — + `test_the_page_does_not_re_parse_nut_status_flags` holds that line. + """ + js = _code(client.get("/static/page-ups.js").text) + glyph = js[js.index("function glyphState("):js.index("function drawGlyph(")] + assert "item.on_battery" in glyph and "'onbattery'" in glyph + assert "charger === 'chg'" in glyph and "'charging'" in glyph + assert "charger === 'dchg'" in glyph, "a discharging charger token is on battery too" + assert "charger === 'full'" in glyph and "'online'" in glyph, \ + "a full, online battery must not be drawn as charging" + assert "item.states" in glyph, "a driver with no rich status lost its state entirely" + assert "'unknown'" in glyph, "an unknown state now degrades to a confident 'on line'" + + +def test_ups_glyph_repaints_only_when_something_visible_changes(client) -> None: + """Charge jitters by a tenth of a percent every poll; the `<svg>` does not rebuild on it. + + Rebuilding would re-resolve the theme tokens and re-announce the accessible name every + `NUT_INTERVAL_SECONDS` for a difference no eye can see. `decorative: true` is the + component's documented opt-out — the charge hero and the state chips already say + "Charge 100 percent, on line", and a screen reader should not hear it twice. + """ + js = _code(client.get("/static/page-ups.js").text) + draw = js[js.index("function drawGlyph("):js.index("function upsCard(")] + assert "DashCharts.battery(" in draw + assert "cell.dataset.drawn === drawn" in draw, "the glyph rebuilds on every poll" + assert "decorative: true" in draw, "the glyph duplicates the hero number in a11y" + assert "glyphState(item)" in draw + + +def test_ups_hero_row_makes_room_for_the_glyph(client) -> None: + """The glyph gets a real column, and the row stacks rather than clipping on a phone. + + The track floor is the widest runtime prose the page prints (`9h 59m`) — a number that + cannot wrap needs the floor to be its own width, or the row grows a clipped `1h 28`. + """ + css = _code(_css(client)) + row = re.search(r"\.ups-hero \{([^}]*)\}", css) + assert row, "dash.css lost the UPS hero row" + assert "minmax(112px, 156px) minmax(0, 1fr)" in row.group(1), "the glyph has no column" + assert "align-items: center" in row.group(1) + heroes = re.search(r"\.ups-hero \.heroes \{([^}]*)\}", css) + assert heroes and "minmax(min(140px, 100%), 1fr)" in heroes.group(1), \ + "a hero track can now clip the widest runtime prose" + at = css.index("@media (max-width: 620px)") + block = css[at:at + 240] + assert ".ups-hero" in block and "minmax(0, 1fr)" in block, "the row never stacks" + + # ---- the `/llm` layout (phase 04 task 03) --------------------------------- # # The drift this phase fixes was a 14-cell grid where everything was the same size and diff --git a/tests/test_live_fleet.py b/tests/test_live_fleet.py index 81fa7ff..8b70a41 100644 --- a/tests/test_live_fleet.py +++ b/tests/test_live_fleet.py @@ -30,8 +30,10 @@ from card_contract import assert_card_contract from dashboards import collectors from dashboards.config import Settings, load from dashboards.rates import RateEstimator +from dashboards.severity import classify from dashboards.sources import nut from dashboards.sources.host import HostCollector +from ups_contract import assert_ups_hero_contract pytestmark = pytest.mark.live @@ -151,6 +153,29 @@ async def test_upsd_reports_both_upses_with_sane_units(settings: Settings) -> No assert temp is None or -20.0 <= temp <= 80.0, f"{name}: battery temp {temp} is not °C" +async def test_ups_hero_fields_are_present_on_the_real_batteries(settings: Settings) -> None: + """Phase 05's hero row and battery glyph read these paths on the real UPSes. + + Hermetic twin: `tests/test_ups_contract.py`, driven from the verbatim captures in + `tests/fixtures/nut_*.vars`. This is the half that catches a driver update renaming a + variable, adding `battery.charger.status`, or starting to report a temperature in a unit + the page has never seen — the assertion is on the shape and the vocabulary, never on a + number that moves between polls. + """ + require_upsd(settings) + result = await collectors.NutMonitor(settings).collect() + cards = result.payload["ups"] + assert cards, "upsd answered but exported no UPS — nothing for the hero row to render" + for card in cards: + assert_ups_hero_contract(card) # includes: charger_status is None or a token we label + runtime = card["numbers"]["battery_runtime"] + assert card["severity"]["battery_runtime"] == (None if runtime is None else + classify("ups.battery_runtime", runtime)) + assert not (card["on_battery"] and card["status"] and "OL" in card["status"].split()), ( + f"{card['name']}: on_battery and OL at once — the glyph would show the wrong state" + ) + + async def test_ups_command_gating_is_honest_without_sending_anything(settings: Settings) -> None: """Every advertised command is either enabled or carries the reason it is not. diff --git a/tests/test_nut.py b/tests/test_nut.py index d49251c..7ba0039 100644 --- a/tests/test_nut.py +++ b/tests/test_nut.py @@ -8,13 +8,14 @@ directly, because those are what decide whether a battery test gets sent. from __future__ import annotations import asyncio +import time import pytest import pytest_asyncio from dashboards.sources import nut from dashboards.sources.nut import NutClient, NutError, UpsReading, normalise_celsius -from dashboards.sources.nut_sim import NutSimulator +from dashboards.sources.nut_sim import NutSimulator, SimUps, from_env VAR_ROWS = { "battery.charge": "100", @@ -548,7 +549,142 @@ async def test_simulator_discharges_while_on_battery() -> None: assert ups.charge < before +def test_states_never_say_the_same_word_twice() -> None: + """A driver exporting both the 2.8 token and the legacy flag gets one chip per fact. + + Both spellings arrive at the word "charging"; a chip row that says it twice reads as + two events. The token wins (it distinguishes trim/boost/full), the subsumed flag goes. + """ + both = UpsReading(name="x", vars={"battery.charger.status": "chg", "ups.status": "OL CHRG"}) + assert [s["label"] for s in both.states()] == ["charging", "online"] + dischrg = UpsReading(name="x", vars={"battery.charger.status": "dchg", "ups.status": "OB DISCHRG"}) + assert [s["label"] for s in dischrg.states()] == ["discharging", "on battery"] + plain = UpsReading(name="x", vars={"ups.status": "OL CHRG"}) + assert [s["label"] for s in plain.states()] == ["online", "charging"], \ + "the flag-only route keeps both facts" + + +def test_a_recharging_simulated_ups_reports_charging_like_a_real_driver() -> None: + """A below-float pack on mains is *charging* — APC prints exactly `OL CHRG` there. + + The `/ups` glyph's bolt is keyed on `charger_status == "chg"`, and a model that only + ever answered `ol`/`OL` made that state unreachable in dev — the flag and the charger + token also contradicted each other, plain `OL` next to a charge that was climbing. + """ + ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", charge=60.0) + vars_ = ups.vars(time.time()) + assert vars_["ups.status"] == "OL CHRG" + assert vars_["battery.charger.status"] == "chg" + card = nut.payload(UpsReading(name="apc", vars=vars_, commands=[])) + assert card["charger_status"] == "chg", "the page derives the bolt from this token only" + assert card["on_battery"] is False, "OL CHRG is on line, not on the battery" + + +def test_a_discharging_simulated_ups_is_never_online() -> None: + """`ol`/`full` next to `OB` is a contradiction; a drawn-from battery reports `dchg`.""" + ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", charge=100.0, on_battery=True) + vars_ = ups.vars(time.time()) + assert "OL" not in vars_["ups.status"].split() + assert vars_["battery.charger.status"] == "dchg" + + async def test_simulator_command_for_unknown_ups_raises() -> None: sim = NutSimulator() with pytest.raises(KeyError): sim.command("nope", "beeper.mute") + + +def test_nut_simulate_is_the_only_switch_and_says_so() -> None: + """The enable rule lives once, next to the model it swaps in. + + `app.py` builds the monitor through this, so `1/true/yes/on` mean the same thing in the + assembly and in the module — and anything else means *real upsd*, which is the default that + must never be reachable by an empty or half-set variable. + """ + assert isinstance(from_env("1"), NutSimulator) + assert isinstance(from_env(" TRUE "), NutSimulator) + assert from_env(None) is None + assert from_env("") is None + assert from_env("0") is None, "a commented-out or zeroed NUT_SIMULATE must read real upsd" + + +async def test_every_command_the_simulator_advertises_is_implemented() -> None: + """`commands()` is what the `/ups` page turns into buttons, so it must not list a lie. + + A command the model advertises but `run_command()` does not implement raises, and the dev + page would then show `refused` for a button that is supposed to work — the simulator is + the only driver this app's one write path is ever exercised against, so an unimplemented + advertised command is a bug in the model, not a missing feature. + """ + sim = NutSimulator() + ups = (await sim.read())[0] + assert ups.commands, "a simulator with no commands cannot exercise the buttons at all" + for command in ups.commands: + assert sim.command(ups.name, command).startswith("OK"), command + + +def test_an_unimplemented_command_is_refused_by_the_model_too() -> None: + """Silently accepting a command the driver would reject hides a bad catalog entry.""" + ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1") + with pytest.raises(ValueError): + ups.run_command("outlet.1.switch.off", now=time.time()) + + +def test_the_beeper_commands_change_the_status_a_driver_would_publish() -> None: + """`beeper.mute` answering OK while nothing changes teaches people to trust a dead button. + + Real drivers report `ups.beeper.status`, and the NUT variable dump on the card shows it, + so each of the three commands has its own observable state here — `mute` and `disable` are + different words in NUT and stay different here. + """ + ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1") + assert ups.vars(time.time())["ups.beeper.status"] == "enabled" + now = time.time() + ups.run_command("beeper.mute", now=now) + assert ups.vars(now)["ups.beeper.status"] == "muted" + ups.run_command("beeper.disable", now=now) + assert ups.vars(now)["ups.beeper.status"] == "disabled" + ups.run_command("beeper.enable", now=now) + assert ups.vars(now)["ups.beeper.status"] == "enabled" + + +def test_a_finished_simulated_test_reports_its_result_and_a_stopped_one_reports_nothing() -> None: + """The command → state change → health-check path the simulator exists for. + + `ups.test.result` is the variable the "Last self-test" check reads, so a quick test that + ends with the pack back on line but the old result still published shows a card that never + learned the test happened. An aborted test writes no result: a driver does not. + """ + ups = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", test_result="FAILED", + test_date="2020-01-01") + now = time.time() + ups.run_command("test.battery.start.quick", now=now) + assert ups.on_battery is True + ups.tick(now + 20.0) + assert ups.on_battery is False, "a quick test is over after its ~12s window" + assert ups.test_result == "PASSED" + assert ups.test_date == time.strftime("%Y-%m-%d", time.gmtime(now)) + + aborted = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", test_result="FAILED") + aborted.run_command("test.battery.start.quick", now=now) + aborted.run_command("test.battery.stop", now=now) + aborted.tick(now + 20.0) + assert aborted.test_result == "FAILED", "an aborted test must not be reported as a pass" + + +def test_a_simulated_alarm_reaches_the_card_and_the_health_check() -> None: + """The `alarm:` chip and the `Alarms` row are reachable in dev only through the model. + + `assess()` fails that row for the codes that mean the pack or the load is in trouble, and + neither UPS on this fleet has ever raised one — so the alarm path is pinned here instead of + against a capture nobody can produce on demand. + """ + quiet = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1") + now = time.time() + assert "ups.alarm" not in quiet.vars(now), "a quiet UPS must not invent an alarm" + + loud = SimUps("apc", "Smart-UPS 1500", "APC", "AS-1", _alarms=["LB"]) + card = nut.payload(UpsReading(name="apc", vars=loud.vars(now), commands=[])) + assert card["alarms"] == ["LB"] + check = next(c for c in card["health"]["checks"] if c["name"] == "Alarms") + assert check["status"] == "fail", "a low-battery alarm is not a warning" diff --git a/tests/test_poller.py b/tests/test_poller.py index 71e8526..1d7cd00 100644 --- a/tests/test_poller.py +++ b/tests/test_poller.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio import logging +import sqlite3 import time from collections.abc import Iterator from typing import Any @@ -215,3 +216,110 @@ async def test_events_written_by_a_job_land_in_the_store(store) -> None: lambda: asyncio.sleep(0, Result(payload={}, events=[event]))) await job.run_once(store) assert [e.message for e in store.recent_events(limit=5)] == ["OL → OB"] + + +class RefusingStore(SampleStore): + """A store that turns good polls away for a while — what SQLite does when it is locked. + + This is not hypothetical: the dev server lost four sources at once this way, and nothing + in the UI, the health record or the event log said so. + """ + + def __init__(self, path: Any, refusals: int) -> None: + super().__init__(path) + self.refusals = refusals + + def add_many(self, samples: Any) -> int: + if self.refusals > 0: + self.refusals -= 1 + raise sqlite3.OperationalError("database is locked") + return super().add_many(samples) + + +async def test_a_store_that_refuses_a_good_poll_is_reported_not_survived_silently( + tmp_path: Any, caplog: pytest.LogCaptureFixture) -> None: + """A source whose history is not landing is broken, and must read that way. + + The upstream answered, so the freshest payload stays on the card; everything else says the + opposite of healthy — `ok: false`, the real reason in `error`, and `last_ok` frozen so the + page ages the numbers instead of calling a write failure a live source. + """ + store = RefusingStore(tmp_path / "locked.sqlite3", refusals=0) + try: + job = Job("nut", "ups", "NUT server", 0.5, lambda: asyncio.sleep(0, ok_result(42.0))) + assert await job.run_once(store) is True, "the first poll writes before the store turns nasty" + store.refusals = 1 + with caplog.at_level(logging.ERROR, logger="dashboards.poller"): + assert await job.run_once(store) is False + assert job.state.ok is False + assert job.state.error and "database is locked" in job.state.error + assert job.state.payload == {"value": 42.0}, "a refused write must not blank the card" + assert job.state.last_ok is not None and job.state.last_attempt is not None + assert job.state.last_attempt >= job.state.last_ok + assert "could not record a good poll" in caplog.text + finally: + store.close() + + +async def test_a_refusing_store_keeps_the_loop_polling_and_says_so_once( + tmp_path: Any, caplog: pytest.LogCaptureFixture) -> None: + store = RefusingStore(tmp_path / "locked.sqlite3", refusals=2) + try: + job = Job("nut", "ups", "NUT server", 0.5, lambda: asyncio.sleep(0, ok_result())) + with caplog.at_level(logging.ERROR, logger="dashboards.poller"): + for _ in range(3): + await job.run_once(store) + assert job.state.ok is True, "the third poll got through, so the source is ok again" + assert job.state.last_ok is not None + kinds = [e.kind for e in store.recent_events(limit=10)] + assert kinds.count("store-error") == 1, "one line per outage, not one per refused poll" + assert "recording" in kinds, "the recovery must be as visible as the failure" + assert "could not record a good poll" in caplog.text + assert "database is locked" in caplog.text, "the log must carry the real reason" + finally: + store.close() + + +async def test_last_good_payload_survives_a_refused_write(tmp_path: Any) -> None: + """Invariant 4 with the store, not the upstream, at fault: keep the numbers, age them.""" + store = RefusingStore(tmp_path / "locked.sqlite3", refusals=99) + try: + job = Job("nut", "ups", "NUT server", 0.5, lambda: asyncio.sleep(0, ok_result(42.0))) + await job.run_once(store) + assert job.state.payload == {"value": 42.0}, "a refused write must not blank the card" + assert job.state.last_ok is None, "and it must not be called current either" + assert job.state.error and "the store refused it" in job.state.error + assert job.state.attempts == 1 + finally: + store.close() + + +async def test_an_unexpected_error_cannot_kill_the_poll_loop( + store, caplog: pytest.LogCaptureFixture) -> None: + """The task *is* a source's polling; an escaping bug must not retire it in silence. + + Four dev-server sources once stopped advancing `attempts` while `/api/health` went on + reporting them `ok` — an exception that escaped the loop body, retrieved by nobody. This + guard is the reason that class of failure can only ever be loud. + """ + job = Job("host:gw", "system", "gw", 0.5, lambda: asyncio.sleep(0, ok_result())) + real = job.run_once + calls = 0 + + async def exploding(incoming: SampleStore) -> bool: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("a bug in the bookkeeping") + return await real(incoming) + + job.run_once = exploding # type: ignore[method-assign] + with caplog.at_level(logging.ERROR, logger="dashboards.poller"): + job.start(store) + try: + await asyncio.sleep(1.4) # the interval floor is 0.5s, so this is ~3 polls + finally: + await job.stop() + assert calls >= 2, "the loop must still be calling the poll body after the escape" + assert job.state.attempts >= 1 and job.state.ok is True, "and the next poll must have landed" + assert "raised outside its own error handling" in caplog.text diff --git a/tests/test_ups_contract.py b/tests/test_ups_contract.py new file mode 100644 index 0000000..6c1be52 --- /dev/null +++ b/tests/test_ups_contract.py @@ -0,0 +1,425 @@ +"""The payload contract behind the UPS hero row and battery glyph (phase 05, task 01). + +Phase 05 puts a battery glyph and three big colour-coded numbers — charge %, load %, runtime +left — at the top of every UPS card. Nothing is rendered here until the data is proven +present, for the same reason phase 03 pinned `/system` first: a card that shows `n/a` gets +"fixed" by inventing a field, and an invented field is a number the driver never reported +(`AGENTS.md` invariant 2, rule 7). + +What makes this file different from `tests/test_nut.py` is *where the variables come from*. +That file's `VAR_ROWS` is a hand-written table; this one drives +`tests/fixtures/nut_apc.vars` and `nut_cyberpower.vars` — verbatim `LIST UPS` / `LIST VAR` / +`LIST CMD` replies recorded from the real upsd by `./scripts/capture-fixtures.sh` — because +the two batteries disagree in ways a typed table never would: + +* the APC's `battery.date` is the factory placeholder `2001/09/25` while its + `battery.mfr.date` is the real one (2021/06/24 → a battery past its window), and the + separator is `/`, not the `-` every other date in this app uses; +* the CyberPower answers `battery.mfr.date: CPS`, which is not a date and must stay "unknown" + rather than become a 0-year-old or 56-year-old battery; +* the driver's own `identity.model` is `GX1500U` while the operator's label — the thing on + the card header — is `PR1500LCDRT2U`; +* **neither** driver exports `battery.temperature` **or** `battery.charger.status`, so the + glyph's on-battery state has to come from `on_battery` and `charger_status` must be + optional; +* only the CyberPower exports `output.voltage`. + +The path table itself lives in `tests/ups_contract.py` so the live suite +(`tests/test_live_fleet.py::test_ups_hero_fields_are_present_on_the_real_batteries`) asserts +the same list against real hardware and the two cannot drift apart. + +Two decisions the task asked to be written down, both asserted below: + +* **The glyph derives its state from `charger_status` + `on_battery`.** The NUT flags are + already parsed server-side into `states[]` (`UPS_STATE_LABELS` / `CHARGER_STATUS`); + re-parsing `ups.status` in JavaScript duplicates that map and lets it disagree with the API. +* **Runtime prose stays `Dash.duration()`-shaped** (`10m 21s`, `1h 32m`) through the page's + existing `runtimeText()`, and a runtime nobody measured is `Dash.naText` — `n/a`, never `0s`. +""" +from __future__ import annotations + +import re +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from dashboards.app import create_app +from dashboards.config import Settings +from dashboards.severity import classify +from dashboards.sources import nut +from dashboards.store import SampleStore +from ups_contract import ( + HERO_FIELDS, + UPS_SHAPES, + assert_ups_hero_contract, + parse_capture, + with_vars, +) + +#: Deterministic "now" for the age maths (same instant `tests/test_nut.py` uses). +NOW = 1_767_225_600.0 + + +# --------------------------------------------------------------------------- # +# Fixtures +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="module") +def captures() -> dict[str, nut.UpsReading]: + """Each real UPS as the driver reported it — the shape the hero row must tolerate.""" + return {name: parse_capture(name) for name in UPS_SHAPES} + + +@pytest.fixture(scope="module") +def hero_cards(captures: dict[str, nut.UpsReading]) -> dict[str, dict[str, Any]]: + """`payload()` of each capture: exactly what `/api/ups/snapshot` serves per UPS.""" + return {name: nut.payload(reading, now=NOW) for name, reading in captures.items()} + + +@pytest.fixture +def client(tmp_path: Path) -> Iterator[TestClient]: + """A running app, so the page-side assertions run on the bytes a browser would get. + + There is no JS test runner (a locked anchor), so what can be checked is the served + script: which helper formats the runtime, and what it returns when there is no runtime. + """ + db = str(tmp_path / "ups-contract.sqlite3") + store = SampleStore(db) + app = create_app(Settings(db_path=db), store) + test_client = TestClient(app) + test_client.__enter__() + try: + yield test_client + finally: + test_client.__exit__(None, None, None) + store.close() + + +def _code(body: str) -> str: + """An asset with its comments stripped — prose may *name* a flag the code must not parse.""" + body = re.sub(r"/\*.*?\*/", " ", body, flags=re.S) + return re.sub(r"^\s*//.*$", " ", body, flags=re.M) + + +def _function(js: str, name: str) -> str: + """One top-level function's source, from its signature to its closing brace at column 0.""" + start = js.index(f"function {name}(") + return js[start:js.index("\n}", start) + 2] + + +def _na_text(client: TestClient) -> str: + """The app's own `n/a`, read out of `app.js` rather than repeated here.""" + match = re.search(r"const naText = '([^']*)';", client.get("/static/app.js").text) + assert match is not None, "app.js no longer defines the shared naText" + return match.group(1) + + +# --------------------------------------------------------------------------- # +# The captures themselves are real, and they are the ones the contract names +# --------------------------------------------------------------------------- # +def test_every_captured_ups_is_in_the_contract() -> None: + """A third UPS on the wire must join `UPS_SHAPES`, or its driver goes uncontracted.""" + on_disk = {path.stem.removeprefix("nut_") + for path in (Path(__file__).parent / "fixtures").glob("nut_*.vars")} + assert on_disk == set(UPS_SHAPES) + + +def test_each_capture_is_a_real_upsd_reply_not_an_empty_stub(captures: dict[str, nut.UpsReading], + hero_cards: dict[str, dict[str, Any]]) -> None: + """50+ variables, a status, a description, advertised commands. + + Without this, a truncated fixture would satisfy "the key exists" and the contract would + prove nothing: the whole point of capturing the wire is that reality has 50 variables in + it, not the 15 anybody would type from memory. + """ + for name, reading in captures.items(): + card = hero_cards[name] + assert len(reading.vars) > 40, f"{name}: {len(reading.vars)} vars — this is not a capture" + assert reading.commands, f"{name}: no CMD lines recorded" + assert card["description"], f"{name}: LIST UPS gave no description" + assert card["status"], f"{name}: ups.status is missing — every state would be unknown" + assert card["identity"]["model"], f"{name}: no model to put beside the numbers" + + +# --------------------------------------------------------------------------- # +# Every field the hero row and the glyph read +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", UPS_SHAPES) +def test_every_hero_and_glyph_field_is_served_by_a_real_driver(name: str, + hero_cards: dict[str, dict[str, Any]]) -> None: + assert_ups_hero_contract(hero_cards[name]) + + +def test_the_hero_paths_come_from_the_table_not_from_memory(hero_cards: dict[str, dict[str, Any]]) -> None: + """The table is the contract: every listed path is under `numbers`/`severity`/`health` or + one of the three glyph fields, and the three hero numbers each travel with a verdict.""" + assert set(HERO_FIELDS) >= {"charge %", "load %", "runtime left", "glyph state", "card header"} + for label, paths in HERO_FIELDS.items(): + assert paths, f"{label} lists no payload path" + for card in hero_cards.values(): + for metric in ("charge_pct", "load_pct", "battery_runtime"): + assert card["severity"][metric] == classify(f"ups.{metric}", card["numbers"][metric]), ( + f"{metric}: the card colour does not match severity.py" + ) + + +@pytest.mark.parametrize("name", UPS_SHAPES) +def test_the_hero_numbers_are_plausible_on_the_real_batteries(name: str, + hero_cards: dict[str, dict[str, Any]]) -> None: + """Not just present: sane. 273 V on the battery line is the classic unit bug here.""" + numbers = hero_cards[name]["numbers"] + assert 0.0 <= numbers["charge_pct"] <= 100.0 + assert 0.0 <= numbers["load_pct"] <= 200.0, "over 100 % is legal (overloaded), 0-200 is the honest band" + assert numbers["battery_runtime"] > 0, "a runtime of 0 while online means the estimate is not reported" + assert 12.0 <= numbers["battery_voltage"] <= 60.0, "these are 24 V battery packs, not line voltage" + + +# --------------------------------------------------------------------------- # +# The real-world shape: what the drivers do *not* report +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("name", UPS_SHAPES) +def test_neither_real_driver_reports_a_battery_temperature(name: str, + hero_cards: dict[str, dict[str, Any]]) -> None: + """`None`, never `0 °C` — and never a key the page has to guess at (invariant 2).""" + card = hero_cards[name] + assert "battery_temperature" in card["numbers"] + assert card["numbers"]["battery_temperature"] is None + check = next(c for c in card["health"]["checks"] if c["name"] == "Battery temperature") + assert check["status"] == "unknown" + assert check["value"] is None + + +def test_the_apc_date_is_a_placeholder_and_its_mfr_date_is_the_real_one( + captures: dict[str, nut.UpsReading], hero_cards: dict[str, dict[str, Any]]) -> None: + """`battery.date` means nothing on this APC; `battery.mfr.date` is the fact (AGENTS.md).""" + apc = captures["apc"] + # the driver's own separator (`2001/09/25`), normalised here so the assertion is about the + # date and not about whether this NUT build used slashes + assert apc.vars["battery.date"].replace("/", "-") == "2001-09-25", \ + "the factory placeholder this driver ships" + assert (apc.get("battery_date") or "").replace("/", "-") == "2021-06-24", \ + "VAR_MAP must prefer battery.mfr.date over the placeholder" + age = next(c for c in hero_cards["apc"]["health"]["checks"] if c["name"] == "Battery age") + assert age["value"] is not None and 4.0 <= age["value"] < 6.0, age + assert age["status"] == "warn", "a 2021 battery is past the replacement window; the card says so" + + +def test_the_cyberpower_date_is_not_a_date_so_its_age_stays_unknown( + captures: dict[str, nut.UpsReading], hero_cards: dict[str, dict[str, Any]]) -> None: + """`battery.mfr.date: CPS` is a vendor string. Unknown, never an age invented from it.""" + cyberpower = captures["cyberpower"] + assert cyberpower.vars["battery.mfr.date"] == "CPS" + age = next(c for c in hero_cards["cyberpower"]["health"]["checks"] if c["name"] == "Battery age") + assert age["status"] == "unknown" and age["value"] is None + + +def test_the_card_header_names_the_box_the_operator_labels_it( + hero_cards: dict[str, dict[str, Any]]) -> None: + """The driver calls the CyberPower `GX1500U`; the UPS on the shelf is a PR1500LCDRT2U.""" + card = hero_cards["cyberpower"] + assert card["identity"]["model"] == "GX1500U" + assert "PR1500LCDRT2U" in card["description"], "LIST UPS' description is the operator's label" + assert hero_cards["apc"]["identity"]["mfr"] == "American Power Conversion" + + +def test_the_two_drivers_disagree_about_what_they_publish(hero_cards: dict[str, dict[str, Any]]) -> None: + """One `numbers` dict, two drivers: a missing variable is `None` on one, a number on the other.""" + assert hero_cards["cyberpower"]["numbers"]["output_voltage"] is not None + assert hero_cards["apc"]["numbers"]["output_voltage"] is None, "no output.voltage on the apc driver" + assert hero_cards["cyberpower"]["numbers"]["realpower"] is not None + assert hero_cards["apc"]["numbers"]["realpower"] is None, "only ups.realpower.nominal there" + + +# --------------------------------------------------------------------------- # +# The three verdicts the hero row paints with +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("charge,expected", [ + (100, "ok"), + (45, "warn"), + (15, "fail"), +]) +def test_charge_severity_trips_the_inverted_threshold(captures: dict[str, nut.UpsReading], + charge: int, expected: str) -> None: + """Inverted: a *smaller* number is worse, and `severity.py` owns that direction.""" + card = nut.payload(with_vars(captures["apc"], **{"battery.charge": str(charge)}), now=NOW) + assert card["severity"]["charge_pct"] == expected + assert card["numbers"]["charge_pct"] == float(charge), "the verdict is additive, not a replacement" + + +@pytest.mark.parametrize("runtime,expected", [ + (3600, "ok"), + (400, "warn"), + (200, "fail"), +]) +def test_runtime_severity_trips_the_inverted_threshold(captures: dict[str, nut.UpsReading], + runtime: int, expected: str) -> None: + """Seconds of runtime left: 10 min is amber, 5 min is red, an hour is fine.""" + card = nut.payload(with_vars(captures["apc"], **{"battery.runtime": str(runtime)}), now=NOW) + assert card["severity"]["battery_runtime"] == expected + assert card["numbers"]["battery_runtime"] == float(runtime) + + +def test_a_runtime_the_driver_cannot_estimate_has_no_verdict(captures: dict[str, nut.UpsReading]) -> None: + """No number → no colour → `n/a`. `None` in, `None` out, never a green pass.""" + card = nut.payload(with_vars(captures["cyberpower"], **{"battery.runtime": None}), now=NOW) + assert card["numbers"]["battery_runtime"] is None, ( + "`battery.runtime.low` is a shutdown threshold, not time left — borrowing it for the " + "hero would print a confident 5m for a battery that may have 30" + ) + assert card["severity"]["battery_runtime"] is None + check = next(c for c in card["health"]["checks"] if c["name"] == "Runtime estimate") + assert check["status"] == "unknown" + # the threshold itself is still published, under its own name, for whoever wants it + assert card["numbers"]["battery_runtime_low"] == 300.0 + + +def test_load_severity_trips_the_ordinary_threshold(captures: dict[str, nut.UpsReading]) -> None: + """Load is not inverted: 90 % of rated capacity is the red end, unlike charge/runtime.""" + for load, expected in (("35", "ok"), ("70", "warn"), ("95", "fail")): + card = nut.payload(with_vars(captures["cyberpower"], **{"ups.load": load}), now=NOW) + assert card["severity"]["load_pct"] == expected, f"load {load} %" + + +# --------------------------------------------------------------------------- # +# The glyph's state: `charger_status` + `on_battery`, never a flag re-parse in JS +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("charger,status,expected_on_battery,label", [ + ("ol", "OL", False, "online"), + ("full", "OL", False, "fully charged"), + ("chg", "OL CHRG", False, "charging"), + ("dchg", "OB", True, "discharging"), +]) +def test_the_glyph_states_are_published_as_fields(captures: dict[str, nut.UpsReading], charger: str, + status: str, expected_on_battery: bool, + label: str) -> None: + """The four states a battery glyph draws, from the fields the API already sends.""" + card = nut.payload(with_vars(captures["apc"], **{"battery.charger.status": charger, + "ups.status": status}), now=NOW) + assert card["charger_status"] == charger + assert card["on_battery"] is expected_on_battery + assert label in [state["label"] for state in card["states"]] + + +def test_a_driver_that_capitalises_the_charger_status_is_still_a_known_state( + captures: dict[str, nut.UpsReading]) -> None: + """`payload()` normalises the token to the `CHARGER_STATUS` keys. + + `states()` and `on_battery` already lowercase what they compare; if the field the glyph + reads kept the driver's casing, `charger_status === "full"` would miss on a driver that + answers `FULL` and the battery would stop drawing its state. + """ + card = nut.payload(with_vars(captures["apc"], **{"battery.charger.status": " FULL "}), now=NOW) + assert card["charger_status"] == "full" + assert "fully charged" in [state["label"] for state in card["states"]] + assert_ups_hero_contract(card) + + +def test_full_charge_on_line_never_claims_the_battery_is_charging( + captures: dict[str, nut.UpsReading]) -> None: + """`charger_status == "full"` with `on_battery == False`: the glyph is full, not filling. + + "Charging" is the word an operator reads as "it was flat recently", so a driver that + reports a rested, floating, fully-charged battery must not produce it anywhere. + """ + card = nut.payload(with_vars(captures["apc"], **{"battery.charger.status": "full", + "ups.status": "OL"}), now=NOW) + assert card["charger_status"] == "full" + assert card["on_battery"] is False + words = [state["label"] for state in card["states"]] + [card["status"] or "", + card["charger_status"] or ""] + assert "charging" not in words, f"a full, online UPS is not charging: {words}" + assert not any("charging" in word for word in words), words + + +def test_on_battery_makes_the_glyphs_on_battery_state_available(captures: dict[str, nut.UpsReading], + ) -> None: + """Both routes to on-battery work, including the one with no `ups.status` at all.""" + from_flag = nut.payload(with_vars(captures["apc"], **{"ups.status": "OB DISCHRG", + "battery.charger.status": None}), now=NOW) + assert from_flag["on_battery"] is True + assert {"id": "OB", "label": "on battery", "severity": "warn", + "detail": "Running on battery"} in from_flag["states"] + assert from_flag["charger_status"] is None, "the APC is proof this field is optional" + + from_charger = nut.payload(with_vars(captures["apc"], **{"ups.status": None, + "battery.charger.status": "dchg"}), now=NOW) + assert from_charger["on_battery"] is True + assert "discharging" in [state["label"] for state in from_charger["states"]] + + +@pytest.mark.parametrize("name", UPS_SHAPES) +def test_the_real_captures_omit_charger_status_so_on_battery_carries_the_state( + name: str, captures: dict[str, nut.UpsReading], + hero_cards: dict[str, dict[str, Any]]) -> None: + """The assumption, recorded against hardware: `battery.charger.status` is not there. + + Neither usbhid-ups driver exports the NUT 2.8 rich status, so a glyph that only looked at + `charger_status` would render "unknown" on two healthy batteries. `on_battery` is derived + from `ups.status` server-side and is the field that must drive the glyph here. + """ + assert "battery.charger.status" not in captures[name].vars + card = hero_cards[name] + assert card["charger_status"] is None + assert card["on_battery"] is False + labels = [state["label"] for state in card["states"]] + assert "online" in labels, f"the glyph still needs a state to show: {labels}" + + +def test_the_page_does_not_re_parse_nut_status_flags(client: TestClient) -> None: + """`page-ups.js` may render `states[]` and the raw `status` chip; it may not interpret flags. + + `UPS_STATE_LABELS` and `CHARGER_STATUS` in `sources/nut.py` are the map. A copy of it in + JavaScript is a second opinion about the wire, and one of the two will be wrong. + """ + js = _code(client.get("/static/page-ups.js").text) + assert "status.split(" not in js, "the page is parsing ups.status itself" + for flag in ("OL", "OB", "LB", "RB", "CHRG", "DISCHRG", "FUB", "CAL", "OVER", "NULL"): + assert f'"{flag}"' not in js and f"'{flag}'" not in js, f"the page special-cases the {flag} flag" + assert "item.states" in js, "the states the API labelled are what the card renders" + + +# --------------------------------------------------------------------------- # +# The page-side formatting of "no runtime" +# --------------------------------------------------------------------------- # +def test_runtime_of_none_is_na_not_zero_seconds(client: TestClient) -> None: + """`runtimeText(null)` is the app's `n/a`. `0s` would be a different, alarming sentence. + + Asserted on the served script because there is no JS test runner — the promise under test + is that the runtime cell goes through the one helper and that the helper's unknown branch + returns the shared text before any number can be formatted. + """ + js = client.get("/static/page-ups.js").text + na = _na_text(client) + assert na == "n/a" + + body = _function(js, "runtimeText") + guard = re.search(r"seconds == null.*?return\s+([^;]+);", body) + assert guard is not None, "runtimeText() no longer guards against an absent runtime" + token = guard.group(1).strip() + rendered = na if token == "naText" else token.strip("'\"") + assert rendered == na, f"runtimeText() invents its own unknown text: {token}" + + # the unknown branch is first: nothing may format a number before it + assert body.index("seconds == null") < body.index("s`;"), "null could reach the number formatter" + assert "return '0s'" not in body + + # and the card's runtime cell is written through that helper, from the hero's own number + assert re.search(r"runtimeText\([^)]*battery_runtime\s*\)", js), ( + "the runtime cell no longer formats numbers.battery_runtime through runtimeText()" + ) + + +def test_the_page_reads_the_keys_the_contract_lists(client: TestClient) -> None: + """The hero row's keys are the contract's keys, on the served script and nowhere newer. + + Task 02/03 may restructure the card, but a rename here (`n.chargeRemaining`) means the + page and `/api/ups/snapshot` no longer speak, and the only symptom is a card of `n/a`. + """ + js = client.get("/static/page-ups.js").text + assert re.search(r"item\.numbers\b", js), "the card no longer reads the payload's numbers object" + for key in ("charge_pct", "load_pct", "battery_runtime", "battery_voltage", + "battery_temperature", "line_voltage"): + assert f".{key}" in js, f"the page never reads numbers.{key} — renamed or dropped" + assert "item.health" in js, "the card header still reads health.overall / health.counts" diff --git a/tests/ups_contract.py b/tests/ups_contract.py new file mode 100644 index 0000000..3c5ef71 --- /dev/null +++ b/tests/ups_contract.py @@ -0,0 +1,148 @@ +"""The `/ups` hero-row and battery-glyph payload contract, shared by the two suites. + +Phase 05 makes every UPS card open with a battery glyph that visibly holds its charge and +three big colour-coded numbers — **charge %**, **load %**, **runtime left** — with the health +checks and the command buttons below them. The paths below are the fields that row (and the +glyph) may read; they are asserted twice on purpose: + +* hermetically, from **verbatim upsd captures** (`tests/fixtures/nut_apc.vars`, + `nut_cyberpower.vars`) in `tests/test_ups_contract.py`, and +* against the **real batteries** in + `tests/test_live_fleet.py::test_ups_hero_fields_are_present_on_the_real_batteries`. + +One table, so the live assertions cannot drift away from the fixture assertions +(the same reasoning as `tests/card_contract.py` for `/system`). + +=========================== ============================================================== +Hero element Path in one card of `/api/ups/snapshot` +=========================== ============================================================== +charge % ``numbers.charge_pct`` + ``severity.charge_pct`` +load % ``numbers.load_pct`` + ``severity.load_pct`` +runtime left ``numbers.battery_runtime`` + ``severity.battery_runtime`` +battery volts ``numbers.battery_voltage`` +battery °C ``numbers.battery_temperature`` (``None`` on both real UPSes) +glyph state ``charger_status`` + ``on_battery`` (+ ``states`` for a label) +card header ``health.overall`` + ``health.counts`` +=========================== ============================================================== + +**The glyph's state is ``charger_status`` and ``on_battery``, never a re-parse of +``ups.status`` in JavaScript.** ``UpsReading`` already turns the flags into ``states[]`` with +labels and severities (`UPS_STATE_LABELS` / `CHARGER_STATUS`), and a second copy of that map +in a page is a map that can disagree with the API. Recorded here because both captures prove +the fallback is not theoretical: **neither the APC nor the CyberPower publishes +``battery.charger.status`` at all**, so on this fleet ``on_battery`` is the field that says +whether the glyph is holding charge or draining, and ``charger_status`` must stay optional. + +Values may honestly be ``None`` (that renders `n/a`, per the "never invent a number" rule); +**present** is the contract. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from card_contract import dig +from dashboards.sources import nut + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + +#: The UPS shapes the captures in `tests/fixtures/` cover. A new driver joins this list and +#: gets captured with `./scripts/capture-fixtures.sh` — `test_every_captured_ups_is_in_the_contract` +#: fails if it does not, the way `HOST_SHAPES` does for `/system`. +UPS_SHAPES: tuple[str, ...] = ("apc", "cyberpower") + +#: Hero row / glyph element -> dotted payload paths inside one UPS card. Present is the +#: contract; a value may be ``None`` on a driver that does not report it. +HERO_FIELDS: dict[str, tuple[str, ...]] = { + "charge %": ("numbers.charge_pct", "severity.charge_pct"), + "load %": ("numbers.load_pct", "severity.load_pct"), + "runtime left": ("numbers.battery_runtime", "severity.battery_runtime"), + "battery volts": ("numbers.battery_voltage",), + "battery °C": ("numbers.battery_temperature",), + # the glyph: the rich status when the driver has one, `on_battery` when it does not, + # `states[]` for the label, and `status` only ever as the raw mono chip beside it + "glyph state": ("charger_status", "on_battery", "states", "status"), + # unchanged since before phase 05 — the card header keeps using these + "card header": ("health.overall", "health.counts"), +} + +#: The verdicts the hero row knows. `severity.py`'s vocabulary and nothing else; `unknown` +#: is `assess()`'s word for a condition, not a colour for a value. +VERDICTS: tuple[str | None, ...] = ("ok", "warn", "fail", None) + +#: The health-check rows the hero row depends on being able to explain itself with. +CHECK_NAMES: tuple[str, ...] = ("Charge", "Runtime estimate", "Load", "Battery temperature") + + +def parse_capture(name: str) -> nut.UpsReading: + """Rebuild an `UpsReading` from a verbatim upsd capture. + + The lines are split with the client's own `_split_fields()` rather than a second parser: + upsd quotes a field only when it must (`VAR apc ups.status "OL"` is what NUT 2.8.5 + actually sends), so a hand-rolled splitter would be a second opinion about the wire — + exactly the thing `AGENTS.md` rule 7 refuses to have. + """ + path = FIXTURES / f"nut_{name}.vars" + if not path.exists(): + raise AssertionError( + f"missing capture {path.name} — run ./scripts/capture-fixtures.sh with the upsd " + f"forwarded (DEV_FORWARDS); the UPS hero row is contracted against real drivers" + ) + variables: dict[str, str] = {} + commands: list[str] = [] + description = "" + for line in path.read_text().splitlines(): + if not line or line.startswith("#"): + continue + fields = nut._split_fields(line) + if fields[0] == "UPS" and len(fields) >= 2: + description = " ".join(fields[2:]) + elif fields[0] == "VAR" and len(fields) >= 4: + variables[fields[2]] = " ".join(fields[3:]) + elif fields[0] == "CMD" and len(fields) >= 3: + commands.append(fields[2]) + return nut.UpsReading(name=name, description=description, vars=variables, + commands=commands, ts=1_767_225_600.0) + + +def with_vars(reading: nut.UpsReading, **overrides: str | None) -> nut.UpsReading: + """The same capture with a few variables changed (or removed with ``None``). + + Changing one variable instead of writing a new VAR table keeps every *other* real value + in the payload, so a test about charge severity is still a test about a real APC. + """ + variables = dict(reading.vars) + for key, value in overrides.items(): + if value is None: + variables.pop(key, None) + else: + variables[key] = value + return nut.UpsReading(name=reading.name, description=reading.description, vars=variables, + commands=list(reading.commands), ts=reading.ts) + + +def assert_ups_hero_contract(card: dict[str, Any]) -> None: + """Every hero/glyph path exists on this UPS card, with the vocabulary the page renders.""" + for paths in HERO_FIELDS.values(): + for path in paths: + dig(card, path) + verdicts = card["severity"] + assert isinstance(verdicts, dict), "severity must be a dict of hero verdicts" + for key in ("charge_pct", "load_pct", "battery_runtime"): + assert key in verdicts, f"severity.{key} is missing — the hero would colour nothing" + assert verdicts[key] in VERDICTS, f"severity.{key}={verdicts[key]!r} is not the app vocabulary" + assert isinstance(card["on_battery"], bool), "on_battery is a bool, not a truthy string" + charger = card["charger_status"] + assert charger is None or charger in nut.CHARGER_STATUS, ( + f"charger_status={charger!r} is not a CHARGER_STATUS token (and not None) — the glyph " + f"would have no state to draw" + ) + states = card["states"] + assert isinstance(states, list), "states is a list of {id,label,severity} chips" + for state in states: + assert {"id", "label", "severity"} <= set(state), f"state chip is incomplete: {state}" + counts = card["health"]["counts"] + assert {"ok", "warn", "fail", "unknown"} <= set(counts), f"health.counts: {sorted(counts)}" + names = {check["name"] for check in card["health"]["checks"]} + missing = set(CHECK_NAMES) - names + assert not missing, f"health.checks has no {sorted(missing)} row — the card cannot explain itself"