Author SHA1 Message Date
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
69 changed files with 9058 additions and 1115 deletions
+2 -15
View File
@@ -2,7 +2,6 @@
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
GO ?= go GO ?= go
PREFIX ?= /usr/local/bin PREFIX ?= /usr/local/bin
PUBLISH ?= /home/core/apps/hamlab-demo/site
# every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi # every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi
ARCHES ?= amd64 arm64 arm 386 riscv64 ARCHES ?= amd64 arm64 arm 386 riscv64
@@ -10,7 +9,7 @@ LDFLAGS = -s -w -X main.version=$(VERSION)
# static, no cgo: the same file runs on glibc, musl (Alpine) and anything else with a Linux kernel # static, no cgo: the same file runs on glibc, musl (Alpine) and anything else with a Linux kernel
GOBUILD = CGO_ENABLED=0 GOOS=linux GOARM=6 $(GO) build -trimpath -ldflags "$(LDFLAGS)" GOBUILD = CGO_ENABLED=0 GOOS=linux GOARM=6 $(GO) build -trimpath -ldflags "$(LDFLAGS)"
.PHONY: all web build go dev cross release publish gitea-release deploy push-nodes push-nodes-force test clean .PHONY: all web build go dev cross release gitea-release deploy push-nodes push-nodes-force test clean
all: build all: build
@@ -61,9 +60,7 @@ gitea-release: release
deploy: build cross deploy: build cross
./bin/hamlab install --prefix $(PREFIX) ./bin/hamlab install --prefix $(PREFIX)
@sleep 1; $(PREFIX)/hamlab push-nodes || true @sleep 1; $(PREFIX)/hamlab push-nodes || true
@# The public installer must never lag the fleet: it would offer everyone an older build, and @echo "deployed $(VERSION); 'make gitea-release' publishes it for everyone else"
@# "upgrade" a current machine backwards. Publish only where this hub serves the download site.
@if [ -d $(PUBLISH) ]; then $(MAKE) --no-print-directory publish; fi
# push the installed binary to every managed machine (what deploy does last) # push the installed binary to every managed machine (what deploy does last)
push-nodes: push-nodes:
@@ -75,13 +72,3 @@ push-nodes-force:
clean: clean:
rm -rf bin dist internal/web/dist web/dist rm -rf bin dist internal/web/dist web/dist
# Put the release where `curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh` finds it:
# the static site beside the demo page (nginx, public through NPM). Binaries first, VERSION
# last, so nobody is ever pointed at files that aren't there yet.
publish: release
mkdir -p $(PUBLISH)/dl
cp dist/hamlab-linux-* dist/SHA256SUMS $(PUBLISH)/dl/
cp dist/install.sh $(PUBLISH)/dl/install.sh && cp dist/install.sh $(PUBLISH)/install.sh
cp dist/VERSION $(PUBLISH)/dl/VERSION
@echo "published $(VERSION): curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh"
+294 -32
View File
@@ -18,6 +18,8 @@ stub tab or a roadmap item.
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. | | Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. | | Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. | | Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
## Layout ## Layout
@@ -176,6 +178,44 @@ something long in one, switch away and back, refresh — it's all still there.
the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms
and 10 min at 2 s. Still to do: persistent and 10 min at 2 s. Still to do: persistent
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges. history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
- **Updating from the Updates tab** — **shipped 2026-09-26**: the "what is out of date" list
was read-only, so seeing a newer image and doing something about it were different places.
Each row now has **Update**, and the header has **Update all**. Updating is per stack —
that is the unit compose deals in — so a row acts on whichever stacks use that image, and
several stacks run through a queue one at a time rather than recreating half the machine at
once, each with its task output on screen. A row that succeeds says "updated just now"
instead of re-checking, since a check is a registry request.
- **Recreate, and the Updates tab at full width** (2026-09-26): a stack's actions gained
**Recreate** (`up -d --force-recreate`), the only way out of a container Docker refuses to
start — one pinned to a network that has since been recreated answers "network … not
found" to Deploy and Update alike, because as far as compose is concerned nothing about it
changed. Found the hard way when a SiYuan update landed in exactly that state. The Updates
tab also stopped rendering as a list-and-detail pair: it is a page, not a list, so on a
wide window it now sits under the tab strip at full width instead of being squeezed into
the right-hand half beside an empty column.
- **Cron in English** (`crontext.ts`, 2026-09-26): schedules read "daily at 3:15am" rather
than `15 3 * * *`, with the expression kept beside it. Handles lists, ranges, steps, day
and month names, `@daily` and friends, weekdays/weekends, midnight and noon — and declines
to describe anything it is not sure of, because a confident wrong sentence is worse than
five fields. The Jobs tab had its own smaller version of this; it now uses this one.
- **Hardware tab** — **shipped 2026-09-25** (`internal/hardware`, `internal/api/hardware.go`,
`Hardware.tsx`): what the machine is physically made of, per machine like every other page.
Four headline tiles (processor, memory, storage, graphics) over cards for each: the
processor with its cores, clock range, socket, caches, microcode and the handful of feature
flags anyone asks about; **every memory slot** with size, type, form factor, rated and
running speed, maker, part number, serial, rank and voltage, and the empty ones marked
empty; each drive with model, size, bus, firmware and serial; each display adapter with its
driver, PCI address and video memory; and the board itself — model, firmware and date,
serial, kernel. Read from what Debian already has (dmidecode for what only the firmware
knows, lscpu, lspci, sysfs) and cached for ten minutes, since none of it changes under a
running kernel; "Re-read" is there for the one thing that does, a drive plugged in.
Every reader may come back empty and the page says what it could not read and why: a
machine with no dmidecode, no root, or no PCI bus still gets a page. Verified on `core`
(SMBIOS, Intel graphics) and on the Mac mini (Asahi: no SMBIOS memory table, a
device-tree GPU, heterogeneous cores — where lscpu's "cores per socket" counts one
cluster, so without SMT the thread count is the honest core count).
- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`, - **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
`internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`; `internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`;
reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID; reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID;
@@ -186,7 +226,12 @@ something long in one, switch away and back, refresh — it's all still there.
a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets
on the Dashboard tab (briefly renamed "Monitors" 2026-09-19, back to "Dashboard" on the Dashboard tab (briefly renamed "Monitors" 2026-09-19, back to "Dashboard"
2026-09-20 at Mitchell's request; the view id and URL were always `dashboard`), and a 2026-09-20 at Mitchell's request; the view id and URL were always `dashboard`), and a
frameable `/embed/widget` for Glance and friends. The stock Dashboard modules and the Disks frameable `/embed/widget` for Glance and friends. (An external demo page was deployed at
`hamlab-demo.bigham.dev` and linked from the Widgets tab 2026-09-19; **both removed
2026-09-23** at Mitchell's request — the Widgets tab shows the same thing against the
machine you are signed in to, so the second copy earned nothing and cost a public
read-only token, which was revoked with it. `examples/external-demo/` stays as an example
to read.) The stock Dashboard modules and the Disks
tab read the sensor API too (`sampleAdapter.ts`); `/api/metrics` is retired. tab read the sensor API too (`sampleAdapter.ts`); `/api/metrics` is retired.
Also per-service and per-container sensors (memory and CPU from cgroups), tokens limited Also per-service and per-container sensors (memory and CPU from cgroups), tokens limited
to ID patterns, and `/api/fleet/sensors/query` for the whole fleet in one request. to ID patterns, and `/api/fleet/sensors/query` for the whole fleet in one request.
@@ -247,6 +292,107 @@ something long in one, switch away and back, refresh — it's all still there.
use", and "Remove unused" takes every image no container uses. Nothing is ever forced: use", and "Remove unused" takes every image no container uses. Nothing is ever forced:
Docker refuses what is still in use, and that refusal is the answer. Still absent: Docker refuses what is still in use, and that refusal is the answer. Still absent:
removing networks. removing networks.
- **Top merged into Processes** — **2026-09-23.** The two tabs answered the same question
from the same data (Top already read `/api/procs`, the endpoint the Processes table was
built on), so at Mitchell's instruction there is now one tab, **Processes**, showing what
Top showed: the btop-style whole-machine screen. `Procs.tsx` is deleted; the `top` view
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
Top sat rather than falling to the bottom.
- **Dashboard retired** — **2026-09-23**, at Mitchell's instruction, the same reasoning that
merged Top into Processes: the app repeated itself. The customisable module list is gone
(`Dashboard.tsx` deleted), and with it `/api/prefs/dashboard`, `applyPrefs`, and the
per-user `dashboard` blob in `state.json`. The Processes tab already shows those numbers
on one screen. `fmtBytes`/`fmtRate` moved to `fmt.ts` (Disks, Files and Space use them);
`MONITOR_HISTORY` and `seriesOf` went with the page. The default view after login, after
switching machines, and when a hub-only page is left behind, is now **Processes**. A saved
rail order naming `dashboard` degrades quietly — `orderBy` drops ids with no page.
**Widgets stays and says what it is for**: a notice at the top explains these are for
dashboards *outside* Ham Lab — Glance, Home Assistant, a wall display — served from
`/embed/widget` with a read-only sensor token, not a second copy of the console's screens.
**It also closed a real bug.** `applyPrefs` set the machine's sampling rate from
`intervalMs` inside the dashboard layout, and its `else` branch reset the sampler to the
default whenever that field was absent. Since layouts became per-user, one account saving
a dashboard could silently reset the sampling rate for the whole machine, overriding the
fleet refresh rate set in Settings. The sampler now has one source: `refreshMs` in the UI
prefs, which the hub fans out, remembered per machine as `sampler_ms`.
- **ZFS tab, with scheduled snapshots** — **shipped 2026-09-24** (`internal/storage/snapshots.go`,
`internal/api/zfs.go`, `ZFS.tsx`). Pools, datasets and snapshots moved out of Disks —
Disks is the hardware, ZFS is the filesystem on top of it — and the tab gained what it was
split out for: **snapshot schedules**. Hourly, daily, weekly or monthly, per dataset,
optionally recursive, with a **retention period** and a floor that keeps the newest few
whatever their age. A missed window (the machine was off) runs once on return rather than
being skipped.
**The naming is zfSnap's, deliberately.** `grid` has run zfSnap v1.11.1 from root cron at
02:30 with a 6-week TTL since before this repo existed, and it works. So rather than
invent a second convention for two tools to disagree over, this writes and reads exactly
what zfSnap does — `tank@2026-09-24_02.30.01--6w`, **retention encoded in the name**. Three
consequences: Ham Lab shows and expires the snapshots zfSnap already took; the two can run
side by side without fighting, since both only remove what has passed the expiry written
into its own name; and changing a schedule never re-dates old snapshots, because each
carries the rule it was taken under. The page **detects the existing cron job**
(zfSnap, zfs-auto-snapshot, sanoid, or a bare `zfs snapshot`) and says so, warning if a Ham
Lab schedule is running as well.
**Nothing is destroyed unseen.** Only names that parse as a stamp and a TTL are ever
candidates for expiry, so a snapshot taken by hand is invisible to a schedule; Run now
says that expired snapshots go with it; and a snapshot deleted by hand is confirmed first
and needs its full name echoed back by the caller. (A "what would it delete?" preview
shipped and was removed the same day as redundant — Mitchell's call.)
The datasets table is a **snapshot explorer**: each dataset shows how many snapshots it has
and what they cost, and opens into the snapshots themselves — when each was taken, **when
it expires** (read from the TTL in its own name, so no server call), its size, and Delete.
Deleting takes the same-named snapshot on descendant datasets too, which is how a recursive
schedule made it.
**A schedule is a real cron job** (2026-09-24, Mitchell's call after the first cut ran it
from a ticker inside the daemon). Ham Lab writes `/etc/cron.d/hamlab-zfs`, owns it, and
rewrites it whole on every change; each line calls `hamlab zfs-snapshot <id>` as the
account Ham Lab runs as, reaching zfs through sudo as the daemon does. Snapshots are
backup-shaped and must not stop because a console is down, upgrading, or uninstalled — and
a cron line is greppable by anyone looking at the machine the normal way. **A cron
expression is the single description of when a schedule fires**: the four common shapes
render to one, `custom` states one 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. A run's outcome is written to `zfs-runs/<id>.json` beside the
config, never to `state.json`, because the cron job is a separate process and the daemon
would clobber it. An existing external job can be **adopted** — its schedule, retention
and dataset read out of the cron line into a new Ham Lab schedule.
**zfSnap is retired on `grid`** as of that day: Ham Lab now takes the nightly `tank`
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
had taken left untouched and fully readable here — which is what adopting its naming
bought. See `hamlet/docs/hosts.md`.
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`).
Paste a device's address; its own web page appears in the tab. **Nothing in it knows
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid`
at `10.0.0.217`, but none of that is in the code.
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
**The general problem, and the answer.** A device UI asks for its assets by absolute path
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
bodies per device would not be general. So the prefix is only the entry point: any request
Ham Lab does not serve itself is routed to whichever KVM the asking page belongs to, read
from the **Referer**, falling back to a **cookie** when there is none (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 device never receives Ham Lab's session cookie,
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
now. Redirects to the device's root are rewritten back through the prefix.
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
refused before it loaded.
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
does not need to, since 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 which version you are getting rather than
pretending.
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
to `grid` and you get grid's; adding a second for a machine replaces the first. The
addresses are still kept **on the hub** rather than on each machine, because a KVM earns
its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub
proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at
all. Not done: holding the device's own login on the hub, so its page does not ask for a
second password.
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
@@ -292,9 +438,16 @@ something long in one, switch away and back, refresh — it's all still there.
replace, exec in place. Config default moved to `~/.config/hamlab` (existing replace, exec in place. Config default moved to `~/.config/hamlab` (existing
`~/apps/hamlab` still found by the installer and the CLI). Tested in `~/apps/hamlab` still found by the installer and the CLI). Tested in
containers: Debian 12, Ubuntu 22.04, Fedora 40, Rocky 8, openSUSE Leap 15.5, Alpine containers: Debian 12, Ubuntu 22.04, Fedora 40, Rocky 8, openSUSE Leap 15.5, Alpine
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. Not done: an OpenRC 3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. **procd (OpenWrt) added
backend for the Services tab; runit/s6/sysvinit service files; public hosting for 2026-09-26** (`internal/setup/hamlab.procd`): detected on `/sbin/procd` + `/etc/rc.common`
`install.sh` (the Gitea repo is private, so the one-liner needs `HAMLAB_TOKEN`). ahead of the OpenRC branch, installed to the same `/etc/init.d/hamlab` path and told apart
from OpenRC at uninstall by `USE_PROCD=1` in the file. Verified on the new router
(`gate`, OpenWrt 25.12.5, running as root): `S95`/`K10` symlinks, procd supervising the
instance, Docker tab and stacks working through the hub. The Updates tab needed no change
— apk-tools 3 answers `apk version -l '<'` the same way Alpine's does. Not done: an
OpenRC or procd backend for the Services tab (it is systemd-only and says so cleanly);
runit/s6/sysvinit service files; public hosting for `install.sh` (the Gitea repo is
private, so the one-liner needs `HAMLAB_TOKEN`).
- **Users, sessions and settings that follow the account** — **shipped 2026-09-22** - **Users, sessions and settings that follow the account** — **shipped 2026-09-22**
(`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`, (`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`,
`Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's `Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's
@@ -406,8 +559,7 @@ something long in one, switch away and back, refresh — it's all still there.
still default to private. No token is needed by anything any more (the read-only one was still default to private. No token is needed by anything any more (the read-only one was
revoked; only `hamlab-publish`, which creates releases, remains). Gitea resolves revoked; only `hamlab-publish`, which creates releases, remains). Gitea resolves
`releases/download/latest/<asset>`, so `install.sh` and `HAMLAB_URL` now point straight `releases/download/latest/<asset>`, so `install.sh` and `HAMLAB_URL` now point straight
at the newest release and never need changing; the rolling copy on at the newest release and never need changing. What is public is the source, the history,
`hamlab-demo.bigham.dev` stays as a mirror. What is public is the source, the history,
`BUGS.md` and this file — which describes the lab and names the console's hostname. `BUGS.md` and this file — which describes the lab and names the console's hostname.
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, optional NPM - **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, optional NPM
access list restricting to WireGuard/LAN. access list restricting to WireGuard/LAN.
@@ -435,13 +587,80 @@ something long in one, switch away and back, refresh — it's all still there.
`POST /api/password`, current password required), Access tokens (list/revoke the tokens `POST /api/password`, current password required), Access tokens (list/revoke the tokens
hubs hold), About (version, host, arch, uptime, certificate). hubs hold), About (version, host, arch, uptime, certificate).
## Network tab (2026-09-27)
A **Network** tab, per machine like every other: the interfaces as the kernel has them
(addresses, MAC, MTU, negotiated link speed, carrier, byte totals, live rates from the
sampler's own sensors), the default route and resolvers, what the internet sees of this
connection, and an **internet speed test**.
Decisions worth keeping:
- **Measured by the daemon, not the browser.** The test runs on the machine in the switcher,
so the figure is that machine's line — the router's, if you switch to `gate` — and it
carries on if the phone locks or the tab closes. The page polls a run it is not driving.
- **No tool to install.** It measures against Cloudflare's public speed endpoints
(`__down`, `__up`, `cdn-cgi/trace`), which is what makes it work identically on Debian and
on OpenWrt, where `opkg install` costs flash. That is the feature's one external
dependency, and it is named in `internal/network/speedtest.go`.
- **Several streams, HTTP/2 off, and the ramp thrown away.** One stream over one connection
measures a congestion window; HTTP/2 would put every stream back on one connection and do
the same. The first second of each phase is measured and discarded.
- **Latency is the round trip less the far end's own Server-Timing.** A zero-byte response
is mostly Cloudflare's worker running — 20 to 45 ms of it against a few ms of wire. Left
in, it reported 31 ms for a 7 ms line. Jitter is the mean change between consecutive
probes, not a standard deviation, so one slow probe doesn't run away with it.
- **A refused phase is not a number.** The far end rate-limits by volume, and a test moves a
few hundred megabytes; several in a row earn 429s, and the block observed while building this
lasted over twenty minutes. So: a 20-second cooldown counted from the last *attempt* (a
refused test still used the line), a stream that retries twice before giving up, the rate
computed over the window in which bytes were actually still arriving, and a plain sentence
when it was cut short. Coasting to the end of the clock with nothing arriving reported a
third of the truth.
- **A second download source, for when the first refuses.** A plain 100 MB file on Linode's
Seattle mirror, which has no opinion about volume. It is only reached after a refusal, and
every result names the source it used — that mirror is in one place, so on a machine far from
Seattle it would read low, and a number that quietly came from somewhere else would be a lie.
Upload stays on Cloudflare, which has never been the one to refuse.
- **The last result is published as sensors** (`net.internet.*`), so the line is a widget, a
graph, an alert or a Prometheus series like anything else. Each carries the time the test
ran, not the time it was asked for.
- **Not scheduled.** Nothing takes a speed test on its own. A nightly one recorded to the
history is the obvious next step and deliberately not built yet — the cron shape for it
already exists twice over (ZFS snapshots, Docker image updates).
## VPN tab (2026-09-27)
A **VPN** tab beside Network, per machine: every WireGuard tunnel on the machine in the
switcher, and the devices on it — name, address, last handshake, where it connects from, and
what it has moved.
- **Per machine is the point, not a limitation.** The tunnel people dial into and a tunnel a
machine dials out through are on different boxes here (`gate` and `hamtv`), and a combined
view would invite exactly the question "which box is this on".
- **The kernel is the truth, the config only supplies names.** `wg show all dump` has the
handshakes and counters and knows nothing but public keys; names come from the `# comment`
above a peer in a wg-quick file, or a UCI section's `description` on OpenWrt. Both are read
only to label what the kernel already said.
- **Server or client is worked out, not declared.** A peer allowed `0.0.0.0/0` that has an
endpoint is a way out, so a tunnel holding one is this machine dialling out; a tunnel with a
listen port and peers is one being dialled. Anything else says neither rather than guessing.
- **"Online" is a handshake in the last three minutes**, and the tab says so. WireGuard is
silent when idle; there is no connection to be in, and a tab claiming otherwise would be
lying twice a day.
- **Read-only, and no secrets.** Private keys are in the second column of the dump and are
read past; pre-shared keys are noted as present and never read. Adding or editing peers is
not here: it means generating keys and writing the machine's network configuration, which is
a bigger thing than this tab, and a bad thing to get half right.
## To do ## To do
- **Deprecate copyparty** — asked for 2026-09-23, not started. `copyparty` on `grid` - **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
(`copyparty.service`, `:3923`, `/etc/copyparty.conf`, serving all of `/tank` as user polish, and deprecating copyparty), merged 2026-09-24: they are the same tab and the same
`core` with one account behind NPM) overlaps the **Files** tab almost entirely. The aim work. `copyparty` on `grid` (`copyparty.service`, `:3923`, `/etc/copyparty.conf`, serving
is to stop the service and keep only Ham Lab. What Files must gain first, in the order all of `/tank` as user `core` with one account behind NPM) overlaps **Files** almost
that matters: entirely. The aim is to stop the service and keep only Ham Lab, with general ease-of-use
polish along the way. What Files must gain first, in the order that matters:
- **Multi-select and bulk actions.** copyparty selects many rows and moves, deletes or - **Multi-select and bulk actions.** copyparty selects many rows and moves, deletes or
downloads them together; Files acts on one row at a time. downloads them together; Files acts on one row at a time.
- **Download a folder**, and a multi-file download, as a streamed zip or tar — the thing - **Download a folder**, and a multi-file download, as a streamed zip or tar — the thing
@@ -461,27 +680,70 @@ something long in one, switch away and back, refresh — it's all still there.
`hamlet`'s `docs/hosts.md` bare-metal list, `docs/gotchas.md` (the "`docker ps` is not `hamlet`'s `docs/hosts.md` bare-metal list, `docs/gotchas.md` (the "`docker ps` is not
the inventory" note names it) and the `grid` row in `CLAUDE.md`. the inventory" note names it) and the `grid` row in `CLAUDE.md`.
- **Deprecate Dockhand** — asked for 2026-09-23, not started; the *homelab* half of this - **Grow the Docker tab until Dockhand can go** — asked for 2026-09-23 and widened
(migrating every stack off Dockhand's data dir, then removing the container) is 2026-09-24 to take in Docker tab polish and feature work, on the same reasoning as the
`hamlet`'s `docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", Files item: it is one tab, and the gaps that block retiring the old tool and the things
agreed 2026-09-20. This entry is the *app* half only: what Ham Lab must do before that that would simply make it better are the same body of work. The *homelab* half —
migration can start. Its Stacks page already creates, edits, deploys, updates, restarts, the cutover itself, then moving every stack off Dockhand's data dir — is `hamlet`'s
stops, downs and deletes stacks on every machine, so the gaps are Dockhand's extras: `docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", agreed
- **Git-sourced stacks.** Dockhand's `stack_sources` table points a stack at a repo and 2026-09-20. This entry is the *app* half.
pulls it. Ham Lab has no equivalent. Decide whether to build it or to drop it — most
stacks here have no row in that table anyway, so dropping it may be honest. **Done 2026-09-24 — the eight Mitchell picked from the inventory**, all in the Docker tab:
- **Scheduled image updates.** Ham Lab checks for new images only when asked (a registry per-container **Stats**, **Health** (with the last few probe outputs), **Events** (Ham Lab
request Docker Hub counts) and never updates by itself. If anything relies on Dockhand keeps its own 4000-entry ring, since `docker events` forgets after about a minute),
updating on a schedule, that must move here or be dropped on purpose. **Shell** (`docker exec`, handed to the Terminal tab) and **Limits** (`docker update` for
- **Webhooks / per-host environments**, if either is in use — inventory before deciding. memory, CPU and restart policy); an **updates** tab giving the whole machine at once with
- **Deploying from a host path without an orchestrator container.** Ham Lab already runs a per-container indicator in the list; and **schedules**, which write
compose inside `dockhand` because that is how the stacks were deployed; once they move `/etc/cron.d/hamlab-docker` and call
to a neutral home the "via" path is unused for them, but a stack deployed to `grid` or `hamlab docker-update <id>` — the shape the ZFS schedules already use.
the Mac still goes over `tcp://<node>:2375` from the hub. Decide whether Ham Lab should Also done: Ham Lab keeps working when `dockhand` is removed. It remembers where a
instead ask the node's own Ham Lab to deploy locally, which would end the `*:2375` container's bind mounts pointed (`mounts` in `state.json`) and resolves a stack through
exposure (its own item in `docs/pending.md`). that record when no running container explains its path, running compose on the host
- *Not app work, but the trigger:* nothing here blocks starting the migration except the instead; `repoint` does the same for a schedule at run time. Without this, removing the
inventory. Do that first. container would have made 17 stacks on `core` and 5 on `grid` unfindable at once.
- **Left from the Dockhand inventory, both to decide rather than build.**
- *Git-sourced stacks.* Dockhand's `stack_sources` table points a stack at a repo and
pulls it. Its `git_repositories` and `git_stacks` tables are **empty**, so nothing
here uses it — dropping it is the honest answer unless Mitchell says otherwise.
- *Webhooks, OIDC, LDAP, vulnerability scans.* All four tables empty. Nothing to move.
**Done 2026-09-25 — the two gaps in the tab itself.**
- *Logs are followed over a held connection*, the way the Services tab follows a journal
(`internal/api/docker_logs.go`, `/api/docker/containers/{id}/logs/tail`): one
`docker logs -f` per viewer, stdout and stderr both, stderr coloured as an error. A
reconnect resumes from the last line's timestamp, so a container that restarts or a
phone that slept doesn't replay what is already on screen; the client dedupes by line
rather than by "later than", because `--since` is inclusive and two lines can share a
timestamp. Docker replays the opening backlog down two pipes in no particular order, so
the first 300 ms is held and sorted before it is sent. The polled `?since=` endpoint
stays — it is what the resume is built on. Reconnects back off to 15 s, since a
container that has stopped ends the stream immediately.
- *Networks can be removed and pruned* — `POST /api/docker/networks/{remove,prune}`, the
shape images and volumes already had. The overview says who is attached to each one, so
the ✕ is only offered on a network nothing uses and never on Docker's own three. Prune
is deliberately untested against this host: `core` has several networks belonging to
stacks that are merely down (`pihole_default`, `transmission_default`, `gluetun_default`
among them), and pruning would take them and hand them new subnets on the next deploy.
- **The `*:2375` question, which this is the moment to settle.** A stack deployed to
`grid` or the Mac still goes over `tcp://<node>:2375` from the hub. Decide whether the
hub should instead ask the node's own Ham Lab to deploy locally, which would end the
unauthenticated Docker socket exposure — its own item in `docs/pending.md`, currently
filed under "decided, no action", and this is the change that would make closing it
free.
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
which is the comparison Ham Lab has invited since its first line. Nothing exists yet.
What a future session has to settle before writing code: which stack (libvirt/KVM +
`virsh` is the obvious one for VMs; LXC or Incus for containers, and Incus covers both);
whether it manages what is already installed or installs it; where disk images live
(`tank` on `grid`, local NVMe elsewhere); console access (SPICE/VNC proxied through the
daemon, or a serial console over the existing terminal machinery, which would reuse
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
at all, which is every host here today. Worth scoping as its own plan before starting.
## Open questions ## Open questions
+10
View File
@@ -54,6 +54,14 @@ reset a password; your own password confirms the last two). From the shell:
users` lists them, `hamlab user-rm <user>` removes one. Machine settings — refresh rate, users` lists them, `hamlab user-rm <user>` removes one. Machine settings — refresh rate,
graph history, alert rules — stay the machine's and are shared by everyone. graph history, alert rules — stay the machine's and are shared by everyone.
**Guest logins**, for showing somebody the console: Users → Guest logins makes a
throwaway user name and password, good for half an hour by default (up to 24 hours), and
hands you the pair to paste into a message. A guest is an ordinary account with a clock on
it — the whole console, every machine, the terminal included — that stops working when the
time is up, wherever it is signed in, or the moment you revoke it. There is no reduced
guest: give one to somebody you would hand the password to, and revoke it if you change
your mind.
What adapts to the host, rather than assuming Debian: What adapts to the host, rather than assuming Debian:
| | | | | |
@@ -62,6 +70,8 @@ What adapts to the host, rather than assuming Debian:
| Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) | | Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) |
| Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd | | Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd |
| Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there | | Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there |
| Network | `/proc` and `/sys` for the interfaces, addresses, link speed and default route. The speed test needs no tool installed: it measures against Cloudflare's public speed endpoints, so it works the same on Debian and on the OpenWrt router |
| VPN | WireGuard tunnels and their peers, from `wg show` where wireguard-tools is installed; peer names from wg-quick `.conf` comments or OpenWrt UCI descriptions. Read-only, and it never reads a private or pre-shared key |
| Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed | | Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed |
| Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start | | Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start |
| Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept | | Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept |
+7
View File
@@ -16,6 +16,7 @@ cpu.usage cpu.core.<n>.usage cpu.temp load.1|5|15 uptime
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
temp.<chip>.<label> fan.<chip>.<label> temp.<chip>.<label> fan.<chip>.<label>
net.<iface>.rx|tx|rx_total|tx_total|up net.<iface>.rx|tx|rx_total|tx_total|up
net.internet.download|upload|latency|jitter|tested|ip|edge
disk.<dev>.read|write|util|size|temp disk.<dev>.read|write|util|size|temp
fs.<mount>.size|used|used_pct fs.<mount>.size|used|used_pct
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
@@ -26,6 +27,12 @@ ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between - Built from names the hardware reports, never enumeration order (`hwmon3` moves between
boots). Two identical chips collide; the second gets `_2`. boots). Two identical chips collide; the second gets `_2`.
- Which sensors exist differs per machine. That is the point of the catalog. - Which sensors exist differs per machine. That is the point of the catalog.
- `net.internet.*` is the Network tab's speed test: `download` and `upload` in Mbps,
`latency` and `jitter` in ms, `tested` the age of the reading in seconds, and `ip` and
`edge` the public address and the Cloudflare edge as strings. They appear only once a test
has run on that machine, and each one's `t` is when it ran — so a widget or an alert on
them is reading a measurement from that moment, not a live value. Nothing takes a speed
test on its own; something has to press the button.
## A sensor ## A sensor
+114 -1
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -31,6 +32,7 @@ import (
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/nodes" "hamlab/internal/nodes"
"hamlab/internal/priv"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/setup" "hamlab/internal/setup"
"hamlab/internal/space" "hamlab/internal/space"
@@ -59,6 +61,8 @@ func main() {
serve run the console in the foreground (what the service runs) serve run the console in the foreground (what the service runs)
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped) passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
users list the accounts users list the accounts
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
docker-update <id> run one image-update schedule (what its cron line calls)
user-rm <user> remove an account and sign it out everywhere user-rm <user> remove an account and sign it out everywhere
check verify the config and state load, then exit check verify the config and state load, then exit
version version
@@ -129,6 +133,14 @@ func main() {
if err := passwd(cfg, flag.Arg(1)); err != nil { if err := passwd(cfg, flag.Arg(1)); err != nil {
log.Fatal(err) log.Fatal(err)
} }
case "docker-update":
if err := dockerUpdateJob(cfg, flag.Arg(1)); err != nil {
log.Fatal(err)
}
case "zfs-snapshot":
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
log.Fatal(err)
}
case "users": case "users":
migrateUsers(cfg) migrateUsers(cfg)
if len(cfg.Users) == 0 { if len(cfg.Users) == 0 {
@@ -139,7 +151,14 @@ func main() {
if !u.LastSeen.IsZero() { if !u.LastSeen.IsZero() {
seen = u.LastSeen.Local().Format("2006-01-02 15:04") seen = u.LastSeen.Local().Format("2006-01-02 15:04")
} }
fmt.Printf("%-20s created %s last seen %s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen) note := ""
if u.Guest { // a temporary look-only login, not one of the machine's accounts
note = " guest, until " + u.Expires.Local().Format("15:04")
if u.Expired(time.Now()) {
note = " guest, expired"
}
}
fmt.Printf("%-20s created %s last seen %s%s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen, note)
} }
case "user-rm": case "user-rm":
if err := userRm(cfg, flag.Arg(1)); err != nil { if err := userRm(cfg, flag.Arg(1)); err != nil {
@@ -178,6 +197,100 @@ func main() {
// migrateUsers gives a pre-users config its first account, named after this OS user, so // migrateUsers gives a pre-users config its first account, named after this OS user, so
// the CLI sees the same accounts the daemon will. // the CLI sees the same accounts the daemon will.
// dockerUpdateJob pulls a stack's images, and deploys them when the schedule says to. This
// is what the cron line Ham Lab writes actually calls, so image updates keep happening
// whether or not the console is up.
func dockerUpdateJob(cfg *config.Config, id string) error {
if strings.TrimSpace(id) == "" {
return errors.New("usage: hamlab docker-update <schedule-id>")
}
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
if err != nil {
return err
}
var raw json.RawMessage
st.View(func(s *store.State) { raw = s.DockerJobs })
var d struct {
Jobs []api.UpdateJob `json:"jobs"`
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &d); err != nil {
return fmt.Errorf("reading the schedules: %w", err)
}
}
for _, j := range d.Jobs {
if j.ID != id {
continue
}
// A pull that never finishes must not leave a cron job resident until the next reboot.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
out, runErr := api.RunUpdate(ctx, st, j, api.DockerTool())
rec := storage.RunRecord{At: time.Now(), Taken: j.Stack}
if runErr != nil {
rec.Error = runErr.Error()
}
if err := storage.WriteRun(cfg.DataDir(), "docker-"+id, rec); err != nil {
fmt.Fprintln(os.Stderr, "recording the run:", err)
}
fmt.Print(out)
return runErr
}
return fmt.Errorf("no image-update schedule %q — it may have been removed; this cron line is stale", id)
}
// zfsSnapshot runs one snapshot schedule: take it, then expire what has aged out. This is
// what the cron line Ham Lab writes actually calls, so a schedule keeps running whether or
// not the console is up — and keeps running if Ham Lab is never opened again.
//
// It deliberately does not write state.json. The daemon owns that file and would clobber
// anything written from out here; the outcome goes beside it instead, in zfs-runs/.
func zfsSnapshot(cfg *config.Config, id string) error {
if strings.TrimSpace(id) == "" {
return errors.New("usage: hamlab zfs-snapshot <schedule-id>")
}
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
if err != nil {
return err
}
var raw json.RawMessage
st.View(func(s *store.State) { raw = s.ZFS })
var z struct {
Policies []storage.Policy `json:"policies"`
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &z); err != nil {
return fmt.Errorf("reading the schedules: %w", err)
}
}
var p *storage.Policy
for i := range z.Policies {
if z.Policies[i].ID == id {
p = &z.Policies[i]
}
}
if p == nil {
return fmt.Errorf("no snapshot schedule %q — it may have been removed; this cron line is stale", id)
}
res, runErr := storage.New(priv.Available()).Run(context.Background(), p)
rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned}
if runErr != nil {
rec.Error = runErr.Error()
}
if err := storage.WriteRun(cfg.DataDir(), id, rec); err != nil {
fmt.Fprintln(os.Stderr, "recording the run:", err)
}
if runErr != nil {
return runErr
}
fmt.Printf("%s: took %s", p.Name, res.Taken)
if len(res.Pruned) > 0 {
fmt.Printf(", expired %d", len(res.Pruned))
}
fmt.Println()
return nil
}
func migrateUsers(cfg *config.Config) { func migrateUsers(cfg *config.Config) {
name := os.Getenv("USER") name := os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" { if u, err := user.Current(); err == nil && u.Username != "" {
+6 -6
View File
@@ -12,10 +12,10 @@ window.HAMLAB = { base: 'http://10.0.0.234:8100', token: '<read-only sensor toke
``` ```
Anyone who can load the page can read the token. If the page is public, limit the token to Anyone who can load the page can read the token. If the page is public, limit the token to
patterns that name nothing you'd mind the world reading (the `hamlab-demo` one shows patterns that name nothing you'd mind the world reading — hardware numbers and counts,
hardware numbers and counts, not container or service names). Deployed on `core` as the `hamlab-demo` stack — not container or service names.
see Hamlet `docs/docker.md`. Update it with
`cp examples/external-demo/index.html /home/core/apps/hamlab-demo/site/`.
To link it from the Widgets tab, set `"demo_url": "http://…"` in the hub's `config.json` This is an example to read and copy, not something that runs anywhere. It was deployed on
and restart Ham Lab. `core` as the `hamlab-demo` stack and linked from the Widgets tab until 2026-09-23, when
both were removed: the Widgets tab shows the same widgets against the machine you are
already signed in to, so a second public copy earned nothing and cost a public token.
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# Ham Lab installer. # Ham Lab installer.
# #
# curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh # curl -fsSL https://git.bigham.dev/mitchell/hamlab/releases/download/latest/install.sh | sh
# #
# With a terminal it walks you through it: who the console runs as, where it listens, # With a terminal it walks you through it: who the console runs as, where it listens,
# HTTPS, the firewall, the password — then downloads the build for this machine, checks # HTTPS, the firewall, the password — then downloads the build for this machine, checks
+245 -10
View File
@@ -3,8 +3,8 @@ package api
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with // The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at. // start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
// It drives the docker CLI (or podman), as the user first and as root if that is refused. // It drives the docker CLI (or podman), as the user first and as root if that is refused.
// Creating, removing and pruning are deliberately absent: whatever deployed the stacks // What can be removed here is what nothing uses: an untagged image, a volume no container
// (Dockhand, compose) stays the one place that changes what exists. // holds, a network nothing is attached to. Compose stays the one place a stack is defined.
import ( import (
"context" "context"
@@ -49,6 +49,10 @@ func dockerTool() string {
} }
// dockerRun runs the tool as this user, and again as root if the socket said no. // dockerRun runs the tool as this user, and again as root if the socket said no.
// DockerTool is whichever of docker or podman this machine has, for callers outside the
// package — the scheduled updates the CLI runs.
func DockerTool() string { return dockerTool() }
func dockerRun(ctx context.Context, args ...string) ([]byte, error) { func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
tool := dockerTool() tool := dockerTool()
if tool == "" { if tool == "" {
@@ -72,6 +76,18 @@ func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
return out, nil return out, nil
} }
// dockerWhy picks the line worth showing out of a failed command. Some subcommands —
// `network rm` among them — print the real reason first and then an aggregated "exit
// status 1", which is what dockerRun's last-line rule would otherwise hand back.
func dockerWhy(out []byte, err error) string {
for _, l := range strings.Split(string(out), "\n") {
if l = strings.TrimSpace(l); strings.HasPrefix(l, "Error") || strings.Contains(l, "daemon") {
return l
}
}
return err.Error()
}
func jsonLines(out []byte, each func(map[string]any)) { func jsonLines(out []byte, each func(map[string]any)) {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
var m map[string]any var m map[string]any
@@ -135,23 +151,30 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
} }
return rows return rows
} }
// Who uses what, from one inspect of every container: an image or a volume nothing uses is // Who uses what, from one inspect of every container: an image, a volume or a network
// the only kind this page offers to remove. // nothing uses is the only kind this page offers to remove.
imageUsed, volumeUsers := map[string]bool{}, map[string][]string{} imageUsed := map[string]bool{}
volumeUsers, networkUsers := map[string][]string{}, map[string][]string{}
if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 { if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 {
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}` const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}|{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}`
if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil { if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
p := strings.SplitN(line, "|", 3) p := strings.SplitN(line, "|", 4)
if len(p) < 3 { if len(p) < 4 {
continue continue
} }
name := strings.TrimPrefix(p[0], "/")
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 { if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
imageUsed[id[:12]] = true imageUsed[id[:12]] = true
} }
for _, v := range strings.Split(p[2], ",") { for _, v := range strings.Split(p[2], ",") {
if v != "" { if v != "" {
volumeUsers[v] = append(volumeUsers[v], strings.TrimPrefix(p[0], "/")) volumeUsers[v] = append(volumeUsers[v], name)
}
}
for _, n := range strings.Split(p[3], ",") {
if n != "" {
networkUsers[n] = append(networkUsers[n], name)
} }
} }
} }
@@ -171,8 +194,18 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
v["used_by"] = users v["used_by"] = users
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous") v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
} }
networks := list("network", "ls", "--format", "{{json .}}")
for _, n := range networks {
users := networkUsers[dstr(n, "Name")]
if users == nil {
users = []string{}
}
sort.Strings(users)
n["used_by"] = users
n["predefined"] = predefinedNetwork[dstr(n, "Name")]
}
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers, jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
"images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")}) "images": images, "volumes": volumes, "networks": networks})
} }
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
@@ -394,3 +427,205 @@ func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) {
log.Printf("docker: volume prune (from %s)", clientIP(r)) log.Printf("docker: volume prune (from %s)", clientIP(r))
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]}) jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
} }
// The three networks Docker makes itself. They hold nothing, cannot be removed, and
// their absence would break every container that asks for one by name.
var predefinedNetwork = map[string]bool{"bridge": true, "host": true, "none": true}
// networkRemove deletes one network. Unlike a volume there is no data in it, and unlike an
// image nothing has to be downloaded again — a stack that wants it back gets it recreated
// on the next deploy. Docker refuses one a container is still attached to, which is the
// check that matters, so this asks for no second confirmation.
func (s *Server) networkRemove(w http.ResponseWriter, r *http.Request) {
var body struct{ Name string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) {
jsonErr(w, 400, "which network?")
return
}
if predefinedNetwork[body.Name] {
jsonErr(w, 400, body.Name+" is one of Docker's own networks and can't be removed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
if out, err := dockerRun(ctx, "network", "rm", body.Name); err != nil {
jsonErr(w, 409, dockerWhy(out, err))
return
}
log.Printf("docker: network rm %s (from %s)", body.Name, clientIP(r))
w.WriteHeader(204)
}
// networkPrune removes every network no container is attached to. A stack that is merely
// down counts as unattached, so its network goes too — and comes back on the next deploy,
// with a new subnet. That is the whole cost, and the caller is told it.
func (s *Server) networkPrune(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
out, err := dockerRun(ctx, "network", "prune", "-f")
if err != nil {
jsonErr(w, 500, err.Error())
return
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
log.Printf("docker: network prune (from %s)", clientIP(r))
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
}
// ---- a container's health, its recent life, its console, and its limits ----
// dockerHealth is the health check's verdict and the last few probe results. Docker keeps
// the last five itself, which is all anyone wants: is it failing, and what did it say.
func (s *Server) dockerHealth(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
out, err := dockerRun(ctx, "inspect", "--format", "{{json .State}}", id)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
var st struct {
Status string `json:"Status"`
Health *struct {
Status string `json:"Status"`
FailingStreak int `json:"FailingStreak"`
Log []struct {
Start time.Time `json:"Start"`
End time.Time `json:"End"`
ExitCode int `json:"ExitCode"`
Output string `json:"Output"`
} `json:"Log"`
} `json:"Health"`
}
if json.Unmarshal(out, &st) != nil {
jsonErr(w, 500, "could not read the container's state")
return
}
if st.Health == nil {
jsonOK(w, 200, map[string]any{"configured": false, "state": st.Status})
return
}
jsonOK(w, 200, map[string]any{
"configured": true, "state": st.Status, "status": st.Health.Status,
"failing_streak": st.Health.FailingStreak, "log": st.Health.Log,
})
}
// dockerExec opens a shell inside a container, as a terminal session like any other — so
// it reattaches, resizes and scrolls back the same way, and works on a managed machine
// through the same proxy.
func (s *Server) dockerExec(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
tool := dockerTool()
if tool == "" {
jsonErr(w, 500, "neither docker nor podman is installed here")
return
}
var body struct {
Shell string `json:"shell"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
shell := strings.TrimSpace(body.Shell)
if shell == "" {
// most images have sh; bash is nicer where it exists, so try it first and fall back
shell = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi"
} else if strings.ContainsAny(shell, "\n\r") {
jsonErr(w, 400, "bad shell")
return
}
argv := []string{tool, "exec", "-it", id, "sh", "-c", shell}
if priv.Available() && !dockerReachable() {
argv = priv.Wrap(argv...)
}
sess, err := s.mgr.CreateCmd("", "sh "+shortName(id), argv)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("docker exec into %s as terminal %s by %s", id, sess.ID, clientIP(r))
jsonOK(w, 200, map[string]string{"id": sess.ID, "name": "sh " + shortName(id)})
}
func shortName(id string) string {
if len(id) > 20 {
return id[:20]
}
return id
}
// dockerReachable reports whether docker answers without help.
func dockerReachable() bool {
tool := dockerTool()
if tool == "" {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return exec.CommandContext(ctx, tool, "version", "--format", "{{.Server.Version}}").Run() == nil
}
// dockerUpdate changes a running container's limits and restart policy. `docker update`
// does this live, without recreating it and without touching the compose file — which is
// also the catch, and the caller is told: a deploy from compose puts back whatever compose
// says.
func (s *Server) dockerUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
var body struct {
Memory string `json:"memory"` // "512m", "2g", or "" to leave alone; "0" removes the limit
CPUs string `json:"cpus"` // "1.5", or "0" for no limit
Restart string `json:"restart"` // no | on-failure | unless-stopped | always
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
args := []string{"update"}
if m := strings.TrimSpace(body.Memory); m != "" {
if !regexp.MustCompile(`^[0-9]+([bkmgBKMG])?$`).MatchString(m) {
jsonErr(w, 400, "memory: a number, optionally with b, k, m or g — 0 removes the limit")
return
}
args = append(args, "--memory", m, "--memory-swap", m)
}
if c := strings.TrimSpace(body.CPUs); c != "" {
if !regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`).MatchString(c) {
jsonErr(w, 400, "cpus: a number like 1.5 — 0 removes the limit")
return
}
args = append(args, "--cpus", c)
}
if p := strings.TrimSpace(body.Restart); p != "" {
switch p {
case "no", "on-failure", "unless-stopped", "always":
default:
jsonErr(w, 400, "restart: no, on-failure, unless-stopped or always")
return
}
args = append(args, "--restart", p)
}
if len(args) == 1 {
jsonErr(w, 400, "nothing to change")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if _, err := dockerRun(ctx, append(args, id)...); err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("docker update %s %v by %s", id, args[1:], clientIP(r))
w.WriteHeader(204)
}
+229
View File
@@ -0,0 +1,229 @@
package api
// A container's history.
//
// `docker events` is a live stream with only a short in-memory backlog — ask it for the
// last thirty days and you get the last few minutes, because the daemon keeps events in a
// small ring and loses them all when it restarts. That is why Dockhand kept its own copy,
// and why this does too: a watcher follows the stream and appends the lifecycle events to a
// ring kept beside the config, so "what happened to this container last Tuesday" has an
// answer.
//
// Only lifecycle events are kept. A health check produces three exec events every probe,
// which is thousands a day and tells you nothing that the health panel does not.
import (
"bufio"
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"hamlab/internal/priv"
)
const (
eventsKept = 4000
eventsSaveEvery = 30 * time.Second
)
// ContainerEvent is one thing that happened to one container.
type ContainerEvent struct {
T int64 `json:"t"` // unix ms
Name string `json:"name"`
Action string `json:"action"`
Image string `json:"image,omitempty"`
ExitCode string `json:"exit_code,omitempty"`
Signal string `json:"signal,omitempty"`
Health string `json:"health,omitempty"`
}
type eventLog struct {
mu sync.Mutex
ring []ContainerEvent
dirty bool
path string
}
func (e *eventLog) load(path string) {
e.mu.Lock()
defer e.mu.Unlock()
e.path = path
b, err := os.ReadFile(path)
if err != nil {
return
}
var list []ContainerEvent
if json.Unmarshal(b, &list) == nil {
e.ring = list
}
}
func (e *eventLog) add(ev ContainerEvent) {
e.mu.Lock()
defer e.mu.Unlock()
e.ring = append(e.ring, ev)
if len(e.ring) > eventsKept {
e.ring = append([]ContainerEvent(nil), e.ring[len(e.ring)-eventsKept:]...)
}
e.dirty = true
}
// forName is one container's events, newest first.
func (e *eventLog) forName(name string, since time.Time, limit int) []ContainerEvent {
e.mu.Lock()
defer e.mu.Unlock()
out := []ContainerEvent{}
for _, ev := range e.ring {
if ev.Name == name && ev.T >= since.UnixMilli() {
out = append(out, ev)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].T > out[j].T })
if len(out) > limit {
out = out[:limit]
}
return out
}
func (e *eventLog) save() {
e.mu.Lock()
if !e.dirty || e.path == "" {
e.mu.Unlock()
return
}
b, err := json.Marshal(e.ring)
e.dirty = false
path := e.path
e.mu.Unlock()
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(path), 0o700)
tmp := path + ".tmp"
if os.WriteFile(tmp, b, 0o600) == nil {
_ = os.Rename(tmp, path)
}
}
// watchDockerEvents follows the stream, restarting if Docker goes away. It is the only
// thing that writes the log, so nothing contends with it.
func (s *Server) watchDockerEvents(ctx context.Context) {
s.events.load(filepath.Join(s.cfg.DataDir(), "docker-events.json"))
go func() { // a periodic flush, so a crash loses at most half a minute of history
t := time.NewTicker(eventsSaveEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
s.events.save()
return
case <-t.C:
s.events.save()
}
}
}()
for {
if ctx.Err() != nil {
return
}
if dockerTool() == "" {
time.Sleep(time.Minute) // no Docker here; look again later in case it is installed
continue
}
s.followEvents(ctx)
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second): // the daemon restarted, or the pipe broke
}
}
}
func (s *Server) followEvents(ctx context.Context) {
args := []string{"events", "--format", "{{json .}}", "--filter", "type=container"}
for _, e := range []string{"create", "start", "restart", "stop", "kill", "die", "destroy", "oom", "pause", "unpause", "rename", "update", "health_status"} {
args = append(args, "--filter", "event="+e)
}
tool := dockerTool()
full := append([]string{tool}, args...)
if !dockerReachable() && priv.Available() {
full = priv.Wrap(full...)
}
cmd := exec.CommandContext(ctx, full[0], full[1:]...)
out, err := cmd.StdoutPipe()
if err != nil {
return
}
if err := cmd.Start(); err != nil {
return
}
sc := bufio.NewScanner(out)
sc.Buffer(make([]byte, 64<<10), 1<<20)
n := 0
for sc.Scan() {
var e struct {
Action string `json:"Action"`
Time int64 `json:"time"`
TimeNano int64 `json:"timeNano"`
Actor struct {
Attributes map[string]string `json:"Attributes"`
} `json:"Actor"`
}
if json.Unmarshal(sc.Bytes(), &e) != nil || e.Action == "" {
continue
}
at := e.Actor.Attributes
ev := ContainerEvent{
T: e.Time * 1000, Name: at["name"], Image: at["image"],
Action: e.Action, ExitCode: at["exitCode"], Signal: at["signal"],
}
if e.TimeNano > 0 {
ev.T = e.TimeNano / 1e6
}
// health_status comes through as "health_status: healthy"
if a, h, ok := strings.Cut(e.Action, ": "); ok {
ev.Action, ev.Health = a, h
}
if ev.Name == "" {
continue
}
s.events.add(ev)
n++
}
_ = cmd.Wait()
if n > 0 {
log.Printf("docker events: stream ended after %d events; reconnecting", n)
}
}
// dockerEvents answers from Ham Lab's own history, not Docker's short memory.
func (s *Server) dockerEvents(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
since := r.URL.Query().Get("since")
d := 7 * 24 * time.Hour
switch since {
case "24h":
d = 24 * time.Hour
case "30d":
d = 30 * 24 * time.Hour
default:
since = "7d"
}
ev := s.events.forName(id, time.Now().Add(-d), 500)
s.events.mu.Lock()
total := len(s.events.ring)
s.events.mu.Unlock()
jsonOK(w, 200, map[string]any{"events": ev, "since": since, "kept": total, "capacity": eventsKept})
}
+206
View File
@@ -0,0 +1,206 @@
package api
// Following a container's log over a held connection, the way the Services tab follows a
// journal. The polled `?since=` endpoint next door still exists — it is what a reconnect
// hands back to pick up from — but the live view holds one `docker logs -f` per viewer
// instead of asking again every two seconds.
import (
"bufio"
"context"
"encoding/json"
"io"
"net/http"
"os/exec"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/coder/websocket"
"hamlab/internal/priv"
)
// logLine is one line of a container's output: the time Docker stamped it with, the text,
// and which of the two streams it came out of — stderr is where a container says what went
// wrong, and the page colours it accordingly.
type logLine struct {
T string `json:"t"`
M string `json:"m"`
Err bool `json:"err,omitempty"`
at string // when to sort it by: its own stamp, or the last one its stream carried
}
// splitStamp pulls Docker's RFC 3339 prefix off a line. A line without one (a container
// that writes bare bytes, or a continuation) keeps the whole text and no time — but is
// still sorted where its stream put it, behind the last line that did have one.
func splitStamp(l string, carry *string) logLine {
t, m, ok := strings.Cut(l, " ")
if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil {
return logLine{M: l, at: *carry}
}
*carry = t
return logLine{T: t, M: m, at: t}
}
// dockerTail streams a container's output as JSON text frames until the client goes away.
// `?since=<RFC3339>` resumes where a dropped connection left off; otherwise it starts with
// the last `?n=` lines.
func (s *Server) dockerTail(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !dockerRef.MatchString(id) {
jsonErr(w, 400, "bad container")
return
}
tool := dockerTool()
if tool == "" {
jsonErr(w, 500, "neither docker nor podman is installed here")
return
}
args := []string{tool, "logs", "--timestamps", "--follow"}
if since := r.URL.Query().Get("since"); since != "" {
if _, err := time.Parse(time.RFC3339Nano, since); err != nil {
jsonErr(w, 400, "since is an RFC 3339 time")
return
}
args = append(args, "--since", since)
} else {
args = append(args, "--tail", strconv.Itoa(logCount(r)))
}
args = append(args, id)
if priv.Available() && !dockerReachable() {
args = priv.Wrap(args...)
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
if err != nil {
return
}
defer conn.Close(websocket.StatusNormalClosure, "")
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
// Own process group, so the kill reaches the docker client itself and not just a sudo
// wrapper — the same reason the journal tail does it.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
pg := "-" + strconv.Itoa(cmd.Process.Pid)
if args[0] != tool { // running under sudo: only sudo can signal root's children
k := priv.Wrap("kill", "-9", "--", pg)
return exec.Command(k[0], k[1:]...).Run()
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return
}
stderr, err := cmd.StderrPipe()
if err != nil {
return
}
if err := cmd.Start(); err != nil {
_ = conn.Write(ctx, websocket.MessageText, mustJSON(logLine{M: err.Error(), Err: true}))
return
}
// Both pipes funnel into one writer: a websocket takes one writer at a time.
lines := make(chan logLine, 256)
read := func(p io.Reader, isErr bool) {
sc := bufio.NewScanner(p)
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
carry := ""
for sc.Scan() {
l := splitStamp(sc.Text(), &carry)
l.Err = isErr
select {
case lines <- l:
case <-ctx.Done():
return
}
}
}
done := make(chan struct{}, 2)
go func() { read(stdout, false); done <- struct{}{} }()
go func() { read(stderr, true); done <- struct{}{} }()
go func() { // the command ends when the container does; closing says so, having said why
<-done
<-done
_ = cmd.Wait()
close(lines)
}()
go func() { // reader: only to notice the close
defer cancel()
for {
if _, _, err := conn.Read(ctx); err != nil {
return
}
}
}()
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
// stdout and stderr are two pipes, and what Docker replays down them arrives in no
// particular order. So the opening burst is held for a moment and sorted by the time
// Docker stamped each line with; after that the log is live and arrives in order anyway.
const burstFor = 300 * time.Millisecond
warm := time.NewTimer(burstFor)
defer warm.Stop()
warmC := warm.C // nil once the burst has been flushed: a nil channel never fires
var burst []logLine
write := func(l logLine) bool {
return conn.Write(ctx, websocket.MessageText, mustJSON(l)) == nil
}
flush := func() bool {
sort.SliceStable(burst, func(i, j int) bool { return burst[i].at < burst[j].at })
for _, l := range burst {
if !write(l) {
return false
}
}
burst = nil
return true
}
for {
select {
case <-ctx.Done():
return
case l, ok := <-lines:
if !ok { // the container stopped, or never existed: say the last of it and go
flush()
return
}
if warmC != nil {
burst = append(burst, l)
if len(burst) < 10000 { // a container that never stops talking still gets read
continue
}
if !flush() {
return
}
continue
}
if !write(l) {
return
}
case <-warmC:
warmC = nil
if !flush() {
return
}
case <-ping.C:
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pctx)
pc()
if err != nil {
return
}
}
}
}
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
+351
View File
@@ -0,0 +1,351 @@
package api
// Scheduled image updates.
//
// Dockhand kept three containers up to date nightly — unbound, nginx and pihole, which is
// DNS and ingress — and that is the one thing it does that Ham Lab did not. This replaces
// it, and deliberately in the same shape as the ZFS snapshot schedules: a real line in a
// cron file Ham Lab owns, calling the binary, rather than a ticker inside the daemon.
// Updating an image is not something that should stop happening because a web console is
// down, and a cron line is visible to anyone looking at the machine the normal way.
//
// A schedule stores everything the run needs — project, compose files, how compose has to
// be invoked for that stack — so the cron job is self-contained and does not have to
// rediscover the world before it can act.
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"hamlab/internal/compose"
"hamlab/internal/cron"
"hamlab/internal/storage"
"hamlab/internal/store"
)
// UpdateJob is a standing instruction to pull a stack's images, and optionally deploy them.
type UpdateJob struct {
ID string `json:"id"`
Name string `json:"name"`
Stack string `json:"stack"`
Machine string `json:"machine,omitempty"` // for display; the job runs where it was made
// Services narrows it to some of the stack's services; empty is all of them.
Services []string `json:"services,omitempty"`
Cron string `json:"cron"`
// Apply says whether a newer image is deployed or only fetched. Check-only still pulls,
// so the Docker tab shows what is behind without a registry round trip.
Apply bool `json:"apply"`
Enabled bool `json:"enabled"`
// How to run compose for this stack, captured when the schedule was made.
Spec composeSpec `json:"spec"`
}
type composeSpec struct {
Project string `json:"project"`
Files []string `json:"files"`
Via string `json:"via,omitempty"`
Remote string `json:"remote,omitempty"`
}
func (c composeSpec) toSpec(tool string) compose.Spec {
return compose.Spec{Project: c.Project, Files: c.Files, Via: c.Via, Remote: c.Remote, Tool: tool}
}
const dockerCronFile = "/etc/cron.d/hamlab-docker"
type dockerSchedState struct {
Jobs []UpdateJob `json:"jobs"`
}
func (s *Server) dsLoad() dockerSchedState {
var raw json.RawMessage
s.store.View(func(st *store.State) { raw = st.DockerJobs })
var d dockerSchedState
if len(raw) > 0 {
_ = json.Unmarshal(raw, &d)
}
if d.Jobs == nil {
d.Jobs = []UpdateJob{}
}
return d
}
func (s *Server) dsSave(d dockerSchedState) error {
b, err := json.Marshal(d)
if err != nil {
return err
}
return s.store.Update(func(st *store.State) { st.DockerJobs = b })
}
// dsCronText renders the schedules as cron lines. Same shape as the ZFS file: Ham Lab owns
// it whole and rewrites it on every change.
func dsCronText(jobs []UpdateJob, user, exe, config string) (string, error) {
var b strings.Builder
b.WriteString("# Ham Lab image-update schedules — written by the Docker tab, rewritten whole on every change.\n")
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
for i := range jobs {
j := &jobs[i]
if !j.Enabled {
b.WriteString("# " + j.Name + " — paused\n")
continue
}
expr := strings.Join(strings.Fields(j.Cron), " ")
if err := cron.ValidSchedule(expr); err != nil {
return "", err
}
what := "pull only"
if j.Apply {
what = "pull and deploy"
}
b.WriteString("# " + j.Name + " — " + j.Stack + ", " + what + "\n")
b.WriteString(expr + " " + user + " " + exe + " -config " + config + " docker-update " + j.ID + "\n\n")
}
return b.String(), nil
}
func (s *Server) dsSyncCron(ctx context.Context, d dockerSchedState) error {
live := false
for i := range d.Jobs {
if d.Jobs[i].Enabled {
live = true
}
}
if !live {
return s.storage.RemoveRootFile(ctx, dockerCronFile)
}
exe, err := os.Executable()
if err != nil {
return err
}
exe, _ = filepath.EvalSymlinks(exe)
text, err := dsCronText(d.Jobs, s.user, exe, s.cfg.Path())
if err != nil {
return err
}
return s.storage.WriteRootFile(ctx, dockerCronFile, text)
}
// repoint keeps a schedule working when what deployed its stack has been removed.
//
// A schedule captures how to invoke compose at the moment it is made, so the cron job never
// has to rediscover the world at 3am. But "run compose inside this container" stops being
// true the day that container is deleted — which is exactly what happens when an
// orchestrator is retired, and would otherwise break every schedule made before it went.
// So: if the container named is not running, the files are looked up again, through live
// mounts and remembered ones both, and compose runs on the host instead.
func repoint(ctx context.Context, db *store.Store, spec compose.Spec) compose.Spec {
if spec.Via == "" || db == nil {
return spec
}
if out, err := dockerRun(ctx, "inspect", "-f", "{{.State.Running}}", spec.Via); err == nil && strings.TrimSpace(string(out)) == "true" {
return spec
}
st := stack{Name: spec.Project, File: spec.Files[0], Files: spec.Files}
st.resolve(mountsSeen(ctx, db)) // read-only: this also runs from cron, where the daemon owns state.json
if !st.Editable || st.Via != "" {
return spec // still only reachable through a container, or not reachable at all
}
log.Printf("docker schedule %s: %s is gone, running compose here against %s", spec.Project, spec.Via, st.HostFiles[0])
spec.Files, spec.Via = st.HostFiles, ""
return spec
}
// RunUpdate pulls, and deploys when the schedule says to. Shared with the CLI so the button
// and the cron line cannot drift apart.
func RunUpdate(ctx context.Context, db *store.Store, j UpdateJob, tool string) (string, error) {
if len(j.Spec.Files) == 0 {
return "", errors.New("this schedule has no compose file recorded — open it in the Docker tab and save it again")
}
spec := repoint(ctx, db, j.Spec.toSpec(tool))
var log strings.Builder
run := func(args ...string) error {
cmd := compose.Cmd(ctx, spec, args...)
out, err := cmd.CombinedOutput()
log.Write(out)
return err
}
args := append([]string{"pull"}, j.Services...)
if err := run(args...); err != nil {
return log.String(), err
}
if !j.Apply {
return log.String(), nil
}
up := append([]string{"up", "-d"}, j.Services...)
if err := run(up...); err != nil {
return log.String(), err
}
return log.String(), nil
}
// ---- endpoints ----
func (s *Server) dsList(w http.ResponseWriter, _ *http.Request) {
d := s.dsLoad()
now := time.Now()
out := make([]map[string]any, 0, len(d.Jobs))
for _, j := range d.Jobs {
m := map[string]any{"job": j, "last": storage.ReadRun(s.cfg.DataDir(), "docker-"+j.ID)}
if j.Enabled {
if n, ok := cron.NextRun(strings.Join(strings.Fields(j.Cron), " "), now); ok {
m["next"] = n
}
}
out = append(out, m)
}
jsonOK(w, 200, map[string]any{"jobs": out, "file": dockerCronFile})
}
func (s *Server) dsPut(w http.ResponseWriter, r *http.Request) {
var j UpdateJob
if err := json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&j); err != nil {
jsonErr(w, 400, "bad request")
return
}
j.Cron = strings.Join(strings.Fields(j.Cron), " ")
if err := cron.ValidSchedule(j.Cron); err != nil {
jsonErr(w, 400, "schedule: "+err.Error())
return
}
if strings.TrimSpace(j.Stack) == "" {
jsonErr(w, 400, "which stack?")
return
}
// resolve the stack now, so the cron job never has to
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
stacks, err := s.findStacks(ctx)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
found := false
for _, st := range stacks {
if st.Name == j.Stack {
// seenFiles is the list as compose itself sees it: host paths normally, the
// orchestrator container's own paths when compose runs inside one. Getting this
// backwards gives a cron line that works from the console and fails at 3am.
j.Spec = composeSpec{Project: st.Name, Files: seenFiles(st, -1, ""), Via: st.Via, Remote: st.Remote}
if len(j.Spec.Files) == 0 {
jsonErr(w, 409, "that stack's compose file isn't anywhere this console can reach")
return
}
found = true
}
}
if !found {
jsonErr(w, 404, "no stack called "+j.Stack+" on this machine")
return
}
if strings.TrimSpace(j.Name) == "" {
j.Name = j.Stack
}
d := s.dsLoad()
replaced := false
for i := range d.Jobs {
if d.Jobs[i].ID == j.ID && j.ID != "" {
d.Jobs[i] = j
replaced = true
}
}
if !replaced {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
jsonErr(w, 500, err.Error())
return
}
j.ID = hex.EncodeToString(b)
d.Jobs = append(d.Jobs, j)
}
if err := s.dsSave(d); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := s.dsSyncCron(r.Context(), d); err != nil {
jsonErr(w, 500, "saved, but writing "+dockerCronFile+" failed: "+err.Error())
return
}
log.Printf("docker schedule %q saved by %s (%s, %q, %s)", j.Name, clientIP(r), j.Stack, j.Cron, map[bool]string{true: "pull and deploy", false: "pull only"}[j.Apply])
jsonOK(w, 200, j)
}
func (s *Server) dsDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
d := s.dsLoad()
kept := d.Jobs[:0]
found := false
for _, j := range d.Jobs {
if j.ID == id {
found = true
continue
}
kept = append(kept, j)
}
if !found {
jsonErr(w, 404, "no such schedule")
return
}
d.Jobs = kept
if err := s.dsSave(d); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := s.dsSyncCron(r.Context(), d); err != nil {
jsonErr(w, 500, "removed, but writing "+dockerCronFile+" failed: "+err.Error())
return
}
storage.ForgetRun(s.cfg.DataDir(), "docker-"+id)
log.Printf("docker schedule %s removed by %s", id, clientIP(r))
w.WriteHeader(204)
}
func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var job UpdateJob
found := false
for _, j := range s.dsLoad().Jobs {
if j.ID == id {
job, found = j, true
}
}
if !found {
jsonErr(w, 404, "no such schedule")
return
}
// Outliving the request: a pull the browser gave up on still finishes, but not forever.
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Minute)
defer cancel()
out, err := RunUpdate(ctx, s.store, job, dockerTool())
rec := storage.RunRecord{At: time.Now(), Taken: job.Stack}
if err != nil {
rec.Error = err.Error()
}
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
stackUpdatesForget("/" + job.Stack)
if err != nil {
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
return
}
log.Printf("docker schedule %q run by %s", job.Name, clientIP(r))
jsonOK(w, 200, map[string]string{"output": tail(out, 4000)})
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
+193
View File
@@ -0,0 +1,193 @@
package api
// Guest logins: a temporary account for handing to somebody you want to show around. It is
// an ordinary account with a clock on it — every tab, every action, on this machine and on
// every machine this hub manages — that stops working at its expiry (half an hour by
// default) or the moment it is revoked.
//
// Deliberately not a reduced account: Mitchell, 2026-09-27, replacing a read-only version
// built the same day. He gives these to people he trusts and revokes them otherwise, and a
// console with half its buttons missing is not the console he wanted to show off. What that
// means in practice: a guest has the Terminal tab, which is a shell as the user the daemon
// runs as — root on every host here — and can make an account or a token of its own that
// outlives the guest login.
import (
"encoding/json"
"io"
"log"
"net/http"
"strings"
"time"
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/store"
)
const (
guestPrefix = "guest-"
guestTTLDefault = 30 * time.Minute
guestTTLMax = 24 * time.Hour
guestReapEvery = time.Minute
)
// ---- the accounts themselves ----
func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
now := time.Now()
counts := s.sessions.Count()
out := []map[string]any{}
s.cfg.View(func(c *config.Config) {
for _, u := range c.Users {
if !u.Guest || u.Expired(now) {
continue
}
out = append(out, map[string]any{
"name": u.Name, "created": u.Created, "expires": u.Expires,
"issued_by": u.IssuedBy, "sessions": counts[u.Name], "used": !u.LastSeen.IsZero(),
})
}
})
jsonOK(w, 200, out)
}
// addGuest makes a guest login and hands back its password — the only time it is ever
// readable, here as everywhere else a password is generated.
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
me := actorOf(r)
var body struct {
Minutes int `json:"minutes"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
ttl := guestTTLDefault
if body.Minutes > 0 {
ttl = time.Duration(body.Minutes) * time.Minute
}
if ttl > guestTTLMax {
jsonErr(w, 400, "a guest login lasts at most 24 hours")
return
}
pw, err := auth.GuestPassword()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
hash, err := auth.HashPassword(pw)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
var name string
for try := 0; try < 8 && name == ""; try++ {
suffix, err := auth.GuestSuffix()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
candidate := guestPrefix + suffix
s.cfg.View(func(c *config.Config) {
if _, taken := c.User(candidate); !taken {
name = candidate
}
})
}
if name == "" {
jsonErr(w, 500, "could not find a free guest name")
return
}
now := time.Now()
expires := now.Add(ttl)
if err := s.cfg.Update(func(c *config.Config) {
c.Users = append(c.Users, config.User{Name: name, Hash: hash, Created: now, Guest: true, Expires: expires, IssuedBy: me.User})
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("guest login %q issued until %s (by %s)", name, expires.Format(time.RFC3339), clientIP(r))
jsonOK(w, 200, map[string]any{"name": name, "password": pw, "expires": expires, "minutes": int(ttl.Minutes())})
}
// revokeGuest ends a guest login now. No password: it only ever takes access away, and the
// whole point of the thing is that ending it is one tap.
func (s *Server) revokeGuest(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
found := false
if err := s.cfg.Update(func(c *config.Config) {
kept := c.Users[:0]
for _, u := range c.Users {
if u.Name == name && u.Guest {
found = true
continue
}
kept = append(kept, u)
}
c.Users = kept
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
if !found {
jsonErr(w, 404, "no such guest login")
return
}
s.forgetGuest(name)
log.Printf("guest login %q revoked (by %s)", name, clientIP(r))
w.WriteHeader(204)
}
// forgetGuest signs a guest out everywhere and drops the look they left behind.
func (s *Server) forgetGuest(name string) {
s.sessions.RevokeUser(name)
_ = s.store.Update(func(st *store.State) { delete(st.Users, name) })
}
// watchGuests clears out guest logins whose time is up. Expiry is enforced on every request
// as well — this is what keeps the config file from collecting them, and what signs a guest
// out of a console they left open rather than waiting for their next click.
func (s *Server) watchGuests() {
for {
s.reapGuests()
time.Sleep(guestReapEvery)
}
}
func (s *Server) reapGuests() {
now := time.Now()
var gone []string
var have bool
s.cfg.View(func(c *config.Config) {
for _, u := range c.Users {
if u.Guest {
have = true
if u.Expired(now) {
gone = append(gone, u.Name)
}
}
}
})
if !have || len(gone) == 0 {
return
}
expired := map[string]bool{}
for _, n := range gone {
expired[n] = true
}
if err := s.cfg.Update(func(c *config.Config) {
kept := c.Users[:0]
for _, u := range c.Users {
if expired[u.Name] {
continue
}
kept = append(kept, u)
}
c.Users = kept
}); err != nil {
log.Printf("guests: %v", err)
return
}
for _, n := range gone {
s.forgetGuest(n)
}
log.Printf("guest login(s) expired: %s", strings.Join(gone, ", "))
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/store"
)
// A guest login is an ordinary account with a clock on it, so the clock is the thing worth
// testing: one whose time is up is stopped at the door, whether or not the minute-by-minute
// sweep has reached their account yet, and their session is dropped on the way — so the
// console they left open goes back to the login form rather than carrying on.
func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) {
for _, tc := range []struct {
name string
expires time.Time
wantOK bool
}{
{"still has time", time.Now().Add(10 * time.Minute), true},
{"time is up", time.Now().Add(-time.Second), false},
{"no expiry at all", time.Time{}, false},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(filepath.Join(dir, "config.json"))
if err := cfg.Update(func(c *config.Config) {
c.Users = []config.User{{Name: "guest-abcd", Hash: "x", Guest: true, Expires: tc.expires}}
}); err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(dir, "state.json"))
if err != nil {
t.Fatal(err)
}
s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)}
tok, err := s.sessions.Create("guest-abcd", "10.0.0.5", "curl")
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("GET", "/api/me", nil)
r.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
w := httptest.NewRecorder()
a, ok, answered := s.cookieActor(w, r)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if tc.wantOK {
if !a.Guest || a.User != "guest-abcd" {
t.Errorf("actor = %+v, want the guest account marked as one", a)
}
if answered {
t.Error("answered the caller for a guest that still has time")
}
return
}
if !answered || w.Code != 401 {
t.Errorf("answered = %v, code = %d, want true and 401", answered, w.Code)
}
if _, live := s.sessions.Lookup(tok, "10.0.0.5"); live {
t.Error("the session of an expired guest is still live")
}
})
}
}
+13
View File
@@ -0,0 +1,13 @@
package api
// The Hardware tab: what the machine is made of. Read once and remembered — none of it
// changes while the kernel runs — with ?refresh=1 for the one thing that can, a drive
// plugged in or pulled out.
import (
"net/http"
)
func (s *Server) hardwareReport(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, s.hardware.Report(r.Context(), r.URL.Query().Get("refresh") == "1"))
}
+310
View File
@@ -0,0 +1,310 @@
package api
// IP-KVMs: a device's own web page, shown inside Ham Lab.
//
// The whole difficulty is that Ham Lab is served over TLS and a KVM usually is not, so a
// browser refuses to put the device's page in an iframe — mixed content, blocked before
// anything loads. The fix is to serve the device from Ham Lab's own origin, which also
// means it inherits Ham Lab's login instead of needing a second one, and makes the iframe
// same-origin.
//
// Proxying an arbitrary web app under a path prefix is the hard part, and nothing here
// knows anything about any particular device. A device UI asks for its assets by absolute
// path — "/assets/app.js", "/api/whatever" — which a prefix cannot rewrite away. So the
// prefix is only the entry point: any request Ham Lab does not serve itself is routed to
// whichever KVM the asking page belongs to, worked out from the Referer, and from a cookie
// when there is no Referer (a WebSocket upgrade, say). Ham Lab's own routes always win, so
// a device can never shadow the console.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync"
"time"
"hamlab/internal/config"
)
const kvmPrefix = "/kvm/"
const kvmCookie = "hamlab_kvm"
type kvmProxies struct {
mu sync.Mutex
m map[string]*httputil.ReverseProxy
}
func (s *Server) kvm(id string) (config.KVM, bool) {
var found config.KVM
ok := false
s.cfg.View(func(c *config.Config) {
for _, k := range c.KVMs {
if k.ID == id {
found, ok = k, true
}
}
})
return found, ok
}
// kvmProxy is the reverse proxy for one device, made once and kept.
func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
s.kvms.mu.Lock()
defer s.kvms.mu.Unlock()
if s.kvms.m == nil {
s.kvms.m = map[string]*httputil.ReverseProxy{}
}
if p, ok := s.kvms.m[k.ID]; ok {
return p, nil
}
target, err := url.Parse(k.URL)
if err != nil || target.Host == "" {
return nil, errors.New("this KVM's address is not a URL")
}
prefix := kvmPrefix + k.ID
p := &httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL.Scheme, r.URL.Host = target.Scheme, target.Host
// Entry through the prefix keeps the path after it; anything else arrived by its
// own absolute path and is passed through untouched.
if rest, ok := strings.CutPrefix(r.URL.Path, prefix); ok {
if rest == "" {
rest = "/"
}
r.URL.Path = rest
}
r.URL.RawPath = ""
r.Host = target.Host
// The device gets its own host and none of Ham Lab's session.
r.Header.Del("Origin")
stripCookie(r, cookieName, kvmCookie)
},
FlushInterval: -1, // a video stream must not be buffered
ModifyResponse: func(resp *http.Response) error {
// The device's own framing and transport policy would stop it being embedded, or
// send a browser off to plain HTTP. Ham Lab is the origin now, and says what it
// serves; its own headers are added on the way out.
for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} {
resp.Header.Del(h)
}
// A redirect to the device's own root has to come back through the prefix.
if loc := resp.Header.Get("Location"); loc != "" {
if u, err := url.Parse(loc); err == nil && (u.Host == "" || u.Host == target.Host) && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, prefix) {
u.Scheme, u.Host = "", ""
u.Path = prefix + u.Path
resp.Header.Set("Location", u.String())
}
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]string{"error": k.Name + " unreachable: " + err.Error()})
},
}
s.kvms.m[k.ID] = p
return p, nil
}
// stripCookie removes named cookies from a request, so Ham Lab's session is never handed to
// a device and the device never sees which KVM the browser is looking at.
func stripCookie(r *http.Request, names ...string) {
cs := r.Cookies()
kept := cs[:0]
for _, c := range cs {
drop := false
for _, n := range names {
if c.Name == n {
drop = true
}
}
if !drop {
kept = append(kept, c)
}
}
r.Header.Del("Cookie")
for _, c := range kept {
r.AddCookie(c)
}
}
func (s *Server) forgetKVMProxy(id string) {
s.kvms.mu.Lock()
delete(s.kvms.m, id)
s.kvms.mu.Unlock()
}
// serveKVM is the entry point: /kvm/<id>/… . It also drops a cookie naming the device, so
// that requests the page makes later by absolute path can be traced back to it.
func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
k, ok := s.kvm(id)
if !ok {
jsonErr(w, 404, "no such KVM")
return
}
p, err := s.kvmProxy(k)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode})
p.ServeHTTP(w, r)
}
// kvmFor works out which device a request belongs to when it arrived by absolute path
// rather than through the prefix: the Referer names the page that asked, and a cookie
// covers what has no Referer at all. Nothing device-specific: it never has to know which
// paths a given KVM uses.
func (s *Server) kvmFor(r *http.Request) (config.KVM, bool) {
if ref := r.Header.Get("Referer"); ref != "" {
if u, err := url.Parse(ref); err == nil {
if rest, ok := strings.CutPrefix(u.Path, kvmPrefix); ok {
if id, _, _ := strings.Cut(rest, "/"); id != "" {
if k, ok := s.kvm(id); ok {
return k, true
}
}
}
}
// asked for by a page that is not a KVM: not ours to answer
return config.KVM{}, false
}
if c, err := r.Cookie(kvmCookie); err == nil && c.Value != "" {
return s.kvm(c.Value)
}
return config.KVM{}, false
}
// kvmFallback answers a request Ham Lab does not serve itself, when it came from inside a
// KVM's page. It returns false when the request has nothing to do with a KVM, and the
// caller carries on with its own 404.
func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool {
k, ok := s.kvmFor(r)
if !ok {
return false
}
p, err := s.kvmProxy(k)
if err != nil {
return false
}
p.ServeHTTP(w, r)
return true
}
func machineLabel(m string) string {
if m == "" {
return "this host"
}
return m
}
// ---- the list ----
func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) {
out := []config.KVM{}
s.cfg.View(func(c *config.Config) { out = append(out, c.KVMs...) })
jsonOK(w, 200, out)
}
// kvmForMachine is the KVM attached to a machine, "" being the hub itself.
func (s *Server) kvmForMachine(machine string) (config.KVM, bool) {
var found config.KVM
ok := false
s.cfg.View(func(c *config.Config) {
for _, k := range c.KVMs {
if k.Machine == machine {
found, ok = k, true
}
}
})
return found, ok
}
func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, URL, Machine string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
raw := strings.TrimSpace(body.URL)
if raw != "" && !strings.Contains(raw, "://") {
raw = "http://" + raw // what you would type: an address, not a scheme
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
jsonErr(w, 400, "address: http(s)://host, or just the host")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
name = u.Host
}
if len(name) > maxNameLen {
jsonErr(w, 400, "that name is too long")
return
}
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
jsonErr(w, 500, err.Error())
return
}
machine := strings.TrimSpace(body.Machine)
k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: machine, Added: time.Now()}
// One per machine: a second one for the same host replaces the first rather than sitting
// beside it, since the tab shows the machine you are looking at.
var replaced string
if err := s.cfg.Update(func(c *config.Config) {
kept := c.KVMs[:0]
for _, old := range c.KVMs {
if old.Machine == machine {
replaced = old.ID
continue
}
kept = append(kept, old)
}
c.KVMs = append(kept, k)
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
if replaced != "" {
s.forgetKVMProxy(replaced)
}
log.Printf("kvm %q added for %s (%s) by %s", k.Name, machineLabel(machine), k.URL, clientIP(r))
jsonOK(w, 201, k)
}
func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
found := false
if err := s.cfg.Update(func(c *config.Config) {
kept := c.KVMs[:0]
for _, k := range c.KVMs {
if k.ID == id {
found = true
continue
}
kept = append(kept, k)
}
c.KVMs = kept
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
if !found {
jsonErr(w, 404, "no such KVM")
return
}
s.forgetKVMProxy(id)
log.Printf("kvm %s removed by %s", id, clientIP(r))
w.WriteHeader(204)
}
+123
View File
@@ -0,0 +1,123 @@
package api
// The Network tab: the machine's interfaces, how the internet looks from here, and the speed
// test. One machine at a time, like every other tab — the test runs on the machine in the
// switcher, so the number is that machine's line and not the hub's.
import (
"encoding/json"
"log"
"net/http"
"time"
"hamlab/internal/network"
"hamlab/internal/sensors"
"hamlab/internal/store"
)
// networkOverview is everything the tab draws at once: the interfaces, the outside view, the
// last test and the ones before it.
func (s *Server) networkOverview(w http.ResponseWriter, _ *http.Request) {
run := s.net.Test.State()
jsonOK(w, 200, map[string]any{
"report": network.Look(s.hostName()),
"outside": s.net.Outside(),
"run": run,
"history": s.net.Test.History(),
"method": network.Method,
})
}
func (s *Server) speedtestState(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, s.net.Test.State())
}
func (s *Server) speedtestStart(w http.ResponseWriter, r *http.Request) {
run, err := s.net.Test.Start()
if err != nil {
jsonErr(w, 409, err.Error())
return
}
log.Printf("speed test started on %s (by %s)", s.hostName(), clientIP(r))
jsonOK(w, 200, run)
}
func (s *Server) speedtestCancel(w http.ResponseWriter, r *http.Request) {
if !s.net.Test.Cancel() {
jsonErr(w, 404, "no speed test is running")
return
}
log.Printf("speed test cancelled on %s (by %s)", s.hostName(), clientIP(r))
w.WriteHeader(204)
}
// ---- keeping the results ----
// savedNetwork is what survives a restart: the recent results, so the history is a history
// rather than whatever has happened since the daemon last started.
type savedNetwork struct {
Results []network.Result `json:"results,omitempty"`
}
func (s *Server) loadNetwork() []network.Result {
var saved savedNetwork
var raw json.RawMessage
s.store.View(func(st *store.State) { raw = st.Network })
if len(raw) > 0 {
if err := json.Unmarshal(raw, &saved); err != nil {
log.Printf("network: kept results unreadable (%v); starting a fresh history", err)
}
}
return saved.Results
}
// keepResult is called when a test finishes: append it to what is on disk.
func (s *Server) keepResult(r network.Result) {
if r.Error != "" {
log.Printf("speed test on %s failed: %s", s.hostName(), r.Error)
} else {
log.Printf("speed test on %s: down %s, up %s, latency %.1f ms via %s",
s.hostName(), network.Mbits(r.DownMbps), network.Mbits(r.UpMbps), r.LatencyMs, r.Colo)
}
results := s.net.Test.History()
b, err := json.Marshal(savedNetwork{Results: results})
if err != nil {
return
}
if err := s.store.Update(func(st *store.State) { st.Network = b }); err != nil {
log.Printf("network: %v", err)
}
}
// ---- the speed test as sensors ----
// netSensors publishes the last speed test as ordinary sensors, so the line can be a widget,
// a graph, an alert or a Prometheus series like anything else Ham Lab measures. Each one's
// timestamp is when the test ran, which is how a stale reading says so.
type netSensors struct{ w *network.Watcher }
func (n netSensors) Sensors() []sensors.Sensor {
var out []sensors.Sensor
if o := n.w.Outside(); o.IP != "" {
at := o.At.UnixMilli()
out = append(out, sensors.Sensor{ID: "net.internet.ip", Name: "Public address", Kind: "state", Value: o.IP, T: at})
if o.Colo != "" {
out = append(out, sensors.Sensor{ID: "net.internet.edge", Name: "Internet edge", Kind: "state", Value: o.Colo, T: at})
}
}
run := n.w.Test.State()
if run.Result == nil || run.Result.Error != "" {
return out
}
r := run.Result
at := r.At.UnixMilli()
return append(out,
sensors.Sensor{ID: "net.internet.download", Name: "Download", Kind: "rate", Unit: "Mbps", Value: r.DownMbps, T: at, Min: sensors.F(0)},
sensors.Sensor{ID: "net.internet.upload", Name: "Upload", Kind: "rate", Unit: "Mbps", Value: r.UpMbps, T: at, Min: sensors.F(0)},
sensors.Sensor{ID: "net.internet.latency", Name: "Latency", Kind: "duration", Unit: "ms", Value: r.LatencyMs, T: at, Min: sensors.F(0)},
sensors.Sensor{ID: "net.internet.jitter", Name: "Jitter", Kind: "duration", Unit: "ms", Value: r.JitterMs, T: at, Min: sensors.F(0)},
sensors.Sensor{ID: "net.internet.tested", Name: "Speed test age", Kind: "duration", Unit: "s", Value: round1(time.Since(r.At).Seconds()), T: at, Min: sensors.F(0)},
)
}
func round1(v float64) float64 { return float64(int64(v*10+0.5)) / 10 }
+146 -80
View File
@@ -33,7 +33,9 @@ import (
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/hardware"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/network"
"hamlab/internal/nodes" "hamlab/internal/nodes"
"hamlab/internal/priv" "hamlab/internal/priv"
"hamlab/internal/sensors" "hamlab/internal/sensors"
@@ -67,8 +69,12 @@ type Server struct {
tasks *tasks.Manager tasks *tasks.Manager
updates *updates.Manager updates *updates.Manager
storage *storage.Manager storage *storage.Manager
hardware *hardware.Manager
space *space.Manager space *space.Manager
net *network.Watcher
update updateState update updateState
events eventLog
kvms kvmProxies
fsLocal files.FS fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo fsSudo files.FS // nil without passwordless sudo
nodes *nodes.Manager nodes *nodes.Manager
@@ -85,7 +91,7 @@ type Server struct {
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server { func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version, s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
started: time.Now(), user: os.Getenv("USER")} hardware: hardware.New(), started: time.Now(), user: os.Getenv("USER")}
self, err := os.Executable() self, err := os.Executable()
if err != nil { if err != nil {
self = "hamlab" self = "hamlab"
@@ -99,21 +105,16 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
s.user = "?" s.user = "?"
} }
s.migrateUsers() s.migrateUsers()
// The speed test and the outside view. Its history outlives a restart, and its last result
// is published as sensors, so the line is graphable and alertable like anything else.
s.net = network.NewWatcher(s.keepResult)
s.net.Test.Load(s.loadNetwork())
s.sensors.Add(netSensors{s.net})
s.sensors.Add(newStatusSensors(s)) s.sensors.Add(newStatusSensors(s))
s.alerts = newAlertEngine(s) s.alerts = newAlertEngine(s)
// The saved dashboard layout carries the sampling interval; apply it on boot. // The saved dashboard layout carries the sampling interval; apply it on boot.
var raw json.RawMessage
var histSec int var histSec int
st.View(func(x *store.State) { st.View(func(x *store.State) { histSec = x.HistorySec })
raw, histSec = x.Dashboard, x.HistorySec
if len(raw) == 0 {
for _, u := range x.Users {
raw = u.Dashboard
break
}
}
})
s.applyPrefs(raw)
if histSec > 0 { if histSec > 0 {
col.SetWindow(time.Duration(histSec) * time.Second) col.SetWindow(time.Duration(histSec) * time.Second)
} }
@@ -146,8 +147,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
} }
go s.keepFleetInStep() go s.keepFleetInStep()
} }
go s.watchGuests()
go s.watchForUpdates(context.Background()) go s.watchForUpdates(context.Background())
go s.watchDockerEvents(context.Background())
s.mux = s.routes()
return s
}
// routes is the whole HTTP surface. A method of its own, rather than inline in New, so a
// test can build the real router without the machinery behind it — which is what the guest
// gate is checked against, since it decides a request by asking this router where it would go.
func (s *Server) routes() *http.ServeMux {
m := http.NewServeMux() m := http.NewServeMux()
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) }) m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
m.HandleFunc("POST /api/login", s.login) m.HandleFunc("POST /api/login", s.login)
@@ -194,11 +205,19 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune)) m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove)) m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove))
m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune)) m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune))
m.HandleFunc("POST /api/docker/networks/remove", s.auth(s.networkRemove))
m.HandleFunc("POST /api/docker/networks/prune", s.auth(s.networkPrune))
m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile)) m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave)) m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction)) m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
m.HandleFunc("GET /api/docker/stacks/{name}/diff", s.auth(s.stackDiff)) m.HandleFunc("GET /api/docker/stacks/{name}/diff", s.auth(s.stackDiff))
m.HandleFunc("GET /api/docker/stacks/{name}/updates", s.auth(s.stackUpdates)) m.HandleFunc("GET /api/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
m.HandleFunc("GET /api/docker/updates", s.auth(s.machineUpdates))
m.HandleFunc("GET /api/docker/schedules", s.auth(s.dsList))
m.HandleFunc("PUT /api/docker/schedules", s.auth(s.dsPut))
m.HandleFunc("DELETE /api/docker/schedules/{id}", s.auth(s.dsDelete))
m.HandleFunc("POST /api/docker/schedules/{id}/run", s.auth(s.dsRun))
m.HandleFunc("GET /api/nodes/{node}/docker/updates", s.auth(s.machineUpdates))
// The same, asked of a node through this hub: the hub answers for the stacks whose files // The same, asked of a node through this hub: the hub answers for the stacks whose files
// it holds (deployed to the node over tcp) and passes everything else to the node. // it holds (deployed to the node over tcp) and passes everything else to the node.
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList)) m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
@@ -214,7 +233,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask))) m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction)) m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs)) m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
m.HandleFunc("GET /api/docker/containers/{id}/logs/tail", s.auth(s.dockerTail))
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect)) m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
m.HandleFunc("GET /api/docker/containers/{id}/health", s.auth(s.dockerHealth))
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec))
m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate))
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet)) m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut)) m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest)) m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
@@ -255,12 +279,23 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename)) m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete)) m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod)) m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
m.HandleFunc("GET /api/network/speedtest", s.auth(s.speedtestState))
m.HandleFunc("POST /api/network/speedtest", s.auth(s.speedtestStart))
m.HandleFunc("POST /api/network/speedtest/cancel", s.auth(s.speedtestCancel))
m.HandleFunc("GET /api/storage", s.auth(s.storageOverview)) m.HandleFunc("GET /api/storage", s.auth(s.storageOverview))
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart)) m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest)) m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots)) m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub)) m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot)) m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut))
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete))
m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow))
m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete))
m.HandleFunc("GET /api/space", s.auth(s.spaceOverview)) m.HandleFunc("GET /api/space", s.auth(s.spaceOverview))
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus)) m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan)) m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
@@ -275,6 +310,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/users", s.auth(s.addUser)) m.HandleFunc("POST /api/users", s.auth(s.addUser))
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser)) m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword)) m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
m.HandleFunc("GET /api/guests", s.auth(s.listGuests))
m.HandleFunc("POST /api/guests", s.auth(s.addGuest))
m.HandleFunc("DELETE /api/guests/{name}", s.auth(s.revokeGuest))
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions)) m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession)) m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions)) m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
@@ -289,12 +327,22 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler)) m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow)) m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow)) m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs)) m.HandleFunc("GET /api/kvms", s.auth(s.listKVMs))
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs)) m.HandleFunc("POST /api/kvms", s.auth(s.addKVM))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) m.HandleFunc("DELETE /api/kvms/{id}", s.auth(s.removeKVM))
m.HandleFunc("/kvm/{id}/{rest...}", s.auth(s.serveKVM))
m.HandleFunc("/kvm/{id}", s.auth(s.serveKVM))
// A path Ham Lab does not serve may still belong to a KVM's page, which asks for its
// own assets by absolute path. Ham Lab's own routes matched first, so this can only
// catch what would otherwise have been a 404.
m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) {
if s.kvmFallback(w, r) {
return
}
jsonErr(w, 404, "not found")
}))
m.HandleFunc("/", s.static) m.HandleFunc("/", s.static)
s.mux = m return m
return s
} }
// sensorPath reports whether a path belongs to the read-only sensor API. // sensorPath reports whether a path belongs to the read-only sensor API.
@@ -314,9 +362,16 @@ func sensorPath(p string) bool {
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header() h := w.Header()
// The console must never be framed. An embedded widget exists to be: it is a page // The console must never be framed. Two exceptions: an embedded widget exists to be
// that can only read sensors, and only with a read-only token in its URL. // (a page that can only read sensors, and only with a read-only token in its URL),
if r.URL.Path != "/embed/widget" { // and a proxied KVM is framed by the console itself — 'none' forbids that even from
// the same origin, so those paths say 'self' instead of opening up.
switch {
case r.URL.Path == "/embed/widget":
case strings.HasPrefix(r.URL.Path, kvmPrefix):
h.Set("X-Frame-Options", "SAMEORIGIN")
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
default:
h.Set("X-Frame-Options", "DENY") h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "frame-ancestors 'none'") h.Set("Content-Security-Policy", "frame-ancestors 'none'")
} }
@@ -362,14 +417,56 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
}) })
} }
// signedIn reports whether a request carries a live browser session, without the
// side effects of the auth middleware.
func (s *Server) signedIn(r *http.Request) bool {
c, err := r.Cookie(cookieName)
if err != nil {
return false
}
_, ok := s.sessions.Lookup(c.Value, clientKey(r))
return ok
}
// cookieActor resolves the session cookie to who is calling. answered means the caller has
// already been sent an error and the handler must not run: the account behind a live session
// has gone away, or a guest login's time is up — so a console left open on a visitor's phone
// stops at the expiry, not at whatever they click next.
func (s *Server) cookieActor(w http.ResponseWriter, r *http.Request) (a actor, ok, answered bool) {
c, err := r.Cookie(cookieName)
if err != nil {
return actor{}, false, false
}
ses, live := s.sessions.Lookup(c.Value, clientKey(r))
if !live {
return actor{}, false, false
}
var u config.User
var found bool
s.cfg.View(func(cf *config.Config) { u, found = cf.User(ses.User) })
if !found || u.Expired(time.Now()) {
s.sessions.Delete(c.Value)
s.setCookie(w, r, "", -1)
if found {
jsonErr(w, 401, "this guest login has expired")
} else {
jsonErr(w, 401, "that account no longer exists")
}
return actor{}, false, true
}
return actor{User: ses.User, Session: ses.ID, token: c.Value, Guest: u.Guest, Expires: u.Expires}, true, false
}
// auth accepts the browser session cookie or a bearer API token issued to a hub. // auth accepts the browser session cookie or a bearer API token issued to a hub.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil { a, ok, answered := s.cookieActor(w, r)
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok { if answered {
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
return return
} }
if ok {
next(w, withActor(r, a))
return
} }
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
var hashes, names []string var hashes, names []string
@@ -403,11 +500,11 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ok, trusted := false, false ok, trusted := false, false
var patterns []string var patterns []string
if c, err := r.Cookie(cookieName); err == nil { if a, live, answered := s.cookieActor(w, r); answered {
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid { return
} else if live {
ok, trusted = true, true ok, trusted = true, true
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}) r = withActor(r, a)
}
} }
if !ok { if !ok {
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
@@ -876,11 +973,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
// name is allowed only while there is exactly one account — what a client from before // name is allowed only while there is exactly one account — what a client from before
// user names sends, and what a hub pairing with a one-user node may leave out. // user names sends, and what a hub pairing with a one-user node may leave out.
func (s *Server) checkLogin(username, password string) (string, bool) { func (s *Server) checkLogin(username, password string) (string, bool) {
var users []config.User var users, real []config.User
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) }) s.cfg.View(func(c *config.Config) { users, real = append(users, c.Users...), c.Accounts() })
username = strings.TrimSpace(username) username = strings.TrimSpace(username)
if username == "" && len(users) == 1 { if username == "" && len(real) == 1 { // guest logins are not the account an empty name means
username = users[0].Name username = real[0].Name
} }
if username == "" { if username == "" {
auth.CheckUser(nil, "", password) // take as long as a real check auth.CheckUser(nil, "", password) // take as long as a real check
@@ -930,15 +1027,11 @@ func (s *Server) migrateUsers() {
st.Sessions[tok] = ses st.Sessions[tok] = ses
} }
} }
if len(st.UI) > 0 || len(st.Dashboard) > 0 { if len(st.UI) > 0 {
u := st.UserState(first) if u := st.UserState(first); len(u.UI) == 0 {
if len(u.UI) == 0 {
u.UI = st.UI u.UI = st.UI
} }
if len(u.Dashboard) == 0 { st.UI = nil
u.Dashboard = st.Dashboard
}
st.UI, st.Dashboard = nil, nil
} }
}) })
} }
@@ -1013,17 +1106,22 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
} }
_, rebootErr := os.Stat("/run/reboot-required") _, rebootErr := os.Stat("/run/reboot-required")
updateVersion, _ := s.behind() updateVersion, _ := s.behind()
var demoURL, addr string var addr string
s.cfg.View(func(c *config.Config) { s.cfg.View(func(c *config.Config) {
demoURL = c.DemoURL
if h, _, err := net.SplitHostPort(c.Listen); err == nil && h != "" && h != "0.0.0.0" && h != "::" { if h, _, err := net.SplitHostPort(c.Listen); err == nil && h != "" && h != "0.0.0.0" && h != "::" {
addr = h // the address this console listens on, for the machine menu addr = h // the address this console listens on, for the machine menu
} }
}) })
me := actorOf(r)
var guestUntil any
if me.Guest {
guestUntil = me.Expires
}
jsonOK(w, 200, map[string]any{ jsonOK(w, 200, map[string]any{
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version, "host": s.hostName(), "user": s.user, "account": me.User, "version": s.version,
"guest": me.Guest, "guest_until": guestUntil,
"load": load, "uptime": uptime, "service_started": s.started, "load": load, "uptime": uptime, "service_started": s.started,
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "demo_url": demoURL, "addr": addr, "reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
"update_available": updateVersion, "update_available": updateVersion,
}) })
} }
@@ -2283,44 +2381,6 @@ func (s *Server) pushHistoryWindow(seconds int) {
} }
} }
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
func (s *Server) getDashPrefs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
jsonOK(w, 200, s.userPrefs(r, func(u *store.UserState) json.RawMessage { return u.Dashboard }))
}
func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' {
jsonErr(w, 400, "expected a JSON object")
return
}
if user := actorOf(r).User; user != "" {
if err := s.store.Update(func(st *store.State) { st.UserState(user).Dashboard = json.RawMessage(b) }); err != nil {
jsonErr(w, 500, err.Error())
return
}
}
s.applyPrefs(b)
w.WriteHeader(204)
}
// applyPrefs picks the few layout fields the server acts on (just the interval).
func (s *Server) applyPrefs(raw []byte) {
var p struct {
IntervalMs int `json:"intervalMs"`
}
if len(raw) == 0 || json.Unmarshal(raw, &p) != nil {
return
}
if p.IntervalMs > 0 {
s.metrics.SetInterval(time.Duration(p.IntervalMs) * time.Millisecond)
} else {
s.metrics.SetInterval(metrics.DefaultInterval)
}
}
// ---- terminals ---- // ---- terminals ----
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
@@ -2495,6 +2555,12 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
// A path with no file of ours behind it may belong to a KVM's page, which asks for its
// own assets by absolute path. Checked here rather than in the router because this
// handler is the catch-all — and gated on a session, since this handler is public.
if s.signedIn(r) && s.kvmFallback(w, r) {
return
}
// SPA fallback // SPA fallback
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
http.ServeFileFS(w, r, s.ui, "index.html") http.ServeFileFS(w, r, s.ui, "index.html")
+216 -58
View File
@@ -12,6 +12,12 @@ package api
// relative paths, the .env beside them and the labels all stay exactly as they were // relative paths, the .env beside them and the labels all stay exactly as they were
// deployed — else on the host. // deployed — else on the host.
// //
// Those mounts are also written down. An orchestrator can be removed, and when it is, the
// containers it left behind still carry labels naming paths inside a filesystem that no
// longer exists; without a record of where that filesystem was mounted, every stack on the
// machine would become unfindable at once. A remembered mount resolves with no container to
// run in, which is right: compose runs on the host against the host's copy of the file.
//
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's // A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
// Docker over tcp, so the node runs the containers and the hub holds the files. The node // Docker over tcp, so the node runs the containers and the hub holds the files. The node
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills // alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
@@ -39,7 +45,9 @@ import (
"sync" "sync"
"time" "time"
"hamlab/internal/compose"
"hamlab/internal/priv" "hamlab/internal/priv"
"hamlab/internal/store"
) )
type stack struct { type stack struct {
@@ -89,6 +97,100 @@ func stackMounts(ctx context.Context) []mountMap {
return maps return maps
} }
// mountsFor is stackMounts plus memory. An orchestrator that deployed everything here and
// has since been removed takes the only record of where its files really are with it: the
// containers it left behind are labelled with paths inside *its* filesystem. So every map
// seen is written down, and a path that no live container explains is looked for in what
// was seen before. A remembered map resolves with no `via`, which is correct — the
// container is gone, so compose runs on the host, against the host's copy of the file.
func mountsFor(ctx context.Context, db *store.Store) []mountMap {
live := stackMounts(ctx)
rememberMounts(db, live)
return append(live, recallMounts(db, live)...)
}
// mountsSeen is the same without writing anything down. The daemon owns state.json; a cron
// job that wrote to it would be clobbered by the next thing the daemon saved, so out there
// the record is read and never added to.
func mountsSeen(ctx context.Context, db *store.Store) []mountMap {
live := stackMounts(ctx)
return append(live, recallMounts(db, live)...)
}
// rememberMounts folds what is mounted now into what was mounted before, and forgets a map
// whose source has gone from disk — that one is not coming back, and keeping it would make
// a stack look editable when its file no longer exists.
//
// This is on the path of every stack listing, which the Docker tab polls, so it reads first
// and only writes when the set actually changed.
func rememberMounts(db *store.Store, live []mountMap) {
if len(live) == 0 {
return // docker is down or answered nothing; that is not evidence of anything
}
key := func(c, d string) string { return c + "\x00" + d }
var have []store.RememberedMount
db.View(func(st *store.State) { have = append([]store.RememberedMount(nil), st.Mounts...) })
at := map[string]int{}
for i, m := range have {
at[key(m.Container, m.Dest)] = i
}
now, add, moved := time.Now(), 0, false
for _, m := range live {
i, ok := at[key(m.container, m.dest)]
if !ok {
add++
continue
}
if have[i].Src != m.src {
moved = true
}
}
// Pruning is the expensive half — a stat, or a sudo test(1), per remembered map — so it
// only happens when something else has already made a write necessary.
if add == 0 && !moved {
return
}
_ = db.Update(func(st *store.State) {
at := map[string]int{}
for i, m := range st.Mounts {
at[key(m.Container, m.Dest)] = i
}
for _, m := range live {
if i, ok := at[key(m.container, m.dest)]; ok {
st.Mounts[i].Src, st.Mounts[i].Seen = m.src, now
continue
}
st.Mounts = append(st.Mounts, store.RememberedMount{Container: m.container, Dest: m.dest, Src: m.src, Seen: now})
}
kept := st.Mounts[:0]
for _, m := range st.Mounts {
if readable(m.Src) {
kept = append(kept, m)
}
}
st.Mounts = kept
})
}
// recallMounts is what was seen before and is not running now.
func recallMounts(db *store.Store, live []mountMap) []mountMap {
var remembered []store.RememberedMount
db.View(func(st *store.State) { remembered = append([]store.RememberedMount(nil), st.Mounts...) })
now := map[string]bool{}
for _, m := range live {
now[m.container+"\x00"+m.dest] = true
}
var out []mountMap
for _, m := range remembered {
if now[m.Container+"\x00"+m.Dest] {
continue
}
out = append(out, mountMap{"", filepath.Clean(m.Dest), filepath.Clean(m.Src)}) // no container: run compose here
}
sort.Slice(out, func(i, j int) bool { return len(out[i].dest) > len(out[j].dest) })
return out
}
func readable(p string) bool { func readable(p string) bool {
if _, err := os.Stat(p); err == nil { if _, err := os.Stat(p); err == nil {
return true return true
@@ -172,7 +274,7 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
} }
} }
} }
maps := stackMounts(ctx) maps := mountsFor(ctx, s.store)
stacks := []stack{} stacks := []stack{}
for _, st := range by { for _, st := range by {
sort.Strings(st.Containers) sort.Strings(st.Containers)
@@ -353,7 +455,7 @@ func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
continue continue
} }
if remote == "" { if remote == "" {
remote, maps = s.nodeDocker(ctx, node), stackMounts(ctx) remote, maps = s.nodeDocker(ctx, node), mountsFor(ctx, s.store)
} }
if remote == "-" { if remote == "-" {
continue continue
@@ -511,24 +613,13 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
// composeCmd is `docker compose …` for this stack, run where its files make sense and // composeCmd is `docker compose …` for this stack, run where its files make sense and
// pointed at the Docker that runs its containers. files are given as compose will see them. // pointed at the Docker that runs its containers. files are given as compose will see them.
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd { func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
tool := dockerTool() return compose.Cmd(ctx, specOf(st, files), args...)
base := []string{"compose", "-p", st.Name} }
for _, f := range files {
base = append(base, "-f", f) // specOf is a stack as the compose runner needs it — and as a scheduled update stores it,
} // so the cron job and the console invoke compose identically.
if st.Via != "" { func specOf(st stack, files []string) compose.Spec {
pre := []string{"exec"} return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Remote: st.Remote, Tool: dockerTool()}
if st.Remote != "" {
pre = append(pre, "-e", "DOCKER_HOST="+st.Remote)
}
return exec.CommandContext(ctx, tool, append(append(append(pre, st.Via, "docker"), base...), args...)...)
}
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
cmd.Dir = filepath.Dir(files[0])
if st.Remote != "" {
cmd.Env = append(os.Environ(), "DOCKER_HOST="+st.Remote)
}
return cmd
} }
// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate. // seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate.
@@ -734,6 +825,12 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
script = base("up -d --remove-orphans") + remember script = base("up -d --remove-orphans") + remember
case "update": case "update":
script = base("pull") + " && " + base("up -d --remove-orphans") + remember script = base("pull") + " && " + base("up -d --remove-orphans") + remember
case "recreate":
// The way out of a container Docker will no longer start — most often one pinned to a
// network that has since been recreated, which answers "network … not found" to every
// ordinary deploy. `up -d` leaves such a container alone because its definition has
// not changed; only --force-recreate builds it again.
script = base("up -d --force-recreate --remove-orphans") + remember
case "restart": case "restart":
script = base("restart") script = base("restart")
case "stop": case "stop":
@@ -741,7 +838,7 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
script = base("down --remove-orphans") script = base("down --remove-orphans")
default: default:
jsonErr(w, 400, "deploy, update, restart, stop or down") jsonErr(w, 400, "deploy, update, recreate, restart, stop or down")
return return
} }
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy { if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
@@ -788,6 +885,103 @@ var (
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() } func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
// checkImages asks the registry whether each image is still the one we hold. remote is a
// DOCKER_HOST for a machine whose images live elsewhere, "" for this one. Each image is one
// registry request, which Docker Hub counts — so this is only ever called when asked for.
func checkImages(ctx context.Context, images []string, remote string) []imageCheck {
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
if remote != "" {
args = append([]string{"-H", remote}, args...)
}
return dockerRun(ctx, args...)
}
res := []imageCheck{}
for _, img := range images {
ic := imageCheck{Image: img, State: "unknown"}
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
var have []string
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
ic.Detail = "built or loaded here, not pulled from a registry"
res = append(res, ic)
continue
}
rem, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
digest := strings.TrimSpace(string(rem))
if i := strings.LastIndex(digest, "\n"); i >= 0 {
digest = digest[i+1:]
}
if err != nil || !strings.HasPrefix(digest, "sha256:") {
ic.Detail = "the registry didn't answer"
if err != nil {
ic.Detail += ": " + err.Error()
}
res = append(res, ic)
continue
}
ic.State = "update"
for _, h := range have {
if strings.HasSuffix(h, "@"+digest) {
ic.State = "current"
}
}
res = append(res, ic)
}
return res
}
// machineUpdates checks every image on this machine in one pass, so the container list can
// be coloured and the fleet can be asked "what is behind?" without walking stack by stack.
func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
node := r.PathValue("node")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
stacks, err := s.findStacks(ctx)
if node != "" {
stacks, err = s.hubStacks(ctx, node)
}
if err != nil {
jsonErr(w, 500, err.Error())
return
}
// one entry per image, however many stacks use it
type want struct{ remote string }
imgs := map[string]want{}
byImage := map[string][]string{} // image -> stacks
for _, st := range stacks {
for _, img := range st.Images {
imgs[img] = want{remote: st.Remote}
byImage[img] = append(byImage[img], st.Name)
}
}
refresh := r.URL.Query().Get("refresh") == "1"
key := "machine:" + node
updMu.Lock()
c, ok := updCache[key]
updMu.Unlock()
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli(), "stacks": byImage})
return
}
if !refresh {
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0, "stacks": byImage})
return
}
var list []string
remote := ""
for img, wnt := range imgs {
list = append(list, img)
if wnt.remote != "" {
remote = wnt.remote
}
}
sort.Strings(list)
res := checkImages(ctx, list, remote)
updMu.Lock()
updCache[key] = updAnswer{time.Now(), res}
updMu.Unlock()
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli(), "stacks": byImage})
}
// stackUpdates asks each image's registry for the digest its tag points at now and compares // stackUpdates asks each image's registry for the digest its tag points at now and compares
// it with the digest that was pulled. It is asked for, never polled: every answer is a // it with the digest that was pulled. It is asked for, never polled: every answer is a
// registry request, and Docker Hub counts those. Answers are kept for six hours. // registry request, and Docker Hub counts those. Answers are kept for six hours.
@@ -816,43 +1010,7 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0}) jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
return return
} }
local := func(args ...string) ([]byte, error) { // the Docker that holds the images res := checkImages(ctx, st.Images, st.Remote)
if st.Remote != "" {
args = append([]string{"-H", st.Remote}, args...)
}
return dockerRun(ctx, args...)
}
res := []imageCheck{}
for _, img := range st.Images {
ic := imageCheck{Image: img, State: "unknown"}
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
var have []string
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
ic.Detail = "built or loaded here, not pulled from a registry"
res = append(res, ic)
continue
}
remote, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
digest := strings.TrimSpace(string(remote))
if i := strings.LastIndex(digest, "\n"); i >= 0 {
digest = digest[i+1:]
}
if err != nil || !strings.HasPrefix(digest, "sha256:") {
ic.Detail = "the registry didn't answer"
if err != nil {
ic.Detail += ": " + err.Error()
}
res = append(res, ic)
continue
}
ic.State = "update"
for _, h := range have {
if strings.HasSuffix(h, "@"+digest) {
ic.State = "current"
}
}
res = append(res, ic)
}
updMu.Lock() updMu.Lock()
updCache[key] = updAnswer{time.Now(), res} updCache[key] = updAnswer{time.Now(), res}
updMu.Unlock() updMu.Unlock()
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"context"
"os"
"path/filepath"
"testing"
"hamlab/internal/compose"
"hamlab/internal/store"
)
// A stack deployed by an orchestrator running in a container records the path its compose
// file had inside that container. Resolving it has to work both while the orchestrator is
// running — compose is then run inside it — and after it has been removed, where the same
// mount is only remembered and compose has to run on the host instead.
func TestResolveThroughMounts(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "orch", "stacks", "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "compose.yaml")
if err := os.WriteFile(file, []byte("services: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
inner := "/app/data/stacks/app/compose.yaml"
for _, tc := range []struct {
name, container, wantVia string
}{
{"orchestrator running", "orch", "orch"},
{"orchestrator removed", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
st := stack{Name: "app", File: inner, Files: []string{inner}}
st.resolve([]mountMap{{tc.container, "/app/data", filepath.Join(root, "orch")}})
if !st.Editable {
t.Fatalf("not editable: %s", st.Note)
}
if st.HostFile != file {
t.Errorf("host file = %q, want %q", st.HostFile, file)
}
if st.Via != tc.wantVia {
t.Errorf("via = %q, want %q", st.Via, tc.wantVia)
}
})
}
// Nothing explains the path: the stack is listed, but read-only and says why.
st := stack{Name: "app", File: inner, Files: []string{inner}}
st.resolve(nil)
if st.Editable || st.Note == "" {
t.Errorf("unresolvable stack should be read-only with a note, got editable=%v note=%q", st.Editable, st.Note)
}
}
// Every file of a multi-file stack must resolve through the same place. Redeploying from
// the first file alone would silently drop whatever the override set.
func TestResolveRefusesSplitStack(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "orch", "stacks", "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
st := stack{Name: "app", File: "/app/data/stacks/app/compose.yaml", Files: []string{
"/app/data/stacks/app/compose.yaml",
"/app/data/stacks/app/override.yaml", // never written: not on disk
}}
st.resolve([]mountMap{{"orch", "/app/data", filepath.Join(root, "orch")}})
if st.Editable {
t.Error("a stack with a file that cannot be found should not be editable")
}
}
// A schedule made while an orchestrator was running says "run compose inside that
// container". When the container is gone the schedule must still work, against the same
// files on the host — otherwise retiring the orchestrator silently breaks every scheduled
// update made before it went.
func TestRepointSurvivesAMissingContainer(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "orch", "stacks", "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
host := filepath.Join(dir, "compose.yaml")
if err := os.WriteFile(host, []byte("services: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
db, err := store.Open(filepath.Join(root, "state.json"))
if err != nil {
t.Fatal(err)
}
if err := db.Update(func(st *store.State) {
st.Mounts = []store.RememberedMount{{Container: "gone-orchestrator", Dest: "/app/data", Src: filepath.Join(root, "orch")}}
}); err != nil {
t.Fatal(err)
}
in := compose.Spec{Project: "app", Files: []string{"/app/data/stacks/app/compose.yaml"}, Via: "gone-orchestrator", Tool: "docker"}
got := repoint(context.Background(), db, in)
if got.Via != "" {
t.Errorf("via = %q, want it dropped", got.Via)
}
if len(got.Files) != 1 || got.Files[0] != host {
t.Errorf("files = %v, want [%s]", got.Files, host)
}
// Nothing remembered: the schedule is left exactly as it was rather than pointed at a
// guess. It will fail loudly, which is better than deploying the wrong file.
empty, err := store.Open(filepath.Join(root, "empty.json"))
if err != nil {
t.Fatal(err)
}
if got := repoint(context.Background(), empty, in); got.Via != in.Via || len(got.Files) != 1 || got.Files[0] != in.Files[0] {
t.Errorf("unresolvable spec was changed: %+v", got)
}
}
+12 -2
View File
@@ -26,6 +26,10 @@ type actor struct {
Session string // session ID, "" for a token Session string // session ID, "" for a token
token string // the raw cookie token, so a handler can spare the current session token string // the raw cookie token, so a handler can spare the current session
Token string // token name, "" for a session Token string // token name, "" for a session
// Guest marks a temporary look-around login, and Expires is when it stops working. What
// a guest may reach is decided in the middleware, not here; see internal/api/guests.go.
Guest bool
Expires time.Time
} }
type ctxKey int type ctxKey int
@@ -44,6 +48,8 @@ func actorOf(r *http.Request) actor {
// who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub. // who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub.
func (a actor) String() string { func (a actor) String() string {
switch { switch {
case a.User != "" && a.Guest:
return "guest:" + a.User
case a.User != "": case a.User != "":
return a.User return a.User
case a.Token != "": case a.Token != "":
@@ -59,7 +65,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
counts := s.sessions.Count() counts := s.sessions.Count()
var out []map[string]any var out []map[string]any
s.cfg.View(func(c *config.Config) { s.cfg.View(func(c *config.Config) {
for _, u := range c.Users { for _, u := range c.Accounts() { // guest logins are listed on their own, see listGuests
var seen any var seen any
if !u.LastSeen.IsZero() { if !u.LastSeen.IsZero() {
seen = u.LastSeen seen = u.LastSeen
@@ -84,6 +90,10 @@ func (s *Server) addUser(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit") jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
return return
} }
if strings.HasPrefix(name, guestPrefix) {
jsonErr(w, 400, "names starting with "+guestPrefix+" belong to guest logins")
return
}
if len(body.Password) < auth.MinPasswordLen { if len(body.Password) < auth.MinPasswordLen {
jsonErr(w, 400, "password must be at least 12 characters") jsonErr(w, 400, "password must be at least 12 characters")
return return
@@ -141,7 +151,7 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
} }
found, last := false, false found, last := false, false
if err := s.cfg.Update(func(c *config.Config) { if err := s.cfg.Update(func(c *config.Config) {
if len(c.Users) <= 1 { if len(c.Accounts()) <= 1 { // guest logins don't keep the last real account alive
last = true last = true
return return
} }
+16
View File
@@ -0,0 +1,16 @@
package api
// The VPN tab: this machine's WireGuard tunnels and who is on them. Read-only, and per
// machine like every other tab — the tunnels shown are the ones on the machine in the
// switcher, which is the only honest way to show them: the inbound server and an outbound
// tunnel are different machines here.
import (
"net/http"
"hamlab/internal/vpn"
)
func (s *Server) vpnReport(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, vpn.Look(r.Context()))
}
+262
View File
@@ -0,0 +1,262 @@
package api
// The ZFS tab: pools, datasets, snapshots, and a schedule for taking and expiring them.
//
// grid already runs zfSnap from root cron and has done for longer than this repo has
// existed, so this does not replace it. It reads the same snapshot names, expires by the
// same rule, and surfaces the cron job that drives it — so what is on screen is the truth
// about the machine, whether Ham Lab or zfSnap is doing the work. A Ham Lab schedule is for
// a machine that has no such tool, and the page says plainly when both are running.
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"hamlab/internal/storage"
"hamlab/internal/store"
)
type zfsState struct {
Policies []storage.Policy `json:"policies"`
}
func (s *Server) zfsLoad() zfsState {
var raw json.RawMessage
s.store.View(func(st *store.State) { raw = st.ZFS })
var z zfsState
if len(raw) > 0 {
_ = json.Unmarshal(raw, &z)
}
if z.Policies == nil {
z.Policies = []storage.Policy{}
}
return z
}
func (s *Server) zfsSave(z zfsState) error {
b, err := json.Marshal(z)
if err != nil {
return err
}
return s.store.Update(func(st *store.State) { st.ZFS = b })
}
// externalSnapshotJob finds a cron job that is already taking snapshots on this machine, so
// the page can say so rather than let two schedules quietly double up.
func (s *Server) externalSnapshotJob(ctx context.Context) map[string]any {
if s.cron == nil {
return nil
}
ov := s.cron.Overview(ctx)
for _, j := range ov.Jobs {
if !j.Enabled {
continue
}
c := strings.ToLower(j.Command)
if !strings.Contains(c, "zfsnap") && !strings.Contains(c, "zfs-auto-snapshot") && !strings.Contains(c, "sanoid") &&
!(strings.Contains(c, "zfs ") && strings.Contains(c, "snapshot")) {
continue
}
return map[string]any{
"tool": toolName(c), "schedule": j.Schedule, "command": j.Command,
"user": j.User, "source": j.Source, "id": j.ID, "next": j.Next, "last": j.Last,
}
}
return nil
}
func toolName(cmd string) string {
switch {
case strings.Contains(cmd, "zfsnap"):
return "zfSnap"
case strings.Contains(cmd, "zfs-auto-snapshot"):
return "zfs-auto-snapshot"
case strings.Contains(cmd, "sanoid"):
return "sanoid"
}
return "a cron job"
}
// randomID is a short opaque id for a schedule.
func randomID() (string, error) {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// ---- the cron file ----
//
// A schedule is a cron line, written to /etc/cron.d/hamlab-zfs, which Ham Lab owns and
// rewrites whole whenever the schedules change. Nothing in the daemon runs them: snapshots
// are backup-shaped and should not stop because a web console is down, being upgraded, or
// uninstalled. The line calls `hamlab zfs-snapshot <id>` as the account Ham Lab runs as,
// which reaches zfs through sudo exactly as the daemon does.
// syncCron puts the current schedules on the machine.
func (s *Server) syncCron(ctx context.Context, z zfsState) error {
exe, err := os.Executable()
if err != nil {
return err
}
exe, _ = filepath.EvalSymlinks(exe)
return s.storage.WriteCronFile(ctx, z.Policies, s.user, exe, s.cfg.Path())
}
// ---- endpoints ----
func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) {
z := s.zfsLoad()
now := time.Now()
dir := s.cfg.DataDir()
out := make([]map[string]any, 0, len(z.Policies))
for i := range z.Policies {
p := z.Policies[i]
expr, _ := p.CronExpr()
m := map[string]any{"policy": p, "cron": expr, "last": storage.ReadRun(dir, p.ID)}
if p.Enabled {
if n := p.NextRun(now); !n.IsZero() {
m["next"] = n
}
}
out = append(out, m)
}
jsonOK(w, 200, map[string]any{
"zfs": s.storage.ZFS(r.Context()), "policies": out, "external": s.externalSnapshotJob(r.Context()),
})
}
func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
var p storage.Policy
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
jsonErr(w, 400, "bad request")
return
}
if err := p.Validate(); err != nil {
jsonErr(w, 400, err.Error())
return
}
z := s.zfsLoad()
found := false
for i := range z.Policies {
if z.Policies[i].ID == p.ID && p.ID != "" {
z.Policies[i] = p
found = true
}
}
if !found {
if p.ID == "" {
id, err := randomID()
if err != nil {
jsonErr(w, 500, err.Error())
return
}
p.ID = id
}
z.Policies = append(z.Policies, p)
}
if err := s.zfsSave(z); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := s.syncCron(r.Context(), z); err != nil {
jsonErr(w, 500, "saved, but writing "+storage.CronFile+" failed: "+err.Error())
return
}
expr, _ := p.CronExpr()
log.Printf("zfs schedule %q saved by %s (%s, %q, keep %s)", p.Name, clientIP(r), p.Dataset, expr, p.KeepFor)
jsonOK(w, 200, p)
}
func (s *Server) zfsPolicyDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
z := s.zfsLoad()
kept := z.Policies[:0]
found := false
for _, p := range z.Policies {
if p.ID == id {
found = true
continue
}
kept = append(kept, p)
}
if !found {
jsonErr(w, 404, "no such schedule")
return
}
z.Policies = kept
if err := s.zfsSave(z); err != nil {
jsonErr(w, 500, err.Error())
return
}
if err := s.syncCron(r.Context(), z); err != nil {
jsonErr(w, 500, "removed, but writing "+storage.CronFile+" failed: "+err.Error())
return
}
storage.ForgetRun(s.cfg.DataDir(), id)
log.Printf("zfs schedule %s removed by %s (its snapshots are left alone)", id, clientIP(r))
w.WriteHeader(204)
}
func (s *Server) zfsPolicy(id string) (storage.Policy, bool) {
for _, p := range s.zfsLoad().Policies {
if p.ID == id {
return p, true
}
}
return storage.Policy{}, false
}
func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
p, ok := s.zfsPolicy(id)
if !ok {
jsonErr(w, 404, "no such schedule")
return
}
res, err := s.storage.Run(r.Context(), &p)
rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned}
if err != nil {
rec.Error = err.Error()
}
_ = storage.WriteRun(s.cfg.DataDir(), id, rec)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("zfs schedule %q run by %s: took %s, expired %d", p.Name, clientIP(r), res.Taken, len(res.Pruned))
jsonOK(w, 200, res)
}
// zfsSnapshotDelete removes one snapshot, named in full and typed back by the caller.
func (s *Server) zfsSnapshotDelete(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Confirm string `json:"confirm"`
Recursive bool `json:"recursive"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
if body.Confirm != body.Name {
jsonErr(w, 400, "type the snapshot's full name to confirm")
return
}
if err := s.storage.DestroySnapshot(r.Context(), body.Name, body.Recursive); err != nil {
jsonErr(w, 500, err.Error())
return
}
log.Printf("zfs snapshot %s destroyed by %s%s", body.Name, clientIP(r), map[bool]string{true: " (recursive)", false: ""}[body.Recursive])
w.WriteHeader(204)
}
+57 -6
View File
@@ -113,11 +113,14 @@ var dummyHash = func() string {
return h return h
}() }()
// CheckUser verifies a password for one of users. A missing user fails in the same time // CheckUser verifies a password for one of users. A missing user — or a guest login whose
// as a wrong password. // time is up — fails in the same time as a wrong password.
func CheckUser(users []config.User, name, pw string) bool { func CheckUser(users []config.User, name, pw string) bool {
for _, u := range users { for _, u := range users {
if u.Name == name { if u.Name == name {
if u.Expired(time.Now()) {
break
}
return VerifyPassword(u.Hash, pw) return VerifyPassword(u.Hash, pw)
} }
} }
@@ -125,6 +128,51 @@ func CheckUser(users []config.User, name, pw string) bool {
return false return false
} }
// ---- guest logins ----
// guestAlphabet leaves out every letter and digit that can be misread in a screenshot or
// over the phone: i, l, o, 0 and 1. A guest login is read aloud and typed by hand.
const guestAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
// randomFrom returns n characters of guestAlphabet, chosen without modulo bias.
func randomFrom(n int) (string, error) {
out := make([]byte, 0, n)
buf := make([]byte, n)
limit := byte(256 - 256%len(guestAlphabet)) // the top of the last whole run of the alphabet
for len(out) < n {
if _, err := rand.Read(buf); err != nil {
return "", err
}
for _, b := range buf {
if b >= limit {
continue // would favour the first few letters; ask for another byte instead
}
out = append(out, guestAlphabet[int(b)%len(guestAlphabet)])
if len(out) == n {
break
}
}
}
return string(out), nil
}
// GuestPassword is a password for a guest login: four groups of four, ~78 bits, in an
// alphabet with nothing ambiguous in it.
func GuestPassword() (string, error) {
var groups []string
for i := 0; i < 4; i++ {
g, err := randomFrom(4)
if err != nil {
return "", err
}
groups = append(groups, g)
}
return strings.Join(groups, "-"), nil
}
// GuestSuffix is the tail of a generated guest name.
func GuestSuffix() (string, error) { return randomFrom(4) }
// ---- sessions ---- // ---- sessions ----
// Sessions is a token store backed by the state file. Expiry is sliding. // Sessions is a token store backed by the state file. Expiry is sliding.
@@ -270,11 +318,10 @@ func (s *Sessions) RevokeOthers(user, keep string) int {
// RevokeUser ends every session of user (the account is going away). // RevokeUser ends every session of user (the account is going away).
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") } func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
// Limiter is a single global backoff: after 5 failures, each further failure
// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
// Limiter locks out a client after repeated failed logins, per client address, with a // Limiter locks out a client after repeated failed logins, per client address, with a
// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one // global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one
// address never blocks another. // address never blocks another. Past perClientLock failures, each further failure doubles a
// lockout starting at 30s, capped at 10 minutes.
type Limiter struct { type Limiter struct {
mu sync.Mutex mu sync.Mutex
clients map[string]*bucket clients map[string]*bucket
@@ -290,7 +337,11 @@ type bucket struct {
var ErrLocked = errors.New("too many failed logins") var ErrLocked = errors.New("too many failed logins")
const ( const (
perClientLock = 5 // failures before a client is locked // perClientLock is how many failures one address gets before it is made to wait. Set high
// on purpose: a guest login is a generated password read off a message and typed by hand
// on a phone, and being locked out for fumbling it is worse than the brute-force risk —
// which the doubling backoff past this point, and the global backstop, still cover.
perClientLock = 20
globalLock = 100 // failures (all clients) in the window before everyone waits a minute globalLock = 100 // failures (all clients) in the window before everyone waits a minute
globalWindow = 5 * time.Minute globalWindow = 5 * time.Minute
) )
+50
View File
@@ -0,0 +1,50 @@
// Package compose runs docker compose the way a stack was deployed — which is not always
// simply "on this host". A stack deployed by an orchestrator running inside its own
// container records paths in that container's filesystem, so compose has to run in there;
// a stack deployed to another machine over tcp needs DOCKER_HOST pointed at it. Both the
// console and the scheduled updates it writes must invoke it identically, or a redeploy
// from one would not match the other's labels.
package compose
import (
"context"
"os"
"os/exec"
"path/filepath"
)
// Spec is everything needed to run compose for one stack.
type Spec struct {
Project string // -p
Files []string // -f, in order
Via string // a container to run compose inside; "" is this host
Remote string // a DOCKER_HOST; "" is this machine's Docker
Tool string // "docker" or "podman"
}
// Cmd builds the command. args are compose's own, e.g. "pull" or "up", "-d".
func Cmd(ctx context.Context, s Spec, args ...string) *exec.Cmd {
tool := s.Tool
if tool == "" {
tool = "docker"
}
base := []string{"compose", "-p", s.Project}
for _, f := range s.Files {
base = append(base, "-f", f)
}
if s.Via != "" {
pre := []string{"exec"}
if s.Remote != "" {
pre = append(pre, "-e", "DOCKER_HOST="+s.Remote)
}
return exec.CommandContext(ctx, tool, append(append(append(pre, s.Via, "docker"), base...), args...)...)
}
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
if len(s.Files) > 0 {
cmd.Dir = filepath.Dir(s.Files[0])
}
if s.Remote != "" {
cmd.Env = append(os.Environ(), "DOCKER_HOST="+s.Remote)
}
return cmd
}
+46 -3
View File
@@ -46,6 +46,22 @@ type User struct {
Hash string `json:"hash"` Hash string `json:"hash"`
Created time.Time `json:"created"` Created time.Time `json:"created"`
LastSeen time.Time `json:"last_seen,omitempty"` LastSeen time.Time `json:"last_seen,omitempty"`
// Guest marks a temporary login made to show somebody the console: it may only read,
// it expires by itself at Expires, and it is not one of the machine's accounts — it is
// left out of the account list, does not count towards "the last account", and cannot
// be the one an empty user name means. See internal/api/guests.go.
Guest bool `json:"guest,omitempty"`
// Expires is when a guest login stops working. A guest without one has already
// expired, which is the safe way to read a half-written file.
Expires time.Time `json:"expires,omitempty"`
// IssuedBy is the account that made this guest login.
IssuedBy string `json:"issued_by,omitempty"`
}
// Expired reports whether a guest login's time is up. A real account never expires; a
// guest with no expiry at all counts as expired rather than as forever.
func (u User) Expired(now time.Time) bool {
return u.Guest && (u.Expires.IsZero() || now.After(u.Expires))
} }
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field // UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
@@ -61,6 +77,23 @@ type UpdateSource struct {
Check string `json:"check,omitempty"` Check string `json:"check,omitempty"`
} }
// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access
// to a machine, including when that machine has no OS running. Ham Lab holds the list on
// the hub and proxies each one, because a KVM matters exactly when the machine it is
// attached to cannot answer for itself.
type KVM struct {
ID string `json:"id"`
Name string `json:"name"`
// URL is the device's own address, as you would type it into a browser on the LAN.
URL string `json:"url"`
// Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
// for a node's KVM, because a KVM earns its keep when the machine is down and cannot
// answer for itself.
Machine string `json:"machine"`
Added time.Time `json:"added"`
}
type Config struct { type Config struct {
Listen string `json:"listen"` Listen string `json:"listen"`
// PasswordHash is the single login of installs before users existed. On load it becomes // PasswordHash is the single login of installs before users existed. On load it becomes
@@ -70,9 +103,7 @@ type Config struct {
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
Tokens []Token `json:"tokens,omitempty"` Tokens []Token `json:"tokens,omitempty"`
Nodes []Node `json:"nodes,omitempty"` Nodes []Node `json:"nodes,omitempty"`
// DemoURL, when set, is linked from the Widgets tab: a page outside Ham Lab that KVMs []KVM `json:"kvms,omitempty"`
// shows the sensor API being used (examples/external-demo).
DemoURL string `json:"demo_url,omitempty"`
// Updates: where a newer build comes from. See UpdateSource. // Updates: where a newer build comes from. See UpdateSource.
Updates UpdateSource `json:"update,omitempty"` Updates UpdateSource `json:"update,omitempty"`
@@ -130,6 +161,18 @@ func (c *Config) MigrateUsers(name string) bool {
return true return true
} }
// Accounts is every real account: the machine's users, without the guest logins.
// Call under View/Update or on a config nobody else holds.
func (c *Config) Accounts() []User {
out := make([]User, 0, len(c.Users))
for _, u := range c.Users {
if !u.Guest {
out = append(out, u)
}
}
return out
}
// User finds an account by name. Call under View/Update or on a config nobody else holds. // User finds an account by name. Call under View/Update or on a config nobody else holds.
func (c *Config) User(name string) (User, bool) { func (c *Config) User(name string) (User, bool) {
for _, u := range c.Users { for _, u := range c.Users {
+3
View File
@@ -1,3 +1,6 @@
// Package cron reads and writes what the UI calls the **Jobs** tab (named "Cron" until
// 2026-09-24; the package and its /api/cron endpoints kept the old name).
//
// Package cron reads every scheduled job on the host into one list: /etc/crontab, // Package cron reads every scheduled job on the host into one list: /etc/crontab,
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd // /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
// timers. It computes next runs from the cron expressions, pulls last runs from the // timers. It computes next runs from the cron expressions, pulls last runs from the
+11
View File
@@ -132,6 +132,17 @@ func (s *schedule) dayMatches(t time.Time) bool {
} }
} }
// NextRun is the first time strictly after `from` that a cron expression fires. Exported so
// anything that writes a cron line — the ZFS snapshot schedules — can say when it runs next
// using the same evaluator that reads everyone else's.
func NextRun(expr string, from time.Time) (time.Time, bool) { return nextRun(expr, from) }
// ValidSchedule reports whether a cron expression parses.
func ValidSchedule(expr string) error {
_, err := parseSchedule(expr)
return err
}
// nextRun finds the first time strictly after `from` that the schedule fires. // nextRun finds the first time strictly after `from` that the schedule fires.
func nextRun(expr string, from time.Time) (time.Time, bool) { func nextRun(expr string, from time.Time) (time.Time, bool) {
s, err := parseSchedule(expr) s, err := parseSchedule(expr)
+843
View File
@@ -0,0 +1,843 @@
// Package hardware is what is physically in the machine: the board it is, its processor,
// the memory modules in its slots, the drives attached to it and the graphics in it.
//
// None of this changes while the machine is running, so it is read once and remembered.
// Everything here is read from what Debian and its kin already have — dmidecode for the
// parts only the firmware knows, lscpu, lspci, sysfs — and every reader is allowed to
// come back empty: a machine with no dmidecode, no PCI bus or no root still gets a page,
// with a note saying what could not be read and why.
package hardware
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"hamlab/internal/priv"
"hamlab/internal/storage"
)
type System struct {
Model string `json:"model,omitempty"` // "GMKtec NucBox G3"
Serial string `json:"serial,omitempty"`
Family string `json:"family,omitempty"`
Kernel string `json:"kernel,omitempty"`
Arch string `json:"arch,omitempty"`
Virt string `json:"virt,omitempty"` // "kvm", "lxc", … when this is not real hardware
}
// Slot is an expansion slot as the firmware describes it. Worth treating with suspicion on
// a mini PC, where the table is often copied from a reference design and lists slots the
// case has no room for.
type Slot struct {
Designation string `json:"designation"`
Type string `json:"type,omitempty"`
Usage string `json:"usage,omitempty"` // Available | In Use | Unknown
Length string `json:"length,omitempty"`
Width string `json:"width,omitempty"`
Bus string `json:"bus,omitempty"` // PCI address of what is in it
Device string `json:"device,omitempty"` // and what that turns out to be
}
// Board is the motherboard and the firmware on it.
type Board struct {
Maker string `json:"maker,omitempty"`
Product string `json:"product,omitempty"`
Version string `json:"version,omitempty"`
Serial string `json:"serial,omitempty"`
AssetTag string `json:"asset_tag,omitempty"`
Type string `json:"type,omitempty"` // "Motherboard", "Processor Module", …
Location string `json:"location,omitempty"` // where it sits in the chassis
Chassis string `json:"chassis,omitempty"`
ChassisMaker string `json:"chassis_maker,omitempty"`
BIOS string `json:"bios,omitempty"` // vendor and version
BIOSDate string `json:"bios_date,omitempty"`
BIOSRevision string `json:"bios_revision,omitempty"`
ROMSize string `json:"rom_size,omitempty"`
UEFI bool `json:"uefi,omitempty"`
Slots []Slot `json:"slots,omitempty"`
}
type Cache struct {
Level string `json:"level"` // L1d, L1i, L2, L3
Size string `json:"size"` // as lscpu puts it: "6 MiB (1 instance)"
}
type CPU struct {
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
Arch string `json:"arch,omitempty"`
Sockets int `json:"sockets,omitempty"`
SocketNames []string `json:"socket_names,omitempty"` // what the firmware calls each socket
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
ThreadsPerCore int `json:"threads_per_core,omitempty"`
MinMHz float64 `json:"min_mhz,omitempty"`
MaxMHz float64 `json:"max_mhz,omitempty"`
Family string `json:"family,omitempty"`
ModelID string `json:"model_id,omitempty"`
Stepping string `json:"stepping,omitempty"`
Microcode string `json:"microcode,omitempty"`
Virtualization string `json:"virtualization,omitempty"`
Caches []Cache `json:"caches,omitempty"`
Flags []string `json:"flags,omitempty"` // the handful anyone looks for
}
type Module struct {
Locator string `json:"locator"` // the slot, as the board labels it
Bank string `json:"bank,omitempty"`
Empty bool `json:"empty,omitempty"`
Size uint64 `json:"size,omitempty"`
Type string `json:"type,omitempty"` // DDR4
FormFactor string `json:"form_factor,omitempty"` // SODIMM
SpeedMTs int `json:"speed_mts,omitempty"` // what the module can do
RunningMTs int `json:"running_mts,omitempty"` // what the board is running it at
Maker string `json:"maker,omitempty"`
Part string `json:"part,omitempty"`
Serial string `json:"serial,omitempty"`
Rank int `json:"rank,omitempty"`
Voltage string `json:"voltage,omitempty"`
}
type Memory struct {
Total uint64 `json:"total"` // what the kernel can use
Installed uint64 `json:"installed,omitempty"` // what the modules add up to
MaxCapacity uint64 `json:"max_capacity,omitempty"` // what the board will take
Slots int `json:"slots,omitempty"`
Filled int `json:"filled,omitempty"`
ECC string `json:"ecc,omitempty"`
Modules []Module `json:"modules,omitempty"`
}
type Drive struct {
Name string `json:"name"`
Kind string `json:"kind"` // nvme | ssd | hdd | usb | emmc | sd | other
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
Serial string `json:"serial,omitempty"`
Firmware string `json:"firmware,omitempty"`
Tran string `json:"tran,omitempty"`
Size uint64 `json:"size"`
Rotational bool `json:"rotational,omitempty"`
Removable bool `json:"removable,omitempty"`
}
type GPU struct {
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
Slot string `json:"slot,omitempty"` // PCI address, when it is on a bus
Driver string `json:"driver,omitempty"`
Node string `json:"node,omitempty"` // card0
Memory uint64 `json:"memory,omitempty"`
Class string `json:"class,omitempty"` // VGA | 3D | Display
}
type Report struct {
At time.Time `json:"at"`
System System `json:"system"`
Board Board `json:"board"`
CPU CPU `json:"cpu"`
Memory Memory `json:"memory"`
Drives []Drive `json:"drives"`
GPUs []GPU `json:"gpus"`
Notes []string `json:"notes,omitempty"` // what could not be read, and why
}
// Manager remembers the last report. Hardware does not change under a running kernel —
// the one thing that would, a drive plugged in, is what Refresh is for.
type Manager struct {
mu sync.Mutex
last *Report
at time.Time
}
func New() *Manager { return &Manager{} }
const keepFor = 10 * time.Minute
func (m *Manager) Report(ctx context.Context, refresh bool) *Report {
m.mu.Lock()
if !refresh && m.last != nil && time.Since(m.at) < keepFor {
r := m.last
m.mu.Unlock()
return r
}
m.mu.Unlock()
r := collect(ctx)
m.mu.Lock()
m.last, m.at = r, time.Now()
m.mu.Unlock()
return r
}
func collect(ctx context.Context) *Report {
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
r := &Report{At: time.Now()}
dmi, note := readDMI(ctx)
if note != "" {
r.Notes = append(r.Notes, note)
}
r.System = system(ctx, dmi)
r.Board = board(dmi, pciNames(ctx))
r.CPU = cpu(ctx, dmi)
r.Memory = memory(dmi)
if len(r.Memory.Modules) == 0 && note == "" {
r.Notes = append(r.Notes, "memory modules: the firmware's table says nothing about this machine's slots")
}
r.Drives = drives(ctx)
r.GPUs = gpus(ctx)
if len(r.GPUs) == 0 {
r.Notes = append(r.Notes, "graphics: nothing on the PCI bus and no kernel display device")
}
return r
}
// ---- dmidecode ----
// record is one entry of the firmware's table: its DMI type and its key/value lines.
type record struct {
Type int
V map[string]string
}
// listKey holds every doubly-indented line of a record — dmidecode's lists, which have no
// key of their own: BIOS characteristics, board features, slot characteristics.
const listKey = "\x00list"
var handleRe = regexp.MustCompile(`^Handle 0x[0-9A-Fa-f]+, DMI type (\d+)`)
// readDMI runs dmidecode once for every type the page wants and parses the lot. It needs
// root; without it the page loses the board, the memory modules and the socket names, and
// says so rather than pretending the machine has none.
func readDMI(ctx context.Context) ([]record, string) {
tool, err := exec.LookPath("dmidecode")
if err != nil {
if _, e := os.Stat("/usr/sbin/dmidecode"); e != nil {
return nil, "the board and the memory modules need dmidecode, which isn't installed here"
}
tool = "/usr/sbin/dmidecode"
}
args := []string{tool, "-t", "bios", "-t", "system", "-t", "baseboard", "-t", "chassis", "-t", "slot", "-t", "processor", "-t", "memory"}
if os.Geteuid() != 0 {
if !priv.Available() {
return nil, "the board and the memory modules come from the firmware's table, which only root may read"
}
args = priv.Wrap(args...)
}
out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
if err != nil {
return nil, "the firmware's table could not be read: " + strings.TrimSpace(err.Error())
}
return parse(string(out)), ""
}
// parse turns dmidecode's output into records: a handle line starts one, and the single
// indented "Key: Value" lines under it are its fields. Doubly-indented lines are the
// continuation of a list (a processor's flags), which nothing here reads.
func parse(text string) []record {
var recs []record
var cur *record
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 0, 64<<10), 2<<20)
for sc.Scan() {
line := sc.Text()
if m := handleRe.FindStringSubmatch(line); m != nil {
t, _ := strconv.Atoi(m[1])
recs = append(recs, record{Type: t, V: map[string]string{}})
cur = &recs[len(recs)-1]
continue
}
if cur == nil || !strings.HasPrefix(line, "\t") {
if strings.TrimSpace(line) == "" {
cur = nil
}
continue
}
if strings.HasPrefix(line, "\t\t") { // a list under the field above it, e.g. BIOS characteristics
cur.V[listKey] += strings.TrimSpace(line) + "\n"
continue
}
k, v, ok := strings.Cut(strings.TrimSpace(line), ":")
if !ok {
continue
}
cur.V[k] = strings.TrimSpace(v)
}
return recs
}
func (r record) get(keys ...string) string {
for _, k := range keys {
if v := r.V[k]; v != "" && !unset(v) {
return v
}
}
return ""
}
// unset is every way the firmware has of saying "nobody filled this in".
func unset(v string) bool {
switch strings.ToLower(v) {
case "", "unknown", "not specified", "not provided", "none", "default string", "to be filled by o.e.m.",
"system manufacturer", "system product name", "to be filled by oem", "n/a", "empty", "no module installed",
"<out of spec>", "other", "not applicable":
return true
}
return false
}
func first(recs []record, t int) record {
for _, r := range recs {
if r.Type == t {
return r
}
}
return record{Type: t, V: map[string]string{}}
}
func all(recs []record, t int) []record {
var out []record
for _, r := range recs {
if r.Type == t {
out = append(out, r)
}
}
return out
}
// ---- system ----
func system(ctx context.Context, dmi []record) System {
sys := first(dmi, 1)
s := System{
Model: join(sys.get("Manufacturer"), sys.get("Product Name")),
Serial: sys.get("Serial Number"),
Family: sys.get("Family"),
Arch: runtimeArch(),
}
s.Kernel = readFile("/proc/sys/kernel/osrelease")
if s.Model == "" { // no SMBIOS: a Pi, a Mac, anything with a device tree
s.Model = strings.TrimRight(readFile("/sys/firmware/devicetree/base/model"), "\x00")
}
if out, err := exec.CommandContext(ctx, "systemd-detect-virt").Output(); err == nil {
if v := strings.TrimSpace(string(out)); v != "" && v != "none" {
s.Virt = v
}
}
return s
}
// board reads the motherboard, the chassis it sits in, the firmware on it and the
// expansion slots the firmware claims. All of it is SMBIOS, so a machine without one — a
// Mac, a Pi — gets an empty board, and the page says so rather than inventing one.
func board(dmi []record, pci map[string]string) Board {
b, ch, bios := first(dmi, 2), first(dmi, 3), first(dmi, 0)
out := Board{
Maker: b.get("Manufacturer"),
Product: b.get("Product Name"),
Version: b.get("Version"),
Serial: b.get("Serial Number"),
AssetTag: b.get("Asset Tag"),
Type: b.get("Type"),
Location: b.get("Location In Chassis"),
Chassis: ch.get("Type"),
ChassisMaker: ch.get("Manufacturer"),
BIOS: join(bios.get("Vendor"), bios.get("Version")),
BIOSDate: bios.get("Release Date"),
BIOSRevision: bios.get("BIOS Revision"),
}
// "ROM Size: 0 MB" is a board that never filled the field in, not a board with no ROM
if rom := bios.get("ROM Size"); rom != "" && !strings.HasPrefix(rom, "0 ") {
out.ROMSize = rom
}
if out.Product == out.Maker {
out.Product = "" // a board named after its maker says nothing twice
}
out.UEFI = strings.Contains(bios.V[listKey], "UEFI") // a characteristics line, not a field
for _, sl := range all(dmi, 9) {
s := Slot{
Designation: sl.get("Designation"),
Type: sl.get("Type"),
Usage: sl.get("Current Usage"),
Length: sl.get("Length"),
Width: sl.get("Data Bus Width"),
}
// every slot on some boards claims 0000:00:00.0, which is the host bridge and
// certainly not what is plugged into that slot
if bus := sl.get("Bus Address"); bus != "" && bus != "0000:00:00.0" {
s.Bus, s.Device = bus, pci[bus]
}
if s.Designation != "" {
out.Slots = append(out.Slots, s)
}
}
return out
}
func join(parts ...string) string {
var keep []string
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" && !unset(p) {
keep = append(keep, p)
}
}
// "GMKtec GMKtec" reads badly: drop a maker the product already names
if len(keep) == 2 && strings.HasPrefix(strings.ToLower(keep[1]), strings.ToLower(keep[0])) {
return keep[1]
}
return strings.Join(keep, " ")
}
func readFile(p string) string {
b, err := os.ReadFile(p)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func runtimeArch() string {
if out, err := exec.Command("uname", "-m").Output(); err == nil {
return strings.TrimSpace(string(out))
}
return ""
}
// ---- processor ----
// worthShowing is the short list of processor features anyone actually asks about; the
// full flag list runs to hundreds and belongs in a terminal.
var worthShowing = []string{"aes", "avx", "avx2", "avx512f", "sha_ni", "vmx", "svm", "sse4_2", "rdrand", "hypervisor"}
func cpu(ctx context.Context, dmi []record) CPU {
c := CPU{}
f := lscpu(ctx)
c.Model = f["Model name"]
c.Vendor = f["Vendor ID"]
c.Arch = f["Architecture"]
c.Sockets = atoi(f["Socket(s)"])
c.ThreadsPerCore = atoi(f["Thread(s) per core"])
c.Threads = atoi(f["CPU(s)"])
c.Cores = atoi(f["Core(s) per socket"]) * maxInt(1, c.Sockets)
c.MinMHz = atof(f["CPU min MHz"])
c.MaxMHz = atof(f["CPU max MHz"])
c.Family, c.ModelID, c.Stepping = f["CPU family"], f["Model"], f["Stepping"]
c.Virtualization = f["Virtualization"]
for _, l := range []string{"L1d", "L1i", "L2", "L3"} {
if v := f[l+" cache"]; v != "" {
c.Caches = append(c.Caches, Cache{Level: l, Size: v})
}
}
have := map[string]bool{}
for _, fl := range strings.Fields(f["Flags"]) {
have[fl] = true
}
for _, fl := range worthShowing {
if have[fl] {
c.Flags = append(c.Flags, fl)
}
}
for _, p := range all(dmi, 4) {
if s := p.get("Socket Designation"); s != "" {
c.SocketNames = append(c.SocketNames, s)
}
if c.Model == "" {
c.Model = p.get("Version", "Family")
}
if c.MaxMHz == 0 {
if mhz := atof(strings.TrimSuffix(p.get("Max Speed"), " MHz")); mhz > 0 {
c.MaxMHz = mhz
}
}
}
if c.Sockets == 0 {
c.Sockets = len(c.SocketNames)
}
// A processor whose cores are not all alike — Apple's, Intel's P and E cores — is
// reported by lscpu as several groups, and "core(s) per socket" then counts one group
// rather than the chip. Without SMT every thread is a core, so the thread count is the
// honest answer.
if c.ThreadsPerCore == 1 && c.Threads > c.Cores {
c.Cores = c.Threads
}
// /proc/cpuinfo is the only place the running microcode revision is written down
if b, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == "microcode" {
c.Microcode = strings.TrimSpace(v)
break
}
}
}
if c.Model == "" {
c.Model = "unknown processor"
}
return c
}
func lscpu(ctx context.Context) map[string]string {
f := map[string]string{}
out, err := exec.CommandContext(ctx, "lscpu").Output()
if err != nil {
// no util-linux: /proc/cpuinfo still names the thing and counts it
if b, err := os.ReadFile("/proc/cpuinfo"); err == nil {
n := 0
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, ":")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
switch {
case ok && strings.EqualFold(k, "model name"):
f["Model name"] = v
case ok && k == "processor":
n++
}
}
f["CPU(s)"] = strconv.Itoa(n)
}
return f
}
for _, line := range strings.Split(string(out), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok || strings.HasPrefix(line, " ") {
continue
}
f[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
return f
}
func atoi(s string) int { n, _ := strconv.Atoi(strings.TrimSpace(s)); return n }
func atof(s string) float64 {
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// leadingInt reads the number off the front of "3200 MT/s".
func leadingInt(s string) int {
f := strings.Fields(s)
if len(f) == 0 {
return 0
}
return atoi(f[0])
}
// ---- memory ----
var sizeRe = regexp.MustCompile(`^(\d+)\s*(kB|KB|MB|GB|TB)$`)
// dmiSize turns dmidecode's "8 GB" / "16384 MB" into bytes.
func dmiSize(s string) uint64 {
m := sizeRe.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
return 0
}
n, _ := strconv.ParseUint(m[1], 10, 64)
switch strings.ToUpper(m[2]) {
case "KB":
return n << 10
case "MB":
return n << 20
case "GB":
return n << 30
case "TB":
return n << 40
}
return 0
}
func memory(dmi []record) Memory {
m := Memory{}
if b, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if k, v, ok := strings.Cut(line, ":"); ok && k == "MemTotal" {
m.Total = uint64(atoi(strings.TrimSuffix(strings.TrimSpace(v), " kB"))) << 10
break
}
}
}
for _, a := range all(dmi, 16) {
m.MaxCapacity += dmiSize(a.get("Maximum Capacity"))
switch e := a.V["Error Correction Type"]; {
case e == "None":
m.ECC = "non-ECC" // the array says so outright; worth knowing, not worth hiding
case e != "" && !unset(e):
m.ECC = e
}
}
for _, d := range all(dmi, 17) {
mod := Module{
Locator: d.get("Locator"),
Bank: d.get("Bank Locator"),
Size: dmiSize(d.get("Size")),
}
m.Slots++
if mod.Locator == "" {
mod.Locator = "slot " + strconv.Itoa(m.Slots)
}
if mod.Size == 0 {
mod.Empty = true
m.Modules = append(m.Modules, mod)
continue
}
m.Filled++
mod.Type = d.get("Type")
mod.FormFactor = d.get("Form Factor")
mod.SpeedMTs = leadingInt(d.get("Speed"))
mod.RunningMTs = leadingInt(d.get("Configured Memory Speed", "Configured Clock Speed"))
mod.Maker = makerName(d.get("Manufacturer"), d.V["Module Manufacturer ID"])
mod.Part = d.get("Part Number")
mod.Serial = d.get("Serial Number")
mod.Rank = atoi(d.get("Rank"))
mod.Voltage = d.get("Configured Voltage")
m.Installed += mod.Size
m.Modules = append(m.Modules, mod)
}
return m
}
// jedec maps the memory makers who turn up in a homelab to their names: SPD carries a
// JEDEC id, and dmidecode prints the raw code when the module's string field is empty.
var jedec = map[string]string{
"0X2C00": "Micron", "0X802C": "Micron",
"0XCE00": "Samsung", "0X80CE": "Samsung", "0X00CE": "Samsung",
"0XAD00": "SK hynix", "0X80AD": "SK hynix",
"0X9801": "Kingston", "0X7F98": "Kingston", "0X0198": "Kingston",
"0X859B": "Crucial", "0X04CB": "A-DATA", "0X04CD": "G.Skill",
"0X029E": "Corsair", "0X0B45": "Wilk Elektronik", "0X830B": "Wilk Elektronik",
"0X0443": "Ramaxel", "0X0194": "Smart Modular",
}
// makerName prefers what the module says it is; a bare JEDEC id becomes a name when it is
// one we know, and stays the id when it is not — a wrong name is worse than a number.
func makerName(v, id string) string {
if v != "" && !unset(v) && !strings.HasPrefix(v, "0x") {
return v
}
code := v
if code == "" {
code = id
}
if n, ok := jedec[strings.ToUpper(code)]; ok {
return n
}
if v != "" && !unset(v) {
return v
}
return ""
}
// ---- drives ----
func drives(ctx context.Context) []Drive {
devs, err := storage.Devices(ctx)
if err != nil {
return nil
}
out := make([]Drive, 0, len(devs))
for _, d := range devs {
out = append(out, Drive{
Name: d.Name, Kind: d.Kind, Model: d.Model, Vendor: d.Vendor, Serial: d.Serial,
Tran: d.Tran, Size: d.Size, Rotational: d.Rotational, Removable: d.Removable,
Firmware: firmwareOf(d.Name),
})
}
return out
}
// firmwareOf reads a drive's firmware revision. An NVMe device is folded to its
// controller ("nvme0"), which has no /sys/block entry of its own — the revision is on the
// controller in /sys/class/nvme.
func firmwareOf(name string) string {
return firstNonEmpty(
readFile("/sys/block/"+name+"/device/firmware_rev"),
readFile("/sys/block/"+name+"/device/rev"),
readFile("/sys/class/nvme/"+name+"/firmware_rev"),
)
}
func firstNonEmpty(v ...string) string {
for _, s := range v {
if s != "" {
return s
}
}
return ""
}
// ---- graphics ----
// lspci -mm quotes every field, so a device name can hold the spaces and brackets it
// likes: `"VGA compatible controller [0300]" "Intel Corporation [8086]" "Alder Lake-N …"`.
var quoted = regexp.MustCompile(`"([^"]*)"`)
// stripID drops the trailing PCI id from `Alder Lake-N [UHD Graphics] [46d1]`.
func stripID(s string) string {
if i := strings.LastIndex(s, " ["); i > 0 && strings.HasSuffix(s, "]") {
return strings.TrimSpace(s[:i])
}
return s
}
func gpus(ctx context.Context) []GPU {
var out []GPU
seen := map[string]bool{} // PCI addresses already accounted for
if _, err := exec.LookPath("lspci"); err == nil {
drivers := pciDrivers(ctx)
if b, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output(); err == nil {
for _, line := range strings.Split(string(b), "\n") {
slot, _, _ := strings.Cut(line, " ")
fields := quoted.FindAllStringSubmatch(line, -1)
if len(fields) < 3 {
continue
}
kind := displayClass(fields[0][1])
if kind == "" {
continue
}
seen[slot] = true
g := GPU{Slot: slot, Class: kind, Vendor: stripID(fields[1][1]), Model: stripID(fields[2][1]), Driver: drivers[slot]}
g.Node, g.Memory = drmFor(slot)
out = append(out, g)
}
}
}
// A GPU that is not on a PCI bus at all — an Apple Silicon Mac, a Pi, anything with a
// device tree — is only visible as a kernel display device.
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
for _, c := range cards {
dev, err := filepath.EvalSymlinks(c + "/device")
if err != nil || seen[filepath.Base(dev)] {
continue
}
g := GPU{Node: filepath.Base(c), Class: "Display"}
if drv, err := filepath.EvalSymlinks(dev + "/driver"); err == nil {
g.Driver = filepath.Base(drv)
}
g.Model = deviceTreeName(dev)
if g.Model == "" && g.Driver != "" {
g.Model = g.Driver + " display"
}
if g.Model == "" {
continue
}
g.Memory = vram(dev)
out = append(out, g)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
return out
}
// pciNames maps a PCI address to a readable name, so a slot the firmware says is in use
// can say what is in it.
func pciNames(ctx context.Context) map[string]string {
m := map[string]string{}
if _, err := exec.LookPath("lspci"); err != nil {
return m
}
out, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output()
if err != nil {
return m
}
for _, line := range strings.Split(string(out), "\n") {
slot, _, _ := strings.Cut(line, " ")
f := quoted.FindAllStringSubmatch(line, -1)
if slot == "" || len(f) < 3 {
continue
}
m[slot] = strings.TrimSpace(stripID(f[1][1]) + " " + stripID(f[2][1]))
}
return m
}
func displayClass(c string) string {
switch {
case strings.Contains(c, "[0300]"):
return "VGA"
case strings.Contains(c, "[0302]"):
return "3D"
case strings.Contains(c, "[0301]"), strings.Contains(c, "[0380]"):
return "Display"
}
return ""
}
// pciDrivers maps a PCI address to the kernel module driving it.
func pciDrivers(ctx context.Context) map[string]string {
m := map[string]string{}
out, err := exec.CommandContext(ctx, "lspci", "-D", "-k").Output()
if err != nil {
return m
}
slot := ""
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") {
slot, _, _ = strings.Cut(line, " ")
continue
}
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "Kernel driver in use:"); ok && slot != "" {
m[slot] = strings.TrimSpace(v)
}
}
return m
}
// drmFor finds the kernel display device for a PCI address, and its video memory when the
// driver publishes it (amdgpu does; Intel's integrated graphics have none of their own).
func drmFor(slot string) (string, uint64) {
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
for _, c := range cards {
dev, err := filepath.EvalSymlinks(c + "/device")
if err != nil || filepath.Base(dev) != slot {
continue
}
return filepath.Base(c), vram(dev)
}
return "", 0
}
func vram(dev string) uint64 {
for _, f := range []string{"mem_info_vram_total", "mem_info_vis_vram_total"} {
if v := readFile(dev + "/" + f); v != "" {
n, _ := strconv.ParseUint(v, 10, 64)
return n
}
}
return 0
}
// deviceTreeName reads what a device-tree machine calls its graphics: the compatible
// string, tidied — "apple,agx-g13g" becomes "Apple agx-g13g".
func deviceTreeName(dev string) string {
c := readFile(dev + "/of_node/compatible")
if c == "" {
return ""
}
c = strings.TrimRight(strings.Split(c, "\x00")[0], "\x00")
maker, rest, ok := strings.Cut(c, ",")
if !ok {
return c
}
if maker != "" {
maker = strings.ToUpper(maker[:1]) + maker[1:]
}
return strings.TrimSpace(maker + " " + rest)
}
+103
View File
@@ -0,0 +1,103 @@
package hardware
import "testing"
func TestDMISize(t *testing.T) {
for in, want := range map[string]uint64{
"8 GB": 8 << 30, "16384 MB": 16384 << 20, "512 kB": 512 << 10, "1 TB": 1 << 40,
"No Module Installed": 0, "": 0, "8 GiB": 0,
} {
if got := dmiSize(in); got != want {
t.Errorf("dmiSize(%q) = %d, want %d", in, got, want)
}
}
}
func TestMakerName(t *testing.T) {
cases := []struct{ v, id, want string }{
{"Samsung", "", "Samsung"}, // the module says who it is
{"0x0B45", "Bank 12, Hex 0x45", "Wilk Elektronik"}, // only a JEDEC id, and one we know
{"0xFFFF", "", "0xFFFF"}, // one we don't: the number, not a guess
{"Unknown", "", ""},
{"", "0xCE00", "Samsung"},
}
for _, c := range cases {
if got := makerName(c.v, c.id); got != c.want {
t.Errorf("makerName(%q, %q) = %q, want %q", c.v, c.id, got, c.want)
}
}
}
func TestStripIDAndDisplayClass(t *testing.T) {
if got := stripID("Alder Lake-N [UHD Graphics] [46d1]"); got != "Alder Lake-N [UHD Graphics]" {
t.Errorf("stripID = %q", got)
}
if got := stripID("no brackets here"); got != "no brackets here" {
t.Errorf("stripID left a plain name alone wrong: %q", got)
}
if displayClass(`VGA compatible controller [0300]`) != "VGA" {
t.Error("a VGA controller should be graphics")
}
if displayClass(`Ethernet controller [0200]`) != "" {
t.Error("a network card is not graphics")
}
}
func TestLeadingIntAndJoin(t *testing.T) {
if leadingInt("3200 MT/s") != 3200 || leadingInt("") != 0 || leadingInt("Unknown") != 0 {
t.Error("leadingInt")
}
if got := join("GMKtec", "GMKtec NucBox G3"); got != "GMKtec NucBox G3" {
t.Errorf("a maker the product already names should not be said twice: %q", got)
}
if got := join("Dell Inc.", "OptiPlex 7040"); got != "Dell Inc. OptiPlex 7040" {
t.Errorf("join = %q", got)
}
if got := join("To Be Filled By O.E.M.", "System Product Name"); got != "" {
t.Errorf("firmware boilerplate is not a model: %q", got)
}
}
const sample = `# dmidecode 3.6
Handle 0x0028, DMI type 17, 92 bytes
Memory Device
Array Handle: 0x0027
Size: 8 GB
Form Factor: SODIMM
Locator: Controller0-ChannelA-DIMM0
Type: DDR4
Speed: 3200 MT/s
Manufacturer: 0x0B45
Part Number: WPBH32D408SWD-8G
Configured Memory Speed: 2933 MT/s
Rank: 1
Handle 0x0029, DMI type 17, 92 bytes
Memory Device
Size: No Module Installed
Locator: Controller1-ChannelA-DIMM0
Type: Unknown
`
// parseRecords is what readDMI does once dmidecode has answered; this exercises it
// without needing root or the tool.
func TestMemoryFromRecords(t *testing.T) {
recs := parse(sample)
m := memory(recs)
if m.Slots != 2 || m.Filled != 1 {
t.Fatalf("slots %d filled %d, want 2 and 1", m.Slots, m.Filled)
}
mod := m.Modules[0]
if mod.Size != 8<<30 || mod.Type != "DDR4" || mod.Maker != "Wilk Elektronik" || mod.Part != "WPBH32D408SWD-8G" {
t.Errorf("first module read wrong: %+v", mod)
}
if mod.SpeedMTs != 3200 || mod.RunningMTs != 2933 {
t.Errorf("a module running below its rating should say both: %d / %d", mod.RunningMTs, mod.SpeedMTs)
}
if !m.Modules[1].Empty || m.Modules[1].Locator != "Controller1-ChannelA-DIMM0" {
t.Errorf("empty slot read wrong: %+v", m.Modules[1])
}
if m.Installed != 8<<30 {
t.Errorf("installed = %d", m.Installed)
}
}
+60 -2
View File
@@ -90,6 +90,15 @@ type FS struct {
Type string `json:"type"` Type string `json:"type"`
Size uint64 `json:"size"` Size uint64 `json:"size"`
Used uint64 `json:"used"` Used uint64 `json:"used"`
// Remote is set when the filesystem lives on another machine — a CIFS or NFS share,
// sshfs, and so on. Its bytes are somebody else's disk, so a panel about this machine's
// hardware leaves it out, while the sensor stays for anyone who does want it.
Remote bool `json:"remote,omitempty"`
// Disk is the physical drive these bytes are on ("nvme0", "sda"), named as the sampler
// names it; empty when that cannot be
// said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and
// a remote mount is on none of them.
Disk string `json:"disk,omitempty"`
} }
type UPS struct { type UPS struct {
@@ -766,6 +775,15 @@ func foldNVMe(devs []Disk) []Disk {
// Kernel/virtual filesystems that aren't storage. Anything else that's mounted is // Kernel/virtual filesystems that aren't storage. Anything else that's mounted is
// shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change. // shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change.
// remoteFS is every filesystem type whose storage is not in this machine.
var remoteFS = map[string]bool{
"cifs": true, "smb3": true, "smbfs": true, "nfs": true, "nfs4": true, "afs": true,
"ceph": true, "glusterfs": true, "9p": true, "afpfs": true, "davfs": true,
"fuse.sshfs": true, "sshfs": true, "fuse.rclone": true, "fuse.s3fs": true,
"fuse.davfs2": true, "fuse.curlftpfs": true, "fuse.gcsfuse": true, "beegfs": true,
"lustre": true, "orangefs": true, "pvfs2": true, "coda": true, "ncpfs": true,
}
var pseudoFS = map[string]bool{ var pseudoFS = map[string]bool{
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true, "proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true,
"cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true, "cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true,
@@ -812,7 +830,7 @@ func filesystems() []FS {
return return
} }
bs := uint64(st.Bsize) bs := uint64(st.Bsize)
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs} done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0])}
}() }()
select { select {
case r := <-done: case r := <-done:
@@ -820,12 +838,52 @@ func filesystems() []FS {
out = append(out, r) out = append(out, r)
} }
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
out = append(out, FS{Mount: mount, Type: fs[2]}) // unreachable: size 0 signals it out = append(out, FS{Mount: mount, Type: fs[2], Remote: remoteFS[fs[2]]}) // unreachable: size 0 signals it
} }
} }
return out return out
} }
// diskOf names the physical drive a mount's source device sits on — "/dev/nvme0n1p2" is on
// "nvme0n1". It follows one layer of device-mapper or md (LUKS on a partition, say) but
// gives up when a thing spans several drives, because then no single drive is the answer.
func diskOf(source string) string {
if !strings.HasPrefix(source, "/dev/") {
return "" // a ZFS pool, a network share, a tmpfs: not a block device at all
}
d := parentDisk(strings.TrimPrefix(source, "/dev/"), 0)
// the sampler reports one drive per NVMe controller, so name it the same way
if m := nsRe.FindStringSubmatch(d); m != nil {
return m[1]
}
return d
}
func parentDisk(name string, depth int) string {
if name == "" || depth > 4 || strings.Contains(name, "/") {
return ""
}
base := "/sys/class/block/" + name
if _, err := os.Stat(base); err != nil {
return ""
}
// a partition: its parent directory in sysfs is the whole drive
if _, err := os.Stat(base + "/partition"); err == nil {
if target, err := os.Readlink(base); err == nil {
return filepath.Base(filepath.Dir(target))
}
return ""
}
// device-mapper or md: one drive underneath is an answer, several is not
if ents, err := os.ReadDir(base + "/slaves"); err == nil && len(ents) > 0 {
if len(ents) > 1 {
return ""
}
return parentDisk(ents[0].Name(), depth+1)
}
return name // a whole drive, used without partitioning
}
// ---- UPS ---- // ---- UPS ----
func runOut(args ...string) (string, bool) { func runOut(args ...string) (string, bool) {
+12 -3
View File
@@ -117,9 +117,18 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
for _, x := range s.FS { for _, x := range s.FS {
p := "fs." + sensors.Seg(x.Mount) p := "fs." + sensors.Seg(x.Mount)
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, "mount", x.Mount, "fstype", x.Type) // remote says the bytes are on another machine (a CIFS or NFS share), so a view of
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), "mount", x.Mount, "fstype", x.Type) // this machine's hardware can leave it out without losing the sensor
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, "mount", x.Mount, "fstype", x.Type) lbl := []string{"mount", x.Mount, "fstype", x.Type}
if x.Remote {
lbl = append(lbl, "remote", "1")
}
if x.Disk != "" {
lbl = append(lbl, "disk", x.Disk)
}
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
} }
if u := s.UPS; u != nil { if u := s.UPS; u != nil {
+360
View File
@@ -0,0 +1,360 @@
// Package network is what the Network tab shows: the machine's interfaces as the kernel has
// them, how the internet looks from out there, and a speed test that measures the line.
//
// Everything here is read from /proc and /sys, or measured against Cloudflare's public speed
// endpoints — no tool to install, on any of the machines. That matters for the router, where
// flash is small and `opkg install` is a decision.
package network
import (
"bufio"
"encoding/binary"
"fmt"
"net"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Addr is one address on an interface.
type Addr struct {
IP string `json:"ip"`
Prefix int `json:"prefix"` // network bits, as in /24
V6 bool `json:"v6,omitempty"`
}
// Iface is one network interface: what it is, whether it is up, and what it has moved.
type Iface struct {
Name string `json:"name"`
Kind string `json:"kind"` // ethernet | wifi | bridge | tunnel | loopback | virtual
MAC string `json:"mac,omitempty"`
MTU int `json:"mtu"`
// Up is the administrative state, Carrier whether a cable (or an association) is actually
// there. Up without Carrier is the usual "it's plugged into nothing".
Up bool `json:"up"`
Carrier bool `json:"carrier"`
// SpeedMbps is what the link negotiated, where the driver says: 0 for wireless, virtual
// and tunnel devices, which have no such number.
SpeedMbps int `json:"speed_mbps,omitempty"`
Duplex string `json:"duplex,omitempty"`
Driver string `json:"driver,omitempty"`
SignalDbm *float64 `json:"signal_dbm,omitempty"` // wireless only, from /proc/net/wireless
Addrs []Addr `json:"addrs"`
RxTotal uint64 `json:"rx_total"`
TxTotal uint64 `json:"tx_total"`
// Default marks the interface the default route leaves by — the one that matters when
// the question is about the internet.
Default bool `json:"default,omitempty"`
}
// Report is the machine's network as it stands.
type Report struct {
At time.Time `json:"at"`
Host string `json:"host,omitempty"`
Ifaces []Iface `json:"ifaces"`
Gateway string `json:"gateway,omitempty"`
GatewayVia string `json:"gateway_via,omitempty"` // the interface it is reached through
DNS []string `json:"dns,omitempty"`
Search []string `json:"search,omitempty"`
}
func readStr(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func readInt(path string) int {
n, err := strconv.Atoi(readStr(path))
if err != nil {
return 0
}
return n
}
// kindOf names what sort of device this is, from what the kernel exposes about it rather
// than from its name — a bridge is a bridge whatever it is called.
func kindOf(name string, flags net.Flags) string {
sys := "/sys/class/net/" + name + "/"
switch {
case flags&net.FlagLoopback != 0:
return "loopback"
case dirExists(sys + "wireless"), dirExists(sys + "phy80211"):
return "wifi"
case dirExists(sys + "bridge"):
return "bridge"
case dirExists(sys + "bonding"):
return "bond"
}
// DEVTYPE says vlan, bridge, wlan… for the devices that have one
for _, line := range strings.Split(readStr(sys+"uevent"), "\n") {
if v, ok := strings.CutPrefix(line, "DEVTYPE="); ok && v != "" {
return v
}
}
if !dirExists(sys + "device") { // no bus device behind it: veth, tun, wireguard, docker's own
return "virtual"
}
return "ethernet"
}
func dirExists(p string) bool {
st, err := os.Stat(p)
return err == nil && st.IsDir()
}
// driverOf is the module behind an interface, which is what names the hardware in one word.
func driverOf(name string) string {
l, err := os.Readlink("/sys/class/net/" + name + "/device/driver")
if err != nil {
return ""
}
return l[strings.LastIndexByte(l, '/')+1:]
}
// wirelessSignal is the signal level of each wireless interface, in dBm.
func wirelessSignal() map[string]float64 {
out := map[string]float64{}
f, err := os.Open("/proc/net/wireless")
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
name, rest, ok := strings.Cut(sc.Text(), ":")
if !ok {
continue
}
fs := strings.Fields(rest)
if len(fs) < 3 {
continue
}
if v, err := strconv.ParseFloat(strings.TrimSuffix(fs[2], "."), 64); err == nil {
out[strings.TrimSpace(name)] = v
}
}
return out
}
// counters is every interface's byte totals, from the same file the sampler reads.
func counters() map[string][2]uint64 {
out := map[string][2]uint64{}
f, err := os.Open("/proc/net/dev")
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
name, rest, ok := strings.Cut(sc.Text(), ":")
if !ok {
continue
}
fs := strings.Fields(rest)
if len(fs) < 16 {
continue
}
rx, _ := strconv.ParseUint(fs[0], 10, 64)
tx, _ := strconv.ParseUint(fs[8], 10, 64)
out[strings.TrimSpace(name)] = [2]uint64{rx, tx}
}
return out
}
// defaultRoute is the gateway the machine sends the internet to, and the interface it
// leaves by. Read from /proc/net/route, whose addresses are little-endian hex.
func defaultRoute() (gw, via string) {
f, err := os.Open("/proc/net/route")
if err != nil {
return "", ""
}
defer f.Close()
sc := bufio.NewScanner(f)
best := -1
for sc.Scan() {
fs := strings.Fields(sc.Text())
if len(fs) < 8 || fs[0] == "Iface" {
continue
}
if fs[1] != "00000000" { // not a default route
continue
}
metric, _ := strconv.Atoi(fs[6])
if best >= 0 && metric >= best {
continue
}
ip, err := hexIP(fs[2])
if err != nil {
continue
}
best, gw, via = metric, ip, fs[0]
}
return gw, via
}
// hexIP reads one address out of /proc/net/route, where each is printed as a 32-bit word in
// the host's byte order: 10.0.0.1 appears as "0100000A" on a little-endian machine. So the
// text is parsed as a plain number, then written back out in native order to recover the four
// bytes — which is right on either sort of machine, without asking which this is.
func hexIP(h string) (string, error) {
v, err := strconv.ParseUint(h, 16, 32)
if err != nil || len(h) != 8 {
return "", fmt.Errorf("not an address: %q", h)
}
var b [4]byte
binary.NativeEndian.PutUint32(b[:], uint32(v))
return net.IPv4(b[0], b[1], b[2], b[3]).String(), nil
}
// resolvers is what /etc/resolv.conf points at.
func resolvers() (servers, search []string) {
f, err := os.Open("/etc/resolv.conf")
if err != nil {
return nil, nil
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
fs := strings.Fields(sc.Text())
if len(fs) < 2 || strings.HasPrefix(fs[0], "#") {
continue
}
switch fs[0] {
case "nameserver":
servers = append(servers, fs[1])
case "search", "domain":
search = append(search, fs[1:]...)
}
}
return servers, search
}
// Interfaces is every interface the kernel has, with its addresses and totals.
func Interfaces() []Iface {
list, err := net.Interfaces()
if err != nil {
return nil
}
_, via := defaultRoute()
sig, cnt := wirelessSignal(), counters()
out := make([]Iface, 0, len(list))
for _, in := range list {
sys := "/sys/class/net/" + in.Name + "/"
f := Iface{
Name: in.Name, Kind: kindOf(in.Name, in.Flags), MTU: in.MTU,
MAC: in.HardwareAddr.String(), Up: in.Flags&net.FlagUp != 0,
Driver: driverOf(in.Name), Default: in.Name == via, Addrs: []Addr{},
}
f.Carrier = readStr(sys+"carrier") == "1"
if f.Kind == "loopback" {
f.Carrier = f.Up
}
if sp := readInt(sys + "speed"); sp > 0 { // -1 when the driver doesn't know
f.SpeedMbps = sp
}
if d := readStr(sys + "duplex"); d != "" && d != "unknown" {
f.Duplex = d
}
if v, ok := sig[in.Name]; ok {
f.SignalDbm = &v
}
if c, ok := cnt[in.Name]; ok {
f.RxTotal, f.TxTotal = c[0], c[1]
}
addrs, _ := in.Addrs()
for _, a := range addrs {
n, ok := a.(*net.IPNet)
if !ok {
continue
}
ones, _ := n.Mask.Size()
f.Addrs = append(f.Addrs, Addr{IP: n.IP.String(), Prefix: ones, V6: n.IP.To4() == nil})
}
out = append(out, f)
}
// the interface the internet leaves by first, then the ones carrying addresses, then the rest
sort.SliceStable(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Default != b.Default {
return a.Default
}
ai, bi := len(a.Addrs) > 0 && a.Kind != "loopback", len(b.Addrs) > 0 && b.Kind != "loopback"
if ai != bi {
return ai
}
return a.Name < b.Name
})
return out
}
// Look is the machine's network now. Cheap: a handful of small files.
func Look(host string) Report {
gw, via := defaultRoute()
dns, search := resolvers()
return Report{At: time.Now(), Host: host, Ifaces: Interfaces(), Gateway: gw, GatewayVia: via, DNS: dns, Search: search}
}
// ---- how the internet sees this machine ----
// Outside is what the far end of the line says about this connection: the address the
// internet sees, and where it came ashore. Refreshed on its own clock, because the public
// address here is a DHCP lease and moves without warning.
type Outside struct {
At time.Time `json:"at"`
IP string `json:"ip,omitempty"`
Colo string `json:"colo,omitempty"` // the Cloudflare edge that answered — "SEA"
Country string `json:"country,omitempty"`
Error string `json:"error,omitempty"`
}
// Watcher keeps the outside view fresh and holds the speed tester.
type Watcher struct {
mu sync.RWMutex
out Outside
Test *Tester
}
const outsideEvery = 15 * time.Minute
func NewWatcher(onResult func(Result)) *Watcher {
w := &Watcher{Test: NewTester(onResult)}
go func() {
for {
w.refresh()
time.Sleep(outsideEvery)
}
}()
return w
}
func (w *Watcher) refresh() {
o := probeOutside()
w.mu.Lock()
// a failed probe does not throw away a good answer; it is noted beside it
if o.IP == "" && w.out.IP != "" {
w.out.Error, w.out.At = o.Error, o.At
} else {
w.out = o
}
w.mu.Unlock()
}
// Outside is the last known view from outside, asking again if it has gone stale.
func (w *Watcher) Outside() Outside {
w.mu.RLock()
o := w.out
w.mu.RUnlock()
if time.Since(o.At) > outsideEvery {
w.refresh()
w.mu.RLock()
o = w.out
w.mu.RUnlock()
}
return o
}
+317
View File
@@ -0,0 +1,317 @@
package network
import (
"context"
"errors"
"io"
"math"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
)
func TestHexIP(t *testing.T) {
// /proc/net/route prints each address as a host-order word: on a little-endian machine
// 10.0.0.1 is written "0100000A". These cases are that machine, which is all of ours.
for hex, want := range map[string]string{
"0100000A": "10.0.0.1",
"00000000": "0.0.0.0",
"0101A8C0": "192.168.1.1",
} {
got, err := hexIP(hex)
if err != nil || got != want {
t.Errorf("hexIP(%q) = %q, %v; want %q", hex, got, err, want)
}
}
if _, err := hexIP("nonsense"); err == nil {
t.Error("hexIP accepted nonsense")
}
}
func TestServerTimeIsNotLatency(t *testing.T) {
h := http.Header{}
h.Add("Server-Timing", "cfSpeedEdge;dur=7, cfSpeedWorker;dur=36")
h.Add("Server-Timing", `cfL4;desc="?proto=TCP&rtt=3505&min_rtt=2856"`)
if got := serverMs(h); got != 43 {
t.Errorf("serverMs = %v, want 43 (7+36, and nothing from the cfL4 description)", got)
}
if got := serverMs(http.Header{}); got != 0 {
t.Errorf("serverMs with no header = %v, want 0", got)
}
h2 := http.Header{"Cf-Ray": {"a41f6720f966a36c-SEA"}, "Cf-Meta-Ip": {"50.47.252.221"}}
if m := readMeta(h2); m.Colo != "SEA" || m.IP != "50.47.252.221" {
t.Errorf("readMeta = %+v, want the edge SEA and the address", m)
}
}
func TestRates(t *testing.T) {
// a gibibyte in eight seconds is 1073.7 Mbps
if got := mbps(1<<30, 8); math.Abs(got-1073.7) > 0.1 {
t.Errorf("mbps(1GiB, 8s) = %v, want ~1073.7", got)
}
if got := best([]float64{12.5, 9.2, 40, 9.9}); got != 9.2 {
t.Errorf("best = %v, want 9.2 (the least of them)", got)
}
if got := jitter([]float64{10, 10, 10}); got != 0 {
t.Errorf("jitter of a steady line = %v, want 0", got)
}
if got := jitter([]float64{10}); got != 0 {
t.Errorf("jitter of one sample = %v, want 0", got)
}
if got := jitter([]float64{5, 50, 5, 50}); got != 45 {
t.Errorf("jitter of a line alternating by 45 = %v, want 45", got)
}
// one slow probe among steady ones moves it a little, not enormously
if got := jitter([]float64{10, 10, 10, 60, 10, 10}); got > 25 {
t.Errorf("jitter with a single outlier = %v, want it not to run away", got)
}
}
// The body an upload sends is a fixed size, counts itself, and costs nothing to make.
func TestZerosBodyCountsItself(t *testing.T) {
var n atomic.Int64
z := &zeros{left: 100, n: &n}
buf := make([]byte, 30)
total := 0
for {
got, err := z.Read(buf)
total += got
if err != nil {
break
}
}
if total != 100 || n.Load() != 100 {
t.Errorf("read %d bytes and counted %d, want 100 and 100", total, n.Load())
}
}
// Interfaces has to work on whatever machine this runs on, and always name a loopback.
func TestInterfacesHasLoopback(t *testing.T) {
ifs := Interfaces()
if len(ifs) == 0 {
t.Fatal("no interfaces at all")
}
lo := false
for _, f := range ifs {
if f.Kind == "loopback" {
lo = true
if !f.Up || len(f.Addrs) == 0 {
t.Errorf("loopback %s looks wrong: up=%v addrs=%v", f.Name, f.Up, f.Addrs)
}
}
if f.Name == "" {
t.Error("an interface with no name")
}
}
if !lo {
t.Error("no loopback among the interfaces")
}
}
// The real thing, against the real endpoint: skipped by default, because `make test` must
// not need the internet. HAMLAB_NET_TEST=1 go test ./internal/network/ -run Live -v
func TestLiveSpeedTest(t *testing.T) {
if os.Getenv("HAMLAB_NET_TEST") == "" {
t.Skip("set HAMLAB_NET_TEST=1 to measure the actual line")
}
tr := NewTester(nil)
if _, err := tr.Start(); err != nil {
t.Fatal(err)
}
if _, err := tr.Start(); err == nil {
t.Error("a second test started while the first was running")
}
deadline := time.Now().Add(90 * time.Second)
for tr.Running() && time.Now().Before(deadline) {
time.Sleep(250 * time.Millisecond)
}
st := tr.State()
if st.Result == nil {
t.Fatal("no result")
}
r := *st.Result
t.Logf("down %s up %s latency %.1f ms jitter %.1f ms via %s ip %s %.0f MB moved in %.1fs",
Mbits(r.DownMbps), Mbits(r.UpMbps), r.LatencyMs, r.JitterMs, r.Colo, r.IP,
float64(r.DownBytes+r.UpBytes)/1e6, r.TookSec)
if r.Error != "" {
t.Fatalf("test failed: %s", r.Error)
}
if r.DownMbps <= 0 || r.UpMbps <= 0 || r.LatencyMs <= 0 {
t.Errorf("a figure came back zero: %+v", r)
}
if r.IP == "" {
t.Error("no public address reported")
}
if len(tr.History()) != 1 {
t.Errorf("history has %d results, want 1", len(tr.History()))
}
}
// A phase measured against a local server, at a rate the test controls. This is the part of
// the speed test with arithmetic in it — the window after the ramp, the byte cap that ends it
// early, and the rate that comes out — and none of it needs the internet to check.
func TestPhaseMeasuresTheWindow(t *testing.T) {
const perTick = 1 << 20 // a mebibyte every 5 ms ≈ 1678 Mbps, whatever the machine is doing
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/up" {
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(204)
return
}
buf := make([]byte, perTick)
fl, _ := w.(http.Flusher)
for {
if _, err := w.Write(buf); err != nil {
return
}
if fl != nil {
fl.Flush()
}
select {
case <-time.After(5 * time.Millisecond):
case <-r.Context().Done():
return
}
}
}))
defer srv.Close()
defer func(up string, w, d, mw time.Duration, m int64) {
upURL, warmUp, downFor, minWindow, measureBytes = up, w, d, mw, m
}(upURL, warmUp, downFor, minWindow, measureBytes)
upURL = srv.URL + "/up"
warmUp, downFor, minWindow, measureBytes = 100*time.Millisecond, 3*time.Second, 200*time.Millisecond, 8<<20
here := Source{"local", func(int) string { return srv.URL + "/down" }}
tr := NewTester(nil)
began := time.Now()
mbps, moved, note, err := tr.phase(context.Background(), srv.Client(), "download", 2, downFor, 0, 1, here)
took := time.Since(began)
if err != nil {
t.Fatalf("phase: %v", err)
}
if note != "" {
t.Errorf("note = %q, want none — nothing went wrong", note)
}
// The cap must have ended it early rather than the clock: the phase was allowed three
// seconds and should be done in well under one, having moved at least the cap.
if moved < measureBytes {
t.Errorf("moved %d bytes, want at least the %d-byte cap", moved, measureBytes)
}
if took > time.Second {
t.Errorf("phase took %v of the %v it was allowed; the byte cap should have ended it", took.Round(time.Millisecond), downFor)
}
// The server hands out a mebibyte per 5 ms per stream, so the rate is in the hundreds of
// Mbps. The point is that it is a real quotient of what moved over how long, not that it
// hits a particular figure on a loaded test machine.
if mbps < 50 || mbps > 20000 {
t.Errorf("mbps = %v, which is not a believable quotient", mbps)
}
t.Logf("measured %.0f Mbps over %.1f MiB", mbps, float64(moved)/(1<<20))
// A phase whose server refuses outright fails, and says what it was told.
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
defer bad.Close()
refuser := Source{"refuser", func(int) string { return bad.URL + "/down" }}
_, _, _, err = tr.phase(context.Background(), bad.Client(), "download", 2, 300*time.Millisecond, 0, 1, refuser)
if err == nil {
t.Error("a phase against a server that refuses everything should fail")
} else if !refused(err) {
t.Errorf("error = %q, want the refusal that makes the fallback kick in", err)
}
}
// Stopping a test mid-flight is reported as cancelled, not as whatever the streams said on
// their way out.
func TestCancelIsReportedAsCancelled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 1<<16)
for {
if _, err := w.Write(buf); err != nil {
return
}
select {
case <-time.After(time.Millisecond):
case <-r.Context().Done():
return
}
}
}))
defer srv.Close()
defer func(w, d time.Duration) { warmUp, downFor = w, d }(warmUp, downFor)
warmUp, downFor = 10*time.Millisecond, 10*time.Second
here := Source{"local", func(int) string { return srv.URL + "/down" }}
tr := NewTester(nil)
ctx, cancel := context.WithCancel(context.Background())
go func() { time.Sleep(200 * time.Millisecond); cancel() }()
_, _, _, err := tr.phase(ctx, srv.Client(), "download", 1, downFor, 0, 1, here)
// Cancelled after bytes moved: the figure stands, and the phase does not claim an error.
if err != nil && !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want nil or a cancellation", err)
}
}
// When the first source refuses, the download is taken from the next one and the result says
// so. This is the whole reason there is more than one.
func TestDownloadFallsBackWhenRefused(t *testing.T) {
serving := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/up" {
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(204)
return
}
// paced, so the phase is governed by its window and not by a loopback moving a
// gibibyte before the first tick
buf := make([]byte, 1<<20)
fl, _ := w.(http.Flusher)
for i := 0; i < 64; i++ {
if _, err := w.Write(buf); err != nil {
return
}
if fl != nil {
fl.Flush()
}
select {
case <-time.After(2 * time.Millisecond):
case <-r.Context().Done():
return
}
}
}))
defer serving.Close()
refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
defer refusing.Close()
defer func(srcs []Source, up string, w, d, mw time.Duration, m int64) {
downSources, upURL, warmUp, downFor, minWindow, measureBytes = srcs, up, w, d, mw, m
}(downSources, upURL, warmUp, downFor, minWindow, measureBytes)
downSources = []Source{
{"refuser", func(int) string { return refusing.URL + "/down" }},
{"the-other-one", func(int) string { return serving.URL + "/down" }},
}
upURL = serving.URL + "/up"
warmUp, downFor, upFor, minWindow, measureBytes = 50*time.Millisecond, time.Second, 300*time.Millisecond, 100*time.Millisecond, 4<<20
tr := NewTester(nil)
if _, err := tr.Start(); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(30 * time.Second)
for tr.Running() && time.Now().Before(deadline) {
time.Sleep(50 * time.Millisecond)
}
res := tr.State().Result
if res == nil {
t.Fatal("no result")
}
if res.Source != "the-other-one" {
t.Errorf("source = %q, want the second one — the first refused", res.Source)
}
if res.DownMbps <= 0 {
t.Errorf("download = %v, want a figure from the source that did serve: %+v", res.DownMbps, res)
}
}
+687
View File
@@ -0,0 +1,687 @@
package network
// The speed test. It measures against Cloudflare's public speed endpoints, the same ones
// speed.cloudflare.com uses: a plain GET of N bytes down, a plain POST of N bytes up, and a
// zero-byte GET for the round trip. No account, no API key, nothing to install, and an edge
// close to every one of these machines.
//
// The method, since a number without one means little — it is stated in the UI too:
//
// - Latency is the round trip of a zero-byte response on a connection that is already open,
// less the time the far end says it spent on the request (its Server-Timing header). That
// subtraction is the difference between measuring the line and measuring Cloudflare's
// thinking time: a zero-byte response is almost entirely the latter — tens of
// milliseconds of worker, against a few of actual wire. The figure reported is the best of
// several probes; jitter is the average change from one to the next.
// - Download and upload each run several streams at once for a fixed few seconds. One
// stream measures one TCP connection's congestion window as much as the line, which is
// why every speed test ever made uses more than one.
// - The first second of each phase is thrown away. That is the ramp: counting it reports a
// number nobody's connection ever actually did.
// - Each phase is capped in bytes as well as seconds, so a very fast line cannot run away
// with the meter.
//
// The one thing to know about the far end: it rate-limits by volume, and a test moves a few
// hundred megabytes. Several in a row will start earning 429s, which is why there is a
// cooldown between tests and why a phase that gets refused says so instead of reporting the
// decayed figure it would otherwise compute.
//
// HTTP/2 is deliberately off: over one HTTP/2 connection the streams would share a single
// congestion window, which measures the window, not the line.
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"math"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// The endpoints, and the shape of a phase. Variables rather than constants only so the tests
// can point them at a local server and shrink the windows; nothing else rebinds them.
// Source is somewhere to pull bytes from. Cloudflare's endpoint is the first choice because
// it is anycast — near wherever Ham Lab is running — and because the same host answers for
// upload and for the trace. The fallback exists because that endpoint rate-limits by volume
// and will refuse a connection that has been tested hard: a plain 100 MB file on a public
// mirror has no such opinion. It is in Seattle, so on a machine far from there it will read
// low, which is exactly why every result names the source it used.
type Source struct {
Name string
// URL takes the number of bytes wanted, where the source can be asked; a fixed file
// ignores it and is fetched again each time round.
URL func(bytes int) string
}
var downSources = []Source{
{"cloudflare", func(b int) string { return fmt.Sprintf(cfDownURL, b) }},
{"linode-seattle", func(int) string { return "https://speedtest.seattle.linode.com/100MB-seattle.bin" }},
}
var (
cfDownURL = "https://speed.cloudflare.com/__down?bytes=%d"
upURL = "https://speed.cloudflare.com/__up"
// traceURL is Cloudflare's standard trace: a few lines of key=value naming the address it
// sees, the edge that answered and the country. A few hundred bytes.
traceURL = "https://speed.cloudflare.com/cdn-cgi/trace"
warmUp = time.Second
downFor = 4 * time.Second
upFor = 4 * time.Second
// measureBytes ends a phase early once this much has moved inside the window. A fast line
// says what it is in well under the time allowed, and stopping there is what keeps a test
// down to a couple of hundred megabytes — which is the difference between the far end
// serving this happily and rate-limiting it. A slow line never reaches it and is timed out
// by downFor/upFor instead, which is the honest way round.
measureBytes = int64(64) << 20
// minWindow is how long the window must last whatever the byte cap says. Without it a very
// fast line fills the cap in a few hundredths of a second and the phase would be over
// before there was anything to divide by.
minWindow = time.Second
)
const (
latencyProbes = 8
downStreams = 4
upStreams = 3
downChunk = 25 << 20 // bytes per download request; the far end refuses much more
upChunk = 25 << 20 // and per upload request
// phaseBudget is the runaway guard, counting the ramp too: nothing measures forever.
phaseBudget = int64(512) << 20
httpTimeout = 90 * time.Second
// Cooldown is how long the line is left alone between tests. The far end rate-limits by
// volume, and two back-to-back tests are the surest way to be refused by it.
Cooldown = 20 * time.Second
// Method is what the UI tells the reader about the numbers it is showing.
Method = "Cloudflare's speed endpoints · 4 streams down, 3 up · a 1 s ramp, then up to 4 s or 64 MiB each way"
)
// Result is one finished speed test.
type Result struct {
At time.Time `json:"at"`
DownMbps float64 `json:"down_mbps"`
UpMbps float64 `json:"up_mbps"`
LatencyMs float64 `json:"latency_ms"`
JitterMs float64 `json:"jitter_ms"`
DownBytes int64 `json:"down_bytes"`
UpBytes int64 `json:"up_bytes"`
TookSec float64 `json:"took_sec"`
// Colo is the Cloudflare edge that answered — "SEA" — and IP is this connection's public
// address as that edge saw it.
Colo string `json:"colo,omitempty"`
IP string `json:"ip,omitempty"`
// Source is where the download came from, which matters because the fallback is not in the
// same place as the anycast one. Empty means the usual.
Source string `json:"source,omitempty"`
// Note is set when the figures are real but something went wrong along the way — a stream
// that was refused part-way, most often. The number is what was actually seen; the note is
// why it may be low.
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
}
// Run is a test in progress, or the last one to have finished.
type Run struct {
Started time.Time `json:"started"`
Running bool `json:"running"`
// Phase is where it has got to: latency, download, upload, done.
Phase string `json:"phase"`
// Done is how far through the whole test, 0–1, so one bar can show all of it.
Done float64 `json:"done"`
// Mbps is the live figure for the phase under way — what the needle shows.
Mbps float64 `json:"mbps"`
Bytes int64 `json:"bytes"`
Result *Result `json:"result,omitempty"`
}
// Tester runs one speed test at a time and remembers what the last ones said.
type Tester struct {
mu sync.RWMutex
run Run
hist []Result
// lastTry is when a test was last *started*, which is what the cooldown counts from — a
// test that was refused still used the line, and hammering retries at an endpoint that has
// just rate-limited us is the one thing guaranteed not to help.
lastTry time.Time
cancel context.CancelFunc
onDone func(Result)
maxHist int
}
func NewTester(onDone func(Result)) *Tester {
return &Tester{onDone: onDone, maxHist: 50}
}
// Load takes the results kept from before a restart, oldest first.
func (t *Tester) Load(rs []Result) {
t.mu.Lock()
defer t.mu.Unlock()
t.hist = append(t.hist[:0], rs...)
if n := len(t.hist); n > 0 {
last := t.hist[n-1]
t.run = Run{Started: last.At, Phase: "done", Done: 1, Result: &last}
t.lastTry = last.At
}
}
func (t *Tester) State() Run {
t.mu.RLock()
defer t.mu.RUnlock()
return t.run
}
func (t *Tester) History() []Result {
t.mu.RLock()
defer t.mu.RUnlock()
return append([]Result{}, t.hist...)
}
func (t *Tester) Running() bool {
t.mu.RLock()
defer t.mu.RUnlock()
return t.run.Running
}
// Cancel stops a test in progress. Reports whether there was one.
func (t *Tester) Cancel() bool {
t.mu.Lock()
c := t.cancel
t.mu.Unlock()
if c == nil {
return false
}
c()
return true
}
var ErrBusy = errors.New("a speed test is already running")
// Start begins a test in the background and returns its opening state.
func (t *Tester) Start() (Run, error) {
t.mu.Lock()
if t.run.Running {
r := t.run
t.mu.Unlock()
return r, ErrBusy
}
if since := time.Since(t.lastTry); since < Cooldown && !t.lastTry.IsZero() {
r := t.run
t.mu.Unlock()
return r, fmt.Errorf("give the line %d more seconds — testing back to back is what gets this connection rate-limited", int((Cooldown-since).Seconds())+1)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.cancel = cancel
t.lastTry = time.Now()
t.run = Run{Started: t.lastTry, Running: true, Phase: "latency"}
r := t.run
t.mu.Unlock()
go t.measure(ctx, cancel)
return r, nil
}
func (t *Tester) set(fn func(*Run)) {
t.mu.Lock()
fn(&t.run)
t.mu.Unlock()
}
func (t *Tester) measure(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
started := time.Now()
res := Result{At: started}
tr := newTransport(downStreams)
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr, Timeout: httpTimeout}
// latency, and the meta the same responses carry
lat, meta, err := t.latency(ctx, client)
if err == nil {
res.LatencyMs, res.JitterMs = best(lat), jitter(lat)
res.Colo, res.IP = meta.Colo, meta.IP
}
var note string
if err == nil {
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "download", 0.1, 0 })
// Each source in turn: a refusal from the first is exactly what the second is for, and
// anything else (no route, no DNS) would fail the same way twice, so it stops there.
for i, src := range downSources {
res.Source = src.Name
res.DownMbps, res.DownBytes, note, err = t.phase(ctx, client, "download", downStreams, downFor, 0.1, 0.6, src)
if err == nil || !refused(err) || i == len(downSources)-1 {
break
}
log.Printf("speed test: %s refused the download, trying %s", src.Name, downSources[i+1].Name)
}
res.Note = join(res.Note, note)
}
if err == nil {
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "upload", 0.7, 0 })
res.UpMbps, res.UpBytes, note, err = t.phase(ctx, client, "upload", upStreams, upFor, 0.7, 1.0, Source{Name: "cloudflare"})
res.Note = join(res.Note, note)
}
res.TookSec = round(time.Since(started).Seconds(), 1)
if err != nil {
if ctx.Err() != nil {
res.Error = "cancelled"
} else {
res.Error = err.Error()
}
}
t.mu.Lock()
t.cancel = nil
t.run = Run{Started: started, Phase: "done", Done: 1, Result: &res, Bytes: res.DownBytes + res.UpBytes}
if res.Error == "" || res.DownMbps > 0 { // a cancelled test that got a number is still a reading
t.hist = append(t.hist, res)
if len(t.hist) > t.maxHist {
t.hist = t.hist[len(t.hist)-t.maxHist:]
}
}
done, out := t.onDone, res
t.mu.Unlock()
if done != nil {
done(out)
}
}
// meta is what the edge says about the connection. The address comes from a header of the
// speed endpoint's own; the edge's name is the tail of the ray ID, which every Cloudflare
// response carries ("a41f6720f966a36c-SEA").
type meta struct{ IP, Colo string }
func readMeta(h http.Header) meta {
m := meta{IP: h.Get("cf-meta-ip")}
if ray := h.Get("cf-ray"); ray != "" {
if i := strings.LastIndexByte(ray, '-'); i >= 0 {
m.Colo = ray[i+1:]
}
}
return m
}
// serverMs is how long the far end says it spent on this request, from Server-Timing. The
// cfL4 entry carries no dur= and contributes nothing, which is what we want — it is a
// description of the connection, not time spent.
func serverMs(h http.Header) float64 {
var total float64
for _, v := range h.Values("Server-Timing") {
for _, part := range strings.Split(v, ",") {
_, after, ok := strings.Cut(part, "dur=")
if !ok {
continue
}
end := 0
for end < len(after) && (after[end] == '.' || (after[end] >= '0' && after[end] <= '9')) {
end++
}
if f, err := strconv.ParseFloat(after[:end], 64); err == nil {
total += f
}
}
}
return total
}
// latency times several zero-byte responses on an already-open connection.
func (t *Tester) latency(ctx context.Context, client *http.Client) ([]float64, meta, error) {
var m meta
var out []float64
for i := 0; i <= latencyProbes; i++ { // the first is the handshake, and is not counted
start := time.Now()
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(cfDownURL, 0), nil)
if err != nil {
return nil, m, err
}
resp, err := client.Do(req)
if err != nil {
return nil, m, err
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
return nil, m, fmt.Errorf("speed endpoint answered %s", resp.Status)
}
if i == 0 {
m = readMeta(resp.Header)
continue
}
rtt := float64(time.Since(start).Microseconds())/1000 - serverMs(resp.Header)
if rtt < 0.01 { // the far end claimed more time than the whole request took
rtt = 0.01
}
out = append(out, rtt)
t.set(func(r *Run) { r.Done = 0.02 + 0.08*float64(i)/latencyProbes })
}
if len(out) == 0 {
return nil, m, errors.New("no reply from the speed endpoint")
}
return out, m, nil
}
// phase pushes traffic with several streams for a fixed spell and reports the rate over the
// window after the ramp. from and to are where this phase sits in the whole test's progress.
//
// The window ends when the traffic does, not when the clock runs out: if the far end starts
// refusing part-way, the figure is what was actually moved while it was moving, and the note
// says what happened. Coasting to the end of the clock with no bytes arriving would report a
// number a third of the truth.
func (t *Tester) phase(parent context.Context, client *http.Client, dir string, streams int, forHow time.Duration, from, to float64, src Source) (float64, int64, string, error) {
// parent is the test's own context, which is cancelled when somebody presses Stop. ctx is
// this phase's, which *this function* cancels to shut the streams down — so the two are
// kept apart: asking ctx whether the test was cancelled would always say yes by then, and
// every failure would be reported as "context canceled" whatever really happened.
ctx, stop := context.WithCancel(parent)
defer stop()
var moved atomic.Int64
var firstErr error
var errOnce sync.Once
var alive atomic.Int32
alive.Store(int32(streams))
dead := make(chan struct{})
var deadOnce sync.Once
for i := 0; i < streams; i++ {
go func() {
defer func() {
if alive.Add(-1) == 0 {
deadOnce.Do(func() { close(dead) })
}
}()
tries := 0
for ctx.Err() == nil && moved.Load() < phaseBudget {
var err error
if dir == "download" {
err = stream(ctx, client, src, &moved)
} else {
err = upstream(ctx, client, &moved)
}
if err == nil {
tries = 0
continue
}
if ctx.Err() != nil {
return
}
errOnce.Do(func() { firstErr = err })
if tries++; tries > 2 { // refused three times: this stream is finished
return
}
select {
case <-time.After(400 * time.Millisecond):
case <-ctx.Done():
return
}
}
}()
}
// the ramp, measured and thrown away
select {
case <-time.After(warmUp):
case <-dead:
case <-ctx.Done():
}
mark, at := moved.Load(), time.Now()
last, lastAt := mark, at // the last time bytes were still arriving
deadline := at.Add(forHow)
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
for time.Now().Before(deadline) {
select {
case <-tick.C:
case <-dead:
case <-ctx.Done():
}
if parent.Err() != nil {
break
}
now := moved.Load()
if now > last {
last, lastAt = now, time.Now()
}
// enough has moved to know, and it has been long enough to mean it
if (now-mark >= measureBytes && time.Since(at) >= minWindow) || now >= phaseBudget {
break
}
if secs := time.Since(at).Seconds(); secs > 0 {
live := mbps(now-mark, secs)
part := math.Min(1, secs/forHow.Seconds())
t.set(func(r *Run) { r.Mbps, r.Bytes, r.Done = live, now, from+(to-from)*part })
}
// every stream has given up and nothing more is coming
if alive.Load() == 0 {
break
}
}
total, secs := last-mark, lastAt.Sub(at).Seconds()
stop()
if total <= 0 {
switch {
case firstErr != nil: // what the far end said explains it better than anything else
return 0, moved.Load(), "", firstErr
case parent.Err() != nil:
return 0, moved.Load(), "", parent.Err()
}
return 0, moved.Load(), "", fmt.Errorf("nothing moved in the %s phase", dir)
}
if secs < 0.02 { // too short a window to divide by; the guard is against dividing by zero,
// not a judgement about how long a good measurement takes
return 0, moved.Load(), "", fmt.Errorf("the %s phase was over before it could be measured", dir)
}
note := ""
if firstErr != nil {
note = fmt.Sprintf("the %s phase was cut short: %s", dir, firstErr)
}
return mbps(total, secs), moved.Load(), note, nil
}
// join puts two notes together without leaving an empty one dangling.
func join(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
}
return a + "; " + b
}
// stream fetches one chunk from src, counting bytes as they arrive.
func stream(ctx context.Context, client *http.Client, src Source, n *atomic.Int64) error {
req, err := http.NewRequestWithContext(ctx, "GET", src.URL(downChunk), nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return answered(resp.StatusCode, resp.Status)
}
buf := make([]byte, 64<<10)
for {
got, err := resp.Body.Read(buf)
if got > 0 {
n.Add(int64(got))
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
// upstream posts one chunk of nothing in particular, counting bytes as they go out.
func upstream(ctx context.Context, client *http.Client, n *atomic.Int64) error {
body := &zeros{left: upChunk, n: n}
req, err := http.NewRequestWithContext(ctx, "POST", upURL, body)
if err != nil {
return err
}
req.ContentLength = upChunk
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 204 {
return answered(resp.StatusCode, resp.Status)
}
return nil
}
// answered turns the far end's refusal into something worth reading. It rate-limits by
// volume: enough testing in a short while and it stops serving the big transfers, which is
// the only failure of this test anyone here is likely to meet.
func answered(code int, status string) error {
if code == 429 || code == 403 {
return errRefused
}
return fmt.Errorf("the speed endpoint answered %s", status)
}
// errRefused is the far end rate-limiting this connection, which is the only failure of this
// test anyone here is likely to meet: it limits by volume, and a test moves a few hundred
// megabytes. It is recognisable so the download phase can try its other source.
var errRefused = errors.New("the speed endpoint is rate-limiting this connection — it does that after a lot of testing; leave it a few minutes")
func refused(err error) bool { return errors.Is(err, errRefused) }
// zeros is a request body of a fixed size that costs nothing to produce, counting itself as
// it is read out onto the wire.
type zeros struct {
left int
n *atomic.Int64
}
func (z *zeros) Read(p []byte) (int, error) {
if z.left <= 0 {
return 0, io.EOF
}
if len(p) > z.left {
p = p[:z.left]
}
for i := range p {
p[i] = 0
}
z.left -= len(p)
z.n.Add(int64(len(p)))
return len(p), nil
}
func newTransport(streams int) *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
MaxIdleConns: streams * 2,
MaxIdleConnsPerHost: streams * 2,
// An empty map turns HTTP/2 off: see the note at the top of this file.
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
}
}
// probeOutside asks the far end who it thinks we are. One small request.
func probeOutside() Outside {
tr := newTransport(1)
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", traceURL, nil)
if err != nil {
return Outside{At: time.Now(), Error: err.Error()}
}
resp, err := client.Do(req)
if err != nil {
return Outside{At: time.Now(), Error: shortErr(err)}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode != 200 {
return Outside{At: time.Now(), Error: "the trace endpoint answered " + resp.Status}
}
o := Outside{At: time.Now()}
for _, line := range strings.Split(string(body), "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "ip":
o.IP = v
case "colo":
o.Colo = v
case "loc":
o.Country = v
}
}
if o.IP == "" {
o.Error = "the trace endpoint said nothing about this connection"
}
return o
}
// shortErr keeps the part of a network error a person can act on.
func shortErr(err error) string {
s := err.Error()
if i := strings.LastIndex(s, ": "); i > 0 && len(s)-i < 60 {
return s[i+2:]
}
return s
}
func mbps(bytes int64, secs float64) float64 {
return round(float64(bytes)*8/secs/1e6, 1)
}
func best(v []float64) float64 {
m := v[0]
for _, x := range v {
if x < m {
m = x
}
}
return round(m, 1)
}
// jitter is how much the line wobbles: the average change from one round trip to the next,
// which is the usual definition and, unlike a standard deviation, is not run away with by a
// single slow probe.
func jitter(v []float64) float64 {
if len(v) < 2 {
return 0
}
var sum float64
for i := 1; i < len(v); i++ {
sum += math.Abs(v[i] - v[i-1])
}
return round(sum/float64(len(v)-1), 1)
}
func round(v float64, places int) float64 {
p := math.Pow(10, float64(places))
return math.Round(v*p) / p
}
// Mbits renders a rate the way the line is sold, for a log line.
func Mbits(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) + " Mbps" }
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh /etc/rc.common
# Ham Lab web console (written by hamlab install)
USE_PROCD=1
# after network and firewall, before the tail of the boot
START=95
STOP=10
start_service() {
procd_open_instance hamlab
procd_set_param command "@BIN@" serve
procd_set_param env HAMLAB_CONFIG="@CONFIG@" HOME="@HOME@" USER="@USER@"
procd_set_param user "@USER@"
procd_set_param group "@GROUP@"
procd_set_param pidfile /var/run/hamlab.pid
# respawn: settle 3s, 5 retries inside that window, then give up counting
procd_set_param respawn 3600 3 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger network
}
+25 -1
View File
@@ -37,9 +37,14 @@ var systemdUnit string
//go:embed hamlab.openrc //go:embed hamlab.openrc
var openrcScript string var openrcScript string
//go:embed hamlab.procd
var procdScript string
const ( const (
unitPath = "/etc/systemd/system/hamlab.service" unitPath = "/etc/systemd/system/hamlab.service"
openrcPath = "/etc/init.d/hamlab" openrcPath = "/etc/init.d/hamlab"
// procd (OpenWrt) uses the same path as OpenRC; the two are told apart by content.
procdPath = "/etc/init.d/hamlab"
sudoersPath = "/etc/sudoers.d/hamlab-quiet" sudoersPath = "/etc/sudoers.d/hamlab-quiet"
) )
@@ -228,6 +233,19 @@ func Install(args []string, version string) error {
return err return err
} }
} }
case exists("/sbin/procd") && exists("/etc/rc.common"):
initSys = "procd"
if err := os.WriteFile(procdPath, []byte(fill(procdScript, vars)), 0o755); err != nil {
return err
}
if err := run(procdPath, "enable"); err != nil {
say("warning: %s enable: %v", procdPath, err)
}
if !o.noStart {
if err := run(procdPath, "restart"); err != nil {
return err
}
}
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"): case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
initSys = "OpenRC" initSys = "OpenRC"
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil { if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
@@ -258,7 +276,7 @@ func Install(args []string, version string) error {
url := scheme + "://" + browseAddr(cfg.Listen) url := scheme + "://" + browseAddr(cfg.Listen)
switch { switch {
case initSys == "": case initSys == "":
say("\nNo systemd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest) say("\nNo systemd, procd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
case o.noStart: case o.noStart:
case healthy(cfg.Listen, cfg.TLS == "auto"): case healthy(cfg.Listen, cfg.TLS == "auto"):
say("\nHam Lab is up: %s", url) say("\nHam Lab is up: %s", url)
@@ -294,9 +312,15 @@ func Uninstall(args []string) error {
_ = run("systemctl", "daemon-reload") _ = run("systemctl", "daemon-reload")
} }
if b, err := os.ReadFile(openrcPath); err == nil { if b, err := os.ReadFile(openrcPath); err == nil {
if strings.Contains(string(b), "USE_PROCD=1") {
dataDir = configDirFrom(string(b), "HAMLAB_CONFIG=")
_ = run(procdPath, "stop")
_ = run(procdPath, "disable")
} else {
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=") dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
_ = run("rc-service", "hamlab", "stop") _ = run("rc-service", "hamlab", "stop")
_ = run("rc-update", "del", "hamlab", "default") _ = run("rc-update", "del", "hamlab", "default")
}
os.Remove(openrcPath) os.Remove(openrcPath)
} }
os.Remove(sudoersPath) os.Remove(sudoersPath)
+3 -1
View File
@@ -1,4 +1,6 @@
// Package space measures what is using a disk: a parallel walk of the filesystems a disk // Package space measures what is using a disk — it is what the UI calls the **Usage** tab
// (renamed from "Space" on 2026-09-24; the package and its /api/space endpoints kept the
// old name rather than churn a working API): a parallel walk of the filesystems a disk
// backs, kept as a tree of directories and the files worth seeing, the way WizTree does it. // backs, kept as a tree of directories and the files worth seeing, the way WizTree does it.
// //
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's // The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
+493
View File
@@ -0,0 +1,493 @@
package storage
// Scheduled snapshots with a retention period: take a snapshot of a dataset on a schedule,
// and drop the ones that have aged out.
//
// **The naming is zfSnap's, deliberately.** grid has run zfSnap v1.11.1 from root cron since
// before this repo existed and it works well, so rather than invent a second convention and
// have two tools disagree about what a snapshot is, this writes and reads exactly what
// zfSnap does: `<dataset>@2026-09-24_02.30.01--6w`, with the retention encoded in the name.
// That has three consequences worth knowing. Ham Lab can show and manage the snapshots
// zfSnap already took. The two can run side by side without fighting, because both only
// ever destroy snapshots that have passed the expiry written into their own name. And
// changing a policy's retention does not retroactively re-date old snapshots — each one
// carries the rule it was taken under, which is the behaviour you want from a backup.
//
// Two rules make this safe to run unattended. Only a snapshot whose name parses as a stamp
// and a TTL is ever a candidate — anything taken by hand or named anything else is invisible
// to it. And every policy can be asked what it would delete, before it deletes anything.
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"hamlab/internal/cron"
"hamlab/internal/priv"
)
// stampLayout is zfSnap's timestamp: 2026-09-24_02.30.01.
const stampLayout = "2006-01-02_15.04.05"
// ttlSep separates the stamp from the retention in a snapshot name, as zfSnap writes it.
const ttlSep = "--"
// Policy is a standing instruction: snapshot this dataset on this schedule, keep the
// snapshots this long.
type Policy struct {
ID string `json:"id"`
Name string `json:"name"`
Dataset string `json:"dataset"`
// Recursive takes one atomic snapshot of the dataset and everything under it (zfs -r).
Recursive bool `json:"recursive"`
// Every is hourly | daily | weekly | monthly | custom. At is "HH:MM" (the minute alone
// is used for hourly). Weekday is 0=Sunday, Day is 1–28. With "custom", Cron is a plain
// cron expression and the rest is ignored — the escape hatch for anything the four
// common shapes cannot say.
Every string `json:"every"`
At string `json:"at"`
Weekday int `json:"weekday"`
Day int `json:"day"`
Cron string `json:"cron,omitempty"`
// KeepFor is how long a snapshot lives: "6w", "30d", "12h". It is written into the
// snapshot's own name, zfSnap-style, so each one carries the rule it was taken under.
// KeepMin is a floor — that many newest snapshots survive however old they are, so a
// dataset is never left with none.
KeepFor string `json:"keep_for"`
KeepMin int `json:"keep_min"`
Prefix string `json:"prefix"`
Enabled bool `json:"enabled"`
}
func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) }
// Validate reports what is wrong with a policy, or nil.
func (p *Policy) Validate() error {
if !datasetRe.MatchString(p.Dataset) {
return errors.New("dataset: a pool or pool/dataset name")
}
if pre := p.prefix(); pre != "" && (!snapRe.MatchString(pre) || strings.Contains(pre, ttlSep)) {
return errors.New("prefix: letters, digits, . _ : and - only, and no --")
}
switch p.Every {
case "hourly", "daily", "weekly", "monthly":
case "custom":
if strings.TrimSpace(p.Cron) == "" {
return errors.New("a custom schedule needs a cron expression")
}
default:
return errors.New("every: hourly, daily, weekly, monthly or custom")
}
if _, err := p.CronExpr(); err != nil {
return err
}
if _, _, err := p.clock(); err != nil {
return err
}
if p.Every == "weekly" && (p.Weekday < 0 || p.Weekday > 6) {
return errors.New("weekday: 0 (Sunday) to 6")
}
if p.Every == "monthly" && (p.Day < 1 || p.Day > 28) {
return errors.New("day: 1 to 28, so every month has one")
}
if p.KeepFor != "" {
if _, err := ParseAge(p.KeepFor); err != nil {
return err
}
}
if p.KeepMin < 0 {
return errors.New("keep at least: 0 or more")
}
if p.KeepFor == "" {
return errors.New("a retention period is required — it is written into each snapshot's name, and is what lets it be cleaned up later")
}
return nil
}
func (p *Policy) clock() (hour, min int, err error) {
at := strings.TrimSpace(p.At)
if at == "" {
return 0, 0, nil
}
h, m, ok := strings.Cut(at, ":")
if !ok {
return 0, 0, errors.New("at: HH:MM")
}
hour, err = strconv.Atoi(h)
if err == nil {
min, err = strconv.Atoi(m)
}
if err != nil || hour < 0 || hour > 23 || min < 0 || min > 59 {
return 0, 0, errors.New("at: HH:MM, 00:00 to 23:59")
}
return hour, min, nil
}
// ParseAge reads "6w", "30d", "12h", "90m" as a duration. Weeks and days are what a
// retention period is actually written in, and time.ParseDuration knows neither.
func ParseAge(s string) (time.Duration, error) {
s = strings.TrimSpace(strings.ToLower(s))
if s == "" {
return 0, errors.New("empty period")
}
unit := s[len(s)-1]
n, err := strconv.Atoi(s[:len(s)-1])
if err != nil || n <= 0 {
return 0, fmt.Errorf("%q: a number and a unit, like 6w, 30d or 12h", s)
}
switch unit {
case 'm':
return time.Duration(n) * time.Minute, nil
case 'h':
return time.Duration(n) * time.Hour, nil
case 'd':
return time.Duration(n) * 24 * time.Hour, nil
case 'w':
return time.Duration(n) * 7 * 24 * time.Hour, nil
}
return 0, fmt.Errorf("%q: the unit is m, h, d or w", s)
}
// CronExpr renders the schedule as a cron expression — which is what actually runs it. The
// four common shapes are sugar over this; a custom policy states it outright. One expression
// is the only description of when a schedule fires, so the page, the cron file and the next
// run can never disagree.
func (p *Policy) CronExpr() (string, error) {
if p.Every == "custom" {
expr := strings.Join(strings.Fields(p.Cron), " ")
if err := cron.ValidSchedule(expr); err != nil {
return "", fmt.Errorf("cron expression: %w", err)
}
if strings.HasPrefix(expr, "@reboot") {
return "", errors.New("@reboot is not a schedule for snapshots")
}
return expr, nil
}
hour, min, err := p.clock()
if err != nil {
return "", err
}
switch p.Every {
case "hourly":
return fmt.Sprintf("%d * * * *", min), nil
case "daily":
return fmt.Sprintf("%d %d * * *", min, hour), nil
case "weekly":
return fmt.Sprintf("%d %d * * %d", min, hour, p.Weekday), nil
case "monthly":
return fmt.Sprintf("%d %d %d * *", min, hour, p.Day), nil
}
return "", errors.New("unknown schedule")
}
// NextRun is when cron will next fire this schedule, read with the same evaluator the Jobs
// tab uses on everyone else's lines.
func (p *Policy) NextRun(now time.Time) time.Time {
expr, err := p.CronExpr()
if err != nil {
return time.Time{}
}
t, ok := cron.NextRun(expr, now)
if !ok {
return time.Time{}
}
return t
}
// SnapTime reads a snapshot's name in zfSnap's convention and returns when it was taken,
// when it expires, and whether the name parsed at all. A name without a TTL never expires.
// An optional prefix in front of the stamp is allowed, so a policy can label its own.
func SnapTime(name string) (taken time.Time, expires time.Time, ok bool) {
_, snap, cut := strings.Cut(name, "@")
if !cut {
return
}
body, ttl, hasTTL := strings.Cut(snap, ttlSep)
// an optional "<prefix>-" before the stamp
stamp := body
if len(body) > len(stampLayout) {
stamp = body[len(body)-len(stampLayout):]
}
t, err := time.ParseInLocation(stampLayout, stamp, time.Local)
if err != nil {
return time.Time{}, time.Time{}, false
}
taken, ok = t, true
if hasTTL {
if d, err := ParseAge(ttl); err == nil {
expires = t.Add(d)
}
}
return
}
// Doomed is which of this dataset's snapshots have passed the expiry written into their own
// names, oldest first — exactly what zfSnap -d would remove. This is what the UI shows
// before anything is deleted, and what Run then acts on. KeepMin holds back that many of the
// newest whatever their names say, so a dataset is never left with none.
func (m *Manager) Doomed(ctx context.Context, p *Policy) ([]Snapshot, error) {
if err := p.Validate(); err != nil {
return nil, err
}
all, err := m.Snapshots(ctx, p.Dataset)
if err != nil {
return nil, err
}
var dated []Snapshot
expiry := map[string]time.Time{}
for _, s := range all {
taken, exp, ok := SnapTime(s.Name)
if !ok || exp.IsZero() {
continue // hand-made, or kept forever: not ours to remove
}
_ = taken
dated = append(dated, s)
expiry[s.Name] = exp
}
sort.Slice(dated, func(i, j int) bool { return dated[i].Created > dated[j].Created }) // newest first
now := time.Now()
var doomed []Snapshot
for i, s := range dated {
if i < p.KeepMin {
continue // the floor: these survive however old they are
}
if expiry[s.Name].Before(now) {
doomed = append(doomed, s)
}
}
sort.Slice(doomed, func(i, j int) bool { return doomed[i].Created < doomed[j].Created }) // oldest first
return doomed, nil
}
// DestroySnapshot removes one snapshot. recursive also removes the same-named snapshot on
// every dataset beneath it, which is what a recursive policy created.
func (m *Manager) DestroySnapshot(ctx context.Context, full string, recursive bool) error {
dataset, snap, ok := strings.Cut(full, "@")
if !ok || !datasetRe.MatchString(dataset) || !snapRe.MatchString(snap) {
return errors.New("bad snapshot name")
}
args := []string{"zfs", "destroy"}
if recursive {
args = append(args, "-r")
}
out, code, err := m.run(ctx, append(args, full)...)
if err != nil {
return err
}
if code != 0 {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
// SnapshotRecursive takes dataset@name, optionally of everything beneath it too.
func (m *Manager) SnapshotRecursive(ctx context.Context, dataset, name string, recursive bool) error {
if !datasetRe.MatchString(dataset) || !snapRe.MatchString(name) {
return errors.New("bad snapshot name")
}
args := []string{"zfs", "snapshot"}
if recursive {
args = append(args, "-r")
}
out, code, err := m.run(ctx, append(args, dataset+"@"+name)...)
if err != nil {
return err
}
if code != 0 {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
// RunResult is what one pass of a policy did.
type RunResult struct {
Taken string `json:"taken"`
Pruned []string `json:"pruned"`
Skipped int `json:"skipped"` // snapshots that are not this policy's, left alone
}
// Run takes the snapshot and prunes what has aged out. It is the same code the scheduler
// calls, so "Run now" and the timer cannot drift apart.
func (m *Manager) Run(ctx context.Context, p *Policy) (RunResult, error) {
var res RunResult
if err := p.Validate(); err != nil {
return res, err
}
name := time.Now().Format(stampLayout)
if pre := strings.TrimSpace(p.Prefix); pre != "" {
name = pre + "-" + name
}
if p.KeepFor != "" {
name += ttlSep + strings.TrimSpace(strings.ToLower(p.KeepFor)) // zfSnap's convention: the retention lives in the name
}
if err := m.SnapshotRecursive(ctx, p.Dataset, name, p.Recursive); err != nil {
return res, err
}
res.Taken = p.Dataset + "@" + name
doomed, err := m.Doomed(ctx, p)
if err != nil {
return res, err // the snapshot was taken; the prune is what failed
}
for _, s := range doomed {
if err := m.DestroySnapshot(ctx, s.Name, p.Recursive); err != nil {
return res, fmt.Errorf("destroying %s: %w", s.Name, err)
}
res.Pruned = append(res.Pruned, s.Name)
}
return res, nil
}
// ---- what happened when it ran ----
//
// The policies are a definition the daemon owns; what a run did is written by whatever ran
// it, which is a cron job in its own process. Keeping the two apart means the CLI never has
// to write state.json behind the daemon's back — the last writer would win and one of them
// would lose its work.
// RunRecord is the outcome of one run of one schedule.
type RunRecord struct {
At time.Time `json:"at"`
Taken string `json:"taken,omitempty"`
Pruned []string `json:"pruned,omitempty"`
Error string `json:"error,omitempty"`
}
func runPath(dir, id string) string { return filepath.Join(dir, "zfs-runs", id+".json") }
// WriteRun records what a run did, beside the config.
func WriteRun(dir, id string, r RunRecord) error {
if err := os.MkdirAll(filepath.Join(dir, "zfs-runs"), 0o700); err != nil {
return err
}
b, err := json.Marshal(r)
if err != nil {
return err
}
p := runPath(dir, id)
tmp := p + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, p)
}
// ReadRun is the last thing a schedule did, or the zero value if it has never run.
func ReadRun(dir, id string) RunRecord {
var r RunRecord
b, err := os.ReadFile(runPath(dir, id))
if err == nil {
_ = json.Unmarshal(b, &r)
}
return r
}
// ForgetRun drops a schedule's record, for when the schedule itself goes.
func ForgetRun(dir, id string) { _ = os.Remove(runPath(dir, id)) }
// ---- the cron file ----
// CronFile is where Ham Lab writes the snapshot schedules. One file it owns entirely and
// rewrites whole, so there is never a half-updated set of lines, and so a schedule removed
// in the UI is removed from the machine.
const CronFile = "/etc/cron.d/hamlab-zfs"
// CronFileText renders every schedule as cron lines. user is who they run as — the account
// Ham Lab itself runs as, which reaches zfs through sudo exactly as the daemon does, so the
// files it writes stay owned by that account rather than by root.
func CronFileText(policies []Policy, user, exe, config string) (string, error) {
var b strings.Builder
b.WriteString("# Ham Lab snapshot schedules — written by the ZFS tab, rewritten whole on every change.\n")
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
b.WriteString("SHELL=/bin/sh\n")
b.WriteString("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
for i := range policies {
p := &policies[i]
if !p.Enabled {
b.WriteString("# " + p.Name + " — paused\n")
continue
}
expr, err := p.CronExpr()
if err != nil {
return "", fmt.Errorf("%s: %w", p.Name, err)
}
name := strings.ReplaceAll(p.Name, "\n", " ")
fmt.Fprintf(&b, "# %s — %s, keep %s\n", name, p.Dataset, p.KeepFor)
fmt.Fprintf(&b, "%s %s %s -config %s zfs-snapshot %s\n\n", expr, user, exe, config, p.ID)
}
return b.String(), nil
}
// WriteCronFile puts the schedules on the machine, or removes the file when there are none.
func (m *Manager) WriteCronFile(ctx context.Context, policies []Policy, user, exe, config string) error {
live := false
for i := range policies {
if policies[i].Enabled {
live = true
}
}
if !live {
out, code, err := m.run(ctx, "rm", "-f", CronFile)
if err != nil {
return err
}
if code != 0 {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
text, err := CronFileText(policies, user, exe, config)
if err != nil {
return err
}
return m.writeRoot(ctx, CronFile, text)
}
// WriteRootFile puts text at a root-owned path, through the same privilege escalation every
// other write here uses. Shared by the snapshot schedules and the image-update schedules,
// which both own a file in /etc/cron.d.
func (m *Manager) WriteRootFile(ctx context.Context, path, text string) error {
return m.writeRoot(ctx, path, text)
}
// RemoveRootFile deletes one, for when the last schedule of a kind goes away.
func (m *Manager) RemoveRootFile(ctx context.Context, path string) error {
out, code, err := m.run(ctx, "rm", "-f", path)
if err != nil {
return err
}
if code != 0 {
return errors.New(strings.TrimSpace(string(out)))
}
return nil
}
func (m *Manager) writeRoot(ctx context.Context, path, text string) error {
args := []string{"tee", path}
if m.sudo {
args = priv.Wrap(args...)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
cmd.Stdin = strings.NewReader(text)
var errb bytes.Buffer
cmd.Stdout, cmd.Stderr = nil, &errb
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return errors.New(msg)
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
package storage
import (
"strings"
"testing"
"time"
"hamlab/internal/cron"
)
func TestSnapTimeReadsZfSnapNames(t *testing.T) {
// the real thing off grid
taken, exp, ok := SnapTime("tank@2026-09-24_02.30.01--6w")
if !ok {
t.Fatal("zfSnap's own name must parse")
}
if taken.Format("2006-01-02 15:04:05") != "2026-09-24 02:30:01" {
t.Fatalf("taken = %v", taken)
}
if got := exp.Sub(taken); got != 6*7*24*time.Hour {
t.Fatalf("ttl = %v, want 6w", got)
}
// a labelled one from a policy with a prefix
if _, e, ok := SnapTime("tank/apps@nightly-2026-09-24_02.30.01--30d"); !ok || e.IsZero() {
t.Fatal("a prefixed name must parse and expire")
}
// no TTL: never expires
if _, e, ok := SnapTime("tank@2026-09-24_02.30.01"); !ok || !e.IsZero() {
t.Fatal("a name without a TTL must parse and never expire")
}
// things that are not ours
for _, n := range []string{"tank@before-upgrade", "tank@manual", "tank", "tank@2026-13-45_99.99.99--6w"} {
if _, _, ok := SnapTime(n); ok {
t.Errorf("%q must not parse as a scheduled snapshot", n)
}
}
}
func TestCronExprAndNextRun(t *testing.T) {
now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local)
for _, c := range []struct {
p Policy
want string
}{
{Policy{Every: "daily", At: "02:30"}, "30 2 * * *"},
{Policy{Every: "hourly", At: "00:30"}, "30 * * * *"},
{Policy{Every: "weekly", At: "04:00", Weekday: 0}, "0 4 * * 0"},
{Policy{Every: "monthly", At: "05:15", Day: 12}, "15 5 12 * *"},
{Policy{Every: "custom", Cron: "*/15 9-17 * * 1-5"}, "*/15 9-17 * * 1-5"},
} {
got, err := c.p.CronExpr()
if err != nil || got != c.want {
t.Errorf("CronExpr(%+v) = %q, %v; want %q", c.p.Every, got, err, c.want)
}
}
// the next run is read with the same evaluator the Jobs tab uses
daily := Policy{Every: "daily", At: "02:30"}
if n := daily.NextRun(now); n.Hour() != 2 || n.Minute() != 30 || n.Day() != 25 {
t.Fatalf("NextRun = %v, want 25th 02:30", n)
}
// a custom expression that does not parse is refused, not silently ignored
if _, err := (&Policy{Every: "custom", Cron: "not a schedule"}).CronExpr(); err == nil {
t.Fatal("a bad cron expression must be refused")
}
if _, err := (&Policy{Every: "custom", Cron: "@reboot"}).CronExpr(); err == nil {
t.Fatal("@reboot is not a snapshot schedule")
}
}
func TestCronFileText(t *testing.T) {
ps := []Policy{
{ID: "abc", Name: "tank nightly", Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Enabled: true},
{ID: "def", Name: "paused one", Dataset: "tank/apps", Every: "hourly", At: "00:05", KeepFor: "2d"},
}
text, err := CronFileText(ps, "core", "/usr/local/bin/hamlab", "/home/core/apps/hamlab/config.json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(text, "30 2 * * * core /usr/local/bin/hamlab -config /home/core/apps/hamlab/config.json zfs-snapshot abc") {
t.Fatalf("the enabled schedule is missing or malformed:\n%s", text)
}
if strings.Contains(text, "zfs-snapshot def") {
t.Fatalf("a paused schedule must not be scheduled:\n%s", text)
}
// every line cron will read must parse as a schedule
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.Contains(line, "=") {
continue
}
f := strings.Fields(line)
if err := cron.ValidSchedule(strings.Join(f[:5], " ")); err != nil {
t.Errorf("line %q: %v", line, err)
}
}
}
func TestValidateRefusesNonsense(t *testing.T) {
ok := Policy{Dataset: "tank/apps", Every: "daily", At: "02:30", KeepFor: "6w"}
if err := ok.Validate(); err != nil {
t.Fatalf("a sane policy: %v", err)
}
bad := []Policy{
{Dataset: "", Every: "daily", At: "02:30", KeepFor: "6w"},
{Dataset: "tank", Every: "yearly", At: "02:30", KeepFor: "6w"},
{Dataset: "tank", Every: "custom", KeepFor: "6w"},
{Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"},
{Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"},
{Dataset: "tank", Every: "daily", At: "02:30"}, // no retention
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
{Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"},
}
for i, p := range bad {
if err := p.Validate(); err == nil {
t.Errorf("bad policy %d was accepted: %+v", i, p)
}
}
}
func TestParseAge(t *testing.T) {
for s, want := range map[string]time.Duration{
"6w": 6 * 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour,
"12h": 12 * time.Hour, "90m": 90 * time.Minute,
} {
if got, err := ParseAge(s); err != nil || got != want {
t.Errorf("ParseAge(%q) = %v, %v; want %v", s, got, err, want)
}
}
for _, s := range []string{"", "6", "w", "-1d", "0w", "6y", "six weeks"} {
if _, err := ParseAge(s); err == nil {
t.Errorf("ParseAge(%q) should fail", s)
}
}
}
+19 -4
View File
@@ -41,19 +41,34 @@ func (s *Session) UnmarshalJSON(b []byte) error {
// UserState is what follows an account rather than the machine: how the console looks // UserState is what follows an account rather than the machine: how the console looks
// to that person and how their dashboard is laid out. // to that person and how their dashboard is laid out.
type UserState struct { type UserState struct {
Dashboard json.RawMessage `json:"dashboard,omitempty"` // layout prefs, opaque to the server
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
} }
// RememberedMount is one bind mount of a container that was running when Ham Lab last
// looked. A compose stack records the path its file had *inside* whatever deployed it, so
// when that container is gone the path is the only way back to the file on disk.
type RememberedMount struct {
Container string `json:"container"`
Dest string `json:"dest"` // the path inside the container
Src string `json:"src"` // the path on the machine
Seen time.Time `json:"seen"`
}
type State struct { type State struct {
Terminals []Terminal `json:"terminals"` Terminals []Terminal `json:"terminals"`
Sessions map[string]Session `json:"sessions"` // token -> session Sessions map[string]Session `json:"sessions"` // token -> session
Users map[string]*UserState `json:"users,omitempty"` Users map[string]*UserState `json:"users,omitempty"`
// Dashboard and UI are the pre-users machine-wide prefs; on first boot with users they // UI is the pre-users machine-wide look; on first boot with users it moves to the first
// move to the first account. Kept so an old state file still reads. // account. Kept so an old state file still reads.
Dashboard json.RawMessage `json:"dashboard,omitempty"`
UI json.RawMessage `json:"ui,omitempty"` UI json.RawMessage `json:"ui,omitempty"`
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go) Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go)
Network json.RawMessage `json:"network,omitempty"` // speed test results (internal/api/network.go)
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
// orchestrator that has since been removed can still be found on disk. See
// internal/api/stacks.go.
Mounts []RememberedMount `json:"mounts,omitempty"`
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
} }
+11
View File
@@ -84,10 +84,21 @@ func newID() string {
// Create starts a new login shell. id may be empty. // Create starts a new login shell. id may be empty.
func (m *Manager) Create(id, name string) (*Session, error) { func (m *Manager) Create(id, name string) (*Session, error) {
return m.CreateCmd(id, name, nil)
}
// CreateCmd is Create with something other than a login shell: argv nil is the shell, and
// anything else runs that instead — `docker exec -it …` for a container's console. Every
// session is the same thing afterwards, so attach, resize, the scrollback ring and the tab
// strip work without knowing which it is.
func (m *Manager) CreateCmd(id, name string, argv []string) (*Session, error) {
if id == "" { if id == "" {
id = newID() id = newID()
} }
cmd := exec.Command(m.shell, "-l") cmd := exec.Command(m.shell, "-l")
if len(argv) > 0 {
cmd = exec.Command(argv[0], argv[1:]...)
}
cmd.Dir = m.home cmd.Dir = m.home
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1") cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow}) ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
+365
View File
@@ -0,0 +1,365 @@
// Package vpn reads this machine's WireGuard tunnels: the interfaces, and the peers on them
// with names put back on.
//
// The live state comes from `wg show all dump`, which is the kernel's own answer and the only
// place handshakes and byte counts exist. The kernel knows nothing about names, though — a
// peer there is a public key — so the configuration is read alongside it purely to label
// them: `# comments` in a wg-quick .conf, or the `description` of a UCI peer section on
// OpenWrt. Everything here is read-only, and no private or pre-shared key is ever read, let
// alone returned.
package vpn
import (
"bufio"
"context"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"hamlab/internal/priv"
)
// Peer is one end of a tunnel: a device that dials in, or the server this machine dials out
// to. Which it is depends on the tunnel; see Tunnel.Kind.
type Peer struct {
// Name is what the configuration calls it, where it says at all. The kernel has no idea.
Name string `json:"name,omitempty"`
PublicKey string `json:"public_key"`
// Endpoint is where its packets come from, empty until it has been heard from.
Endpoint string `json:"endpoint,omitempty"`
AllowedIPs []string `json:"allowed_ips"`
// Handshake is the last completed handshake, absent when there has never been one — a peer
// that is configured but has not connected since this tunnel came up. A pointer because
// omitempty does nothing for a zero time.Time, and a peer that has never been heard from
// would otherwise be reported as having last spoken in the year one.
Handshake *time.Time `json:"handshake,omitempty"`
// Online is a handshake inside the last three minutes. WireGuard is silent when idle, so
// this means "was talking recently", not "is connected" — there is no such thing here.
Online bool `json:"online"`
Rx int64 `json:"rx"`
Tx int64 `json:"tx"`
Keepalive int `json:"keepalive,omitempty"`
// PSK notes that a pre-shared key is in use, without the key itself going anywhere.
PSK bool `json:"psk,omitempty"`
}
// Tunnel is one WireGuard interface.
type Tunnel struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
// Kind is "server" when peers dial in to this machine, "client" when this machine dials
// out through a peer, and "" when it is too new or too odd to tell. It is worked out from
// the shape of the peers, not from anything WireGuard states: a peer that is routed
// everything and has an endpoint of its own is an exit, and a tunnel with one of those is
// this machine going out through it.
Kind string `json:"kind"`
ListenPort int `json:"listen_port,omitempty"`
FwMark string `json:"fwmark,omitempty"`
Addrs []string `json:"addrs"`
MTU int `json:"mtu,omitempty"`
Peers []Peer `json:"peers"`
// Exit is the peer this machine goes out through, for a client tunnel.
Exit string `json:"exit,omitempty"`
}
// Report is every tunnel on this machine, and why there are none when there are none.
type Report struct {
At time.Time `json:"at"`
Tunnels []Tunnel `json:"tunnels"`
// Tool is false when wireguard-tools is not installed, Root false when the daemon cannot
// reach root — either one means the peers cannot be read, and the tab should say which.
Tool bool `json:"tool"`
Root bool `json:"root"`
Note string `json:"note,omitempty"`
}
const onlineWithin = 3 * time.Minute
// Look reads the tunnels on this machine.
func Look(ctx context.Context) Report {
r := Report{At: time.Now(), Tunnels: []Tunnel{}, Root: priv.Available()}
if _, err := exec.LookPath("wg"); err != nil {
r.Note = "wireguard-tools is not installed here, so there is nothing to read"
return r
}
r.Tool = true
args := []string{"wg", "show", "all", "dump"}
if priv.Available() {
args = priv.Wrap(args...)
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
out, err := cmd.Output()
if err != nil {
if !r.Root {
r.Note = "reading WireGuard needs root, and this daemon has no way to it"
} else {
r.Note = "wg show failed: " + strings.TrimSpace(err.Error())
}
return r
}
r.Tunnels = parseDump(string(out))
names := peerNames()
for i := range r.Tunnels {
t := &r.Tunnels[i]
t.Addrs, t.MTU = ifaceAddrs(t.Name)
for j := range t.Peers {
p := &t.Peers[j]
p.Name = names[p.PublicKey]
p.Online = p.Handshake != nil && time.Since(*p.Handshake) < onlineWithin
}
t.Kind, t.Exit = classify(*t)
sort.SliceStable(t.Peers, func(a, b int) bool {
pa, pb := t.Peers[a], t.Peers[b]
if pa.Online != pb.Online {
return pa.Online
}
if ha, hb := shookAt(pa), shookAt(pb); !ha.Equal(hb) {
return ha.After(hb)
}
return pa.Name < pb.Name
})
}
if len(r.Tunnels) == 0 {
r.Note = "no WireGuard interfaces are up on this machine"
}
return r
}
// parseDump reads `wg show all dump`. The first line of each interface is the interface
// itself, the rest are its peers; every line is tab-separated and positional.
//
// interface: name private-key public-key listen-port fwmark
// peer: name public-key psk endpoint allowed-ips handshake rx tx keepalive
//
// The private key is in the second column and is read past without being kept.
func parseDump(out string) []Tunnel {
// An empty list, never nil: this is JSON to a browser that will take its length, and a
// null there is a blank page rather than "no tunnels".
tunnels := []Tunnel{}
byName := map[string]int{}
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
for sc.Scan() {
f := strings.Split(sc.Text(), "\t")
if len(f) < 5 {
continue
}
name := f[0]
if len(f) == 5 { // an interface line
port, _ := strconv.Atoi(f[3])
byName[name] = len(tunnels)
tunnels = append(tunnels, Tunnel{Name: name, PublicKey: f[2], ListenPort: port, FwMark: off(f[4]), Peers: []Peer{}})
continue
}
if len(f) < 9 {
continue
}
i, ok := byName[name]
if !ok {
continue
}
hs, _ := strconv.ParseInt(f[5], 10, 64)
rx, _ := strconv.ParseInt(f[6], 10, 64)
tx, _ := strconv.ParseInt(f[7], 10, 64)
ka, _ := strconv.Atoi(f[8])
p := Peer{PublicKey: f[1], PSK: f[2] != "(none)", Endpoint: off(f[3]), Rx: rx, Tx: tx, Keepalive: ka}
if f[4] != "(none)" {
p.AllowedIPs = strings.Split(f[4], ",")
}
if hs > 0 {
at := time.Unix(hs, 0)
p.Handshake = &at
}
tunnels[i].Peers = append(tunnels[i].Peers, p)
}
return tunnels
}
// shookAt is a peer's last handshake, or the zero time when there has never been one.
func shookAt(p Peer) time.Time {
if p.Handshake == nil {
return time.Time{}
}
return *p.Handshake
}
// off turns wg's placeholders into an empty string.
func off(v string) string {
if v == "(none)" || v == "off" {
return ""
}
return v
}
// classify decides whether this machine is the one being dialled or the one dialling. A peer
// that is allowed every address and has an endpoint is a way out to the internet; a tunnel
// with one of those is this machine using it. Otherwise, if anything can reach us here, it is
// a server.
func classify(t Tunnel) (kind, exit string) {
for _, p := range t.Peers {
for _, a := range p.AllowedIPs {
if a == "0.0.0.0/0" || a == "::/0" {
if p.Endpoint != "" {
name := p.Name
if name == "" {
name = p.Endpoint
}
return "client", name
}
}
}
}
if t.ListenPort > 0 && len(t.Peers) > 0 {
return "server", ""
}
return "", ""
}
// ifaceAddrs is what the interface carries, and its MTU.
func ifaceAddrs(name string) ([]string, int) {
addrs := []string{}
mtu := 0
ifi, err := net.InterfaceByName(name)
if err != nil {
return addrs, mtu
}
mtu = ifi.MTU
got, err := ifi.Addrs()
if err != nil {
return addrs, mtu
}
for _, a := range got {
addrs = append(addrs, a.String())
}
return addrs, mtu
}
// ---- names ----
// peerNames maps a public key to what the configuration calls that peer. Two shapes are
// understood, which between them cover every machine here: wg-quick's .conf files, where the
// name is a comment, and OpenWrt's UCI, where it is the peer section's description.
func peerNames() map[string]string {
out := map[string]string{}
for k, v := range confNames() {
out[k] = v
}
for k, v := range uciNames() { // UCI wins: on OpenWrt it is the live configuration
out[k] = v
}
return out
}
// confNames reads /etc/wireguard/*.conf. In a wg-quick file a peer is named by the comment
// that introduces it, which is the convention every tool that writes these files follows:
//
// # phone
// [Peer]
// PublicKey = …
//
// confDir is where wg-quick keeps its files; a variable so the test can point it elsewhere.
var confDir = "/etc/wireguard"
func confNames() map[string]string {
out := map[string]string{}
files, _ := filepath.Glob(filepath.Join(confDir, "*.conf"))
for _, path := range files {
text, err := readMaybeRoot(path)
if err != nil {
continue
}
sc := bufio.NewScanner(strings.NewReader(text))
// last is the most recent comment line; when a [Peer] block opens, that comment becomes
// its pending name. Keeping the two apart is what lets each block take the comment that
// introduces it and ignore any comment written inside it.
last, pending := "", ""
inPeer := false
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
switch {
case strings.EqualFold(line, "[Peer]"):
inPeer, pending, last = true, last, ""
case strings.HasPrefix(line, "["):
inPeer, pending, last = false, "", ""
case strings.HasPrefix(line, "#"):
if c := strings.TrimSpace(strings.TrimLeft(line, "#")); c != "" {
last = c
}
case inPeer && strings.HasPrefix(line, "PublicKey"):
// The separator is the first '='; the key's own base64 padding is the rest of
// the line, so everything after that first one is the key.
if _, v, ok := strings.Cut(line, "="); ok && pending != "" {
out[strings.TrimSpace(v)] = pending
}
pending = ""
}
}
}
return out
}
// readMaybeRoot reads a file, falling back to root when it is not ours to read — which
// /etc/wireguard always is, since it holds private keys. Only the comments and public keys in
// it are looked at; nothing else in the file is kept or returned.
func readMaybeRoot(path string) (string, error) {
b, err := os.ReadFile(path)
if err == nil {
return string(b), nil
}
if !os.IsPermission(err) || !priv.Available() {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
args := priv.Wrap("cat", path)
out, cerr := exec.CommandContext(ctx, args[0], args[1:]...).Output()
if cerr != nil {
return "", cerr
}
return string(out), nil
}
// uciNames reads OpenWrt's configuration: each peer is a section with a description and a
// public key. `uci show` is used rather than the file, so includes and overrides are applied.
func uciNames() map[string]string {
out := map[string]string{}
if _, err := exec.LookPath("uci"); err != nil {
return out
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
b, err := exec.CommandContext(ctx, "uci", "-q", "show", "network").Output()
if err != nil {
return out
}
desc, key := map[string]string{}, map[string]string{}
for _, line := range strings.Split(string(b), "\n") {
lhs, rhs, ok := strings.Cut(line, "=")
if !ok || !strings.HasPrefix(lhs, "network.") {
continue
}
parts := strings.Split(strings.TrimPrefix(lhs, "network."), ".")
if len(parts) != 2 {
continue
}
section, option := parts[0], parts[1]
v := strings.Trim(rhs, "'")
switch option {
case "description":
desc[section] = v
case "public_key":
key[section] = v
}
}
for section, k := range key {
if d := desc[section]; d != "" {
out[k] = d
}
}
return out
}
+193
View File
@@ -0,0 +1,193 @@
package vpn
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Real output of `wg show all dump`, from the two machines here: gate, where devices dial in,
// and hamtv, which dials out through Mullvad. Keys are the real public ones, which are public.
const gateDump = "wg0\tPRIVATEKEYWOULDBEHERE=\t4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=\t51820\toff\n" +
"wg0\tNeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=\t3KWsu0mdVb2l5B/j0qHE03yE9LXi3FJ1WAjWMuxaLAw=\t10.0.0.33:63573\t172.21.46.3/32\t1790565744\t134192164\t415977404\toff\n" +
"wg0\twUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=\t4Pd5u2t6U9EdHlFlneg+hMr0G2v+IvoE/EqGfFdDPI8=\t(none)\t172.21.46.2/32\t0\t0\t0\toff\n"
const hamtvDump = "wg0\tPRIVATEKEYWOULDBEHERE=\tF/rgcHi+ZiaWPoSQ9iPcldIUbbMotE1hwSB/74Y9Qi4=\t39281\toff\n" +
"wg0\tzqsfGglzJPY657WMRxf/S4omG7+ZkSDIpDq+ggbc9yo=\t(none)\t23.234.72.2:51820\t0.0.0.0/0,::/0\t1790571246\t18206675520\t2687837240\t25\n"
func TestParseServerDump(t *testing.T) {
ts := parseDump(gateDump)
if len(ts) != 1 {
t.Fatalf("got %d tunnels, want 1", len(ts))
}
w := ts[0]
if w.Name != "wg0" || w.ListenPort != 51820 {
t.Errorf("interface = %s:%d, want wg0:51820", w.Name, w.ListenPort)
}
if w.PublicKey != "4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=" {
t.Errorf("public key = %q; the third column is the public one", w.PublicKey)
}
// The private key is column two of the interface line and must not appear anywhere.
if w.PublicKey == "PRIVATEKEYWOULDBEHERE=" || w.FwMark == "PRIVATEKEYWOULDBEHERE=" {
t.Error("the private key was kept")
}
if len(w.Peers) != 2 {
t.Fatalf("got %d peers, want 2", len(w.Peers))
}
p := w.Peers[0]
if p.PublicKey != "NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=" || p.Endpoint != "10.0.0.33:63573" {
t.Errorf("first peer = %+v", p)
}
if !p.PSK {
t.Error("first peer has a pre-shared key and should say so")
}
if got := p.AllowedIPs; len(got) != 1 || got[0] != "172.21.46.3/32" {
t.Errorf("allowed ips = %v", got)
}
if p.Rx != 134192164 || p.Tx != 415977404 {
t.Errorf("counters = %d/%d", p.Rx, p.Tx)
}
if p.Handshake == nil || p.Handshake.Unix() != 1790565744 {
t.Errorf("handshake = %v", p.Handshake)
}
// The second peer has never connected: no endpoint, no handshake, nothing moved.
if q := w.Peers[1]; q.Endpoint != "" || q.Handshake != nil || q.Rx != 0 {
t.Errorf("second peer = %+v, want one that has never been heard from", q)
}
if kind, exit := classify(w); kind != "server" || exit != "" {
t.Errorf("classify = %q/%q, want server", kind, exit)
}
}
func TestParseClientDump(t *testing.T) {
ts := parseDump(hamtvDump)
if len(ts) != 1 || len(ts[0].Peers) != 1 {
t.Fatalf("got %d tunnels", len(ts))
}
w := ts[0]
p := w.Peers[0]
if len(p.AllowedIPs) != 2 || p.AllowedIPs[0] != "0.0.0.0/0" {
t.Errorf("allowed ips = %v, want everything", p.AllowedIPs)
}
if p.Keepalive != 25 {
t.Errorf("keepalive = %d, want 25", p.Keepalive)
}
if p.PSK {
t.Error("this peer has no pre-shared key")
}
// It routes everything and has an endpoint, so this machine goes out through it.
kind, exit := classify(w)
if kind != "client" || exit != "23.234.72.2:51820" {
t.Errorf("classify = %q/%q, want client through the endpoint", kind, exit)
}
}
// A tunnel that is up but has no peers at all is neither, and must not be guessed at.
func TestClassifyUndecided(t *testing.T) {
if kind, _ := classify(Tunnel{Name: "wg0", ListenPort: 51820}); kind != "" {
t.Errorf("classify with no peers = %q, want no opinion", kind)
}
}
// Peers are named by the comment above them in a wg-quick file. The public key's own base64
// padding is an '=' too, which is the thing to get wrong.
func TestConfNames(t *testing.T) {
dir := t.TempDir()
conf := `[Interface]
Address = 172.21.46.1/24
PrivateKey = SECRETSECRETSECRETSECRETSECRETSECRETSECRET=
ListenPort = 51820
# phone
[Peer]
PublicKey = NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=
AllowedIPs = 172.21.46.3/32
# the laptop
[Peer]
PublicKey = wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=
AllowedIPs = 172.21.46.2/32
`
if err := os.WriteFile(filepath.Join(dir, "wg0.conf"), []byte(conf), 0o600); err != nil {
t.Fatal(err)
}
old := confDir
confDir = dir
defer func() { confDir = old }()
names := confNames()
if got := names["NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU="]; got != "phone" {
t.Errorf("first peer named %q, want phone", got)
}
if got := names["wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg="]; got != "the laptop" {
t.Errorf("second peer named %q, want the laptop", got)
}
for k := range names {
if k == "SECRETSECRETSECRETSECRETSECRETSECRETSECRET=" {
t.Error("the private key was read as if it were a peer")
}
}
if len(names) != 2 {
t.Errorf("%d names, want 2: %v", len(names), names)
}
}
func TestOnlineIsRecentHandshake(t *testing.T) {
for _, tc := range []struct {
name string
hs time.Time
want bool
}{
{"just now", time.Now().Add(-30 * time.Second), true},
{"a while back", time.Now().Add(-10 * time.Minute), false},
{"never", time.Time{}, false},
} {
got := !tc.hs.IsZero() && time.Since(tc.hs) < onlineWithin
if got != tc.want {
t.Errorf("%s: online = %v, want %v", tc.name, got, tc.want)
}
}
}
// A peer that has never handshaken must carry no time at all, not the zero one. omitempty
// does nothing for a time.Time, so this is the difference between the page saying "never
// connected" and saying the device was last seen in the year one.
func TestNeverConnectedHasNoTimestamp(t *testing.T) {
w := parseDump(gateDump)[0]
b, err := json.Marshal(w.Peers[1])
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "handshake") {
t.Errorf("json = %s, want no handshake field at all", b)
}
if strings.Contains(string(b), "0001-01-01") {
t.Errorf("json = %s, want no year-one timestamp", b)
}
// and the one that has connected still carries its time
if b, _ := json.Marshal(w.Peers[0]); !strings.Contains(string(b), "handshake") {
t.Errorf("json = %s, want the handshake of a peer that has one", b)
}
}
// A machine with wireguard-tools installed but no tunnel up produces an empty dump. That has
// to marshal as [] and not as null: the page takes the length of it, and null blanks the
// whole console rather than saying "no tunnels".
func TestEmptyDumpIsAnEmptyList(t *testing.T) {
for _, dump := range []string{"", "\n", " \n\n"} {
got := parseDump(dump)
if got == nil {
t.Fatalf("parseDump(%q) = nil, want an empty list", dump)
}
b, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if string(b) != "[]" {
t.Errorf("parseDump(%q) marshals to %s, want []", dump, b)
}
}
}
-474
View File
@@ -1,474 +0,0 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline'
import { Loading, Spinner, askYes, toast } from './ui'
import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
import WidgetPicker from './WidgetPicker'
const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)'
// ---- module registry: the customizable blocks, in default order ----
type Kind = 'tile' | 'card'
interface Module { id: string; label: string; kind: Kind }
const MODULES: Module[] = [
{ id: 'cpu', label: 'CPU', kind: 'tile' },
{ id: 'mem', label: 'Memory', kind: 'tile' },
{ id: 'load', label: 'Load', kind: 'tile' },
{ id: 'uptime', label: 'Uptime', kind: 'tile' },
{ id: 'temps', label: 'Temperatures', kind: 'card' },
{ id: 'fans', label: 'Fans', kind: 'card' },
{ id: 'net', label: 'Network', kind: 'card' },
{ id: 'disks', label: 'Disks', kind: 'card' },
{ id: 'fs', label: 'Filesystems', kind: 'card' },
{ id: 'ups', label: 'UPS', kind: 'card' },
]
const byId = (id: string) => MODULES.find((m) => m.id === id)
const DEFAULT_INTERVAL = 2000
const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
function normalize(raw: DashPrefs): Layout {
// Saved prefs are whatever some client last wrote; a wrong shape must not take the page down.
const list = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const p = { order: list<string>(raw?.order), hidden: list<string>(raw?.hidden), hiddenItems: list<string>(raw?.hiddenItems), widgets: list<WidgetSpec>(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined }
const known = new Set(MODULES.map((m) => m.id))
const order = (p.order ?? []).filter((id) => known.has(id))
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv,
widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') }
}
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets })
// ---- formatting ----
// A live value either starts a new point or is averaged into the one being built — the same
// rule the machine itself uses to keep its history, so the tail of a six-hour graph isn't a
// minute of 2-second samples crushed against the right edge. n is how many values this point
// has had, including this one.
function pushPoint(arr: number[], v: number, fresh: boolean, n: number) {
if (!fresh && arr.length) { arr[arr.length - 1] += (v - arr[arr.length - 1]) / n; return }
arr.push(v)
if (arr.length > MAX) arr.shift()
}
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
export function fmtBytes(b: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let i = 0
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
}
export function fmtRate(bps: number): string {
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
let i = 0
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
}
function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
}
const pct = (v: number) => `${Math.round(v)}%`
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' { return frac >= bad ? 'bad' : frac >= warn ? 'warn' : '' }
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest.
function collapseTemps(temps: Temp[]): (Temp & { key: string })[] {
const byChip = new Map<string, Temp[]>()
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t)
const out: (Temp & { key: string })[] = []
for (const [chip, list] of byChip) {
if (list.length <= 8) { out.push(...list.map((t) => ({ ...t, key: `${t.chip}/${t.label}` }))); continue }
const hot = list.reduce((a, b) => (b.c > a.c ? b : a))
out.push({ ...hot, label: `${list.length} sensors · hottest ${hot.label}`, chip, key: `${chip}/*` })
}
return out
}
// ---- small building blocks ----
function Meter({ frac, cls }: { frac: number; cls?: string }) {
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Legend({ items }: { items: { name: string; color: string }[] }) {
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
}
interface Edit {
on: boolean
hideItem: (key: string) => void
unhideItem: (key: string) => void
hiddenItems: Set<string>
}
// In customize mode every row gets a ✕; hidden rows come back as chips under the module.
function HideBtn({ k, edit }: { k: string; edit: Edit }) {
return edit.on ? <button className="hide-item" title="hide" onClick={() => edit.hideItem(k)}>✕</button> : null
}
function HiddenChips({ prefix, edit, label }: { prefix: string; edit: Edit; label: (k: string) => string }) {
if (!edit.on) return null
const keys = [...edit.hiddenItems].filter((k) => k.startsWith(prefix + ':'))
if (keys.length === 0) return null
return <div className="chips inline">{keys.map((k) => <button key={k} className="chip" title="show again" onClick={() => edit.unhideItem(k)}>+ {label(k.slice(prefix.length + 1))}</button>)}</div>
}
// ---- module bodies ----
interface Ctx { s: Sample; h: Series | null; edit: Edit }
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
}
function body(id: string, { s, h, edit }: Ctx): ReactNode {
const shown = (key: string) => !edit.hiddenItems.has(key)
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
switch (id) {
case 'cpu':
return (
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length === 0 ? undefined : s.cores.length <= 16 ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}>
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
</Tile>
)
case 'mem':
return (
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
</Tile>
)
case 'load':
return <Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
case 'uptime':
return <Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
case 'temps': {
const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`))
return (
<>
<h2>Temperatures</h2>
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
{rows.map((t) => {
const limit = t.crit || t.max || 100
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
return (
<div className="row" key={t.key}>
<span className="k">{t.label}<small>{t.chip}</small></span>
<Meter frac={t.c / limit} cls={cls} />
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}<HideBtn k={`temp:${t.key}`} edit={edit} /></span>
</div>
)
})}
<HiddenChips prefix="temp" edit={edit} label={(k) => k.replace('/*', '')} />
</>
)
}
case 'fans': {
const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`))
return (
<>
<h2>Fans</h2>
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
{rows.map((f) => (
<div className="row" key={`${f.chip}/${f.label}`}>
<span className="k">{f.label}<small>{f.chip}</small></span>
<span />
<span className="num">{f.rpm} rpm<HideBtn k={`fan:${f.chip}/${f.label}`} edit={edit} /></span>
</div>
))}
<HiddenChips prefix="fan" edit={edit} label={(k) => k} />
</>
)
}
case 'net':
return (
<>
<h2>Network</h2>
{s.net.filter((x) => shown(`net:${x.iface}`)).map((x) => (
<div className="sub-card" key={x.iface}>
<div className="sub-head">
<span className={'dot ' + (x.up ? 'ok' : '')} />
<span>{x.iface}</span>
{!x.up && <span className="muted small">down</span>}
<span className="spacer" />
{x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
<HideBtn k={`net:${x.iface}`} edit={edit} />
</div>
{x.up && h?.net[x.iface] && (
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
)}
{x.up && <div className="legend">
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
</div>}
</div>
))}
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
</>
)
case 'disks':
return (
<>
<h2>Disks</h2>
{s.disks.length === 0 && <div className="empty">No block devices found.</div>}
{s.disks.filter((d) => shown(`disk:${d.name}`)).map((d) => (
<div className="sub-card" key={d.name}>
<div className="sub-head">
<span>{d.name}</span>
<span className="model">{d.model}</span>
<span className="spacer" />
<span className="num">{fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
<HideBtn k={`disk:${d.name}`} edit={edit} />
</div>
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
{h?.disk[d.name] && (
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
)}
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
</div>
))}
<HiddenChips prefix="disk" edit={edit} label={(k) => k} />
</>
)
case 'fs':
return (
<>
<h2>Filesystems</h2>
{s.fs.filter((f) => shown(`fs:${f.mount}`)).map((f) => (
<div className="row" key={f.mount}>
<span className="k">{f.mount}<small>{f.type}</small></span>
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}<HideBtn k={`fs:${f.mount}`} edit={edit} /></span>
</div>
))}
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
</>
)
case 'ups':
return (
<>
<h2>UPS</h2>
{!s.ups && <div className="empty">No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).</div>}
{s.ups && (
<>
<div className="sub-head">
<span className={'dot ' + (s.ups.on_battery ? 'bad' : 'ok')} />
<span>{s.ups.state} · {s.ups.source}</span>
<span className="spacer" />
<span className="model">{s.ups.model} · {s.ups.driver}</span>
</div>
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
</>
)}
</>
)
}
return null
}
// ---- the dashboard ----
export default function Dashboard() {
const [latest, setLatest] = useState<Sample | null>(null)
const [err, setErr] = useState('')
const [layout, setLayout] = useState<Layout>(() => normalize({}))
const [editing, setEditing] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('')
const savedTimer = useRef<number | undefined>(undefined)
const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(undefined)
const refreshMs = useRefreshMs()
const historySec = useHistorySec()
// Persist layout changes server-side (debounced) so every device sees the same dashboard.
const update = useCallback((fn: (l: Layout) => Layout) => {
setLayout((prev) => {
const next = fn(prev)
clearTimeout(saveTimer.current)
setSaving('saving')
saveTimer.current = window.setTimeout(async () => {
try {
await api.saveDashPrefs(toPrefs(next))
setSaving('saved')
clearTimeout(savedTimer.current)
savedTimer.current = window.setTimeout(() => setSaving(''), 1500)
} catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}, 300)
return next
})
}, [])
useEffect(() => {
let stop = false
api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout'))
return () => { stop = true }
}, [])
// Everything on this page comes from the sensor API: the catalog for names, labels and
// thresholds; one batch of histories to seed the curves; one stream for the values.
const catalog = useRef<Catalog>(new Map())
const refetching = useRef(false)
const binN = useRef(1) // values averaged into the point currently being built
const lastPush = useRef(0) // when that point was started, by this browser's clock
const stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
const seed = useCallback(async () => {
try {
catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current)
lastPush.current = Date.now(); binN.current = 1
setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { seed() }, [seed, stock.resumed]) // on load, and again after the page was hidden: the curves have a gap to refill
useEffect(() => {
if (!stock.data || catalog.current.size === 0) return
const v: Values = mergeValues(stock.data)
if (stale(v, catalog.current) && !refetching.current) { // something appeared (a disk, an interface): learn its labels
refetching.current = true
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
}
const s = sampleOf(v, catalog.current)
const h = hist.current
if (h) {
// The stride is timed on this clock alone (lastPush), never against the history's own
// timestamps: those are the machine's, and a viewer whose clock runs minutes slow would
// otherwise wait those minutes before its curves gained a point.
const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs)
const fresh = np.fresh
lastPush.current = np.start
binN.current = fresh ? 1 : binN.current + 1
if (fresh) pushPoint(h.t, s.t, true, 1)
const n = h.t.length
// a curve that has no point in this one yet starts one, whatever the others are doing:
// an interface or disk that appeared mid-point must still line up with the time axis
const push = (arr: number[], x: number) => pushPoint(arr, x, fresh || arr.length < n, binN.current)
push(h.cpu, s.cpu); push(h.mem, s.mem.used)
for (const x of s.net) {
const c = (h.net[x.iface] ??= { rx: [], tx: [] })
pad(c.rx, n - 1); pad(c.tx, n - 1); push(c.rx, x.rx_bps); push(c.tx, x.tx_bps)
}
for (const d of s.disks) {
const c = (h.disk[d.name] ??= { r: [], w: [], u: [] })
pad(c.r, n - 1); pad(c.w, n - 1); pad(c.u, n - 1); push(c.r, d.read_bps); push(c.w, d.write_bps); push(c.u, d.util_pct)
}
for (const t of s.temps) {
const c = (h.temp[`${t.chip}/${t.label}`] ??= [])
pad(c, n - 1); push(c, t.c)
}
}
setLatest(s)
}, [stock.data, historySec, refreshMs])
useEffect(() => { if (stock.error && !latest) setErr(stock.error) }, [stock.error, latest])
// The sampler on this machine follows the global rate (clamped to what it supports).
useEffect(() => {
if (!loaded) return
const want = clampSampler(refreshMs)
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
}, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
const [picking, setPicking] = useState(false)
const [editingWidget, setEditingWidget] = useState<WidgetSpec | null>(null)
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
const edit: Edit = {
on: editing,
hiddenItems: layout.hiddenItems,
hideItem: (k) => update((l) => ({ ...l, hiddenItems: new Set([...l.hiddenItems, k]) })),
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
}
const ctx: Ctx = { s: latest, h: hist.current, edit }
// Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
const move = (id: string, dir: -1 | 1) => update((l) => {
const kind = byId(id)!.kind
const same = l.order.filter((x) => byId(x)?.kind === kind && !l.hidden.has(x))
const i = same.indexOf(id), j = i + dir
if (j < 0 || j >= same.length) return l
const order = [...l.order]
const a = order.indexOf(id), b = order.indexOf(same[j])
;[order[a], order[b]] = [order[b], order[a]]
return { ...l, order }
})
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
const reset = async () => { if (await askYes(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
const tools = (id: string, kind: Kind) => {
if (!editing) return null
const same = visible(kind), i = same.indexOf(id)
return (
<div className="mod-tools">
<button title="move up" disabled={i === 0} onClick={() => move(id, -1)}>▲</button>
<button title="move down" disabled={i === same.length - 1} onClick={() => move(id, 1)}>▼</button>
<button title="hide module" onClick={() => hideMod(id)}>hide</button>
</div>
)
}
return (
<div className={'dash' + (editing ? ' editing' : '')}>
<div className="dash-bar">
{err && <span className="error">{err}</span>}
{editing ? <span className="hint">last {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
: <span className="hint">last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<span className="spacer" />
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
{saving === 'saved' && <span className="saved">saved ✓</span>}
{editing && <button className="btn" onClick={reset}>Reset</button>}
<button className={'btn' + (editing ? ' on' : '')} disabled={!loaded} title={loaded ? '' : 'layout not loaded'} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
</div>
{visible('tile').length > 0 && (
<div className="kpis">
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
</div>
)}
{(layout.widgets.length > 0 || editing) && (
<div className="kpis widgets">
{layout.widgets.map((w, i) => (
<div className={'tile w-' + w.type} key={w.id}>
{editing && (
<div className="mod-tools">
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
<button title="change its sensor, type or title" onClick={() => setEditingWidget(w)}>edit</button>
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
</div>
)}
<Widget spec={w} data={live.data ? live.data[w.id] : undefined} />
</div>
))}
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering: {live.error}.</div>}
</div>
)}
{editingWidget && <WidgetPicker initial={editingWidget} onClose={() => setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />}
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
{visible('card').length > 0 && (
<div className="cards">
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
</div>
)}
{editing && hiddenMods.length > 0 && (
<div className="card">
<h2>Hidden modules</h2>
<div className="chips">{hiddenMods.map((id) => <button key={id} className="chip" onClick={() => showMod(id)}>+ {byId(id)!.label}</button>)}</div>
</div>
)}
</div>
)
}
+4 -93
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api' import { api, SmartInfo, StorageDevice } from './api'
import { fmtBytes, fmtRate } from './Dashboard' import { fmtBytes, fmtRate } from './fmt'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import { Btn, Loading, Spinner, askText, toast } from './ui' import { askText, Btn, Loading, Meter, Spinner, toast } from './ui'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
import { Sensor, useSensorStream } from './sensors' import { Sensor, useSensorStream } from './sensors'
@@ -11,10 +11,6 @@ const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb
const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`) const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`)
const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` } const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
function Meter({ frac, cls }: { frac: number; cls?: string }) {
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
// ---- SMART panel ---- // ---- SMART panel ----
function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) { function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
@@ -109,82 +105,9 @@ function DeviceCard({ d, live, forceOpen }: { d: StorageDevice; live?: { read_bp
) )
} }
// ---- ZFS ----
function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) {
const scrubbing = /scrub in progress/i.test(p.scan ?? '')
const paused = /scrub paused/i.test(p.scan ?? '')
const [cfg, setCfg] = useState(false)
const act = (a: 'start' | 'pause' | 'stop') => async () => {
try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return (
<section className="card">
<div className="sub-head">
<span className={'dot ' + (p.health === 'ONLINE' ? 'ok' : p.health === 'DEGRADED' ? 'warn' : 'bad')} />
<span className="dname">{p.name}</span>
<span className="badge">{p.health}</span>
<span className="spacer" />
<span className="num">{fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%</span>
</div>
<Meter frac={p.cap / 100} cls={p.cap >= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
<div className="props" style={{ marginTop: 8 }}>
<div><span className="k">Free</span><span>{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}</span></div>
{p.scan && <div><span className="k">Scan</span><span style={{ whiteSpace: 'normal' }}>{p.scan}</span></div>}
{p.errors && <div><span className="k">Errors</span><span className={p.errors.startsWith('No known') ? '' : 'warn-text'}>{p.errors}</span></div>}
</div>
<div className="svc-actions">
{!scrubbing && <Btn className="btn" confirm={`Start a scrub of ${p.name}? It reads the whole pool and can take many hours.`} onClick={act('start')}>{paused ? 'Resume scrub' : 'Scrub'}</Btn>}
{scrubbing && <Btn className="btn" onClick={act('pause')}>Pause scrub</Btn>}
{(scrubbing || paused) && <Btn className="btn" confirm="Stop the scrub?" onClick={act('stop')}>Stop scrub</Btn>}
<button className="btn" onClick={() => setCfg(!cfg)}>{cfg ? 'Hide vdevs' : 'Vdevs'}</button>
</div>
{cfg && <pre className="unit-file">{p.config}</pre>}
</section>
)
}
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
const [open, setOpen] = useState(false)
async function toggle() {
setOpen(!open)
if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } }
}
async function snapshot() {
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
const name = await askText(`Snapshot name for ${d.name}@`, def)
if (!name) return
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const depth = d.name.split('/').length - 1
return (
<>
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
<span className="num">{fmtBytes(d.used)}</span>
<span className="num muted">{fmtBytes(d.refer)}</span>
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
<span className="num muted">{d.snapshots}</span>
<span className="num muted">{d.compressratio}</span>
<span className="ds-mount muted">{d.mount}</span>
</div>
{open && (
<div className="ds-snaps">
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div>
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div>
)}
</div>
)}
</>
)
}
// ---- page ---- // ---- page ----
export default function Disks() { export default function Disks() {
const [devs, setDevs] = useState<StorageDevice[] | null>(null) const [devs, setDevs] = useState<StorageDevice[] | null>(null)
const [zfs, setZfs] = useState<ZFSInfo | null>(null)
const [hasSmart, setHasSmart] = useState(true) const [hasSmart, setHasSmart] = useState(true)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [task, setTask] = useState<string | null>(null) const [task, setTask] = useState<string | null>(null)
@@ -194,7 +117,7 @@ export default function Disks() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const r = await api.storage() const r = await api.storage()
setDevs(r.devices); setZfs(r.zfs); setHasSmart(r.smartctl); setErr('') setDevs(r.devices); setHasSmart(r.smartctl); setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, []) }, [])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
@@ -237,18 +160,6 @@ export default function Disks() {
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)} {devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
</div> </div>
<div className="dash-bar"><h2 className="section">ZFS</h2></div>
{zfs && !zfs.available && <div className="notice">{zfs.message || 'No ZFS pools on this host.'}</div>}
{zfs?.available && zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
{zfs?.available && zfs.pools.length > 0 && (
<>
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
<section className="card datasets">
<div className="ds-row head"><span>Dataset</span><span className="num">Used</span><span className="num">Refer</span><span className="num">In snaps</span><span className="num">Snaps</span><span className="num">Ratio</span><span>Mount</span></div>
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
</section>
</>
)}
</div> </div>
) )
} }
+448 -41
View File
@@ -1,67 +1,119 @@
// Docker: containers grouped by the stack that deployed them, with start / stop / restart, // Docker: containers grouped by the stack that deployed them, with start / stop / restart,
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory // logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
// come from the same container sensors everything else uses. // come from the same container sensors everything else uses.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api' import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import StackNew, { DupFrom } from './StackNew' import StackNew, { DupFrom } from './StackNew'
import { Sensor, fmtValue, useSensorStream } from './sensors' import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs' import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } from './ui' import Sparkline from './Sparkline'
import { fmtBytes } from './fmt'
import { describeCron } from './crontext'
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast, useEscape } from './ui'
type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks' type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'networks'
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok') const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ') const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
// A container's output, followed over a held connection the way the Services tab follows a
// journal. A reconnect resumes from the last line's timestamp, so a container that restarts
// — or a phone that slept — doesn't replay what is already on screen.
function LogView({ id }: { id: string }) { function LogView({ id }: { id: string }) {
const [lines, setLines] = useState<{ t: string; m: string }[]>([]) const [lines, setLines] = useState<DockerLogLine[]>([])
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [live, setLive] = useState(false)
const [follow, setFollow] = useState(true) const [follow, setFollow] = useState(true)
const [paused, setPaused] = useState(false)
const box = useRef<HTMLDivElement>(null) const box = useRef<HTMLDivElement>(null)
const last = useRef('') const last = useRef('')
const pausedRef = useRef(false)
pausedRef.current = paused
const pending = useRef<DockerLogLine[]>([])
// What a resume has already shown: `--since` is inclusive, and two lines can share a
// timestamp (a container writing to both streams at once), so the boundary is a set of
// lines rather than a moment. Bounded — only the lines around a reconnect can repeat.
const seen = useRef<{ set: Set<string>; q: string[] }>({ set: new Set(), q: [] })
useEffect(() => { useEffect(() => {
let stop = false; setLines([]); last.current = '' setLines([]); setErr(''); setLive(false); last.current = ''; pending.current = []
const tick = async () => { seen.current = { set: new Set(), q: [] }
if (stop) return let closed = false
if (document.visibilityState === 'visible') { let sock: WebSocket | null = null
try { let retry: number | undefined
const r = await api.dockerLogs(id, last.current) let attempt = 0
const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines const connect = () => {
if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) } if (closed) return
setErr('') const q = last.current ? `since=${encodeURIComponent(last.current)}` : 'n=300'
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } // fixed when the log opens: a reconnect must not follow the console to another machine
const s = new WebSocket(wsURLPath(`/api/docker/containers/${encodeURIComponent(id)}/logs/tail?${q}`))
sock = s
s.onopen = () => { attempt = 0; setLive(true); setErr('') }
s.onmessage = (ev) => {
const l = JSON.parse(ev.data) as DockerLogLine
if (l.t) {
const k = l.t + '|' + (l.err ? 'e' : 'o') + '|' + l.m
const { set, q } = seen.current
if (set.has(k)) return
set.add(k); q.push(k)
if (q.length > 1000) for (const old of q.splice(0, 500)) set.delete(old)
if (l.t > last.current) last.current = l.t
} }
window.setTimeout(tick, 2000) if (pausedRef.current) { pending.current.push(l); return }
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), l] : [...prev, l]))
} }
tick() // a container that has stopped — or was never there — ends the stream at once, so
return () => { stop = true } // back off rather than asking Docker the same question twice a second forever
s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, Math.min(15000, 1000 * 2 ** attempt++)) }
s.onerror = () => { setErr('log stream disconnected'); s.close() }
}
connect()
return () => { closed = true; clearTimeout(retry); sock?.close() }
}, [id]) }, [id])
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow]) // Resume: flush what arrived while paused.
useEffect(() => {
if (paused || pending.current.length === 0) return
const p = pending.current; pending.current = []
setLines((prev) => [...prev, ...p].slice(-2000))
}, [paused])
useEffect(() => { if (follow && !paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow, paused])
return ( return (
<div className="logs"> <div className="logs">
<div className="logs-bar"><span className="muted">{lines.length} lines</span><span className="spacer" /><button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button><button className="chip" onClick={() => setLines([])}>Clear</button></div> <div className="logs-bar">
{err && <div className="error">{err}</div>} <span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'} · {lines.length} lines{err && ` · ${err}`}</span>
<span className="spacer" />
<button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button>
<button className="chip" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
<button className="chip" onClick={() => setLines([])}>Clear</button>
</div>
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}> <div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
{lines.map((l, i) => <div className="log" key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)} {lines.map((l, i) => <div className={'log' + (l.err ? ' err' : '')} key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>} {lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
</div> </div>
</div> </div>
) )
} }
// What a container is doing, and what can be done to it: its numbers over time, its health
// checks, what has happened to it lately, a shell inside it, and its limits.
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) { function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
const [tab, setTab] = useState<'logs' | 'inspect'>('logs') const [tab, setTab] = useState<'logs' | 'stats' | 'health' | 'events' | 'inspect'>('logs')
const [info, setInfo] = useState<Record<string, unknown> | null>(null) const [info, setInfo] = useState<Record<string, unknown> | null>(null)
useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name]) const [limits, setLimits] = useState(false)
useEffect(() => { setInfo(null); if (tab === 'inspect' || limits) api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, limits, c.name])
const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } } const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
const running = c.state === 'running' const running = c.state === 'running'
const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`] const seg = c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')
const s = (k: string) => live[`container.${seg}.${k}`]
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? [] const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
const shell = async () => {
try { const t = await api.dockerExec(c.name); toast(`shell open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return ( return (
<div className="svc-detail"> <div className="svc-detail">
<CloseBtn onClick={onBack} /> <CloseBtn onClick={onBack} />
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div> <div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span>{c.health && c.health !== 'none' && <span className={'badge' + (c.health === 'healthy' ? '' : ' bad')}>{c.health}</span>}</div>
<div className="props"> <div className="props">
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div> <div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
<div><span className="k">Status</span><span>{c.status}</span></div> <div><span className="k">Status</span><span>{c.status}</span></div>
@@ -72,27 +124,181 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
<div className="svc-actions"> <div className="svc-actions">
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>} {!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
{!running && <Btn className="btn danger" confirm={`Remove the container ${c.name}? Its image, volumes and bind-mounted data stay; anything written inside the container itself is lost.${c.stack ? ` Deploying the ${c.stack} stack makes it again.` : ''}`} onClick={async () => { await act('remove')(); onBack() }}>Remove</Btn>} {!running && <Btn className="btn danger" confirm={`Remove the container ${c.name}? Its image, volumes and bind-mounted data stay; anything written inside the container itself is lost.${c.stack ? ` Deploying the ${c.stack} stack makes it again.` : ''}`} onClick={async () => { await act('remove')(); onBack() }}>Remove</Btn>}
{running && <Btn className="btn" onClick={shell}>Shell</Btn>}
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>} {running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
<Btn className="btn" onClick={() => setLimits(true)}>Limits</Btn>
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>} {running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
</div> </div>
<div className="tabs"><button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button><button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button></div> <div className="tabs">
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
<button className={tab === 'stats' ? 'on' : ''} onClick={() => setTab('stats')}>Stats</button>
<button className={tab === 'health' ? 'on' : ''} onClick={() => setTab('health')}>Health</button>
<button className={tab === 'events' ? 'on' : ''} onClick={() => setTab('events')}>Events</button>
<button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button>
</div>
{tab === 'logs' && <LogView id={c.name} />} {tab === 'logs' && <LogView id={c.name} />}
{tab === 'stats' && <Stats seg={seg} live={live} />}
{tab === 'health' && <Health id={c.name} />}
{tab === 'events' && <Events id={c.name} />}
{tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : ( {tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : (
<div className="props"> <div className="props stacked">
<div><span className="k">Restarts</span><span>{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×</span></div> <div><span className="k">Restarts</span><span>{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×</span></div>
<div><span className="k">Network</span><span className="mono small">{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}</span></div> <div><span className="k">Network</span><span className="mono small">{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}</span></div>
{Number(info.memory_limit) > 0 && <div><span className="k">Mem limit</span><span>{fmtValue(Number(info.memory_limit), 'B')}</span></div>} {Number(info.memory_limit) > 0 && <div><span className="k">Mem limit</span><span>{fmtValue(Number(info.memory_limit), 'B')}</span></div>}
{!!info.error && <div><span className="k">Error</span><span className="warn-text">{String(info.error)}</span></div>} {!!info.error && <div><span className="k">Error</span><span className="warn-text">{String(info.error)}</span></div>}
{!!info.compose_file && <div><span className="k">Compose</span><span className="mono small">{String(info.compose_file)}</span></div>} {!!info.compose_file && <div><span className="k">Compose</span><span className="mono small">{String(info.compose_file)}</span></div>}
{mounts.map((m) => <div key={m.Destination}><span className="k">Mount</span><span className="mono small">{m.Source} → {m.Destination}{m.RW ? '' : ' (ro)'}</span></div>)} {mounts.length > 0 && <div><span className="k">Mounts</span><span className="mono small">{mounts.map((m) => <div key={m.Destination}>{m.Source} → {m.Destination}{m.RW ? '' : ' (read only)'}</div>)}</span></div>}
<div><span className="k">Env</span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ')} <span className="muted">(names only)</span></span></div> <div><span className="k">Env <span className="muted">names only</span></span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ') || '—'}</span></div>
</div> </div>
))} ))}
{limits && <LimitsDialog c={c} info={info} onClose={() => setLimits(false)} onSaved={() => { setLimits(false); onChanged() }} />}
</div>
)
}
// CPU and memory over time, from the container sensors the sampler already keeps.
function Stats({ seg, live }: { seg: string; live: Record<string, Sensor> }) {
const [hist, setHist] = useState<{ id: string; t: number[]; v: number[] }[] | null>(null)
const historySec = useHistorySec()
useEffect(() => {
let alive = true
fetchHistories(`container.${seg}.cpu,container.${seg}.mem`)
.then((h) => { if (alive) setHist(h) })
.catch(() => { if (alive) setHist([]) })
return () => { alive = false }
}, [seg])
const css = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || '#3987e5'
const of = (k: string) => hist?.find((h) => h.id === `container.${seg}.${k}`)?.v ?? []
const cpu = of('cpu'), mem = of('mem')
if (hist === null) return <Loading label="Reading the history…" />
if (cpu.length < 2 && mem.length < 2) return <div className="notice">No history yet for this container — it is sampled while it runs, so give it a moment.</div>
return (
<div className="cards">
<section className="card">
<div className="sub-head"><span className="dname">CPU</span><span className="spacer" /><span className="num">{fmtValue(live[`container.${seg}.cpu`]?.value, '%')} of a core</span></div>
<Sparkline series={[{ name: 'cpu', values: cpu, color: css('--s1') }]} format={(v) => `${v.toFixed(1)}%`} height={64} />
<div className="muted small">the last {fmtWindowSec(historySec)}</div>
</section>
<section className="card">
<div className="sub-head"><span className="dname">Memory</span><span className="spacer" /><span className="num">{fmtValue(live[`container.${seg}.mem`]?.value, 'B')}</span></div>
<Sparkline series={[{ name: 'mem', values: mem, color: css('--s2') }]} format={(v) => fmtBytes(v)} height={64} />
<div className="muted small">the last {fmtWindowSec(historySec)}</div>
</section>
</div>
)
}
// The health check's verdict, and what its last few probes actually said.
function Health({ id }: { id: string }) {
const [h, setH] = useState<DockerHealth | null>(null)
const [err, setErr] = useState('')
useEffect(() => { setH(null); api.dockerHealth(id).then(setH).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id])
if (err) return <div className="error">{err}</div>
if (!h) return <Loading label="Asking…" />
if (!h.configured) return <div className="notice">This image defines no health check, so Docker only knows whether the process is running — which it is ({h.state}). A health check is a <span className="mono">healthcheck:</span> block in the compose file.</div>
return (
<>
<div className="props">
<div><span className="k">Health</span><span className={h.status === 'healthy' ? '' : 'warn-text'}>{h.status}</span></div>
<div><span className="k">State</span><span>{h.state}</span></div>
{!!h.failing_streak && <div><span className="k">Failing</span><span className="warn-text">{h.failing_streak} probe{h.failing_streak === 1 ? '' : 's'} in a row</span></div>}
</div>
<div className="muted small" style={{ marginTop: 8 }}>The last {h.log?.length ?? 0} probes — Docker keeps five.</div>
{(h.log ?? []).slice().reverse().map((p, i) => (
<section className="card" key={i} style={{ marginTop: 6 }}>
<div className="sub-head">
<span className={'sdot ' + (p.ExitCode === 0 ? 'ok' : 'bad')} />
<span>{new Date(p.Start).toLocaleString()}</span>
<span className="spacer" />
<span className="muted small">exit {p.ExitCode} · {Math.max(0, new Date(p.End).getTime() - new Date(p.Start).getTime())} ms</span>
</div>
{p.Output.trim() && <pre className="run-out" style={{ maxHeight: 140 }}>{p.Output.trim()}</pre>}
</section>
))}
</>
)
}
// What has happened to this container lately, asked of Docker rather than kept here.
function Events({ id }: { id: string }) {
const [ev, setEv] = useState<DockerEvent[] | null>(null)
const [kept, setKept] = useState(0)
const [since, setSince] = useState('7d')
const [err, setErr] = useState('')
useEffect(() => { setEv(null); setErr(''); api.dockerEvents(id, since).then((r) => { setEv(r.events); setKept(r.kept) }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id, since])
const bad = (a: string, h?: string) => /die|kill|oom|destroy/.test(a) || (h !== undefined && h !== 'healthy')
return (
<>
<div className="chips" style={{ marginBottom: 8 }}>
{['24h', '7d', '30d'].map((x) => <button key={x} className={'chip' + (since === x ? ' on' : '')} onClick={() => setSince(x)}>{x}</button>)}
</div>
{err && <div className="error">{err}</div>}
{!ev && !err && <Loading label="Asking Docker…" />}
{ev && ev.length === 0 && <div className="notice">Nothing for this container in the last {since}. Ham Lab keeps its own record — Docker's own only reaches back a few minutes — so history starts from when this version was installed. {kept} events kept across every container.</div>}
{ev && ev.length > 0 && (
<div className="snap-list">
{ev.map((e, i) => (
<div className="snap" key={i}>
<span className={bad(e.action, e.health) ? 'warn-text' : ''}>{e.action}{e.health ? `: ${e.health}` : ''}</span>
<span className="muted">{new Date(e.t).toLocaleString()}</span>
<span className="muted small">{e.exit_code ? `exit ${e.exit_code}` : ''}{e.signal ? ` signal ${e.signal}` : ''}</span>
<span />
<span />
</div>
))}
</div>
)}
</>
)
}
// Memory, CPU and restart policy, changed on the running container. Docker does this live,
// which is the point — and also the catch, said plainly below.
function LimitsDialog({ c, info, onClose, onSaved }: { c: DockerContainer; info: Record<string, unknown> | null; onClose: () => void; onSaved: () => void }) {
const curMem = Number(info?.memory_limit ?? 0)
const [mem, setMem] = useState(curMem > 0 ? String(Math.round(curMem / (1 << 20))) + 'm' : '')
const [cpus, setCpus] = useState('')
const [restart, setRestart] = useState(String(info?.restart ?? ''))
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEscape(onClose)
const save = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true)
try { await api.dockerLimits(c.name, { memory: mem, cpus, restart }); toast(`${c.name} updated`); onSaved() }
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
<h3>Limits for {c.name}</h3>
<label className="muted">Memory — 512m, 2g, or 0 for no limit{curMem > 0 ? ` (now ${fmtBytes(curMem)})` : ' (no limit now)'}
<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder="leave blank to keep" spellCheck={false} />
</label>
<label className="muted">CPUs — 1.5 means one and a half cores, 0 for no limit
<input className="mono" value={cpus} onChange={(e) => setCpus(e.target.value)} placeholder="leave blank to keep" spellCheck={false} />
</label>
<label className="muted">Restart policy
<select value={restart} onChange={(e) => setRestart(e.target.value)}>
<option value="">leave as it is</option>
{['no', 'on-failure', 'unless-stopped', 'always'].map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
<div className="notice attn">
This changes the running container now, without recreating it. It does <b>not</b> edit the
compose file{c.stack ? <> — so deploying <b>{c.stack}</b> puts back whatever the file says</> : ''}.
Edit the stack to make it stick.
</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={busy || (!mem && !cpus && !restart)}>Apply</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div> </div>
) )
} }
// A plain line diff (longest common subsequence): compose files are short, so the simple way is fine.
function lineDiff(a: string, b: string): { s: ' ' | '+' | '-'; t: string }[] { function lineDiff(a: string, b: string): { s: ' ' | '+' | '-'; t: string }[] {
const x = a.split('\n'), y = b.split('\n'); const n = x.length, m = y.length const x = a.split('\n'), y = b.split('\n'); const n = x.length, m = y.length
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1)) const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
@@ -120,6 +326,7 @@ const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.inde
// One stack: its compose file to edit, and the four things to do with it. Saving validates // One stack: its compose file to edit, and the four things to do with it. Saving validates
// with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act. // with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act.
function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void; onDuplicate: (from: DupFrom) => void }) { function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void; onDuplicate: (from: DupFrom) => void }) {
const [st, setSt] = useState<Stack | null>(null) const [st, setSt] = useState<Stack | null>(null)
const [text, setText] = useState(''); const [orig, setOrig] = useState('') const [text, setText] = useState(''); const [orig, setOrig] = useState('')
@@ -140,7 +347,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
} }
const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
const run = (a: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const run = (a: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div> if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div> if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return ( return (
@@ -159,6 +366,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
<Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn> <Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn>
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>} {st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>}
{st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>} {st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="build the containers again from scratch: docker compose up -d --force-recreate" confirm={`Recreate every container in ${st.name}?\n\nThis is the way out of a container Docker refuses to start — one pinned to a network that no longer exists, say, which answers "network … not found" to an ordinary Deploy. Nothing in a volume or a bind mount is touched.`} onClick={run('recreate')}>Recreate</Btn>}
{st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>} {st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>}
</div> </div>
<div className="svc-actions"> <div className="svc-actions">
@@ -203,6 +411,12 @@ export default function Docker() {
// what a new stack could join, and what it could collide with // what a new stack could join, and what it could collide with
const nets = useMemo(() => (d?.networks ?? []).map((n) => String(n.Name)).filter((n) => !['bridge', 'host', 'none'].includes(n)).sort(), [d]) const nets = useMemo(() => (d?.networks ?? []).map((n) => String(n.Name)).filter((n) => !['bridge', 'host', 'none'].includes(n)).sort(), [d])
const taken = useMemo(() => { const ports: Record<string, string> = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d]) const taken = useMemo(() => { const ports: Record<string, string> = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d])
// What the registry last said about each image, so a container can be marked without
// asking again. Only ever read from the cache here — checking is a deliberate act.
const [upd, setUpd] = useState<MachineUpdates | null>(null)
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div> if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div> if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
const selStack = sel.startsWith('stack:') ? sel.slice(6) : '' const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
@@ -210,12 +424,12 @@ export default function Docker() {
const shownTab: Tab = selStack ? 'stacks' : tab const shownTab: Tab = selStack ? 'stacks' : tab
const up = d.containers.filter((c) => c.state === 'running').length const up = d.containers.filter((c) => c.state === 'running').length
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`] const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
const cols: Record<Exclude<Tab, 'containers' | 'stacks'>, [string, string][]> = { images: [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']], volumes: [['Name', 'Name'], ['Driver', 'Driver'], ['Mountpoint', 'Mountpoint']], networks: [['Name', 'Name'], ['Driver', 'Driver'], ['Scope', 'Scope']] } const imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']]
return ( return (
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}> <div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
<div className="svc-list"> <div className="svc-list">
<div className="svc-tools"> <div className="svc-tools">
<div className="chips">{(['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : t === 'stacks' ? (stacks?.length ?? '…') : d[t].length}</button>)}</div> <div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>} {(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}> {shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" /> <input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
@@ -224,6 +438,7 @@ export default function Docker() {
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn> <Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
</form>} </form>}
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()} {shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
{shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />} {shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />} {shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
</div> </div>
@@ -242,7 +457,7 @@ export default function Docker() {
{list.map((c) => ( {list.map((c) => (
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}> <div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
<span className={'sdot ' + dot(c)} /> <span className={'sdot ' + dot(c)} />
<div className="svc-name"><div className="n">{c.name}</div><div className="d">{c.image}</div></div> <div className="svc-name"><div className="n">{c.name}<UpdateDot state={imgState[c.image]} /></div><div className="d">{c.image}</div></div>
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div> <div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
</div> </div>
))} ))}
@@ -260,16 +475,208 @@ export default function Docker() {
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })} return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table> {d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
)} )}
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && ( {shownTab === 'networks' && (
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <th />}</tr></thead> <table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
<tbody>{d[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map((row, i) => <tr key={i}>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}{shownTab === 'images' && <td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td>}</tr>)}</tbody></table> <tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
)}
{shownTab === 'images' && (
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr>)}</tbody></table>
)} )}
</div> </div>
</div> </div>
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>} {shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))} {shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />} {making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)} {shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
</div> </div>
) )
} }
// A container's image against the registry, as a single mark: nothing when nobody has
// asked, so an uncoloured list means "not checked" rather than "all current".
function UpdateDot({ state }: { state?: string }) {
if (!state || state === 'current') return null
const known = state === 'update'
return <span className={'updot' + (known ? ' behind' : ' unknown')} title={known ? 'a newer image has been published' : 'the registry could not say'}>{known ? '↑' : '?'}</span>
}
// What is out of date here, and the schedules that keep it from happening again.
function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u: MachineUpdates) => void }) {
const [busy, setBusy] = useState(false)
const [jobs, setJobs] = useState<UpdateJobRow[] | null>(null)
const [file, setFile] = useState('')
const [editing, setEditing] = useState<Partial<UpdateJob> | null>(null)
const [stacks, setStacks] = useState<string[]>([])
const loadJobs = useCallback(async () => {
try { const r = await api.updateJobs(); setJobs(r.jobs); setFile(r.file) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setJobs([]) }
}, [])
useEffect(() => { loadJobs(); api.stacks().then((r) => setStacks(r.stacks.map((s) => s.name))).catch(() => {}) }, [loadJobs])
const check = async () => {
setBusy(true)
try { onChecked(await api.machineUpdates(true)); toast('checked') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy(false) }
}
const run = async (j: UpdateJob) => {
if (!await askYes(`Run “${j.name}” now? It pulls ${j.stack}'s images${j.apply ? ' and deploys them, which restarts its containers' : ', without deploying them'}.`, 'Run now')) return
try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const remove = async (j: UpdateJob) => {
if (!await askYes(`Remove the schedule “${j.name}”? Nothing is updated or rolled back — the schedule simply stops.`, 'Remove')) return
try { await api.deleteUpdateJob(j.id); toast('removed'); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const imgs = upd?.images ?? []
const behind = imgs.filter((i) => i.state === 'update')
// Updating is per stack — that is what compose can do — so an image row acts on whichever
// stacks use it, and a queue runs them one at a time rather than recreating half the
// machine at once.
const [queue, setQueue] = useState<string[]>([])
const [running, setRunning] = useState<{ stack: string; id: string } | null>(null)
const [done, setDone] = useState<string[]>([])
const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd])
const startNext = useCallback(async (rest: string[]) => {
const next = rest[0]
if (!next) { setRunning(null); setQueue([]); return }
setQueue(rest.slice(1))
try { setRunning({ stack: next, id: (await api.stackAction(next, 'update')).id }) }
catch (e) { toast(`${next}: ${e instanceof Error ? e.message : 'failed'}`, 'err'); setRunning(null); startNext(rest.slice(1)) }
}, [])
const updateStacks = async (names: string[], what: string) => {
const uniq = [...new Set(names)]
if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return }
if (!await askYes(`Update ${what}?\n\nThis pulls the newer image${uniq.length > 1 ? 's' : ''} and recreates ${uniq.length === 1 ? `the ${uniq[0]} stack` : `these stacks, one at a time: ${uniq.join(', ')}`}. Their containers restart.`, 'Update')) return
setDone([]); startNext(uniq)
}
const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))]
return (
<div className="svc-detail">
<div className="dash-bar"><h2 className="section">What is out of date</h2><span className="spacer" />
<span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked yet'}</span>
{behindStacks.length > 0 && <Btn className="btn on" disabled={!!running} onClick={() => updateStacks(behindStacks, `everything out of date — ${behind.length} image${behind.length === 1 ? '' : 's'} across ${behindStacks.length} stack${behindStacks.length === 1 ? '' : 's'}`)}>Update all · {behind.length}</Btn>}
<Btn className="btn" busy={busy} onClick={check}>Check now</Btn>
</div>
<div className="muted small">Each check is one request per image, which registries count — so it happens when asked, and the answer is kept for six hours.</div>
{imgs.length === 0 && <div className="notice" style={{ marginTop: 8 }}>Nothing checked yet on this machine.</div>}
{imgs.length > 0 && (
<section className="card" style={{ marginTop: 8 }}>
{behind.length === 0 && <div className="muted">Every image that could be checked is current.</div>}
<div className="upd-list">
{imgs.filter((i) => i.state !== 'current').map((i) => (
<div className="row" key={i.image}>
<span className="k mono small">{i.image}</span>
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{done.some((d) => stacksFor(i.image).includes(d)) ? <span className="good">updated just now</span> : i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
<span className="muted small">{stacksFor(i.image).join(' ')}</span>
{i.state === 'update' && !done.some((d) => stacksFor(i.image).includes(d)) && (
<Btn className="btn" disabled={!!running || stacksFor(i.image).length === 0} title={stacksFor(i.image).length ? `pull it and recreate ${stacksFor(i.image).join(', ')}` : 'no stack here uses this image'}
onClick={() => updateStacks(stacksFor(i.image), `${i.image}`)}>Update</Btn>
)}
</div>
))}
</div>
{behind.length === 0 && imgs.some((i) => i.state === 'current') && <div className="muted small" style={{ marginTop: 6 }}>{imgs.filter((i) => i.state === 'current').length} current.</div>}
{running && (
<div style={{ marginTop: 8 }}>
<div className="sub-head"><span className="dname">Updating {running.stack}</span><span className="spacer" /><span className="muted small">{queue.length > 0 ? `${queue.length} to follow` : 'last one'}</span></div>
<TaskOutput id={running.id} onDone={(t) => {
if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack])
else toast(`${running.stack}: update failed`, 'err')
startNext(queue)
}} />
</div>
)}
</section>
)}
<div className="dash-bar" style={{ marginTop: 14 }}><h2 className="section">Schedules</h2><span className="spacer" />
<Btn className="btn on" onClick={() => setEditing({ cron: '0 3 * * *', apply: true, enabled: true })}>+ New schedule</Btn>
</div>
<div className="muted small">Written to <span className="mono">{file || '/etc/cron.d/hamlab-docker'}</span>, which Ham Lab owns — so they keep running whether or not this console is up.</div>
{jobs === null && <Loading label="Reading…" />}
{jobs?.length === 0 && <div className="notice" style={{ marginTop: 8 }}>No schedule yet. Nothing on this machine updates itself.</div>}
<div className="cards" style={{ marginTop: 8 }}>
{(jobs ?? []).map(({ job, next, last }) => (
<section className="card" key={job.id}>
<div className="sub-head">
<span className={'dot ' + (last?.error ? 'bad' : job.enabled ? 'ok' : '')} />
<span className="dname">{job.name}</span>
{!job.enabled && <span className="badge">paused</span>}
<span className="spacer" />
<span className="muted small">{job.enabled && next ? new Date(next).toLocaleString() : 'not scheduled'}</span>
</div>
<div className="props" style={{ marginTop: 6 }}>
<div><span className="k">Stack</span><span>{job.stack}{job.services?.length ? ` · ${job.services.join(' ')}` : ''}</span></div>
<div><span className="k">Runs</span><span>{describeCron(job.cron) || <span className="mono small">{job.cron}</span>}{describeCron(job.cron) && <span className="muted mono small"> · {job.cron}</span>}</span></div>
<div><span className="k">Does</span><span>{job.apply ? 'pulls and deploys' : 'pulls only — you deploy'}</span></div>
<div><span className="k">Last run</span><span>{last?.at ? new Date(last.at).toLocaleString() : 'never'}</span></div>
</div>
{last?.error && <div className="error" style={{ marginTop: 6 }}>{last.error}</div>}
<div className="svc-actions">
<Btn className="btn on" onClick={() => run(job)}>Run now</Btn>
<button className="btn" onClick={() => setEditing(job)}>Edit</button>
<Btn className="btn" onClick={async () => { await api.saveUpdateJob({ ...job, enabled: !job.enabled }); loadJobs() }}>{job.enabled ? 'Pause' : 'Resume'}</Btn>
<Btn className="btn danger" onClick={() => remove(job)}>Remove</Btn>
</div>
</section>
))}
</div>
{editing && <JobDialog j={editing} stacks={stacks} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); loadJobs() }} />}
</div>
)
}
function JobDialog({ j, stacks, onClose, onSaved }: { j: Partial<UpdateJob>; stacks: string[]; onClose: () => void; onSaved: () => void }) {
const [f, setF] = useState<Partial<UpdateJob>>(j)
const [svc, setSvc] = useState((j.services ?? []).join(' '))
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEscape(onClose)
const set = (k: keyof UpdateJob, v: unknown) => setF((x) => ({ ...x, [k]: v }))
const save = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true)
try { await api.saveUpdateJob({ ...f, services: svc.split(/\s+/).filter(Boolean) }); onSaved() }
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
<h3>{f.id ? 'Edit schedule' : 'Keep a stack up to date'}</h3>
<label className="muted">Stack
<select value={f.stack ?? ''} onChange={(e) => set('stack', e.target.value)} required>
<option value="">choose…</option>
{stacks.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<label className="muted">Name — optional
<input value={f.name ?? ''} onChange={(e) => set('name', e.target.value)} placeholder={f.stack ?? 'nightly'} maxLength={64} />
</label>
<label className="muted">Only these services — optional, space separated; blank means the whole stack
<input className="mono" value={svc} onChange={(e) => setSvc(e.target.value)} placeholder="all of them" spellCheck={false} />
</label>
<label className="muted">When — a cron expression
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 3 * * *" spellCheck={false} required />
</label>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={!!f.apply} onChange={(e) => set('apply', e.target.checked)} style={{ width: 'auto' }} />
Deploy the new image, not just fetch it
</label>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
Enabled
</label>
<div className="notice">
{f.apply
? 'Deploying restarts the stack’s containers. On something that carries DNS or ingress, pick an hour you are asleep.'
: 'Pulling only means the new image is on the machine and the list shows it as available — nothing restarts until you deploy.'}
</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={busy || !f.stack || !f.cron}>Save</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react' import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { api, FileEntry, FileInfo } from './api' import { api, FileEntry, FileInfo } from './api'
import { fmtBytes } from './Dashboard' import { fmtBytes } from './fmt'
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui' import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
+8 -2
View File
@@ -31,7 +31,7 @@ function Tile({ spec, data, note }: { spec: WidgetSpec; data: WidgetData; note:
) )
} }
export default function Gallery({ demoUrl }: { demoUrl?: string }) { export default function Gallery() {
const [cat, setCat] = useState<Sensor[] | null>(null) const [cat, setCat] = useState<Sensor[] | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const rate = useRefreshMs() const rate = useRefreshMs()
@@ -57,11 +57,17 @@ export default function Gallery({ demoUrl }: { demoUrl?: string }) {
return ( return (
<div className="dash gallery"> <div className="dash gallery">
<div className="notice" style={{ marginBottom: 10 }}>
These widgets are for <b>dashboards outside Ham Lab</b> — Glance, Home Assistant, a wall
display. Each one is a page at <span className="mono">/embed/widget</span> you put in an
iframe, reading this machine through a <b>read-only sensor token</b> (Settings → Access
tokens). Ham Lab shows you these numbers on its own pages; this tab is for handing them
to something else.
</div>
<div className="dash-bar"> <div className="dash-bar">
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span> <span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" /> <input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
<span className="spacer" /> <span className="spacer" />
{demoUrl && <a className="btn" href={demoUrl} target="_blank" rel="noreferrer" title="a page outside Ham Lab, built on the same sensor API">External demo ↗</a>}
</div> </div>
{!needle && <h2 className="section">Widget types</h2>} {!needle && <h2 className="section">Widget types</h2>}
+262
View File
@@ -0,0 +1,262 @@
// Hardware: what this machine is made of — the board, the processor, the memory modules in
// their slots, the drives and the graphics. None of it moves, so the page is a read rather
// than a monitor: what is live (temperature, usage, SMART) belongs to Dashboard and Disks.
import { useCallback, useEffect, useState } from 'react'
import { api, Hardware as HW, HWBoard, HWDrive, HWGPU, HWModule } from './api'
import { fmtBytes } from './fmt'
import { Btn, Loading } from './ui'
const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', emmc: 'eMMC', sd: 'SD', other: 'disk' }
const ghz = (mhz?: number) => (mhz ? `${(mhz / 1000).toFixed(mhz >= 1000 ? 2 : 1)} GHz` : '')
// A row of the "key: value" kind, dropped entirely when there is no value — a machine that
// doesn't report its serial should show one fewer line, not an empty one.
function P({ k, v, mono }: { k: string; v?: string | number | null | false; mono?: boolean }) {
if (v === undefined || v === null || v === false || v === '' || v === 0) return null
return <div><span className="k">{k}</span><span className={mono ? 'mono' : undefined} title={String(v)}>{v}</span></div>
}
function Module({ m }: { m: HWModule }) {
if (m.empty) {
return (
<div className="sub-card">
<div className="sub-head"><span className="dname muted">{m.locator}</span><span className="spacer" /><span className="badge">empty</span></div>
</div>
)
}
const speed = m.running_mts && m.speed_mts && m.running_mts !== m.speed_mts
? `${m.running_mts} MT/s (rated ${m.speed_mts})` // the board is running it slower than it could
: m.running_mts || m.speed_mts ? `${m.running_mts || m.speed_mts} MT/s` : ''
return (
<div className="sub-card">
<div className="sub-head">
<span className="dname">{fmtBytes(m.size ?? 0)} {m.type}</span>
<span className="spacer" />
<span className="model mono">{m.locator}</span>
</div>
<div className="props">
<P k="Maker" v={m.maker} />
<P k="Form" v={m.form_factor} />
<P k="Part" v={m.part} mono />
<P k="Speed" v={speed} />
<P k="Rank" v={m.rank ? `${m.rank}R` : ''} />
<P k="Voltage" v={m.voltage} />
<P k="Serial" v={m.serial} mono />
</div>
</div>
)
}
function Drive({ d }: { d: HWDrive }) {
return (
<div className="sub-card">
<div className="sub-head">
<span className="dname">{d.model || d.name}</span>
<span className="spacer" />
<span className="badge">{KIND[d.kind] ?? d.kind}</span>
</div>
<div className="props">
<P k="Size" v={fmtBytes(d.size)} />
<P k="Device" v={`/dev/${d.name}`} mono />
<P k="Bus" v={d.tran ? d.tran.toUpperCase() : ''} />
<P k="Spins" v={d.rotational ? 'yes — mechanical' : 'no — solid state'} />
<P k="Firmware" v={d.firmware} mono />
<P k="Serial" v={d.serial} mono />
<P k="Vendor" v={d.vendor} />
</div>
</div>
)
}
function Gpu({ g }: { g: HWGPU }) {
return (
<div className="sub-card">
<div className="sub-head">
<span className="dname">{g.model}</span>
<span className="spacer" />
{g.class && <span className="badge">{g.class}</span>}
</div>
<div className="props">
<P k="Vendor" v={g.vendor} />
<P k="Driver" v={g.driver} mono />
<P k="Memory" v={g.memory ? fmtBytes(g.memory) : ''} />
<P k="Slot" v={g.slot} mono />
<P k="Node" v={g.node} mono />
</div>
</div>
)
}
// The board and the firmware on it. Everything here is SMBIOS, so a machine without one
// — a Mac, a Pi — gets a card that says as much instead of a column of blanks.
function Motherboard({ b }: { b: HWBoard }) {
const named = b.maker || b.product || b.bios
// A board that reports every slot "Available" is not telling us anything — mini PCs
// copy the slot table out of a reference design and list slots the case has no room
// for. Only worth showing when the firmware actually tracks what is plugged in.
const slots = (b.slots ?? []).some((s) => s.usage === 'In Use') ? (b.slots ?? []) : []
return (
<section className="card">
<h2>Motherboard</h2>
{!named && <div className="empty">This machine has no SMBIOS table, so its firmware doesn't say what board it is.</div>}
{named && (
<div className="props">
<P k="Maker" v={b.maker} />
<P k="Model" v={b.product} />
<P k="Version" v={b.version} />
<P k="Kind" v={b.type} />
<P k="Chassis" v={[b.chassis_maker, b.chassis].filter(Boolean).join(' ')} />
<P k="In chassis" v={b.location} />
<P k="Serial" v={b.serial} mono />
<P k="Asset tag" v={b.asset_tag} mono />
</div>
)}
{(b.bios || b.bios_date) && (
<div className="props" style={{ marginTop: 8 }}>
<P k="Firmware" v={b.bios} />
<P k="Revision" v={b.bios_revision} />
<P k="Dated" v={b.bios_date} />
<P k="ROM" v={b.rom_size} />
<P k="Boots" v={b.uefi ? 'UEFI' : ''} />
</div>
)}
{slots.length > 0 && (
<div style={{ marginTop: 8 }}>
<div className="sub-head"><span className="dname">Expansion slots</span><span className="spacer" /><span className="muted small">as the firmware lists them</span></div>
<table className="dtable">
<thead><tr><th>Slot</th><th>Type</th><th>What is in it</th></tr></thead>
<tbody>{slots.map((s, i) => (
<tr key={s.designation + i}>
<td className="mono small">{s.designation}</td>
<td className="mono small">{[s.type, s.length && s.length !== 'Other' ? s.length.toLowerCase() : ''].filter(Boolean).join(' · ')}</td>
<td className="small" title={s.bus}>{s.usage === 'In Use' ? (s.device ?? s.bus ?? 'in use') : <span className="muted">{(s.usage ?? '').toLowerCase() || '—'}</span>}</td>
</tr>
))}</tbody>
</table>
</div>
)}
</section>
)
}
export default function Hardware() {
const [hw, setHw] = useState<HW | null>(null)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async (refresh = false) => {
if (refresh) setBusy(true)
try { setHw(await api.hardware(refresh)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy(false) }
}, [])
useEffect(() => { load() }, [load])
if (err) return <div className="dash"><div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Hardware tab. Update it from Machines." : err}</div></div>
if (!hw) return <div className="dash"><Loading label="Reading the machine…" /></div>
const { system: sys, board, cpu, memory: mem } = hw
const drives = hw.drives ?? []
const gpus = hw.gpus ?? []
const capacity = drives.reduce((n, d) => n + d.size, 0)
const threads = cpu.threads ?? 0
const cores = cpu.cores ?? 0
return (
<div className="dash">
<div className="dash-bar">
<span className="status"><b>{sys.model || 'this machine'}</b></span>
{sys.virt && <span className="badge" title="this is a virtual machine, so the parts below are what the host handed it">{sys.virt}</span>}
<span className="spacer" style={{ flex: 1 }} />
<Btn className="btn" busy={busy} onClick={() => load(true)}>Re-read</Btn>
</div>
<div className="kpis">
<div className="tile">
<div className="label">Processor</div>
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{cpu.model}</div>
<div className="sub">{cores ? `${cores} core${cores === 1 ? '' : 's'}` : ''}{threads && threads !== cores ? ` · ${threads} threads` : ''}{cpu.max_mhz ? ` · up to ${ghz(cpu.max_mhz)}` : ''}</div>
</div>
<div className="tile">
<div className="label">Memory</div>
<div className="value">{fmtBytes(mem.installed || mem.total)}</div>
<div className="sub">{mem.slots ? `${mem.filled ?? 0} of ${mem.slots} slot${mem.slots === 1 ? '' : 's'} filled` : 'slots unknown'}{mem.ecc ? ` · ${mem.ecc}` : ''}</div>
</div>
<div className="tile">
<div className="label">Storage</div>
<div className="value">{fmtBytes(capacity)}</div>
<div className="sub">{drives.length} drive{drives.length === 1 ? '' : 's'}</div>
</div>
<div className="tile">
<div className="label">Graphics</div>
<div className="value" title={gpus[0]?.model} style={(gpus[0]?.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{gpus[0]?.model ?? 'none found'}</div>
<div className="sub">{gpus.length > 1 ? `${gpus.length} adapters` : gpus[0]?.driver ? `${gpus[0].driver} driver` : ''}</div>
</div>
</div>
{(hw.notes ?? []).map((n, i) => <div className="notice" key={i}>{n}</div>)}
<div className="cards">
<section className="card">
<h2>Processor</h2>
<div className="props">
<P k="Model" v={cpu.model} />
<P k="Vendor" v={cpu.vendor} />
<P k="Cores" v={cores ? `${cores}${cpu.threads_per_core && cpu.threads_per_core > 1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} />
<P k="Clock" v={cpu.min_mhz && cpu.max_mhz ? `${ghz(cpu.min_mhz)} – ${ghz(cpu.max_mhz)}` : ghz(cpu.max_mhz)} />
<P k="Socket" v={(cpu.socket_names ?? []).join(', ')} />
<P k="Arch" v={cpu.arch} />
<P k="Family" v={[cpu.family && `family ${cpu.family}`, cpu.model_id && `model ${cpu.model_id}`, cpu.stepping && `stepping ${cpu.stepping}`].filter(Boolean).join(' · ')} />
<P k="Microcode" v={cpu.microcode} mono />
<P k="Virt" v={cpu.virtualization} />
</div>
{(cpu.caches ?? []).length > 0 && (
<div className="props" style={{ marginTop: 8 }}>
{(cpu.caches ?? []).map((c) => <P key={c.level} k={c.level} v={c.size} />)}
</div>
)}
{(cpu.flags ?? []).length > 0 && (
<div className="chips" style={{ marginTop: 8 }}>{(cpu.flags ?? []).map((f) => <span className="badge" key={f}>{f}</span>)}</div>
)}
</section>
<section className="card">
<h2>Memory</h2>
<div className="props">
<P k="Installed" v={mem.installed ? fmtBytes(mem.installed) : ''} />
<P k="Usable" v={fmtBytes(mem.total)} />
<P k="Board takes" v={mem.max_capacity ? fmtBytes(mem.max_capacity) : ''} />
<P k="ECC" v={mem.ecc} />
</div>
<div style={{ marginTop: 8 }}>
{(mem.modules ?? []).map((m, i) => <Module key={m.locator + i} m={m} />)}
{(mem.modules ?? []).length === 0 && <div className="empty">The firmware doesn't say what is in the slots.</div>}
</div>
</section>
<section className="card">
<h2>Drives</h2>
{drives.map((d) => <Drive key={d.name} d={d} />)}
{drives.length === 0 && <div className="empty">No drives found.</div>}
</section>
<section className="card">
<h2>Graphics</h2>
{gpus.map((g, i) => <Gpu key={(g.slot ?? '') + i} g={g} />)}
{gpus.length === 0 && <div className="empty">No display adapter found.</div>}
</section>
<Motherboard b={board} />
<section className="card">
<h2>System</h2>
<div className="props">
<P k="Model" v={sys.model} />
<P k="Family" v={sys.family} />
<P k="Serial" v={sys.serial} mono />
<P k="Kernel" v={sys.kernel} mono />
<P k="Arch" v={sys.arch} />
<P k="Virtual" v={sys.virt} />
</div>
</section>
</div>
</div>
)
}
+6 -20
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { describeCron } from './crontext'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services' import { Logs } from './Services'
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui' import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
@@ -13,22 +14,7 @@ function rel(ms: number): string {
return s >= 0 ? `in ${txt}` : `${txt} ago` return s >= 0 ? `in ${txt}` : `${txt} ago`
} }
// Plain-English reading of the common five-field shapes; anything else shows the raw expression. const describe = (expr: string) => describeCron(expr) || expr
function describe(expr: string): string {
const alias: Record<string, string> = { '@reboot': 'at boot', '@hourly': 'every hour', '@daily': 'every day at 00:00', '@midnight': 'every day at 00:00', '@weekly': 'every Sunday at 00:00', '@monthly': 'on the 1st at 00:00', '@yearly': 'every 1 Jan', '@annually': 'every 1 Jan' }
if (alias[expr]) return alias[expr]
const f = expr.split(/\s+/)
if (f.length !== 5) return expr
const [mi, h, dom, mon, dow] = f
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const at = /^\d+$/.test(mi) && /^\d+$/.test(h) ? `at ${h.padStart(2, '0')}:${mi.padStart(2, '0')}` : /^\d+$/.test(mi) && h === '*' ? `every hour at :${mi.padStart(2, '0')}` : mi.startsWith('*/') && h === '*' ? `every ${mi.slice(2)} min` : null
if (!at) return expr
const dowTxt = dow === '*' ? '' : /^\d$/.test(dow) ? `every ${days[+dow]}` : `on days ${dow}`
const domTxt = dom === '*' ? '' : /^\d+$/.test(dom) ? `on the ${dom}${['st', 'nd', 'rd'][+dom - 1] ?? 'th'}` : `on days ${dom}`
const monTxt = mon === '*' ? '' : `in month ${mon}`
const when = [dowTxt, domTxt, monTxt].filter(Boolean).join(', ')
return (when ? `${when} ` : dow === '*' && dom === '*' && !at.startsWith('every') ? 'every day ' : '') + at
}
const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s) const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s)
@@ -175,11 +161,11 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
} }
// ---- page ---- // ---- page ----
export default function Cron() { export default function Jobs() {
const [ov, setOv] = useState<CronOverview | null>(null) const [ov, setOv] = useState<CronOverview | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [hashSel, setHashSel] = useHashSel('cron') // "job:<id>" or "timer:<unit>", in the URL const [hashSel, setHashSel] = useHashSel('jobs') // "job:<id>" or "timer:<unit>", in the URL
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '') const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
const [editing, setEditing] = useState<string | null>(null) const [editing, setEditing] = useState<string | null>(null)
@@ -206,7 +192,7 @@ export default function Cron() {
else pane = <div className="svc-empty muted">Select a job or timer.</div> else pane = <div className="svc-empty muted">Select a job or timer.</div>
return ( return (
<div className={'cron' + (sel || editing ? ' has-sel' : '')}> <div className={'cron jobs' + (sel || editing ? ' has-sel' : '')}>
<div className="cron-list"> <div className="cron-list">
<div className="svc-tools"> <div className="svc-tools">
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div> <div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
@@ -218,7 +204,7 @@ export default function Cron() {
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>} {ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
<div className="cron-rows"> <div className="cron-rows">
{!ov && !err && <Loading label="Reading crontabs…" />} {!ov && !err && <Loading label="Reading crontabs…" />}
{ov && <h3>Cron jobs · {jobs.length}</h3>} {ov && <h3>Scheduled jobs · {jobs.length}</h3>}
{jobs.map((j) => ( {jobs.map((j) => (
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}> <div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
<span className={'sdot ' + (j.enabled ? 'ok' : '')} /> <span className={'sdot ' + (j.enabled ? 'ok' : '')} />
+187
View File
@@ -0,0 +1,187 @@
// The KVM of the machine you are looking at: the device's own web page, shown in the tab.
//
// One per machine, and it follows the machine switcher — switch to `grid` and you get
// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns
// its keep exactly when that machine is down and cannot answer for itself; the hub proxies
// the device either way.
//
// Nothing here knows anything about any particular KVM. It is an iframe pointed at Ham
// Lab's own proxy, so whatever web UI the device serves is what you get.
//
// On the keyboard: a page cannot capture keys and forward them into an iframe, and does not
// need to. An iframe with focus receives them directly. What the browser itself swallows —
// Escape, Tab, Ctrl-W, the function keys — is what `navigator.keyboard.lock()` releases,
// and that needs fullscreen and only exists in Chromium. Everywhere else you get the plain
// version, which is most keys, and the device's own on-screen buttons for the rest.
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
import { KVM as Device, api } from './api'
import { Btn, Loading, askYes, toast, useEscape } from './ui'
// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a
// browser without it simply does not get the extra keys.
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
// Fullscreen, across the three ways browsers spell it. 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 there the page expands inside the viewport instead, which is the same thing
// minus the browser's own chrome.
type FSElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void }
type FSDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise<void> | void }
const nativeFullscreen = () => {
if (typeof document === 'undefined') return false
const el = document.documentElement as FSElement
return typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'
}
const fullscreenElement = () => (document as FSDocument).fullscreenElement ?? (document as FSDocument).webkitFullscreenElement ?? null
async function enterFullscreen(el: FSElement) {
if (typeof el.requestFullscreen === 'function') return el.requestFullscreen()
if (typeof el.webkitRequestFullscreen === 'function') return el.webkitRequestFullscreen()
throw new Error('this browser has no fullscreen for a page element')
}
function exitFullscreen() {
const d = document as FSDocument
if (typeof d.exitFullscreen === 'function') return d.exitFullscreen()
if (typeof d.webkitExitFullscreen === 'function') return d.webkitExitFullscreen()
}
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
const [adding, setAdding] = useState(false)
const [err, setErr] = useState('')
const [full, setFull] = useState(false) // native fullscreen is on
const [expanded, setExpanded] = useState(false) // the fallback: filling the viewport in-page
const box = useRef<HTMLDivElement>(null)
const frame = useRef<HTMLIFrameElement>(null)
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
const load = useCallback(async () => {
try { setKvm((await api.kvms()).find((k) => k.machine === key) ?? null) }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [key])
useEffect(() => { load() }, [load])
// Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather
// than the browser. Both are undone together when fullscreen ends, however it ends.
useEffect(() => {
const onChange = () => {
const on = !!fullscreenElement()
setFull(on)
if (!on) keyboardAPI()?.unlock?.()
}
document.addEventListener('fullscreenchange', onChange)
document.addEventListener('webkitfullscreenchange', onChange)
return () => {
document.removeEventListener('fullscreenchange', onChange)
document.removeEventListener('webkitfullscreenchange', onChange)
keyboardAPI()?.unlock?.()
}
}, [])
// Expanding is in-page, so Escape is ours to handle.
useEscape(() => setExpanded(false), expanded)
const goBig = async () => {
if (!nativeFullscreen()) { setExpanded(true); frame.current?.focus(); return }
try {
if (box.current) await enterFullscreen(box.current)
await keyboardAPI()?.lock?.()
frame.current?.focus()
} catch {
setExpanded(true) // refused (an iframe policy, a permissions policy): fill the page instead
frame.current?.focus()
}
}
const goSmall = () => { setExpanded(false); if (fullscreenElement()) exitFullscreen() }
const remove = async () => {
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
try { await api.removeKVM(kvm.id); toast('removed'); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
if (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
const locks = !!keyboardAPI()?.lock
const big = full || expanded
return (
<div className="kvm">
<div className="kvm-bar">
<span className="dname">{title}</span>
{kvm
? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
: <span className="muted small">no KVM</span>}
<span className="spacer" />
{kvm && <>
<Btn className="btn" onClick={goBig} title={!nativeFullscreen() ? 'fill the screen — this browser has no true fullscreen for a page element' : locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>{nativeFullscreen() ? 'Fullscreen' : 'Expand'}</Btn>
<a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
<Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
<Btn className="btn danger" onClick={remove}>Remove</Btn>
</>}
{!kvm && <Btn className="btn on" onClick={() => setAdding(true)}>+ Add a KVM for {title}</Btn>}
</div>
{kvm ? (
<div className={'kvm-frame' + (full ? ' full' : '') + (expanded ? ' expanded' : '')} ref={box}>
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
allow="fullscreen; clipboard-read; clipboard-write" />
{expanded && <button className="btn kvm-shrink" onClick={goSmall} title="back to the console">Done</button>}
</div>
) : (
<div className="svc-empty muted kvm-none">
<div>
<p>No KVM for <b>{title}</b>.</p>
<p className="small">Add one with its address and its page appears here. The address is kept on the hub,
so it still works when {title} does not — which is when you will want it.</p>
</div>
</div>
)}
{kvm && !big && (
<div className="kvm-hint muted small">
Click the picture, then type — the keys go to {title}.
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
</div>
)}
{adding && <AddDialog machine={key} title={title} existing={kvm} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load() }} />}
</div>
)
}
function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
const [url, setUrl] = useState(existing?.url ?? '')
const [name, setName] = useState(existing?.name ?? '')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEscape(onClose)
const save = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true)
try { await api.addKVM(name.trim(), url.trim(), machine); onAdded() }
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
<h3>{existing ? `Change ${title}'s KVM` : `A KVM for ${title}`}</h3>
<label className="muted">Address — whatever you would type into a browser
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
</label>
<label className="muted">Name — optional; the address is used otherwise
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={`${title} KVM`} maxLength={64} />
</label>
<div className="muted small">
Ham Lab serves the device from its own address, so a KVM that speaks only plain HTTP
still works here, and it is behind this login rather than needing a second one. The
address is kept on the hub, so it still reaches the device when {title} is down. The
device itself is not changed and is never reachable from outside.
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={busy || !url.trim()}>{existing ? 'Save' : 'Add'}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
+26 -1
View File
@@ -2,6 +2,19 @@ import { FormEvent, useState } from 'react'
import { api } from './api' import { api } from './api'
import { Spinner } from './ui' import { Spinner } from './ui'
// Drawn, not typed: 👁 is an emoji on phones, full colour and its own size. Outline strokes
// match the glyphs in the rail and the power button.
const EyeIcon = (
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M1.2 8S3.9 3.6 8 3.6 14.8 8 14.8 8 12.1 12.4 8 12.4 1.2 8 1.2 8z" /><circle cx="8" cy="8" r="2.1" />
</svg>
)
const EyeOffIcon = (
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M1.2 8S3.9 3.6 8 3.6 14.8 8 14.8 8 12.1 12.4 8 12.4 1.2 8 1.2 8z" /><circle cx="8" cy="8" r="2.1" /><path d="M2.4 13.6 13.6 2.4" />
</svg>
)
const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again
export default function Login({ onLogin }: { onLogin: () => void }) { export default function Login({ onLogin }: { onLogin: () => void }) {
@@ -9,6 +22,9 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
const [pw, setPw] = useState('') const [pw, setPw] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
// Shown on request: a generated password is typed by hand off a message, and a wrong
// character looks exactly like a wrong password from here.
const [show, setShow] = useState(false)
async function submit(e: FormEvent) { async function submit(e: FormEvent) {
e.preventDefault() e.preventDefault()
@@ -26,8 +42,17 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
<img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} /> <img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} />
<input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off" <input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off"
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} /> value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
<input type="password" autoFocus={!!user} autoComplete="current-password" placeholder="password" <div className="pwfield">
{/* revealed, the field is a text field, so the keyboard would otherwise offer to
capitalise and correct what is typed into it */}
<input type={show ? 'text' : 'password'} autoFocus={!!user} autoComplete="current-password" placeholder="password"
spellCheck={false} autoCapitalize="off" autoCorrect="off"
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} /> value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
<button type="button" className="pweye" onClick={() => setShow(!show)} disabled={busy}
aria-label={show ? 'hide the password' : 'show the password'} aria-pressed={show} title={show ? 'hide' : 'show'}>
{show ? EyeOffIcon : EyeIcon}
</button>
</div>
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button> <button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
</form> </form>
+201
View File
@@ -0,0 +1,201 @@
// Network: how this machine reaches the internet, and how fast the line actually is.
//
// The speed test runs on the machine in the switcher, not in this browser — so it measures
// that machine's line, and it keeps running if the phone is locked or the tab is closed. What
// is drawn here is what the daemon reports about a test it is running itself.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, Iface, NetworkInfo, SpeedResult, SpeedRun } from './api'
import { Sensor, fmtValue, useSensorStream } from './sensors'
import { fmtBytes } from './fmt'
import { Btn, Loading, toast } from './ui'
import { useRefreshMs } from './prefs'
import Sparkline from './Sparkline'
// Docker's per-network bridges, and the veth ends inside them, are real interfaces and
// nobody wants to read nineteen of them. They fold away unless asked for.
const scenery = (f: Iface) =>
!f.default && (/^br-[0-9a-f]{12}$/.test(f.name) || /^(veth|docker)/.test(f.name) || f.kind === 'loopback')
const KIND: Record<string, string> = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi', wireguard: 'VPN tunnel', none: 'tunnel' }
// A WireGuard interface is listed here because it is an interface and carries traffic, but
// everything about it that is worth knowing — who is on it, when they last spoke — belongs to
// the VPN tab, and is only there.
const isTunnel = (f: Iface) => f.kind === 'wireguard' || /^(wg|tun|tap)[0-9]/.test(f.name)
const goVPN = () => window.dispatchEvent(new CustomEvent('hamlab:view', { detail: 'vpn' }))
// Where the bytes came from. The usual source is anycast and its edge is the interesting
// part; a fallback source is somewhere fixed, and then *which* one is the interesting part,
// because a mirror far away reads low and the figure should not pretend otherwise.
const where = (r: SpeedResult) =>
r.source && r.source !== 'cloudflare' ? ` from ${r.source}` : r.colo ? ` via ${r.colo}` : ''
// A round number of megabits, which is how a line is sold and how it should be read.
const mbps = (v: number) => (v >= 100 ? v.toFixed(0) : v.toFixed(1))
const ago = (iso: string) => {
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
if (s < 60) return 'just now'
if (s < 3600) return `${Math.floor(s / 60)} min ago`
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
return `${Math.floor(s / 86400)} d ago`
}
// The four figures a speed test produces, big enough to read from across the room.
function Figures({ r, live }: { r?: SpeedResult; live?: SpeedRun }) {
const running = !!live?.running
const phase = live?.phase
const downNow = running && phase === 'download' ? live!.mbps : undefined
const upNow = running && phase === 'upload' ? live!.mbps : undefined
return (
<div className="speedgrid">
<Figure label="Download" unit="Mbps" value={downNow ?? r?.down_mbps} live={downNow !== undefined} waiting={running && phase === 'latency'} />
<Figure label="Upload" unit="Mbps" value={upNow ?? r?.up_mbps} live={upNow !== undefined} waiting={running && phase !== 'upload'} />
<Figure label="Latency" unit="ms" value={r?.latency_ms} waiting={running && phase === 'latency'} />
<Figure label="Jitter" unit="ms" value={r?.jitter_ms} waiting={running && phase === 'latency'} />
</div>
)
}
function Figure({ label, unit, value, live, waiting }: { label: string; unit: string; value?: number; live?: boolean; waiting?: boolean }) {
return (
<div className={'speedfig' + (live ? ' live' : '')}>
<div className="label">{label}</div>
<div className="big">{value === undefined || value === null ? (waiting ? '·' : '—') : mbps(value)}<i>{unit}</i></div>
</div>
)
}
export default function Network() {
const [d, setD] = useState<NetworkInfo | null>(null)
const [err, setErr] = useState('')
const [all, setAll] = useState(false)
const [busy, setBusy] = useState(false)
const rate = useRefreshMs()
const load = useCallback(async () => {
try { setD(await api.network()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { load() }, [load])
const run = d?.run
const running = !!run?.running
// While a test is running the state is polled hard, because the needle should move; at rest
// the page is a read and asks again only now and then.
useEffect(() => {
const every = running ? Math.max(400, Math.min(rate, 1000)) : Math.max(5000, rate)
const t = setInterval(() => {
if (document.visibilityState !== 'visible') return
if (running) api.speedtest().then((r) => setD((old) => (old ? { ...old, run: r } : old))).catch(() => {})
else load()
}, every)
return () => clearInterval(t)
}, [running, rate, load])
// The run that just ended brings a result and a history entry with it.
const phase = run?.phase
useEffect(() => { if (phase === 'done') load() }, [phase, load])
const live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: Math.max(1000, rate) })
const sens = live.data?.n ?? {}
const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value
const ifaces = d?.report.ifaces ?? []
const shown = all ? ifaces : ifaces.filter((f) => !scenery(f))
const hidden = ifaces.length - shown.length
const hist = useMemo(() => (d?.history ?? []).filter((r) => !r.error), [d])
const last = run?.result && !run.result.error ? run.result : hist[hist.length - 1]
const start = async () => {
setBusy(true)
try { const r = await api.startSpeedtest(); setD((old) => (old ? { ...old, run: r } : old)) }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
finally { setBusy(false) }
}
const cancel = async () => {
try { await api.cancelSpeedtest(); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Network tab. Update it from Machines." : err}</div> : <Loading label="Reading the network…" />}</div>
const o = d.outside
const failed = run?.result?.error
return (
<div className="dash net">
<div className="dash-bar"><h2 className="section">Internet speed</h2><span className="spacer" />
<span className="muted small">{last ? `measured ${ago(last.at)}` : 'not measured yet'}</span></div>
<section className="card speedtest">
<Figures r={last} live={run} />
{running && (
<div className="speedbar">
<div className="meter"><div className="fill" style={{ width: `${Math.round((run!.done || 0) * 100)}%` }} /></div>
<span className="muted small">{run!.phase === 'latency' ? 'measuring the round trip' : run!.phase === 'download' ? 'pulling down' : 'pushing up'} · {fmtBytes(run!.bytes || 0)} moved</span>
</div>
)}
<div className="svc-actions">
{running
? <><Btn className="btn danger" onClick={cancel}>Stop</Btn><span className="muted small">running on {d.report.host} — it carries on if you close this</span></>
: <><Btn className="btn on" busy={busy} onClick={start}>Run speed test</Btn>
<span className="muted small">{last ? `${fmtBytes((last.down_bytes ?? 0) + (last.up_bytes ?? 0))} moved in ${last.took_sec}s${where(last)}` : 'about ten seconds'}</span></>}
</div>
{failed && <div className="error">{failed === 'cancelled' ? 'Stopped.' : failed}</div>}
{!failed && last?.note && <div className="notice">{last.note}</div>}
<div className="muted small method">{d.method}</div>
</section>
{hist.length > 1 && (
<>
<div className="dash-bar"><h2 className="section">Past tests</h2><span className="spacer" /><span className="muted small">{hist.length} kept</span></div>
<section className="card">
<Sparkline height={60} format={(v) => `${mbps(v)} Mbps`}
series={[{ name: 'down', values: hist.map((r) => r.down_mbps), color: 'var(--s1)' },
{ name: 'up', values: hist.map((r) => r.up_mbps), color: 'var(--s2)' }]} />
<div className="props" style={{ marginTop: 8 }}>
{[...hist].reverse().slice(0, 8).map((r) => (
<div key={r.at}><span className="k">{new Date(r.at).toLocaleString()}</span>
<span className="mono">↓ {mbps(r.down_mbps)} · ↑ {mbps(r.up_mbps)} Mbps · {r.latency_ms} ms</span></div>
))}
</div>
</section>
</>
)}
<div className="dash-bar"><h2 className="section">Out there</h2></div>
<section className="card">
<div className="props">
<div><span className="k">Public address</span><span className="mono">{o.ip || '—'}{o.error ? ` · ${o.error}` : ''}</span></div>
{o.colo && <div><span className="k">Reached via</span><span>{o.colo}{o.country ? ` · ${o.country}` : ''}</span></div>}
{d.report.gateway && <div><span className="k">Gateway</span><span className="mono">{d.report.gateway}{d.report.gateway_via ? ` via ${d.report.gateway_via}` : ''}</span></div>}
{!!d.report.dns?.length && <div><span className="k">DNS</span><span className="mono">{d.report.dns.join(', ')}</span></div>}
{!!d.report.search?.length && <div><span className="k">Search</span><span className="mono">{d.report.search.join(' ')}</span></div>}
</div>
</section>
<div className="dash-bar"><h2 className="section">Interfaces</h2><span className="spacer" />
{hidden > 0 && <button className="link" onClick={() => setAll(true)}>show {hidden} more</button>}
{all && <button className="link" onClick={() => setAll(false)}>hide the scenery</button>}</div>
{shown.map((f) => {
const rx = rateOf(f.name, 'rx'), tx = rateOf(f.name, 'tx')
return (
<section className="card iface" key={f.name}>
<div className="sub-head">
<span className="dname mono">{f.name}</span>
{f.default && <span className="badge ok" title="the default route leaves by this one">internet</span>}
<span className="badge">{KIND[f.kind] ?? f.kind}</span>
{isTunnel(f) && <button className="link" onClick={goVPN} title="who is on this tunnel">peers →</button>}
{!f.up ? <span className="badge bad">down</span> : !f.carrier && <span className="badge bad">no link</span>}
<span className="spacer" />
<span className="muted small mono">{typeof rx === 'number' ? `↓ ${fmtValue(rx, 'B/s')}` : ''} {typeof tx === 'number' ? `↑ ${fmtValue(tx, 'B/s')}` : ''}</span>
</div>
<div className="props">
{f.addrs.length > 0 && <div><span className="k">Address</span><span className="mono">{f.addrs.filter((a) => !a.v6).map((a) => `${a.ip}/${a.prefix}`).join(', ') || '—'}</span></div>}
{f.addrs.some((a) => a.v6) && <div><span className="k">IPv6</span><span className="mono" title={f.addrs.filter((a) => a.v6).map((a) => `${a.ip}/${a.prefix}`).join('\n')}>{f.addrs.filter((a) => a.v6).length} addresses</span></div>}
{!!f.speed_mbps && <div><span className="k">Link</span><span>{f.speed_mbps >= 1000 ? `${f.speed_mbps / 1000} Gbps` : `${f.speed_mbps} Mbps`}{f.duplex ? ` ${f.duplex} duplex` : ''}</span></div>}
{f.signal_dbm !== undefined && <div><span className="k">Signal</span><span>{f.signal_dbm} dBm</span></div>}
{f.mac && <div><span className="k">MAC</span><span className="mono">{f.mac}</span></div>}
<div><span className="k">MTU</span><span>{f.mtu}{f.driver ? ` · ${f.driver}` : ''}</span></div>
<div><span className="k">Moved</span><span className="mono">↓ {fmtBytes(f.rx_total)} · ↑ {fmtBytes(f.tx_total)}</span></div>
</div>
</section>
)
})}
</div>
)
}
-166
View File
@@ -1,166 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react'
import { api, Proc, ProcTable } from './api'
import { fmtBytes, fmtRate } from './Dashboard'
import { Btn, Loading, askYes, toast } from './ui'
import { useRefreshMs } from './prefs'
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
const pct = (v: number) => `${Math.round(v)}%`
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
const fmtDur = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}` : s >= 60 ? `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}` : `${s.toFixed(1)}s`)
const fmtStart = (ms: number) => { const d = new Date(ms), now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) }
const STATE: Record<string, string> = { R: 'running', S: 'sleeping', D: 'disk wait', Z: 'zombie', T: 'stopped', t: 'traced', I: 'idle' }
const BLOCKS = '▁▂▃▄▅▆▇█'
// btop-style gradient bar: the track carries the green→yellow→red gradient and a cover
// from the right hides the unfilled part, so colour depends on position not on level.
function GBar({ frac }: { frac: number }) {
return <div className="gbar"><div className="gcover" style={{ width: `${100 - Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Row({ label, frac, value }: { label: string; frac: number; value: string }) {
return <div className="brow"><span className="bl">{label}</span><GBar frac={frac} /><span className="bv">{value}</span></div>
}
// Block-character graph, coloured per column by its value, like btop's cpu graph.
function Graph({ values, max = 100 }: { values: number[]; max?: number }) {
const cols = 60
const v = values.slice(-cols)
const pad = cols - v.length
return (
<div className="graph" aria-hidden="true">
{pad > 0 && <span className="gpad">{'\u2581'.repeat(pad)}</span>}
{v.map((x, i) => { const f = Math.min(1, Math.max(0, x / max)); return <span key={i} className={f >= 0.8 ? 'g3' : f >= 0.5 ? 'g2' : 'g1'}>{BLOCKS[Math.min(7, Math.round(f * 7))]}</span> })}
</div>
)
}
function Box({ title, right, children, className = '' }: { title: string; right?: string; children: React.ReactNode; className?: string }) {
return <section className={'box ' + className}><span className="box-title">{title}</span>{right && <span className="box-right">{right}</span>}{children}</section>
}
export default function Procs() {
const [t, setT] = useState<ProcTable | null>(null)
const [err, setErr] = useState('')
const [sort, setSort] = useState<Col>('cpu')
const [asc, setAsc] = useState(false)
const [q, setQ] = useState('')
const [mine, setMine] = useState(false)
const [sel, setSel] = useState<number | null>(null)
const [paused, setPaused] = useState(false)
const [showTop, setShowTop] = useState<boolean>(() => localStorage.getItem('hamlab.procs.top') !== '0')
const interval = useRefreshMs()
const load = useCallback(async () => {
try { setT(await api.procs()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => {
load()
const id = window.setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, interval)
return () => window.clearInterval(id)
}, [load, paused, interval])
const head = (c: Col, label: string, cls = '') => (
<button className={'th ' + cls + (sort === c ? ' on' : '')} title={{ cpu: 'share of one core, right now', rss: 'resident memory (RSS)', cpu_time: 'total CPU time used since it started', started: 'when the process started', pid: 'process id', name: 'program name', user: 'owner' }[c]} onClick={() => { if (sort === c) setAsc(!asc); else { setSort(c); setAsc(c === 'name' || c === 'user' || c === 'pid') } }}>
{label}{sort === c ? (asc ? ' ▲' : ' ▼') : ''}
</button>
)
if (!t) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Reading processes…" />}</div>
const needle = q.trim().toLowerCase()
const rows = t.procs
.filter((p) => (!needle || p.name.toLowerCase().includes(needle) || (p.cmd ?? '').toLowerCase().includes(needle) || p.user.toLowerCase().includes(needle) || String(p.pid) === needle) && (!mine || p.user !== 'root'))
.sort((a, b) => {
const va = a[sort] as number | string, vb = b[sort] as number | string
const r = typeof va === 'string' ? (va as string).localeCompare(vb as string) : (va as number) - (vb as number)
return asc ? r : -r
})
const selected = sel !== null ? t.procs.find((p) => p.pid === sel) : undefined
const memFrac = t.mem.total ? t.mem.used / t.mem.total : 0
const swapFrac = t.swap.total ? t.swap.used / t.swap.total : 0
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
if (!await askYes(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`)) return
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.name}`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return (
<div className="procs-page">
{showTop && <div className="procs-top">
<Box title="cpu" right={`${t.cpu_model ?? ''}${t.cpu_mhz ? ` · ${(t.cpu_mhz / 1000).toFixed(2)}${t.cpu_max_mhz ? ` / ${(t.cpu_max_mhz / 1000).toFixed(1)}` : ''} GHz` : ''}${t.cpu_temp ? ` · ${t.cpu_temp.toFixed(0)}°C` : ''}`}>
<div className="cpu-total">
<Graph values={t.cpu_hist} />
<div className="cpu-total-v"><span className="bl">CPU</span><span className="bv big">{pct(t.cpu)}</span><span className="muted small">total, all cores</span></div>
</div>
<div className="cores">
{t.cores.map((c, i) => <Row key={i} label={`C${i}`} frac={c / 100} value={pct(c)} />)}
</div>
<div className="box-foot muted">
<span>load avg <b>{t.load[0].toFixed(2)}</b> {t.load[1].toFixed(2)} {t.load[2].toFixed(2)} <small>(1 · 5 · 15 min, {t.cores.length} cores)</small></span>
<span>up <b>{fmtUptime(t.uptime)}</b></span>
<span><b>{t.total}</b> tasks, <b>{t.running}</b> running</span>
</div>
</Box>
<Box title="mem" right={`${fmtBytes(t.mem.total)} total`}>
<Row label="Used" frac={memFrac} value={`${fmtBytes(t.mem.used)} · ${pct(memFrac * 100)}`} />
<Row label="Available" frac={t.mem.total ? t.mem.avail / t.mem.total : 0} value={fmtBytes(t.mem.avail)} />
<Row label="Cached" frac={t.mem.total ? (t.mem.cached ?? 0) / t.mem.total : 0} value={fmtBytes(t.mem.cached ?? 0)} />
<Row label="Free" frac={t.mem.total ? (t.mem.free ?? 0) / t.mem.total : 0} value={fmtBytes(t.mem.free ?? 0)} />
{t.swap.total > 0 ? (
<><div className="box-sub">swap · {fmtBytes(t.swap.total)} total</div>
<Row label="Used" frac={swapFrac} value={`${fmtBytes(t.swap.used)} · ${pct(swapFrac * 100)}`} />
<Row label="Free" frac={t.swap.total ? t.swap.avail / t.swap.total : 0} value={fmtBytes(t.swap.avail)} /></>
) : <div className="box-sub muted">no swap</div>}
<div className="box-foot muted small">Used = total − available. Cached is file cache the kernel gives back under pressure, so "available" is the honest headroom.</div>
</Box>
<Box title="net" className="netbox">
{t.net.filter((n) => n.up).map((n) => (
<div className="netrow" key={n.iface}>
<span className="bl mono">{n.iface}</span>
<span className="bv"><span className="dn">▼ {fmtRate(n.rx_bps)}</span> <span className="upc">▲ {fmtRate(n.tx_bps)}</span></span>
<span className="muted small">{fmtBytes(n.rx_total)} in · {fmtBytes(n.tx_total)} out</span>
</div>
))}
{t.net.filter((n) => n.up).length === 0 && <div className="muted">no interfaces up</div>}
</Box>
</div>}
<div className="procs-sticky">
<div className="procs-tools">
<button className={'chip' + (showTop ? ' on' : '')} title="show or hide the cpu/mem/net boxes" onClick={() => { setShowTop(!showTop); localStorage.setItem('hamlab.procs.top', showTop ? '0' : '1') }}>{showTop ? 'metrics ▴' : 'metrics ▾'}</button>
<input placeholder="filter name, command, user, pid" value={q} onChange={(e) => setQ(e.target.value)} />
<button className={'chip' + (mine ? ' on' : '')} onClick={() => setMine(!mine)}>hide root</button>
<button className={'chip' + (paused ? ' on' : '')} onClick={() => setPaused(!paused)}>{paused ? 'paused' : 'live'}</button>
<span className="muted small">{rows.length} shown</span>
</div>
<div className="prow phead">
{head('pid', 'PID', 'num')}{head('user', 'User')}{head('name', 'Program')}{head('cpu', 'CPU %', 'num')}{head('rss', 'Memory', 'num')}{head('cpu_time', 'CPU time', 'num')}{head('started', 'Started', 'num')}
</div>
</div>
<div className="ptable">
{rows.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
{rows.map((p) => (
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
<span className="num muted">{p.pid}</span>
<span className="ell">{p.user}</span>
<span className="ell mono">{p.name}{p.threads > 1 && <small className="muted"> ×{p.threads}</small>}</span>
<span className={'num' + (p.cpu >= 50 ? ' hot' : '')}>{p.cpu.toFixed(1)}</span>
<span className="num">{fmtBytes(p.rss)}</span>
<span className="num muted">{fmtDur(p.cpu_time)}</span>
<span className="num muted">{fmtStart(p.started)}</span>
</div>
))}
</div>
{selected && (
<div className="pdetail">
<div className="sub-head"><b className="mono">{selected.name}</b><span className="muted">pid {selected.pid} · parent {selected.ppid} · {selected.user} · {STATE[selected.state] ?? selected.state} · nice {selected.nice} · {selected.threads} threads</span><span className="spacer" /><button className="icon-btn" onClick={() => setSel(null)}>✕</button></div>
<div className="mono cmdline">{selected.cmd || '(kernel thread)'}</div>
<div className="svc-actions">
<Btn className="btn" onClick={() => signal(selected, 'TERM')}>Terminate</Btn>
<Btn className="btn danger" onClick={() => signal(selected, 'KILL')}>Kill −9</Btn>
<span className="muted">cpu {selected.cpu.toFixed(1)}% · {fmtBytes(selected.rss)} · running {fmtDur(selected.cpu_time)} of cpu since {new Date(selected.started).toLocaleString()}</span>
</div>
</div>
)}
</div>
)
}
+88 -27
View File
@@ -4,27 +4,31 @@ import Rail, { Group } from './Rail'
import TerminalPane, { TermHandle } from './TerminalPane' import TerminalPane, { TermHandle } from './TerminalPane'
import TermTabs from './TermTabs' import TermTabs from './TermTabs'
import KeyBar from './KeyBar' import KeyBar from './KeyBar'
import Dashboard from './Dashboard'
import Gallery from './Gallery' import Gallery from './Gallery'
import Docker from './Docker' import Docker from './Docker'
import Alerts from './Alerts' import Alerts from './Alerts'
import Services from './Services' import Services from './Services'
import Cron from './Cron' import Jobs from './Jobs'
import Updates from './Updates' import Updates from './Updates'
import Disks from './Disks' import Disks from './Disks'
import Hardware from './Hardware'
import Network from './Network'
import VPN from './VPN'
import ZFS from './ZFS'
import KVM from './KVM'
import Files from './Files' import Files from './Files'
import Procs from './Procs'
import Top from './Top' import Top from './Top'
import Machines from './Machines' import Machines from './Machines'
import Settings from './Settings' import Settings from './Settings'
import Space from './Space' import Usage from './Usage'
import Users from './Users' import Users from './Users'
import MachineSwitcher from './MachineSwitcher' import MachineSwitcher from './MachineSwitcher'
import { MachineStatus, setMachine } from './api' import { MachineStatus, setMachine } from './api'
import { LIST_FLOOR, useRefreshMs } from './prefs' import { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power' import Power from './Power'
import { applyAccent } from './accents' import { applyAccent } from './accents'
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui' import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
import { fmtLeft, useTimeLeft } from './guest'
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
const FolderIcon = ( const FolderIcon = (
@@ -51,35 +55,57 @@ const BellIcon = (
</svg> </svg>
) )
const GlobeIcon = (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
<circle cx="8" cy="8" r="6.2" /><path d="M1.9 8h12.2M8 1.8c1.7 1.8 2.6 3.9 2.6 6.2S9.7 12.4 8 14.2C6.3 12.4 5.4 10.3 5.4 8S6.3 3.6 8 1.8z" />
</svg>
)
// A padlock-ish shield: the tab is about who may come in, and a keyhole reads at 14px where
// a padlock's shackle does not.
const ShieldIcon = (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
<path d="M8 1.6 13.2 3.5v4.2c0 3-2.1 5.4-5.2 6.7-3.1-1.3-5.2-3.7-5.2-6.7V3.5z" /><circle cx="8" cy="7.2" r="1.3" /><path d="M8 8.5v2" />
</svg>
)
const GROUPS: Group[] = [ const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔' }, { id: 'procs', label: 'Processes', icon: '▚' },
{ id: 'top', label: 'Top', icon: '▚' },
{ id: 'procs', label: 'Processes', icon: '≡' },
{ id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' }, { id: 'services', label: 'Services', icon: '◈' },
{ id: 'docker', label: 'Docker', icon: DockerIcon }, { id: 'jobs', label: 'Jobs', icon: ClockIcon },
{ id: 'cron', label: 'Cron', icon: ClockIcon },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'disks', label: 'Disks', icon: '▤' }, { id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'space', label: 'Space', icon: '▧' },
{ id: 'files', label: 'Files', icon: FolderIcon }, { id: 'files', label: 'Files', icon: FolderIcon },
{ id: 'network', label: 'Network', icon: GlobeIcon },
{ id: 'vpn', label: 'VPN', icon: ShieldIcon },
{ id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'kvm', label: 'KVM', icon: '⌨' },
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'alerts', label: 'Alerts', icon: BellIcon }, { id: 'alerts', label: 'Alerts', icon: BellIcon },
{ id: 'machines', label: 'Machines', icon: '⌂' }, { id: 'machines', label: 'Machines', icon: '⌂' },
{ id: 'users', label: 'Users', icon: '⚇' },
{ id: 'widgets', label: 'Widgets', icon: '▦' }, { id: 'widgets', label: 'Widgets', icon: '▦' },
{ id: 'users', label: 'Users', icon: '⚇' },
{ id: 'settings', label: 'Settings', icon: '⚙' }, { id: 'settings', label: 'Settings', icon: '⚙' },
] ]
type View = 'terminal' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings' type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings'] const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and // URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
// forward work, reload restores the tab, and links can point at a page on a machine. // forward work, reload restores the tab, and links can point at a page on a machine.
// Pages that were renamed, so a link or a bookmark made before it still lands where it meant.
const RENAMED_VIEWS: Record<string, View> = { space: 'usage', cron: 'jobs', top: 'procs', dashboard: 'procs' }
function parseHash(): { machine: string | null; view: View } | null { function parseHash(): { machine: string | null; view: View } | null {
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
if (!m || !VIEWS.includes(m[2] as View)) return null if (!m) return null
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View } const view = VIEWS.includes(m[2] as View) ? (m[2] as View) : RENAMED_VIEWS[m[2]]
if (!view) return null
return { machine: m[1] === 'hub' ? null : m[1], view }
} }
function fmtUptime(s: number): string { function fmtUptime(s: number): string {
@@ -97,6 +123,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
const [machines, setMachines] = useState<MachineStatus[]>([]) const [machines, setMachines] = useState<MachineStatus[]>([])
const [remoteMe, setRemoteMe] = useState<Me | null>(null) const [remoteMe, setRemoteMe] = useState<Me | null>(null)
const [remoteErr, setRemoteErr] = useState('') const [remoteErr, setRemoteErr] = useState('')
// A guest login is an account with a clock on it: nothing is hidden from it, but the header
// says how long it has, for whoever is holding the phone.
const leftMs = useTimeLeft(hubMe.guest_until)
// A machine is titled by its own hostname; the name it was added under stays its identity // A machine is titled by its own hostname; the name it was added under stays its identity
// (the URL, its accent, what the hub's API calls it). See titleOf. // (the URL, its accent, what the hub's API calls it). See titleOf.
const title = titleOf(machine, machines, hubMe.host) const title = titleOf(machine, machines, hubMe.host)
@@ -104,7 +133,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
const listRate = useRefreshMs(LIST_FLOOR) const listRate = useRefreshMs(LIST_FLOOR)
const [terms, setTerms] = useState<Term[]>([]) const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'dashboard') const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
const [drawer, setDrawer] = useState(false) const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true) const [online, setOnline] = useState(true)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@@ -120,6 +149,32 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
useEffect(() => { reload() }, [reload]) useEffect(() => { reload() }, [reload])
// One page sending you to another — the Network tab handing a tunnel over to the VPN tab.
// A custom event rather than a hash assignment, because assigning location.hash does not
// reliably raise popstate, which is what this shell navigates by.
useEffect(() => {
const h = (e: Event) => {
const id = (e as CustomEvent<string>).detail
if (VIEWS.includes(id as View)) { setView(id as View); setDrawer(false) }
}
window.addEventListener('hamlab:view', h)
return () => window.removeEventListener('hamlab:view', h)
}, [])
// A shell opened inside a container from the Docker tab is a terminal like any other:
// pick it up, show the Terminal tab, and make it the active one.
useEffect(() => {
const h = async (e: Event) => {
const id = (e as CustomEvent<string>).detail
if (!id) return
await reload()
setActive(id)
setView('terminal')
}
window.addEventListener('hamlab:terminal', h)
return () => window.removeEventListener('hamlab:terminal', h)
}, [reload])
// Machine list for the switcher (hub-local), refreshed while the console is open. // Machine list for the switcher (hub-local), refreshed while the console is open.
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, []) const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate]) useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
@@ -144,7 +199,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
// the machine is changing: the URL sets the machine and the page in one go, and deciding from // the machine is changing: the URL sets the machine and the page in one go, and deciding from
// inside the machine change read the page we were leaving — so a link from Settings to a // inside the machine change read the page we were leaving — so a link from Settings to a
// machine's Disks arrived at its Dashboard. // machine's Disks arrived at its Dashboard.
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('dashboard') }, [machine, view]) useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host]) useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
// The tab's title says where you are, so history entries and open tabs can be told apart. // The tab's title says where you are, so history entries and open tabs can be told apart.
@@ -253,6 +308,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span> <span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} /> <span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} /> <Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
{hubMe.guest && <span className="badge guest" title={`this guest login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>} {hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
<Btn className="link" onClick={logout}>logout</Btn> <Btn className="link" onClick={logout}>logout</Btn>
</header> </header>
@@ -264,21 +320,26 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<div className="backdrop" onClick={() => setDrawer(false)} /> <div className="backdrop" onClick={() => setDrawer(false)} />
<main className="main" key={machine ?? 'hub'}> <main className="main" key={machine ?? 'hub'}>
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
{view === 'settings' && <Settings me={hubMe} />} {view === 'settings' && <Settings me={hubMe} />}
{view === 'users' && <Users me={hubMe} />} {view === 'users' && <Users me={hubMe} />}
{view === 'space' && <Space key={machine ?? ''} />} {view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />} {view === 'usage' && <Usage key={machine ?? ''} />}
{view === 'dashboard' && <Dashboard />} {view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
{view === 'widgets' && <Gallery demoUrl={hubMe.demo_url} />} {view === 'widgets' && <Gallery />}
{view === 'docker' && <Docker />} {view === 'docker' && <Docker />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
{view === 'top' && <Top />} {view === 'procs' && <Top />}
{view === 'procs' && <Procs />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
{view === 'cron' && <Cron />} {view === 'jobs' && <Jobs />}
{view === 'updates' && <Updates />} {view === 'updates' && <Updates />}
{view === 'disks' && <Disks />} {view === 'disks' && <Disks />}
{view === 'hardware' && <Hardware />}
{view === 'network' && <Network />}
{view === 'vpn' && <VPN />}
{view === 'zfs' && <ZFS />}
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />} {view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
</PageBoundary>
{termView && <TermTabs terms={ordered} active={active} creating={creating} {termView && <TermTabs terms={ordered} active={active} creating={creating}
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab} onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />} onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
@@ -304,7 +365,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span> <span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted">{view === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span> <span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+54 -13
View File
@@ -250,6 +250,20 @@ export default function Top() {
// the process table, and the CPU model, come from the same place the Processes page reads // the process table, and the CPU model, come from the same place the Processes page reads
const [pt, setPt] = useState<ProcTable | null>(null) const [pt, setPt] = useState<ProcTable | null>(null)
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
const [pools, setPools] = useState<Record<string, string>>({})
useEffect(() => {
let live = true
api.storage().then((r) => {
if (!live) return
const m: Record<string, string> = {}
for (const d of r.devices) for (const part of d.parts) if (part.fstype === 'zfs_member' && part.label) m[d.name] = part.label
setPools(m)
}).catch(() => { /* the rows simply say less */ })
return () => { live = false }
}, [])
const [err, setErr] = useState('') const [err, setErr] = useState('')
useEffect(() => { useEffect(() => {
let stop = false let stop = false
@@ -319,8 +333,12 @@ export default function Top() {
const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached) const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached)
const swap = s?.swap ?? pt?.swap const swap = s?.swap ?? pt?.swap
const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo') const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo')
const iface = net[0] // the one that has actually carried traffic, not whichever the kernel listed first — wg0
const fs = s?.fs ?? [] // and a dormant wlan should not push the real NIC out of the way. Tapping a name switches.
const busiest = net.length ? net.reduce((a, b) => (b.rx_total + b.tx_total > a.rx_total + a.tx_total ? b : a)) : undefined
const iface = net.find((n) => n.iface === pickedIf) ?? busiest
// the disks box is this machine's own storage: a CIFS or NFS share is somebody else's disk
const fs = (s?.fs ?? []).filter((f) => !f.remote)
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0) const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept // the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap)) const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
@@ -378,18 +396,26 @@ export default function Top() {
<Box n={3} name="disks" className="top-disks" right="io"> <Box n={3} name="disks" className="top-disks" right="io">
<div className="t-disks"> <div className="t-disks">
{swap && swap.total > 0 && <Disk name="swap" size={swap.total} used={swap.used} />} {(s?.disks ?? []).map((d) => {
{fs.map((f) => { // what is on this drive: the filesystems the sampler could attribute to it. A ZFS
// which drive is behind a mount isn't in the sample, so only the root filesystem // pool or an md array spans several drives, so nothing is attributed and the row
// gets an IO line, against the machine's largest drive — which is the one it is on // shows what is true of the hardware — how busy it is, and how hot.
const d = f.mount === '/' ? [...(s?.disks ?? [])].sort((a, b) => b.size - a.size)[0] : undefined const on = fs.filter((f) => f.disk === d.name)
return <Disk key={f.mount} name={f.mount === '/' ? 'root' : f.mount.split('/').filter(Boolean).pop() ?? f.mount} size={f.size} used={f.used} io={d ? d.util_pct : undefined} /> const used = on.reduce((a, f) => a + f.used, 0)
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
})} })}
{fs.length === 0 && <div className="t-dim">no filesystems reported</div>} {(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
</div> </div>
</Box> </Box>
<Box n={4} name="net" className="top-net" right={iface ? <span className="t-if">{iface.iface}</span> : undefined}> <Box n={4} name="net" className="top-net" right={net.length ? (
<span className="t-ifs">
{net.map((n) => (
<button key={n.iface} className={'t-if' + (iface && n.iface === iface.iface ? ' on' : '')}
title={`${n.iface} — ${bytes(n.rx_total)} in, ${bytes(n.tx_total)} out`} onClick={() => setPickedIf(n.iface)}>{n.iface}</button>
))}
</span>
) : undefined}>
{iface ? ( {iface ? (
<div className="t-net"> <div className="t-net">
<div className="t-net-graphs"> <div className="t-net-graphs">
@@ -470,14 +496,29 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
) )
}) })
function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) { // One physical drive. `used` is the bytes of the filesystems on it, left out when none can
const upct = size > 0 ? (used / size) * 100 : 0 // be attributed to it — a pool member's share of a pool is not a number worth inventing.
function Disk({ name, note, size, used, io, temp, mounts, pool }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[]; pool?: string }) {
const upct = used !== undefined && size > 0 ? (used / size) * 100 : 0
return ( return (
<div className="t-disk"> <div className="t-disk">
<div className="t-row"><b>{name}</b><span className="spacer" />{bytes(size)}</div> <div className="t-row">
<b>{name}</b>
{note && <span className="t-dim t-model" title={note}>{note}</span>}
<span className="spacer" />
{temp !== undefined && temp > 0 && <span className="t-temp">{Math.round(temp)}°C</span>}
{bytes(size)}
</div>
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>} {io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
{used !== undefined ? (
<>
<div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div> <div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div>
<div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div> <div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div>
{mounts && mounts.length > 0 && <div className="t-row t-sub t-dim t-mounts" title={mounts.join(' ')}>{mounts.join(' ')}</div>}
</>
) : (
<div className="t-row t-sub t-dim">{pool ? `member of ZFS pool ${pool}` : 'nothing mounted from it'}</div>
)}
</div> </div>
) )
} }
+5 -4
View File
@@ -1,10 +1,11 @@
// Space: what is using a disk, WizTree-style. Pick a disk, scan it (a parallel walk of every // Usage: what is using a disk, WizTree-style. (The tab was called Space until 2026-09-24;
// the Go package and the /api/space endpoints behind it still carry the old name.) Pick a disk, scan it (a parallel walk of every
// filesystem the disk backs, as root when the host allows), then read the result two ways: // filesystem the disk backs, as root when the host allows), then read the result two ways:
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every // a tree of folders largest-first, and a treemap of whatever folder is selected, where every
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is. // file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, SpaceFile, SpaceTarget, SpaceTree } from './api' import { api, SpaceFile, SpaceTarget, SpaceTree } from './api'
import { fmtBytes } from './Dashboard' import { fmtBytes } from './fmt'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui' import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
@@ -45,10 +46,10 @@ const ago = (iso?: string) => {
const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` } const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` }
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/') const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
export default function Space() { export default function Usage() {
const [targets, setTargets] = useState<SpaceTarget[] | null>(null) const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [sel, setSel] = useHashSel('space') const [sel, setSel] = useHashSel('usage')
const [tab, setTab] = useState<'tree' | 'files'>('tree') const [tab, setTab] = useState<'tree' | 'files'>('tree')
const rate = useRefreshMs() const rate = useRefreshMs()
+80 -1
View File
@@ -3,19 +3,98 @@
// a token rather than a login, so a node's accounts are managed on that node's console. // a token rather than a login, so a node's accounts are managed on that node's console.
import { FormEvent, useCallback, useEffect, useState } from 'react' import { FormEvent, useCallback, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { api, Me, Session, User } from './api' import { api, FreshGuest, Guest, Me, Session, User } from './api'
import { Btn, Loading, askYes, toast, useEscape } from './ui' import { Btn, Loading, askYes, toast, useEscape } from './ui'
import { fmtLeft } from './guest'
export default function Users({ me }: { me: Me }) { export default function Users({ me }: { me: Me }) {
return ( return (
<div className="dash settings"> <div className="dash settings">
<Account me={me} /> <Account me={me} />
<Sessions /> <Sessions />
<Guests />
<Accounts me={me} /> <Accounts me={me} />
</div> </div>
) )
} }
const GUEST_MINUTES = [15, 30, 60, 120, 480] as const
// A login to hand somebody you are showing the console to: an ordinary account with a clock
// on it, which expires on its own and can be ended here at any moment. Making one is the only
// time its password is readable, so it is shown until dismissed rather than in a toast.
function Guests() {
const [list, setList] = useState<Guest[] | null>(null)
const [minutes, setMinutes] = useState<number>(30)
const [fresh, setFresh] = useState<FreshGuest | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => { try { setList(await api.guests()) } catch { setList([]) } }, [])
useEffect(() => { load() }, [load])
// The clock is the point of the thing, so it keeps up without a reload.
useEffect(() => { const t = setInterval(load, 30000); return () => clearInterval(t) }, [load])
const create = async () => {
setBusy(true)
try { setFresh(await api.createGuest(minutes)); load() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
finally { setBusy(false) }
}
const revoke = async (g: Guest) => {
if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once, everywhere.`, 'Revoke')) return
try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return (
<>
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can be revoked</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list?.length === 0 && <div className="empty">None. One lets somebody in for a while without giving them an account.</div>}
{list?.map((g) => {
const left = new Date(g.expires).getTime() - Date.now()
return (
<div className="row" key={g.name}>
<span className="k mono">{g.name}</span>
<span className="muted small">{left > 0 ? `expires in ${fmtLeft(left)}` : 'expiring'} · {g.sessions > 0 ? `${g.sessions} signed in` : g.used ? 'signed in earlier' : 'not used yet'}{g.issued_by ? ` · by ${g.issued_by}` : ''}</span>
<Btn className="btn danger" onClick={() => revoke(g)}>Revoke</Btn>
</div>
)
})}
</section>
{fresh && <FreshCard g={fresh} onClose={() => setFresh(null)} />}
<div className="card addform" style={{ marginTop: 8 }}>
<label>Lasts
<select value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
</select>
</label>
<div className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A guest can do everything you can, on every machine — including the terminal — until the time is up or you revoke it. Give one out accordingly.</span></div>
</div>
</>
)
}
// The credentials, once. Copy takes the whole invitation, because that is what gets pasted
// into a message.
function FreshCard({ g, onClose }: { g: FreshGuest; onClose: () => void }) {
const invite = `${location.origin}\nuser: ${g.name}\npassword: ${g.password}\nexpires ${new Date(g.expires).toLocaleTimeString()}`
const copy = async (text: string, what: string) => {
try { await navigator.clipboard.writeText(text); toast(`${what} copied`) }
catch { toast('select and copy it by hand', 'err') }
}
return (
<section className="card fresh" style={{ marginTop: 8 }}>
<div className="row"><span className="k">User</span><span className="mono">{g.name}</span><Btn className="btn" onClick={() => copy(g.name, 'user')}>Copy</Btn></div>
<div className="row"><span className="k">Password</span><span className="mono">{g.password}</span><Btn className="btn" onClick={() => copy(g.password, 'password')}>Copy</Btn></div>
<div className="svc-actions" style={{ marginTop: 8 }}>
<Btn className="btn on" onClick={() => copy(invite, 'invitation')}>Copy invitation</Btn>
<span className="muted small">Shown once. Expires {new Date(g.expires).toLocaleTimeString()}.</span>
<span className="spacer" />
<button className="btn" onClick={onClose}>Done</button>
</div>
</section>
)
}
function Account({ me }: { me: Me }) { function Account({ me }: { me: Me }) {
const [cur, setCur] = useState('') const [cur, setCur] = useState('')
const [pw1, setPw1] = useState('') const [pw1, setPw1] = useState('')
+118
View File
@@ -0,0 +1,118 @@
// VPN: this machine's WireGuard tunnels, and the devices on them.
//
// Per machine, like every other tab, which here is the whole point: the tunnel people dial
// into and a tunnel this machine dials out through are different machines, and showing them
// together would invite the question "which box is this actually on".
import { useCallback, useEffect, useState } from 'react'
import { api, VPNReport, VPNPeer, VPNTunnel } from './api'
import { fmtBytes } from './fmt'
import { Loading } from './ui'
import { useRefreshMs } from './prefs'
// WireGuard says nothing when it has nothing to say, so "online" is a handshake in the last
// few minutes rather than a connection — there is no connection to have.
const ago = (iso?: string) => {
if (!iso) return 'never'
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
if (s < 60) return `${Math.floor(s)}s ago`
if (s < 3600) return `${Math.floor(s / 60)} min ago`
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
return `${Math.floor(s / 86400)} d ago`
}
// A public key is long and nobody reads the middle of it; the ends are what you compare.
const shortKey = (k: string) => (k.length > 16 ? `${k.slice(0, 8)}…${k.slice(-6)}` : k)
function Peer({ p, server }: { p: VPNPeer; server: boolean }) {
return (
<div className="card iface vpn-peer">
<div className="sub-head">
<span className={'sdot ' + (p.online ? 'ok' : p.handshake ? '' : 'bad')} />
<span className="dname">{p.name || <span className="muted">unnamed</span>}</span>
{p.online && <span className="badge ok">online</span>}
{!p.handshake && <span className="badge">never connected</span>}
<span className="spacer" />
<span className="muted small mono">{(p.allowed_ips ?? []).join(', ')}</span>
</div>
<div className="props">
<div><span className="k">Last handshake</span><span>{ago(p.handshake)}</span></div>
{p.endpoint && <div><span className="k">{server ? 'Comes from' : 'Endpoint'}</span><span className="mono">{p.endpoint}</span></div>}
<div><span className="k">Moved</span><span className="mono">↓ {fmtBytes(p.rx)} · ↑ {fmtBytes(p.tx)}</span></div>
{!!p.keepalive && <div><span className="k">Keepalive</span><span>every {p.keepalive}s</span></div>}
<div><span className="k">Public key</span><span className="mono" title={p.public_key}>{shortKey(p.public_key)}</span></div>
{p.psk && <div><span className="k">Pre-shared key</span><span>in use</span></div>}
</div>
</div>
)
}
function Tunnel({ t }: { t: VPNTunnel }) {
const server = t.kind === 'server'
const peers = t.peers ?? []
const online = peers.filter((p) => p.online).length
return (
<>
<div className="dash-bar">
<h2 className="section">{t.name}</h2>
<span className="badge">{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'}</span>
<span className="spacer" />
<span className="muted small">{server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''}</span>
</div>
<section className="card">
<div className="props">
{!!t.addrs?.length && <div><span className="k">Address</span><span className="mono">{t.addrs.join(', ')}</span></div>}
{!!t.listen_port && <div><span className="k">Listening on</span><span className="mono">{t.listen_port}/udp</span></div>}
{!!t.mtu && <div><span className="k">MTU</span><span>{t.mtu}</span></div>}
<div><span className="k">Public key</span><span className="mono" title={t.public_key}>{shortKey(t.public_key)}</span></div>
{t.fwmark && <div><span className="k">Firewall mark</span><span className="mono">{t.fwmark}</span></div>}
</div>
</section>
{peers.map((p) => <Peer key={p.public_key} p={p} server={server} />)}
{peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</div></section>}
</>
)
}
export default function VPN() {
const [d, setD] = useState<VPNReport | null>(null)
const [err, setErr] = useState('')
const rate = useRefreshMs()
const load = useCallback(async () => {
try { setD(await api.vpn()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { load() }, [load])
// Handshakes and counters move; nothing here is expensive to read.
useEffect(() => {
const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate))
return () => clearInterval(t)
}, [load, rate])
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the VPN tab. Update it from Machines." : err}</div> : <Loading label="Reading the tunnels…" />}</div>
// A machine running an older build answers with null rather than an empty list, and taking
// the length of that is what a blank page is made of.
const tunnels = d.tunnels ?? []
return (
<div className="dash net">
{tunnels.length === 0 && (
<>
<div className="dash-bar"><h2 className="section">VPN</h2></div>
<section className="card"><div className="empty">{d.note || 'Nothing here.'}</div>
<div className="muted small" style={{ marginTop: 6 }}>
Tunnels are the machine's own, so look on the one that carries them — the switcher at the top of the rail.
</div>
</section>
</>
)}
{tunnels.map((t) => <Tunnel key={t.name} t={t} />)}
{tunnels.length > 0 && (
<div className="muted small method" style={{ marginTop: 12 }}>
Read from the kernel with <span className="mono">wg show</span>; names come from the configuration beside it.
Online means a handshake in the last three minutes — WireGuard is silent when idle, so there is no connection to be in.
Private and pre-shared keys are never read.
</div>
)}
</div>
)
}
+359
View File
@@ -0,0 +1,359 @@
// ZFS: pools, datasets, snapshots, and a schedule for taking and expiring them.
//
// Split out of the Disks tab on 2026-09-24. Disks is the hardware; this is the filesystem
// on top of it, which has enough of its own to say.
//
// On the schedule: `grid` has run zfSnap from root cron since before Ham Lab existed, and it
// works. So this reads and writes the same snapshot names — `2026-09-24_02.30.01--6w`, with
// the retention in the name — rather than inventing a second convention for the two tools to
// disagree over. An existing cron job is detected and shown, so the page tells the truth
// about the machine whether Ham Lab or something else is doing the work.
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
import { fmtBytes } from './fmt'
import { useRefreshMs } from './prefs'
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
const ago = (t?: string | number) => {
if (!t) return 'never'
const s = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000)
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`
}
const soon = (t?: string) => {
if (!t) return '—'
const s = (new Date(t).getTime() - Date.now()) / 1000
return s < 60 ? 'any moment' : s < 3600 ? `in ${Math.round(s / 60)} min` : s < 86400 ? `in ${Math.round(s / 3600)} h` : `in ${Math.round(s / 86400)} d`
}
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const describe = (p: ZFSPolicy) => {
const when = p.every === 'custom' ? `on ${p.cron}`
: p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}`
: p.every === 'daily' ? `every day at ${p.at}`
: p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}`
: `on day ${p.day} of the month at ${p.at}`
return `${when} · keep ${p.keep_for}${p.recursive ? ' · recursive' : ''}`
}
// ---- pools and datasets (moved from the Disks tab) ----
function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) {
const scrubbing = /scrub in progress/i.test(p.scan ?? '')
const paused = /scrub paused/i.test(p.scan ?? '')
const [cfg, setCfg] = useState(false)
const act = (a: 'start' | 'pause' | 'stop') => async () => {
try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return (
<section className="card">
<div className="sub-head">
<span className={'dot ' + (p.health === 'ONLINE' ? 'ok' : p.health === 'DEGRADED' ? 'warn' : 'bad')} />
<span className="dname">{p.name}</span>
<span className="badge">{p.health}</span>
<span className="spacer" />
<span className="num">{fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%</span>
</div>
<Meter frac={p.cap / 100} cls={p.cap >= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
<div className="props" style={{ marginTop: 8 }}>
<div><span className="k">Free</span><span>{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}</span></div>
{p.scan && <div><span className="k">Scan</span><span style={{ whiteSpace: 'normal' }}>{p.scan}</span></div>}
{p.errors && <div><span className="k">Errors</span><span className={p.errors.startsWith('No known') ? '' : 'warn-text'}>{p.errors}</span></div>}
</div>
<div className="svc-actions">
{!scrubbing && <Btn className="btn" confirm={`Start a scrub of ${p.name}? It reads the whole pool and can take many hours.`} onClick={act('start')}>{paused ? 'Resume scrub' : 'Scrub'}</Btn>}
{scrubbing && <Btn className="btn" onClick={act('pause')}>Pause scrub</Btn>}
{(scrubbing || paused) && <Btn className="btn" confirm="Stop the scrub?" onClick={act('stop')}>Stop scrub</Btn>}
<button className="btn" onClick={() => setCfg(!cfg)}>{cfg ? 'Hide vdevs' : 'Vdevs'}</button>
</div>
{cfg && <pre className="unit-file">{p.config}</pre>}
</section>
)
}
// A snapshot's name carries when it was taken and how long it lives — 2026-09-24_02.30.01--6w
// — so the explorer can say when each one goes without asking the server anything.
function snapMeta(full: string): { taken?: Date; expires?: Date } {
const snap = full.split('@')[1] ?? ''
const [body, ttl] = snap.split('--')
const m = /(\d{4})-(\d{2})-(\d{2})_(\d{2})\.(\d{2})\.(\d{2})$/.exec(body)
if (!m) return {}
const taken = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6])
const a = /^(\d+)([mhdw])$/.exec(ttl ?? '')
if (!a) return { taken }
const ms = { m: 60e3, h: 3600e3, d: 86400e3, w: 7 * 86400e3 }[a[2]]!
return { taken, expires: new Date(taken.getTime() + +a[1] * ms) }
}
// One dataset in the snapshot explorer: how many it has and what they cost, opening into
// the snapshots themselves, each of which can be taken now or deleted.
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
const [open, setOpen] = useState(false)
const reload = useCallback(async () => {
try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) }
}, [d.name])
async function toggle() {
setOpen(!open)
if (!open && !snaps) await reload()
}
async function snapshot() {
const def = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '.')
const name = await askText(`Snapshot name for ${d.name}@`, def)
if (!name) return
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
async function destroy(s: ZFSSnapshot) {
const short = s.name.split('@')[1]
if (!await askYes(`Delete ${s.name}? It is removed from this dataset and from every dataset beneath it that has a snapshot of the same name. This cannot be undone.`, 'Delete')) return
try { await api.deleteZfsSnapshot(s.name, true); toast(`${short} deleted`); await reload(); onChanged() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const depth = d.name.split('/').length - 1
return (
<>
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
<span className="num">{d.snapshots}</span>
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
<span className="num muted">{fmtBytes(d.used)}</span>
<span className="num muted">{fmtBytes(d.refer)}</span>
<span className="ds-mount muted">{d.mount}</span>
</div>
{open && (
<div className="ds-snaps">
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted small">{d.snapshots} snapshot{d.snapshots === 1 ? '' : 's'} · {fmtBytes(d.avail)} free on this dataset</span></div>
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
<div className="snap-list">{snaps.slice().reverse().map((s) => {
const { taken, expires } = snapMeta(s.name)
const gone = expires && expires.getTime() < Date.now()
return (
<div key={s.name} className="snap">
<span className="mono snap-name" title={s.name}>{s.name.split('@')[1]}</span>
<span className="muted snap-when">{taken ? taken.toLocaleString() : new Date(s.created).toLocaleString()}</span>
<span className={'muted snap-exp' + (gone ? ' warn' : '')}>{expires ? (gone ? 'expired' : `expires ${expires.toLocaleDateString()}`) : 'kept'}</span>
<span className="num muted">{fmtBytes(s.used)}</span>
<Btn className="btn danger snap-del" onClick={() => destroy(s)}>Delete</Btn>
</div>
)
})}</div>
)}
</div>
)}
</>
)
}
// ---- schedules ----
function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; onAdopt: () => void }) {
return (
<div className={'notice' + (mine > 0 ? ' attn' : '')} style={{ marginBottom: 8 }}>
<b>{x.tool}</b> is already taking snapshots on this machine, from cron as <b>{x.user}</b>:
<div className="mono small" style={{ margin: '4px 0', wordBreak: 'break-all' }}>{x.schedule} &nbsp; {x.command}</div>
<span className="muted">in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''}</span>
{mine > 0
? <div className="warn" style={{ marginTop: 6 }}>A Ham Lab schedule is running as well. Both use the same names and both only remove snapshots that have passed the expiry written into them, so they will not fight — but they will take twice as many. Keep one.</div>
: <div className="svc-actions" style={{ marginTop: 6 }}>
<Btn className="btn" onClick={onAdopt}>Adopt as a schedule</Btn>
<span className="muted small">Copies it into a Ham Lab schedule you can edit here. Turn the old cron job off on the Jobs tab once you are happy with it.</span>
</div>}
</div>
)
}
type ExternalJob = ZFSExternal
function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) {
const [busy, setBusy] = useState('')
const run = async () => {
if (!await askYes(`Take a snapshot of ${p.dataset} now? Snapshots past their retention are removed at the same time, as they would be on the schedule.`, 'Run now')) return
setBusy('run')
try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); onChanged() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
}
const remove = async () => {
if (!await askYes(`Remove the schedule "${p.name}"? The snapshots it already took are left exactly as they are.`, 'Remove')) return
try { await api.deleteZfsSchedule(p.id); toast('schedule removed'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const toggle = async () => {
try { await api.saveZfsSchedule({ ...p, enabled: !p.enabled }); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return (
<section className="card">
<div className="sub-head">
<span className={'dot ' + (last.error ? 'bad' : p.enabled ? 'ok' : '')} />
<span className="dname">{p.name || p.dataset}</span>
{!p.enabled && <span className="badge">paused</span>}
<span className="spacer" />
<span className="muted small">{p.enabled ? soon(next) : 'not scheduled'}</span>
</div>
<div className="props" style={{ marginTop: 6 }}>
<div><span className="k">Dataset</span><span className="mono">{p.dataset}</span></div>
<div><span className="k">When</span><span>{describe(p)}</span></div>
<div><span className="k">Cron</span><span className="mono small" title={`in ${'/etc/cron.d/hamlab-zfs'}`}>{cron}</span></div>
<div><span className="k">Last run</span><span>{ago(last.at)}{last.pruned?.length ? ` · ${last.pruned.length} expired removed` : ''}</span></div>
{last.taken && <div><span className="k">Last snapshot</span><span className="mono small">{last.taken.split('@')[1]}</span></div>}
{p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
</div>
{last.error && <div className="error" style={{ marginTop: 8 }}>{last.error}</div>}
<div className="svc-actions">
<Btn className="btn on" busy={busy === 'run'} onClick={run}>Run now</Btn>
<button className="btn" onClick={onEdit}>Edit</button>
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
<Btn className="btn danger" onClick={remove}>Remove</Btn>
</div>
</section>
)
}
const BLANK: Partial<ZFSPolicy> = { name: '', dataset: '', recursive: true, every: 'daily', at: '02:30', weekday: 0, day: 1, keep_for: '6w', keep_min: 1, enabled: true }
function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolicy>; datasets: string[]; onClose: () => void; onSaved: () => void }) {
const [f, setF] = useState<Partial<ZFSPolicy>>({ ...BLANK, ...p })
const [err, setErr] = useState('')
useEscape(onClose)
const set = (k: keyof ZFSPolicy, v: unknown) => setF((x) => ({ ...x, [k]: v }))
const save = async (e: FormEvent) => {
e.preventDefault(); setErr('')
try { await api.saveZfsSchedule({ ...f, name: (f.name || '').trim() || f.dataset }); onSaved() }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
<h3>{f.id ? 'Edit schedule' : 'New snapshot schedule'}</h3>
<label className="muted">Dataset
<select value={f.dataset} onChange={(e) => set('dataset', e.target.value)} required>
<option value="">choose…</option>
{datasets.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
</label>
<label className="muted">Name — what this schedule is for
<input value={f.name} onChange={(e) => set('name', e.target.value)} placeholder={f.dataset || 'nightly'} maxLength={64} />
</label>
<label className="muted">How often
<span className="chips">
{(['hourly', 'daily', 'weekly', 'monthly', 'custom'] as const).map((k) => (
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
))}
</span>
</label>
{f.every === 'custom' ? (
<label className="muted">Cron expression — minute hour day month weekday
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 */6 * * *" spellCheck={false} required />
</label>
) : (
<label className="muted">At
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
</label>
)}
{f.every === 'weekly' && (
<label className="muted">On
<select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}>
{WEEKDAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
</select>
</label>
)}
{f.every === 'monthly' && (
<label className="muted">Day of the month — 1 to 28, so every month has one
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
</label>
)}
<label className="muted">Snapshot name prefix — optional; the timestamp and retention follow it
<input value={f.prefix ?? ''} onChange={(e) => set('prefix', e.target.value)} placeholder="none" spellCheck={false} />
</label>
<label className="muted">Keep for — written into each snapshot's name, as zfSnap does it: 6w, 30d, 12h
<input value={f.keep_for} onChange={(e) => set('keep_for', e.target.value)} placeholder="6w" spellCheck={false} required />
</label>
<label className="muted">Always keep the newest — a floor, whatever their age
<input type="number" min={0} max={99} value={f.keep_min} onChange={(e) => set('keep_min', Number(e.target.value))} />
</label>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={!!f.recursive} onChange={(e) => set('recursive', e.target.checked)} style={{ width: 'auto' }} />
Recursive — one atomic snapshot of this dataset and everything under it
</label>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
Enabled
</label>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={!f.dataset || !f.keep_for}>Save</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
// adopt reads an existing cron job into the fields of a new schedule, so replacing it is a
// matter of checking the numbers rather than retyping them. zfSnap's own flags are read
// where they can be: -a <age> is the retention, -r is recursive.
function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
const cmd = x.command
const keep = /-a\s+(\d+[mhdwMy])/.exec(cmd)?.[1] ?? /--\s*(\d+[mhdw])\b/.exec(cmd)?.[1] ?? '6w'
const f = x.schedule.trim().split(/\s+/)
const min = Number(f[0]), hour = Number(f[1])
const simple = f.length === 5 && Number.isInteger(min) && Number.isInteger(hour) && f[2] === '*' && f[3] === '*' && f[4] === '*'
const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset
return {
...BLANK, name: `${x.tool} (adopted)`, dataset: ds,
recursive: /\s-\w*r/.test(cmd) || true,
every: simple ? 'daily' : 'custom',
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
cron: simple ? '' : x.schedule.trim(),
keep_for: keep, keep_min: 1, enabled: true,
}
}
// ---- page ----
export default function ZFS() {
const [ov, setOv] = useState<ZFSOverview | null>(null)
const [err, setErr] = useState('')
const [editing, setEditing] = useState<Partial<ZFSPolicy> | null>(null)
const rate = useRefreshMs()
const load = useCallback(async () => {
try { setOv(await api.zfsOverview()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(load, Math.max(5000, rate * 10)); return () => clearInterval(t) }, [load, rate])
if (err && !ov) return <div className="dash"><div className="error">{err}</div></div>
if (!ov) return <div className="dash"><Loading label="Reading ZFS…" /></div>
const zfs: ZFSInfo = ov.zfs
const datasets = zfs.datasets.filter((d) => d.type !== 'volume').map((d) => d.name)
const mine = ov.policies.filter((x) => x.policy.enabled).length
if (!zfs.available) return <div className="dash"><div className="notice">{zfs.message || 'No ZFS on this host.'}</div></div>
return (
<div className="dash">
<div className="dash-bar"><h2 className="section">Pools</h2></div>
{zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
</div>
{ov.external && <ExternalNotice x={ov.external} mine={mine} onAdopt={() => setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />}
{ov.policies.length === 0 && !ov.external && <div className="notice">Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.</div>}
<div className="cards">{ov.policies.map(({ policy, cron, next, last }) => (
<ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
))}</div>
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshots</h2><span className="spacer" />
<span className="muted small">{zfs.datasets.reduce((a, d) => a + d.snapshots, 0)} across {zfs.datasets.length} datasets · open one to browse, take or delete</span>
</div>
<section className="card ds-table">
<div className="ds-row head">
<span>Dataset</span><span className="num">Snapshots</span><span className="num">They use</span>
<span className="num">Dataset uses</span><span className="num">Referenced</span><span>Mount</span>
</div>
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
</section>
{editing && <ScheduleDialog p={editing} datasets={datasets.length ? datasets : zfs.pools.map((p) => p.name)} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
</div>
)
}
+107 -7
View File
@@ -55,7 +55,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
} }
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string } export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
export interface Me { demo_url?: string; addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string } export interface Me { addr?: string; host: string; user: string; account?: string; guest?: boolean; guest_until?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
export interface PushResult { name: string; ok: boolean; version?: string; error?: string } export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
// What a machine is called on screen: the hostname it reports, which follows a rename within // What a machine is called on screen: the hostname it reports, which follows a rename within
// a poll. The name it was added under stays its identity — the URL, its accent colour, and // a poll. The name it was added under stays its identity — the URL, its accent colour, and
@@ -70,7 +70,7 @@ export interface Temp { chip: string; label: string; c: number; max?: number; cr
export interface Fan { chip: string; label: string; rpm: number } export interface Fan { chip: string; label: string; rpm: number }
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number } export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number } export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
export interface FS { mount: string; type: string; size: number; used: number } export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string }
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string } export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number } export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number } export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
@@ -90,7 +90,6 @@ export interface Series {
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched // Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label"). // off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number } export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number }
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] }
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean } export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> } export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
@@ -110,6 +109,39 @@ export interface StorageDevice { name: string; kind: string; model?: string; ven
export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean } export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean }
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number } export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number }
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number } export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
export interface UpdateJob { id: string; name: string; stack: string; machine?: string; services?: string[]; cron: string; apply: boolean; enabled: boolean }
export interface UpdateJobRow { job: UpdateJob; next?: string; last?: { at?: string; taken?: string; error?: string } }
export interface MachineUpdates { images: ImageCheck[]; checked: number; stacks: Record<string, string[]> }
export interface DockerHealthProbe { Start: string; End: string; ExitCode: number; Output: string }
export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] }
export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string }
// ---- hardware (the Hardware tab: what the machine is made of) ----
export interface HWSystem { model?: string; serial?: string; family?: string; kernel?: string; arch?: string; virt?: string }
export interface HWSlot { designation: string; type?: string; usage?: string; length?: string; width?: string; bus?: string; device?: string }
export interface HWBoard {
maker?: string; product?: string; version?: string; serial?: string; asset_tag?: string; type?: string; location?: string
chassis?: string; chassis_maker?: string; bios?: string; bios_date?: string; bios_revision?: string; rom_size?: string
uefi?: boolean; slots?: HWSlot[]
}
export interface HWCache { level: string; size: string }
export interface HWCPU {
model?: string; vendor?: string; arch?: string; sockets?: number; socket_names?: string[]
cores?: number; threads?: number; threads_per_core?: number; min_mhz?: number; max_mhz?: number
family?: string; model_id?: string; stepping?: string; microcode?: string; virtualization?: string
caches?: HWCache[]; flags?: string[]
}
export interface HWModule {
locator: string; bank?: string; empty?: boolean; size?: number; type?: string; form_factor?: string
speed_mts?: number; running_mts?: number; maker?: string; part?: string; serial?: string; rank?: number; voltage?: string
}
export interface HWMemory { total: number; installed?: number; max_capacity?: number; slots?: number; filled?: number; ecc?: string; modules?: HWModule[] }
export interface HWDrive { name: string; kind: string; model?: string; vendor?: string; serial?: string; firmware?: string; tran?: string; size: number; rotational?: boolean; removable?: boolean }
export interface HWGPU { model?: string; vendor?: string; slot?: string; driver?: string; node?: string; memory?: number; class?: string }
export interface Hardware { at: string; system: HWSystem; board: HWBoard; cpu: HWCPU; memory: HWMemory; drives: HWDrive[] | null; gpus: HWGPU[] | null; notes?: string[] }
export interface DockerLogLine { t: string; m: string; err?: boolean }
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] } export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean } export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean } export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
@@ -126,6 +158,9 @@ export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record<
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean } export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean } export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string } export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
// A guest login: a full account with a clock on it, gone at `expires` or when revoked.
export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean }
export interface FreshGuest { name: string; password: string; expires: string; minutes: number }
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string } export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> } export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
@@ -134,6 +169,44 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number } export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number } export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
// ---- the Network tab ----
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
export interface Iface {
name: string; kind: string; mac?: string; mtu: number; up: boolean; carrier: boolean
speed_mbps?: number; duplex?: string; driver?: string; signal_dbm?: number
addrs: IfAddr[]; rx_total: number; tx_total: number; default?: boolean
}
export interface NetReport { at: string; host?: string; ifaces: Iface[]; gateway?: string; gateway_via?: string; dns?: string[]; search?: string[] }
export interface Outside { at: string; ip?: string; colo?: string; country?: string; error?: string }
export interface SpeedResult {
at: string; down_mbps: number; up_mbps: number; latency_ms: number; jitter_ms: number
down_bytes: number; up_bytes: number; took_sec: number; colo?: string; ip?: string; source?: string; note?: string; error?: string
}
// A test in progress, or the last one to have finished. `done` is 0–1 across the whole test
// and `mbps` is the live figure for the phase under way.
export interface SpeedRun { started: string; running: boolean; phase: string; done: number; mbps: number; bytes: number; result?: SpeedResult }
export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedRun; history: SpeedResult[]; method: string }
// ---- the VPN tab ----
// A peer is a device that dials in (on a server tunnel) or the way out (on a client one).
// `online` is a handshake in the last few minutes, not a connection: WireGuard has none.
export interface VPNPeer {
name?: string; public_key: string; endpoint?: string; allowed_ips: string[]
handshake?: string; online: boolean; rx: number; tx: number; keepalive?: number; psk?: boolean
}
export interface VPNTunnel {
name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number
fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: string
}
export interface VPNReport { at: string; tunnels: VPNTunnel[]; tool: boolean; root: boolean; note?: string }
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; cron: string; next?: string; last: ZFSRun }[]; external?: ZFSExternal | null }
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean } export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
export interface FileInfo extends FileEntry { path: string; text: boolean } export interface FileInfo extends FileEntry { path: string; text: boolean }
@@ -180,6 +253,15 @@ export const api = {
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'), storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`), smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`), smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
// Always the hub's: it holds every machine's KVM, so the tab works when the machine does not.
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
deleteZfsSnapshot: (name: string, recursive: boolean) => req<void>('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }),
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`), zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`), zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`),
zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }), zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }),
@@ -208,13 +290,14 @@ export const api = {
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`), serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true), historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true),
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true), setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true), login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true),
users: () => req<User[]>('GET', '/api/users', undefined, true), users: () => req<User[]>('GET', '/api/users', undefined, true),
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true), addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true), removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true), resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
guests: () => req<Guest[]>('GET', '/api/guests', undefined, true),
createGuest: (minutes: number) => req<FreshGuest>('POST', '/api/guests', { minutes }, true),
revokeGuest: (name: string) => req<void>('DELETE', `/api/guests/${encodeURIComponent(name)}`, undefined, true),
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true), sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true), revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true), revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
@@ -231,10 +314,25 @@ export const api = {
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }), installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true), tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
hardware: (refresh = false) => req<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
network: () => req<NetworkInfo>('GET', '/api/network'),
vpn: () => req<VPNReport>('GET', '/api/vpn'),
speedtest: () => req<SpeedRun>('GET', '/api/network/speedtest'),
startSpeedtest: () => req<SpeedRun>('POST', '/api/network/speedtest'),
cancelSpeedtest: () => req<void>('POST', '/api/network/speedtest/cancel'),
docker: () => req<DockerInfo>('GET', '/api/docker'), docker: () => req<DockerInfo>('GET', '/api/docker'),
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`), dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`), dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`), dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`),
updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'),
saveUpdateJob: (j: Partial<UpdateJob>) => req<UpdateJob>('PUT', '/api/docker/schedules', j),
deleteUpdateJob: (id: string) => req<void>('DELETE', `/api/docker/schedules/${encodeURIComponent(id)}`),
runUpdateJob: (id: string) => req<{ output: string }>('POST', `/api/docker/schedules/${encodeURIComponent(id)}/run`),
dockerHealth: (id: string) => req<DockerHealth>('GET', `/api/docker/containers/${encodeURIComponent(id)}/health`),
dockerEvents: (id: string, since = '7d') => req<{ events: DockerEvent[]; since: string; kept: number; capacity: number }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/events?since=${since}`),
dockerExec: (id: string) => req<{ id: string; name: string }>('POST', `/api/docker/containers/${encodeURIComponent(id)}/exec`, {}),
dockerLimits: (id: string, p: { memory?: string; cpus?: string; restart?: string }) => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/limits`, p),
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'), stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'), stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'),
createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p), createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
@@ -244,11 +342,13 @@ export const api = {
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }), pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }), removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'), pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
removeNetwork: (name: string) => req<void>('POST', '/api/docker/networks/remove', { name }),
pruneNetworks: () => req<{ result: string }>('POST', '/api/docker/networks/prune'),
stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`), stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`), stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`), stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }), saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`), stackAction: (name: string, action: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true), alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true), saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true), testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
+134
View File
@@ -0,0 +1,134 @@
// A cron expression in English. Not a parser for running jobs — internal/cron does that,
// and computes the next run — just a sentence for the person reading the schedule, who
// should not have to decode "15 3 * * 1" in their head.
//
// Anything it can't say plainly it declines to describe, and the caller shows the
// expression instead. A confident wrong sentence is worse than five fields.
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const DAY_ALIAS: Record<string, number> = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
const MONTH_ALIAS: Record<string, number> = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }
// "@daily" and friends, which crontab(5) accepts in place of the five fields.
const ALIASES: Record<string, string> = {
'@yearly': 'once a year, on 1 January at midnight',
'@annually': 'once a year, on 1 January at midnight',
'@monthly': 'monthly, on the 1st at midnight',
'@weekly': 'weekly, on Sunday at midnight',
'@daily': 'daily at midnight',
'@midnight': 'daily at midnight',
'@hourly': 'hourly, on the hour',
'@reboot': 'at every boot',
}
const clock = (h: number, m: number) => {
if (m === 0 && h === 0) return 'midnight'
if (m === 0 && h === 12) return 'noon'
const ampm = h < 12 ? 'am' : 'pm'
const h12 = h % 12 === 0 ? 12 : h % 12
return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2, '0')}${ampm}`
}
// Monday-to-Friday and Saturday-Sunday are worth naming; the rest get listed.
function dayNames(d: number[]): string {
const set = d.join(',')
if (set === '1,2,3,4,5') return 'weekdays'
if (set === '0,6') return 'weekends'
return list(d.map((x) => DAYS[x]))
}
const list = (parts: string[]) =>
parts.length <= 1 ? (parts[0] ?? '') : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
const ordinal = (n: number) => {
const s = ['th', 'st', 'nd', 'rd'][(n % 100 - 20) % 10] ?? ['th', 'st', 'nd', 'rd'][n % 100] ?? 'th'
return `${n}${s}`
}
// values of one field, or null when it is a form this doesn't describe
function values(field: string, min: number, max: number, alias: Record<string, number> = {}): number[] | null {
const out = new Set<number>()
for (const part of field.split(',')) {
const [range, stepText] = part.split('/')
const step = stepText === undefined ? 1 : Number(stepText)
if (!Number.isInteger(step) || step < 1) return null
let lo: number, hi: number
if (range === '*') {
lo = min; hi = max
} else {
const ends = range.split('-').map((v) => {
const n = alias[v.toLowerCase()] ?? Number(v)
return Number.isInteger(n) ? n : NaN
})
if (ends.some(Number.isNaN) || ends.length > 2) return null
lo = ends[0]
hi = ends.length === 2 ? ends[1] : (stepText === undefined ? ends[0] : max)
}
if (lo < min || hi > max || lo > hi) return null
for (let v = lo; v <= hi; v += step) out.add(v)
}
return [...out].sort((a, b) => a - b)
}
const everyValue = (v: number[], min: number, max: number) => v.length === max - min + 1
// stepOf reports the interval when a field is a plain "every n" over its whole range.
function stepOf(v: number[], min: number, max: number): number | null {
if (v.length < 2 || v[0] !== min) return null
const step = v[1] - v[0]
for (let i = 1; i < v.length; i++) if (v[i] - v[i - 1] !== step) return null
return v[v.length - 1] + step > max ? step : null
}
/** describeCron turns "15 3 * * *" into "daily at 3:15am", or returns "" if it can't. */
export function describeCron(expr: string): string {
const raw = (expr ?? '').trim()
if (!raw) return ''
if (raw.startsWith('@')) return ALIASES[raw.toLowerCase()] ?? ''
const f = raw.split(/\s+/)
if (f.length !== 5) return '' // 6-field (seconds) and Quartz forms aren't ours to guess at
const [minF, hourF, domF, monF, dowF] = f
const mins = values(minF, 0, 59)
const hours = values(hourF, 0, 23)
const doms = values(domF, 1, 31)
const mons = values(monF, 1, 12, MONTH_ALIAS)
const dows = values(dowF.replace(/7/g, '0'), 0, 6, DAY_ALIAS)
if (!mins || !hours || !doms || !mons || !dows) return ''
// --- when in the day ---
let time: string
const everyMin = everyValue(mins, 0, 59)
const everyHour = everyValue(hours, 0, 23)
const minStep = stepOf(mins, 0, 59)
const hourStep = stepOf(hours, 0, 23)
if (everyMin && everyHour) time = 'every minute'
else if (everyMin) time = `every minute of ${list(hours.map((h) => clock(h, 0).replace(/(am|pm)/, '$1')))}`
else if (minStep && everyHour) time = `every ${minStep} minutes`
else if (minStep) time = `every ${minStep} minutes, ${hours.length === 1 ? `during the ${clock(hours[0], 0)} hour` : `during ${list(hours.map((h) => clock(h, 0)))}`}`
else if (everyHour && mins.length === 1) time = mins[0] === 0 ? 'hourly, on the hour' : `hourly, at ${mins[0]} past`
else if (hourStep && mins.length === 1) time = `every ${hourStep} hours` + (mins[0] === 0 ? ', on the hour' : `, at ${mins[0]} past`)
else if (mins.length === 1 && hours.length >= 1) time = `at ${list(hours.map((h) => clock(h, mins[0])))}`
else {
const times: string[] = []
for (const h of hours) for (const m of mins) times.push(clock(h, m))
if (times.length > 6) return '' // a wall of times is not a translation
time = `at ${list(times)}`
}
// --- which days ---
const allDom = everyValue(doms, 1, 31)
const allDow = everyValue(dows, 0, 6)
const allMon = everyValue(mons, 1, 12)
let days = ''
if (allDom && allDow) days = ''
else if (allDom) days = `on ${dayNames(dows)}`
else if (allDow) days = `on the ${list(doms.map(ordinal))}`
// crontab(5): with both set, the job runs when *either* matches — worth saying out loud
else days = `on the ${list(doms.map(ordinal))}, and on ${dayNames(dows)}`
const months = allMon ? '' : `in ${list(mons.map((m) => MONTHS[m - 1]))}`
const daily = !days && !months
const head = daily ? (time.startsWith('every') || time.startsWith('hourly') ? '' : 'daily ') : ''
return [head + time, days, months].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
}
+19
View File
@@ -0,0 +1,19 @@
// Sizes and rates, shown the way the rest of the console shows them. These lived in
// Dashboard.tsx until that page was retired (2026-09-23); Disks, Files and Space all use
// them, so they belong on their own.
// fmtBytes is binary — what a disk or a file actually occupies.
export function fmtBytes(b: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let i = 0
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
}
// fmtRate is decimal — how throughput is quoted everywhere else.
export function fmtRate(bps: number): string {
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
let i = 0
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
}
+24
View File
@@ -0,0 +1,24 @@
// A guest login's clock. That is the whole difference between a guest and an account: it
// can do everything, until it can't, so what the console has to show is how long is left.
import { useEffect, useState } from 'react'
// fmtLeft says how long is left, in the roundest terms that are still honest.
export function fmtLeft(ms: number): string {
const min = Math.floor(ms / 60000)
if (min >= 120) return `${Math.floor(min / 60)} h ${min % 60} min`
if (min >= 1) return `${min} min`
return 'under a minute'
}
// useTimeLeft is the milliseconds until `until`, re-rendering the caller every 15 seconds so
// the number keeps up. 0 when there is no expiry, which is every normal account.
export function useTimeLeft(until?: string): number {
const [, tick] = useState(0)
const active = !!until
useEffect(() => {
if (!active) return
const t = setInterval(() => tick((n) => n + 1), 15000)
return () => clearInterval(t)
}, [active])
return until ? Math.max(0, new Date(until).getTime() - Date.now()) : 0
}
+18 -1
View File
@@ -5,8 +5,25 @@ import { useEffect, useState } from 'react'
const KEY = 'hamlab.rail' const KEY = 'hamlab.rail'
// Pages that were renamed, so an order saved before it still puts the page where it was put:
// Top became Processes, Space became Usage, Cron became Jobs. A page that was removed
// outright (Dashboard) is not here — orderBy drops ids that match no page.
const RENAMED: Record<string, string> = { top: 'procs', space: 'usage', cron: 'jobs' }
export function getRailOrder(): string[] { export function getRailOrder(): string[] {
try { const v = JSON.parse(localStorage.getItem(KEY) ?? '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [] } catch { return [] } let raw: unknown
try { raw = JSON.parse(localStorage.getItem(KEY) ?? '[]') } catch { return [] }
if (!Array.isArray(raw)) return []
const seen = new Set<string>()
const out: string[] = []
for (const x of raw) {
if (typeof x !== 'string') continue
const id = RENAMED[x] ?? x
if (seen.has(id)) continue // a merged page keeps the earlier of the two slots
seen.add(id)
out.push(id)
}
return out
} }
export function setRailOrder(ids: string[], save = true) { export function setRailOrder(ids: string[], save = true) {
+2 -18
View File
@@ -1,7 +1,7 @@
// The stock Dashboard modules were written against one Sample object. They now read the // The stock Dashboard modules were written against one Sample object. They now read the
// sensor API like everything else; this file is the seam — it rebuilds that object from // sensor API like everything else; this file is the seam — it rebuilds that object from
// sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds). // sensor values (the live stream) and sensor metadata (the catalog: labels, thresholds).
import { Sample, Series } from './api' import { Sample } from './api'
import { Sensor } from './sensors' import { Sensor } from './sensors'
export type Values = Record<string, number | boolean | string> export type Values = Record<string, number | boolean | string>
@@ -19,7 +19,6 @@ export function mergeValues(parts: unknown[]): Values {
return out return out
} }
// The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature. // The curves they keep: CPU, memory, per-NIC and per-disk rates, every temperature.
export const MONITOR_HISTORY = 'cpu.usage,mem.used,net.*.rx,net.*.tx,disk.*.read,disk.*.write,disk.*.util,temp.*'
const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0) const num = (v: Values, id: string) => (typeof v[id] === 'number' ? (v[id] as number) : 0)
const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '') const str = (v: Values, id: string) => (typeof v[id] === 'string' ? (v[id] as string) : '')
@@ -52,7 +51,7 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })), fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })), net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })), disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used') })), fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk })),
ups: v['ups.battery'] === undefined ? null : { ups: v['ups.battery'] === undefined ? null : {
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'), driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'), on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
@@ -63,18 +62,3 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
// The modules' history: one shared time axis (the CPU's), every other curve right-aligned // The modules' history: one shared time axis (the CPU's), every other curve right-aligned
// to it and zero-padded on the left where the sensor is younger than the ring. // to it and zero-padded on the left where the sensor is younger than the ring.
export function seriesOf(hist: { id: string; t: number[]; v: number[] }[], cat: Catalog): Series {
const by = new Map(hist.map((h) => [h.id, h]))
const t = by.get('cpu.usage')?.t ?? []
const n = t.length
const fit = (id: string) => { const v = (by.get(id)?.v ?? []).slice(-n); return [...new Array(Math.max(0, n - v.length)).fill(0), ...v] as number[] }
const out: Series = { t: [...t], cpu: fit('cpu.usage'), mem: fit('mem.used'), net: {}, disk: {}, temp: {} }
for (const h of hist) {
const l = cat.get(h.id)?.labels
const stem = h.id.slice(0, h.id.lastIndexOf('.'))
if (h.id.startsWith('net.') && l?.iface) out.net[l.iface] ??= { rx: fit(stem + '.rx'), tx: fit(stem + '.tx') }
else if (h.id.startsWith('disk.') && l?.device) out.disk[l.device] ??= { r: fit(stem + '.read'), w: fit(stem + '.write'), u: fit(stem + '.util') }
else if (h.id.startsWith('temp.') && l?.chip !== undefined) out.temp[`${l.chip}/${l.label}`] = fit(h.id)
}
return out
}
+87 -3
View File
@@ -20,6 +20,12 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; } .login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
.login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; } .login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; }
.login button:disabled { opacity: .5; } .login button:disabled { opacity: .5; }
/* the password field and its show/hide eye, which sits inside the field's right edge */
.login .pwfield { position: relative; display: flex; }
.login .pwfield input { flex: 1; min-width: 0; padding-right: 44px; }
.login button.pweye { position: absolute; right: 1px; top: 1px; bottom: 1px; width: 42px; padding: 0; background: none; color: var(--muted); font-weight: 400; border-radius: 0 6px 6px 0; }
.login button.pweye:hover, .login button.pweye[aria-pressed="true"] { color: var(--fg); }
.login button.pweye:disabled { opacity: .4; }
.error { color: var(--bad); font-size: 13px; } .error { color: var(--bad); font-size: 13px; }
/* layout */ /* layout */
@@ -94,6 +100,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; } .tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
.tile .spark { margin-top: 8px; } .tile .spark { margin-top: 8px; }
.card .empty { color: var(--muted); font-size: 13px; } .card .empty { color: var(--muted); font-size: 13px; }
/* the out-of-date list: image, what the registry said, which stacks, and the button */
.upd-list .row { grid-template-columns: minmax(130px, 1.3fr) minmax(110px, 1.5fr) auto auto; }
.upd-list .row .btn { padding: 2px 10px; }
@media (max-width: 560px) { .upd-list .row { grid-template-columns: 1fr auto; } .upd-list .row > :nth-child(2) { grid-column: 1 / -1; } }
.row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; } .row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; }
.row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; } .row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; }
.num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; } .num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }
@@ -163,11 +173,28 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.svc:not(.has-sel) .svc-list, .cron:not(.has-sel) .cron-list { width: auto; flex: 1 1 auto; border-right: 0; } .svc:not(.has-sel) .svc-list, .cron:not(.has-sel) .cron-list { width: auto; flex: 1 1 auto; border-right: 0; }
.svc:not(.has-sel) .svc-empty, .cron:not(.has-sel) .svc-empty { display: none; } .svc:not(.has-sel) .svc-empty, .cron:not(.has-sel) .svc-empty { display: none; }
} }
/* A tab that is a page rather than a list-and-detail — Docker's Updates — stacks: the tab
strip on top, the page below at full width. Otherwise the empty list column takes half
the window and the page is squeezed into the other half, which looks like a phone. */
.svc.wide { flex-direction: column; }
/* :not(.has-sel) to outrank the wide-window rule above, which grows the list to fill */
.svc.wide:not(.has-sel) .svc-list { width: auto; flex: 0 0 auto; border-right: 0; border-bottom: 1px solid var(--border); }
.svc.wide .svc-rows { display: none; }
.svc.wide > .svc-detail { flex: 1 1 auto; min-height: 0; }
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; } .svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; } .svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; } .props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; }
.props > div { display: flex; gap: 8px; min-width: 0; } .props .k { color: var(--muted); width: 80px; flex-shrink: 0; } .props > div { display: flex; gap: 8px; min-width: 0; } .props .k { color: var(--muted); width: 80px; flex-shrink: 0; }
.props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; } .props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; }
/* One property per row, label above its value. For panes like Inspect, whose values are
long paths and lists — side by side they were half truncated and read as columns. */
.props.stacked { grid-template-columns: 1fr; gap: 10px 0; }
.props.stacked > div { flex-direction: column; gap: 2px; }
.props.stacked .k { width: auto; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
.props.stacked span:not(.k) { overflow: visible; white-space: normal; overflow-wrap: anywhere; }
.props.stacked .k { text-transform: none; } .props.stacked > div > .k { text-transform: uppercase; }
.props.stacked .k .muted { text-transform: none; letter-spacing: 0; }
.props.stacked span:not(.k) > div + div { margin-top: 3px; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); } .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
.tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; } .tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); } .tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
@@ -285,14 +312,21 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
the fallback above stays a fallback. */ the fallback above stays a fallback. */
.disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); } .disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); }
.datasets { padding: 0; overflow-x: auto; } .datasets { padding: 0; overflow-x: auto; }
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } /* the columns and gaps add up to 692: at 640 the row ended before its own cells did */ .ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 90px 90px 90px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; }
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } .ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
.ds-row .num { text-align: right; } .ds-row .num { text-align: right; }
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; } .ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; } .ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
.snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; } .snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; }
.snap { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; } .snap { display: grid; grid-template-columns: minmax(150px, 1fr) 150px 120px 70px auto; gap: 10px; align-items: center; font-size: 12px; padding: 2px 0; border-bottom: 1px solid var(--border); }
.snap .snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.snap .snap-when, .snap .snap-exp { white-space: nowrap; }
.snap .snap-exp.warn { color: var(--s2); }
.snap .num { text-align: right; }
.snap .snap-del { padding: 0 6px; font-size: 11px; opacity: .55; }
.snap:hover .snap-del { opacity: 1; }
@media (max-width: 800px) { .snap { grid-template-columns: minmax(120px, 1fr) auto auto; } .snap .snap-when, .snap .snap-exp { display: none; } }
/* files */ /* files */
.files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); } .files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
@@ -388,10 +422,29 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; } .mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); } .mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); } .mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
/* Network tab: the speed test's four figures, large enough to read at arm's length, and the
bar that fills while one is running. */
.net .speedgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
@media (max-width: 560px) { .net .speedgrid { grid-template-columns: repeat(2, 1fr); } }
.net .speedfig { min-width: 0; }
.net .speedfig .label { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.net .speedfig .big { font-size: 28px; font-weight: 600; line-height: 1.15; font-variant-numeric: tabular-nums; }
.net .speedfig .big i { font-size: 12px; font-weight: 400; font-style: normal; color: var(--muted); margin-left: 4px; }
.net .speedfig.live .big { color: var(--accent); }
.net .speedbar { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
.net .card.speedtest .svc-actions { margin-top: 10px; }
.net .card.speedtest .method { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
.net .card.iface { margin-top: 8px; }
.net .card + .dash-bar { margin-top: 14px; }
.addform { display: flex; flex-direction: column; gap: 10px; } .addform { display: flex; flex-direction: column; gap: 10px; }
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } .addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; } .addform input, .addform select { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
.header .badge.bad { color: var(--bad); border-color: var(--bad); } .header .badge.bad { color: var(--bad); border-color: var(--bad); }
/* a guest login says so, and how long it has, where the power button would be */
.badge.guest { color: var(--accent2); border-color: var(--accent2); text-transform: none; margin-left: 0; white-space: nowrap; }
.card.fresh { border-color: var(--accent2); }
.card.fresh .mono { user-select: all; }
/* settings + themes */ /* settings + themes */
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } .themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
@@ -720,3 +773,34 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; } .space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; }
.space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; } .space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; }
} }
/* the Processes tab's drive rows and NIC switcher */
.t-disk .t-model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 14ch; margin-left: 6px; }
.t-disk .t-temp { margin-right: 8px; color: var(--t-blue, var(--accent2)); }
.t-disk .t-mounts { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.t-ifs { display: inline-flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
.t-ifs .t-if { padding: 0 4px; border-radius: 3px; color: var(--muted); }
.t-ifs .t-if:hover { color: var(--fg); background: var(--bg3); }
.t-ifs .t-if.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); }
/* ZFS: the schedule dialog's controls match the rest of the modal forms */
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
.modal .chips { margin-top: 2px; }
/* ---- KVM: a device's own web page, framed ---- */
.kvm { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
.kvm-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-wrap: wrap; }
.kvm-bar .kvm-url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 28ch; }
.kvm-frame { flex: 1; min-height: 0; background: #000; position: relative; }
.kvm-frame iframe { width: 100%; height: 100%; border: 0; display: block; background: #000; }
.kvm-frame.full { background: #000; }
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; }
.kvm-frame.expanded iframe { height: 100%; }
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
+38 -1
View File
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from 'react' import { Component, ReactNode, useEffect, useRef, useState } from 'react'
// ---- spinners ---- // ---- spinners ----
export function Spinner({ size = 14 }: { size?: number }) { export function Spinner({ size = 14 }: { size?: number }) {
@@ -14,6 +14,12 @@ export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; ti
) )
} }
// A filled bar. Red past 95%, amber past 85%, unless the caller says otherwise.
export function Meter({ frac, cls }: { frac: number; cls?: string }) {
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
export function Loading({ label = 'Loading…' }: { label?: string }) { export function Loading({ label = 'Loading…' }: { label?: string }) {
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div> return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
} }
@@ -149,3 +155,34 @@ export async function okToLeave(): Promise<boolean> {
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true } if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
return false return false
} }
// A page that throws takes the whole console down with it: React unmounts the tree, and what
// is left is a blank window with no clue in it. The VPN tab did exactly that on a machine
// whose tunnel list came back null. So each page is rendered inside one of these, and a fault
// costs that page rather than the session — the rail, the switcher and every other tab still
// work, and the error says which page and what it said.
export class PageBoundary extends Component<{ page: string; children: ReactNode }, { err: Error | null }> {
constructor(props: { page: string; children: ReactNode }) {
super(props)
this.state = { err: null }
}
static getDerivedStateFromError(err: Error) { return { err } }
componentDidCatch(err: Error) { console.error('page failed:', err) }
// A different page is a different mount; clear the fault when the page changes.
componentDidUpdate(prev: { page: string }) { if (prev.page !== this.props.page && this.state.err) this.setState({ err: null }) }
render() {
if (!this.state.err) return this.props.children
return (
<div className="dash">
<div className="dash-bar"><h2 className="section">{this.props.page}</h2></div>
<section className="card">
<div className="error">This page hit a fault and stopped drawing. The rest of the console is unaffected.</div>
<div className="muted small mono" style={{ marginTop: 8 }}>{String(this.state.err)}</div>
<div className="svc-actions" style={{ marginTop: 10 }}>
<button className="btn" onClick={() => this.setState({ err: null })}>Try again</button>
</div>
</section>
</div>
)
}
}