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
130 lines
9.5 KiB
Markdown
130 lines
9.5 KiB
Markdown
# 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.
|