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:
@@ -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.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
+29
@@ -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()`).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
+18
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
+16
@@ -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`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
+17
@@ -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.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+47
@@ -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 ==
|
||||||
@@ -52,12 +52,19 @@ page still renders.
|
|||||||
`404 {"configured": false}` **without dialling**, and the page says "not configured".
|
`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
|
A red panel for a server the operator never installed destroys the signal from the
|
||||||
panels that matter.
|
panels that matter.
|
||||||
4. **One dead source must not blink another.** Each source is its own asyncio task with its
|
4. **One dead source must not blink another — and nothing may stop polling in silence.** Each
|
||||||
own timeout and health record (`poller.py`). A failed poll keeps the last good payload and
|
source is its own asyncio task with its own timeout and health record (`poller.py`). A
|
||||||
marks it stale in the UI instead of zeroing it — on `/` too, where a host that has never
|
failed poll keeps the last good payload and marks it stale in the UI instead of zeroing it —
|
||||||
answered still gets a fleet row (`unreachable: true`, dimmed, every number `n/a`) because
|
on `/` too, where a host that has never answered still gets a fleet row
|
||||||
dropping the row would turn a dead agent into "never configured". Transitions write
|
(`unreachable: true`, dimmed, every number `n/a`) because dropping the row would turn a dead
|
||||||
events; repeated failure writes one line, not 500.
|
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;
|
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
|
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.
|
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.
|
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
|
- `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.
|
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
|
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
|
source (`host`) — a page filtering `/api/events` by `host` gets an empty feed forever
|
||||||
(found on `/system` during phase 03 browser verification).
|
(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`.
|
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
|
||||||
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
||||||
a mystery score), `COMMANDS` catalog with risk tiers, `available_commands()` filtering,
|
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
|
- `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`.
|
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
|
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).
|
- `prom.py` — Prometheus text-exposition parser (in-house; only for engines we don't own).
|
||||||
- `rates.py` — Δ/window estimator, keyed by resolved series name.
|
- `rates.py` — Δ/window estimator, keyed by resolved series name.
|
||||||
- `severity.py` — the only place thresholds live: `THRESHOLDS`/`INVERTED` per metric,
|
- `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
|
`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
|
`.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
|
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
|
values the API does not rate, and **no page script calls it any more** — `/` was converted
|
||||||
(`/` was converted in phase 02, `/system` in phase 03 and `/llm` in phase 04 — those pages
|
in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in phase 05, so every
|
||||||
colour only from the `severity` verdicts the API sends, and no `severityFor(` call survives
|
colour on all four pages comes from a `severity` verdict the API sent
|
||||||
in their scripts).
|
(`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`,
|
Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`,
|
||||||
`gpu.busy_pct@0`, `llm.decode_tokens_per_second`, `ups.charge_pct`), `@stream` for per-device
|
`gpu.busy_pct@0`, `llm.decode_tokens_per_second`, `ups.charge_pct`), `@stream` for per-device
|
||||||
@@ -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
|
`ups.description`, the operator's label — the driver's own `identity.model` answers
|
||||||
`GX1500U`, so the description is what identifies the box).
|
`GX1500U`, so the description is what identifies the box).
|
||||||
Real-hardware quirks the parsers already handle: `battery.mfr.date` is the useful date
|
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.date` is often an unset placeholder — `2001/09/25`, slashes and all, on the APC),
|
||||||
`battery.mfr.date: CPS` (not a date → `unknown`, never a fake age), neither driver reports
|
CyberPower reports `battery.mfr.date: CPS` (not a date → `unknown`, never a fake age),
|
||||||
a battery temperature, and `ups.test.result: No test initiated` is the factory state on both.
|
**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
|
- 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
|
set we rely on is documented in `deploy/nut-sudoers` (additions are listed there for the
|
||||||
work that is still blocked).
|
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 status # per-source ok/DOWN
|
||||||
./scripts/dev-server.sh logs
|
./scripts/dev-server.sh logs
|
||||||
|
|
||||||
uv run pytest # hermetic: 485 passed + 7 live-skipped, no network
|
uv run pytest # hermetic: 543 passed + 8 live-skipped, no network
|
||||||
uv run pytest --cov # coverage gate (96.9%; floor 94%)
|
uv run pytest --cov # coverage gate (97.9%; floor 94%)
|
||||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||||
uv run ruff check src tests agent && uv run pyright
|
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
|
bash .agents/validate.sh # the phase gate: the four checks above (pytest
|
||||||
# --cov --cov-fail-under=94, ruff, pyright, plus a
|
# --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
|
`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
|
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),
|
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
|
- `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
|
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
|
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,
|
- 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`.
|
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
|
- 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,
|
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
|
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
|
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
|
a queue nobody was in — `/llm` passes `minSpan: 1` on sessions and queued so its axis is never
|
||||||
finer than one request.
|
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
|
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
|
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`
|
*leading* duplicated subject the way `alerts._clean_error()` does for the overview — `poller.py`
|
||||||
|
|||||||
@@ -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
|
* `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
|
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
|
* `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.
|
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
|
* `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
|
A big colour-coded number is `Dash.hero(el, {...})` on the
|
||||||
`.hero` / `.hero-bar` styles, coloured by the `severity` in the payload via
|
`.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
|
`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
|
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
|
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
|
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
|
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
|
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
|
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
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest # unit + API tests, fully mocked, no network
|
uv run pytest # unit + API tests, fully mocked, no network
|
||||||
uv run pytest --cov # coverage gate
|
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 pytest -m live --live # optional: real fleet / engines / upsd
|
||||||
uv run ruff check src tests agent && uv run pyright # lint + types
|
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
|
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
|
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
|
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
|
(`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.*`,
|
also skips when its source is unset (**source `.env.dev` first** — run bare and they all skip
|
||||||
`NutMonitor.collect`) against whatever hardware is reachable, and they never write — a
|
quietly, which reads exactly like a pass), they drive the production code paths
|
||||||
battery test on live hardware is not a unit test.
|
(`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
|
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
|
`/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
|
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
|
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
|
## Battery-test buttons
|
||||||
|
|
||||||
@@ -229,6 +252,11 @@ not `actions = -`:
|
|||||||
instcmds = test.battery.start.quick test.battery.start.deep test.battery.stop
|
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
|
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
|
(`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.
|
development; everything it produces is badged `SIMULATED`, and it is never the default.
|
||||||
|
|||||||
@@ -35,6 +35,76 @@ for entry in "${HOSTS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
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 [ "${1:-}" = "--with-llm" ]; then
|
||||||
if [ ${#ENGINES[@]} -eq 0 ]; then
|
if [ ${#ENGINES[@]} -eq 0 ]; then
|
||||||
echo " ! no GUFO_BASE_URL / VLLM_BASE_URL set (see .env.dev) — nothing to capture"
|
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
|
failed=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Two things leak out of a real capture that nobody notices: LUKS/partition UUIDs (which
|
# 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>) and the
|
# identify *your* disks, and appear in `device` strings as /dev/mapper/luks-<uuid>), the
|
||||||
# FQDN a host thinks it has. They are replaced deterministically so re-capturing does not
|
# FQDN a host thinks it has, and a UPS's `ups.serial`/`device.serial` (which identifies the
|
||||||
# churn the fixture, and the tests keep the same shapes to assert against.
|
# 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'
|
python3 - "$FIX" <<'PY'
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
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
|
SYNTHETIC = "00000000-0000-4000-8000-" # already-scrubbed markers: re-running must not churn
|
||||||
fix = pathlib.Path(sys.argv[1])
|
fix = pathlib.Path(sys.argv[1])
|
||||||
changed = []
|
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()):
|
for path in sorted(fix.iterdir()):
|
||||||
if path.suffix not in (".json", ".txt"):
|
if path.suffix not in (".json", ".txt", ".vars"):
|
||||||
continue
|
continue
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
seen: dict[str, str] = {}
|
seen: dict[str, str] = {}
|
||||||
|
serials: dict[str, str] = {}
|
||||||
|
|
||||||
def swap(match: re.Match[str]) -> str:
|
def swap(match: re.Match[str]) -> str:
|
||||||
value = match.group(0)
|
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
|
# "hostname": "gw.lab.example.com" -> "gw": the dashboard's host key is the target name
|
||||||
# from HOST_TARGETS, never whatever the box calls itself
|
# from HOST_TARGETS, never whatever the box calls itself
|
||||||
text = re.sub(r'("hostname": "[^"]*?)\.[^"]*(")', r"\1\2", text)
|
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():
|
if text != path.read_text():
|
||||||
path.write_text(text)
|
path.write_text(text)
|
||||||
changed.append(path.name)
|
changed.append(path.name)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from .config import Settings
|
|||||||
from .poller import Hub
|
from .poller import Hub
|
||||||
from .rates import RateEstimator
|
from .rates import RateEstimator
|
||||||
from .sources.host import HostCollector
|
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
|
from .store import SampleStore
|
||||||
|
|
||||||
WEB = Path(__file__).resolve().parent / "web"
|
WEB = Path(__file__).resolve().parent / "web"
|
||||||
@@ -47,7 +47,7 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
|
|||||||
host_collector = HostCollector(rates)
|
host_collector = HostCollector(rates)
|
||||||
nut_monitor: NutMonitor | None = None
|
nut_monitor: NutMonitor | None = None
|
||||||
if settings.nut_enabled:
|
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)
|
nut_monitor = NutMonitor(settings, simulator=simulator)
|
||||||
|
|
||||||
hub = Hub(store)
|
hub = Hub(store)
|
||||||
@@ -64,10 +64,6 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
|
|||||||
return ctx, store, hub.start
|
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:
|
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."""
|
"""`store` is for tests: when supplied it is not created and not closed here."""
|
||||||
settings = settings or DEFAULT_SETTINGS
|
settings = settings or DEFAULT_SETTINGS
|
||||||
@@ -88,7 +84,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
|
|||||||
hub = Hub(store)
|
hub = Hub(store)
|
||||||
nut_monitor = None
|
nut_monitor = None
|
||||||
if settings.nut_enabled:
|
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)
|
nut_monitor = NutMonitor(settings, simulator=simulator)
|
||||||
for job in host_jobs(list(settings.host_targets), client,
|
for job in host_jobs(list(settings.host_targets), client,
|
||||||
HostCollector(rates), settings.host_interval):
|
HostCollector(rates), settings.host_interval):
|
||||||
|
|||||||
@@ -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.
|
(`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;
|
* **Transitions are events, spam is not.** ok→fail and fail→ok write to the event log;
|
||||||
500 consecutive failures write one line.
|
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
|
from __future__ import annotations
|
||||||
@@ -92,6 +96,7 @@ class Job:
|
|||||||
self.grace_failures = grace_failures
|
self.grace_failures = grace_failures
|
||||||
self.state = SourceState(key=key, dashboard=dashboard, label=label, configured=enabled)
|
self.state = SourceState(key=key, dashboard=dashboard, label=label, configured=enabled)
|
||||||
self._down_logged = False
|
self._down_logged = False
|
||||||
|
self._store_down_logged = False
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
self._task: asyncio.Task[None] | None = None
|
self._task: asyncio.Task[None] | None = None
|
||||||
self._inflight = False
|
self._inflight = False
|
||||||
@@ -127,9 +132,24 @@ class Job:
|
|||||||
finally:
|
finally:
|
||||||
self._inflight = False
|
self._inflight = False
|
||||||
|
|
||||||
self.state.ok = True
|
try:
|
||||||
self.state.successes += 1
|
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.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
|
# 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.
|
# would otherwise log "answering again", which reads like an outage happened.
|
||||||
if self._down_logged or (not was_ok and self.state.failures):
|
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 ''}"
|
+ (f" after {count} failure{'s' if count != 1 else ''}"
|
||||||
if count else "")))
|
if count else "")))
|
||||||
self._down_logged = False
|
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.consecutive_failures = 0
|
||||||
self.state.last_ok = now
|
self.state.last_ok = now
|
||||||
self.state.error = None
|
self.state.error = None
|
||||||
self.state.samples_written += store.add_many(result.samples)
|
|
||||||
for event in result.events:
|
def _store_refused(self, store: SampleStore, exc: Exception, now: float) -> None:
|
||||||
store.log_event(event)
|
"""Bookkeeping for a store that refused an otherwise good poll.
|
||||||
return True
|
|
||||||
|
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:
|
async def _loop(self, store: SampleStore) -> None:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
started = time.perf_counter()
|
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)
|
remaining = self.interval - (time.perf_counter() - started)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self._stop.wait(), timeout=max(0.2, remaining))
|
await asyncio.wait_for(self._stop.wait(), timeout=max(0.2, remaining))
|
||||||
|
|||||||
@@ -14,15 +14,20 @@ Verified against NUT 2.8.5 (`/usr/bin/upsc` on driveripper). Protocol shape:
|
|||||||
|
|
||||||
-> LIST UPS
|
-> LIST UPS
|
||||||
<- BEGIN LIST UPS
|
<- BEGIN LIST UPS
|
||||||
<- UPS "apc" : APC Smart-UPS
|
<- UPS apc "APC Back-UPS RS 1500MS (USB)"
|
||||||
<- END LIST UPS
|
<- END LIST UPS
|
||||||
-> LIST VAR apc
|
-> LIST VAR "apc"
|
||||||
<- BEGIN LIST VAR apc
|
<- BEGIN LIST VAR apc
|
||||||
<- VAR "ups.status" "OL"
|
<- VAR apc ups.status "OL"
|
||||||
<- END LIST VAR apc
|
<- 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)
|
bad -> ERR ACCESS-DENIED (auth or permission problem)
|
||||||
ok -> OK ...
|
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
|
from __future__ import annotations
|
||||||
@@ -300,7 +305,10 @@ VAR_MAP: dict[str, tuple[str, ...]] = {
|
|||||||
"battery_current": ("battery.current",),
|
"battery_current": ("battery.current",),
|
||||||
"battery_temperature": ("battery.temperature",),
|
"battery_temperature": ("battery.temperature",),
|
||||||
"battery_type": ("battery.type",),
|
"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_runtime_low": ("battery.runtime.low",),
|
||||||
"battery_date": ("battery.mfr.date", "battery.date"),
|
"battery_date": ("battery.mfr.date", "battery.date"),
|
||||||
"test_result": ("battery.test.result", "ups.test.result"),
|
"test_result": ("battery.test.result", "ups.test.result"),
|
||||||
@@ -386,21 +394,31 @@ class UpsReading:
|
|||||||
|
|
||||||
def states(self) -> list[dict[str, str]]:
|
def states(self) -> list[dict[str, str]]:
|
||||||
"""Human state list. The 2.8 `battery.charger.status` wins over legacy ups.status
|
"""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]] = []
|
out: list[dict[str, str]] = []
|
||||||
charger = (self.get("charger_status") or "").strip().lower()
|
charger = (self.get("charger_status") or "").strip().lower()
|
||||||
if charger and charger in CHARGER_STATUS:
|
if charger and charger in CHARGER_STATUS:
|
||||||
label, severity = CHARGER_STATUS[charger]
|
label, severity = CHARGER_STATUS[charger]
|
||||||
out.append({"id": charger, "label": label, "severity": severity})
|
out.append({"id": charger, "label": label, "severity": severity})
|
||||||
|
seen = {entry["label"] for entry in out}
|
||||||
raw = " ".join(self.status_flags)
|
raw = " ".join(self.status_flags)
|
||||||
if raw:
|
if raw:
|
||||||
if raw in STATE_FLAGS:
|
if raw in STATE_FLAGS:
|
||||||
label, severity, detail = STATE_FLAGS[raw]
|
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:
|
else:
|
||||||
for flag in self.status_flags:
|
for flag in self.status_flags:
|
||||||
if flag in STATE_FLAGS:
|
if flag in STATE_FLAGS:
|
||||||
label, severity, detail = STATE_FLAGS[flag]
|
label, severity, detail = STATE_FLAGS[flag]
|
||||||
|
if label in seen:
|
||||||
|
continue
|
||||||
|
seen.add(label)
|
||||||
out.append({"id": flag, "label": label, "severity": severity, "detail": detail})
|
out.append({"id": flag, "label": label, "severity": severity, "detail": detail})
|
||||||
return out
|
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
|
now = time.time() if now is None else now
|
||||||
health = assess(reading, now=now, **assess_kwargs)
|
health = assess(reading, now=now, **assess_kwargs)
|
||||||
numbers = {key: reading.number(key) for key in VAR_MAP}
|
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
|
# charts must not plot 300 "°C"; the raw string stays visible in the variable dump
|
||||||
for key in ("battery_temperature", "ups_temperature"):
|
for key in ("battery_temperature", "ups_temperature"):
|
||||||
if numbers.get(key) is not None:
|
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,
|
"ts": reading.ts,
|
||||||
"status": reading.get("status"),
|
"status": reading.get("status"),
|
||||||
"charger_status": reading.get("charger_status"),
|
"charger_status": charger,
|
||||||
"states": reading.states(),
|
"states": reading.states(),
|
||||||
"alarms": reading.alarms,
|
"alarms": reading.alarms,
|
||||||
"on_battery": reading.on_battery,
|
"on_battery": reading.on_battery,
|
||||||
|
|||||||
@@ -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.
|
`simulated: true` and the page shows a watermark on every simulated number.
|
||||||
|
|
||||||
The model is deliberately small and physical rather than random:
|
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
|
* 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)
|
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
|
* a quick battery test moves to OB for ~12s and then reports PASSED, so the whole
|
||||||
command → state change → event-log path is exercised
|
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
|
from __future__ import annotations
|
||||||
@@ -18,7 +24,6 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .nut import UpsReading
|
from .nut import UpsReading
|
||||||
|
|
||||||
@@ -59,13 +64,21 @@ class SimUps:
|
|||||||
driver: str = "usbhid-ups"
|
driver: str = "usbhid-ups"
|
||||||
_test_until: float = 0.0
|
_test_until: float = 0.0
|
||||||
_deep_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)
|
_alarms: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
def tick(self, now: float) -> None:
|
def tick(self, now: float) -> None:
|
||||||
"""Advance the model by wall-clock time since the last tick."""
|
"""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
|
# 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
|
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:
|
def step(self, dt: float) -> None:
|
||||||
dt = max(0.0, min(dt, 60.0))
|
dt = max(0.0, min(dt, 60.0))
|
||||||
@@ -93,13 +106,23 @@ class SimUps:
|
|||||||
|
|
||||||
def vars(self, now: float) -> dict[str, str]:
|
def vars(self, now: float) -> dict[str, str]:
|
||||||
load_pct = self.load_w / self.nominal_w * 100.0
|
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 []))
|
# Status and charger status must agree with each other *and* with a real driver.
|
||||||
if self.charge >= 99.5 and not self.on_battery:
|
# A below-float pack on mains is charging — APC prints exactly `OL CHRG` there —
|
||||||
flags.append("CHRG" if self.charge < 99.9 else "")
|
# and a battery being drawn from is never "online": `ol`/`full` while `OB` is the
|
||||||
status = " ".join(part for part in flags if part)
|
# contradiction `assess()` would have to guess about. Reporting plain `ol`/`OL`
|
||||||
charger = "ol" if not self.on_battery else ("dchg" if self.charge < 99.5 else "ol")
|
# during the recharge made the page's charging state (the glyph's bolt, keyed on
|
||||||
if self.charge >= 99.9 and not self.on_battery:
|
# `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"
|
charger = "full"
|
||||||
|
else:
|
||||||
|
flags = ["OL", "CHRG"]
|
||||||
|
charger = "chg"
|
||||||
|
status = " ".join(flags)
|
||||||
volts = (self.battery_nominal * (0.86 + 0.14 * self.charge / 100.0)
|
volts = (self.battery_nominal * (0.86 + 0.14 * self.charge / 100.0)
|
||||||
if self.on_battery else self.battery_nominal * 1.045)
|
if self.on_battery else self.battery_nominal * 1.045)
|
||||||
line = 121.4 + 1.1 * math.sin(now / 90.0)
|
line = 121.4 + 1.1 * math.sin(now / 90.0)
|
||||||
@@ -109,6 +132,7 @@ class SimUps:
|
|||||||
"ups.serial": self.serial,
|
"ups.serial": self.serial,
|
||||||
"ups.firmware": self.firmware,
|
"ups.firmware": self.firmware,
|
||||||
"ups.status": status,
|
"ups.status": status,
|
||||||
|
"ups.beeper.status": self._beeper,
|
||||||
"battery.charger.status": charger,
|
"battery.charger.status": charger,
|
||||||
"ups.load": f"{load_pct:.1f}",
|
"ups.load": f"{load_pct:.1f}",
|
||||||
"ups.realpower": f"{self.load_w:.1f}",
|
"ups.realpower": f"{self.load_w:.1f}",
|
||||||
@@ -152,23 +176,29 @@ class SimUps:
|
|||||||
if command == "test.battery.start.quick":
|
if command == "test.battery.start.quick":
|
||||||
self._test_until = now + 12.0
|
self._test_until = now + 12.0
|
||||||
self.on_battery = True
|
self.on_battery = True
|
||||||
|
self._result_pending = True
|
||||||
self.test_date = time.strftime("%Y-%m-%d", time.gmtime(now))
|
self.test_date = time.strftime("%Y-%m-%d", time.gmtime(now))
|
||||||
return "OK Start Quick Battery Test"
|
return "OK Start Quick Battery Test"
|
||||||
if command == "test.battery.start.deep":
|
if command == "test.battery.start.deep":
|
||||||
self._deep_until = now + 90.0
|
self._deep_until = now + 90.0
|
||||||
self.on_battery = True
|
self.on_battery = True
|
||||||
|
self._result_pending = True
|
||||||
return "OK Start Deep Battery Test"
|
return "OK Start Deep Battery Test"
|
||||||
if command in ("test.battery.stop", "calibrate.stop"):
|
if command in ("test.battery.stop", "calibrate.stop"):
|
||||||
self._test_until = self._deep_until = 0.0
|
self._test_until = self._deep_until = 0.0
|
||||||
self.on_battery = False
|
self.on_battery = False
|
||||||
|
self._result_pending = False # an aborted test reports nothing, not a pass
|
||||||
return "OK Stop Test"
|
return "OK Stop Test"
|
||||||
if command == "test.panel.start":
|
if command == "test.panel.start":
|
||||||
return "OK Start Panel Test"
|
return "OK Start Panel Test"
|
||||||
if command == "beeper.enable":
|
if command == "beeper.enable":
|
||||||
self._beeper = True
|
self._beeper = "enabled"
|
||||||
return "OK Beeper enabled"
|
return "OK Beeper enabled"
|
||||||
if command in ("beeper.disable", "beeper.mute"):
|
if command == "beeper.disable":
|
||||||
self._beeper = False
|
self._beeper = "disabled"
|
||||||
|
return "OK Beeper disabled"
|
||||||
|
if command == "beeper.mute":
|
||||||
|
self._beeper = "muted"
|
||||||
return "OK Beeper muted"
|
return "OK Beeper muted"
|
||||||
if command == "calibrate.start":
|
if command == "calibrate.start":
|
||||||
self._deep_until = now + 180.0
|
self._deep_until = now + 180.0
|
||||||
@@ -221,6 +251,10 @@ class NutSimulator:
|
|||||||
raise KeyError(f"unknown simulated UPS {ups_name!r}")
|
raise KeyError(f"unknown simulated UPS {ups_name!r}")
|
||||||
|
|
||||||
|
|
||||||
def from_env(value: str | None) -> Any:
|
def from_env(value: str | None) -> NutSimulator | None:
|
||||||
"""`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd)."""
|
"""`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
|
return NutSimulator() if (value or "").strip().lower() in ("1", "true", "yes", "on") else None
|
||||||
|
|||||||
@@ -471,5 +471,118 @@ function sparkline(container, points, opts) {
|
|||||||
<path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.4"/>${marks}</svg>${foot}`;
|
<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 };
|
||||||
|
|||||||
@@ -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); }
|
.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 -------------------------------------------------- */
|
/* ---- bars, dots, chips -------------------------------------------------- */
|
||||||
.bar {
|
.bar {
|
||||||
position: relative; height: 7px; border-radius: 999px; background: var(--bg-sunken);
|
position: relative; height: 7px; border-radius: 999px; background: var(--bg-sunken);
|
||||||
|
|||||||
@@ -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
|
* 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
|
* variable it read and what it saw, because "health: 87" is not actionable. The backend
|
||||||
@@ -10,8 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { $, fetchJSON, setTxt, setValue, setBar, setDot, num, duration, poll, severityFor,
|
const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash;
|
||||||
eventText } = Dash;
|
|
||||||
|
|
||||||
const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000,
|
const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000,
|
||||||
simulated: false, pending: null };
|
simulated: false, pending: null };
|
||||||
@@ -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
|
* 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.
|
* 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) {
|
function runtimeText(seconds) {
|
||||||
if (seconds == null || !isFinite(seconds)) return 'n/a';
|
if (seconds == null || !isFinite(seconds)) return naText;
|
||||||
if (seconds < 0) return 'on battery';
|
if (seconds < 0) return 'on battery';
|
||||||
const total = Math.round(seconds);
|
const total = Math.round(seconds);
|
||||||
if (total < 120) return `${total}s`;
|
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`;
|
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) {
|
function upsCard(name) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card';
|
card.className = 'card';
|
||||||
@@ -48,15 +132,15 @@ function upsCard(name) {
|
|||||||
</header>
|
</header>
|
||||||
<div class="body stack">
|
<div class="body stack">
|
||||||
<div class="gap states"></div>
|
<div class="gap states"></div>
|
||||||
<div class="metrics">
|
<div class="ups-hero">
|
||||||
<div class="metric"><div class="label">Charge</div>
|
<div data-k="glyph"></div>
|
||||||
<div class="value"><span data-k="charge">n/a</span><span class="unit">%</span></div>
|
<div class="heroes">
|
||||||
<div class="bar" data-bar="charge"><i></i></div></div>
|
<div class="hero" data-k="charge" data-sev="severity.charge_pct"></div>
|
||||||
<div class="metric"><div class="label">Runtime left</div>
|
<div class="hero hero-bar" data-k="load" data-sev="severity.load_pct"></div>
|
||||||
<div class="value sm" data-k="runtime">n/a</div></div>
|
<div class="hero" data-k="runtime" data-sev="severity.battery_runtime"></div>
|
||||||
<div class="metric"><div class="label">Load</div>
|
</div>
|
||||||
<div class="value sm"><span data-k="load">n/a</span><span class="unit">%</span></div>
|
</div>
|
||||||
<div class="bar" data-bar="load"><i></i></div></div>
|
<div class="metrics ups-electrical">
|
||||||
<div class="metric"><div class="label">Line in</div>
|
<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="value sm"><span data-k="line">n/a</span><span class="unit">V</span></div></div>
|
||||||
<div class="metric"><div class="label">Out</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: ${
|
(item.alarms || []).map((alarm) => `<span class="chip warn">alarm: ${
|
||||||
DashCharts.escapeHtml(alarm)}</span>`).join('');
|
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 n = item.numbers || {};
|
||||||
const cell = (key) => q(key);
|
const verdicts = item.severity || {};
|
||||||
setValue(cell('charge'), n.charge_pct, 0);
|
drawGlyph(q('glyph'), item);
|
||||||
setBar(card.querySelector('[data-bar="charge"]'), n.charge_pct,
|
// Charge gets no `.bar`: the glyph beside it *is* this value drawn as a shape, and one
|
||||||
n.charge_pct == null ? '' : n.charge_pct >= 95 ? 'ok' : n.charge_pct >= 60 ? 'warn' : 'fail');
|
// value drawn twice in the same row is the pattern the rest of this app avoids.
|
||||||
setTxt(cell('runtime'), runtimeText(n.battery_runtime));
|
Dash.hero(q('charge'), {
|
||||||
setValue(cell('load'), n.load_pct, 1);
|
label: 'Charge', value: n.charge_pct, unit: '%', digits: 0,
|
||||||
setBar(card.querySelector('[data-bar="load"]'), n.load_pct, severityFor(n.load_pct, 70, 90));
|
severity: verdicts.charge_pct, sub: chargeSub(n),
|
||||||
setValue(cell('line'), n.line_voltage, 1);
|
});
|
||||||
setValue(cell('out'), n.output_voltage, 1);
|
Dash.hero(q('load'), {
|
||||||
setValue(cell('batt'), n.battery_voltage, 2);
|
label: 'Load', value: n.load_pct, unit: '%', digits: 1,
|
||||||
setValue(cell('amps'), n.battery_current, 2);
|
severity: verdicts.load_pct, bar: true, sub: loadSub(n),
|
||||||
setValue(cell('temp'), n.battery_temperature, 1);
|
});
|
||||||
|
// 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: [] };
|
const health = item.health || { checks: [] };
|
||||||
q('checks').innerHTML = (health.checks || []).map((check) => `
|
q('checks').innerHTML = (health.checks || []).map((check) => `
|
||||||
|
|||||||
Vendored
+75
@@ -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
|
||||||
Vendored
+79
@@ -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
@@ -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:
|
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
|
`/` was converted in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in
|
||||||
colours itself from the `severity` verdicts the API already sends, so it carries no
|
phase 05, so no page colours anything from a threshold it wrote itself. The function
|
||||||
threshold of its own. `/ups` is phase 05's job and must keep compiling against the
|
itself stays: it is the documented fallback `setBar()` uses when a caller names no
|
||||||
fallback until it is converted — deleting it early would break that page.
|
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
|
js = client.get("/static/app.js").text
|
||||||
assert "FALLBACK ONLY" in js
|
assert "FALLBACK ONLY" in js
|
||||||
assert "severityFor" in client.get("/static/page-ups.js").text, \
|
assert "severityFor" in js, "the documented fallback itself was deleted"
|
||||||
"/ups still uses the fallback until phase 05 converts it"
|
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:
|
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
|
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 `/llm` layout (phase 04 task 03) ---------------------------------
|
||||||
#
|
#
|
||||||
# The drift this phase fixes was a 14-cell grid where everything was the same size and
|
# The drift this phase fixes was a 14-cell grid where everything was the same size and
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ from card_contract import assert_card_contract
|
|||||||
from dashboards import collectors
|
from dashboards import collectors
|
||||||
from dashboards.config import Settings, load
|
from dashboards.config import Settings, load
|
||||||
from dashboards.rates import RateEstimator
|
from dashboards.rates import RateEstimator
|
||||||
|
from dashboards.severity import classify
|
||||||
from dashboards.sources import nut
|
from dashboards.sources import nut
|
||||||
from dashboards.sources.host import HostCollector
|
from dashboards.sources.host import HostCollector
|
||||||
|
from ups_contract import assert_ups_hero_contract
|
||||||
|
|
||||||
pytestmark = pytest.mark.live
|
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"
|
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:
|
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.
|
"""Every advertised command is either enabled or carries the reason it is not.
|
||||||
|
|
||||||
|
|||||||
+137
-1
@@ -8,13 +8,14 @@ directly, because those are what decide whether a battery test gets sent.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
from dashboards.sources import nut
|
from dashboards.sources import nut
|
||||||
from dashboards.sources.nut import NutClient, NutError, UpsReading, normalise_celsius
|
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 = {
|
VAR_ROWS = {
|
||||||
"battery.charge": "100",
|
"battery.charge": "100",
|
||||||
@@ -548,7 +549,142 @@ async def test_simulator_discharges_while_on_battery() -> None:
|
|||||||
assert ups.charge < before
|
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:
|
async def test_simulator_command_for_unknown_ups_raises() -> None:
|
||||||
sim = NutSimulator()
|
sim = NutSimulator()
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
sim.command("nope", "beeper.mute")
|
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"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
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])))
|
lambda: asyncio.sleep(0, Result(payload={}, events=[event])))
|
||||||
await job.run_once(store)
|
await job.run_once(store)
|
||||||
assert [e.message for e in store.recent_events(limit=5)] == ["OL → OB"]
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user