# Ham Lab — MVP plan Proxmox-style web console for a bare-metal Debian 13 host. First target: `core` (10.0.0.234). MVP is **service + shell + persistent terminals**; everything else is a stub tab or a roadmap item. ## Decisions | Area | Decision | Why | |---|---|---| | Backend | **Go 1.24**, single static binary, frontend embedded via `embed` | `core` has ~370 MiB free RAM; a Go daemon idles at ~15 MiB vs ~80+ for Node. One file to deploy, no nvm path in the unit. Same binary later installs on `grid` unchanged. | | PTY | `creack/pty` — server-owned PTYs running `core`'s login shell | Sessions live in the daemon, not the browser, so refresh/tab-switch reattaches. No tmux dependency (not installed on `core`). | | Transport | One WebSocket per attached terminal; REST for everything else | Standard; NPM passes WebSockets with one toggle. | | Frontend | **React + TypeScript + Vite**, `xterm.js` (+ fit, webgl, web-links addons) | Matches `calorie-counter` / `wedding-website`, so no new toolchain to learn. | | Auth | Single-user **password login**, argon2id hash, HttpOnly/Secure/SameSite=Strict session cookie, login rate-limit, Origin check on WS upgrade | This is a root-capable shell on the public internet. Auth is MVP, not roadmap. | | Service | `hamlab.service`, `User=core`, `Restart=always`, binary at `/usr/local/bin/hamlab` | Same pattern as `calorie-counter.service`. Runs as `core` so `sudo` works in the terminal without a password, as on the SSH path. | | Listen | `10.0.0.234:8100` (free) | NPM is a container, so loopback-only won't do. Firewalling to NPM is a later hardening step. | | Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. | | Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. | | Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. | | Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. | ## Layout ``` ┌──────────────────────────────────────────────────────────────┐ │ header: Ham Lab · core · load 0.42 · ● connected · [logout] │ ├──────────────┬───────────────────────────────────────────────┤ │ Dashboard │ shell 1 ● │ backups │ + │ │ Processes ├───────────────────────────────────────────────┤ │ Terminal │ │ │ Services │ main content (xterm) │ │ Cron │ │ │ … │ │ ├──────────────┴───────────────────────────────────────────────┤ │ footer: v0.1.0 · up 12d · hamlab.bigham.dev │ └──────────────────────────────────────────────────────────────┘ ``` - Left rail: flat list of pages, active one highlighted. No nested leaves. - Terminal page: tab strip across the top of the pane, one tab per session, `● ` when a process is running, `+` pinned at the right edge. Active tab carries rename and close; double-click renames, middle-click closes. (Until 2026-09-18 the sessions were leaves under a collapsible Terminal group in the rail.) - Non-MVP groups render as disabled leaves with "coming soon" — the rail shape is the product; stubs keep it honest about scope. - **Mobile**: rail becomes a slide-in drawer; xterm fits the viewport; a sticky key bar above the keyboard (`Esc Tab Ctrl Alt ↑ ↓ ← → |`) because phone keyboards have none of those. This is what makes it usable as a PWA rather than a novelty. ## Terminal semantics - A session is a PTY + shell owned by the daemon, with an ID, name, cwd hint, and a **scrollback ring buffer** (256 KiB). Attaching replays the buffer, then streams. - Any number of browser tabs can attach to the same session (all get output; all can type). Resize follows the most recent attacher. - Switching tabs in the UI just detaches/attaches; the shell keeps running. - Closing a session sends `SIGHUP` to the process group and drops it. Shell exit closes the session automatically (leaf shows "exited" until dismissed). - Session names persist in `state.db` so the sidebar order survives a service restart. **The shells themselves do not survive a daemon restart** in the MVP — see roadmap (tmux backing). - Idle sessions cost one shell process each; no automatic reaping in MVP. ## API (MVP) ``` POST /api/login {password} → 204 + cookie POST /api/logout → 204 GET /api/me → {host, user, version, uptime, load} GET /api/terminals → [{id, name, running, created}] POST /api/terminals {name?} → {id, ...} PATCH /api/terminals/:id {name} → 204 DELETE /api/terminals/:id → 204 WS /api/terminals/:id/attach binary frames = raw bytes; JSON text frames = {resize} GET /healthz → 200 (unauthenticated, for NPM) ``` Everything under `/api` except `login` and `/healthz` requires the session cookie. ## Project layout ``` hamlab/ cmd/hamlab/main.go serve | passwd | version internal/ auth/ argon2id, sessions, rate limit term/ PTY manager, ring buffer, attach/detach api/ chi router, handlers, WS state/ SQLite (modernc, no cgo) web/ go:embed of web/dist web/ Vite + React + TS + xterm.js, PWA manifest + SW deploy/ hamlab.service install.sh build → /usr/local/bin/hamlab → systemctl restart Makefile build / dev / deploy / lint / test ``` ## Milestones **M0 — skeleton** (service boots, login works) - Go module, chi, config loading, `hamlab passwd`, argon2id login, session cookie. - `hamlab.service` on `core`, `:8100`, serves a placeholder page. - `make deploy` from `core` itself. **M1 — terminals** (the MVP feature) - PTY manager with ring buffer, create/list/rename/close, WS attach with resize. - React shell: header, rail, footer, Terminal group, xterm main pane. - Persistence of session list across refresh and rail switching. **M2 — polish + PWA + ingress** - Mobile drawer, key bar, fit-on-rotate. - `manifest.webmanifest`, icons (Ham logo on black, same recipe as HamTV), service worker caching the app shell only (never `/api`). - NPM proxy host for `hamlab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2. - Stub groups for Services / Cron / ZFS / Dashboard. **Done when:** from the phone, open the PWA, log in, open two terminals, run something long in one, switch away and back, refresh — it's all still there. ## Roadmap (not MVP — notes only) - **Updates tab** — **shipped 2026-09-17** (`internal/updates`, `internal/tasks`, `Updates.tsx`): upgradable packages with current → candidate, suite, security badge and description; index age, last upgrade (from `/var/log/apt/history.log`), unattended-upgrades on/off, reboot-required banner with the packages that caused it; Refresh index, Upgrade all (dist-upgrade), Upgrade selected; changelog per package on demand. Runs go through the new **tasks** package: background command, output kept, followed live over `/api/tasks/{id}/stream`, cancellable, listed at `/api/tasks` (the seed of a Task History tab). apt is fully supported; dnf/pacman list only. - **Power** — header ⏻ button (amber when a reboot is pending), dialog that requires the hostname typed back, then `systemctl reboot` / `poweroff`. `/api/me` carries `reboot_needed` so the header knows. - **tmux-backed sessions** so shells survive a daemon restart / redeploy. Would spawn `tmux new -A -s hamlab-`; needs `mouse on` and status-bar handling in xterm. - **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal lines (live-tail via WS), start/stop/restart/enable buttons, unit file viewer. - **Cron tab** — **shipped 2026-09-17** (`internal/cron`, `Cron.tsx`): one list built from `/etc/crontab`, `/etc/cron.d/*`, every user crontab (via sudo), the scripts in the `run-parts` directories those drive, plus systemd timers. Next run computed by an in-house cron-expression evaluator (tested: ranges, steps, names, dom-OR-dow rule, `@` aliases); last run from the journal's `(user) CMD (command)` lines (cron logs via syslog to journald; reading needs sudo/adm). Commented-out jobs show as disabled; job-shaped lines inside comment blocks (Debian's header examples) are ignored. Per job: Run now (background, as the job's user, `sh -c` with crontab `%` rules, output captured), Enable/Disable (comments the line in place, refuses if the file changed underneath), Edit source (textarea; validated line-by-line; user crontabs installed with `crontab -`, system files written via `sudo tee`), Cron log, and Output file when the command redirects to one. Timers: Run now (starts the activated service), start/stop/enable/disable, live logs of the service via the Services tab's tail. - **Disks tab** — **shipped 2026-09-17** (`internal/storage`, `Disks.tsx`), replacing the planned ZFS tab: block devices from `lsblk -J` (kind, model, size, temperature from the device's hwmon node, partitions with filesystem usage, live read/write/busy from the metrics sampler); SMART via `smartctl -j` when smartmontools is present (one button installs it as a task otherwise): health, temperature, power-on time, self-test status, ATA attribute table with the failure-predicting IDs flagged, NVMe endurance and error counters, short/long self-tests. Spinning disks are read with `-n standby` so the tab never wakes a sleeping drive; "Wake and read" is explicit. ZFS section when the tools and module are there: pools with health, capacity, fragmentation, last scan and vdev tree; scrub start/pause/stop; datasets tree with used/refer/in-snapshots/ snapshot count/compressratio/mount; per-dataset snapshot list and "Snapshot now". Verified against `grid` (raidz2 `tank`, 10 datasets, 515 snapshots, 8 WD40EZAZ + 970 EVO Plus SMART). Snapshot destroy and dataset property editing are deliberately not there yet. - **Dashboard** — **first cut shipped 2026-09-17** (`internal/metrics`, `Dashboard.tsx`): CPU (total + per core), memory/swap, load, uptime, hwmon temps and fans read straight from `/sys` (no lm-sensors), per-interface network rates, per-disk read/write/busy from `/proc/diskstats`, filesystem usage, UPS via `sudo -n pwrstat -status`. Sampled every 2 s into an in-memory ring of 10 min; the UI (the **Dashboard** tab since 2026-09-19) reads it all through the sensor API — see Sensors below. **Customizable**: a Customize mode reorders and hides modules (CPU, Memory, Load, Uptime, Temperatures, Fans, Network, Disks, Filesystems, UPS) and hides individual rows (an interface, a mount, a disk, a sensor); the layout is stored server-side in `state.json` via `GET/PUT /api/prefs/dashboard` so every device sees the same dashboard. The update interval (100 ms – 2 s) is part of the layout and drives both the browser's polling and the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms and 10 min at 2 s. Still to do: persistent history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges. - **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`, `internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`; reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID; catalog, single value, history, a **shaped query** (the client sends the object it wants and gets it back filled in — GraphQL's idea without GraphQL, since sensors differ per machine and a static schema would collapse to `sensor(id:)`), the same query as a live SSE stream, Prometheus `/metrics`, read-only `sensors:read` tokens (never full tokens in a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets on the Dashboard tab (briefly renamed "Monitors" 2026-09-19, back to "Dashboard" 2026-09-20 at Mitchell's request; the view id and URL were always `dashboard`), and a frameable `/embed/widget` for Glance and friends. (An external demo page was deployed at `hamlab-demo.bigham.dev` and linked from the Widgets tab 2026-09-19; **both removed 2026-09-23** at Mitchell's request — the Widgets tab shows the same thing against the machine you are signed in to, so the second copy earned nothing and cost a public read-only token, which was revoked with it. `examples/external-demo/` stays as an example to read.) The stock Dashboard modules and the Disks tab read the sensor API too (`sampleAdapter.ts`); `/api/metrics` is retired. Also per-service and per-container sensors (memory and CPU from cgroups), tokens limited to ID patterns, and `/api/fleet/sensors/query` for the whole fleet in one request. - **Alerts** — **shipped 2026-09-19** (`internal/api/alerts.go`, `Alerts.tsx`): rules over sensors (`container.*.running == false for 120 s`), evaluated by the hub every 15 s for itself and every managed machine in one shaped query each; `hamlab.reachable` is supplied by the hub so "a machine stopped answering" is a rule like any other. Ten defaults (machine offline, container down/unhealthy, failed service, disk full, pool unhealthy, SMART failing, UPS on battery, CPU hot, memory). Firing and resolved are events: kept (last 200, in `state.json`), shown on the hub-only Alerts tab with a count on the rail, and POSTed to channels — **ntfy** (title/priority headers) or a **webhook** (JSON; Home Assistant, Slack). The rule editor shows what a selector matches across the fleet as you type. Not done: quiet hours, per-rule channels, Web Push straight to the PWA. - **Docker tab** — **shipped 2026-09-19** (`internal/api/docker.go`, `Docker.tsx`): containers grouped by compose stack with live memory and CPU from the container sensors, start / stop / restart (asked first), logs that follow (polled with `--since`, no held connection), inspect (env *names* only), and images / volumes / networks tables. Works on every machine through the hub; `#/grid/docker/immich_server` links to one. **Stacks** (`internal/api/stacks.go`): every compose project, found from its containers' labels, with its file to edit and Deploy (`up -d`), Update (pull, then deploy), Restart and Stop as followed tasks. The file is looked for where the label says *and through every container's mounts*, because an orchestrator that runs compose inside its own container (Dockhand here; Dockge, Portainer) records a path in its own filesystem; compose is then run **inside that container**, so `.env`, relative paths and the compose labels stay exactly as deployed (verified: labels identical after a redeploy). Save validates with `compose config` first, keeps the previous file as `.hamlab-bak`, and never deploys by itself. A stack deployed from another host (Dockhand on `core` driving `grid` over tcp) has no file on that machine, so the node alone lists it read-only — but **the hub fills that in** (`hubStacks`): asked about a node, it resolves the files on itself and runs compose here with `DOCKER_HOST` pointed at the node's `tcp://…:2375`, the way it was deployed; the task runs on the hub and the node's page follows it. Also per stack: every file it was deployed from (chips), its **`.env`** (values masked until "Reveal and edit"; validated in place, put back if compose objects), **what Deploy would change** (a line diff against the copy kept at the last deploy from here, `.hamlab-deployed`, else the copy the last save replaced; `.env` values never shown), and **image update checks** — asked for, never polled, since each is a registry request Docker Hub counts; remembered six hours. A save refuses a file changed on disk meanwhile. **Making and unmaking (2026-09-20):** "+ New stack" — a name, a folder, compose text and an optional `.env` (written root-only), checked by compose before it is kept, then "Create and deploy" or "Create only". "+ New container" is the same dialog opened on a form (image, ports, volumes, environment, restart, command, host networking) that writes the compose for you: a container made here is a one-service stack, so it stays editable and redeployable, where a bare `docker run` could only be thrown away and typed again. New stacks go in a **home** — a folder where two or more stacks are already deployed from (Dockhand's `stacks/core` here; `~/stacks` on a machine with none) — and are deployed the way their neighbours were (inside `dockhand`, or over tcp to a node from the hub). Homes are also scanned, so a stack with no containers ("not deployed": never deployed, or taken down) is still listed. **Down** removes a stack's containers; **Delete…** (type its name) takes it down and moves its own files to `/.hamlab-trash/-