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:
@@ -52,12 +52,19 @@ page still renders.
|
||||
`404 {"configured": false}` **without dialling**, and the page says "not configured".
|
||||
A red panel for a server the operator never installed destroys the signal from the
|
||||
panels that matter.
|
||||
4. **One dead source must not blink another.** Each source is its own asyncio task with its
|
||||
own timeout and health record (`poller.py`). A failed poll keeps the last good payload and
|
||||
marks it stale in the UI instead of zeroing it — on `/` too, where a host that has never
|
||||
answered still gets a fleet row (`unreachable: true`, dimmed, every number `n/a`) because
|
||||
dropping the row would turn a dead agent into "never configured". Transitions write
|
||||
events; repeated failure writes one line, not 500.
|
||||
4. **One dead source must not blink another — and nothing may stop polling in silence.** Each
|
||||
source is its own asyncio task with its own timeout and health record (`poller.py`). A
|
||||
failed poll keeps the last good payload and marks it stale in the UI instead of zeroing it —
|
||||
on `/` too, where a host that has never answered still gets a fleet row
|
||||
(`unreachable: true`, dimmed, every number `n/a`) because dropping the row would turn a dead
|
||||
agent into "never configured". Transitions write events; repeated failure writes one line,
|
||||
not 500. **The task is the source's only poller, so no exception may end it**: a store that
|
||||
refuses an otherwise good poll is a *reported* failure (`ok: false`, the real reason in
|
||||
`error`, `last_ok` frozen so the page ages the numbers, one `store-error` event then one
|
||||
`recording` event on recovery), and anything that escapes the poll body is logged and the
|
||||
loop continues. Four dev-server sources once froze behind an unretrieved SQLite exception
|
||||
while `/api/health` went on calling them `ok` — a frozen source that looks healthy is the
|
||||
one failure mode worse than an outage.
|
||||
5. **No CDN, no build step, no internet at runtime.** Every asset is served by this app;
|
||||
charts are hand-rolled SVG (`web/static/charts.js`). The engines live on a network with
|
||||
no outbound access — a CDN reference is a request that never comes back.
|
||||
@@ -107,7 +114,10 @@ Responsibilities, one line each:
|
||||
rates so the agent never remembers who polled it last.
|
||||
- `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real
|
||||
env var always wins. Defaults are safe except the sources, which default to unset.
|
||||
- `poller.py` — job loop, health records, stale/last-good semantics, transition events.
|
||||
- `poller.py` — job loop, health records, stale/last-good semantics, transition events. The
|
||||
success path records (payload → samples → events) and only *then* moves `last_ok`, so a
|
||||
refused SQLite write ages the source and alerts instead of leaving it current with charts
|
||||
that have silently stopped drawing.
|
||||
Events are stored under the **dashboard** name (`system`/`llm`/`ups`), not the sample
|
||||
source (`host`) — a page filtering `/api/events` by `host` gets an empty feed forever
|
||||
(found on `/system` during phase 03 browser verification).
|
||||
@@ -121,11 +131,26 @@ Responsibilities, one line each:
|
||||
`mean(prompt tokens)/mean(prefill time)`, session list from gufo `/slots`.
|
||||
- `sources/nut.py` — upsd client, `UpsReading`, `VAR_MAP`, `assess()` (explicit checks, not
|
||||
a mystery score), `COMMANDS` catalog with risk tiers, `available_commands()` filtering,
|
||||
kelvin-vs-celsius normalisation.
|
||||
kelvin-vs-celsius normalisation, and `charger_status` normalised to the `CHARGER_STATUS`
|
||||
keys (the same lowercase token `states()` and `on_battery` already compare), and `states()`
|
||||
publishes one chip per fact — a driver exporting both `battery.charger.status` and the
|
||||
legacy flag that says the same word gets "charging, online", not "charging, online,
|
||||
charging". `battery_runtime`
|
||||
comes from `battery.runtime` **only** — `battery.runtime.low` is a shutdown threshold, not
|
||||
time left, so it lives at `battery_runtime_low` and never becomes the hero's "runtime left".
|
||||
- `sources/nut_sim.py` — a **labelled** simulator so the UPS page is developable without a
|
||||
battery. It must never be the default, and every payload it produces says `simulated`.
|
||||
Its modelled `battery.mfr.date` is computed relative to today (not a fixed string that
|
||||
ages), so a fresh simulator reads healthy and amber keeps meaning something.
|
||||
ages), so a fresh simulator reads healthy and amber keeps meaning something. Its status
|
||||
follows the real drivers: a below-float pack on mains reports `OL CHRG`/`chg` (the glyph's
|
||||
bolt state is keyed on that token, and the model has to be able to produce it) and a battery
|
||||
being drawn from is `OB`/`dchg`, never `ol`. Every command it advertises is implemented and
|
||||
visible in a variable a real driver publishes — the beeper ones set `ups.beeper.status`
|
||||
(`enabled`/`muted`/`disabled`), and a test window that ends writes `ups.test.result: PASSED`
|
||||
while an aborted one writes nothing; the load/shutdown commands answer `OK` without modelling
|
||||
an outlet, because a faked power cut in the history is indistinguishable from a real one.
|
||||
`from_env()` is the single place the `NUT_SIMULATE` truthy rule lives — `app.py` builds the
|
||||
monitor through it, so the assembly and the model cannot disagree about what is enabled.
|
||||
- `prom.py` — Prometheus text-exposition parser (in-house; only for engines we don't own).
|
||||
- `rates.py` — Δ/window estimator, keyed by resolved series name.
|
||||
- `severity.py` — the only place thresholds live: `THRESHOLDS`/`INVERTED` per metric,
|
||||
@@ -167,10 +192,11 @@ Responsibilities, one line each:
|
||||
`Dash.hero(el, {label, value, unit, digits, severity, sub, bar})` renders/patches a
|
||||
`.hero` big number (plus an optional `.bar`) from that class — it never compares a number
|
||||
to a threshold. `Dash.severityFor(value, warnAt, failAt)` is the documented fallback for
|
||||
values the API does not rate; converting its remaining call sites is phase 05's job
|
||||
(`/` was converted in phase 02, `/system` in phase 03 and `/llm` in phase 04 — those pages
|
||||
colour only from the `severity` verdicts the API sends, and no `severityFor(` call survives
|
||||
in their scripts).
|
||||
values the API does not rate, and **no page script calls it any more** — `/` was converted
|
||||
in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in phase 05, so every
|
||||
colour on all four pages comes from a `severity` verdict the API sent
|
||||
(`tests/test_api.py` fails if a page script names it again). A value the API should rate
|
||||
gets a row in `severity.py`, never a bound in JS.
|
||||
|
||||
Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`,
|
||||
`gpu.busy_pct@0`, `llm.decode_tokens_per_second`, `ups.charge_pct`), `@stream` for per-device
|
||||
@@ -197,9 +223,14 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
`ups.description`, the operator's label — the driver's own `identity.model` answers
|
||||
`GX1500U`, so the description is what identifies the box).
|
||||
Real-hardware quirks the parsers already handle: `battery.mfr.date` is the useful date
|
||||
(`battery.date` is often an unset placeholder like `2001-09-25`), CyberPower reports
|
||||
`battery.mfr.date: CPS` (not a date → `unknown`, never a fake age), neither driver reports
|
||||
a battery temperature, and `ups.test.result: No test initiated` is the factory state on both.
|
||||
(`battery.date` is often an unset placeholder — `2001/09/25`, slashes and all, on the APC),
|
||||
CyberPower reports `battery.mfr.date: CPS` (not a date → `unknown`, never a fake age),
|
||||
**neither driver reports a battery temperature or the NUT 2.8 `battery.charger.status`**
|
||||
(so the battery glyph's state has to come from `on_battery`, and `charger_status` must stay
|
||||
optional), and `ups.test.result: No test initiated` is the factory state on both.
|
||||
Both UPSes are recorded verbatim (`LIST UPS`/`LIST VAR`/`LIST CMD`, serials scrubbed) as
|
||||
`tests/fixtures/nut_<ups>.vars` by `./scripts/capture-fixtures.sh`, and the wire quotes a
|
||||
field only when it must — `VAR apc ups.status "OL"`, not three quoted fields.
|
||||
- Host access is via ssh keys as `ops`. Passwordless sudo exists but is command-scoped; the
|
||||
set we rely on is documented in `deploy/nut-sudoers` (additions are listed there for the
|
||||
work that is still blocked).
|
||||
@@ -216,11 +247,11 @@ cp .env.dev.example .env.dev # dev-only: ssh tunnels + live targets
|
||||
./scripts/dev-server.sh status # per-source ok/DOWN
|
||||
./scripts/dev-server.sh logs
|
||||
|
||||
uv run pytest # hermetic: 485 passed + 7 live-skipped, no network
|
||||
uv run pytest --cov # coverage gate (96.9%; floor 94%)
|
||||
uv run pytest # hermetic: 543 passed + 8 live-skipped, no network
|
||||
uv run pytest --cov # coverage gate (97.9%; floor 94%)
|
||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||
uv run ruff check src tests agent && uv run pyright
|
||||
./scripts/capture-fixtures.sh # re-record ground truth
|
||||
./scripts/capture-fixtures.sh # re-record ground truth (agents + UPS captures)
|
||||
|
||||
bash .agents/validate.sh # the phase gate: the four checks above (pytest
|
||||
# --cov --cov-fail-under=94, ruff, pyright, plus a
|
||||
@@ -253,7 +284,14 @@ service here.
|
||||
`test_every_host_serves_the_hero_card_contract` asserts the same table against the live
|
||||
agents — so a rename fails a test, and a card that shows `n/a` cannot be "fixed" by
|
||||
inventing a field. `network` carries no `severity` on purpose (throughput has no good/bad),
|
||||
and the contract pins its absence.
|
||||
and the contract pins its absence. `tests/ups_contract.py` is the same device for the `/ups`
|
||||
hero row and battery glyph (`HERO_FIELDS`, `UPS_SHAPES`, `assert_ups_hero_contract`):
|
||||
`tests/test_ups_contract.py` drives it from `tests/fixtures/nut_<ups>.vars` — verbatim
|
||||
`LIST UPS`/`LIST VAR`/`LIST CMD` replies from the two real UPSes — and
|
||||
`test_ups_hero_fields_are_present_on_the_real_batteries` drives it against live upsd. The
|
||||
captures are what make the driver disagreements testable: one of them answers
|
||||
`battery.mfr.date: CPS`, neither exports `battery.temperature` or
|
||||
`battery.charger.status`.
|
||||
- `live` (`tests/test_live_fleet.py`) re-runs the production code paths against the real
|
||||
fleet, engines, and upsd. Skipped unless `--live`; each test skips when its source is
|
||||
unset, so partial fleet availability is normal. **Live tests must not write** — a battery
|
||||
@@ -261,7 +299,7 @@ service here.
|
||||
- The agent's real-`/proc` test (`tests/test_agent.py`) touches only the box running pytest,
|
||||
so it stays in the default run and is *not* marked `live`.
|
||||
- Coverage gate: 80% is the repo floor, the phase gate in `.agents/validate.sh` enforces
|
||||
94% (`--cov-fail-under`), and ~97% is where we are; keep it there. New/modified Python
|
||||
94% (`--cov-fail-under`), and ~98% is where we are; keep it there. New/modified Python
|
||||
still has to clear >90% on its own. API tests use `create_app()` with an in-memory DB,
|
||||
never a real target, and never a real upstream: `/api/ups/command`'s send is proven against
|
||||
a scripted `nut.NutClient` (`ScriptedNutClient` in `tests/test_api.py`) because the live
|
||||
@@ -318,6 +356,45 @@ of one queued request averages to 0.14 and an axis fitted to that draws a full-h
|
||||
a queue nobody was in — `/llm` passes `minSpan: 1` on sessions and queued so its axis is never
|
||||
finer than one request.
|
||||
|
||||
A battery is `DashCharts.battery(container, {charge, severity, state, label})` — one inline SVG
|
||||
whose **fill *is* the charge**: 1 viewBox unit per percent, clamped to 0–100 and never rounded up
|
||||
(a 2 % cell must look nearly empty, because a reassuring battery above a flat one is a lie),
|
||||
clipped to the plate so 0 % leaves an empty shell rather than a stray pixel. Colour comes only from
|
||||
the payload `severity` token; a measured charge with no verdict draws faint grey at half strength
|
||||
(`sparkline`'s rule — a shape is not a verdict) and never an empty shell ("0 %" and "not rated" are
|
||||
different sentences). Every state has a **shape** as well as a colour, so the glyph survives
|
||||
monochrome: solid shell `online`, bolt `charging`, dashed outline `onbattery`, no fill with a dash
|
||||
pattern *through* the body `unknown`; a state word it does not know degrades to `unknown`, never to
|
||||
a confident "on line". Accessible **by default** — `role="img"` + `<title>` + `aria-label` built
|
||||
from the inputs ("apc: Charge 100 percent, on line"), because the glyph carries information the
|
||||
markup does not say in words — and a page that prints that sentence next to it passes
|
||||
`decorative: true` to make the `<svg>` `aria-hidden` rather than say it twice. No animation and no
|
||||
`ResizeObserver`: one `viewBox` + `preserveAspectRatio` scaled by `.battery` in `dash.css`, so the
|
||||
hero row gets a big cell on a desktop and a small one on a phone.
|
||||
|
||||
**The `/ups` card is led by the glyph and three heroes** (phase 05): the battery on the left, then
|
||||
**Charge %**, **Load %** and **Runtime left** on `.heroes` at the shared 2.4rem, coloured from
|
||||
`severity.charge_pct` / `severity.load_pct` / `severity.battery_runtime` and nothing else. Charge
|
||||
carries **no** `.bar` — the glyph beside it is that value drawn as a shape, and one value drawn
|
||||
twice in one row is the pattern this app avoids (Load keeps its bar; nothing else draws it). Its
|
||||
`.sub` lines are the context each number needs and nothing more: `24 V nominal · 27.3 V` for the
|
||||
pack, `128 W of 900 W` when the driver publishes both `realpower` figures (a wattage inferred from
|
||||
a percent against another model's rating would be an invented number), `at the current load` for
|
||||
runtime. Runtime prose is `runtimeText()`'s (`1h 32m`, `10m 21s`) and an estimate the driver never
|
||||
made goes into `Dash.hero()` as `null`, so it prints faint and uncoloured rather than as `0m`. The
|
||||
readings the hero row displaced — line in, output, battery volts and amps, battery °C — stay on the
|
||||
card, demoted to `.ups-electrical`: muted, `.95rem`, never folded away, because they are what you
|
||||
reach for *while* the UPS is on battery, and battery °C reads `n/a` on both real drivers here.
|
||||
`state` is derived in the page from `on_battery` → `charger_status` (`chg` charges; `dchg`/`rec` are
|
||||
the server's own on-battery tokens; `full` is **on line, not charging** — a rested floating battery
|
||||
reports it) with one fallback: a state chip the API rated `ok`, because `nut.states()` reserves
|
||||
`ok` for on-line/fully-charged and neither real driver here publishes `battery.charger.status` at
|
||||
all. `ups.status` itself is never re-parsed in JS (`tests/test_ups_contract.py`). `/ups` passes
|
||||
`decorative: true`, because the charge hero and the state chips already say the sentence. The glyph
|
||||
repaints only when charge / verdict / state change (`drawGlyph()` keys a `data-drawn` triple): the
|
||||
charge float jitters every `NUT_INTERVAL_SECONDS`, and rebuilding the `<svg>` on that re-announces
|
||||
an accessible name for a difference no eye can see.
|
||||
|
||||
An event row names its subject **once**. Every feed (`/system`, `/llm`, `/ups`) renders the
|
||||
message through `Dash.eventText(event)`, which drops the `UpstreamError: ` wrapper and a
|
||||
*leading* duplicated subject the way `alerts._clean_error()` does for the overview — `poller.py`
|
||||
|
||||
Reference in New Issue
Block a user