phase: 05_ups_hero_battery_glyph

**Phase 05 final pass — all criteria met; whole project 100 % green.**

Verified (live upsd + isolated simulator):
- Real `apc`/`cyberpower`: Charge/Load/Runtime are the card's three largest elements (38.4 px vs 15.2 px next), coloured only by API `severity` — live right now 100 % ok / 79.0 % **warn** / 5m **fail**; glyph fill = charge (100/100 viewBox units, `--ok` token), `<title>` "apc: Charge 100 percent, on line"; SIMULATED hidden; battery °C `n/a` uncoloured; every button disabled with its reason; `confirm_text: DANGER` on power-cutting commands; **0 non-GET requests** → `data/logs/ups-verify-live-{card,full}.png`.
- Simulator on `:8091` (own `/tmp` DB, killed after): caution dialog → Cancel, dangerous dialog → Run disabled until `DANGER` → Cancel, **0 POSTs**; one deliberate quick test *to the sim only* flipped the card to `OB` (glyph `stroke-dasharray="7 5"`, "on battery", health "discharging, on battery") then `OL CHRG` (bolt) then `OL` → `ups-verify-sim-{dialog-caution,dialog-danger,onbattery-button,onbattery-page,charging-button}.png`; glyph matrix `ups-glyph-*.png` (100/mid/low/unknown/empty/unrated, dark/light/200 %) present; `grep -c "function battery"` = 1.

Defects found in earlier work and fixed:
1. **`poller.py` could stop polling in silence** — 4 of 6 dev-server sources had frozen `attempts` (ages 12–38 min) while `/api/health` still said `ok: true`: the store-write half of `run_once` was outside the `try`, and a dead task's exception is never retrieved. Now `_commit()` moves `last_ok` only after the write lands, `_store_refused()` reports it (`ok:false`, real reason, one `store-error` event + one `recording` on recovery, payload kept), and `_loop()` catches anything that escapes and keeps polling — 4 new tests in `tests/test_poller.py`.
2. **`nut_sim.py` (modified by this phase) sat at 83 % coverage**; its whole command path was untested. Added tests (every advertised command implemented, unknown → `ValueError`, alarms → card + `assess()` fail, `NUT_SIMULATE` truthy rule) and fixed two model lies: `beeper.*` answered OK while changing nothing (now publishes `ups.beeper.status` enabled/muted/disabled) and a finished self-test never wrote `ups.test.result` (aborted writes none). → 100 %.
3. `nut_sim.from_env()` was dead code duplicating `app._truthy`; `app.py` now builds the simulator through it, so the enable gate lives once.

Tests / lint (exact): `bash .agents/validate.sh` → **EXIT=0** — `uv run pytest --cov --cov-fail-under=94` **543 passed, 8 skipped, coverage 97.91 %**; `uv run ruff check src tests agent` clean; `uv run pyright` 0 errors; `node --check` clean; repo hygiene clean. `uv run pytest -m live --live` **with `.env.dev` sourced → 8 passed** (one earlier run: 2 transient `9121/snapshot timed out` against the 4 s `HOST_TIMEOUT`; three later runs 8/8). `uv pip list --outdated` empty. Dev server restarted on the final code: `health ok`, 6/6 sources fresh.

Docs: AGENTS.md — invariant 4 extended ("nothing may stop polling in silence" + store-refusal semantics), `poller.py`/`nut_sim.py` responsibility lines, 533→543 and 96.9 %→97.9 %, "~97 %"→"~98 %". README.md — poller bullet gained the same guarantee, Testing gained "source `.env.dev` before `-m live --live`" (bare runs skip 7 of 8 live tests, which reads like a pass), Battery-test section gained the `deploy/nut-sudoers` install line and the read-before-write warning.

