**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.
589 lines
27 KiB
JavaScript
589 lines
27 KiB
JavaScript
/* 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) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||
}[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 };
|