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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
A pool's member disks are listed under it, not offered apart; the picker shows the
layout, members, size and scan state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The process table carries each logical CPU's core id from sysfs topology; a row with
no reading still has the temperature cells, so the % column is one column.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pick a disk, not a partition; the scan walks every filesystem it backs, as root when
the host allows, in a helper process that streams the tree back. A tree of folders
largest-first over a treemap of the selected folder, nested to the file and coloured by
kind, plus the thousand largest files. The rail no longer offers the hub-only pages
while a remote machine is being looked at.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On a phone the list gives way to the pane and Back returns; on a desktop the list stays
beside it and there was no way to put the pane away.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The login is a user name and a password. The old single password becomes the account
named after the OS user on first boot, along with its sessions and the machine-wide look.
Settings gains Users (add, remove, reset a password — your own password confirms) and
Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents
and the dashboard layout follow the account; the refresh rate, graph history and alerts
stay the machine's. Pairing a machine takes a user on it. Log lines name the actor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An Apple SSD presents three namespaces and the kernel lists each as a whole disk, so the
dashboard was showing one drive as three. The Disks page has always folded them; now the
dashboard, the Top tab and the disk.* sensors do too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A link to a hub page no longer reloads onto the last machine; a link from a hub-only page to
a machine's page arrives at that page; a page being left no longer asks about the next page's
selection; the hub holds its machines to the rate it is itself running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The name a machine was added under stays its identity — the URL, its accent, what the hub's
API calls it — and is what the Machines page shows as 'Added as'. The hostname is read live
rather than at start-up, so renaming a machine reaches the console without a restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The process table's columns fit the box they are in, the rate control saves, curves are
timed on the viewer's clock and keep to the stride, the power icon is drawn, and a young
history draws in from the right instead of being stretched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Boxes with their name in the border, dot curves that climb green to red, per-core meters and
temperatures, memory and filesystems, per-interface traffic and the process table. Drawn from
the sensor stream and /api/procs, so it works on every machine the console can see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each machine keeps 300 points per curve whatever the window, so memory doesn't follow it;
a wider window means wider points, each the average of its bucket rather than one instant
out of it. The hub passes the setting to every machine it manages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A named volume is deleted by typing its name, one at a time; the API wants it named
twice; nothing is forced. Prune touches anonymous volumes only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A new stack is a folder in a home (where stacks are already kept: Dockhand's group here,
~/stacks elsewhere) with compose.yaml and an optional root-only .env, validated by compose
before it is kept and deployed the way its neighbours were — inside dockhand, or to a node
over tcp from the hub. New container is the same dialog on a form that writes the compose.
Homes are scanned, so undeployed stacks stay listed. Delete moves a stack's files to
.hamlab-trash; nothing is forced, and volumes and data are never touched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every icon is rendered from web/scripts/logo-source.png by scripts/icons.mjs: trimmed to
the artwork's own edge, transparent corners for the browser icons, a dark ground from the
artwork's palette for iOS (which paints transparency black) and for Android's maskable
icon, where the whole artwork sits inside the 80% safe circle. The login screen no longer
crops the logo to a circle or repeats the wordmark. Service worker cache bumped so
installed apps fetch the new files.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stacks: deploy with every compose file (labels from inspect, not comma-joined ps);
refuse a stale overwrite; empty list without Docker. Alerts: firing state survives a
restart without re-announcing; vanished alerts are recorded as cleared with the reason;
rule values validated; 200 rules. Docker: 404 for a removed container's logs. Skins: four
palettes lifted to readable contrast. Phone: bigger toggles and swatches. Installer:
deploy republishes; an older published build is never installed silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stacks are found from container labels; the file is resolved on the host or through an
orchestrator's mounts, and compose runs where the file makes sense (inside Dockhand here),
so a redeploy is identical to how the stack was deployed. Save validates with compose and
keeps a .hamlab-bak; Deploy / Update / Restart / Stop run as followed tasks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Alerts: rules over sensors, watched fleet-wide from the hub; ntfy and webhook channels;
firing count on the rail. Docker: containers by stack with live use, start/stop/restart,
following logs, inspect, images/volumes/networks. Skins: HUD, Nord, Gruvbox, Catppuccin
(+Latte), Dracula, Tokyo Night, Solarized (+Light). Accents: per-machine colour.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Widgets: a bad pattern no longer silences every widget; malformed layouts load.
Shell: lost session returns to login at once; tab titles; Escape closes menus and dialogs.
Files: names are one path segment; unsaved edits ask before page/machine/tab changes;
only folders that opened are remembered. Terminals: unique default names; 64-char names.
API: queries capped at 10 000 values; 32 streams per client, 512 in all; only
/embed/widget is frameable. ZFS sensors: one unprivileged zpool list, no sudo journal spam.
Phone: 36 px inputs; dataset rows as wide as their cells. Machines: valid name pattern.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Access-Control-Allow-Origin arrived twice on /api/nodes/<m>/sensors, which a
browser on another origin refuses. Also: demo lists say when the token hides them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>