Notes/deviations: no live instant command was ever sent to real hardware (the four gates leave them disabled — AGENTS gap 1 unchanged); the on-battery proof was driven through the real button against `NUT_SIMULATE=1`, which is stronger than the NOTES hand-driven states. `NOTES-verify.md` under `todo/` left untouched per instructions. Next phase: **none** — 05 is the last in the PLAN.md roadmap.
This commit is contained in:
2026-10-17 10:27:23 -04:00
parent f44a617ef9
commit 34854d32af
44 changed files with 2374 additions and 105 deletions
+3 -7
View File
@@ -26,7 +26,7 @@ from .config import Settings
from .poller import Hub
from .rates import RateEstimator
from .sources.host import HostCollector
from .sources.nut_sim import NutSimulator
from .sources.nut_sim import from_env as simulator_from_env
from .store import SampleStore
WEB = Path(__file__).resolve().parent / "web"
@@ -47,7 +47,7 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
host_collector = HostCollector(rates)
nut_monitor: NutMonitor | None = None
if settings.nut_enabled:
simulator = NutSimulator() if _truthy(os.environ.get("NUT_SIMULATE")) else None
simulator = simulator_from_env(os.environ.get("NUT_SIMULATE"))
nut_monitor = NutMonitor(settings, simulator=simulator)
hub = Hub(store)
@@ -64,10 +64,6 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
return ctx, store, hub.start
def _truthy(value: str | None) -> bool:
return (value or "").strip().lower() in ("1", "true", "yes", "on")
def create_app(settings: Settings | None = None, store: SampleStore | None = None) -> FastAPI:
"""`store` is for tests: when supplied it is not created and not closed here."""
settings = settings or DEFAULT_SETTINGS
@@ -88,7 +84,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
hub = Hub(store)
nut_monitor = None
if settings.nut_enabled:
simulator = NutSimulator() if _truthy(os.environ.get("NUT_SIMULATE")) else None
simulator = simulator_from_env(os.environ.get("NUT_SIMULATE"))
nut_monitor = NutMonitor(settings, simulator=simulator)
for job in host_jobs(list(settings.host_targets), client,
HostCollector(rates), settings.host_interval):
+60 -7
View File
@@ -9,6 +9,10 @@ Rules this enforces, because violating them is what makes dashboards untrustwort
(`stale 12s` in the UI) instead of showing zeros or `n/a` everywhere.
* **Transitions are events, spam is not.** ok→fail and fail→ok write to the event log;
500 consecutive failures write one line.
* **The loop is the source's only poller, so nothing may kill it.** A bookkeeping failure — a
SQLite write refused by lock contention, a full disk, anything — is a *reported* failure,
never a dead task: four dev-server sources once stopped advancing `attempts` while
`/api/health` went on calling them `ok`, and only the growing `age_seconds` said so.
"""
from __future__ import annotations
@@ -92,6 +96,7 @@ class Job:
self.grace_failures = grace_failures
self.state = SourceState(key=key, dashboard=dashboard, label=label, configured=enabled)
self._down_logged = False
self._store_down_logged = False
self._stop = asyncio.Event()
self._task: asyncio.Task[None] | None = None
self._inflight = False
@@ -127,9 +132,24 @@ class Job:
finally:
self._inflight = False
self.state.ok = True
self.state.successes += 1
try:
self._commit(store, result, now, was_ok)
except Exception as exc: # noqa: BLE001 - a refusing store must not kill the loop
self._store_refused(store, exc, now)
return False
return True
def _commit(self, store: SampleStore, result: Result, now: float, was_ok: bool) -> None:
"""Record a good poll: payload, samples, its events, then mark the source good.
That order is the point. `last_ok` moves only once the write landed, so a source whose
history is being refused ages out on the page (and alerts) instead of looking current
forever with charts that silently stop drawing.
"""
self.state.payload = result.payload
self.state.samples_written += store.add_many(result.samples)
for event in result.events:
store.log_event(event)
# Recovery is news only if we ever said it was down: on a fresh start every source
# would otherwise log "answering again", which reads like an outage happened.
if self._down_logged or (not was_ok and self.state.failures):
@@ -139,18 +159,51 @@ class Job:
+ (f" after {count} failure{'s' if count != 1 else ''}"
if count else "")))
self._down_logged = False
if self._store_down_logged:
self._store_down_logged = False
store.log_event(Event(now, self.dashboard, self.label, "recording",
"info", f"{self.label} is recording history again"))
self.state.ok = True
self.state.successes += 1
self.state.consecutive_failures = 0
self.state.last_ok = now
self.state.error = None
self.state.samples_written += store.add_many(result.samples)
for event in result.events:
store.log_event(event)
return True
def _store_refused(self, store: SampleStore, exc: Exception, now: float) -> None:
"""Bookkeeping for a store that refused an otherwise good poll.
The upstream answered, so the freshest payload we have is kept — but the source is not
`ok` and its age keeps growing: history that is not landing means every chart past the
raw window is quietly empty, and a green dot over that is the failure this app exists
to avoid. Nothing here may raise: the caller's task *is* this source's poller, so the
event is attempted, and if the store is what broke, that failure is logged instead.
"""
self.state.ok = False
self.state.failures += 1
self.state.consecutive_failures += 1
self.state.error = f"{type(exc).__name__}: {exc} (the poll succeeded; the store refused it)"
logger.exception("job %s could not record a good poll", self.key)
if self._store_down_logged:
return
self._store_down_logged = True
try:
store.log_event(Event(now, self.dashboard, self.label, "store-error", "error",
self.state.error))
except Exception:
# The store is what is broken; say it out loud rather than die trying to say it.
logger.exception("job %s could not even log that the store refused it", self.key)
async def _loop(self, store: SampleStore) -> None:
while not self._stop.is_set():
started = time.perf_counter()
await self.run_once(store)
try:
await self.run_once(store)
except Exception:
# `run_once` handles its own failures, so escaping here is a bug. Letting it
# propagate would freeze the source with a healthy-looking health record and no
# further polls, which is exactly how this guard was earned.
logger.exception("job %s raised outside its own error handling; still polling",
self.key)
remaining = self.interval - (time.perf_counter() - started)
try:
await asyncio.wait_for(self._stop.wait(), timeout=max(0.2, remaining))
+29 -8
View File
@@ -14,15 +14,20 @@ Verified against NUT 2.8.5 (`/usr/bin/upsc` on driveripper). Protocol shape:
-> LIST UPS
<- BEGIN LIST UPS
<- UPS "apc" : APC Smart-UPS
<- UPS apc "APC Back-UPS RS 1500MS (USB)"
<- END LIST UPS
-> LIST VAR apc
-> LIST VAR "apc"
<- BEGIN LIST VAR apc
<- VAR "ups.status" "OL"
<- VAR apc ups.status "OL"
<- END LIST VAR apc
-> LIST CMD apc ; <- BEGIN LIST CMD apc / CMD test.battery.start.quick / END LIST CMD apc
-> LIST CMD apc ; <- BEGIN LIST CMD apc / CMD apc test.battery.start.quick / END LIST CMD apc
bad -> ERR ACCESS-DENIED (auth or permission problem)
ok -> OK ...
Those lines are verbatim NUT 2.8.5 (`tests/fixtures/nut_apc.vars`, recorded by
`scripts/capture-fixtures.sh`): upsd quotes a field only when it has to — a value with a
space always is, a bare name usually is not — which is why `_split_fields` honours quotes
instead of splitting on whitespace.
"""
from __future__ import annotations
@@ -300,7 +305,10 @@ VAR_MAP: dict[str, tuple[str, ...]] = {
"battery_current": ("battery.current",),
"battery_temperature": ("battery.temperature",),
"battery_type": ("battery.type",),
"battery_runtime": ("battery.runtime", "battery.runtime.low"),
# `battery.runtime.low` is the *shutdown threshold*, not time remaining. Listing it here
# made a UPS that publishes no estimate read as "5 m left" — the biggest number on the
# card, and an invented one (AGENTS.md invariant 2). It stays readable as itself below.
"battery_runtime": ("battery.runtime",),
"battery_runtime_low": ("battery.runtime.low",),
"battery_date": ("battery.mfr.date", "battery.date"),
"test_result": ("battery.test.result", "ups.test.result"),
@@ -386,21 +394,31 @@ class UpsReading:
def states(self) -> list[dict[str, str]]:
"""Human state list. The 2.8 `battery.charger.status` wins over legacy ups.status
when both exist because it distinguishes trim/boost/full/recalibration."""
when both exist because it distinguishes trim/boost/full/recalibration.
Winning means the flag the token already says is dropped, not appended beside it:
a driver exporting both `chg` and `OL CHRG` renders "charging, online", not
"charging, online, charging" — a word said twice on a chip row reads as two events.
"""
out: list[dict[str, str]] = []
charger = (self.get("charger_status") or "").strip().lower()
if charger and charger in CHARGER_STATUS:
label, severity = CHARGER_STATUS[charger]
out.append({"id": charger, "label": label, "severity": severity})
seen = {entry["label"] for entry in out}
raw = " ".join(self.status_flags)
if raw:
if raw in STATE_FLAGS:
label, severity, detail = STATE_FLAGS[raw]
out.append({"id": raw, "label": label, "severity": severity, "detail": detail})
if label not in seen:
out.append({"id": raw, "label": label, "severity": severity, "detail": detail})
else:
for flag in self.status_flags:
if flag in STATE_FLAGS:
label, severity, detail = STATE_FLAGS[flag]
if label in seen:
continue
seen.add(label)
out.append({"id": flag, "label": label, "severity": severity, "detail": detail})
return out
@@ -721,6 +739,9 @@ def payload(reading: UpsReading, *, now: float | None = None, **assess_kwargs: A
now = time.time() if now is None else now
health = assess(reading, now=now, **assess_kwargs)
numbers = {key: reading.number(key) for key in VAR_MAP}
# Normalised to the `CHARGER_STATUS` keys, the way `states()` and `on_battery` already
# read it: a driver that publishes "FULL" must not become a glyph state nobody labelled.
charger = (reading.get("charger_status") or "").strip().lower() or None
# charts must not plot 300 "°C"; the raw string stays visible in the variable dump
for key in ("battery_temperature", "ups_temperature"):
if numbers.get(key) is not None:
@@ -738,7 +759,7 @@ def payload(reading: UpsReading, *, now: float | None = None, **assess_kwargs: A
},
"ts": reading.ts,
"status": reading.get("status"),
"charger_status": reading.get("charger_status"),
"charger_status": charger,
"states": reading.states(),
"alarms": reading.alarms,
"on_battery": reading.on_battery,
+48 -14
View File
@@ -6,11 +6,17 @@ putting a real load on battery. It is never silent about what it is: the payload
`simulated: true` and the page shows a watermark on every simulated number.
The model is deliberately small and physical rather than random:
* charge sits at float (100%) while on line, drains at a Peukert-ish rate when on battery
* charge climbs back to float while on line — reporting `CHRG`/`chg` until it gets there,
the way a real driver does — and drains at a Peukert-ish rate when on battery
* runtime estimates scale with charge and fall off steeply with load (as they do for real
VRLA packs, which is why the number jumps around while the load moves)
* a quick battery test moves to OB for ~12s and then reports PASSED, so the whole
command → state change → event-log path is exercised
* the state a command changes is visible in the variables a real driver publishes — `OB`
plus `ups.test.result`/`ups.test.date` for the tests, `ups.beeper.status` for the beeper
— so the dev page shows an effect instead of only an `OK` toast. The load/shutdown
commands answer `OK` without modelling an outlet: there is no load here to cut, and a
faked power cut in the history is indistinguishable from a real one.
"""
from __future__ import annotations
@@ -18,7 +24,6 @@ from __future__ import annotations
import math
import time
from dataclasses import dataclass, field
from typing import Any
from .nut import UpsReading
@@ -59,13 +64,21 @@ class SimUps:
driver: str = "usbhid-ups"
_test_until: float = 0.0
_deep_until: float = 0.0
_beeper: bool = True
_result_pending: bool = False # a test is running; report its outcome when the window closes
_beeper: str = "enabled" # NUT's `ups.beeper.status`: enabled | muted | disabled
_alarms: list[str] = field(default_factory=list)
def tick(self, now: float) -> None:
"""Advance the model by wall-clock time since the last tick."""
# a running self-test pulls the load from the wall, whichever test it is
was_testing = self.on_battery
self.on_battery = now < self._test_until or now < self._deep_until
if was_testing and not self.on_battery and self._result_pending:
self._result_pending = False
# A driver writes the outcome when the test window closes, and `ups.test.result`
# is what the "Last self-test" health check reads: without this the simulated pack
# comes back on line while the card still quotes the result it had before the test.
self.test_result = "PASSED"
def step(self, dt: float) -> None:
dt = max(0.0, min(dt, 60.0))
@@ -93,13 +106,23 @@ class SimUps:
def vars(self, now: float) -> dict[str, str]:
load_pct = self.load_w / self.nominal_w * 100.0
flags = ["OL"] if not self.on_battery else (["OB"] + (["LB"] if self.charge < 20 else []))
if self.charge >= 99.5 and not self.on_battery:
flags.append("CHRG" if self.charge < 99.9 else "")
status = " ".join(part for part in flags if part)
charger = "ol" if not self.on_battery else ("dchg" if self.charge < 99.5 else "ol")
if self.charge >= 99.9 and not self.on_battery:
# Status and charger status must agree with each other *and* with a real driver.
# A below-float pack on mains is charging — APC prints exactly `OL CHRG` there —
# and a battery being drawn from is never "online": `ol`/`full` while `OB` is the
# contradiction `assess()` would have to guess about. Reporting plain `ol`/`OL`
# during the recharge made the page's charging state (the glyph's bolt, keyed on
# `charger_status == "chg"`) unreachable in the simulator, and that is a state the
# operator has to be able to see in dev.
if self.on_battery:
flags = ["OB"] + (["LB"] if self.charge < 20 else [])
charger = "dchg"
elif self.charge >= 99.9:
flags = ["OL"]
charger = "full"
else:
flags = ["OL", "CHRG"]
charger = "chg"
status = " ".join(flags)
volts = (self.battery_nominal * (0.86 + 0.14 * self.charge / 100.0)
if self.on_battery else self.battery_nominal * 1.045)
line = 121.4 + 1.1 * math.sin(now / 90.0)
@@ -109,6 +132,7 @@ class SimUps:
"ups.serial": self.serial,
"ups.firmware": self.firmware,
"ups.status": status,
"ups.beeper.status": self._beeper,
"battery.charger.status": charger,
"ups.load": f"{load_pct:.1f}",
"ups.realpower": f"{self.load_w:.1f}",
@@ -152,23 +176,29 @@ class SimUps:
if command == "test.battery.start.quick":
self._test_until = now + 12.0
self.on_battery = True
self._result_pending = True
self.test_date = time.strftime("%Y-%m-%d", time.gmtime(now))
return "OK Start Quick Battery Test"
if command == "test.battery.start.deep":
self._deep_until = now + 90.0
self.on_battery = True
self._result_pending = True
return "OK Start Deep Battery Test"
if command in ("test.battery.stop", "calibrate.stop"):
self._test_until = self._deep_until = 0.0
self.on_battery = False
self._result_pending = False # an aborted test reports nothing, not a pass
return "OK Stop Test"
if command == "test.panel.start":
return "OK Start Panel Test"
if command == "beeper.enable":
self._beeper = True
self._beeper = "enabled"
return "OK Beeper enabled"
if command in ("beeper.disable", "beeper.mute"):
self._beeper = False
if command == "beeper.disable":
self._beeper = "disabled"
return "OK Beeper disabled"
if command == "beeper.mute":
self._beeper = "muted"
return "OK Beeper muted"
if command == "calibrate.start":
self._deep_until = now + 180.0
@@ -221,6 +251,10 @@ class NutSimulator:
raise KeyError(f"unknown simulated UPS {ups_name!r}")
def from_env(value: str | None) -> Any:
"""`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd)."""
def from_env(value: str | None) -> NutSimulator | None:
"""`NUT_SIMULATE=1` -> a simulator; anything else -> None (talk to real upsd).
The one place the enable rule lives: `app.py` builds the monitor with this, so the truthy
set cannot drift between the assembly and the model it swaps in.
"""
return NutSimulator() if (value or "").strip().lower() in ("1", "true", "yes", "on") else None
+115 -2
View File
@@ -471,5 +471,118 @@ function sparkline(container, points, opts) {
<path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.4"/>${marks}</svg>${foot}`;
}
window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, cssVar,
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };
/**
* An inline-SVG battery whose fill *is* the charge, for the `/ups` hero row.
*
* `battery(container, {charge, severity, state, label, decorative})`
* - `charge`: 0-100, or `null` when the driver does not publish it. Clamped to the scale,
* and **never rounded up to visible**: 2 % fills 2 % of the interior, because a
* reassuring full-looking battery above a flat cell is a lie (the same rule that makes
* an absent number print `n/a` instead of `0`).
* - `severity`: the payload verdict (`'ok'|'warn'|'fail'`, or a `{severity}` cell) → the
* fill and outline take that theme token through `cssVar()`. Anything else is *no
* verdict*: a muted outline, and a measured charge drawn faint and grey — a shape, not
* a judgement (`sparkline`'s unrated rule, applied to the one thing here that is a
* shape). A measured charge never renders as an empty shell; "0 %" and "not rated" are
* different sentences and only one of them is an alarm.
* - `state`: `'online' | 'onbattery' | 'charging' | 'unknown'` — the page derives it from
* `charger_status` + `on_battery` (see `tests/ups_contract.py`), never by re-parsing
* NUT flags here. Each state has a *shape*, so the glyph reads in monochrome and to a
* screen reader, not only in colour:
* online solid shell
* charging solid shell + a bolt over the plate
* onbattery **dashed** shell outline
* unknown no fill, a dash pattern **through** the body
* - `label`: names this battery ("apc") in the accessible name. No percent text is drawn
* inside the glyph: the hero number next to it already says it, and a card that prints
* one value twice is the pattern this app avoids everywhere else.
*
* Accessible **by default**: `role="img"` + `<title>` + `aria-label` built from the inputs
* ("apc: Charge 100 percent, on line"), which is the choice this component makes — the
* glyph carries information the markup does not repeat in words. A page that prints the
* same sentence beside it passes `decorative: true` to make the `<svg>` `aria-hidden`
* instead of saying it twice; the `<title>` stays for the hover tooltip.
*
* Scales with its container: one `viewBox` + `preserveAspectRatio`, `width:100%` and
* `height:auto` (the `.battery` rule in `dash.css` caps it), so the hero row gets a big
* cell on a desktop and a small one on a phone with no redraw and no ResizeObserver.
* No animation and no transition at all — the fill is re-drawn per render, and a bar that
* slides competes with the number it annotates, so `prefers-reduced-motion` holds by
* construction. Colours are resolved from the tokens at draw time, so a theme switch
* repaints with the next render (every page re-renders on its own poll).
*/
let batterySeq = 0;
const BATTERY_STATES = ['online', 'onbattery', 'charging', 'unknown'];
const BATTERY_STATE_LABELS = { online: 'on line', onbattery: 'on battery',
charging: 'charging', unknown: 'state unknown' };
function battery(container, opts) {
const el = typeof container === 'string' ? document.querySelector(container) : container;
if (!el) return null;
const o = Object.assign({ charge: null, severity: null, state: 'unknown', label: null,
decorative: false }, opts || {});
// Geometry in viewBox units, chosen so 1 unit of fill == 1 % of charge.
const W = 128;
const H = 56;
const shell = { x: 2, y: 8, w: 110, h: 40, r: 9, stroke: 3 };
const inner = { x: 7, y: 12, w: 100, h: 32, r: 4 };
const nub = { x: shell.x + shell.w + 3, y: 20, w: 9, h: 16, r: 3 };
const raw = Number(o.charge);
const measured = o.charge != null && isFinite(raw);
const pctValue = measured ? Math.max(0, Math.min(100, raw)) : null;
let sev = o.severity;
if (sev && typeof sev === 'object') sev = sev.severity;
const ink = sev === 'ok' ? cssVar('--ok')
: sev === 'warn' ? cssVar('--warn')
: sev === 'fail' ? cssVar('--fail') : null;
const stroke = ink || cssVar('--text-muted');
const state = BATTERY_STATES.indexOf(o.state) >= 0 ? o.state : 'unknown';
const clipId = `dash-battery-${(batterySeq += 1)}`;
const dashed = state === 'onbattery' ? ' stroke-dasharray="7 5"' : '';
let fillRect = '';
if (pctValue != null && pctValue > 0) {
const w = (pctValue / 100) * inner.w;
fillRect = `<rect x="${inner.x}" y="${inner.y}" width="${w.toFixed(2)}" height="${inner.h}"` +
` fill="${ink || cssVar('--text-faint')}"${ink ? '' : ' opacity=".5"'}/>`;
}
// A dash *through* the body: the cell exists, the reading does not.
const noReading = state === 'unknown'
? `<line x1="${inner.x + 5}" y1="${inner.y + inner.h / 2}" x2="${inner.x + inner.w - 5}"` +
` y2="${inner.y + inner.h / 2}" stroke="${cssVar('--text-faint')}" stroke-width="3"` +
` stroke-linecap="round" stroke-dasharray="5 6"/>`
: '';
const bolt = state === 'charging'
? `<path d="M13 2 L3 14 h7 l-1 8 L19 10 h-7 z" transform="translate(43.8 13.6) scale(1.2)"` +
` fill="${ink || cssVar('--text-muted')}" stroke="${cssVar('--bg-raised')}"` +
` stroke-width="1.6" stroke-linejoin="round"/>`
: '';
const chargePhrase = pctValue == null ? 'Charge unknown' : `Charge ${Math.round(pctValue)} percent`;
const statePhrase = BATTERY_STATE_LABELS[state];
const spoken = `${chargePhrase}, ${statePhrase}`;
const name = o.label ? `${o.label}: ${spoken}` : spoken;
// `decorative` is the opt-out described above; labelled is the default.
const a11y = o.decorative ? 'aria-hidden="true"'
: `role="img" aria-label="${escapeHtml(name)}"`;
el.classList.add('battery');
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"
preserveAspectRatio="xMidYMid meet" ${a11y}>
<title>${escapeHtml(name)}</title>
<defs><clipPath id="${clipId}"><rect x="${inner.x}" y="${inner.y}" width="${inner.w}"
height="${inner.h}" rx="${inner.r}"/></clipPath></defs>
<rect x="${shell.x}" y="${shell.y}" width="${shell.w}" height="${shell.h}" rx="${shell.r}"
fill="none" stroke="${stroke}" stroke-width="${shell.stroke}"${dashed}/>
<rect x="${nub.x}" y="${nub.y}" width="${nub.w}" height="${nub.h}" rx="${nub.r}" fill="${stroke}"/>
<g clip-path="url(#${clipId})">
<rect x="${inner.x}" y="${inner.y}" width="${inner.w}" height="${inner.h}" fill="${cssVar('--bg-sunken')}"/>
${fillRect}
</g>${noReading}${bolt}</svg>`;
return el;
}
window.DashCharts = { LineChart, niceTicks, renderLegend, sparkline, battery, cssVar,
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };
+59
View File
@@ -347,6 +347,65 @@ main { padding: 1.15rem 1.25rem 3rem; max-width: 1680px; margin: 0 auto; }
}
.hero .hero-lines .dir { color: var(--text-muted); }
/* ---- battery glyph -------------------------------------------------------
* `DashCharts.battery()` draws the whole cell in SVG — the charge fill, the charging bolt,
* the on-battery dashes, the "no reading" dash through the body — so the stylesheet owes
* only the box. One `viewBox` scaled to the container's width, capped so the hero row on a
* big monitor does not grow a metre-tall battery; `height: auto` is what preserves the
* aspect ratio (and with it the stroke width) instead of stretching the shell. No
* transition anywhere: the fill is re-drawn per render, and `prefers-reduced-motion`
* therefore holds by construction.
*/
.battery { display: block; width: 100%; max-width: 210px; }
.battery svg { display: block; width: 100%; height: auto; }
/* ---- UPS hero row -------------------------------------------------------
* Every UPS card opens with the battery glyph and three verdicts — charge, load, runtime
* left — because those are the three questions a UPS answers (TODO L7). The glyph gets a
* column of its own rather than floating above the numbers: it *is* the charge drawn as a
* shape, so it belongs beside the number it echoes — which is also why the charge hero
* itself carries no `.bar` (`page-ups.js`).
*
* The three numbers are the shared `.heroes` row at the shared 2.4rem, not a UPS-specific
* size: `n/a` and `100 %` and `1h 28m` have to be glanceable in the same way on all four
* pages. Its track floor is 140px rather than the `/system` row's 118px because runtime is
* prose, not a bare percent — measured in this stylesheet's own font, `9h 59m` is 138px and
* a `.hero` number cannot wrap, so the floor is what stops a track clipping it. Below the
* floor the row drops to 2 tracks and then 1, which is what keeps two cards side by side on
* a laptop instead of growing a taller one. The documented remainder: a runtime of 10 h or
* more (`10h 05m`, 161px — a UPS at a percent or two of load) reaches past its track into
* the row's right padding. It is the last slot in the row, so it never lands on another
* number, and `runtimeText()`'s prose is pinned by `tests/test_ups_contract.py` rather than
* shortened to make a column fit.
*
* Under 620px the glyph takes its own row: a 156px battery over three full-width numbers is
* still one glance, and 112px of cell next to three squeezed ones is not.
*/
.ups-hero {
display: grid; align-items: center; gap: .75rem 1rem;
grid-template-columns: minmax(112px, 156px) minmax(0, 1fr);
}
.ups-hero .heroes {
gap: .75rem .9rem;
grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr));
}
@media (max-width: 620px) {
.ups-hero { grid-template-columns: minmax(0, 1fr); }
.ups-hero .battery { max-width: 156px; }
}
/* The electrical row under the heroes: line in, output, battery volts and amps, battery °C.
* Still visible rather than folded into the disclosure, because they are the numbers you
* reach for *while* the UPS is on battery — but one step down and in muted ink, so they
* cannot compete with the three verdicts above them. `n/a` stays fainter than the readings
* (both real drivers here report no battery temperature), and a driver that publishes none
* of them leaves an empty grid that takes no space at all. */
.ups-electrical { gap: .5rem .9rem; }
.ups-electrical .metric .value.sm { font-size: .95rem; color: var(--text-muted); }
/* after the rule above on purpose: an unmeasured value is fainter than a muted reading, and
`.metric .value.is-null` alone is not specific enough to survive here */
.ups-electrical .metric .value.sm.is-null { font-size: .95rem; color: var(--text-faint); }
/* ---- bars, dots, chips -------------------------------------------------- */
.bar {
position: relative; height: 7px; border-radius: 999px; background: var(--bg-sunken);
+131 -25
View File
@@ -1,4 +1,6 @@
/* UPS dashboard: health assessment, NUT instant commands, history.
/* UPS dashboard: one card per battery — the glyph plus the charge / load / runtime heroes that
* answer "is my load safe right now" — and below them the health assessment, the NUT instant
* commands, and history.
*
* The health list is deliberately explicit checks rather than a score: every row names the
* variable it read and what it saw, because "health: 87" is not actionable. The backend
@@ -10,8 +12,7 @@
*/
'use strict';
const { $, fetchJSON, setTxt, setValue, setBar, setDot, num, duration, poll, severityFor,
eventText } = Dash;
const { $, fetchJSON, setTxt, setValue, setDot, num, duration, poll, eventText, naText } = Dash;
const state = { range: 86400, cards: new Map(), charts: {}, poller: null, pollMs: 5000,
simulated: false, pending: null };
@@ -21,9 +22,11 @@ const MARKS = { ok: '✓', warn: '!', fail: '✕', unknown: '?' };
/**
* Runtime left as "1h 32m", not "1.5h". The health check next to it spells the same number
* out in h+m; two formats for one value invites a misread while the UPS is discharging.
* A runtime nobody measured is `Dash.naText` (`n/a`), never `0s`: "no runtime" and "no
* estimate" are different sentences, and only one of them is a shutdown emergency.
*/
function runtimeText(seconds) {
if (seconds == null || !isFinite(seconds)) return 'n/a';
if (seconds == null || !isFinite(seconds)) return naText;
if (seconds < 0) return 'on battery';
const total = Math.round(seconds);
if (total < 120) return `${total}s`;
@@ -32,6 +35,87 @@ function runtimeText(seconds) {
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
}
/**
* The battery that opens the charge hero: `24 V nominal · 27.3 V`.
*
* The pack's *nominal* voltage is the spec sheet and the measured one is the health signal —
* 27.3 V on a 24 V pack means the float is doing its job, 22 V on line means it is not. One
* number without the other is trivia, and neither is a value the driver does not publish.
*/
function chargeSub(numbers) {
const bits = [];
if (numbers.battery_voltage_nominal != null) bits.push(`${num(numbers.battery_voltage_nominal, 0)} V nominal`);
if (numbers.battery_voltage != null) bits.push(`${num(numbers.battery_voltage, 1)} V`);
return bits.length ? bits.join(' · ') : null;
}
/**
* `128 W of 900 W` when the driver publishes both numbers, and nothing when it does not.
*
* `ups.load` is a percentage of a rating the UPS may not report (`battery.capacity`-only
* drivers exist), and a watt figure inferred from a percent and a *different* model's rating
* is exactly the invented number this app refuses to print.
*/
function loadSub(numbers) {
if (numbers.realpower == null || numbers.realpower_nominal == null) return null;
return `${num(numbers.realpower, 0)} W of ${num(numbers.realpower_nominal, 0)} W`;
}
/**
* The state the glyph draws, from fields the API already interpreted.
*
* The order matters, and NUT's flags are subtle, so each branch is spelled out. The parser
* for `ups.status` stays in `sources/nut.py` (`STATE_FLAGS` / `CHARGER_STATUS`) — this
* function reads its *outputs* (`on_battery`, the normalised `charger_status`, the rated
* `states[]`), never the raw flag string (`tests/test_ups_contract.py` pins that).
*
* - `on_battery` → `'onbattery'` (dashed shell). The server derives it from the online /
* on-battery flags and, with neither present, from a discharging charger status — so this
* one field already carries the two UPSes on this fleet, neither of whose drivers exports
* the NUT 2.8 `battery.charger.status` at all.
* - `'chg'` → `'charging'` (the bolt). `'dchg'` / `'rec'` are the server's own on-battery
* tokens, so they draw the same dashed shell when a driver reports one without a status
* the server could read.
* - `'full'` is **not** charging. A rested, floating, fully-charged battery reports it, and
* "charging" is the word an operator reads as "this battery was flat recently". It is the
* same verdict as being on line, and that is what the glyph shows.
* - the online family (`ol`, `ol trim`, `ol boost`) → `'online'`, and so does a state chip
* the API itself rated `ok`: `nut.states()` reserves `ok` for on-line and fully-charged,
* which is what a driver with no rich status still manages to say.
* - nothing known → `'unknown'`: a cell with a dash through it, never a confident "on line".
*/
function glyphState(item) {
if (item.on_battery) return 'onbattery';
const charger = item.charger_status;
if (charger === 'chg') return 'charging';
if (charger === 'dchg' || charger === 'rec') return 'onbattery';
if (charger === 'ol' || charger === 'ol trim' || charger === 'ol boost' || charger === 'full') {
return 'online';
}
return (item.states || []).some((state) => state.severity === 'ok') ? 'online' : 'unknown';
}
/**
* Draw the glyph, but only when something a viewer can see changed.
*
* `battery.charge` is a float that drifts by a tenth of a percent between polls, so
* repainting the `<svg>` every 5 s would rebuild the DOM, re-resolve the theme tokens and
* re-announce the accessible name for a difference nobody can see. The container remembers
* the triple it last drew. `decorative: true` because the charge hero beside it and the
* state chips above it already say "Charge 100 percent, on line" — a second copy in the
* accessibility tree is the duplicate the component's docstring warns about.
*/
function drawGlyph(cell, item) {
const numbers = item.numbers || {};
const state = glyphState(item);
const charge = numbers.charge_pct;
const severity = (item.severity || {}).charge_pct;
const drawn = `${charge === undefined ? '' : charge}|${severity || ''}|${state}`;
if (cell.dataset.drawn === drawn) return;
cell.dataset.drawn = drawn;
DashCharts.battery(cell, { charge, severity, state, label: item.name, decorative: true });
}
function upsCard(name) {
const card = document.createElement('article');
card.className = 'card';
@@ -48,15 +132,15 @@ function upsCard(name) {
</header>
<div class="body stack">
<div class="gap states"></div>
<div class="metrics">
<div class="metric"><div class="label">Charge</div>
<div class="value"><span data-k="charge">n/a</span><span class="unit">%</span></div>
<div class="bar" data-bar="charge"><i></i></div></div>
<div class="metric"><div class="label">Runtime left</div>
<div class="value sm" data-k="runtime">n/a</div></div>
<div class="metric"><div class="label">Load</div>
<div class="value sm"><span data-k="load">n/a</span><span class="unit">%</span></div>
<div class="bar" data-bar="load"><i></i></div></div>
<div class="ups-hero">
<div data-k="glyph"></div>
<div class="heroes">
<div class="hero" data-k="charge" data-sev="severity.charge_pct"></div>
<div class="hero hero-bar" data-k="load" data-sev="severity.load_pct"></div>
<div class="hero" data-k="runtime" data-sev="severity.battery_runtime"></div>
</div>
</div>
<div class="metrics ups-electrical">
<div class="metric"><div class="label">Line in</div>
<div class="value sm"><span data-k="line">n/a</span><span class="unit">V</span></div></div>
<div class="metric"><div class="label">Out</div>
@@ -116,19 +200,41 @@ function renderCard(card, item, stateRow) {
(item.alarms || []).map((alarm) => `<span class="chip warn">alarm: ${
DashCharts.escapeHtml(alarm)}</span>`).join('');
// The hero row. Every colour here is a verdict `severity.py` made and `payload()` sent;
// nothing below compares a number to a bound (a JS threshold could disagree with the
// overview's alert for the same value), and a missing number or a missing verdict prints
// `n/a` uncoloured, which is `Dash.hero()`'s contract.
const n = item.numbers || {};
const cell = (key) => q(key);
setValue(cell('charge'), n.charge_pct, 0);
setBar(card.querySelector('[data-bar="charge"]'), n.charge_pct,
n.charge_pct == null ? '' : n.charge_pct >= 95 ? 'ok' : n.charge_pct >= 60 ? 'warn' : 'fail');
setTxt(cell('runtime'), runtimeText(n.battery_runtime));
setValue(cell('load'), n.load_pct, 1);
setBar(card.querySelector('[data-bar="load"]'), n.load_pct, severityFor(n.load_pct, 70, 90));
setValue(cell('line'), n.line_voltage, 1);
setValue(cell('out'), n.output_voltage, 1);
setValue(cell('batt'), n.battery_voltage, 2);
setValue(cell('amps'), n.battery_current, 2);
setValue(cell('temp'), n.battery_temperature, 1);
const verdicts = item.severity || {};
drawGlyph(q('glyph'), item);
// Charge gets no `.bar`: the glyph beside it *is* this value drawn as a shape, and one
// value drawn twice in the same row is the pattern the rest of this app avoids.
Dash.hero(q('charge'), {
label: 'Charge', value: n.charge_pct, unit: '%', digits: 0,
severity: verdicts.charge_pct, sub: chargeSub(n),
});
Dash.hero(q('load'), {
label: 'Load', value: n.load_pct, unit: '%', digits: 1,
severity: verdicts.load_pct, bar: true, sub: loadSub(n),
});
// Runtime prose comes from `runtimeText()` and nowhere else. An estimate the driver never
// made goes in as `null` — not as the string "n/a", which `Dash.hero()` would render like
// a measurement instead of the faint, uncoloured unknown it is.
Dash.hero(q('runtime'), {
label: 'Runtime left', value: n.battery_runtime == null ? null : runtimeText(n.battery_runtime),
severity: verdicts.battery_runtime,
sub: n.battery_runtime == null ? null : 'at the current load',
});
// Secondary: the electrical readings. They stay visible rather than folded away because
// they are the numbers you reach for once the UPS *is* on battery — line in to see whether
// utility power really went, battery volts and amps to see what the pack is doing.
setValue(q('line'), n.line_voltage, 1);
setValue(q('out'), n.output_voltage, 1);
setValue(q('batt'), n.battery_voltage, 2);
setValue(q('amps'), n.battery_current, 2);
// `n/a`, never `0 °C`: neither UPS on this fleet reports a battery temperature, and a zero
// here would read as a cold, healthy pack (`AGENTS.md` invariant 2).
setValue(q('temp'), n.battery_temperature, 1);
const health = item.health || { checks: [] };
q('checks').innerHTML = (health.checks || []).map((check) => `