Commit Graph
100 Commits
Author SHA1 Message Date
Mitchell-JohnsonandClaude Fable 5.1 e198471f8c Files previews: the cache is one database file, <data dir>/thumbs.db
bbolt instead of a tree of hash-named JPEGs: pure Go, so the binary stays static; one
file per machine; an entry carries where the preview came from (engine, codec, size,
when) beside the picture, and a count rides along so the tab can say what it holds
without walking anything. A miss never creates the file, so a machine nobody browses
with previews on never has one; a cache that cannot be opened is logged and previews
are still made and served, just not kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:04:12 -07:00
Mitchell-JohnsonandClaude Fable 5.1 51c718e8e3 PLAN.md: previews on the Files tab, shipped as v0.1.0-200
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 15:25:55 -07:00
Mitchell-JohnsonandClaude Fable 5.1 f8c9479e93 Files: previews — a toggle that shows a thumbnail per image and video, made by ffmpeg on the machine's GPU where it has one, cached under the data dir
A previews button on the Files tab turns the icon column into a 256 px thumbnail for
every image and video row, loaded as rows scroll near. internal/thumbs probes what the
machine can do rather than assuming it: ffmpeg and ffprobe, every /dev/dri/renderD*
opened and exercised, vainfo's decode profiles per node where it exists. A video goes to
the first GPU that lists its codec, with ffmpeg's own software fallback behind it and a
CPU retry behind that; images decode on the CPU, JPEGs at a reduced DCT size. Previews
are kept under <data dir>/thumbs/ keyed by path, size and mtime, with undecodable files
remembered too. Root mode makes them through the same root helper the file reads use.
Where ffmpeg is missing the tab offers to install it; where a render node exists but is
closed to the daemon's user, it offers a drop-in adding the group and a restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 15:20:27 -07:00
Mitchell-JohnsonandClaude Fable 5.1 e7034cd478 VIRTUALIZATION.md: grid's Incus removed for a fresh start; status
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 15:02:52 -07:00
Mitchell-JohnsonandClaude Opus 5.5 e068fbdb2f PLAN.md: copyparty retired ahead of the Files gaps; they stay as Files polish
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:54:27 -07:00
Mitchell-JohnsonandClaude Fable 5.1 faf444d8ce VIRTUALIZATION.md: storage decision revised — a Disk location picker, plain files on grid's root disk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 14:36:06 -07:00
Mitchell-JohnsonandClaude Fable 5.1 ae72e45bf5 Virtualization: a disk-location picker on both create forms, locations by place, add or remove a location
Pools are shown by where their bytes land (plain files on this disk, a ZFS pool inside one
file, a dataset…) rather than by driver name, which read as the NAS pool. A location can be
added as a plain-files pool at any folder on the machine, and removed while nothing uses it.
The driver order at Install is as it was; the labels say what each one really is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 14:33:10 -07:00
Mitchell-JohnsonandClaude Opus 5.5 a9b4d7969e PLAN.md: CPU power on the Processes tab, shipped as v0.1.0-194
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:00:05 -07:00
Mitchell-JohnsonandClaude Opus 5.5 bfedf622be Processes: CPU power beside the clock, from the packages' energy counters
New sensor cpu.power: watts from the RAPL energy counters (Intel, and AMD
Zen through the same driver), summed over every package and averaged into
history. Parts of a package, psys and the MMIO duplicate are left out.

The counters are root-only, so a daemon that isn't root reads them through
`hamlab cpu-energy`, a root helper started once with sudo or doas and asked
over a pipe each sample, the same shape as the GPU client walk's helper.
It reads only the counters it finds itself. No counters or no root means
no reading, and the CPU box shows nothing extra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:58:35 -07:00
Mitchell-JohnsonandClaude Opus 5.5 d5680e8e41 VGA console: stop importing spice-html5's demo stylesheet, which restyled every button
spice.css sets body, *, button and canvas for the whole document. Bundled
into the App chunk since 43ebacd (deployed as v0.1.0-188), it turned every
button into a gradient pill, shifted 15% right and 8% up with a 150px
minimum width, broke the rail and header layout, and bordered every graph
canvas. The console is already styled by the .vga rules in styles.css.

The file is removed from the vendored copy, and a new web test fails on any
stylesheet import outside an allow-list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:19:20 -07:00
Mitchell-JohnsonandClaude Opus 5.5 ea84ee40c7 PLAN.md: the 2375 item deployed as v0.1.0-191 and checked on every machine
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:00:19 -07:00
Mitchell-JohnsonandClaude Opus 5.5 24a9979432 Docker updates: a machine without Docker answers empty, not 500
The hub used to answer this for its nodes and hid the node's 500. Now the
node answers itself, so it needs the same guard the stack list has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:58:13 -07:00
Mitchell-JohnsonandClaude Opus 5.5 c27d9f0ada Stacks: one machine, one set of files; default home is <data dir>/stacks
The hub no longer probes a node's Docker on tcp :2375, holds compose files
for a node, or runs compose against another machine's Docker. hubStacks,
nodeDocker, taskOnHub and the hub-side /api/nodes/{node}/docker/... routes
are gone; the generic node proxy hands those requests to the node's own
Ham Lab. Stack.remote / held_by and compose.Spec.Remote are removed.

New stacks default to <data dir>/stacks on every machine. Folders with two
or more deployed stacks are still offered, after it, never as default.
The ~/stacks fallback is gone. A missing home is skipped without a sudo find.

A schedule saved earlier with a tcp target is refused with a message
instead of being run against this machine's Docker.

PORTABILITY.md 3.1, 3.2, 3.3 and 9.2 done; 3.5 checked, no change needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:53:10 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a0ce3e5eb9 VIRTUALIZATION.md: phase 2 deployed as v0.1.0-188 and verified live; what is left
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:34:05 -07:00
Mitchell-JohnsonandClaude Fable 5.1 80a5b4b464 VGA console: one console operation per VM, a data websocket per SPICE channel
spice-html5 opens a websocket per channel (main, display, inputs, cursor, playback), and
each one was making Ham Lab open a new console operation, which Incus refuses as already
connected. Now the first channel opens the operation and holds its control websocket; every
further channel is one more data websocket on the same operation, which Incus allows; the
operation is let go when the last channel closes. A stale operation from a previous Ham Lab
process is taken over with force.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:32:08 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a5ebfdc9ce VGA console: agree to spice-html5's binary subprotocol; label layout and two labels in the detail pane
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:28:00 -07:00
Mitchell-JohnsonandClaude Fable 5.1 43ebacdd92 Virtualization phase 2: ISOs, New VM from ISO, the VGA console in the browser, nightly exports, mounts, instance sensors
- Library: an ISO shelf in virt/isos: upload (the Files uploader), fetch by URL with an
  optional sha256, import a file from the machine by copying it in, rename, delete (refused
  while attached), attach to a VM as a CD-ROM live or stopped with boot-first, eject, and
  New VM from ISO: an empty VM with the ISO as first boot device, Secure Boot on or off.
