Files
dashboards/src/dashboards/web/static/charts.js
T
ducoterra 34854d32af 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.
2026-10-17 10:27:23 -04:00

589 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* charts.js — hand-rolled SVG time-series charts. No CDN, no chart library.
*
* Coordinate space is real pixels (viewBox = the element's measured size, no
* preserveAspectRatio="none"), which is what keeps axis text crisp: a stretched viewBox
* smears every glyph drawn inside it. Redraw happens on ResizeObserver, not on a timer.
*
* Deliberate behaviours:
* - A series with no points draws nothing and the chart says why. It never draws 0,
* because "no data" and "zero" are different claims.
* - The y-range comes from the data actually plotted, and the labels are generated from
* the same numbers the path used, so a label can never drift from the curve.
* - Gaps are gaps: a sample missing in the middle breaks the line rather than drawing a
* straight lie across the hole.
*/
'use strict';
const CHART_COLORS = ['--series-1', '--series-2', '--series-3', '--series-4',
'--series-5', '--series-6', '--series-7', '--series-8'];
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/** Round a range to a "nice" tick step: 1, 2, 2.5, 5, 10 x 10^n. */
function niceTicks(min, max, count) {
if (!isFinite(min) || !isFinite(max) || min === max) {
const base = isFinite(min) ? min : 0;
return { lo: base - 1, hi: base + 1, step: 1, values: [base - 1, base, base + 1] };
}
const raw = (max - min) / Math.max(1, count);
const mag = Math.pow(10, Math.floor(Math.log10(Math.abs(raw) || 1)));
const norm = raw / mag;
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
const lo = Math.floor(min / step) * step;
const hi = Math.ceil(max / step) * step;
const values = [];
for (let v = lo; v <= hi + step * 0.5; v += step) values.push(Number(v.toFixed(10)));
return { lo, hi, step, values };
}
const DEFAULT_FORMAT = (v) => (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(1));
class LineChart {
/**
* container: element to fill (its width is used; height comes from opts.height)
* opts: {height, yFormat, xSpan, yMin, yMax, area, legend, showAxis, min, unit, ariaLabel}
*/
constructor(container, opts) {
this.el = typeof container === 'string' ? document.querySelector(container) : container;
this.opts = Object.assign({
height: 150, area: true, legend: true, showAxis: true,
yFormat: DEFAULT_FORMAT, xSpan: 3600, yMin: null, yMax: null, unit: '',
}, opts || {});
this.series = [];
this.hoverTs = null;
this._build();
if (typeof ResizeObserver !== 'undefined') {
this._ro = new ResizeObserver(() => this.draw());
this._ro.observe(this.el);
} else {
window.addEventListener('resize', () => this.draw());
}
}
_build() {
this.el.classList.add('chart');
this.el.style.height = this.opts.height + 'px';
this.el.innerHTML = '';
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('width', '100%');
this.svg.setAttribute('height', this.opts.height);
// A chart is an image to assistive tech, which means silence unless the page names it —
// the legend below is a sibling, not a label. `ariaLabel` is optional so pages adopt it
// one at a time (`/system` names every chart it draws; see page-system.js).
if (this.opts.ariaLabel) {
this.svg.setAttribute('role', 'img');
this.svg.setAttribute('aria-label', String(this.opts.ariaLabel));
}
this.el.appendChild(this.svg);
this.empty = document.createElement('div');
this.empty.className = 'empty';
this.el.appendChild(this.empty);
this.tooltip = document.createElement('div');
this.tooltip.className = 'tooltip';
this.el.appendChild(this.tooltip);
this.svg.addEventListener('mousemove', (e) => this._onMove(e));
this.svg.addEventListener('mouseleave', () => { this.hoverTs = null; this._hideTip(); this._drawCursor(); });
}
/** series: [{name, points: [{ts, value}], color?, yMax?}] — replaces previous data. */
setData(series) {
this.series = (series || []).map((s, i) => ({
name: s.name,
color: s.color || cssVar(CHART_COLORS[i % CHART_COLORS.length]),
points: (s.points || []).filter((p) => p && Number.isFinite(p.value) && Number.isFinite(p.ts))
.sort((a, b) => a.ts - b.ts),
}));
this.draw();
return this;
}
_layout() {
const w = Math.max(80, this.el.clientWidth);
const h = this.opts.height;
const pad = this.opts.showAxis ? { l: 44, r: 8, t: 8, b: 16 } : { l: 2, r: 2, t: 3, b: 3 };
return { w, h, pad, plotW: Math.max(10, w - pad.l - pad.r), plotH: Math.max(10, h - pad.t - pad.b) };
}
_xExtent() {
const span = this.opts.xSpan;
const end = Date.now() / 1000;
return [end - span, end];
}
_yExtent() {
if (this.opts.yMin !== null && this.opts.yMax !== null) {
return [this.opts.yMin, this.opts.yMax];
}
let lo = Infinity, hi = -Infinity;
for (const s of this.series) {
for (const p of s.points) {
if (p.value < lo) lo = p.value;
if (p.value > hi) hi = p.value;
}
}
if (!isFinite(lo)) return [0, 1];
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; return [lo - pad, hi + pad]; }
const margin = (hi - lo) * 0.12;
lo = this.opts.yMin !== null ? this.opts.yMin : lo - margin;
hi = this.opts.yMax !== null ? this.opts.yMax : hi + margin;
return [lo, hi];
}
draw() {
if (!this.el.clientWidth) return;
const plotted = this.series.filter((s) => s.points.length);
const { w, h, pad, plotW, plotH } = this._layout();
this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
this.svg.innerHTML = '';
if (!plotted.length) {
this.empty.textContent = 'no samples yet';
this.empty.style.display = 'grid';
return;
}
this.empty.style.display = 'none';
const [x0, x1] = this._xExtent();
const [ylo, yhi] = this._yExtent();
const ticks = niceTicks(ylo, yhi, 4);
this.scales = {
x: (ts) => pad.l + ((ts - x0) / (x1 - x0)) * plotW,
y: (v) => pad.t + plotH - ((Math.min(Math.max(v, ticks.lo), ticks.hi) - ticks.lo) / (ticks.hi - ticks.lo)) * plotH,
ticks, x0, x1, ylo: ticks.lo, yhi: ticks.hi,
};
const ns = 'http://www.w3.org/2000/svg';
const g = (name) => {
const grp = document.createElementNS(ns, 'g');
grp.setAttribute('class', name);
this.svg.appendChild(grp);
return grp;
};
if (this.opts.showAxis) {
const grid = g('grid');
for (const t of ticks.values) {
const y = this.scales.y(t);
if (y < pad.t - 1 || y > pad.t + plotH + 1) continue;
const line = document.createElementNS(ns, 'line');
line.setAttribute('x1', pad.l); line.setAttribute('x2', pad.l + plotW);
line.setAttribute('y1', y); line.setAttribute('y2', y);
grid.appendChild(line);
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', pad.l - 6); text.setAttribute('y', y + 3.5);
text.setAttribute('text-anchor', 'end');
text.textContent = this.opts.yFormat(t);
grid.appendChild(text);
}
const axis = g('axis');
for (let i = 0; i <= 4; i++) {
const ts = x0 + ((x1 - x0) * i) / 4;
const x = this.scales.x(ts);
const text = document.createElementNS(ns, 'text');
text.setAttribute('x', x); text.setAttribute('y', h - 3);
text.setAttribute('text-anchor', i === 0 ? 'start' : i === 4 ? 'end' : 'middle');
text.textContent = shortTime(ts, x1 - x0);
axis.appendChild(text);
}
}
for (const s of plotted) {
const paths = buildPath(s.points, this.scales, plotH + pad.t);
if (this.opts.area && paths.length) {
for (const seg of paths) {
const area = document.createElementNS(ns, 'path');
area.setAttribute('class', 'area');
area.setAttribute('d', seg.area);
area.setAttribute('fill', s.color);
this.svg.appendChild(area);
}
}
for (const seg of paths) {
const line = document.createElementNS(ns, 'path');
line.setAttribute('class', 'line');
line.setAttribute('d', seg.line);
line.setAttribute('stroke', s.color);
this.svg.appendChild(line);
}
}
const cursor = document.createElementNS(ns, 'g');
cursor.setAttribute('class', 'cursor');
this.svg.appendChild(cursor);
this.cursorEl = cursor;
this._drawCursor();
}
_drawCursor() {
if (!this.cursorEl || !this.scales || this.hoverTs == null) {
if (this.cursorEl) this.cursorEl.innerHTML = '';
return;
}
const { pad } = this._layout();
const x = this.scales.x(this.hoverTs);
this.cursorEl.innerHTML = '';
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', x); line.setAttribute('x2', x);
line.setAttribute('y1', pad.t); line.setAttribute('y2', this.opts.height - pad.b);
this.cursorEl.appendChild(line);
}
_onMove(event) {
if (!this.scales) return;
const rect = this.svg.getBoundingClientRect();
const box = (this.svg.getAttribute('viewBox') || '0 0 1 1').split(' ');
const px = ((event.clientX - rect.left) / rect.width) * Number(box[2]);
const { pad, plotW } = this._layout();
const { x0, x1 } = this.scales;
const frac = Math.min(1, Math.max(0, (px - pad.l) / plotW));
const at = x0 + frac * (x1 - x0);
this.hoverTs = at;
this._drawCursor();
this._showTip(at, event);
}
_showTip(at, event) {
const rows = this.series.map((s) => {
const near = nearest(s.points, at);
if (!near) return null;
return `<div class="row"><span class="swatch" style="background:${s.color}"></span>` +
`<span>${escapeHtml(s.name)}</span>` +
`<span class="val">${this.opts.yFormat(near.value)}${this.opts.unit ? ' ' + this.opts.unit : ''}</span></div>`;
}).filter(Boolean);
if (!rows.length) { this._hideTip(); return; }
this.tooltip.innerHTML = `<div class="ts">${fullTime(at)}</div>${rows.join('')}`;
this.tooltip.style.opacity = '1';
const rect = this.el.getBoundingClientRect();
const left = Math.min(rect.width - 150, Math.max(4, event.clientX - rect.left + 12));
this.tooltip.style.left = left + 'px';
this.tooltip.style.top = '8px';
}
_hideTip() { this.tooltip.style.opacity = '0'; }
}
/** Split points into contiguous runs; a gap > 3x the median step breaks the line. */
function buildPath(points, scales, baseline) {
if (!points.length) return [];
const steps = [];
for (let i = 1; i < points.length; i++) steps.push(points[i].ts - points[i - 1].ts);
steps.sort((a, b) => a - b);
const median = steps.length ? steps[Math.floor(steps.length / 2)] : 0;
const maxGap = median > 0 ? median * 3 : Infinity;
const runs = [];
let current = [points[0]];
for (let i = 1; i < points.length; i++) {
if (points[i].ts - points[i - 1].ts > maxGap) { runs.push(current); current = [points[i]]; }
else current.push(points[i]);
}
runs.push(current);
return runs.map((run) => {
let line = '';
run.forEach((p, i) => {
const x = scales.x(p.ts).toFixed(1);
const y = scales.y(p.value).toFixed(1);
line += (i === 0 ? `M${x} ${y}` : ` L${x} ${y}`);
});
const first = scales.x(run[0].ts).toFixed(1);
const last = scales.x(run[run.length - 1].ts).toFixed(1);
const area = `${line} L${last} ${baseline.toFixed(1)} L${first} ${baseline.toFixed(1)} Z`;
return { line, area };
});
}
function nearest(points, ts) {
if (!points.length) return null;
let lo = 0, hi = points.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (points[mid].ts < ts) lo = mid + 1; else hi = mid;
}
const a = points[Math.max(0, lo - 1)], b = points[lo];
return Math.abs(a.ts - ts) < Math.abs(b.ts - ts) ? a : b;
}
function shortTime(ts, span) {
const d = new Date(ts * 1000);
if (span > 86400 * 3) return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
if (span > 86400) return d.toLocaleDateString(undefined, { weekday: 'short' }) + ' ' +
d.toLocaleTimeString(undefined, { hour: '2-digit' });
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
function fullTime(ts) {
return new Date(ts * 1000).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
/** Legend with the live last value per series, under the chart. */
/**
* Legend with the live last value per series, under the chart.
*
* `showPeak` adds the window maximum too. Throughput is bursty: a legend that reads
* `0.0 tok/s` next to a chart that clearly went to 60 twice is read as a broken dashboard,
* even though "last poll was idle" is the truth. Saying both keeps it honest and legible.
*/
function renderLegend(container, series, format, showPeak) {
container.innerHTML = series.map((s, i) => {
const color = s.color || cssVar(CHART_COLORS[i % CHART_COLORS.length]);
const points = (s.points || []).filter((p) => Number.isFinite(p.value));
const last = points[points.length - 1];
let peakHtml = '';
if (showPeak && points.length) {
const peak = points.reduce((best, p) => (p.value > best.value ? p : best), points[0]);
if (peak.value !== last.value && peak.value > 0) {
peakHtml = `<span class="lv faint" title="highest value in the window">peak ${format(peak.value)}</span>`;
}
}
return `<span class="item"><span class="swatch" style="background:${color}"></span>` +
`<span>${escapeHtml(s.name)}</span>` +
`<span class="lv">${last ? format(last.value) : 'n/a'}</span>${peakHtml}</span>`;
}).join('');
}
/**
* Small non-interactive sparkline for cards where a full chart would shout.
*
* Same honesty rules as `LineChart`, at 160×34: fewer than `minPoints` points say
* "no history" (never a flat zero line, never a confident line through one point), and
* labels quote the data, not the padded axis — a flat window reports the one real value.
*
* opts (all optional; omitting them keeps the autoscaled, full-width line this function
* has always drawn):
* - height: plot height in px (default 34; the box is the point of a sparkline)
* - color: explicit stroke/fill colour; wins over `severity`
* - severity: payload verdict ('ok'|'warn'|'fail', or a `{severity}` cell) → stroke from
* that theme token (`cssVar('--ok')` etc.). Anything else — null, '', an
* unknown word — draws **no colour**: a faint grey stroke and no fill,
* because an unrated series is a shape, not a verdict (an unknown number
* must never look green)
* - min / max: explicit y bounds (today's behaviour)
* - minSpan: smallest y span an autoscaled axis may have, in the series' own units.
* For counts (sessions, queued) pass 1: a 1-minute rollup averages 8 seconds
* of one waiting request down to 0.14, and an axis that shrinks to fit turns
* that into a full-height spike — the twin of the rescale fixedMax fixes. An
* all-zero window still pads to the mid-line, so "nothing queued" reads flat
* - fixedMax: known ceiling for percent-like metrics — plot against [min ?? 0, fixedMax]
* instead of rescaling to the data, so an 8 % KV window that once held a
* 92 % spike reads the same as one that did not. Adds top/bottom scale marks
* so "the line is at a third" says a third *of what*; values clamp to the
* axis rather than drawing off-box
* - align: 'right' pins the newest sample to the right edge and grows the line
* leftwards as history fills in (llm_monitor's drawSpark). With `span` a
* partly-filled window occupies only the right of the plot — and x stays
* time, not sample index, so the shape keeps meaning *when*. Default 'left'
* spans the data domain (today's behaviour)
* - span: x window in seconds for align:'right' (default: the oldest sample)
* - labels: `{current, max}` → the small `now / max` foot drawSpark prints, formatted
* with `format`; `max` is the window maximum of the data, never the fixed
* axis
* - format: value → string for the labels and scale marks (default DEFAULT_FORMAT)
* - minPoints: finite points required before a line is drawn (default 2)
* - ariaLabel: names the spark for assistive tech; the generated fallback quotes values
*
* No animation and no CSS transition at all — `prefers-reduced-motion` is honoured by
* construction, and a moving squiggle competes with the number it annotates.
*/
function sparkline(container, points, opts) {
const o = Object.assign({
height: 34, color: null, severity: null, min: null, max: null, fixedMax: null,
minSpan: null, align: 'left', span: null, labels: null, format: DEFAULT_FORMAT,
minPoints: 2, ariaLabel: null,
}, opts || {});
const fmt = typeof o.format === 'function' ? o.format : DEFAULT_FORMAT;
const pts = (points || [])
.map((p, i) => (p && Number.isFinite(p.value)
? { ts: Number.isFinite(p.ts) ? p.ts : i, value: p.value }
: null))
.filter(Boolean)
.sort((a, b) => a.ts - b.ts);
const w = Math.max(60, container.clientWidth || 160);
const h = o.height;
container.innerHTML = '';
if (pts.length < Math.max(1, o.minPoints)) {
container.innerHTML = '<div class="faint mono" style="font-size:11px">no history</div>';
return;
}
const ys = pts.map((p) => p.value);
const dataMax = Math.max(...ys);
let lo, hi;
if (o.fixedMax != null) {
lo = o.min != null ? o.min : 0;
hi = Math.max(o.fixedMax, lo); // never an inverted axis
} else {
lo = o.min !== null ? o.min : Math.min(...ys);
hi = o.max !== null ? o.max : Math.max(...ys);
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; }
// the axis never gets finer than one thing (see `minSpan` above)
if (o.minSpan > 0 && hi - lo < o.minSpan) hi = lo + o.minSpan;
}
const first = pts[0];
const last = pts[pts.length - 1];
let x0 = first.ts;
const x1 = last.ts;
if (o.align === 'right' && o.span > 0) x0 = x1 - o.span;
// Marks live in a left gutter so they never sit on the line; a 160px box has the pixels.
const marksOn = o.fixedMax != null;
const padL = marksOn ? 24 : 1;
const inner = { l: padL, r: 1, t: 2, b: 2 };
const plotW = Math.max(10, w - inner.l - inner.r);
const plotH = Math.max(6, h - inner.t - inner.b);
const sx = (ts) => {
const span = (x1 - x0) || 1;
const clamped = Math.min(Math.max(ts, x0), x1);
return inner.l + ((clamped - x0) / span) * plotW;
};
const sy = (v) => {
const c = o.fixedMax != null ? Math.min(Math.max(v, lo), hi) : v;
return inner.t + plotH - ((c - lo) / ((hi - lo) || 1)) * plotH;
};
let sev = o.severity;
if (sev && typeof sev === 'object') sev = sev.severity;
const sevColor = sev === 'ok' ? cssVar('--ok')
: sev === 'warn' ? cssVar('--warn')
: sev === 'fail' ? cssVar('--fail') : null;
const stroke = o.color || sevColor || cssVar('--text-faint');
const filled = Boolean(o.color || sevColor);
const d = pts.map((p, i) => `${i ? 'L' : 'M'}${sx(p.ts).toFixed(1)} ${sy(p.value).toFixed(1)}`).join(' ');
const baseY = (inner.t + plotH).toFixed(1);
const area = `<path d="${d} L${sx(last.ts).toFixed(1)} ${baseY} L${sx(first.ts).toFixed(1)} ${baseY} Z" fill="${stroke}" opacity=".14"/>`;
const baseline = `<line x1="${inner.l}" x2="${(inner.l + plotW).toFixed(1)}" y1="${baseY}" y2="${baseY}" stroke="${stroke}" opacity=".22"/>`;
let marks = '';
if (marksOn) {
// top/bottom of the *axis* only — enough to read the line as a fraction of the ceiling
const mk = (v, y) => `<text x="2" y="${y}" font-size="8" fill="${cssVar('--text-faint')}" dominant-baseline="${y < h / 2 ? 'hanging' : 'alphabetic'}">${escapeHtml(fmt(v))}</text>`;
marks = mk(hi, inner.t + 7) + mk(lo, h - inner.b);
}
const aria = o.ariaLabel ||
`trend from ${fmt(first.value)} to ${fmt(last.value)}, max ${fmt(dataMax)}`;
let foot = '';
if (o.labels && (o.labels.current || o.labels.max)) {
const bits = [];
if (o.labels.current) bits.push(`now ${fmt(last.value)}`);
if (o.labels.max) bits.push(`max ${fmt(dataMax)}`);
foot = `<div class="faint mono" style="font-size:10px;line-height:1.25">${escapeHtml(bits.join(' / '))}</div>`;
}
container.innerHTML = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none"
role="img" aria-label="${escapeHtml(aria)}">${filled ? area : ''}${baseline}
<path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.4"/>${marks}</svg>${foot}`;
}
/**
* 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 };