Files
dashboards/.agents/PLAN.md
T
ducoterra 4b4962d7fb chore(agent): phase roadmap from TODO.md, 5 phases
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
2026-10-16 20:10:23 -04:00

9.5 KiB

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.