# Homelab Dashboards — Master Plan > Master design document for a phased-execution project. The phase roadmap below was > derived from `TODO.md` on 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.md` definition of done) — a screenshot in `data/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}; `metric` is dotted with `@stream` for 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 card `status`, `states[]`, `on_battery`, `numbers` (`charge_pct`, `load_pct`, `battery_runtime`, `battery_voltage`, `line_voltage`, `battery_temperature`), `health.{overall,severity,checks[],counts}`, `commands[]` (each with `enabled`, `reason`, `confirm_text`, `risk`), `simulated` at the top level. Real fleet today: `apc` (APC Back-UPS RS 1500MS) and `cyberpower` (CyberPower PR1500LCDRT2U) on driveripper's upsd at port **3051**, forwarded to `127.0.0.1:3494` in dev. Neither driver reports a battery temperature; `battery.mfr.date` is the only date that means anything. - `/api/summary` returns `{ts, system:{enabled,fleet,hosts[],states}, llm:{...}, ups:{...}, recent_events[]}` — phase 02 adds `alerts` and the overview page shrinks to fleet+alerts. ## Validation / Verification Workflow Per task, in order: 1. `uv run pytest` — hermetic, no network; must stay green (baseline at conversion: **258 passed, 6 skipped, 94% coverage**). 2. `uv run pytest --cov` — coverage on new/modified Python **>90%**; overall must not drop below 94%. 3. `uv run ruff check src tests agent && uv run pyright` — clean. 4. `uv pip list --outdated` — empty whenever dependencies moved. 5. `uv run pytest -m live --live` (with `.env.dev` sourced) — 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. 6. Browser verification of every touched page with live data, screenshot saved to `data/logs/` (`./scripts/dev-server.sh restart` keeps http://127.0.0.1:8090 up). 7. Commit per task with `--no-gpg-sign` and the `LLM-Generated: true` trailer. ## 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.