- The VGA console: Ham Lab asks Incus for the VM's SPICE console over its unix socket and
  bridges the display websocket to the browser, where spice-html5 (vendored from upstream
  b372aab, LGPL, loaded on demand) draws it; Ctrl+Alt+Del, fit-to-window.
- Exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a cron
  line in /etc/cron.d/hamlab-virt calling hamlab virt-export, settings and the last run
  as files beside the data; Export now per instance or for all.
- Disks tab per instance: the root disk, attached ISOs with Eject, bind mounts in and out
  (shift for containers, virtiofs for VMs).
- instance.* sensors (running, down, state, cpu, mem) and the VM-or-container-down alert
  default, offered once to installs seeded before it existed.
- cloud-init: Use my SSH keys writes a cloud-config from the account's public keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 22:21:57 -07:00
Mitchell-JohnsonandClaude Fable 5.1 66498a58c5 VIRTUALIZATION.md: phase 1 deployed as v0.1.0-184, Install verified live on grid
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 15:31:06 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b790b0c31c virt: the description is an instance field, not a config key — set it with a PATCH
Found on the first live test on grid: incus config set refused 'description' and the whole
limits save with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 15:30:10 -07:00
Mitchell-JohnsonandClaude Fable 5.1 38675ce9af Portability audit: every lab-specific design choice, listed for generalisation
PORTABILITY.md sweeps the whole repo for assumptions tied to this lab's hosts,
network, hardware, distro, init system, orchestrator history and privilege
posture. Nothing is changed yet; this is the list to work from, graded A/B/C
with the general form of each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 15:29:12 -07:00
Mitchell-JohnsonandClaude Fable 5.1 dfe2ca359a incus: syntax-check the generated install script for each machine shape; refusals; create args
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 14:16:05 -07:00
Mitchell-JohnsonandClaude Fable 5.1 289aa4aee3 VIRTUALIZATION.md: phase 1 built as v0.1.0-180, awaiting deploy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 14:13:54 -07:00
Mitchell-JohnsonandClaude Fable 5.1 240129c83f Virtualization tab, phase 1: Incus per machine — detect, install, instances, consoles, snapshots, images
The tab reports one of four states for the selected machine (ready, repair, installable,
unavailable) from what it finds — /dev/kvm, the package manager's incus candidate, the
pool drivers the kernel has — and acts on that machine only. Install is one followed task:
packages, a pool whose backing file sits in Ham Lab's own virt/ folder (zfs pool on a file,
btrfs on a loop mount, or a plain dir), a bridge on a subnet picked clear of the machine's
routes and Docker's pools (never 10.x on a /8 LAN), the DOCKER-USER rules that keep
Docker's FORWARD drop off the bridge, the incus-admin group for Ham Lab's account, and a
restart. Instances list with live CPU and memory, create from the images: catalogue or a
local image (container or VM, limits, cloud-init), start/stop/restart/force-stop/rename/
delete, console and shell as terminal tabs, snapshots with Incus's own schedule and expiry,
limits editing. Images: local store, catalogue search and download (exported into the
library too), import a tarball or a VM disk from a path, publish an instance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 14:09:56 -07:00
Mitchell-JohnsonandClaude Fable 5.1 af53b45b67 Virtualization plan: every artefact under Ham Lab's data directory, one virt/ tree per machine
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 13:48:31 -07:00
Mitchell-JohnsonandClaude Fable 5.1 e772f7fae2 Virtualization plan: a Library for ISOs and images; VGA console moves to phase 2 with it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 13:36:27 -07:00
Mitchell-JohnsonandClaude Fable 5.1 0fa6ab98e2 Virtualization plan: capability-gated per machine, never policy-gated; Install or report
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 12:56:18 -07:00
Mitchell-JohnsonandClaude Fable 5.1 16e85e1e42 Virtualization tab: scoping plan — Incus on grid, what is and isn't feasible
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 12:11:33 -07:00
Mitchell-JohnsonandClaude Opus 5.5 1fbf345166 BUGS: bug bash 6 and its quality-of-life builds deployed as v0.1.0-174
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:11:29 -07:00
Mitchell-JohnsonandClaude Opus 5.5 07645089d7 Version builds from the base tag, not from a published release's tag
git describe had started picking the nearest release tag (v0.1.0-137-gc129f65,
fetched from Gitea), so a build came out as v0.1.0-137-gc129f65-36-g7d0b87b,
which the version compare reads as build 137: older than the 169 it replaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:06:48 -07:00
Mitchell-JohnsonandClaude Opus 5.5 7d0b87bc45 A GPU with nothing measurable is one line, not half the Processes page
The Mac's Asahi GPU exposes no busy counter, clock, sensor or memory reading, so
its box was a graph that could never draw beside a column of dashes. A GPU with
nothing to read now gets one line (driver, "nothing measurable" or the stall,
and whether a process has it open), its row shrinks to fit, and the Gpu% column
and gpu sort are dropped when no GPU on the machine can light them. QoL #3 from
bug bash 6 (BUGS.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:31:25 -07:00
Mitchell-JohnsonandClaude Fable 5.1 f3ca3d8f73 Idle sessions sign themselves out; a KVM's login is remembered; the compose editor knows YAML
A session unused for seven days is dropped, the Sessions list marks a day's idleness and
can sign all of those out at once. The hub keeps a KVM's own cookies from any browser that
has them and lends them to one that hasn't, on the wire and back to the page. The stack
editor and the new-stack dialog use CodeEditor: line numbers, YAML and .env colour under a
real textarea, Tab/Shift+Tab/Enter indentation, and a key bar on touch screens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 01:20:27 -07:00
Mitchell-JohnsonandClaude Fable 5.1 faaa6aaf9d The phone's ZFS table headers no longer collide; the bash notes that deploy is still owed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 11:34:31 -07:00
Mitchell-JohnsonandClaude Fable 5.1 e0e2562f0a Bug bash 6: the phone fits ZFS, Usage and journal lines; a snapshot's default name is local time; twelve smaller faults
Found by a headless pass over every page on every machine at desktop and phone widths, then
every interaction on the hub. BUGS.md has the list, what was checked and found fine, and the
quality-of-life notes to discuss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 11:28:44 -07:00
Mitchell-JohnsonandClaude Fable 5.1 3a58e265c6 A GPU is named from an embedded PCI ID table, the same on every machine
The router has no lspci, so its GeminiLake chip came up as 'Intel GPU
3185' beside the identical chip's 'Intel UHD Graphics 600' on hamtv.
A trimmed pci.ids — the graphics devices of Intel, AMD and NVIDIA, and the
virtual GPUs, 115 KB — rides in the binary and is consulted first; lspci
is the fallback for a card newer than the table. scripts/pci-ids-gpu.sh
regenerates it from the hub's copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:48:05 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b5cecce435 Never read sysfs through Go's poller, and a GPU that stops answering is skipped
On grid the sampler sat 42 minutes in runtime_pollWait on the Arc A380's
hwmon fan1_input: kernfs accepts epoll, so os.ReadFile made the fd
non-blocking, i915 answered EAGAIN once, and the poller waited for a
readiness kernfs only signals on sysfs_notify. Every graph on the machine
froze while the API carried on.

readStr now reads with plain blocking syscalls. Each GPU's sysfs reads run
under a three-second deadline; a device that does not answer is marked
stalled, logged, and left alone until restart, with the box saying so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:41:22 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a8f4655c83 A graph's canvas is out of flow, so a box can never grow itself without end
The gpu box sat in an auto-height grid row. The canvas inside it was sized
from its box and also sized the box, so every resize fed the next and the
Processes tab grew for as long as it was open. The canvas is now absolute
within its box, and the gpu row has a floor instead of auto.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:54:49 -07:00
Mitchell-JohnsonandClaude Fable 5.1 7e070bd524 The gpu box's layout rules, which the first commit lost
The desktop grid placement, the stats panel, the engine label width and
the client rows' columns were never in the stylesheet: on a phone the
caption sat on the stats head, 'enhance' ran into its meter, and a client
row came out as one word. Restored, with the graph box as tall as the
cpu's so its caption always has a line of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:53:50 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b44faf3ac9 The ZFS tab shows the ARC, and names every caching tier a pool has or lacks
A Cache section between the pools and the schedules. One card for the ARC:
size against its ceiling with the target and floor, sparklines of size and
hit rate from the sampler's history, what it holds (MRU/MFU/dirty, data vs
metadata, compression, the ghost lists), the prefetcher and dbuf cache, the
memory-pressure counters, and the since-boot breakdown by demand/prefetch
and data/metadata. Then a card per pool for L2ARC, SLOG, special vdev,
dedup table and spares — the devices and their fill where they exist, and
where they don't, plainly none and what that means (the ZIL writing to the
pool's own vdevs, with how much it has written).

