Add homelab dashboards: system, LLM, and UPS on one domain

Three dashboards served by one FastAPI app with a SQLite history store
(raw / 1m / 1h) and hand-written SVG charts: no Prometheus, no Grafana,
no CDN, no build step. Nothing is fetched from the internet at runtime.

* system: a stdlib-only agent (agent/host_agent.py) reports /proc and
  /sys counters; the dashboard turns them into rates over a labelled
  window, so every number says how it was measured.
* llm: gufo/llama.cpp and vLLM /metrics are parsed by an in-house
  exposition parser. Measured delta/elapsed throughput beats a native
  per-request gauge, and each value carries its provenance.
* ups: a NUT wire-protocol client plus health checks that name what
  they looked at. Commands are gated by NUT_ALLOW_ACTIONS,
  DASH_READ_ONLY and a DANGER confirmation, re-checked server-side.

Missing values render as n/a rather than 0, and an unconfigured source
answers 404 {"configured": false} without dialling anything, so "not
deployed here" never looks like an outage.

253 tests at 94% coverage against captured real fixtures; ruff and
pyright clean.

LLM-Generated: true
This commit is contained in:
2026-10-16 17:20:45 -04:00
commit c4bb374ccc
56 changed files with 13018 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
/* 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}
*/
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);
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('');
}
/** Column heat bars for per-core usage: 48 cores read as a pattern, not a table. */
function renderCores(container, values, format) {
const f = format || ((v) => (v == null ? 'n/a' : v.toFixed(0) + '%'));
container.innerHTML = (values || []).map((v, i) => {
const pct = v == null ? 0 : Math.max(0, Math.min(100, v));
const hue = pct > 85 ? 'var(--fail)' : pct > 60 ? 'var(--warn)' : 'var(--accent)';
return `<span class="core" title="core ${i}: ${escapeHtml(f(v))}">` +
`<i style="height:${pct.toFixed(1)}%;background:${hue}"></i></span>`;
}).join('');
}
/** Small non-interactive sparkline for cards where a full chart would shout. */
function sparkline(container, points, opts) {
const o = Object.assign({ height: 34, color: cssVar('--series-1'), min: null, max: null }, opts || {});
const pts = (points || []).filter((p) => p && Number.isFinite(p.value));
const w = Math.max(60, container.clientWidth || 160);
const h = o.height;
container.innerHTML = '';
if (pts.length < 2) {
container.innerHTML = '<div class="faint mono" style="font-size:11px">no history</div>';
return;
}
const xs = pts.map((p) => p.ts), ys = pts.map((p) => p.value);
const x0 = Math.min(...xs), x1 = Math.max(...xs);
let lo = o.min !== null ? o.min : Math.min(...ys);
let hi = o.max !== null ? o.max : Math.max(...ys);
if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; }
const sx = (ts) => ((ts - x0) / (x1 - x0 || 1)) * (w - 2) + 1;
const sy = (v) => h - 2 - ((v - lo) / (hi - lo)) * (h - 4);
const d = pts.map((p, i) => `${i ? 'L' : 'M'}${sx(p.ts).toFixed(1)} ${sy(p.value).toFixed(1)}`).join(' ');
const svg = `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="none"
role="img" aria-label="trend from ${lo.toFixed(2)} to ${hi.toFixed(2)}">
<path d="${d} L${sx(x1).toFixed(1)} ${h} L${sx(x0).toFixed(1)} ${h} Z" fill="${o.color}" opacity=".14"/>
<path d="${d}" fill="none" stroke="${o.color}" stroke-width="1.4"/></svg>`;
container.innerHTML = svg;
}
window.DashCharts = { LineChart, niceTicks, renderLegend, renderCores, sparkline, cssVar,
CHART_COLORS, DEFAULT_FORMAT, escapeHtml };