# 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 `lab.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. | | Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. | | 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. | | Capability, not policy | **A tab offers what the selected machine can do, detected live — never what a rule says should run there.** Where the software is missing but installable, Ham Lab installs it; where it cannot be installed or the hardware lacks it, the tab says so in a sentence and offers what does work. Every action lands on the machine in the switcher. | Mitchell, 2026-10-09, after the Virtualization plan's first draft excluded the Mac mini and `hamtv` on the strength of `hamlet/CLAUDE.md`'s deployment rules: *"I want the option to do it anywhere I want, unless the hardware / software literally doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on any Linux system."* Those rules bind the agent's unasked deployments, not the product. | | Ham Lab's data lives in Ham Lab's folder | **Everything a tab manages sits under the machine's data directory (`config.DataDir()`), never at a convenient path elsewhere.** Existing material is imported by copying. The engine's runtime state (`/var/lib/docker`, `/var/lib/incus`) is the engine's; Ham Lab keeps what would rebuild it. | Mitchell, 2026-10-09, when the Virtualization plan pointed its ISO library at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it manages its own data."* The same reason `core`'s compose files moved under `apps/hamlab/stacks/` on 2026-10-05. `grid`'s and the Mac's stacks at `~/stacks//` were the standing exception; since 2026-10-10 the stacks code offers `/stacks` as the default home everywhere and such folders only as discovered extras. | | No virtualization | **Ham Lab does not manage VMs or system containers** — no Incus, no QEMU, no passthrough, no VM consoles. It is a lightweight console; Docker is its one container engine. | Mitchell, 2026-10-15: *"Virtualization is out of scope for HamLab. This is supposed to be a lightweight application, and VMs and pass through and stuff is too much."* The tab built 2026-10-09 (Incus, phases 1–2, deployed as v0.1.0-196) was cut from `main` the same day and kept on the **`virtualization` branch** (its last commit is `main` as of `a2674be`, with `VIRTUALIZATION.md` and the vendored spice-html5). Don't rebuild it on `main`. | ## 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 · lab.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 `lab.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. - **Updating from the Updates tab** — **shipped 2026-09-26**: the "what is out of date" list was read-only, so seeing a newer image and doing something about it were different places. Each row now has **Update**, and the header has **Update all**. Updating is per stack — that is the unit compose deals in — so a row acts on whichever stacks use that image, and several stacks run through a queue one at a time rather than recreating half the machine at once, each with its task output on screen. A row that succeeds says "updated just now" instead of re-checking, since a check is a registry request. - **Recreate, and the Updates tab at full width** (2026-09-26): a stack's actions gained **Recreate** (`up -d --force-recreate`), the only way out of a container Docker refuses to start — one pinned to a network that has since been recreated answers "network … not found" to Deploy and Update alike, because as far as compose is concerned nothing about it changed. Found the hard way when a SiYuan update landed in exactly that state. The Updates tab also stopped rendering as a list-and-detail pair: it is a page, not a list, so on a wide window it now sits under the tab strip at full width instead of being squeezed into the right-hand half beside an empty column. - **Cron in English** (`crontext.ts`, 2026-09-26): schedules read "daily at 3:15am" rather than `15 3 * * *`, with the expression kept beside it. Handles lists, ranges, steps, day and month names, `@daily` and friends, weekdays/weekends, midnight and noon — and declines to describe anything it is not sure of, because a confident wrong sentence is worse than five fields. The Jobs tab had its own smaller version of this; it now uses this one. - **Hardware tab** — **shipped 2026-09-25** (`internal/hardware`, `internal/api/hardware.go`, `Hardware.tsx`): what the machine is physically made of, per machine like every other page. Four headline tiles (processor, memory, storage, graphics) over cards for each: the processor with its cores, clock range, socket, caches, microcode and the handful of feature flags anyone asks about; **every memory slot** with size, type, form factor, rated and running speed, maker, part number, serial, rank and voltage, and the empty ones marked empty; each drive with model, size, bus, firmware and serial; each display adapter with its driver, PCI address and video memory; and the board itself — model, firmware and date, serial, kernel. Read from what Debian already has (dmidecode for what only the firmware knows, lscpu, lspci, sysfs) and cached for ten minutes, since none of it changes under a running kernel; "Re-read" is there for the one thing that does, a drive plugged in. Every reader may come back empty and the page says what it could not read and why: a machine with no dmidecode, no root, or no PCI bus still gets a page. Verified on `core` (SMBIOS, Intel graphics) and on the Mac mini (Asahi: no SMBIOS memory table, a device-tree GPU, heterogeneous cores — where lscpu's "cores per socket" counts one cluster, so without SMT the thread count is the honest core count). - **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 whose file is not on the machine that runs it is listed read-only, and that is the end of it: **since 2026-10-10 the hub holds no files for a node and never points compose at another machine's Docker.** The `hubStacks` path — which resolved a node's files on the hub and deployed over `tcp://…:2375`, the way Dockhand had — is gone, and every `/api/nodes//docker/…` request is the node's own Ham Lab's to answer through the generic proxy. 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**. Since 2026-10-10 the default is Ham Lab's own `/stacks`, offered first on every machine; a folder where two or more stacks are already deployed from is offered as well, and a stack made there is deployed the way its neighbours were (inside an orchestrator's container, when that is how they run). 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/-