Live numbers come from the sampler, which reads arcstats every tick and
publishes zfs.arc.* sensors; the fuller picture is read on request, with
zpool list -Hpv and zfs get run without sudo and cached for thirty seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 18:30:02 -07:00
Mitchell-JohnsonandClaude Fable 5.1 db40220365 GPU boxes on the Processes tab: busy, engines, temps, and who has the card open
Every DRM device with a real GPU driver gets a box under the cpu box, laid out
like it: the busy curve, a meter for the device and one per engine, temperature,
clock, power and fan where the card has them, and the processes holding it open
with their share. The process table gains a Gpu% column and sort.

Device busy is Intel's RC6 idle residency inverted (what intel_gpu_top shows,
and readable without root) or amdgpu's gpu_busy_percent. Per-process shares are
DRM fdinfo deltas, as nvtop reads them; other users' fdinfo needs root, so a
daemon running as a user keeps one root child (hamlab drm-clients) over a pipe
rather than paying a sudo a second. Everything is also a gpu.* sensor.

What cannot be measured is left out, not shown as zero: an Arc A380 on i915 6.12
has no temperature sensor, an integrated GPU wears the package temperature
marked *, and Asahi has neither a busy counter nor fdinfo yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 18:18:02 -07:00
Mitchell-JohnsonandClaude Opus 5 0e82719070 plan: the VPN tab edits devices, and what that cost in care
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 23:22:13 -07:00
Mitchell-JohnsonandClaude Opus 5 8fb21ee95e Create, rename and remove the devices that dial in
On a tunnel this machine configures — today that means OpenWrt's UCI, which is
where the server here lives — the VPN tab can add a device, rename it and take
it away. A wg-quick host is reported as unmanaged and shows no buttons, because
half-supporting a second configuration format is worse than an honest refusal.

Adding generates the keypair and a pre-shared key on the machine, picks the
lowest free address in the tunnel's range, and hands back the complete client
profile with a QR code for the WireGuard apps. The private key in it is
generated for that one call and stored nowhere, so the profile is shown until
dismissed and then gone — the same bargain as any password this console makes.

Nothing restarts the network, which matters when the interface belongs to the
router the house is behind. `uci commit` writes the file and applies nothing;
`wg set` changes one peer on the live interface and touches nothing else.

The order of the two is chosen so they cannot disagree quietly. Adding writes
the configuration first and rolls it back if the live tunnel refuses, because a
device that works only after the next reboot is the worst of the three outcomes.
Removing does the live tunnel first, since that is the moment access actually
ends, and says plainly if the configuration half then fails.

Removing asks for the device's name to be typed back, at both ends. It is the
one action here that cannot be undone and it sits one tap from a list of devices
that all look alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 23:20:41 -07:00
Mitchell-JohnsonandClaude Opus 5 32fdbf169a Fix the blank VPN tab, and stop one page taking the console with it
On a machine with wireguard-tools installed but no tunnel up — core, which is
the machine the console opens on — the tunnel list was a nil slice, which is
null in JSON, and the page took its length. That throws, React unmounts the
whole tree, and what is left is a blank window with nothing to go on.

Two fixes, because either alone leaves the failure possible. The parse returns
an empty list rather than nil, with a test that pins the marshalled shape; and
the page treats a missing list as an empty one, so a node still running an older
build cannot blank the console either.

And the reason a small mistake had such a large blast radius: nothing caught it.
Each page now renders inside a boundary, so a fault costs that page and says
what it was, while the rail, the switcher and every other tab carry on. The
terminals are deliberately left outside it — they hold live WebSockets, and
remounting them to recover from somebody else's error would drop the sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 22:18:59 -07:00
Mitchell-JohnsonandClaude Opus 5 acb83d3a4a A peer that never connected has no timestamp, and names are readable
Two faults in the VPN tab, both found by running it against the real machines.

