phase: 05_ups_hero_battery_glyph

**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), `<title>` "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.
This commit is contained in:
2026-10-17 10:27:23 -04:00
parent f44a617ef9
commit 34854d32af
44 changed files with 2374 additions and 105 deletions
@@ -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).
@@ -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), `<title>` "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.
@@ -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 ==
@@ -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()`).
@@ -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 ==
@@ -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`.
@@ -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 ==
@@ -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`.
@@ -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 ==
@@ -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.
@@ -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 ==
+98 -21
View File
@@ -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`
+36 -8
View File
@@ -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.
+93 -5
View File
@@ -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)
+3 -7
View File
@@ -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):
+60 -7
View File
@@ -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))
+29 -8
View File
@@ -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,
+48 -14
View File
@@ -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
+115 -2
View File
@@ -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)}</title>
<defs><clipPath id="${clipId}"><rect x="${inner.x}" y="${inner.y}" width="${inner.w}"
height="${inner.h}" rx="${inner.r}"/></clipPath></defs>
<rect x="${shell.x}" y="${shell.y}" width="${shell.w}" height="${shell.h}" rx="${shell.r}"
fill="none" stroke="${stroke}" stroke-width="${shell.stroke}"${dashed}/>
<rect x="${nub.x}" y="${nub.y}" width="${nub.w}" height="${nub.h}" rx="${nub.r}" fill="${stroke}"/>
<g clip-path="url(#${clipId})">
<rect x="${inner.x}" y="${inner.y}" width="${inner.w}" height="${inner.h}" fill="${cssVar('--bg-sunken')}"/>
${fillRect}
</g>${noReading}${bolt}</svg>`;
return el;
}
window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, battery, cssVar,
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };
+59
View File
@@ -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);
+131 -25
View File
@@ -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 `<svg>` 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) {
</header>
<div class="body stack">
<div class="gap states"></div>
<div class="metrics">
<div class="metric"><div class="label">Charge</div>
<div class="value"><span data-k="charge">n/a</span><span class="unit">%</span></div>
<div class="bar" data-bar="charge"><i></i></div></div>
<div class="metric"><div class="label">Runtime left</div>
<div class="value sm" data-k="runtime">n/a</div></div>
<div class="metric"><div class="label">Load</div>
<div class="value sm"><span data-k="load">n/a</span><span class="unit">%</span></div>
<div class="bar" data-bar="load"><i></i></div></div>
<div class="ups-hero">
<div data-k="glyph"></div>
<div class="heroes">
<div class="hero" data-k="charge" data-sev="severity.charge_pct"></div>
<div class="hero hero-bar" data-k="load" data-sev="severity.load_pct"></div>
<div class="hero" data-k="runtime" data-sev="severity.battery_runtime"></div>
</div>
</div>
<div class="metrics ups-electrical">
<div class="metric"><div class="label">Line in</div>
<div class="value sm"><span data-k="line">n/a</span><span class="unit">V</span></div></div>
<div class="metric"><div class="label">Out</div>
@@ -116,19 +200,41 @@ function renderCard(card, item, stateRow) {
(item.alarms || []).map((alarm) => `<span class="chip warn">alarm: ${
DashCharts.escapeHtml(alarm)}</span>`).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) => `
+75
View File
@@ -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
+79
View File
@@ -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
+260 -7
View File
@@ -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 "<clipPath" in body and 'clip-path="url(#' in body
# an empty cell is the sunken plate, never a transparent hole in the card
assert "cssVar('--bg-sunken')" in body
def test_battery_colours_come_from_the_verdict_tokens_only(client) -> 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"` + `<title>` 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
+25
View File
@@ -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.
+137 -1
View File
@@ -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"
+108
View File
@@ -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
+425
View File
@@ -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"
+148
View File
@@ -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"