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,48 @@
|
||||
# Phase 05 — UPS hero numbers and a battery glyph
|
||||
|
||||
**Source:** `TODO.md` L7 — "UPS looks good but need charge, load, and runtime left to be big, obvious, and color coded. Also an SVG battery symbol that shows current charge would be a nice touch."
|
||||
**Story:** n/a
|
||||
**Context:** `.agents/PLAN.md` → *Architectural Anchors* (severity from the API, hand-rolled SVG), `01_severity_language`, `src/dashboards/web/pages/ups.html`, `src/dashboards/web/static/page-ups.js`, `src/dashboards/web/static/charts.js`, `src/dashboards/sources/nut.py`
|
||||
|
||||
## Objective
|
||||
|
||||
Each UPS card opens with a battery glyph that visibly holds its charge, next to three big,
|
||||
colour-coded numbers: **charge %**, **load %**, **runtime left**. The health checks, diagnostics
|
||||
buttons and history stay below them, working exactly as they do now.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `01_severity_language` (todo) — charge/load/runtime verdicts come from the API, and the
|
||||
`.hero` style comes from `dash.css`.
|
||||
- `03_system_per_host_panels` (todo) — proves the hero pattern on `/system` first, so the UPS
|
||||
page is not the place that pattern gets invented.
|
||||
|
||||
## Tasks
|
||||
|
||||
1. `01_ups_hero_data.md` — confirm the API gives what the hero row and glyph need (`numbers.charge_pct`, `numbers.load_pct`, `numbers.battery_runtime`, `numbers.battery_voltage`, `charger_status`, `on_battery`) with severity attached; contract tests against real upsd.
|
||||
2. `02_battery_glyph.md` — `DashCharts.battery(container, {charge, severity, state, label})`: inline SVG with a fill proportional to charge, severity colour, charging/on-battery treatment, accessible label.
|
||||
3. `03_ups_hero_row.md` — restructure the card in `page-ups.js`/`ups.html`: hero row + glyph on top, checks and diagnostics below, `n/a` never coloured.
|
||||
4. `04_verify_real_batteries.md` — verify against both real UPSes plus the low-charge and on-battery states reachable only through the simulator.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Python: the only expected change is in `sources/nut.py`'s `payload()` (severity + any
|
||||
missing glyph field); coverage **>90%** there.
|
||||
- Contract: `tests/test_ups_contract.py` asserts the hero fields and their severities exist for
|
||||
the shapes the real drivers produce — including `battery_temperature` being `None`, which is
|
||||
true of both the APC and the CyberPower here.
|
||||
- Glyph states are exercised in devtools with synthetic values (`100 ok`, `60 warn`,
|
||||
`12 fail`, `null`) **and** in the live simulator (`NUT_SIMULATE=1`), which can be driven to
|
||||
low charge and on-battery — real hardware will not do that on demand, and `nut_sim.py`'s
|
||||
quick test already flips `OB` for ~12s.
|
||||
- Live: `uv run pytest -m live --live` covers the real UPSes' numbers.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] each UPS card shows charge / load / runtime as its three largest elements, coloured from API `severity`
|
||||
- [ ] the glyph's fill visibly tracks charge: `data/logs/ups-glyph-*.png` at 100 %, mid, low, and the unknown state
|
||||
- [ ] on battery the glyph/card state changes visibly (simulator is acceptable proof — say which in `NOTES-verify.md`)
|
||||
- [ ] `SIMULATED` still badges the card when `NUT_SIMULATE=1`, and never when reading real upsd
|
||||
- [ ] battery-test buttons unchanged: `Quick battery test` → confirm dialog → **Cancel** works, and `DANGER` is still required for the power-cutting ones
|
||||
- [ ] `uv run pytest` green, coverage ≥94%, `ruff` + `pyright` clean, `-m live --live` green
|
||||
- [ ] no behavior change in completed phases
|
||||
@@ -0,0 +1,51 @@
|
||||
# Task 01 — UPS hero data contract
|
||||
|
||||
**Phase:** `05_ups_hero_battery_glyph` · **Source:** `TODO.md:7` — "charge, load, and runtime left to be big, obvious, and color coded"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Know exactly what the hero row reads, and prove it — with real batteries, since the
|
||||
CyberPower and the APC disagree in ways the fixtures do not cover.
|
||||
|
||||
## Work
|
||||
|
||||
1. Inspect what `/api/ups/snapshot` returns today for the two real UPSes:
|
||||
`curl -s localhost:8090/api/ups/snapshot | python3 -m json.tool`
|
||||
(dev forwards `127.0.0.1:3494` → driveripper's upsd on `3051`).
|
||||
2. Confirm `payload()` in `src/dashboards/sources/nut.py` supplies, per card:
|
||||
- `numbers.charge_pct`, `numbers.load_pct`, `numbers.battery_runtime`,
|
||||
`numbers.battery_voltage`, `numbers.battery_temperature`
|
||||
- `charger_status` (`full` / `ol` / `dchg`) and `on_battery` — the glyph's state comes from
|
||||
these, not from guessing
|
||||
- `severity` for charge / load / runtime (from phase 01; `sources/nut.py`'s
|
||||
`payload()` is where to attach it if it is not there yet)
|
||||
- `health.overall` and `health.counts` (unchanged — the card header already uses them).
|
||||
3. `tests/test_ups_contract.py` (new) —
|
||||
- a `UpsReading` built from captured-style vars asserts each of the paths above exists, and
|
||||
that a missing `battery.temperature` yields `None` rather than 0 (both real drivers omit
|
||||
it — this is the exact real-world shape);
|
||||
- charge 100 → `ok`; charge 45 → `warn`; charge 15 → `fail` (inverted metric);
|
||||
- runtime 3600 s → `ok`; 400 s → `warn`; 200 s → `fail`; `None` → `None` severity, and the
|
||||
page-side formatting of `None` must be `n/a` (assert whatever helper is used returns the
|
||||
app's `naText`, not `0s`);
|
||||
- `charger_status == "full"` with `on_battery == False` renders "charging" nowhere, and
|
||||
`on_battery == True` makes the glyph's on-battery state available.
|
||||
- ASSUMPTION: the glyph derives its state from `charger_status` + `on_battery` rather than
|
||||
NUT status flags being re-parsed in JS — the flags are already parsed server-side into
|
||||
`states[]` (`UPS_STATE_LABELS`) and re-parsing them client-side duplicates that map.
|
||||
- ASSUMPTION: runtime prose stays `Dash.duration()`-formatted (`10m 21s`, `1h 32m`) — no new
|
||||
formatter, because the UPS page already prints runtime that way.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Coverage **>90%** on `sources/nut.py` changes.
|
||||
- Live: `uv run pytest -m live --live` — `test_upsd_reports_both_upses_with_sane_units` is the
|
||||
real-hardware version of the same assertions.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `uv run pytest tests/test_ups_contract.py -v` green
|
||||
- [ ] `curl -s localhost:8090/api/ups/snapshot` shows `severity` for charge/load/runtime on **both** `apc` and `cyberpower`
|
||||
- [ ] `battery_temperature` is `null` on both, and nothing in the API turned it into 0
|
||||
- [ ] `uv run pytest` green; `ruff` + `pyright` clean
|
||||
@@ -0,0 +1,58 @@
|
||||
# Task 02 — Battery glyph
|
||||
|
||||
**Phase:** `05_ups_hero_battery_glyph` · **Source:** `TODO.md:7` — "an SVG battery symbol that shows current charge would be a nice touch"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
An inline SVG battery whose fill *is* the charge, whose colour *is* the severity, and whose
|
||||
outline says "unknown" when there is no number — in the same hand-rolled-SVG house style as
|
||||
the charts.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/charts.js` — `battery(container, opts)` with
|
||||
`{charge: number|null, severity: 'ok'|'warn'|'fail'|null, state: 'online'|'onbattery'|'charging'|'unknown', label?: string}`:
|
||||
- horizontal cell: rounded body + terminal nub on the right, drawn as one `<svg>` with a
|
||||
`<rect>` fill sized `charge/100` of the interior, clipped so a 0 % charge leaves an
|
||||
empty shell rather than a stray pixel;
|
||||
- colour from the theme tokens via the existing `cssVar()`: `--ok` / `--warn` / `--fail`;
|
||||
`severity: null` → outline only, no fill, muted stroke;
|
||||
- `charge` clamped to 0–100 but never *rounded up* to visible: 2 % must look nearly empty,
|
||||
because a reassuring full-looking battery at 2 % is a lie;
|
||||
- state affordances drawn in SVG, no icon font: a bolt overlay when
|
||||
`state === 'charging'`, a subtle hatch or dashed outline when on battery, and a dash
|
||||
pattern through the body when charge is unknown;
|
||||
- accessible by construction: `role="img"`, `<title>` built from the inputs
|
||||
("Charge 100 percent, on line"), and `aria-hidden` only when the adjacent hero number
|
||||
already carries the same text — pick one, and comment which;
|
||||
- scales by container width (`viewBox` + `preserveAspectRatio`) so the hero row can make it
|
||||
big on desktop and small on a phone.
|
||||
2. No animation. If a fill transition is added at all, it must sit behind
|
||||
`@media (prefers-reduced-motion: no-preference)`.
|
||||
3. Export it from the `window.DashCharts = { … }` object at `charts.js:377`.
|
||||
4. `src/dashboards/web/static/dash.css` — only what the glyph needs (sizing, `.battery`
|
||||
wrapper, the on-battery hatch if it is CSS rather than SVG).
|
||||
- ASSUMPTION: horizontal cell, not vertical — runtime/charge read left-to-right next to the
|
||||
hero numbers and it matches how NUT prints battery bars in `upsc`.
|
||||
- ASSUMPTION: no emoji/Unicode battery character anywhere; it would render differently per
|
||||
platform and cannot be coloured by severity.
|
||||
- ASSUMPTION: `state` is derived in the page from `status`/`charger_status`/`on_battery`
|
||||
(`page-ups.js`), because that mapping is presentation, not data.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Static-asset assertion in `tests/test_api.py` that `battery` is exported from
|
||||
`/static/charts.js`.
|
||||
- Browser/devtools matrix — call it directly with synthetic inputs and screenshot each:
|
||||
`{charge:100, severity:'ok', state:'online'}`, `{charge:60, severity:'ok', state:'charging'}`,
|
||||
`{charge:35, severity:'warn', state:'onbattery'}`, `{charge:12, severity:'fail', state:'onbattery'}`,
|
||||
`{charge:2, severity:'fail', state:'onbattery'}`, `{charge:null, severity:null, state:'unknown'}`,
|
||||
and `charge:0`. Also zoom to 200 % and check both themes.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `curl -s localhost:8090/static/charts.js | grep -c "function battery"` → 1
|
||||
- [ ] the six-state devtools matrix is screenshotted into `data/logs/` and each state is distinguishable without colour (shape/hatch/label)
|
||||
- [ ] `<title>` present and correct for at least the online and on-battery cases
|
||||
- [ ] `uv run pytest` green; `ruff` + `pyright` clean
|
||||
@@ -0,0 +1,64 @@
|
||||
# Task 03 — The hero row
|
||||
|
||||
**Phase:** `05_ups_hero_battery_glyph` · **Source:** `TODO.md:7` — "charge, load, and runtime left to be big, obvious, and color coded. Also an SVG battery symbol that shows current charge"
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
Each UPS card opens with the battery glyph and three big numbers. Everything else on the card
|
||||
moves below them and changes nothing about how it works.
|
||||
|
||||
## Work
|
||||
|
||||
1. `src/dashboards/web/static/page-ups.js` — `upsCard()` (L34):
|
||||
- replace the top of the `.metrics` block (L50–68) with a hero row: the `data-k="glyph"`
|
||||
container plus three `.hero` blocks — **Charge %**, **Load %**, **Runtime left** — with
|
||||
`.sub` lines for the context that matters (charge: `12 V nominal · 27.3 V`; load:
|
||||
`128 W of 900 W` when `realpower`/`realpower_nominal` allow it, otherwise just the %;
|
||||
runtime: `at the current load`);
|
||||
- demote to a secondary, visibly smaller row or the existing disclosure: line in, output,
|
||||
battery volts, battery amps, battery °C. Do not delete them — they are the numbers you
|
||||
want the moment the UPS is on battery;
|
||||
- keep unchanged: the state chips (`OL`/`OB`/`LB`), the `SIMULATED` badge, the
|
||||
`degraded`/`healthy` chip, health checks (`data-k="checks"`), diagnostics
|
||||
(`data-k="commands"`), the NUT variable dump (`data-k="raw"`), and the whole
|
||||
`#confirm-dialog` flow including typing `DANGER`.
|
||||
2. `renderCard()` (L84):
|
||||
- hero values via `Dash.hero()`, colours via `Dash.severityFrom(item.severity.charge_pct)`
|
||||
etc. — **delete the `severityFor(...)` call in this file**;
|
||||
- `runtimeText()` (L24) stays the single place runtime prose is built (`1h 32m`,
|
||||
`10m 21s`); `null` → `n/a` with no colour — never `0m`;
|
||||
- derive the glyph state: `on_battery` → `'onbattery'`; else `charger_status` in
|
||||
('dchg',) → `'charging'`; `charger_status === 'full'` or `status` containing `OL` →
|
||||
`'online'`; nothing known → `'unknown'`. Comment the mapping — NUT's flags are subtle
|
||||
(`OL` and `CHRG` can both be present, and a fully-charged-on-line unit reports
|
||||
`charger.status: full`, which is not "charging");
|
||||
- draw the glyph through `DashCharts.battery()`; re-draw only when charge/state changes so
|
||||
the poll loop does not rebuild DOM every 5s.
|
||||
3. `src/dashboards/web/pages/ups.html` — the cards are built in JS into `#ups-cards`; only
|
||||
the four history charts (`#chart-charge`, `#chart-runtime`, `#chart-voltage`,
|
||||
`#chart-load`), the events card and the dialog live here. Leave them; if a hero row needs a
|
||||
wrapper class, put it in `dash.css`.
|
||||
4. `src/dashboards/web/static/dash.css` — hero row layout (glyph + three heroes, wraps to a
|
||||
stack on narrow screens), secondary row visibly secondary (muted, smaller), and the card
|
||||
must not grow so tall that two cards stop fitting side by side on a laptop.
|
||||
- ASSUMPTION: the four history charts and the event log stay where they are. The TODO says
|
||||
the page "looks good"; only the top of each card changes.
|
||||
- ASSUMPTION: charge wins the space next to the glyph, runtime is the number people
|
||||
actually act on. Both are heroes; neither is buried.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Contract already covered by `tests/test_ups_contract.py` (task 01).
|
||||
- Browser: real fleet (`apc`, `cyberpower`), both themes, plus the synthetic low/on-battery
|
||||
states from task 04's simulator pass.
|
||||
- Watch the poll: numbers update every `NUT_INTERVAL_SECONDS` (5s) without layout shift — the
|
||||
tabular numerals from phase 01 make that true; verify rather than assume.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] each card's three largest elements are Charge / Load / Runtime, colour-coded from API severity
|
||||
- [ ] the glyph sits with them and visibly matches charge (both real UPSes are at 100 %, so verify the match with the synthetic states too)
|
||||
- [ ] battery °C renders `n/a` on both real UPSes without a failure colour
|
||||
- [ ] battery-test buttons + `DANGER` confirmation still work (click one through to the dialog, then **Cancel** — do not send a command)
|
||||
- [ ] `uv run pytest` green, `ruff` + `pyright` clean
|
||||
@@ -0,0 +1,57 @@
|
||||
# Task 04 — Verify on real batteries, and the states they will not give you
|
||||
|
||||
**Phase:** `05_ups_hero_battery_glyph` · **Source:** `TODO.md:7` — the whole item; the colour coding only means something if the alarming states have been seen
|
||||
**Story:** n/a
|
||||
|
||||
## Objective
|
||||
|
||||
See the hero row in every state that matters — including the two that only the simulator can
|
||||
produce, because unplugging a live UPS to test a dashboard is not a test.
|
||||
|
||||
## Work
|
||||
|
||||
1. Real fleet (dev env points at `127.0.0.1:3494` → upsd on driveripper:3051):
|
||||
- `apc` — Back-UPS RS 1500MS: charge 100 %, load ~47 %, runtime ~10m, battery age
|
||||
**5.3 years → warn**, no battery temp;
|
||||
- `cyberpower` — PR1500LCDRT2U: charge 100 %, load ~14 %, runtime ~56m, battery age
|
||||
**unknown** (`battery.mfr.date: CPS` is not a date and must not become an age), no
|
||||
battery temp.
|
||||
Screenshot both cards. The two "unknown/warn" differences are the point: they prove the
|
||||
page is not rendering a template.
|
||||
2. States only `nut_sim.py` can produce — start the app with `NUT_SIMULATE=1` and
|
||||
`NUT_ALLOW_ACTIONS=0` and reach them the honest way (drive `SimUps.tick()`/`step()` in a
|
||||
unit test or a REPL and read `NutSimulator.read()` output; the quick battery test puts a
|
||||
unit on `OB` for ~12s):
|
||||
- 100 % online → green, no bolt;
|
||||
- ~60 % charging → bolt visible;
|
||||
- ~35 % on battery → amber, on-battery treatment;
|
||||
- 12 % on battery (`OB LB`) → red;
|
||||
- charge absent → outline only, `n/a`, no colour, no fake runtime.
|
||||
Screenshot each. `SIMULATED` must be visible in every one of these shots — if it is not,
|
||||
that is a bug in this phase, not a cosmetic miss.
|
||||
3. Failure path: kill the upsd forward (`./scripts/dev-tunnels.sh stop`/`start`, or stop
|
||||
`nut-server` remotely **only** if the operator agrees) and confirm the cards keep the last
|
||||
good numbers and mark them stale rather than showing `n/a` everywhere
|
||||
(`AGENTS.md` invariant 4).
|
||||
4. Commands: click **Quick battery test** → dialog appears → type `DANGER` → then **Cancel**.
|
||||
Verifying the gate without sending the command is the correct amount of clever here; if
|
||||
the operator explicitly asks for a live quick test, run it and watch `ups.test.result`
|
||||
change to `PASSED` (and the health check go green), which is the payoff for the whole
|
||||
phase.
|
||||
5. Record what was seen in `NOTES-verify.md` in this phase directory, listing each screenshot
|
||||
and which state it proves.
|
||||
- ASSUMPTION: no instant command is sent as part of automated verification. A deep test
|
||||
discharges a battery that something real is plugged into.
|
||||
|
||||
## Testing & Quality
|
||||
|
||||
- Evidence task; fix on discovery, then re-shoot.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `data/logs/ups-real-apc.png` and `data/logs/ups-real-cyberpower.png` exist and were read
|
||||
- [ ] five synthetic states screenshotted, each with the `SIMULATED` badge visible
|
||||
- [ ] stale-source screenshot shows last-good values, not zeros
|
||||
- [ ] the `DANGER` dialog path exercised and cancelled; no command was sent without the operator asking
|
||||
- [ ] `NOTES-verify.md` written in this phase directory
|
||||
- [ ] `uv run pytest` and `uv run pytest -m live --live` green
|
||||
Reference in New Issue
Block a user