omitempty does nothing for a zero time.Time, so a peer that has never handshaken
was serialised as the year one and the page would have shown mbp14 as last seen
on 1 January 0001 instead of "never connected". The field is a pointer now, and
a test pins the JSON.

And peer names on a wg-quick host were never going to appear: /etc/wireguard is
root-only because it holds private keys, and the file was being opened as the
daemon's own user. It now falls back to reading it as root, the same way the
kernel state beside it is already read. Only comments and public keys are taken
from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 22:01:13 -07:00
Mitchell-JohnsonandClaude Opus 5 6f634bbe11 A VPN tab: the tunnels, and who is on them
Beside Network, per machine. Every WireGuard interface on the machine in the
switcher, with its address, listen port and public key, and under it the peers:
name, allowed addresses, last handshake, where they connect from, and what they
have moved.

Per machine is the point rather than a limitation. The tunnel devices dial into
and a tunnel a machine dials out through are different boxes here, and putting
them on one page would invite the question of which box you were looking at.

The kernel is the truth and the configuration only supplies names: wg show all
dump has the handshakes and the counters and knows nothing but public keys, so
the # comment above a peer in a wg-quick file, or a UCI section's description on
OpenWrt, is read alongside purely to label them. Whether a tunnel is dialled or
dialling is worked out from the shape of its peers, and says neither when it
cannot tell.

Online means a handshake in the last three minutes, and the tab says so in as
many words: WireGuard is silent when idle, so there is no connection to be in.

Read-only, and no secrets leave: the private key is the second column of the
dump and is read past, pre-shared keys are noted as present and never read.

The Network tab keeps listing tunnels as the interfaces they are, and hands off
to here for anything about them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 21:59:30 -07:00
Mitchell-JohnsonandClaude Opus 5 9c7848907b Network sits under Files by default
Where Mitchell had already dragged it. An account with its own rail order is
unaffected either way; this is the slot a fresh one gets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:20:49 -07:00
Mitchell-JohnsonandClaude Opus 5 48d3887514 A second download source, for when the first one refuses
Cloudflare rate-limits by volume, and the block it handed out while this was
being built lasted over twenty minutes — too fragile to be the only thing behind
a button. So the download phase tries its sources in order: the anycast endpoint
first, then a plain 100 MB file on Linode's Seattle mirror, which has no opinion
about how much has been pulled today.

Only a refusal moves to the next one; anything else would fail the same way
twice. Every result names the source it used, and the page says "from
linode-seattle" rather than "via SEA" when it was the fallback — that mirror is
in one fixed place, so somewhere else it reads low, and a figure that quietly
came from a different continent would be a lie.

Upload stays on Cloudflare, which has never been the one to refuse.

