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:
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => `
|
||||
|
||||
Reference in New Issue
Block a user