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:
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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 };
|
||||
Reference in New Issue
Block a user