Also: the too-short-window guard was refusing to divide 512 MiB by ten
milliseconds on a link fast enough to hit the runaway budget before the first
tick. It now guards against dividing by zero and nothing more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:15:33 -07:00
Mitchell-JohnsonandClaude Opus 5 2f59a54678 A Network tab, with an internet speed test
Per machine like every other tab: the interfaces as the kernel has them
(addresses, MAC, MTU, negotiated link speed, carrier, totals, live rates from
the sampler's own sensors), the default route and resolvers, what the internet
sees of this connection, and a speed test with the last fifty results kept.

The test runs in the daemon, not the browser, so the figure is the line of the
machine in the switcher — the router's, if you switch to gate — and it carries
on when the phone locks. It measures against Cloudflare's public speed
endpoints, so there is nothing to install on any host, which is what makes it
work the same on Debian and on OpenWrt where flash is scarce.

What the measurement does and does not count:

  - Several streams, HTTP/2 deliberately off. One stream over one connection
    measures a congestion window, and HTTP/2 would put every stream back onto
    one connection and measure the same thing.
  - The first second of each phase is thrown away as the ramp.
  - A phase ends on 64 MiB as well as on four seconds, so a fast line says what
    it is without moving a gigabyte. A minimum window keeps a very fast line
    from filling the cap before there is anything to divide by.
  - Latency is the round trip less the far end's own Server-Timing. A zero-byte
    response is mostly Cloudflare's worker thinking: left in, it reported 31 ms
    for a line the edge itself measures at 3.5. Jitter is the mean change
    between consecutive probes, so one slow probe cannot run away with it.
  - The far end rate-limits by volume. So: a 20 s cooldown counted from the last
    attempt rather than the last success, streams that retry twice, the rate
    computed over the window in which bytes were actually still arriving, and a
    plain sentence when it was refused. Coasting to the end of the clock with
    nothing arriving reported a third of the truth.

The last result is published as net.internet.* sensors, each stamped with when
the test ran, so the line can be a widget, a graph, an alert or a Prometheus
series like anything else. The public address is refreshed on its own quarter
hour, which is worth having here: it is a DHCP lease and it moves.

Tests are offline — the window, the byte cap, the refusal and the cancellation
run against a local server; the route parser's byte order is pinned by a case
that would have caught the bug it did. The live measurement is behind
HAMLAB_NET_TEST=1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 20:11:03 -07:00
Mitchell-JohnsonandClaude Opus 5 a0987c940f Twenty login tries before the backoff starts, not five
A guest password is typed by hand off a message on a phone. Five tries is not
many when a hyphen lands in the wrong place, and being told to wait is worse
than the brute-force risk here: past twenty the doubling lockout still applies,
the global backstop is unchanged, and the password is ~78 bits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:04:23 -07:00
Mitchell-JohnsonandClaude Opus 5 ed64aca63b An eye on the login form, to see what is being typed
A guest password is read off a message and typed by hand, and from the login
form a mistyped character and a wrong password look identical. Revealed, the
field is a text field, so it also turns off capitalisation and autocorrect,
which a phone keyboard would otherwise offer for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:03:26 -07:00
Mitchell-JohnsonandClaude Opus 5 179f342a2c A guest login is a full account with a clock on it
Mitchell, on the read-only version built earlier today: scratch it. He hands
these to people he trusts and revokes them otherwise, so a console with half
its tabs missing solved a problem he doesn't have.

So the allow-list, the middleware check and every "not for a guest" branch in
the UI are gone. What is left is the part that was the point: a throwaway
account that expires by itself, is checked on every request, swept once a
minute, and can be ended in one tap. A guest now gets the whole console on
every machine, terminal included.

Said once, since it is his call and he has made it: that means a guest has a
root-capable shell on every host, and can make an account or a token of its own
that outlives the guest login.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 17:53:55 -07:00
Mitchell-JohnsonandClaude Opus 5 8a7167bf03 A guest login: temporary, look-only, revocable
Users -> Guest logins makes a throwaway user name and password, good for half
an hour by default, to hand to somebody who wants to see the console. It is a
normal account with `guest: true` and an expiry, so login, sessions and the
saved look need no special case, and it is left out of the account list, out of
"the last account can't be removed", and out of the one account an empty user
name means.

What a guest may do is one allow-list, checked in the auth middleware, rather
than a flag ~190 handlers have to remember. The list is matched by asking the
router which handler a request would reach, so it cannot drift from the routes
it names, and it is checked against the scoped path — /api/nodes/grid/procs is
/api/procs on grid — because the hub proxies to a node with a token that is
full control of that node. The node cannot tell a guest from its owner, so the
only place it can be stopped is here, before the request leaves.

A guest gets the dashboard, containers, stacks, disks, hardware, ZFS and usage,
on this machine and on any managed one, and nothing else: no terminal (attaching
is a GET, and it is a root shell), no files, no container logs or environments,
no stack files, no alert channels, no cron, no tokens, no settings, no power.

Expiry is enforced on every request and swept once a minute, so a console left
open on a visitor's phone stops within the minute rather than at their next
click; revoking signs them out at once. The console hides what a guest cannot
use and says how long is left where the power button would be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 17:23:16 -07:00
Mitchell-JohnsonandClaude Opus 5 4450f1285d Install under procd, so OpenWrt counts as a host too
The binary was already static and musl-clean; the only thing standing between
Ham Lab and an OpenWrt box was that setup.go knew systemd and OpenRC and
nothing else, so an install there ended at "nothing starts it at boot."

Adds an embedded procd init script, detected on /sbin/procd + /etc/rc.common
ahead of the OpenRC branch. Both backends write /etc/init.d/hamlab, so
uninstall tells them apart by USE_PROCD=1 rather than by path.

Verified on the new router: procd supervises the instance, S95/K10 symlinks
land, and the hub drives its Docker and stacks like any other node. The
Updates tab needed nothing — apk-tools 3 answers the same query Alpine's does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 17:39:04 -07:00
Mitchell-JohnsonandClaude Opus 5 4e3c817b2a A Recreate action, and the Updates tab uses the whole window
Recreate is `up -d --force-recreate`. It exists because Deploy and Update
both leave a container alone when its definition has not changed, which is
precisely the state a container is in when Docker will not start it — pinned
to a network that has since been recreated, answering "network … not found"
to everything. siyuan landed there today and there was no way out from the
console.

The Updates tab is a page, not a list with a detail pane, so on a wide window
it was being squeezed into the right-hand half with an empty list column
taking the left — which read as a phone layout on a desktop. It now sits
under the tab strip at full width. The list-and-detail tabs are unchanged.
The rule needed :not(.has-sel) to outrank the wide-window rule that grows the
list to fill the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 13:14:18 -07:00
Mitchell-JohnsonandClaude Opus 5 94800b8207 Update from the list that says what is out of date
The Updates tab could tell you eleven images were behind and offer nothing to
do about it; updating meant knowing which stack each image belonged to and
going to find it. Now each row has Update and the header has Update all.

Updating is per stack, because that is the unit compose deals in, so a row
acts on whichever stacks use that image — postgres:15-alpine is wikijs's,
postgres:17 is vikunja's — and Update all walks the affected stacks through a
queue one at a time, with each task's output on screen, rather than
recreating half the machine at once. A stack that finishes says "updated just
now" rather than re-checking, since every check is a registry request and
Docker Hub counts them.

Also: cron expressions read as English. "daily at 3:15am", with 15 3 * * *
kept beside it. Jobs.tsx had a smaller version of this for its own list; it
now uses the shared one, which handles lists, ranges, steps, names,
weekdays/weekends, midnight and noon, and says nothing at all when it is not
certain — the caller falls back to the expression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 12:49:32 -07:00
Mitchell-JohnsonandClaude Opus 5 03e0761f3e Hardware: a Motherboard card
The board, the chassis and the firmware on it, which the System card was
reducing to two lines. Maker, model, version, serial and asset tag; chassis
and where the board sits in it; the firmware's vendor, version, revision,
date, ROM size and whether the machine boots UEFI — that last one is a line
in SMBIOS's characteristics list rather than a field, so the parser now keeps
those lists too.

Expansion slots come with it, with the device found at each occupied slot's
PCI address rather than the address alone. They are only shown when the
firmware tracks occupancy at all: core's NucBox lists five PCIe x1 slots it
does not physically have, all "Available", copied out of a reference design.
grid's board reports real addresses and real occupancy, which is the case
worth printing.

A machine with no SMBIOS gets a card that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 22:21:27 -07:00
Mitchell-JohnsonandClaude Opus 5 38039d1adc Hardware sits under KVM in the rail
Mitchell's placement. The rail order is the account's anyway — this is only
where a page starts for someone who has never dragged it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 22:02:56 -07:00
Mitchell-JohnsonandClaude Opus 5 776bcb236d A Hardware tab: what the machine is made of
Per machine, like every other page. Four tiles — processor, memory, storage,
graphics — over a card for each.

Memory is the part that had nowhere to live before: every slot, with the
module's size, type, form factor, rated and running speed, maker, part
number, serial, rank and voltage, and the empty slots marked empty. A module
whose maker is only a JEDEC id gets a name when it is one we know and keeps
the number when it is not, because a wrong name is worse than a code.

Everything is read from what Debian already has: dmidecode for the parts only
the firmware knows, lscpu, lspci, sysfs. None of it changes under a running
kernel, so it is read once and kept for ten minutes; Re-read is for the one
thing that does change, a drive plugged in.

Every reader is allowed to come back empty. A machine with no dmidecode, no
root to read it with, or no PCI bus at all still gets a page, with a note
saying what was missing and why. Checked against the Mac mini, which is all
three cases at once: no SMBIOS memory table, a GPU only the device tree knows
about, and cores that are not all alike — lscpu counts one cluster as "cores
per socket" there, so without SMT the thread count is the honest answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:41:40 -07:00
Mitchell-JohnsonandClaude Opus 5 09dc846730 docker: logs follow a held connection, and networks can be removed
Two gaps the Docker tab had against Dockhand and against its own Services
tab next door.

Logs: one `docker logs -f` per viewer over a WebSocket, the shape the
Services tab's journal tail already uses, instead of asking again every two
seconds. stdout and stderr both, with stderr marked so the page can colour
what went wrong. Docker replays the backlog down two pipes in no particular
order, so the opening 300 ms is held and sorted by the stamp Docker wrote;
after that the log is live and in order. A reconnect resumes from the last
line rather than from the top — `--since` is inclusive and two lines can
share a nanosecond, so the client drops what it has already shown by line,
not by "later than". A container that has stopped ends the stream at once,
so reconnects back off to 15 s.

Networks: remove and prune, which images and volumes have had all along.
The overview now says which containers are attached to each network, so the
✕ appears only on one nothing uses, and never on bridge, host or none.
`network rm` prints the real reason and then an aggregate "exit status 1",
so the message shown is the first line, not the last.

Prune is not exercised against `core`: several networks here belong to
stacks that are merely down, and pruning would give them new subnets on
their next deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:17:37 -07:00
Mitchell-JohnsonandClaude Opus 5 b8c654b61b docker: drop the fleet-wide updates list
A view shows one machine: the one in the switcher. Seeing another machine's
state means switching to it, not a hub-side page that reaches across — and
this was one. Recorded as a decision so it does not get rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 00:19:12 -07:00
Mitchell-JohnsonandClaude Opus 5 608f0b7005 docker: stack Inspect one property per row
Inspect's values are long paths, network names and env lists. In a two-column
grid on one line each they were half truncated and read as columns of
unrelated things. One per row, label above value, wrapping instead of an
ellipsis. Mounts are one block rather than a repeated "Mount" label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 00:17:09 -07:00
Mitchell-JohnsonandClaude Opus 5 639807ff8a plan: the Docker tab's Dockhand gap is closed; the cutover is homelab work
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 20:21:06 -07:00
Mitchell-JohnsonandClaude Opus 5 f17a365ea1 stacks: remember where a container's bind mounts pointed
Every stack on core and grid is labelled with a path inside Dockhand's
filesystem — /app/data/stacks/… — because Dockhand ran compose from inside its
own container. Ham Lab resolves those by walking the bind mounts of running
containers, which works right up until the container doing the mounting is
removed. Retiring Dockhand would, in one step, make seventeen stacks on core
and five on grid unfindable.

So the mounts are written down as they are seen, and a path no live container
explains is looked for in what was seen before. A remembered mount resolves
with no container to run in, which is the right answer: compose runs on the
host, against the host's own copy of the file.

The same problem bites a scheduled update, which captures how to invoke
compose when it is made so the cron job never has to rediscover the world at
3am. repoint() checks the named container is still running and falls back the
same way if it is not — and pointedly leaves the spec alone if nothing
explains the path, so it fails loudly rather than deploying a guess.

Out in cron the record is read and never added to: the daemon owns state.json
and would clobber anything written from there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 20:19:54 -07:00
Mitchell-JohnsonandClaude Opus 5 192504ead3 docker: the eight things Dockhand did that Ham Lab did not
Dockhand is being retired, so the Docker tab has to cover what it actually
earned its keep doing. Eight pieces, all per container or per machine:

- Stats: CPU, memory and IO over time, from sensors already collected.
- Health: the container's own healthcheck, with the last few probe outputs —
  which is where a failing check says why.
- Events: `docker events` keeps a minute of backlog and nothing more, so Ham
  Lab follows the stream and keeps a ring of its own. A restart at 4am is
  still there at 9.
- Shell: `docker exec` into a container, in the terminal already here.
- Limits: memory, CPU and restart policy changed live, without hand-editing
  the compose file for a number you want to try.
- Updates: what the registry has that this machine does not, for every image
  at once rather than one stack at a time.
- The same across every machine, asked of the hub — which is the only thing
  that can reach them all.
- Schedules: a real line in /etc/cron.d/hamlab-docker calling the binary,
  in the same shape as the ZFS snapshot schedules. Dockhand updated DNS and
  ingress nightly from a ticker inside a web app; a cron line keeps running
  when the console does not, and is visible to anyone reading the machine the
  normal way.

internal/compose is new and shared, so a deploy from the console and a deploy
from cron invoke compose identically — including the case where compose has to
run inside the orchestrator's own container, where the paths are that
container's and not the host's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 20:11:36 -07:00
Mitchell-JohnsonandClaude Opus 5 a70f3e2ed2 Container events are Ham Lab's own record, not Docker's short memory
docker events is a live stream with a small in-memory backlog: ask for thirty days and you
get a few minutes, and a daemon restart loses the lot. That is why Dockhand kept its own
copy. A watcher now follows the stream and appends lifecycle events to a ring beside the
config, flushed every thirty seconds, so the question of what happened to a container last
week has an answer. Health-check exec events are excluded — three per probe, thousands a
day, and the health panel says it better.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:55:36 -07:00
Mitchell-JohnsonandClaude Opus 5 b3e1c495f0 Docker: a container's stats, health, events, shell and limits
Five things the tab could not do, all on the container detail.

Stats draws CPU and memory over time from the container sensors the sampler already keeps,
so there is nothing new to collect.

Health shows the check's verdict, the failing streak and what the last five probes actually
printed, and says plainly when an image defines no health check at all rather than leaving
a blank panel.

Events asks Docker what has happened to the container over the last day, week or month —
started, died, killed, health going bad, with exit codes — rather than keeping a copy that
would only go stale.

Shell opens a session inside the container as an ordinary terminal, so it reattaches,
resizes and scrolls back like the rest and works on a managed machine through the same
proxy. The terminal manager grew a CreateCmd for it; every session is the same thing
afterwards.

Limits changes memory, CPUs and restart policy on the running container with docker update,
live and without recreating it. That is also the catch, and the dialog says so: it does not
edit the compose file, so a deploy puts back whatever the file says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:52:00 -07:00
Mitchell-JohnsonandClaude Opus 5 c129f655a7 Reorder the default rail: the machine, then its storage, then what runs on it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 19:12:14 -07:00
Mitchell-JohnsonandClaude Opus 5 d8bf5df89f Fullscreen on Safari, and a fallback where there is none
Safari on a Mac wants the webkit prefix; Safari on iOS has no fullscreen for an ordinary
element at all, only for a <video>, so requestFullscreen was simply undefined and the tab
threw. It now uses whichever spelling the browser has, and where there is none the frame
fills the viewport in-page instead — the same result minus the browser's own chrome — with
a Done button and Escape to come back. The button reads Expand rather than Fullscreen when
that is what it will do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 17:51:41 -07:00
Mitchell-JohnsonandClaude Opus 5 e64b0a1727 PLAN: the KVM tab is per machine
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 17:01:05 -07:00
Mitchell-JohnsonandClaude Opus 5 f3441ae718 The KVM tab is per machine, and follows the machine switcher
It was a hub-side list you pointed at machines, which had it backwards. A machine has one
KVM; switch to grid and you get grid's. The address is still kept on the hub rather than on
the machine, because a KVM earns its keep exactly when that machine is down and cannot
answer for itself — so the tab reaches the device either way. Adding a second KVM for a
machine replaces the first rather than sitting beside it.

The tab is no longer hub-only and now sits beside Machines in the rail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:58:08 -07:00
Mitchell-JohnsonandClaude Opus 5 70fcf86283 PLAN: the KVM tab shipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:43:44 -07:00
Mitchell-JohnsonandClaude Opus 5 823c72122c A proxied KVM may be framed by the console that proxies it
frame-ancestors 'none' forbids framing even from the same origin, so /kvm/ paths say
'self' instead. Without this the iframe was refused before it loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:42:51 -07:00
Mitchell-JohnsonandClaude Opus 5 fd88c4ff78 A KVM tab: paste an address, get the device's own page
Ham Lab proxies each KVM from its own origin, which is what makes the simple version
possible. A KVM that speaks only plain HTTP would otherwise be blocked outright as mixed
content in a TLS console; served from here it loads, it is behind this login rather than a
second one, and the iframe is same-origin.

Nothing in it knows anything about any particular device. The prefix /kvm/<id>/ is only the
entry point — a device UI asks for its assets by absolute path, which no prefix can rewrite
away, so any request Ham Lab does not serve itself is routed back to whichever KVM the
asking page belongs to, worked out from the Referer and from a cookie when there is no
Referer, as on a WebSocket upgrade. Ham Lab's own routes always match first, so a device can
never shadow the console, and the fallback is gated on a live session.

The list is hub-side on purpose: a KVM earns its keep when the machine is down, which is
when that machine's own Ham Lab cannot answer.

On the keyboard, a page cannot forward keys into an iframe and does not need to — one with
focus receives them directly. Fullscreen plus navigator.keyboard.lock() is what takes
Escape, Tab and Ctrl-W back from the browser; that is Chromium-only and the tab says so
rather than pretending otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:39:27 -07:00
Mitchell-JohnsonandClaude Opus 5 e6625a540c PLAN: the Docker tab's polish and retiring Dockhand are one item
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:15:00 -07:00
Mitchell-JohnsonandClaude Opus 5 66c1ed7d13 PLAN: the Files polish and retiring copyparty are one item
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:13:45 -07:00
Mitchell-JohnsonandClaude Opus 5 384aea79ca PLAN: the snapshot explorer, and the preview is gone
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:17:25 -07:00
Mitchell-JohnsonandClaude Opus 5 daf33a44b2 The datasets table is a snapshot explorer, and snapshots can be deleted
Renamed Datasets to Snapshots and turned the columns around to match: how many each
dataset has and what they cost come first, the dataset's own size after. Opening one lists
its snapshots with when it was taken, when it expires — read from the TTL in its own name,
so no server call is needed — its size, and a Delete button. Deleting takes the same-named
snapshot on descendant datasets too, which is how a recursive schedule made it, and says so
before it does anything.

Dropped "What would it delete?" from the schedule cards, and the endpoint behind it. Run
now says plainly that expired snapshots go at the same time, which is the whole of what the
preview was telling anyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:16:13 -07:00
Mitchell-JohnsonandClaude Opus 5 c3854bafda PLAN: schedules are cron jobs, and zfSnap is retired
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:12:17 -07:00
Mitchell-JohnsonandClaude Opus 5 09b7558d44 Snapshot schedules are real cron jobs, written and owned by Ham Lab
A schedule is now a line in /etc/cron.d/hamlab-zfs, which Ham Lab owns and rewrites whole
whenever the schedules change; the line calls `hamlab zfs-snapshot <id>` as the account Ham
Lab runs as, reaching zfs through sudo exactly as the daemon does. The daemon's own ticker
is gone. Snapshots are backup-shaped and should not stop because a web console is down,
being upgraded, or uninstalled — and a cron line is greppable by anyone looking at the
machine the normal way, which a schedule hidden inside a daemon is not.

A cron expression is now the single description of when a schedule fires: the four common
shapes render to one, a custom schedule states it outright, and the next run is read back
with the same evaluator the Jobs tab uses on everyone else's lines. So the page, the file
and the prediction cannot disagree.

Schedules gain a custom cron expression and an optional snapshot name prefix. What a run
did moves out of the policy into zfs-runs/<id>.json beside the config, because the cron job
is a separate process and must never write state.json behind the daemon's back.

The detected external job can be adopted: its schedule, retention and dataset are read out
of the cron line into a new Ham Lab schedule, ready to check rather than retype.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:09:43 -07:00
Mitchell-JohnsonandClaude Opus 5 4dc25fdd13 PLAN: Usage, Jobs and the ZFS tab are done
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:03:38 -07:00
Mitchell-JohnsonandClaude Opus 5 7d0ec5e018 ZFS gets its own tab, with scheduled snapshots and a retention period
Pools, datasets and snapshots move out of Disks — Disks is the hardware, this is the
filesystem on top of it. New: snapshot schedules, hourly to monthly, with a retention
period and a floor that keeps the newest few whatever their age.

The naming is zfSnap's, deliberately. grid has run zfSnap from root cron since before this
repo existed and it works, so rather than invent a second convention this writes and reads
exactly what zfSnap does: 2026-09-24_02.30.01--6w, with the retention in the name. Ham Lab
can therefore show and expire the snapshots zfSnap already took, the two cannot disagree
about what a snapshot is, and changing a schedule never re-dates old ones — each carries
the rule it was taken under. An existing cron job is detected and shown on the page, with a
warning if a Ham Lab schedule is running as well.

Nothing is destroyed unseen. Every schedule answers what it would delete, with the list,
before Run now touches anything, and only names that parse as a stamp and a TTL are ever
candidates. A snapshot taken by hand is invisible to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:01:53 -07:00
Mitchell-JohnsonandClaude Opus 5 a07a444170 The Space tab is Usage; the Cron tab is Jobs
Both are named for what they do rather than the tool underneath, the way Services is.
Old links and saved rail orders still land in the right place. The Go packages and their
endpoints keep their names, with a pointer in each to what the UI calls them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:54:23 -07:00
Mitchell-JohnsonandClaude Opus 5 dfa9e88f5b PLAN: the KVM tab, as simply as it can actually be built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:41:58 -07:00
Mitchell-JohnsonandClaude Opus 5 260d5d4880 PLAN: the KVM is a Sipeed NanoKVM at 10.0.0.217 — framing is fine, HTTP is the blocker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:39:51 -07:00
Mitchell-JohnsonandClaude Opus 5 72c7f45e19 PLAN: to do — a KVM tab, the keyboard-video-mouse kind
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:37:25 -07:00
Mitchell-JohnsonandClaude Opus 5 2e4f3a7fe6 A drive in a pool says which pool, not that nothing is on it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:11:39 -07:00
Mitchell-JohnsonandClaude Opus 5 e0625be684 The disks box is drives; the net box lets you pick the interface
Disks: one row per physical drive with its model, size, temperature and IO, and a usage
bar built from the filesystems the sampler can attribute to it. Attribution is new — a
mount's source device is resolved through sysfs to the drive behind it, following one
layer of device-mapper or md and folding NVMe namespaces the way the sampler does. A pool
member gets no usage bar, because one drive's share of a raidz pool is not a real number.

Net: every interface that is up is listed and tappable, defaulting to the one that has
actually carried traffic rather than whichever the kernel listed first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:09:39 -07:00
Mitchell-JohnsonandClaude Opus 5 bf0bdd54af The disks box is this machine's own storage, not a network share
core mounts grid's tank over CIFS, and it was listed beside root and efi as though it
were a disk in the box. Filesystems whose storage is on another machine are now marked
remote — a label on the fs sensors, so the sensor stays for anyone who wants it — and the
Processes tab's disks box leaves them out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 23:16:17 -07:00
Mitchell-JohnsonandClaude Opus 5 9bf60deffc Retire the Dashboard tab; Processes is the front page
The app repeated itself. The module list, /api/prefs/dashboard, applyPrefs and the
per-user dashboard blob are gone. Widgets stays and now says plainly that it is for
dashboards outside Ham Lab.

This also closes a bug: applyPrefs set the sampling rate from intervalMs inside the
dashboard layout, and reset it to the default when that field was absent — so with
per-user layouts, one account saving a dashboard could reset the sampling rate for the
whole machine. The sampler now has one source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 23:03:49 -07:00
Mitchell-JohnsonandClaude Opus 5 1eb59fd08c One tab called Processes, showing what Top showed
The two read the same data and answered the same question. Procs.tsx is gone; a rail
order saved before this keeps the merged page in Top's slot. The mouse back-button item
is dropped from the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 22:58:06 -07:00
Mitchell-JohnsonandClaude Opus 5 e184b4a149 PLAN: seven more to do — tab overlap, two renames, ZFS scheduling, virtualization, Files polish
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 21:38:19 -07:00
Mitchell-JohnsonandClaude Opus 5 363ff02bda Drop the external demo: the Widgets tab is the demo
The hamlab-demo site, its Widgets-tab button, the demo_url config key and the make publish
target that fed it are all gone, along with the public read-only token it needed. The
example stays in examples/external-demo as something to read. The installer one-liner
already comes from Gitea releases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 20:17:22 -07:00
Mitchell-JohnsonandClaude Opus 5 b5454c1022 Public downloads: the installer and the updater come from Gitea releases, no token
REQUIRE_SIGNIN_VIEW is off and mitchell/hamlab is public, so anyone can clone it or pull
a release. The other five repos are individually private and 404 to anonymous. Gitea
resolves releases/download/latest/<asset>, so the install one-liner and HAMLAB_URL point
at the newest release and never need changing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 18:59:42 -07:00
Mitchell-JohnsonandClaude Opus 5 1599ac65ef PLAN: the updater shipped; what is left is where a public download lives
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:33:20 -07:00
Mitchell-JohnsonandClaude Opus 5 68bf3bb218 An updater: what is published on Gitea, and installing it from Settings
A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo
API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is
what make release already writes and what install.sh already downloads. The binary is
checked against SHA256SUMS before it goes near the running one, and a token is sent when
the source is behind a login, so a private Gitea serves its own fleet without opening the
instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the
installer never disagree about which build is newer.

Settings gains an Updates card: installed, published, when it last looked, the source,
release notes, Check for updates, and Install. The rail badges Settings when a newer
build is out. Nothing installs unasked — it restarts the daemon and drops terminals.

scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release.
Deliberate, and not part of deploy: deploy pushes to the machines this hub owns,
publishing offers the build to every install anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:31:32 -07:00
Mitchell-JohnsonandClaude Opus 5 98a8de8091 PLAN: to do — deprecate copyparty, deprecate Dockhand
The Dockhand entry is the app half only; migrating the stacks stays in hamlet's
docs/pending.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:32:52 -07:00
Mitchell-JohnsonandClaude Fable 5.1 ad88a48c01 space: the diff's clock runs inside sudo, so zfs is killed too
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 01:19:19 -07:00
Mitchell-JohnsonandClaude Fable 5.1 314a021a3e space: zfs diffs run side by side, give up after four minutes, and die properly
The timeout killed sudo and left zfs holding the pipe, so the refresh of tank sat on
immich's diff indefinitely. The process group is killed now, and a slow dataset is walked
by mtime instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 01:13:05 -07:00
Mitchell-JohnsonandClaude Fable 5.1 53d1da08a5 Space keeps its scans and refreshes them: zfs diff, folder mtimes, resume after cancel
A scan is saved beside the config and read back after a restart. The next scan starts
from it: on a pool with snapshots zfs diff names what changed and only those folders are
read; elsewhere a folder whose mtime is unchanged keeps its list and only its kept files
are stat'ed again. A cancelled scan keeps what it walked and Resume reads the rest.
Forget drops the saved result. The treemap is every file a tile, grouped by folder, with
no nested boxes: the folder chains of a Linux system made a staircase of empty headers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 00:43:30 -07:00
Mitchell-JohnsonandClaude Fable 5.1 c96d6facad PLAN: Space scans a disk or a pool, from a dropdown
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:28:00 -07:00
Mitchell-JohnsonandClaude Fable 5.1 8f77cc23e1 Space: the picker itself — pools with layout and members, then disks on their own
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:27:20 -07:00