The five UI items in TODO.md become an executable pipeline. Planning files only — no application code changed. - 01_severity_language: one Python threshold table + `severity` on the values the pages colour, plus the .hero style. Today the thresholds are magic numbers at six JS call sites (75/90 here, 80/92 there), which is exactly how "same colour, four meanings" happens. - 02_overview_fleet_and_alerts: /api/summary gains a derived `alerts` list; the page drops to fleet + warnings. Unconfigured sources must not alert — that is invariant 3, tested. - 03_system_per_host_panels: per-host hero cards, no per-core, shared multi-host charts and the detail picker deleted. The six card metrics already exist in the payload, so the first task pins them with tests instead of inventing fields. - 04_llm_chart_readability: charts.js already exports sparkline() and no page calls it — that is why the page reads flat. Port llm_monitor's drawSpark behaviour (fixed max, right growth, p95 foots) and keep the provenance tags. - 05_ups_hero_battery_glyph: charge/load/runtime hero + an SVG battery whose fill is the charge, verified against both real UPSes and the low-charge/on-battery states only the simulator can produce. PLAN.md records the ratified anchors and the deviation worth knowing: severity vocabulary is ok|warn|fail, matching dash.css and nut.py, not the "danger" wording in the proposal — one synonym for one colour. TODO.md cleared to a bare heading; the items are now phases. .gitignore stops ignoring all of .agents/ (the roadmap is project history) while keeping phase-sessions/ and pipeline.log out. deploy/nut-sudoers rewritten: its header still blamed a port mystery that is solved; the remaining need is reading upsd.users so adding a scoped dashboards user does not delete the accounts upsmon authenticates as. LLM-Generated: true
9.5 KiB
Homelab Dashboards — Master Plan
Master design document for a phased-execution project. The phase roadmap below was derived from
TODO.mdon 2026-10-16; each phase overview and task file cites its source lines in that file. The LOCKED DECISIONS below are binding: no phase may introduce technology outside them without explicit user permission.
AGENTS.md(repo root) is the sibling document: the invariants, the fleet facts, and the known gaps. It is not superseded by this file — read both.
Assumptions & Design Principles
- The dashboard's job is a fast, honest glance. Every TODO item is a legibility complaint, not a feature request: the same truth with less ink and clearer colour.
- One colour language. Green/amber/red means exactly one thing across all four pages, computed server-side in Python so it is unit-tested and cannot drift per page.
- Fewer numbers, larger. Removing a metric from a page is a feature, but only when the history stays in the DB. Pages stop plotting what the operator stopped asking for; the stored series are kept so the decision is reversible.
- The existing pages are payload-driven and stay that way. No page scrapes HTML, no page grows its own business logic. If a card needs a judgement ("is 5.3 years old bad?"), the API supplies it.
- Charts stay hand-rolled SVG.
llm_monitor's per-cell sparklines are the reference for "useful and readable"; they are inline SVG with no library, which is exactly the house style, so they port rather than get replaced. - Every UI change is verified in a browser against live data (
AGENTS.mddefinition of done) — a screenshot indata/logs/, not a claim.
Architectural Anchors
| COMPONENT | DECISION | RATIONALE | STATUS |
|---|---|---|---|
| Backend | FastAPI, single process, one domain, four routes (/, /system, /llm, /ups) |
Inherited from the existing code | LOCKED |
| Frontend | Vanilla JS + one stylesheet. No framework, no CDN, no build step, nothing fetched at runtime | The engines live on a network with no outbound access; a CDN reference is a dead request | LOCKED |
| Charts | Hand-rolled SVG in web/static/charts.js |
Same reason as above; llm_monitor proved it works for a year |
LOCKED |
| History | SQLite (sample(source,host,metric,res,ts) + event), raw → 1m → 1h rollups |
Inherited; bounded, no TSDB to operate | LOCKED |
| Writes | Read-only except POST /api/ups/command, gated four times |
Invariant 1 of AGENTS.md |
LOCKED |
| API shape | Additive only: existing /api/* fields keep their names and meaning |
The four pages, the live tests, and future consumers depend on them | LOCKED (ratified 2026-10-16) |
| Severity | New src/dashboards/severity.py is the only place thresholds live; API responses carry severity per value; CSS/JS only render the class |
Testable once, identical on every page; JS business logic is untested by definition | LOCKED (ratified 2026-10-16) |
| JS tests | None added. Coverage gate (>90%) applies to new/modified Python; UI is proven by payload-contract tests plus browser screenshots | Adding a JS toolchain would violate the no-build-step anchor for no extra confidence in the colour logic | LOCKED (ratified 2026-10-16) |
| Dropped visuals | Per-core CPU charts and shared multi-host charts are removed from /system; the underlying series stay in SQLite |
Explicit user request; reversible because nothing is deleted from history | LOCKED (ratified 2026-10-16) |
| Toolchain | Python 3.14, pytest, ruff, pyright, uv |
Inherited; the lock resolves for 3.14 only | LOCKED |
| Planning tree | .agents/ is tracked and committed; .agents/phase-sessions/ and .agents/pipeline.log are ignored |
The roadmap is project history, not scratch | LOCKED (ratified 2026-10-16) |
High-Level Architecture
agent/host_agent.py (each host) gufo / vLLM upsd (NUT, port 3051)
│ GET /snapshot │ GET /metrics │ LIST UPS / LIST VAR / LIST CMD
▼ ▼ ▼
collectors.py ─── fetch, one UpstreamError type ────────────────────────────────┐
│ parse │
▼ ▼
sources/host.py · sources/llm.py · sources/nut.py poller.py (task per source)
│ Result(payload, samples, events) ◄── NEW: severity.py classifies values
▼ ▼
store.py ── SQLite (WAL): sample + event ──────────────────────────► api.py ──► web/
/api/summary is the
overview's one call
Pages and their scripts (src/dashboards/web/):
| Page | HTML | Script | Charts it builds today |
|---|---|---|---|
/ |
pages/index.html |
page-index.js |
none (three summary cards + fleet table + events) |
/system |
pages/system.html |
page-system.js |
#chart-cpu, #chart-mem, #chart-disk, #chart-net, #chart-gpu, #chart-temp, #chart-detail — all shared, multi-host |
/llm |
pages/llm.html |
page-llm.js |
#chart-decode, #chart-prompt, #chart-kv, #chart-queue |
/ups |
pages/ups.html |
page-ups.js |
battery charge, runtime estimate, voltages, load |
Shared helpers: app.js (nav, poll loop, fetchJSON, formatting), charts.js
(DashCharts.LineChart, escape/axis helpers), dash.css (tokens, cards, bars).
Data Model
sample(source, host, metric, res, ts)—source∈ {host, llm, ups};res∈ {raw, 1m, 1h};metricis dotted with@streamfor per-device series (cpu.usage_pct@core7,disk.used_pct@/srv,gpu.busy_pct@0). The metric namespace is a public interface: phases may stop plotting a series, they may not rename or stop storing it.event(ts, source, kind, severity, message)— transitions only, which is what the overview's alert list will reuse.- Host payload (
/api/system/snapshot, per host):cpu.{usage_pct,temp_c,load1},memory.used_pct,disks.{worst_used_pct,worst_mount,util_pct},network.{rx_bytes_per_s,tx_bytes_per_s,interfaces[]},gpus[].{busy_pct,vram_used_bytes,vram_total_bytes,vram_used_pct,mem_used_pct,temp_c},temperatures.{cpu_c,max_c},processes,agent.errors. Everything the per-host cards ask for already exists — phase 03 tests the contract rather than inventing fields. - UPS payload (
/api/ups/snapshot): per cardstatus,states[],on_battery,numbers(charge_pct,load_pct,battery_runtime,battery_voltage,line_voltage,battery_temperature),health.{overall,severity,checks[],counts},commands[](each withenabled,reason,confirm_text,risk),simulatedat the top level. Real fleet today:apc(APC Back-UPS RS 1500MS) andcyberpower(CyberPower PR1500LCDRT2U) on driveripper's upsd at port 3051, forwarded to127.0.0.1:3494in dev. Neither driver reports a battery temperature;battery.mfr.dateis the only date that means anything. /api/summaryreturns{ts, system:{enabled,fleet,hosts[],states}, llm:{...}, ups:{...}, recent_events[]}— phase 02 addsalertsand the overview page shrinks to fleet+alerts.
Validation / Verification Workflow
Per task, in order:
uv run pytest— hermetic, no network; must stay green (baseline at conversion: 258 passed, 6 skipped, 94% coverage).uv run pytest --cov— coverage on new/modified Python >90%; overall must not drop below 94%.uv run ruff check src tests agent && uv run pyright— clean.uv pip list --outdated— empty whenever dependencies moved.uv run pytest -m live --live(with.env.devsourced) — the opt-in suite that drives the production code paths against the real fleet, both engines, and real upsd. Baseline: 6 passed. UI phases must not break it.- Browser verification of every touched page with live data, screenshot saved to
data/logs/(./scripts/dev-server.sh restartkeeps http://127.0.0.1:8090 up). - Commit per task with
--no-gpg-signand theLLM-Generated: truetrailer.
Phase Roadmap
| # | Phase | TODO source | Objective (one line) |
|---|---|---|---|
| 01 | 01_severity_language |
TODO.md L5, L7 (colour-coding clauses) |
One server-side threshold table + severity on every value the pages colour, plus the CSS/JS vocabulary to render it |
| 02 | 02_overview_fleet_and_alerts |
TODO.md L3 |
Overview is fleet + warnings/errors and nothing else |
| 03 | 03_system_per_host_panels |
TODO.md L4, L5 |
/system is per-host hero cards (cpu/mem/temp/disk/gpu/net, no per-core) with per-host charts, no shared charts |
| 04 | 04_llm_chart_readability |
TODO.md L6 |
LLM page readable again: per-cell sparklines and percentiles in the llm_monitor idiom, big colour-coded rates |
| 05 | 05_ups_hero_battery_glyph |
TODO.md L7 |
Charge / load / runtime big and colour-coded, with an SVG battery that shows charge |
There is deliberately no foundation phase: pytest, ruff and pyright are configured, the suite is green, and the project launches, which are the only conditions that would have required one.