Author SHA1 Message Date
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
10 changed files with 757 additions and 134 deletions
+104 -68
View File
@@ -293,11 +293,66 @@ something long in one, switch away and back, refresh — it's all still there.
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.** Every schedule answers *what would it delete?* with the
list, sizes and dates, before Run now touches anything; only names that parse as a stamp
and a TTL are candidates, so a snapshot taken by hand is invisible to it; and a snapshot
deleted by hand needs its full name typed back. The scheduler is a 30 s tick in the daemon,
and `Run now` calls the same code, so the button and the timer cannot drift apart.
**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,
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
@@ -487,11 +542,12 @@ something long in one, switch away and back, refresh — it's all still there.
## To do
- **Deprecate copyparty** — asked for 2026-09-23, not started. `copyparty` on `grid`
(`copyparty.service`, `:3923`, `/etc/copyparty.conf`, serving all of `/tank` as user
`core` with one account behind NPM) overlaps the **Files** tab almost entirely. The aim
is to stop the service and keep only Ham Lab. What Files must gain first, in the order
that matters:
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
polish, and deprecating copyparty), merged 2026-09-24: they are the same tab and the same
work. `copyparty` on `grid` (`copyparty.service`, `:3923`, `/etc/copyparty.conf`, serving
all of `/tank` as user `core` with one account behind NPM) overlaps **Files** almost
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
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
@@ -511,26 +567,44 @@ 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
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
(migrating every stack off Dockhand's data dir, then removing the container) is
`hamlet`'s `docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator",
agreed 2026-09-20. This entry is the *app* half only: what Ham Lab must do before that
migration can start. Its Stacks page already creates, edits, deploys, updates, restarts,
stops, downs and deletes stacks on every machine, so the gaps are Dockhand's extras:
- **Git-sourced stacks.** Dockhand's `stack_sources` table points a stack at a repo and
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.
- **Scheduled image updates.** Ham Lab checks for new images only when asked (a registry
request Docker Hub counts) and never updates by itself. If anything relies on Dockhand
updating on a schedule, that must move here or be dropped on purpose.
- **Webhooks / per-host environments**, if either is in use — inventory before deciding.
- **Deploying from a host path without an orchestrator container.** Ham Lab already runs
compose inside `dockhand` because that is how the stacks were deployed; once they move
to a neutral home the "via" path is unused for them, but a stack deployed to `grid` or
the Mac still goes over `tcp://<node>:2375` from the hub. Decide whether Ham Lab should
instead ask the node's own Ham Lab to deploy locally, which would end the `*:2375`
exposure (its own item in `docs/pending.md`).
- *Not app work, but the trigger:* nothing here blocks starting the migration except the
- **Grow the Docker tab until Dockhand can go** — asked for 2026-09-23 and widened
2026-09-24 to take in Docker tab polish and feature work, on the same reasoning as the
Files item: it is one tab, and the gaps that block retiring the old tool and the things
that would simply make it better are the same body of work. The *homelab* half —
migrating every stack off Dockhand's data dir, then removing the container — is `hamlet`'s
`docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", agreed
2026-09-20. This entry is the *app* half.
Where it stands: Stacks already creates, edits, deploys, updates, restarts, stops, downs
and deletes stacks on every machine, and Containers does start/stop/restart, logs that
follow, inspect, and the images/volumes/networks tables. So this is not a rewrite.
- **What Dockhand still does that Ham Lab does not.**
- *Git-sourced stacks.* Dockhand's `stack_sources` table points a stack at a repo and
pulls it. Ham Lab has no equivalent. Decide whether to build it or drop it — most
stacks here have no row in that table anyway, so dropping it may be the honest answer.
- *Scheduled image updates.* Ham Lab checks for new images only when asked, since each
check is a registry request Docker Hub counts, and never updates by itself. If
anything relies on Dockhand updating on a schedule, that must move here or be dropped
on purpose. Note the Jobs tab and the ZFS schedules already show the shape this would
take: a cron line Ham Lab owns, not a ticker in the daemon.
- *Webhooks and per-host environments*, if either is in use. Inventory before deciding.
- **Known gaps in the tab itself**, worth closing whether or not Dockhand goes:
- *Removing networks* — volumes and images can be pruned and removed, networks cannot.
- *Container detail is thin next to the stack view.* No per-container resource limits,
restart-policy editing, or exec-into-container from the tab (the Terminal tab is the
workaround). Decide which of these belong here at all.
- *Logs are polled with `--since` rather than followed over a held connection.* That was
deliberate and works, but a long tail is choppier than the Services tab's live tail,
which does hold a connection. Worth reconciling the two.
- *Image update checks are remembered six hours and asked for per stack*, so there is no
one view of "what is out of date across the fleet".
- **The `*:2375` question, which this is the moment to settle.** Ham Lab runs compose
inside `dockhand` because that is how the stacks were deployed; once they move to a
neutral home that path is unused for them, but 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.
- *Not app work, but the trigger:* nothing above blocks starting the migration except the
inventory. Do that first.
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
@@ -544,44 +618,6 @@ something long in one, switch away and back, refresh — it's all still there.
`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.
- **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started.
General polish and ease of use. The **Deprecate copyparty**
item names the heavier pieces (multi-select and bulk actions, folder download, resumable
uploads, a gallery, search) — worth doing the two together, since they are the same tab.
- **A KVM tab** — asked for 2026-09-24, not started. **Dead simple is the brief**: paste one
HTTP link, the tab shows whatever UI that KVM serves, and the keyboard goes to it. **This
is KVM the keyboard-video-mouse switch, not KVM the hypervisor** — the Virtualization item
above means the other one.
- **The device**: a **Sipeed NanoKVM** attached to `grid` at **`10.0.0.217`** (MAC
`48:da:35:6f:d0:31`), web UI on port 80. The only one so far, so store a list, not one URL.
- **Framing works.** Probed 2026-09-24: it sends **no `X-Frame-Options` and no
`frame-ancestors`**, so it does not refuse to be embedded. No fallback needed.
- **Proxy it through the daemon — that is what makes the simple version possible.** The
KVM is **HTTP only**, and Ham Lab is served over TLS, so a bare `http://…` iframe is
blocked as mixed content and the "just paste a link" idea dies there. Serving it from Ham
Lab's own origin (`/api/kvm/<id>/…`, on the `proxyNode` machinery, which already carries
WebSockets — exactly what the video and input streams are) fixes three things at once:
no mixed content, the KVM sits behind **Ham Lab's** login rather than a second one
(`/api/vm/info` answers 401, so it has its own, which the hub can hold once), and the
iframe becomes **same-origin**, which matters for the keyboard below.
- **The keyboard needs less than it sounds.** A parent page *cannot* capture keys and
forward them into a **cross-origin** iframe — the browser forbids synthetic events across
origins — but it does not need to: an iframe with focus receives keystrokes directly, and
the NanoKVM's own page already handles them. So "capture and give it to the iframe" is
really: click-to-focus, keep focus there, and stop the console's own shortcuts from
eating keys while it is focused. What is genuinely worth adding is **fullscreen** plus
`navigator.keyboard.lock()`, which is what stops the *browser* swallowing Escape, Tab and
Ctrl-W so they reach the guest — note it is Chromium-only, so Firefox and iOS Safari get
the plain version. Proxying makes the iframe same-origin anyway, so real forwarding stays
available if some KVM ever needs it.
- **Where the list lives is the one real design decision.** A KVM earns its keep when the
machine is *down* — no OS, a bad kernel, a BIOS screen — which is when that host's Ham Lab
cannot answer. So it is **hub-side** and must work for machines running no Ham Lab at all.
A `kvm_url` in each node's own `config.json` would fail in exactly the case it is for.
- **Do not give it an NPM host** without deciding to: `*.bigham.dev` is a wildcard to the
public IP, so that would put BIOS-level control of `grid` on the internet behind only the
KVM's own login. Behind Ham Lab's proxy it inherits Ham Lab's auth instead.
## Open questions
+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)
}
+42 -5
View File
@@ -69,6 +69,7 @@ type Server struct {
storage *storage.Manager
space *space.Manager
update updateState
kvms kvmProxies
fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo
nodes *nodes.Manager
@@ -254,7 +255,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
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("GET /api/zfs/schedules/{id}/expired", s.auth(s.zfsPreview))
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))
@@ -285,7 +285,20 @@ 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("GET /api/prefs/history", s.auth(s.getHistoryWindow))
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
m.HandleFunc("GET /api/kvms", s.auth(s.listKVMs))
m.HandleFunc("POST /api/kvms", s.auth(s.addKVM))
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)
s.mux = m
return s
@@ -308,9 +321,16 @@ func sensorPath(p string) bool {
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// The console must never be framed. An embedded widget exists to be: it is a page
// that can only read sensors, and only with a read-only token in its URL.
if r.URL.Path != "/embed/widget" {
// The console must never be framed. Two exceptions: an embedded widget exists to be
// (a page that can only read sensors, and only with a read-only token in its URL),
// 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("Content-Security-Policy", "frame-ancestors 'none'")
}
@@ -356,6 +376,17 @@ 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
}
// auth accepts the browser session cookie or a bearer API token issued to a hub.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -2446,6 +2477,12 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) {
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
w.Header().Set("Cache-Control", "no-cache")
http.ServeFileFS(w, r, s.ui, "index.html")
-18
View File
@@ -217,24 +217,6 @@ func (s *Server) zfsPolicy(id string) (storage.Policy, bool) {
return storage.Policy{}, false
}
// zfsPreview is what this schedule would destroy if it ran now. Nothing is deleted.
func (s *Server) zfsPreview(w http.ResponseWriter, r *http.Request) {
p, ok := s.zfsPolicy(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such schedule")
return
}
doomed, err := s.storage.Doomed(r.Context(), &p)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
if doomed == nil {
doomed = []storage.Snapshot{}
}
jsonOK(w, 200, map[string]any{"expired": doomed})
}
func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
p, ok := s.zfsPolicy(id)
+18
View File
@@ -61,6 +61,23 @@ type UpdateSource struct {
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 {
Listen string `json:"listen"`
// PasswordHash is the single login of installs before users existed. On load it becomes
@@ -70,6 +87,7 @@ type Config struct {
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
Tokens []Token `json:"tokens,omitempty"`
Nodes []Node `json:"nodes,omitempty"`
KVMs []KVM `json:"kvms,omitempty"`
// Updates: where a newer build comes from. See UpdateSource.
Updates UpdateSource `json:"update,omitempty"`
+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>
)
}
+11 -8
View File
@@ -12,6 +12,7 @@ import Jobs from './Jobs'
import Updates from './Updates'
import Disks from './Disks'
import ZFS from './ZFS'
import KVM from './KVM'
import Files from './Files'
import Top from './Top'
import Machines from './Machines'
@@ -54,22 +55,23 @@ const GROUPS: Group[] = [
{ id: 'procs', label: 'Processes', icon: '▚' },
{ id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' },
{ id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'files', label: 'Files', icon: FolderIcon },
{ id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'kvm', label: 'KVM', icon: '⌨' },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
{ id: 'machines', label: 'Machines', icon: '⌂' },
{ id: 'users', label: 'Users', icon: '⚇' },
{ id: 'widgets', label: 'Widgets', icon: '▦' },
{ id: 'users', label: 'Users', icon: '⚇' },
{ id: 'settings', label: 'Settings', icon: '⚙' },
]
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
@@ -269,6 +271,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<main className="main" key={machine ?? 'hub'}>
{view === 'settings' && <Settings me={hubMe} />}
{view === 'users' && <Users me={hubMe} />}
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
{view === 'usage' && <Usage key={machine ?? ''} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
{view === 'widgets' && <Gallery />}
@@ -306,7 +309,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>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<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 === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : 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 === '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>
</div>
)
+54 -32
View File
@@ -68,18 +68,43 @@ function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: ()
)
}
// 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) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } }
if (!open && !snaps) await reload()
}
async function snapshot() {
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
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`); setSnaps(null); onChanged() }
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
@@ -87,18 +112,29 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
<>
<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">{d.snapshots}</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="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">avail {fmtBytes(d.avail)}</span></div>
<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) => <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 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>
)}
@@ -126,18 +162,11 @@ function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; on
type ExternalJob = ZFSExternal
function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) {
const [expired, setExpired] = useState<ZFSSnapshot[] | null>(null)
const [busy, setBusy] = useState('')
const preview = async () => {
setBusy('preview')
try { setExpired((await api.zfsExpired(p.id)).expired) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
}
const run = async () => {
const n = expired?.length
const warn = n ? ` It will also destroy ${n} expired snapshot${n === 1 ? '' : 's'}.` : ''
if (!await askYes(`Take a snapshot of ${p.dataset} now?${warn}`, 'Run now')) return
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` : ''}`); setExpired(null); onChanged() }
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 () => {
@@ -165,20 +194,7 @@ function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy
{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>}
{expired && (
<div className="notice" style={{ marginTop: 8 }}>
{expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : (
<>
<b>{expired.length}</b> snapshot{expired.length === 1 ? '' : 's'} would be destroyed by a run now:
<div className="snap-list" style={{ marginTop: 6, maxHeight: 180, overflow: 'auto' }}>
{expired.map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleDateString()} · {fmtBytes(s.used)}</span></div>)}
</div>
</>
)}
</div>
)}
<div className="svc-actions">
<Btn className="btn" busy={busy === 'preview'} onClick={preview}>What would it delete?</Btn>
<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>
@@ -326,8 +342,14 @@ export default function ZFS() {
<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">Datasets</h2><span className="spacer" /><span className="muted small">used · referenced · in snapshots · count · compression · mount</span></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>
+6 -1
View File
@@ -133,6 +133,8 @@ 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 SpaceFile { path: string; size: number; apparent: number; mtime: number }
export interface KVM { id: string; name: string; url: string; machine: string; added: 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 }
@@ -184,10 +186,13 @@ export const api = {
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' : ''}`),
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)}`),
zfsExpired: (id: string) => req<{ expired: ZFSSnapshot[] }>('GET', `/api/zfs/schedules/${encodeURIComponent(id)}/expired`),
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)}`),
+25 -2
View File
@@ -285,14 +285,21 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
the fallback above stays a fallback. */
.disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); }
.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 .num { text-align: right; }
.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-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 { 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 { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
@@ -733,3 +740,19 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
/* 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; }