Compare commits
77
Commits
@@ -608,3 +608,147 @@ swallows a failed WebSocket where Firefox writes it to the console. One thing it
|
||||
hands logs "can't establish a connection". Sitting still on a page produces none, the address
|
||||
is right, and the terminal belongs to the machine asked; `sockets.cjs` checks that ownership
|
||||
rather than the noise.
|
||||
|
||||
# Bug bash 6 — 2026-10-03: every page, every machine, desktop and phone
|
||||
|
||||
Headless Chromium over all 19 pages on the hub and on each of the four machines at 1440px
|
||||
and at an iPhone 13's 390px (158 page loads, each measured for horizontal overflow, elements
|
||||
poking past the viewport, console errors and failed requests), then every interaction on
|
||||
the hub: login and a wrong password, every rail entry and the drawer, the machine switcher,
|
||||
the power dialog, terminals (create, type, rename by dialog and double-click, reorder by
|
||||
drag and by the arrows, close by × and middle-click, Alt+Shift+T/←/→, the phone's key bar),
|
||||
Services (filter, chips, detail, logs pause/resume/clear, unit file, a cancelled restart),
|
||||
Jobs (job and timer detail, the crontab editor), Disks (SMART, show-all), Files (navigate,
|
||||
crumbs, path box, hidden, sort, filter, folder, file, edit, save, Ctrl+S, unsaved-changes
|
||||
guards on back and on leaving the page, rename, chmod, copy path, move, delete with the name
|
||||
typed wrong and right, upload by button and by drag-and-drop, overwrite prompt, download,
|
||||
image preview, binary notice, open in terminal, root toggle), Network (interfaces, show
|
||||
more, the VPN hand-off, a full speed test), Usage (picker, tree, treemap hover and click,
|
||||
largest files), Docker (every tab, container detail with all five tabs, Limits, a cancelled
|
||||
restart, a shell into gitea, the stack editor with Revert and the diff, the .env mask,
|
||||
a wrong-name delete, the new-stack dialog in both modes with its clash warning, pulling and
|
||||
removing an image, and a whole stack lifecycle — create and deploy whoami, see it answer,
|
||||
Down, Delete), KVM (dialogs, the NanoKVM frame on grid), Hardware re-read, Updates (changelog,
|
||||
selection, All/None, a real apt-get update as a task), Alerts (rule dialog with live hits
|
||||
and a bad pattern, save, pause, edit, delete, channel validation), Machines (swatches,
|
||||
add-form validation, an unreachable address, Open), Widgets, Users (sessions, change-password
|
||||
with the wrong current password, add and remove an account with the right and wrong
|
||||
password, the reset dialog, a guest login created, used in a second browser, and revoked
|
||||
under it), Settings (every theme, refresh rate, history window, a read-only token used in
|
||||
/embed and refused for /api/me, revoked), the rail's Arrange mode, the Processes page
|
||||
(rate, sorts, filter, select, a cancelled signal, interface pick), logout and back in. Then
|
||||
read-only probes of the API with traversal, injection and malformed input. Firefox could
|
||||
not run here (the box lacks its GTK libraries).
|
||||
|
||||
## Bugs — all ✅ fixed and verified by the script that found them
|
||||
|
||||
Verified against the live API through the Vite dev server rather than a deployed build: the
|
||||
permission classifier refused `make deploy` (as it did for the GPU work the day before).
|
||||
**Deployed 2026-10-08 as `v0.1.0-174-g0764508`**, with quality-of-life #2, #3, #8 and #10.
|
||||
|
||||
1. ✅ **The ZFS tab was wider than a phone.** The dataset table carried a 700px minimum and
|
||||
the ARC's "since boot" breakdown four fixed columns; on grid the page scrolled 330px
|
||||
sideways. Both drop to three columns and the ellipsis below 800px.
|
||||
2. ✅ **The Usage tab ran under the footer on a phone**, its legend printed over the version
|
||||
line: the tree and the treemap each insisted on a minimum height the screen didn't have.
|
||||
Below 800px the page scrolls, the tree 40vh and the map 60vh.
|
||||
3. ✅ **Journal lines on a phone squeezed the message into a third of the width**, three
|
||||
columns deep. Below 560px the stamp and unit share a line and the message takes the next.
|
||||
4. ✅ **Services on `gate` (no systemd) showed the server's error flush against the edge,
|
||||
with "Nothing matches." under it** as if a filter had hidden them. A padded notice now, and
|
||||
no filter message when there was nothing to filter.
|
||||
5. ✅ **ZFS "Snapshot now" offered a name seven hours wrong.** The default came from
|
||||
`toISOString()` — UTC — while zfSnap's names, and the list reading them back, are local
|
||||
time: at 11:25 it offered `…_18.25.52`, and that snapshot would have shown as taken in the
|
||||
future and expiring late. Built from local fields now.
|
||||
6. ✅ **"Adopt as a schedule" always ticked Recursive** — `/\s-\w*r/.test(cmd) || true`.
|
||||
7. ✅ **Files "+ file" opened the editor even when the create had failed**, on a path that
|
||||
didn't exist. It opens only after the create succeeds.
|
||||
8. ✅ **Updates: a dangling " · " after each version on `gate`**, whose `apk` reports no suite.
|
||||
9. ✅ **Updates: apt's raw "E: Failed to fetch changelog: … Changelog unavailable" for a
|
||||
third-party package** (docker-ce) was shown as the changelog. Said plainly now.
|
||||
10. ✅ **Services "Since" fell back to systemd's raw timestamp in Firefox**, which can't parse
|
||||
"Fri 2026-10-02 23:48:25 PDT". The fields are read directly now.
|
||||
11. ✅ **Hardware on `gate` led with a separator** — "· 4 threads · up to 2.70 GHz" — when the
|
||||
core count is unknown. The parts are joined, not concatenated.
|
||||
12. ✅ **Settings → Updates said "This is the newest published build" of a build newer than
|
||||
anything published** (169 installed, 137 published). It says so instead.
|
||||
13. ✅ **Escape did not close the phone's drawer**, alone among the overlays.
|
||||
14. ✅ **Machines "Update N outdated", when some were busy and the force was accepted,
|
||||
concatenated both result lists** and toasted every machine twice.
|
||||
|
||||
## Found by the sweep, not a fault of the console
|
||||
|
||||
**`core`'s boot NVMe (WD Blue SN580 500GB) is failing.** The "Drive failing SMART" rule fired
|
||||
during the sweep, as it had the evening before; `smartctl` confirms it: critical warning 4
|
||||
(*NVM subsystem reliability has been degraded*), 18 media errors, every one an *Unrecovered
|
||||
Read Error*, 64 unsafe shutdowns, 2.5 years powered on. The console did its job; the drive is
|
||||
a `hamlet` matter (`docs/pending.md`).
|
||||
|
||||
## Checked and fine
|
||||
|
||||
Every page on every machine at both widths, with no console error that wasn't an expected
|
||||
401/400/403 from a deliberately wrong input. Login lockout messages, the password eye. URL
|
||||
selections surviving a reload and back/forward on every page that keeps one. The unsaved-work
|
||||
guard on the file editor and the stack editor, from the Back button, the rail, and the
|
||||
switcher. Terminal tabs, their order, their shortcuts; a shell inside a container landing on
|
||||
the Terminal tab. The whole stack lifecycle, image pull and prune, container Limits. A guest
|
||||
login kicked out at the moment of revocation. A revoked read-only token refused on
|
||||
`/api/sensors`, the stream and `/metrics` (an earlier reading that it still worked was the
|
||||
probe sending the session cookie alongside it). Traversal in every path-taking endpoint,
|
||||
injection in unit, device, dataset, package and container names, malformed `since`, `q` and
|
||||
`ids`, a nonexistent task, a wrong-method call — each answered with the right 4xx and the
|
||||
right words. No session, no cookie: 401 on every API, locally and through NPM. The KVM proxy
|
||||
hands NanoKVM's own `/api/ws` through to the device (the 401 in the console is the device's,
|
||||
before its own login).
|
||||
|
||||
## Quality of life — to discuss, not built
|
||||
|
||||
1. **Treemap blank space.** Where a folder's children don't add up to its size — `tank`'s
|
||||
root, where snapshots and compression hold the rest — the leftover is drawn as nothing.
|
||||
An "unaccounted" tile would say what it is.
|
||||
2. ✅ **Sessions pile up** — built 2026-10-06. Every script, curl and headless browser that
|
||||
logs in leaves a row; the hub had fifty, forty-six of them from automated sweeps. Now a
|
||||
session unused for seven days signs itself out (`auth.SessionIdle`), the Sessions list
|
||||
marks any not used in a day *idle*, and "Sign out N idle" (`POST
|
||||
/api/sessions/revoke-idle?hours=24`) drops those while leaving every device used since.
|
||||
3. ✅ **The Mac's GPU box takes half the Processes page** — built 2026-10-07. A GPU with
|
||||
nothing to read (no busy counter, engine, temperature, clock, power, memory or client) is
|
||||
now one line in its box: driver, "nothing measurable" (or the stall, if a sysfs read hung),
|
||||
and whether a process has it open. Its row shrinks to fit, and the Gpu% column and `gpu`
|
||||
sort go too when no GPU on the machine is measurable, since no row could ever light. A
|
||||
client opening the device brings the full box back. On the Mac: 210 → 35 px at 1440px,
|
||||
246 → 49 px on an iPhone 13, the process table 397 → 495 px tall. Verified through the
|
||||
Vite dev server against all five machines (the four Intel ones unchanged), plus doctored
|
||||
responses for a measurable GPU beside an unmeasurable one, a stalled card and no root.
|
||||
4. **A host without systemd** still gets Services chips reading "All 0" and a "systemd timers
|
||||
· 0" heading on Jobs. The server answers Services with a 500 there; a 200 with an
|
||||
`available: false`, as Docker does, would let both pages say so up front.
|
||||
5. **An alert whose sensor vanishes resolves silently.** If `smartctl` errors out, the
|
||||
`smart.*.passed` sensor is omitted and a firing "Drive failing SMART" clears. A sensor
|
||||
that disappears while a rule fires should hold the alert, or say the sensor is gone.
|
||||
6. **Network "Past tests" rows are clipped** on desktop ("↓ 302 · ↑ 300 Mbps · 7.5 …") by the
|
||||
four-column props grid.
|
||||
7. **Logs "Clear" has no "Reload"**: once cleared, the backlog only comes back by leaving the
|
||||
detail and returning.
|
||||
8. ✅ **NanoKVM asks for its own login every time** — built 2026-10-06, as remembering the
|
||||
device's cookie. The hub keeps the cookies a browser carries for a KVM (`kvm_cookies` in
|
||||
`state.json`), rewrites any the device sets by header so they stick to Ham Lab's origin
|
||||
(Domain dropped, Secure only when the console is on TLS), lends them on the wire to a
|
||||
browser that lacks them *and* hands them back as a Set-Cookie the device's own page can
|
||||
read — its SPA checks for its token client-side before asking the server. A logout from the
|
||||
device, or removing the KVM, forgets them. Covered by `kvm_test.go` against a fake device;
|
||||
not yet seen against the NanoKVM itself, whose login is not in the vault (the "PiKVM"
|
||||
entry's `admin` password is refused, as is the factory default).
|
||||
9. **Settings → Updates could offer to publish** when the installed build is newer than the
|
||||
last release, since that is what `make gitea-release` is for and the page already knows.
|
||||
10. ✅ **The compose editor is a plain textarea** — built 2026-10-06 as `CodeEditor`: line
|
||||
numbers, YAML and .env colouring painted behind a real textarea (so undo, selection and
|
||||
the keyboard stay the browser's), Tab/Shift+Tab indent by two, Enter keeps the indent and
|
||||
steps in after a key or a list dash, Ctrl+/ comments, and on a touch screen a bar of the
|
||||
strokes a phone keyboard lacks: outdent, indent, `#`, `- `, `: `, quotes. Used by the stack
|
||||
editor (compose and .env) and the new-stack dialog.
|
||||
11. **The hub's `/api/nodes/<m>/../me` answers a 307** from Go's path cleaning rather than a
|
||||
404 — harmless, but a stray.
|
||||
12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you
|
||||
can"), but worth a second look now that Users includes removing accounts.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Commit first, then deploy: the version is the commit the binary was built from.
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
# Commit first, then deploy: the version is the commit the binary was built from, counted
|
||||
# from the base tag. A published release is tagged with a version of this same shape
|
||||
# (v0.1.0-137-gc129f65), and described from one of those a build would count from 137 again
|
||||
# and read as older than what it replaces — so tags with a dash are never the base.
|
||||
VERSION ?= $(shell git describe --tags --exclude '*-*' --always 2>/dev/null || echo dev)
|
||||
GO ?= go
|
||||
PREFIX ?= /usr/local/bin
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ 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. |
|
||||
| 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. |
|
||||
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
|
||||
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
||||
| Capability, not policy | **A tab offers what the selected machine can do, detected live — never what a rule says should run there.** Where the software is missing but installable, Ham Lab installs it; where it cannot be installed or the hardware lacks it, the tab says so in a sentence and offers what does work. Every action lands on the machine in the switcher. | Mitchell, 2026-10-09, after the Virtualization plan's first draft excluded the Mac mini and `hamtv` on the strength of `hamlet/CLAUDE.md`'s deployment rules: *"I want the option to do it anywhere I want, unless the hardware / software literally doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on any Linux system."* Those rules bind the agent's unasked deployments, not the product. |
|
||||
| Ham Lab's data lives in Ham Lab's folder | **Everything a tab manages sits under the machine's data directory (`config.DataDir()`), never at a convenient path elsewhere.** Existing material is imported by copying. The engine's runtime state (`/var/lib/docker`, `/var/lib/incus`) is the engine's; Ham Lab keeps what would rebuild it. | Mitchell, 2026-10-09, when the Virtualization plan pointed its ISO library at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it manages its own data."* The same reason `core`'s compose files moved under `apps/hamlab/stacks/` on 2026-10-05. `grid`'s and the Mac's stacks at `~/stacks/<machine>/` were the standing exception; since 2026-10-10 the stacks code offers `<data dir>/stacks` as the default home everywhere and such folders only as discovered extras. |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -176,6 +180,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
|
||||
and 10 min at 2 s. Still to do: persistent
|
||||
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
|
||||
- **Updating from the Updates tab** — **shipped 2026-09-26**: the "what is out of date" list
|
||||
was read-only, so seeing a newer image and doing something about it were different places.
|
||||
Each row now has **Update**, and the header has **Update all**. Updating is per stack —
|
||||
that is the unit compose deals in — so a row acts on whichever stacks use that image, and
|
||||
several stacks run through a queue one at a time rather than recreating half the machine at
|
||||
once, each with its task output on screen. A row that succeeds says "updated just now"
|
||||
instead of re-checking, since a check is a registry request.
|
||||
- **Recreate, and the Updates tab at full width** (2026-09-26): a stack's actions gained
|
||||
**Recreate** (`up -d --force-recreate`), the only way out of a container Docker refuses to
|
||||
start — one pinned to a network that has since been recreated answers "network … not
|
||||
found" to Deploy and Update alike, because as far as compose is concerned nothing about it
|
||||
changed. Found the hard way when a SiYuan update landed in exactly that state. The Updates
|
||||
tab also stopped rendering as a list-and-detail pair: it is a page, not a list, so on a
|
||||
wide window it now sits under the tab strip at full width instead of being squeezed into
|
||||
the right-hand half beside an empty column.
|
||||
- **Cron in English** (`crontext.ts`, 2026-09-26): schedules read "daily at 3:15am" rather
|
||||
than `15 3 * * *`, with the expression kept beside it. Handles lists, ranges, steps, day
|
||||
and month names, `@daily` and friends, weekdays/weekends, midnight and noon — and declines
|
||||
to describe anything it is not sure of, because a confident wrong sentence is worse than
|
||||
five fields. The Jobs tab had its own smaller version of this; it now uses this one.
|
||||
|
||||
- **Hardware tab** — **shipped 2026-09-25** (`internal/hardware`, `internal/api/hardware.go`,
|
||||
`Hardware.tsx`): what the machine is physically made of, per machine like every other page.
|
||||
Four headline tiles (processor, memory, storage, graphics) over cards for each: the
|
||||
processor with its cores, clock range, socket, caches, microcode and the handful of feature
|
||||
flags anyone asks about; **every memory slot** with size, type, form factor, rated and
|
||||
running speed, maker, part number, serial, rank and voltage, and the empty ones marked
|
||||
empty; each drive with model, size, bus, firmware and serial; each display adapter with its
|
||||
driver, PCI address and video memory; and the board itself — model, firmware and date,
|
||||
serial, kernel. Read from what Debian already has (dmidecode for what only the firmware
|
||||
knows, lscpu, lspci, sysfs) and cached for ten minutes, since none of it changes under a
|
||||
running kernel; "Re-read" is there for the one thing that does, a drive plugged in.
|
||||
Every reader may come back empty and the page says what it could not read and why: a
|
||||
machine with no dmidecode, no root, or no PCI bus still gets a page. Verified on `core`
|
||||
(SMBIOS, Intel graphics) and on the Mac mini (Asahi: no SMBIOS memory table, a
|
||||
device-tree GPU, heterogeneous cores — where lscpu's "cores per socket" counts one
|
||||
cluster, so without SMT the thread count is the honest core count).
|
||||
|
||||
- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
|
||||
`internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`;
|
||||
reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID;
|
||||
@@ -218,12 +260,13 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
filesystem; compose is then run **inside that container**, so `.env`, relative paths
|
||||
and the compose labels stay exactly as deployed (verified: labels identical after a
|
||||
redeploy). Save validates with `compose config` first, keeps the previous file as
|
||||
`<file>.hamlab-bak`, and never deploys by itself. A stack deployed from another host
|
||||
(Dockhand on `core` driving `grid` over tcp) has no file on that machine, so the node
|
||||
alone lists it read-only — but **the hub fills that in** (`hubStacks`): asked about a
|
||||
node, it resolves the files on itself and runs compose here with `DOCKER_HOST` pointed
|
||||
at the node's `tcp://…:2375`, the way it was deployed; the task runs on the hub and the
|
||||
node's page follows it. Also per stack: every file it was deployed from (chips), its
|
||||
`<file>.hamlab-bak`, and never deploys by itself. A stack whose file is not on the
|
||||
machine that runs it is listed read-only, and that is the end of it: **since 2026-10-10
|
||||
the hub holds no files for a node and never points compose at another machine's
|
||||
Docker.** The `hubStacks` path — which resolved a node's files on the hub and deployed
|
||||
over `tcp://…:2375`, the way Dockhand had — is gone, and every
|
||||
`/api/nodes/<node>/docker/…` request is the node's own Ham Lab's to answer through the
|
||||
generic proxy. Also per stack: every file it was deployed from (chips), its
|
||||
**`.env`** (values masked until "Reveal and edit"; validated in place, put back if
|
||||
compose objects), **what Deploy would change** (a line diff against the copy kept at the
|
||||
last deploy from here, `<file>.hamlab-deployed`, else the copy the last save replaced;
|
||||
@@ -236,9 +279,10 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
form (image, ports, volumes, environment, restart, command, host networking) that writes
|
||||
the compose for you: a container made here is a one-service stack, so it stays editable
|
||||
and redeployable, where a bare `docker run` could only be thrown away and typed again.
|
||||
New stacks go in a **home** — a folder where two or more stacks are already deployed from
|
||||
(Dockhand's `stacks/core` here; `~/stacks` on a machine with none) — and are deployed the
|
||||
way their neighbours were (inside `dockhand`, or over tcp to a node from the hub). Homes
|
||||
New stacks go in a **home**. Since 2026-10-10 the default is Ham Lab's own
|
||||
`<data dir>/stacks`, offered first on every machine; a folder where two or more stacks
|
||||
are already deployed from is offered as well, and a stack made there is deployed the way
|
||||
its neighbours were (inside an orchestrator's container, when that is how they run). Homes
|
||||
are also scanned, so a stack with no containers ("not deployed": never deployed, or taken
|
||||
down) is still listed. **Down** removes a stack's containers; **Delete…** (type its name)
|
||||
takes it down and moves its own files to `<home>/.hamlab-trash/<name>-<time>/` — moved,
|
||||
@@ -259,6 +303,52 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
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.
|
||||
- **GPUs on the Processes tab** — **shipped 2026-10-02** (`internal/metrics/gpu.go`, the
|
||||
`GPUBox` in `Top.tsx`, `gpu.*` sensors). Asked for as "GPU activity and temps, like btop
|
||||
has it, always on". Each DRM device with a real GPU driver gets a box under the cpu box:
|
||||
busy curve, busy meter, a meter per engine (render/video/copy/enhance), temperature, clock,
|
||||
power and fan where the card has them, and the processes that have it open with their
|
||||
share. The process table gains a `Gpu%` column and sort. **How it is measured, and the
|
||||
honest gaps:** device busy is Intel's RC6 idle residency inverted (the counter every GT
|
||||
exposes without root; `intel_gpu_top` shows the same number) or amdgpu's own percent;
|
||||
per-process and per-engine shares are DRM fdinfo deltas, the way nvtop reads them. Other
|
||||
users' fdinfo needs root, so a daemon running as `core` keeps **one** root child
|
||||
(`hamlab drm-clients`, over a pipe) rather than a sudo a second. The Arc A380 on i915 6.12
|
||||
exposes power, energy and fan but **no temperature**, so its box shows none; an integrated
|
||||
GPU shows the CPU package temperature marked `*`. Asahi (the Mac mini) has neither a busy
|
||||
counter nor fdinfo in 6.19, so it lists the device and says so. No NVIDIA path: nothing here
|
||||
has one to test against. **Names come from an embedded PCI ID table** (2026-10-02,
|
||||
`internal/metrics/pciids_gpu.txt`, regenerated by `scripts/pci-ids-gpu.sh` from the hub's
|
||||
`pci.ids`): `gate` has no `lspci` and showed "Intel GPU 3185" beside `hamtv`'s "Intel UHD
|
||||
Graphics 600" for the identical chip. The table is tried first everywhere, so the name — and
|
||||
the sensor ID built from it — does not depend on which distro's pciutils a machine has;
|
||||
`lspci` is the fallback for a card newer than the table. Two fixes the same evening:
|
||||
the gpu box's layout rules had been lost from the stylesheet (v0.1.0-166/167), and on
|
||||
`grid` the sampler hung for 42 minutes in Go's poller on the Arc's `fan1_input` —
|
||||
`readStr` now uses plain blocking syscalls and each GPU's sysfs reads run under a
|
||||
3-second deadline (v0.1.0-168; `hamlet/docs/gotchas.md` has the mechanism).
|
||||
- **CPU power on the Processes tab** — **shipped 2026-10-10** (v0.1.0-194,
|
||||
`internal/metrics/cpupower.go`, the `cpu.power` sensor). Asked for beside the clock speed,
|
||||
and that is where it sits: "3600 MHz · 89 W" in the cpu box's header. Watts are the RAPL
|
||||
package energy counters' delta between samples, summed over packages; `psys`, a package's
|
||||
parts and the MMIO duplicate are left out. The counters are root-only, so a daemon running
|
||||
as `core` keeps a second root child, `hamlab cpu-energy`, beside `drm-clients`. Read on all
|
||||
four Intel machines; the Mac mini has no CPU-only counter (its SMC reports the whole
|
||||
machine's draw instead) and shows nothing.
|
||||
- **The ARC on the ZFS tab** — **shipped 2026-10-02** (`internal/metrics/arc.go`,
|
||||
`internal/storage/arc.go`, `GET /api/zfs/arc`, the Cache section in `ZFS.tsx`, `zfs.arc.*`
|
||||
sensors). Asked for as "current ARC usage, including any types of caching services in the
|
||||
pool". One card for the ARC: size against its ceiling with the target and floor, sparklines
|
||||
of size and hit rate from the sampler's history, what it holds (MRU/MFU/dirty, data vs
|
||||
metadata, compression, the ghost lists), the prefetcher and dbuf cache, memory-pressure
|
||||
counters, and the since-boot breakdown by demand/prefetch × data/metadata. Then a card per
|
||||
pool naming every caching tier — L2ARC, SLOG, special vdev, dedup table, spares — with its
|
||||
devices and fill, **or saying plainly that there is none and what that means** (the ZIL on
|
||||
the pool's own vdevs, with how much it has written), plus the properties that steer caching
|
||||
(`primarycache`, `secondarycache`, `sync`, `logbias`). Read from `/proc/spl/kstat/zfs/*`
|
||||
and `/sys/module/zfs/parameters`, with `zpool list -Hpv` and `zfs get` run without sudo
|
||||
(they need none) every 30 s at most. The day it shipped it showed `tank`'s ARC pinned at
|
||||
its 2 GiB floor under `arc_no_grow`, with a 53 GiB python3 process holding the RAM.
|
||||
- **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
|
||||
@@ -276,6 +366,83 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
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,
|
||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||
@@ -321,9 +488,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
|
||||
`~/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
|
||||
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. Not done: an OpenRC
|
||||
backend for the Services tab; runit/s6/sysvinit service files; public hosting for
|
||||
`install.sh` (the Gitea repo is private, so the one-liner needs `HAMLAB_TOKEN`).
|
||||
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. **procd (OpenWrt) added
|
||||
2026-09-26** (`internal/setup/hamlab.procd`): detected on `/sbin/procd` + `/etc/rc.common`
|
||||
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**
|
||||
(`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
|
||||
@@ -463,13 +637,94 @@ 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
|
||||
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.
|
||||
- **No secrets are read.** Private keys are in the second column of the dump and are read
|
||||
past; pre-shared keys are noted as present and never read.
|
||||
- **Devices can be created, renamed and removed** (added 2026-09-27, asked for the same day),
|
||||
on a tunnel the machine configures — today OpenWrt's UCI, which is where the server here
|
||||
lives. A wg-quick host is reported unmanaged and shows no buttons: half-supporting a second
|
||||
configuration format is worse than an honest refusal.
|
||||
- **Nothing restarts the network.** `uci commit` writes the file and applies nothing; `wg
|
||||
set` changes one peer on the live interface. Proven on `gate` by the existing peers'
|
||||
transfer counters surviving a create and a delete — a reload would have zeroed them.
|
||||
- **The two halves are ordered so they cannot disagree quietly.** Adding writes the
|
||||
configuration first and rolls it back if the live tunnel refuses, because a device that
|
||||
works only after the next reboot is the worst of the three outcomes. Removing does the
|
||||
live tunnel first, since that is the moment access ends, and says so plainly if the
|
||||
configuration half then fails.
|
||||
- **The keypair is generated on the machine** and the profile, with its QR, is shown once.
|
||||
The private key is stored nowhere — the same bargain as a guest login's password.
|
||||
- **Removing asks for the name to be typed back**, at both ends. It cannot be undone and it
|
||||
sits one tap from a list of devices that all look alike.
|
||||
|
||||
## 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
|
||||
@@ -489,69 +744,69 @@ 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
|
||||
inventory. Do that first.
|
||||
- **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 —
|
||||
the cutover itself, then moving every stack off Dockhand's data dir — is `hamlet`'s
|
||||
`docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", agreed
|
||||
2026-09-20. This entry is the *app* half.
|
||||
|
||||
- **Rename the Space tab** — asked for 2026-09-23, not started. "Space" is vague; the tab
|
||||
is WizTree for a disk or pool. Candidates: **Usage**, **Disk Usage**, **Storage**. Note
|
||||
`Disks` already exists and `Storage` would muddle with it, so Usage is probably the one.
|
||||
The rename touches the rail label, the `View` id, the hash route (`#/<machine>/space`,
|
||||
which people may have bookmarked — decide whether to keep `space` as the id and change
|
||||
only the label), the footer's page name and `Space.tsx`'s own strings.
|
||||
**Done 2026-09-24 — the eight Mitchell picked from the inventory**, all in the Docker tab:
|
||||
per-container **Stats**, **Health** (with the last few probe outputs), **Events** (Ham Lab
|
||||
keeps its own 4000-entry ring, since `docker events` forgets after about a minute),
|
||||
**Shell** (`docker exec`, handed to the Terminal tab) and **Limits** (`docker update` for
|
||||
memory, CPU and restart policy); an **updates** tab giving the whole machine at once with
|
||||
a per-container indicator in the list; and **schedules**, which write
|
||||
`/etc/cron.d/hamlab-docker` and call
|
||||
`hamlab docker-update <id>` — the shape the ZFS schedules already use.
|
||||
Also done: Ham Lab keeps working when `dockhand` is removed. It remembers where a
|
||||
container's bind mounts pointed (`mounts` in `state.json`) and resolves a stack through
|
||||
that record when no running container explains its path, running compose on the host
|
||||
instead; `repoint` does the same for a schedule at run time. Without this, removing the
|
||||
container would have made 17 stacks on `core` and 5 on `grid` unfindable at once.
|
||||
|
||||
- **ZFS gets its own tab, under Disks** — asked for 2026-09-23, not started. Today ZFS is a
|
||||
section inside `Disks.tsx`: pools with health, capacity, fragmentation, scrub controls, a
|
||||
dataset tree and per-dataset snapshot lists with "Snapshot now". Moving it out makes room
|
||||
for what is actually wanted: **scheduled snapshots with a retention policy**, configured
|
||||
from the console rather than by hand. What exists to build on — `grid` runs `zfSnap`
|
||||
nightly at 02:30 from root cron with 6-week expiry (`zfsnap-cron-6w-daily.sh`, predates
|
||||
this repo), and Ham Lab's Cron tab can already read, write and run root crontabs, so the
|
||||
job can be managed rather than reinvented. Decide: does Ham Lab drive `zfSnap`/`zfs-auto-
|
||||
snapshot` if present, or write and own its own timer? Per-dataset policies or one for the
|
||||
pool? And it must show what a retention rule *will* delete before it runs.
|
||||
- **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.
|
||||
|
||||
- **Rename Cron to something generic** — asked for 2026-09-23, not started. Mitchell's
|
||||
reasoning: the Services tab is systemd but "Services" is the right generic name for it,
|
||||
so the scheduling tab should be named for the job it does, not the tool. **Scheduled** or
|
||||
**Jobs**. The tab already covers more than cron — `/etc/crontab`, `/etc/cron.d`, user
|
||||
crontabs, `run-parts` directories *and* systemd timers — so the generic name is the
|
||||
honest one. Same rename surface as the Space tab: rail label, `View` id, hash route,
|
||||
footer name.
|
||||
**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.
|
||||
|
||||
- **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.
|
||||
- **The `*:2375` question — settled, and the code removed 2026-10-10.** The hub asks the
|
||||
node's own Ham Lab; nothing probes or uses a node's Docker port. `hubStacks`,
|
||||
`nodeDocker`, `taskOnHub`, the hub-side `/api/nodes/{node}/docker/…` routes and the
|
||||
`remote` / `held_by` fields are gone. A schedule saved before this that still carries a
|
||||
`remote` is refused with a message, never run against the wrong Docker. `PORTABILITY.md`
|
||||
§3 has the audit it came from. Deployed as v0.1.0-191 and checked on all five machines
|
||||
through the hub.
|
||||
|
||||
- **A Virtualization tab** — asked for 2026-09-23; **scoped, built and deployed 2026-10-09 — see
|
||||
`VIRTUALIZATION.md`** for what shipped (phases 1 and 2) and what is left. Incus 6.0 LTS as the engine; the tab detects per machine whether Incus is
|
||||
running, installable, or unavailable, and whether KVM is there, and acts on the machine in
|
||||
the switcher — never a default host. Containers and VMs in one list, console/shell through
|
||||
the terminal machinery, snapshots, exports, and the old Proxmox estate on `tank` as a later
|
||||
import. That file has the probe of every host, the per-machine layout Ham Lab computes
|
||||
(pool driver, a bridge subnet that avoids the LAN's /8 and Docker's pools, the Docker
|
||||
`FORWARD` fix) and the phased build.
|
||||
|
||||
- **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.
|
||||
|
||||
## Open questions
|
||||
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# Portability audit — what in Ham Lab is specific to this lab (2026-10-09)
|
||||
|
||||
Goal: Ham Lab drops onto any set of Linux machines and works. This is the full sweep of
|
||||
every place the code, defaults, docs or UI assume Mitchell's hosts, network, hardware,
|
||||
distro, init system, orchestrator history or posture. **Nothing here is fixed yet** — this
|
||||
is the list to work from. Each item: where, what it assumes, what the general form is.
|
||||
|
||||
Severity key: **A** breaks or misbehaves on an ordinary host · **B** works but is wrong by
|
||||
default or phones home to this lab · **C** cosmetic (placeholder text, comments, docs).
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard-wired lab identifiers
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 1.1 | **B** | `internal/selfupdate/selfupdate.go:36` `DefaultURL = "https://git.bigham.dev/api/v1/repos/mitchell/hamlab"` | Every install with no `update.url` checks **this** Gitea 90 s after boot and daily (`internal/api/update.go:1951, 2016`). A stranger's install phones home here by default. | Default should be **off** (or a build-time `-X` variable set at release). Installer asks or `install.sh` passes `HAMLAB_URL` through into `update.url`. |
|
||||
| 1.2 | **B** | `install.sh:4, 27` `HAMLAB_URL` default; `README.md:23-25, 31` | The curl one-liner and the install source are this Gitea. | Default comes from the release that served the script (`@URL@` filled by `make release`), or is required. |
|
||||
| 1.3 | **B** | `scripts/gitea-release.sh:14` `REPO` default | Publishing goes to `mitchell/hamlab` on this Gitea. | Require `HAMLAB_REPO`; no default. |
|
||||
| 1.4 | **C** | `README.md:100` "On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager" | Lab deployment fact in the product README. | Move to `hamlet/docs/hosts.md`. |
|
||||
| 1.5 | **C** | `examples/external-demo/index.html:8` `base: 'http://10.0.0.234:8100'` | Demo config names the hub's LAN IP. | `http://<hamlab-host>:8100`. |
|
||||
| 1.6 | **C** | `SENSORS.md:149` Prometheus target `10.0.0.234:8100`; `:187` `&machine=grid`; `:196-208` Glance examples at `hamlab.bigham.dev`, `zfs.tank.used_pct`, `/api/nodes/grid/...`; `:218-220` `grid,wyse`, `{"core":…,"grid":…,"mac":…}` | Docs use real host names, pool and domain. | Generic placeholders (`<host>`, `<machine>`, `<pool>`). |
|
||||
| 1.7 | **C** | `PLAN.md:3-4` "bare-metal Debian 13 host. First target: `core` (10.0.0.234)"; decisions table rows (Listen `10.0.0.234:8100`, Data `/home/core/apps/hamlab/`, Ingress NPM/`hamlab.bigham.dev`, Repo) | Product plan is written as this lab's plan. | Keep as history, or split lab facts out to `hamlet`. The repo is public. |
|
||||
| 1.8 | **C** | `VIRTUALIZATION.md:339-353` per-machine probe table; `:412, 425-436` `~/apps/hamlab`, `/tank/...`, boot NVMe free space | Plan is written against the five lab hosts. | Same as 1.7. |
|
||||
| 1.9 | **C** | `BUGS.md` throughout | Bug bashes narrate `core`, `grid`, `wyse`, `hamtv`, `gate`, the Mac. | History; fine to leave, but it is public. |
|
||||
| 1.10 | **C** | `cmd/hamlab/main.go:1` "Proxmox-style web console for a bare-metal Debian host"; `internal/hardware/hardware.go:5` "what Debian and its kin already have"; `internal/hardware/hardware.go:29` `// "GMKtec NucBox G3"`; `internal/metrics/metrics.go:1358` "did, on grid, 2026-10-02"; `internal/api/server.go:1382-1383` `["grid", …]`, `?machines=grid,wyse`; `internal/api/stacks.go:10` `/home/core/apps/dockhand/stacks/…`; `internal/api/dockersched.go:5` "unbound, nginx and pihole"; `internal/api/zfs.go:5` "grid already runs zfSnap"; `internal/storage/snapshots.go:6` "grid has run zfSnap v1.11.1"; `internal/api/guests.go:8` "Mitchell, 2026-09-27"; `internal/network/network.go:306` "the public address here is a DHCP lease"; `internal/metrics/gpu.go:17` "Asahi, today"; `internal/metrics/pciids.go:4-6` "the router's GPU"; `web/src/Top.tsx:522, 576`; `web/src/hashsel.ts:1-9`; `web/src/Embed.tsx:3` `&machine=grid` | Comments explain decisions by naming lab hosts. | Harmless, but rewrite the ones in package doc comments so a reader of the source doesn't need the lab to understand the code. |
|
||||
|
||||
## 2. UI placeholders and defaults that are this lab's
|
||||
|
||||
| # | Sev | Where | Text | General form |
|
||||
|---|---|---|---|---|
|
||||
| 2.1 | **C** | `web/src/Machines.tsx:116-118` | `placeholder="grid"`, `"https://10.0.0.214:8100"`, `"core"` | `nas`, `https://192.0.2.10:8100`, `admin` or the hub's own user. |
|
||||
| 2.2 | **C** | `web/src/KVM.tsx:166` | `placeholder="10.0.0.217"` (the NanoKVM on `grid`) | `192.0.2.20` or `kvm.local`. |
|
||||
| 2.3 | **C** | `web/src/Settings.tsx:242` | `placeholder="cpu.*, temp.*, zfs.tank.*"` | `cpu.*, temp.*, fs.*`. |
|
||||
| 2.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. |
|
||||
| 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. |
|
||||
| 2.6 | **C** | `web/src/StackNew.tsx:110, 113, 114` | `/home/core/apps/whoami:/data`, `TZ=America/Los_Angeles`, `com.example.owner=mitchell` | `./data:/data`, `TZ=UTC`, `com.example.owner=you`. |
|
||||
| 2.7 | **C** | `web/src/Gallery.tsx:69`, `web/src/WidgetPicker.tsx:36` | `"find — gitea, temp, enp3s0…"`, `"filter sensors — cpu, temp, fan, enp3s0…"` | Drop the container and NIC names. |
|
||||
| 2.8 | **C** | `web/src/VPN.tsx:105` | default DNS for a new device `1.1.1.1, 1.0.0.1` | Fine as a public default, but should offer the tunnel's own address (the home resolver) first. |
|
||||
| 2.9 | **B** | `web/src/ZFS.tsx:212` `BLANK` | new schedule defaults `at: '02:30'`, `keep_for: '6w'` — `grid`'s old zfSnap cron line | Neutral defaults (`03:00`, `4w`), or none. |
|
||||
| 2.10 | **A** | `web/src/ZFS.tsx:302` `adopt()` | `/\b(tank[\w/-]*)\b/` — the dataset of an adopted external job is found by looking for the word **`tank`** | Parse the dataset from the command as the last `zfs`-shaped argument, or match against the datasets the page already has. On any other pool name adopt silently picks the fallback. |
|
||||
|
||||
## 3. Topology inherited from the lab (hub `core`, Dockhand, `tcp://:2375`)
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 3.1 | **done** 2026-10-10 | `internal/api/stacks.go` `nodeDocker()` (removed) | For every node whose stacks aren't editable locally, the hub **probes `tcp://<node-host>:2375`** with `docker -H … version` and, if it answers, deploys to the node over an unauthenticated Docker socket. This is the Dockhand-era layout; on any other network an open 2375 is either absent or a security problem the console should not quietly use. | Remove the probe. The node's own Ham Lab deploys its stacks (the files live on the node, under its data dir). `PLAN.md` "To do" already names this decision. |
|
||||
| 3.2 | **done** 2026-10-10 | `hubStacks()`, `taskOnHub()`, the hub-side `/api/nodes/{node}/docker/…` routes, `Stack.remote`/`held_by` (all removed; the generic node proxy carries those requests) | A hub can hold compose files for a node and run compose remotely; new stacks for a node may be created **on the hub**. Only true here because Dockhand on `core` deployed `grid` and the Mac. | Same as 3.1: one machine, one set of files, one Docker. Hub-held stacks become an import path (copy to the node) rather than a steady state. |
|
||||
| 3.3 | **done** 2026-10-10 | `internal/api/stacks.go` `homesOf()` — `<DataDir>/stacks` is always offered first and default; the `~/stacks` fallback is gone; `TestHomesDefaultToDataDir` | Where new stacks go is **discovered** from where ≥2 stacks already are (Dockhand's `stacks/<machine>` habit), else `~/stacks`. Neither is Ham Lab's data directory, which the project's own rule says everything managed must live under. | Default home = `<DataDir>/stacks`. Discovered homes stay as *additional* places, never the default. |
|
||||
| 3.4 | **B** | `internal/api/stacks.go:100-203` remembered mounts, `internal/api/dockersched.go:772-795` `repoint()` | Machinery exists to find compose files **inside a removed orchestrator container's filesystem**. Generic in form, but it exists because of Dockhand and is exercised only here. | Keep (it is general), but it is not a reason to keep 3.1/3.2. |
|
||||
| 3.5 | — | `internal/api/stacks.go` `stackAction`, `internal/compose/compose.go` | Inside a `Via` container the command is literally `docker compose`. | **Checked 2026-10-10, no change:** the CLI inside an orchestrator's container is that container's own `docker`, independent of what the host runs; the host's tool only chooses the `exec`. Both places now say so in a comment. |
|
||||
| 3.6 | **B** | `internal/api/stacks.go:908` `docker buildx imagetools inspect` | Update checks need the **buildx** plugin. Docker CE on Debian has it; Podman and minimal Docker installs do not → every image reports "the registry didn't answer". | Fall back to `skopeo inspect` or a direct registry `HEAD` for the digest. |
|
||||
| 3.7 | **B** | `Makefile:445-448` `deploy` | The machine you build on **is** the hub: installs locally, then `push-nodes`. | Fine for a developer; document that `deploy` means "I am the hub". A plain `make install` target for a single box. |
|
||||
| 3.8 | **B** | `internal/nodes/nodes.go:316-323` | A hub pushes other architectures only if `<prefix>/hamlab-<arch>` siblings exist, which only `make deploy`/`install.sh`-from-dist put there. | A hub without siblings should fetch the right build from `update.url` instead of erroring. |
|
||||
|
||||
## 4. Init system: systemd assumed where the product promises procd and OpenRC too
|
||||
|
||||
The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-262`). These paths do not.
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 4.1 | **A** | `internal/updates/updates.go:537-543` `Power()` | `systemctl reboot` / `systemctl poweroff`. On OpenWrt (`gate`) and OpenRC the Power dialog fails. | Try `systemctl`, else `reboot` / `poweroff` (busybox, OpenRC, sysvinit all have them). |
|
||||
| 4.2 | **A** | `internal/incus/incus.go:1017, 1044-1058, 1069, 1075` | The Incus install script writes `/etc/systemd/system/*` drop-ins and units, adds `SupplementaryGroups` to `hamlab.service`, restarts with `systemd-run`. On OpenRC/procd the daemon never joins `incus-admin` → the tab stays in "repair" forever; the Docker FORWARD fix is never persisted. `HamlabUnit = "hamlab.service"` (`:35`) is surfaced in the API (`:280`). | Branch on the detected init: OpenRC `rc-update`/`conf.d`, procd init script edits, or fall back to `usermod` + a daemon restart via the installer's own supervisor knowledge. Docker rules via a boot hook that is not a unit. |
|
||||
| 4.3 | **A** | `internal/services/services.go:163-170` | Services tab is systemd-only ("says so cleanly"). `gate` (procd) and any OpenRC/runit/s6 box have no Services tab. | `rc-service`/`rc-status` (OpenRC), `/etc/init.d/<x> status` + `ubus call service list` (procd), `sv` (runit) backends. Noted as "not done" in `PLAN.md:487-489`. |
|
||||
| 4.4 | **A** | `internal/cron/cron.go:353-373, 401, 442, 532-534, 669-670` | Jobs tab reads `/etc/crontab`, `/etc/cron.d`, `/var/spool/cron{,/crontabs}` (Debian/cronie), `/usr/sbin/anacron`, last-runs from **journald**, timers via `systemctl`; writes only `/etc/crontab` and `/etc/cron.d`. Busybox crond (OpenWrt, Alpine) keeps crontabs in `/etc/crontabs/<user>` and has no `cron.d` → the Jobs tab is empty on `gate` and schedules written by 4.5 never run. | Add `/etc/crontabs/*` as a source; last-runs from syslog/logread when there is no journal. |
|
||||
| 4.5 | **A** | `internal/storage/snapshots.go:402` `/etc/cron.d/hamlab-zfs`; `internal/api/dockersched.go:65` `/etc/cron.d/hamlab-docker` | ZFS snapshot and image-update schedules are cron.d files. No cron.d on busybox crond; no cron at all on a systemd-timer-only host (Fedora Server, CoreOS). | Detect: cron.d → cron.d; busybox → a line in `/etc/crontabs/<user>`; none but systemd → a timer unit. The daemon-side runner already exists (`hamlab zfs-snapshot`, `docker-update`). |
|
||||
| 4.6 | **B** | `internal/hardware/hardware.go:333` `systemd-detect-virt` | Virtualisation detection is systemd-only. | Fall back to `/sys/class/dmi/id/sys_vendor`, `/proc/cpuinfo` `hypervisor` flag, `/sys/devices/virtual/dmi`. |
|
||||
| 4.7 | **B** | `internal/api/server.go:1129`, `internal/updates/updates.go:187` | `/run/reboot-required` is Debian/Ubuntu-only. `updates.Status` falls back to a kernel check for other distros; `/api/me.reboot_needed` (the Machines badge, the header's amber power button) does not. | Share one function. |
|
||||
| 4.8 | **B** | `install.sh:154-157, 164, 375-378`; `internal/setup/setup.go:284` | The wizard's init detection knows systemd and OpenRC only: on OpenWrt it warns "no systemd or OpenRC: nothing starts it at boot" although `hamlab install` then installs a procd script fine; the "logs" hint and the "isn't answering yet" hint have no procd case. | Add procd to both. |
|
||||
| 4.9 | **B** | `internal/setup/setup.go:609-641` `firewall()` | Only firewalld and ufw are detected. OpenWrt fw4/nftables, plain nftables, iptables, Alpine awall are not. | Detect `fw4`/`nft`/`iptables`; on OpenWrt add a LAN-zone rule or say what to open. |
|
||||
| 4.10 | **B** | `internal/setup/setup.go:568-605` `quietSudoers()`; `internal/services/services.go:176-185` | The sudoers quieting list is tuned to what the lab polls (`smartctl`, `pwrstat`, `journalctl`, `zpool`, `zfs`). A host with NUT or apcupsd gets `upsc`/`apcaccess` lines in its auth log at 10 s intervals; a host without `/etc/sudoers.d` (OpenWrt) gets nothing, which is correct. | Build the list from what the collector actually probes. |
|
||||
|
||||
## 5. Distro and package assumptions beyond init
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 5.1 | **B** | `internal/incus/incus.go:213-250, 351-352` | Incus can be installed with apt, dnf, pacman, apk. **zypper is missing** (the Updates tab supports it), so an openSUSE host reads "No package manager Ham Lab knows how to drive". `opkg` is known only to say "not packaged for OpenWrt". | Add zypper; phrase the opkg case as "not packaged for this distribution". |
|
||||
| 5.2 | **B** | `internal/incus/incus.go:973-978` | dnf branch adds `qemu-system-x86-core`/`edk2-ovmf` or `qemu-system-aarch64-core`/`edk2-aarch64`; other arches (riscv64, ppc64le, which `IncusArch()` maps) get no QEMU. pacman and apk branches assume their package names never change. | Table per PM × arch, or let `incus` pull its own deps where the distro does that (Debian does). |
|
||||
| 5.3 | **B** | `internal/incus/incus.go:958, 970` `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold`; `internal/updates/updates.go:78, 432` | Debian-isms exported unconditionally. Harmless elsewhere. | Fine; note only. |
|
||||
| 5.4 | **B** | `scripts/pci-ids-gpu.sh:7, 41` default `/usr/share/misc/pci.ids` | Debian's path for `pci.ids`; Fedora/Arch keep it in `/usr/share/hwdata/`. Build-time only. | Try both. |
|
||||
| 5.5 | **B** | `internal/api/status_sensors.go:283`, `internal/api/docker.go:133` | Container memory/CPU read from three cgroup paths: cgroup v2 + systemd driver, cgroupfs, libpod. cgroup v1 hosts (older Debian/Ubuntu, some Alpine, OpenWrt) get no `container.*.mem/cpu`. | Add the v1 `memory/docker/<id>` and `cpu,cpuacct` paths, or fall back to `docker stats --no-stream` once in a while. |
|
||||
| 5.6 | **B** | `internal/metrics/metrics.go:1064` `clkTck = 100` | USER_HZ assumed 100. True on every mainstream build; wrong on a kernel built otherwise. | `sysconf` via `getconf CLK_TCK` once at start. |
|
||||
| 5.7 | **B** | `internal/incus/subnet.go:150-179` `ip -j`; `internal/space/manager.go:578-585` `timeout -s KILL` | iproute2 JSON output (busybox `ip` has no `-j`); GNU `timeout`. | Parse plain `ip route`/`ip addr`; busybox has `timeout` too but flag spelling differs — use the context deadline. |
|
||||
|
||||
## 6. Hardware and vendor assumptions
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 6.1 | **B** | `internal/metrics/gpu.go:343-383` `read()` | Busy/clock/VRAM are read for **i915, xe, amdgpu** only — the three GPUs in this lab. Nouveau, nvidia-drm, panfrost, v3d, Asahi are listed but never measured. `PLAN.md:317`: "No NVIDIA path: nothing here has one to test against." | `nvidia-smi --query-gpu` where present; `/sys/class/drm/*/device/gpu_busy_percent` for nouveau; `devfreq` for the ARM drivers. |
|
||||
| 6.2 | **B** | `internal/metrics/pciids_gpu.txt`, `scripts/pci-ids-gpu.sh:12-15` | Embedded PCI ID table trimmed to Intel, AMD, NVIDIA and three virtual vendors. Matrox, ASPEED (every server BMC), Moore Threads, etc. fall back to `lspci` or "Vendor GPU <id>". | Include the full display-class device list, not three vendors. |
|
||||
| 6.3 | **B** | `internal/metrics/gpu.go:237-241` | "Integrated" = PCI bus `0000:00:`. Wrong on multi-domain or APU boards where the iGPU hangs off a bridge; cosmetic. | Use `boot_vga` + `PCI_SUBSYS` heuristics, or drop the flag. |
|
||||
| 6.4 | **B** | `internal/metrics/metrics.go:189-196, 917-968` | UPS probe order **pwrstat (CyberPower) first**, then NUT, then apcupsd; pwrstat's `....`-separated output is parsed bespoke. A host with both pwrstat and NUT shows pwrstat only. | Prefer NUT (the vendor-neutral one), then the vendor tools; or list every UPS found. |
|
||||
| 6.5 | **B** | `internal/metrics/metrics.go:1179-1182` `cpuTemp()` | CPU temperature from `coretemp`, `k10temp`, `thermal`/`cpu`, `cpu_thermal`, `soc_thermal`. Misses `zenpower`, `acpitz` as the only sensor, Apple `macsmc` names, many ARM SoC zone names. | Broaden; fall back to the hottest zone when none matches. |
|
||||
| 6.6 | **B** | `internal/hardware/hardware.go:617-627` `jedec` | JEDEC IDs for "the memory makers who turn up in a homelab" — 14 entries. | Embed the JEDEC JEP106 bank list (public, small). |
|
||||
| 6.7 | **B** | `internal/storage/storage.go`, `internal/api/zfs.go`, `internal/storage/arc.go`, `web/src/ZFS.tsx` | A whole **ZFS** tab and ZFS-aware Space/Disks logic; **no btrfs or LVM equivalent** (snapshots, subvolumes, scrub, balance). Not wrong, but the tab list reflects a ZFS NAS. | Later: a Btrfs tab with the same shape, or a generic "Filesystems" tab that shows what the host has. |
|
||||
| 6.8 | **B** | `internal/storage/snapshots.go:1-16, 966-968` | Snapshot naming is **zfSnap's** (`2026-09-24_02.30.01--6w`) because `grid` ran zfSnap. A sanoid/zfs-auto-snapshot user's existing snapshots (`autosnap_…`, `zfs-auto-snap_daily-…`) are detected (`internal/api/zfs.go:1764-1798`) but can never be expired by Ham Lab, and new schedules write a third convention beside them. | Make the naming a per-schedule choice (zfSnap / sanoid / zfs-auto-snapshot), or at least document the convention as the product's rather than inherited. |
|
||||
| 6.9 | **B** | `internal/network/speedtest.go:64-67` `downSources` | Fallback download source is **`speedtest.seattle.linode.com`** — chosen because the lab is in the Pacific Northwest. Everywhere else it reads low. | Pick the nearest of several mirrors, or drop the fallback and report the refusal. |
|
||||
| 6.10 | **B** | `internal/network/speedtest.go:70-74, 604`, `internal/network/network.go:323-334` | Public IP, edge and speed come from **Cloudflare** endpoints, polled every 15 min with no off switch. Air-gapped or privacy-minded installs have no way to disable the outbound probe. | A `network.outside: off` setting; honour it in the watcher. |
|
||||
| 6.11 | **B** | `internal/vpn/edit.go:5-7, 419-421` | Peers can be **created/edited only through OpenWrt UCI** "because that is where the server here is configured". A wg-quick server (the common case everywhere else) is read-only. | Write wg-quick `.conf` too (append a `[Peer]` block + `wg set`), guarded the same way. |
|
||||
| 6.12 | **B** | `internal/vpn/vpn.go:251-253` | Peer names come from wg-quick comments or UCI descriptions — "which between them cover every machine here". NetworkManager and systemd-networkd WireGuard configs are not read. | Add `.nmconnection` and `.netdev` readers. |
|
||||
| 6.13 | **B** | `internal/vpn/edit.go:515` `profile()` | Split-tunnel routes hard-code `10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12` on top of the tunnel's own prefix. | Offer the host's actual LAN prefixes (from `TakenPrefixes`-style route reading), with RFC 1918 as the fallback. |
|
||||
| 6.14 | **B** | `internal/incus/subnet.go:1-9, 23-34, 55`; `web/src/Virtualization.tsx:133` | Bridge subnet candidates skip **all of 10/8** because "this LAN is 10.0.0.0/8"; order is 172.16.100–254, then 192.168.254–200, then 172.16.1–99; last-resort `172.16.100.0/24`. Works anywhere, but a lab on 172.16/12 gets 192.168.x first and a lab on 192.168/16 never gets its own range. | Read the host's routes (already done) and pick from all three RFC 1918 blocks in a neutral order. |
|
||||
| 6.15 | **B** | `internal/network/network.go:170-199, 303-314`; `internal/incus/subnet.go`; `internal/vpn/edit.go:558-595`; `internal/tlsauto/tlsauto.go:533-537`; `install.sh:114-120`; `internal/setup/setup.go:648-666` | **IPv4 everywhere**: default route from `/proc/net/route` only, outside view v4 only, bridge/peer allocation v4 only, cert SANs v4 only, installer lists v4 addresses only. | `/proc/net/ipv6_route`, v6 SANs, v6 peer addresses. An IPv6-only host today has no gateway, no cert SAN and no bridge. |
|
||||
|
||||
## 7. Privilege and deployment posture (the lab's NOPASSWD-everywhere model)
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 7.1 | **A** | `internal/priv/priv.go`; every tab that calls `priv.Available()`; `web/src/Updates.tsx:2144` "give its user passwordless sudo or doas"; `web/src/Jobs.tsx:1883` | Full function needs the service user to have **unrestricted passwordless sudo**, which `hamlet/CLAUDE.md` grants on every host here. The installer neither creates nor offers a sudoers rule, so on a fresh box most tabs are read-only and the UI tells the user to go fix sudoers by hand. | `hamlab install --sudo` writes a **scoped** `/etc/sudoers.d/hamlab` naming the commands Ham Lab runs (smartctl, zpool, zfs, systemctl, docker, journalctl, tee for its cron files, `hamlab fs|space-walk|drm-clients|cpu-energy|replace`), or installs as root. Either way the console works out of the box. |
|
||||
| 7.2 | **A** | `internal/api/server.go:1069-1080` `clientKey()`, `:1083-1092`, `:431-433` `secure()` | The **first `X-Forwarded-For` hop is trusted unconditionally**, because here Ham Lab always sits behind Nginx Proxy Manager. On a host exposed directly, a client sets the header and defeats the per-client login rate limiter and the session IP check. | Trust `X-Forwarded-*` only from configured proxy addresses (`trusted_proxies` in config), else use the socket peer. |
|
||||
| 7.3 | **B** | `internal/api/server.go:1324` `X-Accel-Buffering: no` "nginx (NPM)" | Harmless header; comment names the lab's proxy. | Keep header; generalise comment. |
|
||||
| 7.4 | **B** | `internal/api/guests.go:1-9`, `PLAN.md:21` | Guest logins are full-power ("he gives these to people he trusts"). A product default. | Keep, but it is a posture decision taken for this household; a read-only guest is the generic expectation and was cut. Document as a deliberate choice in README. |
|
||||
| 7.5 | **B** | `internal/api/users.go:1-6` | "a household console, not a tenancy": any signed-in user can add users, make tokens, open a root shell. | Same as 7.4 — fine, but say so where a stranger installs it. |
|
||||
|
||||
## 8. Resource tuning from the lab's smallest host
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 8.1 | **B** | `cmd/hamlab/main.go:451` `debug.SetMemoryLimit(160 << 20)`; `PLAN.md:11` "`core` has ~370 MiB free RAM" | A fixed 160 MiB soft heap limit sized for `core`. On a 64 GiB NAS it forces needless GC under a big Space scan; on a 512 MiB Pi it is still half the box. | Scale from `MemTotal` (e.g. 2–5 %, floor 64 MiB, ceiling 1 GiB), or make it a config key. |
|
||||
| 8.2 | **B** | `internal/space/walk.go:893` `workers := 32`; `internal/space/manager.go:578` 3-minute `zfs diff` patience | Tuned on `grid` (16 cores) and `core`. | Workers from `GOMAXPROCS`; patience configurable. |
|
||||
| 8.3 | **C** | `internal/metrics/metrics.go:32-43` intervals, `History = 300` | Reasonable generic defaults; listed only because 8.1 made them look lab-tuned. | None. |
|
||||
|
||||
## 9. Data directory and legacy paths
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 9.1 | **B** | `internal/config/config.go:115-117` `Candidates()`; `internal/setup/setup.go:413-422`; `README.md:38` | `~/apps/hamlab/config.json` is searched as a legacy location — the lab's pre-v0.2 path, kept so `core`, `grid` and the Mac keep working. | Keep for one more release with a one-time migration log line, then drop. A stranger never had it. |
|
||||
| 9.2 | **done** 2026-10-10 | 3.3 above; `PLAN.md:24` "`grid`'s and the Mac's stacks at `~/stacks/<machine>/` are the standing exception" | The "everything under DataDir" rule has a lab-shaped exception baked into the stacks code. | Default to DataDir; treat `~/stacks` as a discovered home like any other. |
|
||||
| 9.3 | **C** | `internal/setup/setup.go:351` `ownFiles` | Correctly scoped; note only. | None. |
|
||||
|
||||
## 10. Build and release
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 10.1 | **B** | `Makefile:60-61, 442-443`; `scripts/gitea-release.sh` | Publishing is Gitea/Forgejo-only (release API shape). `selfupdate` also understands a static directory, so publishing could too. | `make release` + any static host is already enough; add a GitHub/Forgejo-agnostic `release-dir` note. |
|
||||
| 10.2 | **C** | `scripts/gitea-release.sh:500-515` | Needs `python3` on the publishing host. | `jq`, or do it in Go (`hamlab publish`). |
|
||||
| 10.3 | **C** | `Makefile:392` `ARCHES ?= amd64 arm64 arm 386 riscv64` | Fine. `ppc64le`/`s390x` are mapped by `IncusArch()` but never built. | Add or drop them from `IncusArch`. |
|
||||
|
||||
## 11. Things that are already general (checked, no change needed)
|
||||
|
||||
Listed so the sweep is known to be complete, not because they need work.
|
||||
|
||||
- `internal/priv` (root / sudo / doas), `internal/setup` service templates, `IP_FREEBIND` listen, self-update atomic replace and re-exec.
|
||||
- `internal/updates`: apt, dnf, zypper, pacman, apk with refresh/upgrade/install.
|
||||
- `internal/storage` block devices: `lsblk -J` with a `/sys/block` fallback, eMMC health, NVMe namespace folding, SMART only when `smartctl` exists.
|
||||
- `internal/metrics` CPU/mem/hwmon/thermal/net/disk/fs from `/proc` and `/sys`; remote-FS and pseudo-FS lists; filesystem statfs with a timeout.
|
||||
- `internal/hardware`: every reader may come back empty with a note; device-tree fallbacks for non-SMBIOS boards.
|
||||
- `internal/incus` detection and the per-machine "ready / repair / installable / unavailable" model (modulo 4.2, 5.1, 5.2).
|
||||
- `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
|
||||
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
|
||||
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`.
|
||||
- `internal/api/kvm.go`: "nothing in it knows anything about any particular KVM" — verified.
|
||||
- `internal/compose` (modulo 3.5).
|
||||
- The React UI: machine-switcher model, hash routing, per-account prefs, themes.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. **Posture and safety first (A):** 7.2 trusted proxies; 7.1 installer-written scoped sudoers; ~~3.1 drop the `:2375` probe (with 3.2 as its consequence)~~ done 2026-10-10.
|
||||
2. **"Any Linux" promises the README already makes (A):** 4.1 power, 4.4/4.5 cron on busybox and timer-only hosts, 4.2 Incus install on non-systemd, 4.3 a Services backend for OpenRC/procd, 2.10 the `tank` regex.
|
||||
3. **Lab defaults that phone home or mislead (B):** 1.1–1.3 update/install URLs, 2.9 schedule defaults, 6.9/6.10 speed-test sources and an off switch, 8.1 memory limit, ~~3.3/9.2 stack home = DataDir~~ done 2026-10-10.
|
||||
4. **Breadth (B):** 5.1 zypper for Incus, 5.5 cgroup v1, 6.1/6.2 GPUs beyond Intel/AMD, 6.4 UPS order, 6.11/6.12 wg-quick/NetworkManager, 6.15 IPv6.
|
||||
5. **Cosmetics (C):** every placeholder and comment in §1.10 and §2, then split lab facts out of `PLAN.md`, `VIRTUALIZATION.md`, `SENSORS.md`, `README.md`.
|
||||
@@ -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,
|
||||
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:
|
||||
|
||||
| | |
|
||||
@@ -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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
+27
-1
@@ -12,13 +12,16 @@ added to the registry in `api.New`.
|
||||
## IDs
|
||||
|
||||
```
|
||||
cpu.usage cpu.core.<n>.usage cpu.temp load.1|5|15 uptime
|
||||
cpu.usage cpu.core.<n>.usage cpu.temp cpu.power load.1|5|15 uptime
|
||||
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
||||
temp.<chip>.<label> fan.<chip>.<label>
|
||||
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
|
||||
fs.<mount>.size|used|used_pct
|
||||
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
||||
gpu.<name>.usage|render|video|video-enhance|copy|compute|temp|power|fan|freq|mem_used|mem_total|clients
|
||||
zfs.arc.size|target|min|max|mru|mfu|data|metadata|hit_pct|reads|misses|lifetime_hit_pct|no_grow|compress_ratio|l2_size|l2_hit_pct
|
||||
```
|
||||
|
||||
- Segments are lower case `[a-z0-9_-]`: `Package id 0` → `package_id_0`, `/mnt/tank` →
|
||||
@@ -26,6 +29,29 @@ 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
|
||||
boots). Two identical chips collide; the second gets `_2`.
|
||||
- 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.
|
||||
- `cpu.power` is the CPU packages' own power in watts, from the kernel's RAPL energy
|
||||
counters (Intel, and AMD Zen), summed over every package: cores, built-in graphics and
|
||||
memory controller, not the whole machine. The counters are root-only, so a daemon that is
|
||||
not root reads them through a helper started once with sudo or doas. It is absent on a
|
||||
machine without the counters (ARM, the Mac mini) or without a way to root.
|
||||
- `gpu.<name>.*` is each graphics device, named as lspci names it (`gpu.intel_arc_a380`;
|
||||
labels carry `driver` and the PCI `bus`). `usage` is how busy the device was — from the
|
||||
GT's idle residency on Intel, `gpu_busy_percent` on amdgpu — and is absent on a driver
|
||||
with no such counter. The per-engine percentages (`render`, `video`…) are the sum over the
|
||||
processes holding the device open, read from DRM fdinfo, and exist only while something
|
||||
has it open. `temp` is the card's own sensor, or on an integrated GPU the CPU package it
|
||||
shares a die with (label `source` says which); an Arc on i915 has none and reports none.
|
||||
`freq` is the actual clock, 0 when power-gated. `clients` counts the processes using it.
|
||||
- `zfs.arc.*` is the ZFS read cache in RAM, on a machine with the module loaded. `hit_pct`
|
||||
is over the last sampling interval and appears once an interval has had any reads (an idle
|
||||
interval repeats the last value); `lifetime_hit_pct` is since boot. `no_grow` is true
|
||||
while memory pressure stops the cache growing. `l2_*` appear only with cache devices.
|
||||
|
||||
## A sensor
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# Virtualization tab — plan (2026-10-09; phase 1 built the same day)
|
||||
|
||||
**Status:** phases 1 and 2 are **deployed to all five machines as v0.1.0-188** (2026-10-09).
|
||||
Incus runs on `grid` (installed from the tab); the other machines show Install or, on `gate`,
|
||||
unavailable. Phase 2 verified live on `grid` through the hub: ISO fetched by URL with its
|
||||
sha256 checked, **New VM from ISO** booted Alpine with Secure Boot off, **the VGA console
|
||||
drew it in a real browser** (headless Chromium, 1280×800 canvas, keyboard and mouse live),
|
||||
a virtiofs mount appeared in a running VM, an export tarball landed in `virt/exports/` and the
|
||||
nightly cron line is written, `instance.*` sensors flow and the "VM or container down" alert
|
||||
default is in. Two things found and fixed on the way: spice-html5 wants the `binary`
|
||||
websocket subprotocol agreed; and it opens one websocket per SPICE channel, so the bridge
|
||||
holds **one console operation per VM** and gives each channel its own data websocket on it
|
||||
(Incus refuses a second operation as "already connected"). **Left from phase 2:** the LAN
|
||||
bridge step (`br0` on the uplink — needs a live test on a machine that can afford to drop
|
||||
off the network for a moment, which is a decision, not a build), cloud-init presets beyond
|
||||
"Use my SSH keys", and a backup line for `grid`'s `virt/exports/` (see `hamlet/docs/backups.md`).
|
||||
`t-alpine` on `grid` is the live example; delete it from the tab when done.
|
||||
|
||||
A tab for creating, watching, consoling into and deleting **LXC containers and KVM virtual
|
||||
machines**, the way Proxmox does with `pct` and `qm` — **per machine, like every other
|
||||
page.** This is the scoping PLAN.md asked for. Nothing here is built. Live state of the
|
||||
hosts stays in `hamlet/docs/hosts.md`.
|
||||
|
||||
## The governing rule: capability, not policy
|
||||
|
||||
Stated by Mitchell 2026-10-09, after the first draft of this plan excluded hosts on the
|
||||
strength of the homelab's deployment rules:
|
||||
|
||||
> Anything that's done when a different machine is selected SHOULD GO ON THAT machine. I
|
||||
> want the option to do it anywhere I want, unless the hardware / software literally
|
||||
> doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on
|
||||
> any Linux system. If certain parts / libraries aren't available on that machine, those
|
||||
> tabs should just report that.
|
||||
|
||||
So:
|
||||
|
||||
- **Every action lands on the machine in the switcher.** There is no "VM host", no default
|
||||
machine, no cross-machine placement. `grid` is where he will most likely click first;
|
||||
the tab does not know or care.
|
||||
- **The tab is gated by what the machine can do, detected live** — never by a rule about
|
||||
what *should* run there. The `hamlet/CLAUDE.md` do-not-break rules (Mac mini for
|
||||
single-core workloads, `hamtv` not spare hardware) bind the *agent's* unasked
|
||||
deployments. They are not feature gates for the product, and the first draft was wrong
|
||||
to treat them as such.
|
||||
- **Ham Lab owns its data, and its data lives in its own folder.** Everything this tab
|
||||
manages — instance storage, ISOs, image tarballs, exports — sits under Ham Lab's data
|
||||
directory on *that* machine (`config.DataDir()`, `~/apps/hamlab` on `core`, `grid` and
|
||||
the Mac), in one `virt/` tree. Never a path elsewhere on the machine, however convenient
|
||||
the existing folder. Stated 2026-10-09 after the first Library draft pointed the ISO shelf
|
||||
at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it
|
||||
manages its own data."* This is the rule that moved `core`'s compose files under
|
||||
`apps/hamlab/stacks/` on 2026-10-05; `grid`'s and the Mac's stacks, still at
|
||||
`~/stacks/<machine>/`, are the outstanding exception and are not touched by this plan.
|
||||
The engine's own runtime state (`/var/lib/incus`, like `/var/lib/docker`) is the engine's,
|
||||
not Ham Lab's data: Ham Lab keeps what it would need to rebuild it.
|
||||
- **Ham Lab installs or reports.** It detects what is missing, offers to install it where it
|
||||
knows how, and where it cannot or the hardware cannot do the job, says so in one sentence
|
||||
and offers what *does* work (containers without KVM, for instance).
|
||||
|
||||
## Decision: Incus 6.0 LTS as the engine
|
||||
|
||||
| Option | Verdict |
|
||||
|---|---|
|
||||
| **Incus** (LXD fork, linuxcontainers.org) | **Chosen.** One daemon, one CLI and one JSON API for both containers and VMs. Packaged by Debian 13 (`incus` 6.0.4 LTS — `apt install incus` pulls `qemu-system-x86`, `swtpm`, `virtiofsd`, `incus-agent`, so VMs work out of the box), Fedora (6.23), Arch, Alpine, Ubuntu (Zabbly repo); not by OpenWrt. Storage drivers for ZFS, btrfs, LVM and plain directories, so it fits a machine with or without ZFS. Built-in image server (`images:debian/13`, `images:ubuntu/24.04`, Alpine, Fedora, Arch… as container *or* VM, per architecture), snapshot schedules with expiry, `incus export` backups, per-instance CPU/memory limits, cloud-init, text and VGA console over websocket. Everything is reachable as `incus … --format json` or `incus query`, which is exactly how Ham Lab already drives Docker: `exec.Command`, no new Go dependency, no cgo. |
|
||||
| libvirt/KVM + `virsh`, plus LXC separately | Two daemons, two models, XML. libvirt's official Go binding needs cgo, which the static binary rules out (the pure-Go `go-libvirt` exists but then containers are a second project). Rejected. |
|
||||
| Proxmox | An OS, not a library. Rejected. |
|
||||
|
||||
## What each machine can do today
|
||||
|
||||
Probed 2026-10-09 (`/dev/kvm`, cpu flags, kvm modules, RAM, disk, package availability).
|
||||
These are **facts the tab will detect at runtime**, not a list of where instances are
|
||||
allowed. The last column is what the tab would show the day it ships.
|
||||
|
||||
| Machine | Arch | KVM | RAM (free) | Incus from the package manager | Pool driver available | Tab state on first open |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `grid` | x86-64 | yes — VT-x, `kvm_intel` loaded, nested on | 62 GiB (~25; ARC holds 23 of the rest, max 40) | apt: `incus` 6.0.4 | **zfs** (2.3.9, `tank`) | **Install** — containers and VMs |
|
||||
| `core` | x86-64 | yes — N100, `kvm_intel` loaded | 7.5 GiB (~4) | apt: `incus` 6.0.4 | btrfs or dir (ZFS was purged 2026-09-23) | **Install** — containers and VMs; the create form shows free memory so a VM's size is an informed choice |
|
||||
| Mac mini | aarch64 | yes — Asahi KVM, 16K pages | 15 GiB (~8) | dnf: `incus` 6.23, `qemu-system-aarch64-core`, `edk2-aarch64` | btrfs or dir | **Install** — containers and VMs, **arm64 images only** (the picker filters by host arch). Whether a stock 4K-page guest kernel boots under the 16K host is untested; `muvm` does exactly that, so it is expected to. |
|
||||
| `hamtv` | x86-64 | yes | 3.7 GiB (~2.9) | apt: `incus` 6.0.4 | btrfs or dir | **Install** — containers and small VMs |
|
||||
| `gate` | x86-64 | **no** `/dev/kvm` | 8 GiB (~6) | **none** — OpenWrt's opkg has no Incus | — | **Unavailable**: "Incus isn't packaged for OpenWrt, and this kernel has no KVM." Nothing to install. |
|
||||
|
||||
Nothing is installed anywhere today: no qemu, libvirt, lxc or incus on any host.
|
||||
|
||||
### Detection the tab runs, in order
|
||||
|
||||
1. `incus` on `PATH` and its socket answering → **ready**; read pools, networks, instances.
|
||||
2. Else: a package manager Ham Lab knows (apt, dnf, pacman, apk) that has an `incus`
|
||||
candidate → **installable**, with the Install dialog pre-filled (below).
|
||||
3. Else → **unavailable**, with the reason in one sentence (no package; no root; OpenWrt).
|
||||
4. Independently: `/dev/kvm` present and openable → VMs offered. Absent → **containers
|
||||
only**, said once on the page, and the create form has no VM option.
|
||||
5. Architecture from `uname -m`, so the image picker only shows images that can run here.
|
||||
6. Pool driver: `zfs` if the module and tools are present, else `btrfs` (installing
|
||||
`btrfs-progs` as part of Install), else `dir`. The dialog says which it picked and why.
|
||||
|
||||
## What the tab does (v1)
|
||||
|
||||
Mirrors the Docker tab's shape: a list, a detail page, actions that are followed tasks,
|
||||
destructive ones confirmed by typing the name.
|
||||
|
||||
- **Install** — one task that runs *on the selected machine*: install the package(s),
|
||||
`incus admin init --preseed` with the layout from the dialog, create the pool and bridge,
|
||||
add Ham Lab's user to `incus-admin` (socket access, the `docker`-group shape) and add it
|
||||
to the unit's `SupplementaryGroups`, apply the Docker fix where Docker is present (below),
|
||||
then restart Ham Lab. Idempotent, so it doubles as "repair". The dialog shows and lets him
|
||||
edit: pool driver (detected), pool size (proposed from free space), bridge subnet
|
||||
(computed), whether to apply the Docker rules. Where it all goes is not a choice: Ham
|
||||
Lab's folder.
|
||||
- **Instances** — containers and VMs in one list: type, state, IPv4, CPU and memory live,
|
||||
disk, snapshot count, uptime. Click for detail.
|
||||
- **Create** — container or VM (VM only where KVM is); image from the `images:` remote,
|
||||
filtered to this architecture, searchable, with the common ones pinned (Debian 13, Ubuntu
|
||||
24.04, Alpine, Fedora); name, CPUs, memory (with this machine's free memory beside it),
|
||||
root disk size; network (the NAT bridge, or a LAN bridge if this machine has one);
|
||||
optional cloud-init user-data (SSH key, packages, a first command); profile. Create, or
|
||||
create and start. Collisions (name taken) named before Create.
|
||||
- **Lifecycle** — start, stop, restart, force stop, rename, delete (type the name; the
|
||||
instance's snapshots go with it, and the dialog says so).
|
||||
- **Console** — two buttons, both open as tabs in the Terminal page through the existing PTY
|
||||
manager (`term.CreateCmd`, the `docker exec` path): **Console** (`incus console`, the
|
||||
serial console — works before the network is up, shows boot output) and **Shell**
|
||||
(`incus exec <n> -- login shell`). Containers and, via `incus-agent`, VMs.
|
||||
- **Snapshots** — take, restore (confirmed), delete; a **schedule** per instance with an
|
||||
expiry, which is Incus's own `snapshots.schedule`/`snapshots.expiry`, so no cron file of
|
||||
ours this time.
|
||||
- **Resources** — edit CPU and memory limits (live for containers; VMs take CPU hotplug and
|
||||
memory balloon live with the agent, else on restart), grow the root disk, add a bind mount
|
||||
(a host path into a container; virtiofs into a VM).
|
||||
- **Library** — ISOs for VMs and images (container templates and VM images). Its own
|
||||
section below.
|
||||
- **Storage and networks** — the pools with their fill, the bridges with their subnets.
|
||||
Read-only in v1 beyond what Create and Install need.
|
||||
- **Sensors** — `instance.<name>.cpu`, `.memory`, `.running` from Incus's state, like the
|
||||
`container.*` sensors, so Alerts gets an "instance down" default and widgets can show one.
|
||||
|
||||
## Where the data lives
|
||||
|
||||
One tree per machine, under Ham Lab's data directory, created by Install:
|
||||
|
||||
```
|
||||
~/apps/hamlab/virt/
|
||||
pool.img the instance pool's backing file (zfs or btrfs loop; a plain directory
|
||||
pool/ when the driver is dir) — every instance's disks live in here
|
||||
isos/ the ISO library: the files themselves
|
||||
images/ the image library: template tarballs and VM disk images as files, the
|
||||
way Proxmox keeps CT templates — Incus's image store is loaded from here
|
||||
exports/ nightly `incus export` tarballs, with retention
|
||||
virt.json what Ham Lab chose and when: driver, pool size, bridge subnet, ISO and
|
||||
image inventory, export schedule
|
||||
```
|
||||
|
||||
Consequences, stated once:
|
||||
|
||||
- The tree is on whatever disk holds Ham Lab's data directory — the boot NVMe on all three.
|
||||
Install proposes the pool size from that disk's free space. On `grid` that disk has
|
||||
790 GiB free; on `core`, 175 GiB.
|
||||
- Bulk data a guest needs is **bind-mounted in from where it already is** (`/tank/…` into an
|
||||
instance on `grid`), the same split Docker has: the instance is Ham Lab's, the media is
|
||||
not. That is a device on the instance, recorded in its config, not a second home.
|
||||
- On `core` the whole tree rides the existing nightly rsync to `grid` for free. On `grid`
|
||||
and the Mac it is as backed up as the rest of their Ham Lab folders — which is to say the
|
||||
exports are the backup, and the backup job will need to be told about them in phase 2.
|
||||
- Existing material elsewhere — the 105 GiB of ISOs at `/tank/software/isos/`, the Proxmox
|
||||
archives — is **imported into the tree by copying**, file by file, on request. The
|
||||
library never indexes a folder it does not own.
|
||||
|
||||
## Library: ISOs and images (asked for 2026-10-09)
|
||||
|
||||
Proxmox has a storage view with **ISO Images** and **CT Templates**. Incus has the same two
|
||||
things under other names, and the tab gives them one home, a **Library** sub-tab, per machine
|
||||
like everything else.
|
||||
|
||||
| Proxmox calls it | Incus calls it | What it is here |
|
||||
|---|---|---|
|
||||
| ISO Images | an ISO file attached to a VM as a `disk` device (added as a ROM device; VMs only), or a custom storage volume of type `iso` | **`virt/isos/` in Ham Lab's folder**, attached by path — no copy into the pool |
|
||||
| CT Templates | **images** — the `images:` remote (linuxcontainers.org), the local image store, anything imported or published | **`virt/images/` in Ham Lab's folder** — tarballs and disk images as files, loaded into Incus from there; the remote is the catalogue to pull from |
|
||||
|
||||
### ISOs
|
||||
|
||||
- **The ISO library is `virt/isos/` in Ham Lab's folder**, on every machine, full stop. The
|
||||
105 GiB at `/tank/software/isos/` on `grid` (`linux/`, `windows/`, `truenas/`,
|
||||
`virtio_drivers/`…) is a *source* to import from — pick the ones wanted, Ham Lab copies them
|
||||
in — not the library itself.
|
||||
- **Getting an ISO in:** upload from the browser (the Files tab's `PUT /api/files/upload`
|
||||
with its progress bar, reused), **download from a URL** as a followed task with an optional
|
||||
checksum (the usual way — an ISO starts as a link), or **import from a path** already on the
|
||||
machine — which **copies** the file into `virt/isos/` and says so. Rename, delete (refused while a VM has it attached, naming the VM).
|
||||
- **Using one:** attach to / detach from a VM as a ROM device by path, with `boot.priority`
|
||||
set so it boots first; **New VM from ISO** makes an empty VM (`incus init --empty --vm`)
|
||||
with the ISO attached, starts it and opens the console. After install, Detach is one click
|
||||
and boot order falls back to the disk.
|
||||
- **Honest limit:** an ISO install is an interactive graphical installer, so it needs the
|
||||
**VGA console in the browser** (`spice-html5` over Incus's VGA console websocket).
|
||||
Installers that run on a serial console (Debian with `console=ttyS0`, Alpine, Arch) work
|
||||
through the text console before that exists. Windows needs the virtio drivers ISO attached
|
||||
as a second ROM device as well — Incus's documented path is `distrobuilder repack-windows`
|
||||
to bake them in; the drivers are already in `virtio_drivers/` on `tank`. So **the ISO shelf
|
||||
and the VGA console ship together, in phase 2**, moved up from phase 3.
|
||||
- ISO *volumes* (`incus storage volume import <pool> <iso> <name> --type=iso`) are the
|
||||
alternative: read-only, attachable to several VMs at once, but they copy the file into the
|
||||
pool. Not used here — a file in `virt/isos/` is already Ham Lab's, and a volume would copy it
|
||||
a second time into the pool. Offered as an option only if a need shows up.
|
||||
|
||||
### Images (container templates and VM images)
|
||||
|
||||
- **The catalogue:** browse `images:` filtered to this machine's architecture, by type
|
||||
(container / VM), with the common ones pinned. **Download** copies one into the local
|
||||
store (`incus image copy images:debian/13 local: --alias debian-13`) so a later Create is
|
||||
instant and works without the internet. Images used by Create are cached by Incus anyway;
|
||||
this is just the explicit version, Proxmox's "Templates → Download".
|
||||
- **The local store:** every image here with alias, type, arch, size, origin (remote,
|
||||
published, imported), when it was last used, and **which instances were made from it**
|
||||
(Incus records the base image on each instance). Alias, delete (refused while an
|
||||
instance's base — Incus knows), "New instance from this image".
|
||||
- **Import:** a unified tarball or metadata + rootfs pair (a container template from
|
||||
anywhere, including the phase-3 Proxmox conversions), or a **qcow2 / raw disk with
|
||||
metadata Ham Lab writes** for a VM image — Home Assistant OS, OPNsense, a vendor cloud
|
||||
image — by upload, URL, or a path on the machine. Same three ways in as ISOs.
|
||||
- **Publish:** a stopped instance becomes an image with an alias — Proxmox's "convert to
|
||||
template", the one thing the Images section of v1 always had.
|
||||
- **Where they live:** as files in `virt/images/` — the downloaded or imported tarball, the
|
||||
published image exported to one (`incus image export`) — so the library is complete without
|
||||
Incus and can be reloaded into a fresh install. Incus's own image store under
|
||||
`/var/lib/incus/images/` is a cache derived from it, and the page says so.
|
||||
|
||||
## Not in v1, with reasons
|
||||
|
||||
| Thing | Why not yet |
|
||||
|---|---|
|
||||
| **VGA console in the browser** | Incus hands the VM's SPICE socket over a websocket; drawing it needs `spice-html5` in the UI. Doable (the Incus UI does it); text console + shell cover every Linux guest until then. **Phase 2, with the ISO shelf** — an ISO install is useless without it. |
|
||||
| **Windows VMs** | Incus runs them (ISO repacked with virtio drivers by `distrobuilder`), but without a VGA console in the browser they are unusable from the tab. After the above. |
|
||||
| **GPU passthrough to a VM** | Removes the card from the host and from whatever uses it there (Jellyfin on `grid`). Offered only with that warning, later. Sharing a GPU into a *container* (`gpu` device) leaves the host its card and is how `r720-jellyfin` used to run. Phase 3. |
|
||||
| **Clustering / live migration** | Incus clusters, but these machines differ in size and one is arm64, with no shared storage. Not planned. |
|
||||
| **A fleet-wide instance list** | One machine per view. The switcher is the machine. |
|
||||
| **A second engine (bare LXC on OpenWrt, libvirt-only hosts)** | Would double the backend for one router that has no KVM. The tab reports Incus unavailable there and stops. |
|
||||
|
||||
## Layout decisions Ham Lab makes per machine (overridable in the Install dialog)
|
||||
|
||||
1. **Storage.** The instance pool's backing lives in Ham Lab's folder: Install creates a
|
||||
sparse `virt/pool.img` and hands it to Incus as the pool's `source` (zfs or btrfs by the
|
||||
detection above), or `virt/pool/` as a `dir` pool where neither is available. Sized from
|
||||
the disk's free space, shown and editable. Bulk data is **bind-mounted in**, not stored in
|
||||
the pool. The driver, path and size are recorded in `virt.json` so the page can say where
|
||||
the data is and Install can rebuild the pool from the file after a reinstall.
|
||||
2. **Bridge subnet, computed.** Incus's `auto` picks a random `10.x.y.0/24`, and **this
|
||||
LAN is `10.0.0.0/8`** — every host has a /8 on its NIC, so any 10.x bridge collides.
|
||||
Ham Lab reads the machine's routes and picks the first free /24 from `192.168.0.0/16`,
|
||||
then `172.16.0.0/12`, that is neither on-link nor routed nor inside Docker's configured
|
||||
pool (`172.28.0.0/14` on `grid`; `172.19`–`172.29` and `192.168.80`–`143` live on
|
||||
`core`). Instances get `name.incus` DNS from Incus's dnsmasq.
|
||||
3. **LAN addresses are a later, explicit step.** A `br0` on the machine's uplink lets an
|
||||
instance take a lease from `gate` and a Pi-hole name, but it rewrites the host's network
|
||||
config and drops it off the network for seconds — never part of Install, always its own
|
||||
confirmed action (with the NanoKVM standing by on `grid`). macvlan was considered and
|
||||
rejected: no host change, but host and guests cannot talk, so nothing on the host could
|
||||
reach an instance.
|
||||
4. **Docker and Incus on one host.** Docker sets the `FORWARD` policy to `DROP`, which
|
||||
silently kills the Incus bridge's traffic. Fix is two `DOCKER-USER` accept rules for the
|
||||
bridge, persisted — applied by Install wherever Docker is detected (`grid`, `core`, the
|
||||
Mac, `hamtv` all run it).
|
||||
5. **Memory.** A VM's memory is a hard reservation; the create form shows the machine's
|
||||
free memory and warns past it, and never refuses. On a ZFS host Install offers to cap
|
||||
the ARC (on `grid`, 40 GiB → 16 GiB) so guest RAM is deterministic; his choice.
|
||||
6. **Privilege.** Ham Lab's user joins `incus-admin`; no sudo in the hot path. Install
|
||||
itself goes through `priv` like the other installers.
|
||||
7. **Backups.** Nightly `incus export --instance-only` of every instance to `virt/exports/`
|
||||
with a retention, as a cron line like the ZFS schedules (`hamlab incus-export`), from
|
||||
phase 2. On `core` that folder is inside the nightly rsync already; on `grid` and the Mac
|
||||
the backup job gets a line for it in phase 2.
|
||||
|
||||
## The old Proxmox estate (importable, phase 3)
|
||||
|
||||
`/tank/backups/proxmox/` holds the retired `r720` node: `/etc/pve` (12 LXC confs, 2 VM
|
||||
confs), 118 GiB of `vzdump` archives, and three raw VM disks. Latest of each:
|
||||
|
||||
| ID | Name | Latest archive | Size |
|
||||
|---|---|---|---|
|
||||
| 100 | r720-docker | 2026-01-11 | 20.7 GB |
|
||||
| 101 | r720-cockpit | 2026-01-11 | 439 MB |
|
||||
| 102 | r720-wordpress-alexblaskovich | 2026-01-11 | 1.2 GB |
|
||||
| 103 | r720-spoonterm | 2025-06-14 | 494 MB |
|
||||
| 104 | r720-ollama | 2026-01-11 | 11.3 GB |
|
||||
| 105 | r720-transmission | 2026-01-11 | 322 MB |
|
||||
| 106 | r720-apache | 2026-01-11 | 347 MB |
|
||||
| 107 | r720-nut | 2025-03-29 | 205 MB |
|
||||
| 108 | r720-filebrowser | 2026-01-11 | 262 MB |
|
||||
| 109 | r720-bookstack | 2025-04-27 | 516 MB |
|
||||
| 110 | r720-jellyfin | 2026-01-11 | 2.2 GB |
|
||||
| 111 | r720-wireguard | 2025-04-13 | 510 MB |
|
||||
| 112 | r720-eggwiki | 2026-01-11 | 634 MB |
|
||||
| VMs | steam (102), powerpanel (105/107/112) | `.vma.zst` 2024–2025; raw disks `vm-105`, `vm-112`, `base-104` | 19 GB, 1.9 GB ×6, 97 GiB |
|
||||
|
||||
- **LXC:** feasible and scriptable. Unpack the rootfs (`tar --zstd -xpf … --numeric-owner`),
|
||||
read `etc/vzdump/pct.conf` for arch/ostype, drop it, write `metadata.yaml`, pack, `incus
|
||||
image import`, launch. Unprivileged uid shifting is Incus's job after that. The result is
|
||||
an image in the Library, so a restored container is launched from there like any other
|
||||
template. An import action in the tab (pick an archive on this machine) is plausible once
|
||||
the script works by hand. x86-64 archives only run on x86-64 machines; the picker says so.
|
||||
- **VMs:** the raw disks import directly (`incus-migrate`, or an empty VM with the disk
|
||||
imported into its root volume). The `.vma.zst` archives need Proxmox's `vma` extractor (or
|
||||
the Python port) to become raw disks first. Most of these are the same `powerpanel` box
|
||||
backed up six times, and `tank-space-audit.md` already has them down for deletion — decide
|
||||
which, if any, are wanted before anything is restored.
|
||||
|
||||
## Build plan
|
||||
|
||||
| Phase | What | Effort |
|
||||
|---|---|---|
|
||||
| **1 — detection, Install, the tab** | `internal/incus` (detection, CLI wrapper, JSON types, `incus monitor` for live state, the preseed builder and subnet picker), `internal/api/virt.go` (routes below), `Virtualization.tsx` (the three page states; list, detail, create dialog, snapshot panel), rail entry `virt`, terminal integration, sensors, and the Library's **Images** shelf (browse and download from `images:`, local store, import, publish). **Tested by clicking Install on `grid` first, then `core`** — two different pool drivers, both with Docker present. Then the Mac for the arm64 path. `gate` for the unavailable state. | 2–3 sessions |
|
||||
| **2 — operations (done 2026-10-09, except the LAN bridge)** | The Library's **ISO** shelf (directory, upload/URL/path, attach, New VM from ISO) **with the VGA console** (`spice-html5`), snapshot schedules UI, nightly exports with retention and the per-machine export directory, limits editor, bind mounts, the LAN-bridge step, instance alert default, cloud-init presets. | 1–2 sessions |
|
||||
| **3 — the rest** | Proxmox import (archives become Library images), GPU device for containers, Windows via `distrobuilder repack-windows`, GPU passthrough to a VM with its warning. | as wanted |
|
||||
|
||||
### API sketch
|
||||
|
||||
```
|
||||
GET /api/virt {state: ready|installable|unavailable, reason, kvm, arch,
|
||||
pool_drivers[], pools[], networks[], instances[], free_memory}
|
||||
POST /api/virt/install {driver, size, path, subnet, docker_rules, arc_max?} task
|
||||
GET /api/virt/images?remote=images&q= local cache + remote search, filtered to this arch
|
||||
DELETE /api/virt/images/{fingerprint}
|
||||
POST /api/virt/instances {name, type, image, cpu, memory, disk, network, cloudinit, profile, start} task
|
||||
GET /api/virt/instances/{name} config, state, devices, snapshots
|
||||
PATCH /api/virt/instances/{name} limits, description, devices
|
||||
POST /api/virt/instances/{name}/{start|stop|restart|kill|delete|rename|publish|export}
|
||||
POST /api/virt/instances/{name}/console → terminal {id} (incus console)
|
||||
POST /api/virt/instances/{name}/exec → terminal {id} (incus exec -- sh -lc …)
|
||||
POST /api/virt/instances/{name}/snapshots {name}; POST …/{snap}/restore; DELETE …/{snap}
|
||||
PUT /api/virt/instances/{name}/schedule {cron, expiry}
|
||||
POST /api/virt/import {path} task (export tarball, or phase-3 Proxmox archive)
|
||||
|
||||
GET /api/virt/isos virt/isos/, scanned: name, size, attached-to
|
||||
POST /api/virt/isos/import {path} task: copy a file on this machine into virt/isos/
|
||||
POST /api/virt/isos/fetch {url, sha256?} task; uploads reuse PUT /api/files/upload
|
||||
DELETE /api/virt/isos/{name} refused while attached
|
||||
POST /api/virt/instances/{name}/iso {iso, boot_first} attach; DELETE …/iso detach
|
||||
POST /api/virt/instances/from-iso {name, iso, cpu, memory, disk, network} task → console
|
||||
GET /api/virt/images/remote?q=&type= the `images:` catalogue for this arch
|
||||
POST /api/virt/images/download {alias} task (incus image copy images:… local:)
|
||||
POST /api/virt/images/import {path | url, alias, type} task: copy into virt/images/, then load into Incus
|
||||
GET /api/virt/instances/{name}/vga websocket: Incus VGA console → spice-html5 (phase 2)
|
||||
```
|
||||
|
||||
Everything is per machine and reaches a node through the existing `/api/nodes/{name}/…`
|
||||
proxy with no hub-side code; consoles attach over the existing `/api/terminals/{id}/attach`.
|
||||
Tasks stream through `/api/tasks/{id}/stream` as Docker's do.
|
||||
|
||||
## Sources
|
||||
|
||||
- Incus on Debian: https://wiki.debian.org/Incus
|
||||
- Incus and Docker on one host (the `FORWARD` drop): https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/
|
||||
- Bridge options and `ipv4.address: auto`: https://linuxcontainers.org/incus/docs/main/reference/network_bridge/
|
||||
- Consoles (text and VGA): https://linuxcontainers.org/incus/docs/main/howto/instances_console/
|
||||
- ZFS storage driver: https://linuxcontainers.org/incus/docs/main/reference/storage_zfs/
|
||||
- macvlan's host-isolation: https://linuxcontainers.org/incus/docs/main/reference/network_macvlan/
|
||||
- Image format for the Proxmox import: https://linuxcontainers.org/incus/docs/main/reference/image_format/
|
||||
+139
-1
@@ -4,6 +4,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -29,8 +30,10 @@ import (
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/nodes"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/services"
|
||||
"hamlab/internal/setup"
|
||||
"hamlab/internal/space"
|
||||
@@ -59,6 +62,9 @@ func main() {
|
||||
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)
|
||||
users list the accounts
|
||||
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
|
||||
virt-export [name…] export Incus instances to the library (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
|
||||
check verify the config and state load, then exit
|
||||
version
|
||||
@@ -78,6 +84,10 @@ func main() {
|
||||
os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
case "space-walk": // helper for the Space tab: measure mountpoints, stream the tree back
|
||||
os.Exit(space.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
case "drm-clients": // privileged helper for the GPU readings: who has the GPU open (run as root by the daemon)
|
||||
os.Exit(metrics.DRMClientsHelper(os.Stdin, os.Stdout))
|
||||
case "cpu-energy": // privileged helper for the CPU power reading: the packages' energy counters (run as root by the daemon)
|
||||
os.Exit(metrics.CPUEnergyHelper(os.Stdin, os.Stdout))
|
||||
case "install", "uninstall":
|
||||
var err error
|
||||
if cmd == "install" {
|
||||
@@ -129,6 +139,18 @@ func main() {
|
||||
if err := passwd(cfg, flag.Arg(1)); err != nil {
|
||||
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 "virt-export": // the nightly cron line from the Virtualization tab; optional instance names
|
||||
if err := virtExport(cfg, flag.Args()[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case "users":
|
||||
migrateUsers(cfg)
|
||||
if len(cfg.Users) == 0 {
|
||||
@@ -139,7 +161,14 @@ func main() {
|
||||
if !u.LastSeen.IsZero() {
|
||||
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":
|
||||
if err := userRm(cfg, flag.Arg(1)); err != nil {
|
||||
@@ -178,6 +207,115 @@ func main() {
|
||||
|
||||
// migrateUsers gives a pre-users config its first account, named after this OS user, so
|
||||
// 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
|
||||
}
|
||||
|
||||
// virtExport runs the instance exports the Virtualization tab scheduled, as the account Ham
|
||||
// Lab runs as (which is in incus-admin), and records the run beside the data.
|
||||
func virtExport(cfg *config.Config, names []string) error {
|
||||
who := os.Getenv("USER")
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
who = u.Username
|
||||
}
|
||||
run := incus.New(cfg.DataDir(), who, version).RunExports(context.Background(), names, func(l string) { fmt.Println(l) })
|
||||
fmt.Printf("exported %d, pruned %d, %.0f s\n", len(run.Exported), len(run.Pruned), run.Seconds)
|
||||
if len(run.Errors) > 0 {
|
||||
return errors.New(strings.Join(run.Errors, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(cfg *config.Config) {
|
||||
name := os.Getenv("USER")
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
|
||||
@@ -71,6 +71,9 @@ type alertSaved struct {
|
||||
Channels []AlertChannel `json:"channels"`
|
||||
Events []AlertEvent `json:"events"`
|
||||
Seeded bool `json:"seeded"`
|
||||
// SawInstanceRule marks that the instance-down default has been offered once; set so that
|
||||
// removing it stays removed.
|
||||
SawInstanceRule bool `json:"saw_instance_rule,omitempty"`
|
||||
// Firing is what was firing when the hub last looked, so a restart (every deploy is one)
|
||||
// carries on where it was and doesn't announce the same alerts all over again.
|
||||
Firing map[string]alertKept `json:"firing,omitempty"`
|
||||
@@ -102,6 +105,7 @@ var defaultRules = []AlertRule{
|
||||
{ID: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true},
|
||||
{ID: "instance-down", Name: "VM or container down", Sensor: "instance.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true},
|
||||
@@ -122,6 +126,23 @@ func newAlertEngine(s *Server) *alertEngine {
|
||||
for key, k := range e.saved.Firing {
|
||||
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
|
||||
}
|
||||
// a default added after an install was seeded joins its rules once; removing it afterwards sticks
|
||||
if e.saved.Seeded && !e.saved.SawInstanceRule {
|
||||
has := false
|
||||
for _, r := range e.saved.Rules {
|
||||
if r.ID == "instance-down" {
|
||||
has = true
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
for _, d := range defaultRules {
|
||||
if d.ID == "instance-down" {
|
||||
e.saved.Rules = append(e.saved.Rules, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.saved.SawInstanceRule = true
|
||||
}
|
||||
for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
|
||||
if r.ID == "container-down" && r.Sensor == "container.*.running" {
|
||||
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
|
||||
|
||||
+245
-10
@@ -3,8 +3,8 @@ package api
|
||||
// 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.
|
||||
// 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
|
||||
// (Dockhand, compose) stays the one place that changes what exists.
|
||||
// What can be removed here is what nothing uses: an untagged image, a volume no container
|
||||
// holds, a network nothing is attached to. Compose stays the one place a stack is defined.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -49,6 +49,10 @@ func dockerTool() string {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
tool := dockerTool()
|
||||
if tool == "" {
|
||||
@@ -72,6 +76,18 @@ func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
||||
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)) {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
var m map[string]any
|
||||
@@ -135,23 +151,30 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return rows
|
||||
}
|
||||
// Who uses what, from one inspect of every container: an image or a volume nothing uses is
|
||||
// the only kind this page offers to remove.
|
||||
imageUsed, volumeUsers := map[string]bool{}, map[string][]string{}
|
||||
// Who uses what, from one inspect of every container: an image, a volume or a network
|
||||
// nothing uses is the only kind this page offers to remove.
|
||||
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 {
|
||||
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 {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
p := strings.SplitN(line, "|", 3)
|
||||
if len(p) < 3 {
|
||||
p := strings.SplitN(line, "|", 4)
|
||||
if len(p) < 4 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(p[0], "/")
|
||||
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
|
||||
imageUsed[id[:12]] = true
|
||||
}
|
||||
for _, v := range strings.Split(p[2], ",") {
|
||||
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["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,
|
||||
"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}$`)
|
||||
@@ -394,3 +427,205 @@ func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("docker: volume prune (from %s)", clientIP(r))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,357 @@
|
||||
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 is read but never written: schedules made before v0.2 could point compose at
|
||||
// another machine's Docker over tcp. Such a job is refused rather than run against this
|
||||
// machine's Docker, which is where it would land without the variable.
|
||||
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, 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")
|
||||
}
|
||||
if j.Spec.Remote != "" {
|
||||
return "", errors.New("this schedule was made to deploy to another machine over tcp, which this console no longer does — make it again on that machine")
|
||||
}
|
||||
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}
|
||||
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:]
|
||||
}
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
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"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
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
|
||||
// Whether the console itself is on TLS: the response rewriter needs to know, and by
|
||||
// then only the outbound request is in hand.
|
||||
if secure(r) {
|
||||
r.Header.Set(kvmSecureHeader, "1")
|
||||
} else {
|
||||
r.Header.Del(kvmSecureHeader)
|
||||
}
|
||||
// The device gets its own host and none of Ham Lab's session.
|
||||
r.Header.Del("Origin")
|
||||
stripCookie(r, cookieName, kvmCookie)
|
||||
// What is left is the device's own login. A browser that has it teaches the hub; one
|
||||
// that has not is lent what the hub remembers, so the device sees a signed-in client.
|
||||
if own := r.Cookies(); len(own) > 0 {
|
||||
s.rememberKVMCookies(k.ID, own)
|
||||
} else {
|
||||
for name, val := range s.kvmCookies(k.ID) {
|
||||
r.AddCookie(&http.Cookie{Name: name, Value: val})
|
||||
}
|
||||
}
|
||||
},
|
||||
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 cookie the device sets by header is kept for Ham Lab's origin — so its Domain
|
||||
// (the device's) goes, Secure follows the console rather than the device, and the
|
||||
// hub remembers it too.
|
||||
if set := resp.Cookies(); len(set) > 0 {
|
||||
https := resp.Request != nil && resp.Request.Header.Get(kvmSecureHeader) == "1"
|
||||
resp.Header.Del("Set-Cookie")
|
||||
for _, c := range set {
|
||||
c.Domain = ""
|
||||
if c.Path == "" {
|
||||
c.Path = "/"
|
||||
}
|
||||
c.Secure = c.Secure && https
|
||||
if v := c.String(); v != "" {
|
||||
resp.Header.Add("Set-Cookie", v)
|
||||
}
|
||||
}
|
||||
s.rememberKVMCookies(k.ID, set)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// kvmSecureHeader rides on the outbound request to say whether the console was reached
|
||||
// over TLS; the device ignores it.
|
||||
const kvmSecureHeader = "X-Hamlab-Secure"
|
||||
|
||||
// kvmCookies is what the hub remembers of a device's own cookies — its login, in practice.
|
||||
func (s *Server) kvmCookies(id string) map[string]string {
|
||||
var out map[string]string
|
||||
s.store.View(func(st *store.State) {
|
||||
if m := st.KVMCookies[id]; len(m) > 0 {
|
||||
out = make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// rememberKVMCookies keeps the cookies a browser (or the device's own Set-Cookie) carries
|
||||
// for a KVM, so that a browser without them can be lent them. A cookie emptied or expired
|
||||
// by the device is forgotten. Written only when something changed: this runs on every
|
||||
// proxied request.
|
||||
func (s *Server) rememberKVMCookies(id string, cs []*http.Cookie) {
|
||||
have := s.kvmCookies(id)
|
||||
changed := false
|
||||
for _, c := range cs {
|
||||
if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie {
|
||||
continue
|
||||
}
|
||||
gone := c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now()))
|
||||
if gone {
|
||||
if _, ok := have[c.Name]; ok {
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if have[c.Name] != c.Value {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
_ = s.store.Update(func(st *store.State) {
|
||||
if st.KVMCookies == nil {
|
||||
st.KVMCookies = map[string]map[string]string{}
|
||||
}
|
||||
m := st.KVMCookies[id]
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
for _, c := range cs {
|
||||
if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie {
|
||||
continue
|
||||
}
|
||||
if c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) {
|
||||
delete(m, c.Name)
|
||||
} else {
|
||||
m[c.Name] = c.Value
|
||||
}
|
||||
}
|
||||
if len(m) == 0 {
|
||||
delete(st.KVMCookies, id)
|
||||
} else {
|
||||
st.KVMCookies[id] = m
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// forgetKVMCookies drops a device's remembered login — when the device is removed.
|
||||
func (s *Server) forgetKVMCookies(id string) {
|
||||
_ = s.store.Update(func(st *store.State) { delete(st.KVMCookies, id) })
|
||||
}
|
||||
|
||||
// lendKVMCookies hands the browser the device cookies the hub remembers and it lacks, as
|
||||
// the device's own page would have set them: readable by its scripts, for Ham Lab's whole
|
||||
// origin, kept for half a year. The device's SPA checks for its token client-side before it
|
||||
// ever asks the server, so lending it only on the wire would still land on the login form.
|
||||
func (s *Server) lendKVMCookies(w http.ResponseWriter, r *http.Request, id string) {
|
||||
for name, val := range s.kvmCookies(id) {
|
||||
if _, err := r.Cookie(name); err == nil {
|
||||
continue
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: 180 * 24 * 3600, Secure: secure(r), SameSite: http.SameSiteLaxMode})
|
||||
}
|
||||
}
|
||||
|
||||
// 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})
|
||||
s.lendKVMCookies(w, r, id)
|
||||
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)
|
||||
s.forgetKVMCookies(id)
|
||||
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlab/internal/auth"
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
// A KVM's own login is a cookie its page sets in one browser. The hub remembers it from the
|
||||
// requests that carry it, lends it to a browser that lacks it — both on the wire to the
|
||||
// device and back to the browser, since the device's page checks for it client-side — and
|
||||
// forgets it when the device says it is gone or the KVM is removed.
|
||||
func TestKVMCookiesAreRememberedAndLent(t *testing.T) {
|
||||
var seen []string // the Cookie header the fake device received, per request
|
||||
device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = append(seen, r.Header.Get("Cookie"))
|
||||
if r.URL.Path == "/api/auth/login" {
|
||||
// a device that sets its token by header, with its own domain and Secure
|
||||
http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "tok-1", Domain: "10.0.0.217", Secure: true})
|
||||
}
|
||||
if r.URL.Path == "/api/auth/logout" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "", MaxAge: -1})
|
||||
}
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer device.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := config.Default(filepath.Join(dir, "config.json"))
|
||||
if err := cfg.Update(func(c *config.Config) {
|
||||
c.KVMs = []config.KVM{{ID: "abc123", Name: "test", URL: device.URL, Machine: ""}}
|
||||
}); 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)}
|
||||
k, _ := s.kvm("abc123")
|
||||
p, err := s.kvmProxy(k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ask := func(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.SetPathValue("id", "abc123")
|
||||
for _, c := range cookies {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
if strings.HasPrefix(path, kvmPrefix) {
|
||||
s.serveKVM(w, r)
|
||||
} else {
|
||||
p.ServeHTTP(w, r)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// 1. the device sets its token by header: it reaches the browser without the device's
|
||||
// domain or Secure (plain-http console), and the hub remembers it
|
||||
w := ask("/api/auth/login", &http.Cookie{Name: cookieName, Value: "hamlab-session"})
|
||||
var set *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "nano-kvm-token" {
|
||||
set = c
|
||||
}
|
||||
}
|
||||
if set == nil || set.Domain != "" || set.Secure || set.Value != "tok-1" || set.Path != "/" {
|
||||
t.Fatalf("rewritten Set-Cookie = %+v, want nano-kvm-token=tok-1 for / without Domain or Secure", set)
|
||||
}
|
||||
if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-1" {
|
||||
t.Fatalf("remembered = %v, want the token", got)
|
||||
}
|
||||
if strings.Contains(seen[0], "hamlab_session") {
|
||||
t.Fatalf("the device was handed Ham Lab's session: %q", seen[0])
|
||||
}
|
||||
|
||||
// 2. a browser that has the cookie teaches a newer value
|
||||
ask("/api/vm/info", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"}, &http.Cookie{Name: cookieName, Value: "x"})
|
||||
if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-2" {
|
||||
t.Fatalf("remembered = %v, want tok-2", got)
|
||||
}
|
||||
if seen[1] != "nano-kvm-token=tok-2" {
|
||||
t.Fatalf("device saw %q, want the browser's own cookie only", seen[1])
|
||||
}
|
||||
|
||||
// 3. a browser without it is lent it: on the wire, and back as a Set-Cookie it can read
|
||||
w = ask(kvmPrefix+"abc123/", &http.Cookie{Name: cookieName, Value: "x"})
|
||||
if seen[2] != "nano-kvm-token=tok-2" {
|
||||
t.Fatalf("device saw %q from a browser with no cookie, want the remembered token", seen[2])
|
||||
}
|
||||
lent := false
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "nano-kvm-token" && c.Value == "tok-2" && !c.HttpOnly && c.Path == "/" && c.MaxAge > 0 {
|
||||
lent = true
|
||||
}
|
||||
}
|
||||
if !lent {
|
||||
t.Fatalf("browser was not lent the token: %v", w.Result().Cookies())
|
||||
}
|
||||
|
||||
// 4. the device logging out forgets it
|
||||
ask("/api/auth/logout", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"})
|
||||
if got := s.kvmCookies("abc123"); got != nil {
|
||||
t.Fatalf("after logout remembered = %v, want nothing", got)
|
||||
}
|
||||
|
||||
// 5. removing the KVM clears whatever is left
|
||||
s.rememberKVMCookies("abc123", []*http.Cookie{{Name: "nano-kvm-token", Value: "tok-3"}})
|
||||
s.forgetKVMCookies("abc123")
|
||||
if got := s.kvmCookies("abc123"); got != nil {
|
||||
t.Fatalf("after remove remembered = %v, want nothing", got)
|
||||
}
|
||||
_ = url.Parse
|
||||
}
|
||||
@@ -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 }
|
||||
+179
-37
@@ -33,7 +33,10 @@ import (
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/hardware"
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/network"
|
||||
"hamlab/internal/nodes"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/sensors"
|
||||
@@ -67,8 +70,13 @@ type Server struct {
|
||||
tasks *tasks.Manager
|
||||
updates *updates.Manager
|
||||
storage *storage.Manager
|
||||
hardware *hardware.Manager
|
||||
space *space.Manager
|
||||
net *network.Watcher
|
||||
update updateState
|
||||
events eventLog
|
||||
kvms kvmProxies
|
||||
virt *incus.Manager
|
||||
fsLocal files.FS
|
||||
fsSudo files.FS // nil without passwordless sudo
|
||||
nodes *nodes.Manager
|
||||
@@ -85,7 +93,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 {
|
||||
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()
|
||||
if err != nil {
|
||||
self = "hamlab"
|
||||
@@ -98,7 +106,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
if s.user == "" {
|
||||
s.user = "?"
|
||||
}
|
||||
s.virt = incus.New(cfg.DataDir(), s.user, version)
|
||||
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.alerts = newAlertEngine(s)
|
||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||
@@ -136,8 +150,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
}
|
||||
go s.keepFleetInStep()
|
||||
}
|
||||
go s.watchGuests()
|
||||
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.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
||||
m.HandleFunc("POST /api/login", s.login)
|
||||
@@ -184,27 +208,57 @@ 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/volumes/remove", s.auth(s.volumeRemove))
|
||||
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("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
|
||||
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}/updates", s.auth(s.stackUpdates))
|
||||
// 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.
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks", s.auth(s.stackCreate))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/homes", s.auth(s.stackHomes))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/delete", s.auth(s.stackDelete))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackFile))
|
||||
m.HandleFunc("PUT /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackSave))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}", s.auth(s.taskOnHub(s.getTask)))
|
||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
||||
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))
|
||||
// Asked of a node through this hub, every one of these is the node's to answer: the
|
||||
// generic /api/nodes/{name}/{rest...} proxy above carries them there unchanged.
|
||||
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/tail", s.auth(s.dockerTail))
|
||||
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/virt", s.auth(s.virtOverview))
|
||||
m.HandleFunc("POST /api/virt/install", s.auth(s.virtInstall))
|
||||
m.HandleFunc("GET /api/virt/images/remote", s.auth(s.virtRemoteImages))
|
||||
m.HandleFunc("POST /api/virt/images/download", s.auth(s.virtImageDownload))
|
||||
m.HandleFunc("POST /api/virt/images/import", s.auth(s.virtImageImport))
|
||||
m.HandleFunc("POST /api/virt/images/publish", s.auth(s.virtImagePublish))
|
||||
m.HandleFunc("POST /api/virt/images/delete", s.auth(s.virtImageDelete))
|
||||
m.HandleFunc("POST /api/virt/instances", s.auth(s.virtCreate))
|
||||
m.HandleFunc("GET /api/virt/instances/{name}", s.auth(s.virtInstance))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/rename", s.auth(s.virtRename))
|
||||
m.HandleFunc("PUT /api/virt/instances/{name}/config", s.auth(s.virtConfig))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/snapshots", s.auth(s.virtSnapshotCreate))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/snapshots/{snap}/{action}", s.auth(s.virtSnapshotAction))
|
||||
m.HandleFunc("POST /api/virt/instances/from-iso", s.auth(s.virtFromISO))
|
||||
m.HandleFunc("GET /api/virt/instances/{name}/vga", s.auth(s.virtVGA))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/iso", s.auth(s.virtISOAttach))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/iso/detach", s.auth(s.virtISODetach))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/mounts", s.auth(s.virtMountAdd))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/mounts/remove", s.auth(s.virtMountRemove))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction))
|
||||
m.HandleFunc("POST /api/virt/isos/fetch", s.auth(s.virtISOFetch))
|
||||
m.HandleFunc("POST /api/virt/isos/import", s.auth(s.virtISOImport))
|
||||
m.HandleFunc("POST /api/virt/isos/delete", s.auth(s.virtISODelete))
|
||||
m.HandleFunc("POST /api/virt/isos/rename", s.auth(s.virtISORename))
|
||||
m.HandleFunc("GET /api/virt/exports", s.auth(s.virtExports))
|
||||
m.HandleFunc("PUT /api/virt/exports", s.auth(s.virtExportsPut))
|
||||
m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun))
|
||||
m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete))
|
||||
m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys))
|
||||
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
|
||||
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
|
||||
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
|
||||
@@ -245,12 +299,27 @@ 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/delete", s.auth(s.filesDelete))
|
||||
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("POST /api/vpn/{iface}/peers", s.auth(s.addVPNPeer))
|
||||
m.HandleFunc("PATCH /api/vpn/{iface}/peers/{section}", s.auth(s.editVPNPeer))
|
||||
m.HandleFunc("DELETE /api/vpn/{iface}/peers/{section}", s.auth(s.removeVPNPeer))
|
||||
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/smart/{dev}", s.auth(s.storageSmart))
|
||||
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("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
||||
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
||||
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
|
||||
m.HandleFunc("GET /api/zfs/arc", s.auth(s.zfsARC))
|
||||
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/{disk}", s.auth(s.spaceStatus))
|
||||
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
|
||||
@@ -265,9 +334,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
m.HandleFunc("POST /api/users", s.auth(s.addUser))
|
||||
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
||||
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("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-idle", s.auth(s.revokeIdleSessions))
|
||||
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
|
||||
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
|
||||
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
|
||||
@@ -279,10 +352,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("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
|
||||
return m
|
||||
}
|
||||
|
||||
// sensorPath reports whether a path belongs to the read-only sensor API.
|
||||
@@ -302,9 +387,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'")
|
||||
}
|
||||
@@ -350,14 +442,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.
|
||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok {
|
||||
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
|
||||
a, ok, answered := s.cookieActor(w, r)
|
||||
if answered {
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
next(w, withActor(r, a))
|
||||
return
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
var hashes, names []string
|
||||
@@ -391,11 +525,11 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, trusted := false, false
|
||||
var patterns []string
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
|
||||
if a, live, answered := s.cookieActor(w, r); answered {
|
||||
return
|
||||
} else if live {
|
||||
ok, trusted = true, true
|
||||
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
|
||||
}
|
||||
r = withActor(r, a)
|
||||
}
|
||||
if !ok {
|
||||
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
||||
@@ -814,11 +948,7 @@ func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
|
||||
node := r.PathValue("node") // routes that also carry a {name} of their own call the machine {node}
|
||||
if node == "" {
|
||||
node = r.PathValue("name")
|
||||
}
|
||||
p, ok := s.nodes.Proxy(node)
|
||||
p, ok := s.nodes.Proxy(r.PathValue("name"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
@@ -864,11 +994,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
|
||||
// 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) {
|
||||
var users []config.User
|
||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
||||
var users, real []config.User
|
||||
s.cfg.View(func(c *config.Config) { users, real = append(users, c.Users...), c.Accounts() })
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" && len(users) == 1 {
|
||||
username = users[0].Name
|
||||
if username == "" && len(real) == 1 { // guest logins are not the account an empty name means
|
||||
username = real[0].Name
|
||||
}
|
||||
if username == "" {
|
||||
auth.CheckUser(nil, "", password) // take as long as a real check
|
||||
@@ -1003,8 +1133,14 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
"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,
|
||||
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
||||
"update_available": updateVersion,
|
||||
@@ -2440,6 +2576,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")
|
||||
|
||||
+258
-295
@@ -12,11 +12,16 @@ package api
|
||||
// relative paths, the .env beside them and the labels all stay exactly as they were
|
||||
// deployed — else on the host.
|
||||
//
|
||||
// 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
|
||||
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
||||
// in what the node couldn't (hubStacks): it resolves the files on itself and runs compose
|
||||
// here with DOCKER_HOST pointed at the node.
|
||||
// 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 is one machine's affair: its files and the Docker that runs it are on the same
|
||||
// box, and that box's Ham Lab is the only thing that touches either. Asked about a node's
|
||||
// stacks, a hub passes the request to the node's own Ham Lab and relays the answer; it holds
|
||||
// no files for anyone else and never points compose at another machine's Docker.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -29,7 +34,6 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -39,7 +43,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/compose"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
type stack struct {
|
||||
@@ -52,8 +58,6 @@ type stack struct {
|
||||
Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b
|
||||
HostFiles []string `json:"host_files"` // the same, on the machine that holds them
|
||||
Via string `json:"via"` // container compose runs inside; "" = on the host
|
||||
Remote string `json:"remote"` // DOCKER_HOST compose is pointed at; "" = this machine's Docker
|
||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
||||
Editable bool `json:"editable"`
|
||||
Note string `json:"note,omitempty"`
|
||||
HasEnv bool `json:"has_env"`
|
||||
@@ -89,6 +93,100 @@ func stackMounts(ctx context.Context) []mountMap {
|
||||
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 {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return true
|
||||
@@ -172,7 +270,7 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
maps := stackMounts(ctx)
|
||||
maps := mountsFor(ctx, s.store)
|
||||
stacks := []stack{}
|
||||
for _, st := range by {
|
||||
sort.Strings(st.Containers)
|
||||
@@ -180,7 +278,7 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
st.resolve(maps)
|
||||
stacks = append(stacks, *st)
|
||||
}
|
||||
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks, true), stacks)...)
|
||||
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks), stacks)...)
|
||||
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
|
||||
return stacks, nil
|
||||
}
|
||||
@@ -188,19 +286,23 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
// ---- where stacks live ----
|
||||
|
||||
// A home is a directory of stacks, one folder each: where new ones are made, and where ones
|
||||
// that aren't deployed (never were, or were taken down) are still found. Homes aren't
|
||||
// configured; they are wherever this machine's stacks already are. With none at all, ~/stacks.
|
||||
// that aren't deployed (never were, or were taken down) are still found. The default is
|
||||
// Ham Lab's own: <data dir>/stacks, which is where everything this console manages lives.
|
||||
// Any folder this machine already keeps stacks in — two or more deployed from it — is
|
||||
// offered as well, so what was deployed before Ham Lab arrived stays editable and gains
|
||||
// neighbours where it is, but it is never the default.
|
||||
type stackHome struct {
|
||||
Dir string `json:"dir"` // on the machine that holds the files
|
||||
Dir string `json:"dir"` // on this machine
|
||||
Inner string `json:"inner"` // the same directory as compose sees it (inside Via); Dir otherwise
|
||||
Via string `json:"via"` // container compose runs inside
|
||||
Remote string `json:"remote"` // DOCKER_HOST, when the containers run on another machine
|
||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
||||
Stacks int `json:"stacks"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
// stacksDir is the home Ham Lab owns.
|
||||
func (s *Server) stacksDir() string { return filepath.Join(s.cfg.DataDir(), "stacks") }
|
||||
|
||||
func (s *Server) homesOf(stacks []stack) []stackHome {
|
||||
type tally struct {
|
||||
home stackHome
|
||||
deployed int
|
||||
@@ -216,11 +318,10 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
if strings.Count(dir, "/") < 2 { // "/" or "/tmp": not somewhere to make folders in
|
||||
continue
|
||||
}
|
||||
key := dir + "|" + st.Remote
|
||||
t := by[key]
|
||||
t := by[dir]
|
||||
if t == nil {
|
||||
t = &tally{home: stackHome{Dir: dir, Remote: st.Remote, HeldBy: st.HeldBy}, byVia: map[string]int{}, inner: map[string]string{}}
|
||||
by[key] = t
|
||||
t = &tally{home: stackHome{Dir: dir}, byVia: map[string]int{}, inner: map[string]string{}}
|
||||
by[dir] = t
|
||||
}
|
||||
t.home.Stacks++
|
||||
if len(st.Containers) > 0 {
|
||||
@@ -233,11 +334,13 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
}
|
||||
}
|
||||
// A folder is a home when stacks are kept there as a habit: two or more deployed from it.
|
||||
// One stack on its own (Dockhand's own compose file in ~/apps/dockhand) says nothing about
|
||||
// where the next should go, and would have every compose file near it listed as a stack.
|
||||
// New stacks are deployed the way most of their neighbours were.
|
||||
var homes, lone []stackHome
|
||||
for _, t := range by {
|
||||
// One stack on its own (an orchestrator's own compose file in its app folder) says nothing
|
||||
// about where the next should go, and would have every compose file near it listed as a
|
||||
// stack — unless it is already in Ham Lab's folder. New stacks are deployed the way most
|
||||
// of their neighbours were.
|
||||
own := s.stacksDir()
|
||||
var homes []stackHome
|
||||
for dir, t := range by {
|
||||
best, n := "", -1
|
||||
for via, c := range t.byVia {
|
||||
if c > n || (c == n && via > best) {
|
||||
@@ -253,29 +356,23 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
if t.home.Inner == "" {
|
||||
t.home.Inner = t.home.Dir
|
||||
}
|
||||
if t.deployed >= 2 {
|
||||
if t.deployed >= 2 || dir == own {
|
||||
homes = append(homes, t.home)
|
||||
} else {
|
||||
lone = append(lone, t.home)
|
||||
}
|
||||
}
|
||||
if len(homes) == 0 {
|
||||
homes = lone
|
||||
}
|
||||
sort.Slice(homes, func(i, j int) bool {
|
||||
if (homes[i].Dir == own) != (homes[j].Dir == own) {
|
||||
return homes[i].Dir == own
|
||||
}
|
||||
if homes[i].Stacks != homes[j].Stacks {
|
||||
return homes[i].Stacks > homes[j].Stacks
|
||||
}
|
||||
return homes[i].Dir < homes[j].Dir
|
||||
})
|
||||
if len(homes) == 0 && withFallback {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
homes = append(homes, stackHome{Dir: filepath.Join(home, "stacks"), Inner: filepath.Join(home, "stacks")})
|
||||
if len(homes) == 0 || homes[0].Dir != own {
|
||||
homes = append([]stackHome{{Dir: own, Inner: own}}, homes...)
|
||||
}
|
||||
}
|
||||
if len(homes) > 0 {
|
||||
homes[0].Default = true
|
||||
}
|
||||
return homes
|
||||
}
|
||||
|
||||
@@ -292,6 +389,9 @@ func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
||||
}
|
||||
var out []stack
|
||||
for _, h := range homes {
|
||||
if _, err := os.Stat(h.Dir); errors.Is(err, os.ErrNotExist) {
|
||||
continue // Ham Lab's own home before its first stack: nothing to find, and no sudo to spend finding it
|
||||
}
|
||||
args := []string{h.Dir, "-mindepth", "2", "-maxdepth", "2", "-type", "f", "("}
|
||||
for i, n := range composeNames {
|
||||
if i > 0 {
|
||||
@@ -315,7 +415,7 @@ func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
||||
continue
|
||||
}
|
||||
have[name], have[dir] = true, true
|
||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, Remote: h.Remote, HeldBy: h.HeldBy, Editable: true, Note: "not deployed"}
|
||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, Editable: true, Note: "not deployed"}
|
||||
st.Files = []string{filepath.Join(h.Inner, filepath.Base(dir), filepath.Base(f))}
|
||||
st.File = st.Files[0]
|
||||
st.HasEnv = readable(st.envPath())
|
||||
@@ -334,114 +434,23 @@ func contains(list []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// hubStacks is a node's stack list with the hub's help: what the node could not find is
|
||||
// looked for here, and if it is here — and the node's Docker answers over tcp, which is how
|
||||
// it was deployed in the first place — the stack becomes editable from this hub.
|
||||
func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
|
||||
code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks", nil, nil)
|
||||
// stackFor finds the stack a request means.
|
||||
func (s *Server) stackFor(ctx context.Context, r *http.Request) (stack, error) {
|
||||
all, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res struct{ Stacks []stack }
|
||||
if code != 200 || json.Unmarshal(b, &res) != nil {
|
||||
return nil, fmt.Errorf("%s answered %d", node, code)
|
||||
}
|
||||
remote, maps := "", []mountMap(nil)
|
||||
for i := range res.Stacks {
|
||||
st := &res.Stacks[i]
|
||||
if st.Editable || len(st.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
if remote == "" {
|
||||
remote, maps = s.nodeDocker(ctx, node), stackMounts(ctx)
|
||||
}
|
||||
if remote == "-" {
|
||||
continue
|
||||
}
|
||||
note := st.Note
|
||||
if st.resolve(maps); !st.Editable {
|
||||
st.Note = note
|
||||
continue
|
||||
}
|
||||
st.Remote, st.HeldBy = remote, s.hostName()
|
||||
}
|
||||
var held []stack
|
||||
for _, st := range res.Stacks {
|
||||
if st.HeldBy != "" {
|
||||
held = append(held, st)
|
||||
}
|
||||
}
|
||||
res.Stacks = append(res.Stacks, scanHomes(ctx, s.homesOf(held, false), res.Stacks)...)
|
||||
sort.Slice(res.Stacks, func(i, j int) bool { return res.Stacks[i].Name < res.Stacks[j].Name })
|
||||
return res.Stacks, nil
|
||||
}
|
||||
|
||||
var (
|
||||
nodeDockerMu sync.Mutex
|
||||
nodeDockerOK = map[string]string{} // node → "tcp://host:2375", or "-" when it doesn't answer
|
||||
)
|
||||
|
||||
// nodeDocker is the node's Docker endpoint as Dockhand-style deployments use it, if it answers.
|
||||
func (s *Server) nodeDocker(ctx context.Context, node string) string {
|
||||
nodeDockerMu.Lock()
|
||||
defer nodeDockerMu.Unlock()
|
||||
if v, ok := nodeDockerOK[node]; ok && v != "-" {
|
||||
return v
|
||||
}
|
||||
ep := "-"
|
||||
for _, n := range s.nodes.List() {
|
||||
if n.Name != node {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(n.URL); err == nil && u.Hostname() != "" {
|
||||
cand := "tcp://" + u.Hostname() + ":2375"
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
if exec.CommandContext(c, dockerTool(), "-H", cand, "version", "--format", "{{.Server.Version}}").Run() == nil {
|
||||
ep = cand
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
nodeDockerOK[node] = ep
|
||||
return ep
|
||||
}
|
||||
|
||||
// stackFor finds the stack a request means: on this machine, or (on a hub route) one of a
|
||||
// node's stacks that this hub holds the files for. handled is false when the request should
|
||||
// simply be passed on to the node.
|
||||
func (s *Server) stackFor(ctx context.Context, r *http.Request) (st stack, handled bool, err error) {
|
||||
var all []stack
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
all, err = s.hubStacks(ctx, node)
|
||||
} else {
|
||||
all, err = s.findStacks(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return stack{}, true, err
|
||||
return stack{}, err
|
||||
}
|
||||
for _, x := range all {
|
||||
if x.Name == r.PathValue("name") {
|
||||
if r.PathValue("node") != "" && x.Remote == "" {
|
||||
return x, false, nil // the node can serve this one itself
|
||||
}
|
||||
return x, true, nil
|
||||
return x, nil
|
||||
}
|
||||
}
|
||||
return stack{}, true, errors.New("no such stack")
|
||||
return stack{}, errors.New("no such stack")
|
||||
}
|
||||
|
||||
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
stacks, err := s.hubStacks(ctx, node)
|
||||
if err != nil {
|
||||
s.proxyNode(w, r) // let the node's own answer (or failure) through
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"stacks": stacks})
|
||||
return
|
||||
}
|
||||
if dockerTool() == "" { // no Docker is an empty answer, not a failure
|
||||
jsonOK(w, 200, map[string]any{"stacks": []stack{}})
|
||||
return
|
||||
@@ -485,11 +494,7 @@ func target(r *http.Request, st stack) (host string, index int, ok bool) {
|
||||
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -508,27 +513,16 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, 200, map[string]any{"stack": st, "text": string(b), "sum": sumOf(b), "index": i})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// composeCmd is `docker compose …` for this stack, run where its files make sense. files are
|
||||
// given as compose will see them.
|
||||
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
|
||||
tool := dockerTool()
|
||||
base := []string{"compose", "-p", st.Name}
|
||||
for _, f := range files {
|
||||
base = append(base, "-f", f)
|
||||
return compose.Cmd(ctx, specOf(st, files), args...)
|
||||
}
|
||||
if st.Via != "" {
|
||||
pre := []string{"exec"}
|
||||
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
|
||||
|
||||
// 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.
|
||||
func specOf(st stack, files []string) compose.Spec {
|
||||
return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Tool: dockerTool()}
|
||||
}
|
||||
|
||||
// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate.
|
||||
@@ -556,11 +550,7 @@ func (s *Server) writeAsOwner(ctx context.Context, path, text string) error {
|
||||
func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -632,11 +622,7 @@ func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) stackDiff(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -686,11 +672,7 @@ func maskEnv(text string) string {
|
||||
func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -703,17 +685,10 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
flags := priv.Quote(append([]string{"-p", st.Name}, fl...))
|
||||
base := func(args string) string {
|
||||
if st.Via != "" {
|
||||
env := ""
|
||||
if st.Remote != "" {
|
||||
env = "-e " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
||||
// The CLI inside the orchestrator's container is its own `docker`, whatever this host runs.
|
||||
return fmt.Sprintf("%s exec %s docker compose %s %s", dockerTool(), priv.Quote([]string{st.Via}), flags, args)
|
||||
}
|
||||
return fmt.Sprintf("%s exec %s%s docker compose %s %s", dockerTool(), env, priv.Quote([]string{st.Via}), flags, args)
|
||||
}
|
||||
env := ""
|
||||
if st.Remote != "" { // through env(1): a quoted NAME=value in front of a command is a command name to sh, not an assignment
|
||||
env = "env " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
||||
}
|
||||
return fmt.Sprintf("cd %s && %s%s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), env, dockerTool(), flags, args)
|
||||
return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, args)
|
||||
}
|
||||
// After a deploy that worked, keep a copy of exactly what was deployed: the next diff is against it.
|
||||
remember := ""
|
||||
@@ -734,6 +709,12 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
script = base("up -d --remove-orphans") + remember
|
||||
case "update":
|
||||
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":
|
||||
script = base("restart")
|
||||
case "stop":
|
||||
@@ -741,7 +722,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
|
||||
script = base("down --remove-orphans")
|
||||
default:
|
||||
jsonErr(w, 400, "deploy, update, restart, stop or down")
|
||||
jsonErr(w, 400, "deploy, update, recreate, restart, stop or down")
|
||||
return
|
||||
}
|
||||
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
|
||||
@@ -763,7 +744,7 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
_ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run()
|
||||
})
|
||||
}
|
||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
||||
stackUpdatesForget(st.Name)
|
||||
log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
@@ -788,52 +769,21 @@ var (
|
||||
|
||||
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
||||
|
||||
// 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
|
||||
// registry request, and Docker Hub counts those. Answers are kept for six hours.
|
||||
func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
key := r.PathValue("node") + "/" + st.Name
|
||||
updMu.Lock()
|
||||
c, ok := updCache[key]
|
||||
updMu.Unlock()
|
||||
refresh := r.URL.Query().Get("refresh") == "1"
|
||||
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli()})
|
||||
return
|
||||
}
|
||||
if !refresh {
|
||||
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
||||
return
|
||||
}
|
||||
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
||||
if st.Remote != "" {
|
||||
args = append([]string{"-H", st.Remote}, args...)
|
||||
}
|
||||
return dockerRun(ctx, args...)
|
||||
}
|
||||
// checkImages asks the registry whether each image is still the one we hold. 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) []imageCheck {
|
||||
res := []imageCheck{}
|
||||
for _, img := range st.Images {
|
||||
for _, img := range images {
|
||||
ic := imageCheck{Image: img, State: "unknown"}
|
||||
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||
out, err := dockerRun(ctx, "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))
|
||||
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:]
|
||||
}
|
||||
@@ -853,83 +803,111 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
res = append(res, ic)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// machineUpdates checks every image on this machine in one pass, so the container list can
|
||||
// be coloured and "what is behind?" answered without walking stack by stack.
|
||||
func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
if dockerTool() == "" { // no Docker is an empty answer, not a failure
|
||||
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0, "stacks": map[string][]string{}})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
stacks, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// one entry per image, however many stacks use it
|
||||
byImage := map[string][]string{} // image -> stacks
|
||||
for _, st := range stacks {
|
||||
for _, img := range st.Images {
|
||||
byImage[img] = append(byImage[img], st.Name)
|
||||
}
|
||||
}
|
||||
refresh := r.URL.Query().Get("refresh") == "1"
|
||||
key := "machine"
|
||||
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
|
||||
for img := range byImage {
|
||||
list = append(list, img)
|
||||
}
|
||||
sort.Strings(list)
|
||||
res := checkImages(ctx, list)
|
||||
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
|
||||
// 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.
|
||||
func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
key := st.Name
|
||||
updMu.Lock()
|
||||
c, ok := updCache[key]
|
||||
updMu.Unlock()
|
||||
refresh := r.URL.Query().Get("refresh") == "1"
|
||||
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli()})
|
||||
return
|
||||
}
|
||||
if !refresh {
|
||||
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
||||
return
|
||||
}
|
||||
res := checkImages(ctx, st.Images)
|
||||
updMu.Lock()
|
||||
updCache[key] = updAnswer{time.Now(), res}
|
||||
updMu.Unlock()
|
||||
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli()})
|
||||
}
|
||||
|
||||
// taskOnHub serves a task this hub is running for one of its nodes' stacks; anything else
|
||||
// under /api/nodes/<node>/tasks belongs to the node.
|
||||
func (s *Server) taskOnHub(local http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.tasks.Get(r.PathValue("id")); ok {
|
||||
local(w, r)
|
||||
return
|
||||
}
|
||||
s.proxyNode(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- making and unmaking stacks ----
|
||||
|
||||
var stackNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
|
||||
// homesFor is where a new stack could go, seen from this request: this machine's homes, or —
|
||||
// on a hub route — the node's own homes plus the ones this hub holds for it.
|
||||
func (s *Server) homesFor(ctx context.Context, r *http.Request) ([]stackHome, error) {
|
||||
node := r.PathValue("node")
|
||||
if node == "" {
|
||||
// homesFor is where a new stack could go on this machine.
|
||||
func (s *Server) homesFor(ctx context.Context) ([]stackHome, error) {
|
||||
stacks, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.homesOf(stacks, true), nil
|
||||
}
|
||||
stacks, err := s.hubStacks(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var held []stack
|
||||
for _, st := range stacks {
|
||||
if st.HeldBy != "" {
|
||||
held = append(held, st)
|
||||
}
|
||||
}
|
||||
homes := s.homesOf(held, false)
|
||||
if code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks/homes", nil, nil); err == nil && code == 200 {
|
||||
var res struct{ Homes []stackHome }
|
||||
if json.Unmarshal(b, &res) == nil {
|
||||
for _, h := range res.Homes {
|
||||
if len(homes) > 0 && h.Stacks == 0 {
|
||||
continue // the node's empty ~/stacks is only worth offering when there is nothing else
|
||||
}
|
||||
h.Default = false
|
||||
homes = append(homes, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(homes) > 0 {
|
||||
homes[0].Default = true
|
||||
}
|
||||
return homes, nil
|
||||
return s.homesOf(stacks), nil
|
||||
}
|
||||
|
||||
func (s *Server) stackHomes(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
if dockerTool() == "" && r.PathValue("node") == "" {
|
||||
if dockerTool() == "" {
|
||||
jsonOK(w, 200, map[string]any{"homes": []stackHome{}})
|
||||
return
|
||||
}
|
||||
homes, err := s.homesFor(ctx, r)
|
||||
homes, err := s.homesFor(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if homes == nil { // a machine with no Docker and nothing held for it: an empty list, never null
|
||||
homes = []stackHome{}
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"homes": homes})
|
||||
}
|
||||
|
||||
@@ -956,7 +934,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "the compose file can't be empty")
|
||||
return
|
||||
}
|
||||
homes, err := s.homesFor(ctx, r)
|
||||
homes, err := s.homesFor(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -971,17 +949,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "that isn't one of the places this machine keeps stacks")
|
||||
return
|
||||
}
|
||||
if r.PathValue("node") != "" && home.HeldBy == "" { // the node's own folder: the node makes it
|
||||
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
var all []stack
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
all, _ = s.hubStacks(ctx, node)
|
||||
} else {
|
||||
all, _ = s.findStacks(ctx)
|
||||
}
|
||||
all, _ := s.findStacks(ctx)
|
||||
for _, st := range all {
|
||||
if st.Name == name {
|
||||
jsonErr(w, 409, "there is already a stack called "+name)
|
||||
@@ -1043,7 +1011,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
_ = sudo("chmod", "600", filepath.Join(dir, ".env")) // it is where the secrets go
|
||||
}
|
||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{host}, HostFile: host, Files: []string{filepath.Join(home.Inner, name, "compose.yaml")},
|
||||
Via: home.Via, Remote: home.Remote, HeldBy: home.HeldBy, Editable: true, Note: "not deployed", HasEnv: strings.TrimSpace(body.Env) != ""}
|
||||
Via: home.Via, Editable: true, Note: "not deployed", HasEnv: strings.TrimSpace(body.Env) != ""}
|
||||
st.File = st.Files[0]
|
||||
if msg, err := composeCmd(ctx, st, seenFiles(st, -1, ""), "config", "-q").CombinedOutput(); err != nil {
|
||||
undo()
|
||||
@@ -1062,12 +1030,7 @@ func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
|
||||
defer cancel()
|
||||
raw, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -1105,7 +1068,7 @@ func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
_ = run("rmdir", dir) // only if nothing else was in it
|
||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
||||
stackUpdatesForget(st.Name)
|
||||
log.Printf("stack %s: taken down and %d file(s) moved to %s (from %s)", st.Name, moved, trash, clientIP(r))
|
||||
jsonOK(w, 200, map[string]any{"trash": trash, "moved": moved})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"hamlab/internal/compose"
|
||||
"hamlab/internal/config"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// Where new stacks go: Ham Lab's own <data dir>/stacks is always offered, first and as the
|
||||
// default, whether or not anything is in it yet. A folder this machine already keeps stacks
|
||||
// in — two or more deployed from it — is offered too, but never ahead of Ham Lab's own.
|
||||
func TestHomesDefaultToDataDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := config.Default(filepath.Join(root, "data", "config.json"))
|
||||
s := &Server{cfg: cfg}
|
||||
own := filepath.Join(root, "data", "stacks")
|
||||
legacy := filepath.Join(root, "home", "stacks", "box")
|
||||
deployed := func(dir, name string) stack {
|
||||
f := filepath.Join(dir, name, "compose.yaml")
|
||||
return stack{Name: name, Containers: []string{name + "-1"}, Files: []string{f}, File: f, HostFiles: []string{f}, HostFile: f, Editable: true}
|
||||
}
|
||||
|
||||
// Nothing deployed anywhere: one home, Ham Lab's own.
|
||||
homes := s.homesOf(nil)
|
||||
if len(homes) != 1 || homes[0].Dir != own || !homes[0].Default {
|
||||
t.Fatalf("empty machine: homes = %+v, want just %s as default", homes, own)
|
||||
}
|
||||
|
||||
// Two stacks deployed from a pre-existing folder: it is offered, after Ham Lab's own.
|
||||
homes = s.homesOf([]stack{deployed(legacy, "a"), deployed(legacy, "b")})
|
||||
if len(homes) != 2 || homes[0].Dir != own || !homes[0].Default || homes[1].Dir != legacy || homes[1].Default || homes[1].Stacks != 2 {
|
||||
t.Fatalf("legacy folder: homes = %+v, want [%s (default), %s]", homes, own, legacy)
|
||||
}
|
||||
|
||||
// One lone stack elsewhere says nothing about where the next should go; one in Ham Lab's
|
||||
// own folder is already home.
|
||||
homes = s.homesOf([]stack{deployed(filepath.Join(root, "home", "apps"), "orchestrator"), deployed(own, "c")})
|
||||
if len(homes) != 1 || homes[0].Dir != own || homes[0].Stacks != 1 || !homes[0].Default {
|
||||
t.Fatalf("lone stacks: homes = %+v, want just %s", homes, own)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/sensors"
|
||||
"hamlab/internal/storage"
|
||||
@@ -54,6 +55,7 @@ func newStatusSensors(s *Server) *statusSensors {
|
||||
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}, acc: map[string]*bucket{}}
|
||||
go p.loop("services", servicesEvery, p.services)
|
||||
go p.loop("containers", containersEvery, p.containers)
|
||||
go p.loop("instances", containersEvery, p.instances)
|
||||
go p.loop("updates", updatesEvery, p.updates)
|
||||
go p.loop("zfs", zfsEvery, p.zfs)
|
||||
go p.loop("smart", smartEvery, p.smart)
|
||||
@@ -320,6 +322,43 @@ type cpuMark struct {
|
||||
t int64
|
||||
}
|
||||
|
||||
// instances are Incus's containers and VMs. "Down" means it should be up and is not: an
|
||||
// instance marked to start with the machine that is stopped, or one in Error — a stopped
|
||||
// instance with no such mark was stopped on purpose, as with Docker containers.
|
||||
func (p *statusSensors) instances(ctx context.Context, t int64) []sensors.Sensor {
|
||||
if !incus.Installed() {
|
||||
return nil
|
||||
}
|
||||
list, err := p.s.virt.InstancesFor(ctx, "sensors")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var res []sensors.Sensor
|
||||
var running, total float64
|
||||
for _, in := range list {
|
||||
total++
|
||||
id := "instance." + sensors.Seg(in.Name)
|
||||
l := map[string]string{"instance": in.Name, "type": in.Type, "image": in.Image}
|
||||
up := in.Status == "Running"
|
||||
if up {
|
||||
running++
|
||||
}
|
||||
down := in.Status == "Error" || (!up && in.Config["boot.autostart"] == "true")
|
||||
res = append(res,
|
||||
sensors.Sensor{ID: id + ".running", Name: in.Name, Kind: "state", Value: up, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".down", Name: in.Name + " down", Kind: "state", Value: down, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".state", Name: in.Name + " state", Kind: "state", Value: strings.ToLower(in.Status), T: t, Labels: l})
|
||||
if up {
|
||||
res = append(res,
|
||||
sensors.Sensor{ID: id + ".cpu", Name: in.Name + " CPU", Kind: "usage", Unit: "%", Value: float64(int(in.CPUPct*10+0.5)) / 10, T: t, Min: sensors.F(0), Labels: l},
|
||||
sensors.Sensor{ID: id + ".mem", Name: in.Name + " memory", Kind: "bytes", Unit: "B", Value: float64(in.MemUsage), T: t, Labels: l})
|
||||
}
|
||||
}
|
||||
return append(res,
|
||||
sensors.Sensor{ID: "instances.total", Name: "Instances", Kind: "count", Value: total, T: t},
|
||||
sensors.Sensor{ID: "instances.running", Name: "Running instances", Kind: "count", Value: running, T: t})
|
||||
}
|
||||
|
||||
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
|
||||
st := p.s.updates.Status(ctx)
|
||||
if st.Manager == "" {
|
||||
|
||||
+36
-2
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,6 +27,10 @@ type actor struct {
|
||||
Session string // session ID, "" for a token
|
||||
token string // the raw cookie token, so a handler can spare the current 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
|
||||
@@ -44,6 +49,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.
|
||||
func (a actor) String() string {
|
||||
switch {
|
||||
case a.User != "" && a.Guest:
|
||||
return "guest:" + a.User
|
||||
case a.User != "":
|
||||
return a.User
|
||||
case a.Token != "":
|
||||
@@ -59,7 +66,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
counts := s.sessions.Count()
|
||||
var out []map[string]any
|
||||
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
|
||||
if !u.LastSeen.IsZero() {
|
||||
seen = u.LastSeen
|
||||
@@ -84,6 +91,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")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(name, guestPrefix) {
|
||||
jsonErr(w, 400, "names starting with "+guestPrefix+" belong to guest logins")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < auth.MinPasswordLen {
|
||||
jsonErr(w, 400, "password must be at least 12 characters")
|
||||
return
|
||||
@@ -141,7 +152,7 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
found, last := false, false
|
||||
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
|
||||
return
|
||||
}
|
||||
@@ -254,6 +265,29 @@ func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// revokeIdleSessions drops the caller's sessions that have not been used for ?hours (24 by
|
||||
// default): the scripts and headless browsers that signed in once and never came back,
|
||||
// without touching the phone that was used this morning.
|
||||
func (s *Server) revokeIdleSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
if a.User == "" {
|
||||
jsonErr(w, 403, "only a signed-in user may do that")
|
||||
return
|
||||
}
|
||||
hours := 24
|
||||
if h := r.URL.Query().Get("hours"); h != "" {
|
||||
n, err := strconv.Atoi(h)
|
||||
if err != nil || n < 1 || n > 24*365 {
|
||||
jsonErr(w, 400, "hours is a whole number from 1 to 8760")
|
||||
return
|
||||
}
|
||||
hours = n
|
||||
}
|
||||
n := s.sessions.RevokeIdle(a.User, a.token, time.Duration(hours)*time.Hour)
|
||||
log.Printf("%d idle session(s) of %q revoked (idle > %dh, by %s)", n, a.User, hours, clientIP(r))
|
||||
jsonOK(w, 200, map[string]int{"revoked": n})
|
||||
}
|
||||
|
||||
func (s *Server) revokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
if a.User == "" {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
// The VPN tab: this machine's WireGuard tunnels, who is on them, and — where the tunnel is
|
||||
// one this machine configures — adding, renaming and removing the devices that dial in.
|
||||
//
|
||||
// Per machine like every other tab: the tunnels shown and edited are the ones on the machine
|
||||
// in the switcher, which is the only honest way to show them, since the inbound server and an
|
||||
// outbound tunnel are different machines here.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"hamlab/internal/vpn"
|
||||
)
|
||||
|
||||
func (s *Server) vpnReport(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, 200, vpn.Look(r.Context()))
|
||||
}
|
||||
|
||||
// tunnelOf finds the tunnel a request names, so every editing handler starts from the live
|
||||
// state rather than from whatever the page last saw.
|
||||
func (s *Server) tunnelOf(ctx context.Context, w http.ResponseWriter, name string) (vpn.Tunnel, bool) {
|
||||
for _, t := range vpn.Look(ctx).Tunnels {
|
||||
if t.Name == name {
|
||||
if !t.Managed {
|
||||
jsonErr(w, 400, vpn.ErrUnmanaged.Error())
|
||||
return t, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
jsonErr(w, 404, "no such tunnel on this machine")
|
||||
return vpn.Tunnel{}, false
|
||||
}
|
||||
|
||||
// addVPNPeer makes a device and hands back its profile. The profile holds a private key that
|
||||
// is generated for this call and stored nowhere, so it is the only time it can be read — the
|
||||
// same bargain as any password this console generates.
|
||||
func (s *Server) addVPNPeer(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body vpn.NewPeer
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
made, err := vpn.AddPeer(r.Context(), t, body)
|
||||
if err != nil {
|
||||
code := 400
|
||||
if errors.Is(err, vpn.ErrUnmanaged) {
|
||||
code = 409
|
||||
}
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
// The name and address are worth a log line; the profile is emphatically not.
|
||||
log.Printf("vpn: device %q added to %s as %s (by %s)", made.Name, t.Name, made.Address, clientIP(r))
|
||||
jsonOK(w, 200, made)
|
||||
}
|
||||
|
||||
func (s *Server) editVPNPeer(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct{ Name, Address string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
section := r.PathValue("section")
|
||||
if err := vpn.EditPeer(r.Context(), t, section, body.Name, body.Address); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("vpn: device %s on %s changed (by %s)", section, t.Name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) removeVPNPeer(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
section := r.PathValue("section")
|
||||
var body struct{ Confirm string }
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&body)
|
||||
// The name has to be typed back. Removing a device is the one action here that cannot be
|
||||
// undone — the key is gone and the profile on the device stops working — and it is one tap
|
||||
// away from a list of devices that all look alike.
|
||||
var want string
|
||||
for _, p := range t.Peers {
|
||||
if p.Section == section {
|
||||
want = p.Name
|
||||
}
|
||||
}
|
||||
if want != "" && !strings.EqualFold(strings.TrimSpace(body.Confirm), want) {
|
||||
jsonErr(w, 400, "type the device's name to remove it")
|
||||
return
|
||||
}
|
||||
if err := vpn.DeletePeer(r.Context(), t, section); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("vpn: device %s (%s) removed from %s (by %s)", section, want, t.Name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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()),
|
||||
})
|
||||
}
|
||||
|
||||
// zfsARC is the cache: the sampler's live ARC (size, the hit rate over the last interval)
|
||||
// beside the storage package's fuller picture (since-boot counters, tunables, and each
|
||||
// pool's cache/log/special devices or the lack of them).
|
||||
func (s *Server) zfsARC(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]any{"detail": s.storage.ARC(r.Context()), "live": nil}
|
||||
if smp, ok := s.metrics.Latest(); ok && smp.ARC != nil {
|
||||
out["live"] = smp.ARC
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
+99
-10
@@ -28,6 +28,10 @@ const (
|
||||
argonKeyLen = 32
|
||||
|
||||
SessionTTL = 30 * 24 * time.Hour
|
||||
// SessionIdle is how long a session may go unused before it is dropped, whatever its
|
||||
// expiry says. Every script, curl and headless browser that signs in leaves a session;
|
||||
// a phone that is used keeps its own alive by being used.
|
||||
SessionIdle = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
func HashPassword(pw string) (string, error) {
|
||||
@@ -113,11 +117,14 @@ var dummyHash = func() string {
|
||||
return h
|
||||
}()
|
||||
|
||||
// CheckUser verifies a password for one of users. A missing user fails in the same time
|
||||
// as a wrong password.
|
||||
// CheckUser verifies a password for one of users. A missing user — or a guest login whose
|
||||
// time is up — fails in the same time as a wrong password.
|
||||
func CheckUser(users []config.User, name, pw string) bool {
|
||||
for _, u := range users {
|
||||
if u.Name == name {
|
||||
if u.Expired(time.Now()) {
|
||||
break
|
||||
}
|
||||
return VerifyPassword(u.Hash, pw)
|
||||
}
|
||||
}
|
||||
@@ -125,6 +132,51 @@ func CheckUser(users []config.User, name, pw string) bool {
|
||||
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 is a token store backed by the state file. Expiry is sliding.
|
||||
@@ -141,12 +193,26 @@ func NewSessions(st *store.Store) *Sessions {
|
||||
func pruneExpired(state *store.State) {
|
||||
now := time.Now()
|
||||
for tok, ses := range state.Sessions {
|
||||
if ses.Expires.Before(now) {
|
||||
if !live(ses, now) {
|
||||
delete(state.Sessions, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// live is whether a session still counts: unexpired, and used within SessionIdle. A
|
||||
// session from before visits were recorded is judged by its creation, or failing that by
|
||||
// its expiry alone.
|
||||
func live(ses store.Session, now time.Time) bool {
|
||||
if !ses.Expires.After(now) {
|
||||
return false
|
||||
}
|
||||
seen := ses.LastSeen
|
||||
if seen.IsZero() {
|
||||
seen = ses.Created
|
||||
}
|
||||
return seen.IsZero() || now.Sub(seen) <= SessionIdle
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -186,7 +252,7 @@ func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) {
|
||||
ok, touch := false, false
|
||||
s.st.View(func(state *store.State) {
|
||||
found, have := state.Sessions[tok]
|
||||
if have && found.Expires.After(time.Now()) {
|
||||
if have && live(found, time.Now()) {
|
||||
ses, ok = found, true
|
||||
touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour
|
||||
}
|
||||
@@ -216,7 +282,7 @@ func (s *Sessions) List(user string) []store.Session {
|
||||
s.st.View(func(state *store.State) {
|
||||
now := time.Now()
|
||||
for _, ses := range state.Sessions {
|
||||
if ses.User == user && ses.Expires.After(now) {
|
||||
if ses.User == user && live(ses, now) {
|
||||
out = append(out, ses)
|
||||
}
|
||||
}
|
||||
@@ -231,7 +297,7 @@ func (s *Sessions) Count() map[string]int {
|
||||
s.st.View(func(state *store.State) {
|
||||
now := time.Now()
|
||||
for _, ses := range state.Sessions {
|
||||
if ses.Expires.After(now) {
|
||||
if live(ses, now) {
|
||||
n[ses.User]++
|
||||
}
|
||||
}
|
||||
@@ -267,14 +333,33 @@ func (s *Sessions) RevokeOthers(user, keep string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// RevokeIdle ends every session of user not seen for longer than idle, except the one
|
||||
// holding keep. Returns how many.
|
||||
func (s *Sessions) RevokeIdle(user, keep string, idle time.Duration) int {
|
||||
n := 0
|
||||
now := time.Now()
|
||||
_ = s.st.Update(func(state *store.State) {
|
||||
for tok, ses := range state.Sessions {
|
||||
seen := ses.LastSeen
|
||||
if seen.IsZero() {
|
||||
seen = ses.Created
|
||||
}
|
||||
if ses.User == user && tok != keep && now.Sub(seen) > idle {
|
||||
delete(state.Sessions, tok)
|
||||
n++
|
||||
}
|
||||
}
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
// RevokeUser ends every session of user (the account is going away).
|
||||
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
|
||||
// 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 {
|
||||
mu sync.Mutex
|
||||
clients map[string]*bucket
|
||||
@@ -290,7 +375,11 @@ type bucket struct {
|
||||
var ErrLocked = errors.New("too many failed logins")
|
||||
|
||||
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
|
||||
globalWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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.
|
||||
// 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. Compose always drives this
|
||||
// machine's own Docker: a stack's files and its containers are on the same box.
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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
|
||||
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 != "" {
|
||||
// The CLI inside the orchestrator's container is its own `docker`, whatever this host runs.
|
||||
return exec.CommandContext(ctx, tool, append(append([]string{"exec", 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])
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -46,6 +46,22 @@ type User struct {
|
||||
Hash string `json:"hash"`
|
||||
Created time.Time `json:"created"`
|
||||
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
|
||||
@@ -61,6 +77,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 +103,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"`
|
||||
|
||||
@@ -127,6 +161,18 @@ func (c *Config) MigrateUsers(name string) bool {
|
||||
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.
|
||||
func (c *Config) User(name string) (User, bool) {
|
||||
for _, u := range c.Users {
|
||||
|
||||
@@ -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,
|
||||
// /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
|
||||
|
||||
@@ -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.
|
||||
func nextRun(expr string, from time.Time) (time.Time, bool) {
|
||||
s, err := parseSchedule(expr)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package incus
|
||||
|
||||
// Nightly exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a
|
||||
// cron line like the ZFS schedules — `hamlab virt-export` in /etc/cron.d/hamlab-virt — so the
|
||||
// backup runs whether or not the console is up. The settings and the last run's record are
|
||||
// files beside the data, not state.json, because the cron job is a separate process.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const CronFile = "/etc/cron.d/hamlab-virt"
|
||||
|
||||
type ExportConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Hour int `json:"hour"`
|
||||
Minute int `json:"minute"`
|
||||
Keep int `json:"keep"` // newest exports kept per instance
|
||||
InstanceOnly bool `json:"instance_only"` // leave the snapshots out of the tarball
|
||||
Instances []string `json:"instances,omitempty"` // empty: every instance
|
||||
Updated time.Time `json:"updated,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultExportConfig() ExportConfig {
|
||||
return ExportConfig{Hour: 2, Minute: 15, Keep: 3, InstanceOnly: true}
|
||||
}
|
||||
|
||||
func (c ExportConfig) Validate() error {
|
||||
if c.Hour < 0 || c.Hour > 23 || c.Minute < 0 || c.Minute > 59 {
|
||||
return errors.New("time: hour 0–23, minute 0–59")
|
||||
}
|
||||
if c.Keep < 1 || c.Keep > 100 {
|
||||
return errors.New("keep: between 1 and 100")
|
||||
}
|
||||
for _, n := range c.Instances {
|
||||
if !ValidName(n) {
|
||||
return errors.New("instance: " + n)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ExportConfig) CronExpr() string { return fmt.Sprintf("%d %d * * *", c.Minute, c.Hour) }
|
||||
|
||||
// NextRun is the next time the line fires, for the page.
|
||||
func (c ExportConfig) NextRun(now time.Time) time.Time {
|
||||
n := time.Date(now.Year(), now.Month(), now.Day(), c.Hour, c.Minute, 0, 0, now.Location())
|
||||
if !n.After(now) {
|
||||
n = n.Add(24 * time.Hour)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// CronText is the whole file; rewritten on every change, never edited.
|
||||
func (c ExportConfig) CronText(user, exe, config string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Ham Lab instance exports — written by the Virtualization tab, rewritten whole on every change.\n")
|
||||
b.WriteString("# Edit it there, not here.\n")
|
||||
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||
fmt.Fprintf(&b, "# every instance to virt/exports, keep %d each\n", c.Keep)
|
||||
fmt.Fprintf(&b, "%s %s %s -config %s virt-export\n", c.CronExpr(), user, exe, config)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Manager) exportConfigPath() string { return filepath.Join(m.dir, "exports.json") }
|
||||
func (m *Manager) exportRunPath() string { return filepath.Join(m.dir, "export-runs.json") }
|
||||
|
||||
func (m *Manager) ExportConfig() ExportConfig {
|
||||
c := DefaultExportConfig()
|
||||
if b, err := os.ReadFile(m.exportConfigPath()); err == nil {
|
||||
_ = json.Unmarshal(b, &c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (m *Manager) SaveExportConfig(c ExportConfig) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Updated = time.Now()
|
||||
b, _ := json.MarshalIndent(c, "", " ")
|
||||
return os.WriteFile(m.exportConfigPath(), b, 0o644)
|
||||
}
|
||||
|
||||
type ExportRun struct {
|
||||
At time.Time `json:"at"`
|
||||
Exported []string `json:"exported"`
|
||||
Pruned []string `json:"pruned"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
}
|
||||
|
||||
func (m *Manager) LastExportRun() *ExportRun {
|
||||
b, err := os.ReadFile(m.exportRunPath())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var r ExportRun
|
||||
if json.Unmarshal(b, &r) != nil {
|
||||
return nil
|
||||
}
|
||||
return &r
|
||||
}
|
||||
|
||||
// ExportFile is one tarball in the exports tree.
|
||||
type ExportFile struct {
|
||||
Instance string `json:"instance"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
func (m *Manager) ExportFiles() []ExportFile {
|
||||
out := []ExportFile{}
|
||||
dirs, _ := os.ReadDir(m.ExportsDir())
|
||||
for _, d := range dirs {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
files, _ := os.ReadDir(filepath.Join(m.ExportsDir(), d.Name()))
|
||||
for _, f := range files {
|
||||
if f.IsDir() || strings.HasSuffix(f.Name(), ".part") {
|
||||
continue
|
||||
}
|
||||
if info, err := f.Info(); err == nil {
|
||||
out = append(out, ExportFile{Instance: d.Name(), Name: f.Name(), Size: info.Size(), Modified: info.ModTime()})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Instance != out[j].Instance {
|
||||
return out[i].Instance < out[j].Instance
|
||||
}
|
||||
return out[i].Modified.After(out[j].Modified)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// RunExports exports the named instances (or every one when names is empty) and prunes each
|
||||
// instance's folder to the configured count. Output goes to w line by line for a task to show.
|
||||
func (m *Manager) RunExports(ctx context.Context, names []string, w func(string)) ExportRun {
|
||||
start := time.Now()
|
||||
c := m.ExportConfig()
|
||||
run := ExportRun{At: start, Exported: []string{}, Pruned: []string{}}
|
||||
if err := m.EnsureDirs(); err != nil {
|
||||
run.Errors = append(run.Errors, err.Error())
|
||||
return run
|
||||
}
|
||||
all, err := m.Instances(ctx)
|
||||
if err != nil {
|
||||
run.Errors = append(run.Errors, "listing instances: "+err.Error())
|
||||
return run
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, n := range names {
|
||||
want[n] = true
|
||||
}
|
||||
if len(names) == 0 && len(c.Instances) > 0 {
|
||||
for _, n := range c.Instances {
|
||||
want[n] = true
|
||||
}
|
||||
}
|
||||
for _, in := range all {
|
||||
if len(want) > 0 && !want[in.Name] {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(m.ExportsDir(), in.Name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
run.Errors = append(run.Errors, in.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
stamp := start.Format("2006-01-02_15.04")
|
||||
final := filepath.Join(dir, fmt.Sprintf("%s-%s.tar.gz", in.Name, stamp))
|
||||
part := final + ".part"
|
||||
args := []string{"export", in.Name, part}
|
||||
if c.InstanceOnly {
|
||||
args = append(args, "--instance-only")
|
||||
}
|
||||
w(fmt.Sprintf("exporting %s …", in.Name))
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Hour)
|
||||
out, err := exec.CommandContext(cctx, "incus", args...).CombinedOutput()
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = os.Remove(part)
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
run.Errors = append(run.Errors, in.Name+": "+msg)
|
||||
w(" failed: " + msg)
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(part, final); err != nil {
|
||||
run.Errors = append(run.Errors, in.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(final); err == nil {
|
||||
w(fmt.Sprintf(" %s (%.1f MiB)", filepath.Base(final), float64(st.Size())/(1<<20)))
|
||||
}
|
||||
run.Exported = append(run.Exported, filepath.Base(final))
|
||||
// prune: newest first, keep c.Keep
|
||||
var files []ExportFile
|
||||
for _, f := range m.ExportFiles() {
|
||||
if f.Instance == in.Name {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
for i := c.Keep; i < len(files); i++ {
|
||||
if err := os.Remove(filepath.Join(dir, files[i].Name)); err == nil {
|
||||
run.Pruned = append(run.Pruned, files[i].Name)
|
||||
w(" pruned " + files[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
run.Seconds = time.Since(start).Seconds()
|
||||
b, _ := json.MarshalIndent(run, "", " ")
|
||||
_ = os.WriteFile(m.exportRunPath(), b, 0o644)
|
||||
return run
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
package incus
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The install script is generated, so a quoting slip would only show on a live machine.
|
||||
// Build it for the shapes of machine we have and let sh check the syntax.
|
||||
func TestInstallScriptShapes(t *testing.T) {
|
||||
if _, err := exec.LookPath("sh"); err != nil {
|
||||
t.Skip("no sh")
|
||||
}
|
||||
m := New("/home/core/apps/hamlab", "core", "test")
|
||||
cases := []struct {
|
||||
name string
|
||||
st Status
|
||||
o InstallOpts
|
||||
want []string
|
||||
}{
|
||||
{"grid: zfs, apt, docker", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"zfs", "btrfs", "dir"}, Docker: true, ZFSHost: true, DiskFree: 700 << 30, MemTotal: 62 << 30},
|
||||
InstallOpts{Driver: "zfs", PoolSizeGiB: 300, Subnet: "172.16.100.1/24", DockerRules: true, ARCMaxGiB: 16},
|
||||
[]string{"apt-get", "zpool create", "incus storage create default zfs source=hamlab-virt", "ipv4.address='172.16.100.1/24'", "DOCKER-USER", "zfs_arc_max", "usermod -aG incus-admin 'core'", "SupplementaryGroups=incus-admin", "systemd-run"}},
|
||||
{"core: btrfs, apt, docker, no tools yet", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"btrfs", "dir"}, Docker: true, DiskFree: 170 << 30, MemTotal: 8 << 30},
|
||||
InstallOpts{Driver: "btrfs", PoolSizeGiB: 40, Subnet: "172.16.100.1/24", DockerRules: true},
|
||||
[]string{"btrfs-progs", "mkfs.btrfs", "/etc/fstab", "incus storage create default btrfs source="}},
|
||||
{"mac: dir, dnf, arm64", Status{State: "installable", PM: "dnf", Arch: "aarch64", Drivers: []string{"dir"}, DiskFree: 200 << 30, MemTotal: 15 << 30},
|
||||
InstallOpts{Driver: "dir", Subnet: "172.16.101.1/24"},
|
||||
[]string{"dnf -y install incus qemu-system-aarch64-core edk2-aarch64", "incus storage create default dir source="}},
|
||||
{"repair: already installed", Status{State: "repair", PM: "apt", Arch: "x86_64", Drivers: []string{"dir"}, DiskFree: 10 << 30, MemTotal: 4 << 30},
|
||||
InstallOpts{Driver: "dir", Subnet: "172.16.100.1/24"},
|
||||
[]string{"already installed"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
script, lay, err := m.InstallScript(c.st, c.o)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(script, w) {
|
||||
t.Errorf("%s: script lacks %q", c.name, w)
|
||||
}
|
||||
}
|
||||
if lay.Driver != c.o.Driver || lay.Subnet != c.o.Subnet {
|
||||
t.Errorf("%s: layout %+v", c.name, lay)
|
||||
}
|
||||
cmd := exec.Command("sh", "-n")
|
||||
cmd.Stdin = strings.NewReader(script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Errorf("%s: sh -n: %v\n%s", c.name, err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallScriptRefusals(t *testing.T) {
|
||||
m := New("/x", "core", "test")
|
||||
st := Status{State: "installable", PM: "apt", Drivers: []string{"btrfs", "dir"}, DiskFree: 20 << 30, MemTotal: 8 << 30}
|
||||
for _, o := range []InstallOpts{
|
||||
{Driver: "zfs", PoolSizeGiB: 10, Subnet: "172.16.100.1/24"}, // not offered here
|
||||
{Driver: "btrfs", PoolSizeGiB: 50, Subnet: "172.16.100.1/24"}, // more than is free
|
||||
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "172.16.100.0/24"}, // the network address, not the bridge's
|
||||
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "10.0.0.1/8"}, // too wide
|
||||
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "172.16.100.1/24", ARCMaxGiB: 64}, // more RAM than there is
|
||||
} {
|
||||
if _, _, err := m.InstallScript(st, o); err == nil {
|
||||
t.Errorf("accepted %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateArgs(t *testing.T) {
|
||||
args, err := CreateArgs(CreateSpec{Name: "web-1", Image: "images:debian/13", VM: true, CPU: "2", Memory: "2GiB", Disk: "20GiB", Network: "incusbr0", Start: true}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "launch images:debian/13 web-1 --vm -c limits.cpu=2 -c limits.memory=2GiB -d root,size=20GiB -n incusbr0"
|
||||
if got := strings.Join(args, " "); got != want {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if _, err := CreateArgs(CreateSpec{Name: "web-1", Image: "images:debian/13", VM: true}, false); err == nil {
|
||||
t.Fatal("a VM without KVM must be refused")
|
||||
}
|
||||
if _, err := CreateArgs(CreateSpec{Name: "1bad", Image: "images:debian/13"}, true); err == nil {
|
||||
t.Fatal("a name starting with a digit must be refused")
|
||||
}
|
||||
if _, err := CreateArgs(CreateSpec{Name: "ok", Image: "images:debian/13", Memory: "2 gigs"}, true); err == nil {
|
||||
t.Fatal("a malformed size must be refused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package incus
|
||||
|
||||
// The bridge subnet. Incus's own `auto` picks a random 10.x.y.0/24, and on a LAN that is
|
||||
// itself 10.0.0.0/8 (this one) every such pick is on-link and collides. So Ham Lab chooses:
|
||||
// the first /24 that nothing on the machine already routes, is not an address it holds, and
|
||||
// is outside Docker's configured pools — tried in an order that also stays clear of Docker's
|
||||
// *defaults* (172.17–172.31 and 192.168.0.0/16 carved into /20s), which a Docker that has no
|
||||
// daemon.json will hand out later without asking anyone.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// candidates in order of preference: 172.16.0.0/16 is the one RFC 1918 block Docker never
|
||||
// takes by default; then the top of 192.168/16, which Docker reaches last; then the rest.
|
||||
func candidates() []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for i := 100; i <= 254; i++ {
|
||||
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
|
||||
}
|
||||
for i := 254; i >= 200; i-- {
|
||||
out = append(out, netip.MustParsePrefix(fmt.Sprintf("192.168.%d.0/24", i)))
|
||||
}
|
||||
for i := 1; i < 100; i++ {
|
||||
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func overlaps(a, b netip.Prefix) bool {
|
||||
return a.Contains(b.Addr()) || b.Contains(a.Addr())
|
||||
}
|
||||
|
||||
// PickSubnet returns the first candidate /24 clear of every prefix in taken.
|
||||
func PickSubnet(taken []netip.Prefix) netip.Prefix {
|
||||
for _, c := range candidates() {
|
||||
clear := true
|
||||
for _, t := range taken {
|
||||
if overlaps(c, t) {
|
||||
clear = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if clear {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return netip.MustParsePrefix("172.16.100.0/24") // every candidate taken: not a machine this will run on
|
||||
}
|
||||
|
||||
// Gateway is the bridge's own address in a subnet: its first host, as CIDR, the way
|
||||
// Incus's ipv4.address wants it.
|
||||
func Gateway(p netip.Prefix) string {
|
||||
return fmt.Sprintf("%s/%d", p.Masked().Addr().Next(), p.Bits())
|
||||
}
|
||||
|
||||
// TakenPrefixes reads what the machine already uses: its routes, its addresses, and the
|
||||
// pools Docker is configured to carve networks from.
|
||||
func TakenPrefixes(ctx context.Context) []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
add := func(s string) {
|
||||
if p, err := netip.ParsePrefix(s); err == nil && p.Addr().Is4() {
|
||||
out = append(out, p.Masked())
|
||||
} else if a, err := netip.ParseAddr(s); err == nil && a.Is4() {
|
||||
out = append(out, netip.PrefixFrom(a, 32))
|
||||
}
|
||||
}
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "route", "show").Output(); err == nil {
|
||||
var routes []struct{ Dst string }
|
||||
if json.Unmarshal(b, &routes) == nil {
|
||||
for _, r := range routes {
|
||||
if r.Dst != "" && r.Dst != "default" {
|
||||
add(r.Dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "addr", "show").Output(); err == nil {
|
||||
var links []struct {
|
||||
AddrInfo []struct {
|
||||
Local string `json:"local"`
|
||||
Prefixlen int `json:"prefixlen"`
|
||||
} `json:"addr_info"`
|
||||
}
|
||||
if json.Unmarshal(b, &links) == nil {
|
||||
for _, l := range links {
|
||||
for _, a := range l.AddrInfo {
|
||||
add(fmt.Sprintf("%s/%d", a.Local, a.Prefixlen))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range dockerPools() {
|
||||
add(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dockerPools reads default-address-pools from Docker's daemon.json, and falls back to
|
||||
// Docker's built-in defaults when the file names none.
|
||||
func dockerPools() []string {
|
||||
var cfg struct {
|
||||
Pools []struct {
|
||||
Base string `json:"base"`
|
||||
} `json:"default-address-pools"`
|
||||
}
|
||||
if b, err := os.ReadFile("/etc/docker/daemon.json"); err == nil && json.Unmarshal(b, &cfg) == nil && len(cfg.Pools) > 0 {
|
||||
var out []string
|
||||
for _, p := range cfg.Pools {
|
||||
out = append(out, p.Base)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{"172.17.0.0/12", "192.168.0.0/16"}
|
||||
}
|
||||
|
||||
// String form used in the layout file and on the page.
|
||||
func prefixString(p netip.Prefix) string { return strings.TrimSpace(p.Masked().String()) }
|
||||
@@ -0,0 +1,41 @@
|
||||
package incus
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPickSubnetAvoidsTaken(t *testing.T) {
|
||||
taken := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"), // the LAN, a /8
|
||||
netip.MustParsePrefix("172.16.100.0/24"), // first choice gone
|
||||
netip.MustParsePrefix("172.16.101.0/24"), // and the second
|
||||
netip.MustParsePrefix("172.28.0.0/14"), // Docker's configured pool
|
||||
netip.MustParsePrefix("192.168.80.0/20"), // a Docker network
|
||||
}
|
||||
got := PickSubnet(taken)
|
||||
if got.String() != "172.16.102.0/24" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
if Gateway(got) != "172.16.102.1/24" {
|
||||
t.Fatalf("gateway %s", Gateway(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickSubnetWholeBlockTaken(t *testing.T) {
|
||||
// 172.16.0.0/12 covers every 172.16.x candidate; the picker must move to 192.168
|
||||
got := PickSubnet([]netip.Prefix{netip.MustParsePrefix("172.16.0.0/12")})
|
||||
if got.String() != "192.168.254.0/24" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlapsEitherWay(t *testing.T) {
|
||||
a := netip.MustParsePrefix("172.16.5.0/24")
|
||||
if !overlaps(a, netip.MustParsePrefix("172.16.0.0/12")) || !overlaps(netip.MustParsePrefix("172.16.0.0/12"), a) {
|
||||
t.Fatal("a /24 inside a /12 overlaps both ways")
|
||||
}
|
||||
if overlaps(a, netip.MustParsePrefix("172.16.6.0/24")) {
|
||||
t.Fatal("neighbours do not overlap")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package incus
|
||||
|
||||
// The graphical console. Incus exposes a VM's SPICE display as a websocket: POST to the
|
||||
// instance's console endpoint with type vga and it answers an operation with two secrets,
|
||||
// one for the display stream and one for a control channel. Ham Lab dials both over Incus's
|
||||
// unix socket and hands the display stream to the browser, where spice-html5 draws it.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// SocketPath is where Incus's API listens; the daemon's account reaches it by being in
|
||||
// incus-admin, which Install arranges.
|
||||
func SocketPath() string {
|
||||
if p := os.Getenv("INCUS_SOCKET"); p != "" {
|
||||
return p
|
||||
}
|
||||
for _, p := range []string{"/var/lib/incus/unix.socket", "/run/incus/unix.socket"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return "/var/lib/incus/unix.socket"
|
||||
}
|
||||
|
||||
func (m *Manager) apiClient() *http.Client {
|
||||
sock := SocketPath()
|
||||
return &http.Client{Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", sock)
|
||||
},
|
||||
ForceAttemptHTTP2: false,
|
||||
}}
|
||||
}
|
||||
|
||||
// A SPICE client opens one connection per channel — main, display, inputs, cursor, playback
|
||||
// — and spice-html5 does the same with websockets, all to one URL. Incus allows exactly one
|
||||
// console operation per VM at a time, but takes any number of data websockets on it. So a VGA
|
||||
// session is one operation, held for as long as any channel is open, and every browser
|
||||
// websocket becomes one more data websocket on it.
|
||||
type vgaSession struct {
|
||||
op string
|
||||
secret string
|
||||
control *websocket.Conn
|
||||
cancel context.CancelFunc
|
||||
refs int
|
||||
}
|
||||
|
||||
// VGAChannel returns a fresh data websocket on the VM's console operation, opening the
|
||||
// operation first when none is held. release must be called when the channel is done.
|
||||
func (m *Manager) VGAChannel(ctx context.Context, name string) (data *websocket.Conn, release func(), err error) {
|
||||
if !ValidName(name) {
|
||||
return nil, nil, errors.New("bad name")
|
||||
}
|
||||
client := m.apiClient()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.vga == nil {
|
||||
m.vga = map[string]*vgaSession{}
|
||||
}
|
||||
dial := func(s *vgaSession) (*websocket.Conn, error) {
|
||||
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", s.op, s.secret)
|
||||
ws, _, err := websocket.Dial(c, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
|
||||
return ws, err
|
||||
}
|
||||
s := m.vga[name]
|
||||
if s != nil {
|
||||
if ws, err := dial(s); err == nil {
|
||||
s.refs++
|
||||
ws.SetReadLimit(64 << 20)
|
||||
return ws, m.releaser(name, s), nil
|
||||
}
|
||||
// the operation is gone (the VM restarted, or Incus did): start over
|
||||
s.cancel()
|
||||
delete(m.vga, name)
|
||||
}
|
||||
s, err = m.openVGA(ctx, client, name, false)
|
||||
if err != nil && strings.Contains(err.Error(), "already connected") {
|
||||
// a session a previous Ham Lab process held and never let go of; take it over
|
||||
s, err = m.openVGA(ctx, client, name, true)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ws, err := dial(s)
|
||||
if err != nil {
|
||||
s.cancel()
|
||||
return nil, nil, err
|
||||
}
|
||||
ws.SetReadLimit(64 << 20)
|
||||
s.refs = 1
|
||||
m.vga[name] = s
|
||||
return ws, m.releaser(name, s), nil
|
||||
}
|
||||
|
||||
func (m *Manager) releaser(name string, s *vgaSession) func() {
|
||||
return func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s.refs--
|
||||
if s.refs <= 0 && m.vga[name] == s {
|
||||
s.cancel()
|
||||
delete(m.vga, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// openVGA asks Incus for the console operation and holds its control websocket, which is
|
||||
// what keeps the operation alive.
|
||||
func (m *Manager) openVGA(ctx context.Context, client *http.Client, name string, force bool) (*vgaSession, error) {
|
||||
body, _ := json.Marshal(map[string]any{"type": "vga", "width": 0, "height": 0, "force": force})
|
||||
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(c, "POST", "http://incus/1.0/instances/"+name+"/console", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if isPermission(err) {
|
||||
return nil, errors.New("Ham Lab's account can't reach Incus's socket; Finish setup on the Virtualization tab puts it in incus-admin")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var op struct {
|
||||
Error string `json:"error"`
|
||||
Metadata struct {
|
||||
ID string `json:"id"`
|
||||
Metadata struct {
|
||||
FDs map[string]string `json:"fds"`
|
||||
} `json:"metadata"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&op); err != nil {
|
||||
return nil, errors.New("unreadable answer from incus")
|
||||
}
|
||||
if op.Error != "" {
|
||||
return nil, errors.New(op.Error)
|
||||
}
|
||||
secret, controlSecret := op.Metadata.Metadata.FDs["0"], op.Metadata.Metadata.FDs["control"]
|
||||
if op.Metadata.ID == "" || secret == "" {
|
||||
return nil, errors.New("incus gave no console; is the VM running?")
|
||||
}
|
||||
s := &vgaSession{op: op.Metadata.ID, secret: secret}
|
||||
// the control websocket lives outside any one request: it is the session
|
||||
sctx, scancel := context.WithCancel(context.Background())
|
||||
s.cancel = scancel
|
||||
if controlSecret != "" {
|
||||
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", s.op, controlSecret)
|
||||
control, _, err := websocket.Dial(sctx, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
|
||||
if err != nil {
|
||||
scancel()
|
||||
return nil, err
|
||||
}
|
||||
s.control = control
|
||||
go func() { // drain it; when it ends, so has the operation
|
||||
for {
|
||||
if _, _, err := control.Read(sctx); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
control.Close(websocket.StatusNormalClosure, "")
|
||||
}()
|
||||
go func() { <-sctx.Done(); control.Close(websocket.StatusNormalClosure, "") }()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func isPermission(err error) bool {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
return os.IsPermission(pe)
|
||||
}
|
||||
var oe *net.OpError
|
||||
if errors.As(err, &oe) {
|
||||
return os.IsPermission(oe.Err) || isPermission(oe.Err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package metrics
|
||||
|
||||
// The ZFS ARC — the read cache in RAM — read live from /proc/spl/kstat/zfs/arcstats on a
|
||||
// machine that has the module loaded, and left out of the sample everywhere else. The kstat
|
||||
// file is counters since boot; the hit rate here is over the sampling interval, which is the
|
||||
// number that says whether the cache is doing its job *now*. The lifetime figures and the
|
||||
// pool's caching tiers (L2ARC, SLOG, special vdevs) are the storage package's business.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const arcstatsPath = "/proc/spl/kstat/zfs/arcstats"
|
||||
|
||||
// ARC is the read cache as one sample sees it. Sizes are bytes.
|
||||
type ARC struct {
|
||||
Size uint64 `json:"size"`
|
||||
Target uint64 `json:"target"` // c: what the ARC is currently allowed to hold
|
||||
Min uint64 `json:"min"` // c_min: the floor it will not shrink below
|
||||
Max uint64 `json:"max"` // c_max: the ceiling (zfs_arc_max, or half of RAM)
|
||||
// what is in it
|
||||
MRU uint64 `json:"mru"` // recently used once
|
||||
MFU uint64 `json:"mfu"` // used more than once
|
||||
Anon uint64 `json:"anon"` // in flight: dirty, not yet on disk
|
||||
Data uint64 `json:"data"` // file contents
|
||||
Metadata uint64 `json:"metadata"` // dnodes, indirect blocks, dbufs, bonus buffers
|
||||
MetaUsed uint64 `json:"meta_used"`
|
||||
Headers uint64 `json:"headers"`
|
||||
Compressed uint64 `json:"compressed"`
|
||||
Uncompressed uint64 `json:"uncompressed"`
|
||||
MRUGhost uint64 `json:"mru_ghost"` // what it recently evicted and still remembers
|
||||
MFUGhost uint64 `json:"mfu_ghost"`
|
||||
// L2ARC, the cache devices (0 when there are none)
|
||||
L2Size uint64 `json:"l2_size"`
|
||||
L2ASize uint64 `json:"l2_asize"` // as stored on the device, after compression
|
||||
// over this interval
|
||||
ReadsPerSec float64 `json:"reads_per_sec"`
|
||||
MissesPerSec float64 `json:"misses_per_sec"`
|
||||
HitPct float64 `json:"hit_pct"` // of the interval's reads; an idle interval keeps the last one
|
||||
HitPctKnown bool `json:"hit_pct_known"` // false until an interval has had any reads
|
||||
L2HitPct float64 `json:"l2_hit_pct"`
|
||||
// since boot
|
||||
Hits uint64 `json:"hits"`
|
||||
Misses uint64 `json:"misses"`
|
||||
LifetimeHitPct float64 `json:"lifetime_hit_pct"`
|
||||
// pressure
|
||||
NoGrow bool `json:"no_grow"` // arc_no_grow: memory pressure is stopping it growing
|
||||
Throttles uint64 `json:"throttles"` // memory_throttle_count since boot
|
||||
Prunes uint64 `json:"prunes"` // arc_prune: metadata evictions asked of the filesystems
|
||||
EvictSkip uint64 `json:"evict_skip"`
|
||||
}
|
||||
|
||||
type arcPrev struct {
|
||||
hits, misses, l2hits, l2misses uint64
|
||||
hitPct float64
|
||||
known bool
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// readKstat parses a kstat table ("name type data" rows after two header lines).
|
||||
func readKstat(p string) map[string]uint64 {
|
||||
s := readStr(p)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
out := map[string]uint64{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 3 || f[1] != "4" { // type 4 is KSTAT_DATA_UINT64
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(f[2], 10, 64)
|
||||
if err == nil {
|
||||
out[f[0]] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// arc reads the ARC; nil when this machine has no ZFS module loaded.
|
||||
func (c *Collector) arc(now time.Time) *ARC {
|
||||
k := readKstat(arcstatsPath)
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
a := &ARC{Size: k["size"], Target: k["c"], Min: k["c_min"], Max: k["c_max"],
|
||||
MRU: k["mru_size"], MFU: k["mfu_size"], Anon: k["anon_size"], Data: k["data_size"], Metadata: k["metadata_size"],
|
||||
MetaUsed: k["arc_meta_used"], Headers: k["hdr_size"], Compressed: k["compressed_size"], Uncompressed: k["uncompressed_size"],
|
||||
MRUGhost: k["mru_ghost_size"], MFUGhost: k["mfu_ghost_size"], L2Size: k["l2_size"], L2ASize: k["l2_asize"],
|
||||
Hits: k["hits"], Misses: k["misses"], NoGrow: k["arc_no_grow"] != 0, Throttles: k["memory_throttle_count"], Prunes: k["arc_prune"], EvictSkip: k["evict_skip"]}
|
||||
if n := a.Hits + a.Misses; n > 0 {
|
||||
a.LifetimeHitPct = round1(float64(a.Hits) / float64(n) * 100)
|
||||
}
|
||||
p := c.prevARC
|
||||
if !p.at.IsZero() && a.Hits >= p.hits && a.Misses >= p.misses {
|
||||
if dt := now.Sub(p.at).Seconds(); dt > 0 {
|
||||
hits, misses := a.Hits-p.hits, a.Misses-p.misses
|
||||
a.ReadsPerSec = round1(float64(hits+misses) / dt)
|
||||
a.MissesPerSec = round1(float64(misses) / dt)
|
||||
if hits+misses > 0 {
|
||||
p.hitPct, p.known = round1(float64(hits)/float64(hits+misses)*100), true
|
||||
}
|
||||
if l2 := (k["l2_hits"] - p.l2hits) + (k["l2_misses"] - p.l2misses); l2 > 0 && k["l2_hits"] >= p.l2hits {
|
||||
a.L2HitPct = round1(float64(k["l2_hits"]-p.l2hits) / float64(l2) * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
a.HitPct, a.HitPctKnown = p.hitPct, p.known
|
||||
c.prevARC = arcPrev{hits: a.Hits, misses: a.Misses, l2hits: k["l2_hits"], l2misses: k["l2_misses"], hitPct: p.hitPct, known: p.known, at: now}
|
||||
return a
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package metrics
|
||||
|
||||
// CPU power, from the RAPL energy counters the kernel keeps under /sys/class/powercap: Intel
|
||||
// since Sandy Bridge, and AMD Zen through the same driver. Each counter is a running total in
|
||||
// µJ, so power is the difference between two readings over the time between them. Every
|
||||
// package is summed (`package-0`, and `package-1` on a two-socket board). The other top-level
|
||||
// domains are left out. `psys` claims the whole platform but is only as good as the firmware's
|
||||
// calibration, and on one machine here it reads lower than the package inside it. The MMIO
|
||||
// copy of a package (`intel-rapl-mmio:0`) measures the same thing again. This is the CPU's own
|
||||
// figure, shown beside its clock: cores, built-in graphics and memory controller, not disks,
|
||||
// fans or the power supply.
|
||||
//
|
||||
// The counters are root-only since the PLATYPUS side channel (2020). So, like the GPU client
|
||||
// walk, they are read in-process when the daemon is root, and otherwise by a root helper,
|
||||
// `hamlab cpu-energy`, started once through sudo or doas and asked over a pipe at every
|
||||
// sample: one privilege escalation per daemon, not one a second in the auth log. The helper
|
||||
// takes nothing from the daemon but the newline that asks; it finds and reads the counters
|
||||
// itself. Without root there is no reading, and nothing is shown.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
const raplRoot = "/sys/class/powercap"
|
||||
|
||||
var raplPkgRe = regexp.MustCompile(`^package-\d+$`)
|
||||
|
||||
// raplCount is one package's counter at one moment.
|
||||
type raplCount struct {
|
||||
Dir string `json:"d"`
|
||||
UJ uint64 `json:"uj"`
|
||||
}
|
||||
|
||||
type cpuPowerState struct {
|
||||
probed bool
|
||||
dirs []string // the package domains, found at the first sample
|
||||
max map[string]uint64 // each counter's range: where it wraps to zero
|
||||
useHelper bool // the counters are not readable in-process
|
||||
helper *energyHelper
|
||||
prev []raplCount
|
||||
prevAt time.Time
|
||||
}
|
||||
|
||||
// raplPackages lists the package domains under root, sorted. intel-rapl:0 is a package;
|
||||
// intel-rapl:0:0 is a part of one (core, uncore, dram).
|
||||
func raplPackages(root string) []string {
|
||||
ents, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
n := e.Name()
|
||||
if !strings.HasPrefix(n, "intel-rapl:") || strings.Count(n, ":") != 1 {
|
||||
continue
|
||||
}
|
||||
if raplPkgRe.MatchString(strings.TrimSpace(readStr(filepath.Join(root, n, "name")))) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func readRAPL(root string, dirs []string) ([]raplCount, bool) {
|
||||
out := make([]raplCount, 0, len(dirs))
|
||||
for _, d := range dirs {
|
||||
b, err := os.ReadFile(filepath.Join(root, d, "energy_uj"))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
v, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, raplCount{d, v})
|
||||
}
|
||||
return out, len(out) > 0
|
||||
}
|
||||
|
||||
// raplWatts is the power between two readings of the same counters. A counter that wrapped
|
||||
// is carried across its range. One that went backwards with no known range, or a set of
|
||||
// counters that changed between readings, gives no answer rather than a wrong one.
|
||||
func raplWatts(prev, cur []raplCount, max map[string]uint64, dt float64) (float64, bool) {
|
||||
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
|
||||
return 0, false
|
||||
}
|
||||
var uj float64
|
||||
for i := range cur {
|
||||
if cur[i].Dir != prev[i].Dir {
|
||||
return 0, false
|
||||
}
|
||||
if cur[i].UJ >= prev[i].UJ {
|
||||
uj += float64(cur[i].UJ - prev[i].UJ)
|
||||
continue
|
||||
}
|
||||
m := max[cur[i].Dir]
|
||||
if m == 0 || prev[i].UJ > m {
|
||||
return 0, false
|
||||
}
|
||||
uj += float64(m - prev[i].UJ + cur[i].UJ)
|
||||
}
|
||||
return uj / 1e6 / dt, true
|
||||
}
|
||||
|
||||
// cpuPower is the CPU packages' power since the previous sample, in watts; 0 when this
|
||||
// machine has no counters or no way to read them.
|
||||
func (c *Collector) cpuPower(now time.Time) float64 {
|
||||
p := &c.power
|
||||
if !p.probed {
|
||||
p.probed = true
|
||||
p.dirs = raplPackages(raplRoot)
|
||||
p.max = map[string]uint64{}
|
||||
for _, d := range p.dirs {
|
||||
if v, ok := readInt(filepath.Join(raplRoot, d, "max_energy_range_uj")); ok && v > 0 {
|
||||
p.max[d] = uint64(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(p.dirs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var cur []raplCount
|
||||
ok := false
|
||||
if !p.useHelper {
|
||||
if cur, ok = readRAPL(raplRoot, p.dirs); !ok && priv.Mode() != "root" && priv.Available() {
|
||||
p.useHelper = true
|
||||
}
|
||||
}
|
||||
if p.useHelper {
|
||||
if p.helper == nil {
|
||||
p.helper = &energyHelper{}
|
||||
}
|
||||
cur, ok = p.helper.read()
|
||||
}
|
||||
if !ok {
|
||||
p.prev = nil
|
||||
return 0
|
||||
}
|
||||
w, good := raplWatts(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
|
||||
p.prev, p.prevAt = cur, now
|
||||
if !good {
|
||||
return 0
|
||||
}
|
||||
return round1(w)
|
||||
}
|
||||
|
||||
// energyHelper is the root child that answers each newline with the package counters, one
|
||||
// JSON line. The same shape as the GPU client walk's helper, kept separate from it.
|
||||
type energyHelper struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
in io.WriteCloser
|
||||
out *bufio.Reader
|
||||
failedAt time.Time
|
||||
}
|
||||
|
||||
func (h *energyHelper) read() ([]raplCount, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cmd == nil {
|
||||
if !h.failedAt.IsZero() && time.Since(h.failedAt) < helperRetry {
|
||||
return nil, false
|
||||
}
|
||||
if !h.start() {
|
||||
h.failedAt = time.Now()
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
// a stuck child must not stall the sampler: it is killed, and tried again later
|
||||
cmd := h.cmd
|
||||
stop := time.AfterFunc(5*time.Second, func() { cmd.Process.Kill() })
|
||||
defer stop.Stop()
|
||||
if _, err := io.WriteString(h.in, "\n"); err != nil {
|
||||
h.fail()
|
||||
return nil, false
|
||||
}
|
||||
line, err := h.out.ReadBytes('\n')
|
||||
if err != nil {
|
||||
h.fail()
|
||||
return nil, false
|
||||
}
|
||||
var cs []raplCount
|
||||
if json.Unmarshal(line, &cs) != nil {
|
||||
h.fail()
|
||||
return nil, false
|
||||
}
|
||||
return cs, len(cs) > 0
|
||||
}
|
||||
|
||||
func (h *energyHelper) start() bool {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
full := priv.Wrap(self, "cpu-energy")
|
||||
cmd := exec.Command(full[0], full[1:]...)
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return false
|
||||
}
|
||||
h.cmd, h.in, h.out = cmd, in, bufio.NewReader(out)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *energyHelper) fail() {
|
||||
if h.cmd != nil {
|
||||
h.in.Close()
|
||||
h.cmd.Process.Kill()
|
||||
go h.cmd.Wait()
|
||||
}
|
||||
h.cmd, h.in, h.out = nil, nil, nil
|
||||
h.failedAt = time.Now()
|
||||
}
|
||||
|
||||
// CPUEnergyHelper is `hamlab cpu-energy`: for every line read on stdin, one JSON line of the
|
||||
// package counters on stdout. Ends when stdin does — when the daemon that started it has gone.
|
||||
func CPUEnergyHelper(stdin io.Reader, stdout io.Writer) int {
|
||||
return cpuEnergyHelper(raplRoot, stdin, stdout)
|
||||
}
|
||||
|
||||
func cpuEnergyHelper(root string, stdin io.Reader, stdout io.Writer) int {
|
||||
dirs := raplPackages(root)
|
||||
sc := bufio.NewScanner(stdin)
|
||||
w := bufio.NewWriter(stdout)
|
||||
for sc.Scan() {
|
||||
cs, ok := readRAPL(root, dirs)
|
||||
if !ok {
|
||||
cs = []raplCount{}
|
||||
}
|
||||
b, _ := json.Marshal(cs)
|
||||
w.Write(b)
|
||||
w.WriteByte('\n')
|
||||
if w.Flush() != nil {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A fake /sys/class/powercap: a package with its parts, the platform domain, and the MMIO
|
||||
// copy of the same package. Only the package counts.
|
||||
func fakeRAPL(t *testing.T, domains map[string][2]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for dir, v := range domains {
|
||||
d := filepath.Join(root, dir)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(d, "name"), []byte(v[0]+"\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(d, "energy_uj"), []byte(v[1]+"\n"), 0o644)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestRAPLPackagesOnlyCountsPackages(t *testing.T) {
|
||||
root := fakeRAPL(t, map[string][2]string{
|
||||
"intel-rapl:0": {"package-0", "1000"},
|
||||
"intel-rapl:0:0": {"core", "600"},
|
||||
"intel-rapl:0:1": {"uncore", "10"},
|
||||
"intel-rapl:1": {"psys", "300"},
|
||||
"intel-rapl-mmio:0": {"package-0", "1000"},
|
||||
})
|
||||
if got := raplPackages(root); len(got) != 1 || got[0] != "intel-rapl:0" {
|
||||
t.Fatalf("packages = %v, want [intel-rapl:0]", got)
|
||||
}
|
||||
|
||||
two := fakeRAPL(t, map[string][2]string{"intel-rapl:0": {"package-0", "5"}, "intel-rapl:1": {"package-1", "7"}})
|
||||
got := raplPackages(two)
|
||||
if strings.Join(got, ",") != "intel-rapl:0,intel-rapl:1" {
|
||||
t.Fatalf("two sockets: packages = %v", got)
|
||||
}
|
||||
cs, ok := readRAPL(two, got)
|
||||
if !ok || len(cs) != 2 || cs[0].UJ != 5 || cs[1].UJ != 7 {
|
||||
t.Fatalf("readRAPL = %+v, %v", cs, ok)
|
||||
}
|
||||
if got := raplPackages(filepath.Join(two, "nope")); len(got) != 0 {
|
||||
t.Fatalf("no powercap: packages = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRAPLWatts(t *testing.T) {
|
||||
c := func(d string, uj uint64) raplCount { return raplCount{d, uj} }
|
||||
max := map[string]uint64{"intel-rapl:0": 1_000_000_000}
|
||||
cases := []struct {
|
||||
name string
|
||||
prev, cur []raplCount
|
||||
dt float64
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"steady", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:0", 10_000_000)}, 2, 5, true},
|
||||
{"two packages add", []raplCount{c("intel-rapl:0", 0), c("intel-rapl:1", 0)}, []raplCount{c("intel-rapl:0", 4_000_000), c("intel-rapl:1", 6_000_000)}, 1, 10, true},
|
||||
{"wrapped", []raplCount{c("intel-rapl:0", 999_000_000)}, []raplCount{c("intel-rapl:0", 1_000_000)}, 1, 2, true},
|
||||
{"backwards, range unknown", []raplCount{c("intel-rapl:1", 5_000_000)}, []raplCount{c("intel-rapl:1", 1)}, 1, 0, false},
|
||||
{"counters changed", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:1", 9)}, 1, 0, false},
|
||||
{"first reading", nil, []raplCount{c("intel-rapl:0", 9)}, 1, 0, false},
|
||||
{"no time passed", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:0", 9)}, 0, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := raplWatts(tc.prev, tc.cur, max, tc.dt)
|
||||
if ok != tc.ok || (ok && (got < tc.want-1e-9 || got > tc.want+1e-9)) {
|
||||
t.Errorf("%s: got %v, %v; want %v, %v", tc.name, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The helper answers every newline with the counters it found itself, and stops at EOF.
|
||||
func TestCPUEnergyHelper(t *testing.T) {
|
||||
root := fakeRAPL(t, map[string][2]string{"intel-rapl:0": {"package-0", "42"}, "intel-rapl:0:0": {"core", "7"}})
|
||||
var out bytes.Buffer
|
||||
if code := cpuEnergyHelper(root, strings.NewReader("\n\n"), &out); code != 0 {
|
||||
t.Fatalf("exit %d", code)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("got %d lines, want 2: %q", len(lines), out.String())
|
||||
}
|
||||
var cs []raplCount
|
||||
if err := json.Unmarshal([]byte(lines[1]), &cs); err != nil || len(cs) != 1 || cs[0] != (raplCount{"intel-rapl:0", 42}) {
|
||||
t.Fatalf("line = %s (%v)", lines[1], err)
|
||||
}
|
||||
}
|
||||
|
||||
// History keeps the average of the samples that had a reading.
|
||||
func TestFoldCPUPower(t *testing.T) {
|
||||
b := []Sample{{T: 1, CPUPower: 4}, {T: 2}, {T: 3, CPUPower: 8}}
|
||||
if got := fold(b).CPUPower; got != 6 {
|
||||
t.Fatalf("folded CPU power = %v, want 6", got)
|
||||
}
|
||||
if got := fold([]Sample{{T: 1}, {T: 2}}).CPUPower; got != 0 {
|
||||
t.Fatalf("no readings folded to %v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,17 @@ func fold(b []Sample) Sample {
|
||||
return t / uint64(len(b))
|
||||
}
|
||||
out.CPU = sum(func(s Sample) float64 { return s.CPU })
|
||||
// CPU power: over the samples that have a reading, so a helper restart is not a dip to zero
|
||||
var pw, pn float64
|
||||
for _, s := range b {
|
||||
if s.CPUPower > 0 {
|
||||
pw, pn = pw+s.CPUPower, pn+1
|
||||
}
|
||||
}
|
||||
out.CPUPower = 0
|
||||
if pn > 0 {
|
||||
out.CPUPower = round1(pw / pn)
|
||||
}
|
||||
out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] })
|
||||
out.Load[1] = sum(func(s Sample) float64 { return s.Load[1] })
|
||||
out.Load[2] = sum(func(s Sample) float64 { return s.Load[2] })
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
package metrics
|
||||
|
||||
// GPU telemetry, the way btop and intel_gpu_top get it without a vendor library: the
|
||||
// device from sysfs, and who is using it from DRM fdinfo.
|
||||
//
|
||||
// - The device: /sys/class/drm/card<n> with a driver we know to be a GPU. Clock from the
|
||||
// driver's frequency files, temperature/power/fan from the hwmon under the device, and
|
||||
// how busy it is from the one counter every Intel GT exports without root — RC6 idle
|
||||
// residency, so busy = 100 − idle. amdgpu says it outright (gpu_busy_percent).
|
||||
// - The clients: every process holding /dev/dri/* open has a drm-engine-<name> running
|
||||
// total (nanoseconds) in /proc/<pid>/fdinfo/<fd>; the delta between two reads is that
|
||||
// process's share of each engine, and the sum across clients is the engine's load. Other
|
||||
// users' fdinfo is unreadable, so when the daemon isn't root it keeps one root child
|
||||
// (`hamlab drm-clients`) that does the walk, asked over a pipe — one sudo per daemon
|
||||
// start, not one per second.
|
||||
//
|
||||
// A GPU with no counter of either kind (Asahi, today) is still listed: a name is better
|
||||
// than silence about hardware the machine has.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
// GPU is one graphics device as a Sample reports it.
|
||||
type GPU struct {
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
Bus string `json:"bus,omitempty"` // PCI address; empty for a platform device
|
||||
Integrated bool `json:"integrated,omitempty"`
|
||||
Usage float64 `json:"usage"` // % busy
|
||||
UsageSrc string `json:"usage_src,omitempty"` // "idle" (100 − idle residency), "busy" (the driver's own %), "clients" (the busiest engine); "" when nothing measures it
|
||||
Engines map[string]float64 `json:"engines,omitempty"` // render/video/copy/enhance… % from the clients, normalised by engine count
|
||||
Temp float64 `json:"temp,omitempty"`
|
||||
TempSrc string `json:"temp_src,omitempty"` // "gpu" from the device's own sensor, "package" the CPU die an integrated GPU shares
|
||||
PowerW float64 `json:"power_w,omitempty"`
|
||||
PowerCapW float64 `json:"power_cap_w,omitempty"`
|
||||
FanRPM int `json:"fan_rpm,omitempty"`
|
||||
MHz float64 `json:"mhz,omitempty"` // actual clock (0 when power-gated)
|
||||
ReqMHz float64 `json:"req_mhz,omitempty"` // what the driver asked for
|
||||
MaxMHz float64 `json:"max_mhz,omitempty"`
|
||||
MemUsed uint64 `json:"mem_used,omitempty"` // VRAM used, or the clients' resident bytes where the driver has no VRAM counter
|
||||
MemTotal uint64 `json:"mem_total,omitempty"`
|
||||
Clients []GPUClient `json:"clients"`
|
||||
ClientsOK bool `json:"clients_ok"` // false when fdinfo of other users' processes could not be read
|
||||
Stalled bool `json:"stalled,omitempty"` // a sysfs read of this card never returned; its readings are paused until restart
|
||||
}
|
||||
|
||||
// GPUClient is one process with the device open.
|
||||
type GPUClient struct {
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
Usage float64 `json:"usage"` // its busiest engine, %
|
||||
Engines map[string]float64 `json:"engines,omitempty"`
|
||||
Mem uint64 `json:"mem,omitempty"` // resident bytes
|
||||
}
|
||||
|
||||
// gpuDrivers are DRM drivers that drive a GPU, not just a scanout (simpledrm, ast, the
|
||||
// Apple display coprocessor) — those have no engines and nothing to say.
|
||||
var gpuDrivers = map[string]bool{"i915": true, "xe": true, "amdgpu": true, "radeon": true, "nouveau": true, "nvidia-drm": true,
|
||||
"asahi": true, "panfrost": true, "panthor": true, "v3d": true, "msm": true, "etnaviv": true, "lima": true, "powervr": true, "virtio_gpu": true}
|
||||
|
||||
type gpuDev struct {
|
||||
card, dev, key, bus, driver, name, hwmon string
|
||||
integrated, stalled bool
|
||||
prevIdle, prevEnergy float64
|
||||
prevAt time.Time
|
||||
}
|
||||
|
||||
type gpuState struct {
|
||||
devs []*gpuDev
|
||||
listedAt time.Time
|
||||
clients map[string]clientPrev // device key + client id → engine totals at the previous scan
|
||||
last []drmFD // the newest client walk, reused between walks
|
||||
scanAt time.Time
|
||||
helper *drmHelper
|
||||
ok bool // the last walk saw every process
|
||||
}
|
||||
|
||||
type clientPrev struct {
|
||||
ns map[string]uint64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const gpuRelist = time.Minute // how often the card list is re-read: hotplug is rare, the readdir is cheap
|
||||
const gpuScanEvery = time.Second // engine shares over less than this are noise
|
||||
|
||||
// gpus is the GPU section of a sample. cpuTemp stands in for an integrated GPU's
|
||||
// temperature: it is the same die.
|
||||
func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
|
||||
g := &c.gpu
|
||||
if g.devs == nil || now.Sub(g.listedAt) >= gpuRelist {
|
||||
g.devs, g.listedAt = listGPUs(g.devs), now
|
||||
}
|
||||
if len(g.devs) == 0 {
|
||||
return []GPU{}
|
||||
}
|
||||
if g.clients == nil {
|
||||
g.clients = map[string]clientPrev{}
|
||||
}
|
||||
if g.scanAt.IsZero() || now.Sub(g.scanAt) >= gpuScanEvery {
|
||||
g.last, g.ok = c.scanClients()
|
||||
g.scanAt = now
|
||||
}
|
||||
out := make([]GPU, 0, len(g.devs))
|
||||
live := map[string]bool{}
|
||||
for _, d := range g.devs {
|
||||
x := GPU{Name: d.name, Driver: d.driver, Bus: d.bus, Integrated: d.integrated, Clients: []GPUClient{}, ClientsOK: g.ok}
|
||||
d.readWithin(&x, now)
|
||||
if x.Temp == 0 && d.integrated && cpuTemp > 0 {
|
||||
x.Temp, x.TempSrc = cpuTemp, "package"
|
||||
}
|
||||
// the clients: one entry per client id, under the first pid that holds it (a child
|
||||
// that inherited the fd shares the client)
|
||||
byID := map[string]*GPUClient{}
|
||||
var order []string
|
||||
engines := map[string]float64{}
|
||||
var resident uint64
|
||||
for _, f := range g.last {
|
||||
fi := parseFdinfo(f.Info)
|
||||
if fi.pdev != "" && fi.pdev != d.bus || fi.pdev == "" && fi.driver != d.driver {
|
||||
continue
|
||||
}
|
||||
key := d.key + "/" + fi.client
|
||||
live[key] = true
|
||||
if byID[fi.client] != nil {
|
||||
continue
|
||||
}
|
||||
cl := &GPUClient{PID: f.PID, Name: f.Comm, Mem: fi.mem}
|
||||
byID[fi.client] = cl
|
||||
order = append(order, fi.client)
|
||||
resident += fi.mem
|
||||
if prev, ok := g.clients[key]; ok {
|
||||
if dt := now.Sub(prev.at).Nanoseconds(); dt > 0 {
|
||||
for e, ns := range fi.engines {
|
||||
p, ok := prev.ns[e]
|
||||
if !ok || ns < p {
|
||||
continue
|
||||
}
|
||||
pct := float64(ns-p) / float64(dt) * 100
|
||||
if n := fi.capacity[e]; n > 1 {
|
||||
pct /= float64(n)
|
||||
}
|
||||
pct = round1(pct)
|
||||
if cl.Engines == nil {
|
||||
cl.Engines = map[string]float64{}
|
||||
}
|
||||
cl.Engines[e] = pct
|
||||
engines[e] += pct
|
||||
if pct > cl.Usage {
|
||||
cl.Usage = pct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g.clients[key] = clientPrev{ns: fi.engines, at: now}
|
||||
}
|
||||
for _, id := range order {
|
||||
x.Clients = append(x.Clients, *byID[id])
|
||||
}
|
||||
sort.SliceStable(x.Clients, func(i, j int) bool { return x.Clients[i].Usage > x.Clients[j].Usage })
|
||||
if len(engines) > 0 {
|
||||
x.Engines = map[string]float64{}
|
||||
for e, v := range engines {
|
||||
if v > 100 {
|
||||
v = 100
|
||||
}
|
||||
x.Engines[e] = round1(v)
|
||||
}
|
||||
}
|
||||
if x.UsageSrc == "" && len(x.Engines) > 0 {
|
||||
for _, v := range x.Engines {
|
||||
if v > x.Usage {
|
||||
x.Usage = v
|
||||
}
|
||||
}
|
||||
x.UsageSrc = "clients"
|
||||
}
|
||||
if x.MemTotal == 0 && x.MemUsed == 0 {
|
||||
x.MemUsed = resident
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
for k := range g.clients { // a client that closed the device
|
||||
if !live[k] {
|
||||
delete(g.clients, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listGPUs enumerates the cards, keeping the counters of those already known.
|
||||
func listGPUs(known []*gpuDev) []*gpuDev {
|
||||
ents, _ := os.ReadDir("/sys/class/drm")
|
||||
old := map[string]*gpuDev{}
|
||||
for _, d := range known {
|
||||
old[d.card] = d
|
||||
}
|
||||
out := []*gpuDev{}
|
||||
for _, e := range ents {
|
||||
n := e.Name()
|
||||
if !strings.HasPrefix(n, "card") || strings.Contains(n, "-") {
|
||||
continue
|
||||
}
|
||||
card := "/sys/class/drm/" + n
|
||||
dev, err := filepath.EvalSymlinks(card + "/device")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
driver := ueventValue(dev+"/uevent", "DRIVER")
|
||||
if driver == "" { // some drivers hang the DRIVER key off the parent
|
||||
driver = filepath.Base(readLink(dev + "/driver"))
|
||||
}
|
||||
if !gpuDrivers[driver] {
|
||||
continue
|
||||
}
|
||||
if d, ok := old[card]; ok && d.dev == dev {
|
||||
out = append(out, d)
|
||||
continue
|
||||
}
|
||||
d := &gpuDev{card: card, dev: dev, driver: driver}
|
||||
if pci := ueventValue(dev+"/uevent", "PCI_SLOT_NAME"); pci != "" {
|
||||
d.bus, d.key = pci, pci
|
||||
// an on-die GPU hangs off root bus 0 (Intel's 00:02.0, an AMD APU's 00:08.0 bridge)
|
||||
d.integrated = strings.HasPrefix(pci, "0000:00:")
|
||||
} else {
|
||||
d.key = "driver:" + driver
|
||||
d.integrated = true
|
||||
}
|
||||
if hw, _ := filepath.Glob(dev + "/hwmon/hwmon*"); len(hw) > 0 {
|
||||
d.hwmon = hw[0]
|
||||
}
|
||||
d.name = gpuName(dev, d.bus, driver)
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].card < out[j].card })
|
||||
return out
|
||||
}
|
||||
|
||||
var gpuVendors = map[string]string{"0x8086": "Intel", "0x1002": "AMD", "0x10de": "NVIDIA", "0x1af4": "Virtio", "0x106b": "Apple"}
|
||||
|
||||
var (
|
||||
revRe = regexp.MustCompile(`\s*\(rev [^)]*\)`)
|
||||
bracketRe = regexp.MustCompile(`\[([^\]]+)\]`)
|
||||
)
|
||||
|
||||
// gpuName is "Intel Arc A380": from the embedded PCI ID table first, so the name is the
|
||||
// same on every machine; from lspci for a card newer than the table; and failing both,
|
||||
// the vendor and the raw device ID.
|
||||
func gpuName(dev, bus, driver string) string {
|
||||
if bus != "" {
|
||||
vid := strings.TrimPrefix(strings.TrimSpace(readStr(dev+"/vendor")), "0x")
|
||||
did := strings.TrimPrefix(strings.TrimSpace(readStr(dev+"/device")), "0x")
|
||||
if vn, dn := pciName(vid, did); dn != "" {
|
||||
return lspciName(vn + " " + dn)
|
||||
}
|
||||
if out, ok := runOut("lspci", "-s", bus); ok {
|
||||
if _, desc, found := strings.Cut(out, ": "); found {
|
||||
return lspciName(desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
vendor := gpuVendors[strings.TrimSpace(readStr(dev+"/vendor"))]
|
||||
if compat := readStr(dev + "/of_node/compatible"); compat != "" { // "apple,agx-t8103\0apple,agx-g13g"
|
||||
first := strings.SplitN(compat, "\x00", 2)[0]
|
||||
if v, model, ok := strings.Cut(first, ","); ok {
|
||||
if vendor == "" && v != "" {
|
||||
vendor = strings.ToUpper(v[:1]) + v[1:]
|
||||
}
|
||||
return vendor + " GPU (" + model + ")"
|
||||
}
|
||||
}
|
||||
if vendor == "" && driver != "" {
|
||||
vendor = strings.ToUpper(driver[:1]) + driver[1:]
|
||||
}
|
||||
if id := strings.TrimPrefix(strings.TrimSpace(readStr(dev+"/device")), "0x"); id != "" {
|
||||
return strings.TrimSpace(vendor + " GPU " + id)
|
||||
}
|
||||
return strings.TrimSpace(vendor + " GPU")
|
||||
}
|
||||
|
||||
// lspciName turns "Intel Corporation DG2 [Arc A380] (rev 05)" into "Intel Arc A380".
|
||||
func lspciName(desc string) string {
|
||||
desc = revRe.ReplaceAllString(strings.TrimSpace(desc), "")
|
||||
vendor := ""
|
||||
for _, v := range []struct{ long, short string }{{"Intel Corporation", "Intel"}, {"Advanced Micro Devices, Inc. [AMD/ATI]", "AMD"}, {"Advanced Micro Devices, Inc. [AMD]", "AMD"}, {"NVIDIA Corporation", "NVIDIA"}, {"Red Hat, Inc.", "Virtio"}} {
|
||||
if strings.HasPrefix(desc, v.long) {
|
||||
vendor = v.short
|
||||
desc = strings.TrimSpace(desc[len(v.long):])
|
||||
break
|
||||
}
|
||||
}
|
||||
if vendor == "" { // vendor not recognised: its first word
|
||||
if i := strings.IndexByte(desc, ' '); i > 0 {
|
||||
vendor, desc = desc[:i], strings.TrimSpace(desc[i+1:])
|
||||
}
|
||||
}
|
||||
if m := bracketRe.FindStringSubmatch(desc); m != nil {
|
||||
desc = m[1]
|
||||
}
|
||||
return strings.TrimSpace(vendor + " " + desc)
|
||||
}
|
||||
|
||||
// readWithin is read with a deadline. A sysfs read that does not come back must not take
|
||||
// the whole sampler with it — every graph on every page of this machine stops — so the
|
||||
// device is marked stalled, its goroutine abandoned, and the rest of the sample goes on.
|
||||
const gpuReadWithin = 3 * time.Second
|
||||
|
||||
func (d *gpuDev) readWithin(x *GPU, now time.Time) {
|
||||
if d.stalled {
|
||||
x.Stalled = true
|
||||
return
|
||||
}
|
||||
done := make(chan GPU, 1)
|
||||
go func() {
|
||||
y := *x
|
||||
d.read(&y, now)
|
||||
done <- y
|
||||
}()
|
||||
select {
|
||||
case y := <-done:
|
||||
*x = y
|
||||
case <-time.After(gpuReadWithin):
|
||||
d.stalled, x.Stalled = true, true
|
||||
log.Printf("gpu: %s (%s): a sysfs read did not return within %s; its readings are paused until restart", d.name, d.card, gpuReadWithin)
|
||||
}
|
||||
}
|
||||
|
||||
// read fills the device-level numbers: clock, idle/busy, hwmon.
|
||||
func (d *gpuDev) read(x *GPU, now time.Time) {
|
||||
dt := now.Sub(d.prevAt).Seconds()
|
||||
switch d.driver {
|
||||
case "i915":
|
||||
x.MHz = readF(d.card + "/gt_act_freq_mhz")
|
||||
x.ReqMHz = readF(d.card + "/gt_cur_freq_mhz")
|
||||
x.MaxMHz = readF(d.card + "/gt_max_freq_mhz")
|
||||
idle, ok := readInt(d.card + "/gt/gt0/rc6_residency_ms")
|
||||
if !ok {
|
||||
idle, ok = readInt(d.card + "/power/rc6_residency_ms")
|
||||
}
|
||||
if ok {
|
||||
d.busyFromIdle(x, float64(idle), dt)
|
||||
}
|
||||
case "xe":
|
||||
gt := d.dev + "/tile0/gt0"
|
||||
x.MHz = readF(gt + "/freq0/act_freq")
|
||||
x.ReqMHz = readF(gt + "/freq0/cur_freq")
|
||||
x.MaxMHz = readF(gt + "/freq0/max_freq")
|
||||
if idle, ok := readInt(gt + "/gtidle/idle_residency_ms"); ok {
|
||||
d.busyFromIdle(x, float64(idle), dt)
|
||||
}
|
||||
case "amdgpu":
|
||||
if v, ok := readInt(d.dev + "/gpu_busy_percent"); ok {
|
||||
x.Usage, x.UsageSrc = float64(v), "busy"
|
||||
}
|
||||
x.MemUsed = uint64(readF(d.dev + "/mem_info_vram_used"))
|
||||
x.MemTotal = uint64(readF(d.dev + "/mem_info_vram_total"))
|
||||
// "1: 1800Mhz *" — the starred level is the current one, the last the highest
|
||||
for _, line := range strings.Split(readStr(d.dev+"/pp_dpm_sclk"), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
mhz := leadingNum(f[1])
|
||||
x.MaxMHz = mhz
|
||||
if strings.HasSuffix(line, "*") {
|
||||
x.MHz = mhz
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.hwmon != "" {
|
||||
if t := readF(d.hwmon + "/temp1_input"); t > 0 {
|
||||
x.Temp, x.TempSrc = t/1000, "gpu"
|
||||
}
|
||||
if p := readF(d.hwmon + "/power1_input"); p > 0 {
|
||||
x.PowerW = round1(p / 1e6)
|
||||
} else if p := readF(d.hwmon + "/power1_average"); p > 0 {
|
||||
x.PowerW = round1(p / 1e6)
|
||||
} else if e := readF(d.hwmon + "/energy1_input"); e > 0 { // µJ, cumulative
|
||||
if d.prevEnergy > 0 && e >= d.prevEnergy && dt > 0 && !d.prevAt.IsZero() {
|
||||
x.PowerW = round1((e - d.prevEnergy) / 1e6 / dt)
|
||||
}
|
||||
d.prevEnergy = e
|
||||
}
|
||||
if p := readF(d.hwmon + "/power1_cap"); p > 0 {
|
||||
x.PowerCapW = p / 1e6
|
||||
} else if p := readF(d.hwmon + "/power1_max"); p > 0 {
|
||||
x.PowerCapW = p / 1e6
|
||||
}
|
||||
x.FanRPM = int(readF(d.hwmon + "/fan1_input"))
|
||||
}
|
||||
d.prevAt = now
|
||||
}
|
||||
|
||||
// busyFromIdle: the GT's idle residency is a running ms total; the share of the interval
|
||||
// it did not cover is how busy the device was.
|
||||
func (d *gpuDev) busyFromIdle(x *GPU, idle, dt float64) {
|
||||
x.UsageSrc = "idle"
|
||||
if d.prevAt.IsZero() || dt <= 0 || idle < d.prevIdle {
|
||||
d.prevIdle = idle
|
||||
return
|
||||
}
|
||||
busy := 100 - (idle-d.prevIdle)/(dt*1000)*100
|
||||
if busy < 0 {
|
||||
busy = 0
|
||||
}
|
||||
if busy > 100 {
|
||||
busy = 100
|
||||
}
|
||||
x.Usage = round1(busy)
|
||||
d.prevIdle = idle
|
||||
}
|
||||
|
||||
// ---- DRM fdinfo: who has the device open ----
|
||||
|
||||
// drmFD is one client of a DRM device: the fdinfo text of one open fd, with its owner.
|
||||
type drmFD struct {
|
||||
PID int `json:"pid"`
|
||||
Comm string `json:"comm"`
|
||||
Info string `json:"info"`
|
||||
}
|
||||
|
||||
type fdinfo struct {
|
||||
driver, pdev, client string
|
||||
engines map[string]uint64 // ns, cumulative
|
||||
capacity map[string]int
|
||||
mem uint64 // bytes
|
||||
}
|
||||
|
||||
func parseFdinfo(s string) fdinfo {
|
||||
fi := fdinfo{engines: map[string]uint64{}, capacity: map[string]int{}}
|
||||
var resident, memory uint64
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok || !strings.HasPrefix(k, "drm-") {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
switch {
|
||||
case k == "drm-driver":
|
||||
fi.driver = v
|
||||
case k == "drm-pdev":
|
||||
fi.pdev = v
|
||||
case k == "drm-client-id":
|
||||
fi.client = v
|
||||
case strings.HasPrefix(k, "drm-engine-capacity-"):
|
||||
fi.capacity[k[len("drm-engine-capacity-"):]], _ = strconv.Atoi(v)
|
||||
case strings.HasPrefix(k, "drm-engine-"):
|
||||
n, _ := strconv.ParseUint(strings.TrimSuffix(v, " ns"), 10, 64)
|
||||
fi.engines[k[len("drm-engine-"):]] = n
|
||||
case strings.HasPrefix(k, "drm-resident-"):
|
||||
resident += kib(v)
|
||||
case strings.HasPrefix(k, "drm-memory-"):
|
||||
memory += kib(v)
|
||||
}
|
||||
}
|
||||
fi.mem = resident
|
||||
if fi.mem == 0 {
|
||||
fi.mem = memory
|
||||
}
|
||||
return fi
|
||||
}
|
||||
|
||||
// kib parses "52624 KiB" (or MiB, or a bare count of bytes) to bytes.
|
||||
func kib(v string) uint64 {
|
||||
f := strings.Fields(v)
|
||||
if len(f) == 0 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.ParseUint(f[0], 10, 64)
|
||||
if len(f) > 1 {
|
||||
switch f[1] {
|
||||
case "KiB":
|
||||
n <<= 10
|
||||
case "MiB":
|
||||
n <<= 20
|
||||
case "GiB":
|
||||
n <<= 30
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// drmScan walks /proc for every fd open on /dev/dri/*, one entry per client per process.
|
||||
// Run as root it sees everyone; as a user, only that user's processes.
|
||||
func drmScan() (out []drmFD, complete bool) {
|
||||
ents, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return []drmFD{}, false
|
||||
}
|
||||
complete = true
|
||||
out = []drmFD{}
|
||||
for _, e := range ents {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fds, err := os.ReadDir("/proc/" + e.Name() + "/fd")
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
complete = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
comm := ""
|
||||
for _, fd := range fds {
|
||||
target, err := os.Readlink("/proc/" + e.Name() + "/fd/" + fd.Name())
|
||||
if err != nil || !strings.HasPrefix(target, "/dev/dri/") {
|
||||
continue
|
||||
}
|
||||
info := readStr("/proc/" + e.Name() + "/fdinfo/" + fd.Name())
|
||||
_, rest, ok := strings.Cut(info, "drm-client-id:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(strings.SplitN(rest, "\n", 2)[0])
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
if comm == "" {
|
||||
comm = strings.TrimSpace(readStr("/proc/" + e.Name() + "/comm"))
|
||||
}
|
||||
out = append(out, drmFD{PID: pid, Comm: comm, Info: info})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out, complete
|
||||
}
|
||||
|
||||
// scanClients walks in-process when that sees everything (root), else through the helper.
|
||||
func (c *Collector) scanClients() ([]drmFD, bool) {
|
||||
if priv.Mode() == "root" || !priv.Available() {
|
||||
return drmScan()
|
||||
}
|
||||
if c.gpu.helper == nil {
|
||||
c.gpu.helper = &drmHelper{}
|
||||
}
|
||||
if fds, ok := c.gpu.helper.scan(); ok {
|
||||
return fds, true
|
||||
}
|
||||
return drmScan() // the helper is down: our own processes are better than nothing
|
||||
}
|
||||
|
||||
// drmHelper is the root child that answers "scan" requests. One line in, one JSON line out.
|
||||
type drmHelper struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
in io.WriteCloser
|
||||
out *bufio.Reader
|
||||
failedAt time.Time
|
||||
}
|
||||
|
||||
const helperRetry = 30 * time.Second
|
||||
|
||||
func (h *drmHelper) scan() ([]drmFD, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.cmd == nil {
|
||||
if !h.failedAt.IsZero() && time.Since(h.failedAt) < helperRetry {
|
||||
return nil, false
|
||||
}
|
||||
if !h.start() {
|
||||
h.failedAt = time.Now()
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
// a stuck child must not stall the sampler: it is killed, and tried again later
|
||||
cmd := h.cmd
|
||||
stop := time.AfterFunc(5*time.Second, func() { cmd.Process.Kill() })
|
||||
defer stop.Stop()
|
||||
if _, err := io.WriteString(h.in, "\n"); err != nil {
|
||||
h.fail()
|
||||
return nil, false
|
||||
}
|
||||
line, err := h.out.ReadBytes('\n')
|
||||
if err != nil {
|
||||
h.fail()
|
||||
return nil, false
|
||||
}
|
||||
var fds []drmFD
|
||||
if json.Unmarshal(line, &fds) != nil {
|
||||
h.fail()
|
||||
return nil, false
|
||||
}
|
||||
if fds == nil {
|
||||
fds = []drmFD{}
|
||||
}
|
||||
return fds, true
|
||||
}
|
||||
|
||||
func (h *drmHelper) start() bool {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
full := priv.Wrap(self, "drm-clients")
|
||||
cmd := exec.Command(full[0], full[1:]...)
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return false
|
||||
}
|
||||
h.cmd, h.in, h.out = cmd, in, bufio.NewReaderSize(out, 1<<20)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *drmHelper) fail() {
|
||||
if h.cmd != nil {
|
||||
h.in.Close()
|
||||
h.cmd.Process.Kill()
|
||||
go h.cmd.Wait()
|
||||
}
|
||||
h.cmd, h.in, h.out = nil, nil, nil
|
||||
h.failedAt = time.Now()
|
||||
}
|
||||
|
||||
// DRMClientsHelper is `hamlab drm-clients`: for every line read on stdin, one JSON line of
|
||||
// drmFD on stdout. Ends when stdin does — when the daemon that started it has gone.
|
||||
func DRMClientsHelper(stdin io.Reader, stdout io.Writer) int {
|
||||
sc := bufio.NewScanner(stdin)
|
||||
w := bufio.NewWriter(stdout)
|
||||
for sc.Scan() {
|
||||
fds, _ := drmScan()
|
||||
b, _ := json.Marshal(fds)
|
||||
w.Write(b)
|
||||
w.WriteByte('\n')
|
||||
if w.Flush() != nil {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- small helpers ----
|
||||
|
||||
func readF(p string) float64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(readStr(p)), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func readLink(p string) string {
|
||||
t, err := os.Readlink(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func ueventValue(p, key string) string {
|
||||
for _, line := range strings.Split(readStr(p), "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok && k == key {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const fdinfoA = `pos: 0
|
||||
flags: 02100002
|
||||
drm-driver: i915
|
||||
drm-client-id: 544
|
||||
drm-pdev: 0000:00:02.0
|
||||
drm-total-system0: 52624 KiB
|
||||
drm-resident-system0: 50140 KiB
|
||||
drm-resident-stolen-system0: 32 KiB
|
||||
drm-engine-render: 1000000000 ns
|
||||
drm-engine-copy: 0 ns
|
||||
drm-engine-video: 2000000000 ns
|
||||
drm-engine-capacity-video: 2
|
||||
drm-engine-video-enhance: 0 ns
|
||||
`
|
||||
|
||||
func TestParseFdinfo(t *testing.T) {
|
||||
fi := parseFdinfo(fdinfoA)
|
||||
if fi.driver != "i915" || fi.pdev != "0000:00:02.0" || fi.client != "544" {
|
||||
t.Fatalf("identity: %+v", fi)
|
||||
}
|
||||
if fi.engines["video"] != 2000000000 || fi.capacity["video"] != 2 {
|
||||
t.Errorf("video engine: %+v", fi)
|
||||
}
|
||||
if fi.mem != (50140+32)<<10 {
|
||||
t.Errorf("resident = %d", fi.mem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLspciName(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Intel Corporation DG2 [Arc A380] (rev 05)": "Intel Arc A380",
|
||||
"Intel Corporation RocketLake-S GT1 [UHD Graphics 750] (rev 04)": "Intel UHD Graphics 750",
|
||||
"Intel Corporation Alder Lake-N [UHD Graphics]": "Intel UHD Graphics",
|
||||
"Advanced Micro Devices, Inc. [AMD/ATI] Navi 23 [Radeon RX 6600/6600 XT/6600M] (rev c7)": "AMD Radeon RX 6600/6600 XT/6600M",
|
||||
"NVIDIA Corporation GA106 [GeForce RTX 3060 Lite Hash Rate] (rev a1)": "NVIDIA GeForce RTX 3060 Lite Hash Rate",
|
||||
"Moore Threads Technology Co.,Ltd MTT S80": "Moore Threads Technology Co.,Ltd MTT S80",
|
||||
} {
|
||||
if got := lspciName(in); got != want {
|
||||
t.Errorf("%q → %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two scans a second apart: a client that ran its render engine for half of it and both
|
||||
// video engines flat out reads 50% render, 100% video, and the device's engines follow.
|
||||
func TestGPUClientsShare(t *testing.T) {
|
||||
c := NewCollector()
|
||||
d := &gpuDev{card: "/nonexistent/card9", dev: "/nonexistent", key: "0000:00:02.0", bus: "0000:00:02.0", driver: "i915", name: "Test GPU", integrated: true}
|
||||
c.gpu.devs = []*gpuDev{d}
|
||||
c.gpu.listedAt = time.Now().Add(time.Hour) // keep the fake list
|
||||
c.gpu.clients = map[string]clientPrev{}
|
||||
t0 := time.Now()
|
||||
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: fdinfoA}}
|
||||
c.gpu.scanAt = t0 // no walk: the fake list stands
|
||||
first := c.gpus(t0, 40)
|
||||
if len(first) != 1 || len(first[0].Clients) != 1 || first[0].Clients[0].Usage != 0 {
|
||||
t.Fatalf("first pass: %+v", first)
|
||||
}
|
||||
if first[0].Temp != 40 || first[0].TempSrc != "package" {
|
||||
t.Errorf("an integrated GPU wears the package temperature: %+v", first[0])
|
||||
}
|
||||
later := fdinfoA
|
||||
later = replace(later, "drm-engine-render: 1000000000 ns", "drm-engine-render: 1500000000 ns")
|
||||
later = replace(later, "drm-engine-video: 2000000000 ns", "drm-engine-video: 4000000000 ns")
|
||||
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: later}}
|
||||
c.gpu.scanAt = t0.Add(time.Second)
|
||||
got := c.gpus(t0.Add(time.Second), 40)
|
||||
cl := got[0].Clients[0]
|
||||
if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 {
|
||||
t.Errorf("client share: %+v", cl)
|
||||
}
|
||||
if got[0].Engines["video"] != 100 || got[0].Usage != 100 || got[0].UsageSrc != "clients" {
|
||||
t.Errorf("device from clients: %+v", got[0])
|
||||
}
|
||||
if got[0].MemUsed != cl.Mem {
|
||||
t.Errorf("resident memory should be the clients' sum: %d vs %d", got[0].MemUsed, cl.Mem)
|
||||
}
|
||||
// the Processes table joins the client to its row
|
||||
c.ring = []Sample{{T: 1, GPUs: got}}
|
||||
c.prevProc = nil
|
||||
table := c.Processes()
|
||||
if len(table.GPUs) != 1 {
|
||||
t.Errorf("table carries the GPUs: %+v", table.GPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenGPU(t *testing.T) {
|
||||
s := Sample{T: 5, GPUs: []GPU{{Name: "Intel Arc A380", Driver: "i915", Bus: "0000:03:00.0", Usage: 12, UsageSrc: "idle", Engines: map[string]float64{"video": 30}, PowerW: 9.5, PowerCapW: 55, MHz: 2450, MaxMHz: 2450, Clients: []GPUClient{{PID: 1}}}}}
|
||||
got := map[string]any{}
|
||||
for _, x := range flatten(s, nil) {
|
||||
got[x.ID] = x.Value
|
||||
}
|
||||
for id, want := range map[string]any{"gpu.intel_arc_a380.usage": 12.0, "gpu.intel_arc_a380.video": 30.0, "gpu.intel_arc_a380.power": 9.5, "gpu.intel_arc_a380.freq": 2450.0, "gpu.intel_arc_a380.clients": 1.0} {
|
||||
if got[id] != want {
|
||||
t.Errorf("%s = %v, want %v", id, got[id], want)
|
||||
}
|
||||
}
|
||||
if _, ok := got["gpu.intel_arc_a380.temp"]; ok {
|
||||
t.Error("no temperature sensor, no temperature")
|
||||
}
|
||||
}
|
||||
|
||||
func replace(s, old, new string) string {
|
||||
for i := 0; i+len(old) <= len(s); i++ {
|
||||
if s[i:i+len(old)] == old {
|
||||
return s[:i] + new + s[i+len(old):]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestLiveGPU reads this machine's GPUs for real: HAMLAB_LIVE=1 go test -run TestLiveGPU -v ./internal/metrics
|
||||
func TestLiveGPU(t *testing.T) {
|
||||
if os.Getenv("HAMLAB_LIVE") == "" {
|
||||
t.Skip("set HAMLAB_LIVE=1 to read the real hardware")
|
||||
}
|
||||
c := NewCollector()
|
||||
c.sample()
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
c.sample()
|
||||
s, _ := c.Latest()
|
||||
b, _ := json.MarshalIndent(s.GPUs, "", " ")
|
||||
t.Logf("%s", b)
|
||||
for _, x := range flatten(s, func(id string) bool { return len(id) > 4 && id[:4] == "gpu." }) {
|
||||
t.Logf("%s = %v %s", x.ID, x.Value, x.Unit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLiveARC: HAMLAB_LIVE=1 go test -run TestLiveARC -v ./internal/metrics
|
||||
func TestLiveARC(t *testing.T) {
|
||||
if os.Getenv("HAMLAB_LIVE") == "" {
|
||||
t.Skip("set HAMLAB_LIVE=1 to read the real machine")
|
||||
}
|
||||
c := NewCollector()
|
||||
c.sample()
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
c.sample()
|
||||
s, _ := c.Latest()
|
||||
b, _ := json.MarshalIndent(s.ARC, "", " ")
|
||||
t.Logf("%s", b)
|
||||
for _, x := range flatten(s, func(id string) bool { return strings.HasPrefix(id, "zfs.arc.") }) {
|
||||
t.Logf("%s = %v %s", x.ID, x.Value, x.Unit)
|
||||
}
|
||||
}
|
||||
|
||||
// The embedded table names the lab's four GPUs the way lspci would, so a machine without
|
||||
// pciutils (the router) agrees with one that has it.
|
||||
func TestPCIName(t *testing.T) {
|
||||
for id, want := range map[string]string{"8086:3185": "Intel UHD Graphics 600", "8086:56a5": "Intel Arc A380", "8086:46d1": "Intel UHD Graphics", "8086:4c8a": "Intel UHD Graphics 750", "10de:2684": "NVIDIA GeForce RTX 4090"} {
|
||||
v, d := pciName(id[:4], id[5:])
|
||||
if d == "" {
|
||||
t.Errorf("%s: not in the table", id)
|
||||
continue
|
||||
}
|
||||
if got := lspciName(v + " " + d); got != want {
|
||||
t.Errorf("%s → %q, want %q", id, got, want)
|
||||
}
|
||||
}
|
||||
if _, d := pciName("8086", "ffff"); d != "" {
|
||||
t.Errorf("an unknown device should be empty, got %q", d)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
// lm-sensors, smartmontools or root. Sections whose source is absent (no fans, no
|
||||
// UPS daemon, no hwmon at all on a VM) simply come back empty rather than failing.
|
||||
// The only external commands are optional UPS daemons: pwrstat (CyberPower), upsc
|
||||
// (NUT) and apcaccess (apcupsd), tried in that order.
|
||||
// (NUT) and apcaccess (apcupsd), tried in that order. Two readings are root-only, the GPU
|
||||
// client walk and the CPU energy counters; without root they come from small helpers of
|
||||
// this binary started once through sudo or doas (gpu.go, cpupower.go), or not at all.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
@@ -123,6 +125,7 @@ type Proc struct {
|
||||
Cmd string `json:"cmd,omitempty"`
|
||||
User string `json:"user"`
|
||||
CPU float64 `json:"cpu"` // percent of one core
|
||||
GPU float64 `json:"gpu,omitempty"` // its busiest GPU engine, percent; summed over the GPUs it has open
|
||||
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
|
||||
RSS uint64 `json:"rss"` // bytes
|
||||
Threads int `json:"threads"`
|
||||
@@ -136,6 +139,7 @@ type Sample struct {
|
||||
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
@@ -145,6 +149,8 @@ type Sample struct {
|
||||
Disks []Disk `json:"disks"`
|
||||
FS []FS `json:"fs"`
|
||||
UPS *UPS `json:"ups"`
|
||||
GPUs []GPU `json:"gpus"`
|
||||
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module
|
||||
Uptime float64 `json:"uptime"`
|
||||
|
||||
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
|
||||
@@ -173,6 +179,9 @@ type Collector struct {
|
||||
procAt time.Time
|
||||
lastTable *ProcTable
|
||||
upsProbe func() *UPS // nil when no UPS daemon is installed
|
||||
gpu gpuState
|
||||
power cpuPowerState
|
||||
prevARC arcPrev
|
||||
|
||||
subMu sync.Mutex
|
||||
subs map[chan struct{}]struct{} // told after every sample; see Subscribe
|
||||
@@ -338,6 +347,7 @@ func (c *Collector) sample() {
|
||||
|
||||
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
|
||||
s.CPU, s.Cores = c.cpu()
|
||||
s.CPUPower = c.cpuPower(now)
|
||||
s.Load = loadavg()
|
||||
s.Mem, s.Swap = meminfo()
|
||||
s.Uptime = uptime()
|
||||
@@ -356,6 +366,8 @@ func (c *Collector) sample() {
|
||||
c.lastUPS, c.upsAt = c.upsProbe(), now
|
||||
}
|
||||
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
|
||||
s.GPUs = c.gpus(now, cpuTemp(s.Temps))
|
||||
s.ARC = c.arc(now)
|
||||
|
||||
if first {
|
||||
return // deltas are meaningless on the priming pass
|
||||
@@ -1106,12 +1118,14 @@ type ProcTable struct {
|
||||
CPUModel string `json:"cpu_model,omitempty"`
|
||||
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
||||
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
||||
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
|
||||
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
||||
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Net []Net `json:"net"`
|
||||
GPUs []GPU `json:"gpus"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Procs []Proc `json:"procs"`
|
||||
Total int `json:"total"`
|
||||
@@ -1274,6 +1288,20 @@ func (c *Collector) Processes() ProcTable {
|
||||
}
|
||||
}
|
||||
c.prevProc, c.procAt = cur, now
|
||||
// what each process is doing on the GPUs, from the newest sample's client lists
|
||||
gpuBy := map[int]float64{}
|
||||
gpus := []GPU{}
|
||||
if n := len(c.ring); n > 0 && c.ring[n-1].GPUs != nil {
|
||||
gpus = c.ring[n-1].GPUs
|
||||
for _, g := range gpus {
|
||||
for _, cl := range g.Clients {
|
||||
gpuBy[cl.PID] += cl.Usage
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range procs {
|
||||
procs[i].GPU = round1(gpuBy[procs[i].PID])
|
||||
}
|
||||
sort.Slice(procs, func(i, j int) bool {
|
||||
if procs[i].CPU != procs[j].CPU {
|
||||
return procs[i].CPU > procs[j].CPU
|
||||
@@ -1281,7 +1309,7 @@ func (c *Collector) Processes() ProcTable {
|
||||
return procs[i].RSS > procs[j].RSS
|
||||
})
|
||||
t := ProcTable{T: now.UnixMilli(), Procs: procs, Total: len(procs), Uptime: uptime(), Load: loadavg(),
|
||||
CPUModel: cpuModel, CPUMHz: round1(cpuMHz()), CPUMaxMHz: cpuMaxMHz, CPUHist: []float64{}, Net: []Net{}}
|
||||
CPUModel: cpuModel, CPUMHz: round1(cpuMHz()), CPUMaxMHz: cpuMaxMHz, CPUHist: []float64{}, Net: []Net{}, GPUs: gpus}
|
||||
for _, p := range procs {
|
||||
if p.State == "R" {
|
||||
t.Running++
|
||||
@@ -1290,7 +1318,7 @@ func (c *Collector) Processes() ProcTable {
|
||||
t.Mem, t.Swap = meminfo()
|
||||
if n := len(c.ring); n > 0 {
|
||||
last := c.ring[n-1]
|
||||
t.CPU, t.Cores, t.Net, t.CPUTemp = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps)
|
||||
t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower
|
||||
t.CoreIDs = coreIDs(len(last.Cores))
|
||||
from := n - 90
|
||||
if from < 0 {
|
||||
@@ -1330,12 +1358,32 @@ func Signal(pid int, sig syscall.Signal) error {
|
||||
|
||||
// ---- small helpers ----
|
||||
|
||||
// readStr reads a /proc or /sys file whole, with plain blocking syscalls and never through
|
||||
// the runtime poller. os.ReadFile would hand a sysfs file to the poller — kernfs accepts
|
||||
// epoll — and a driver that answers a non-blocking read with EAGAIN (i915's fan attribute
|
||||
// did, on grid, 2026-10-02) then leaves the reader waiting for a readiness kernfs only
|
||||
// signals on sysfs_notify: never. The sampler sat in that wait for 42 minutes.
|
||||
func readStr(p string) string {
|
||||
b, err := os.ReadFile(p)
|
||||
fd, err := syscall.Open(p, syscall.O_RDONLY|syscall.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
defer syscall.Close(fd)
|
||||
var out []byte
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := syscall.Read(fd, buf)
|
||||
if n > 0 {
|
||||
out = append(out, buf[:n]...)
|
||||
}
|
||||
if err == syscall.EINTR {
|
||||
continue
|
||||
}
|
||||
if err != nil || n == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func readInt(p string) (int64, bool) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package metrics
|
||||
|
||||
// A trimmed PCI ID database, embedded, so a GPU is named the same on every machine —
|
||||
// "Intel UHD Graphics 600" — whether or not that machine has pciutils. OpenWrt has neither
|
||||
// lspci nor pci.ids, and the router's GPU came up as "Intel GPU 3185" beside its twin's
|
||||
// "Intel UHD Graphics 600" until this. scripts/pci-ids-gpu.sh regenerates the file.
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed pciids_gpu.txt
|
||||
var pciIDsGPU string
|
||||
|
||||
var pciTable struct {
|
||||
once sync.Once
|
||||
vendors map[string]string // "8086" → "Intel Corporation"
|
||||
devices map[string]string // "8086:3185" → "GeminiLake [UHD Graphics 600]"
|
||||
}
|
||||
|
||||
// pciName looks a vendor and device ID up (hex, lower case, no 0x): the vendor's name and
|
||||
// the device's; empty where unknown.
|
||||
func pciName(vendor, device string) (string, string) {
|
||||
pciTable.once.Do(func() {
|
||||
pciTable.vendors, pciTable.devices = map[string]string{}, map[string]string{}
|
||||
cur := ""
|
||||
for _, line := range strings.Split(pciIDsGPU, "\n") {
|
||||
switch {
|
||||
case line == "" || line[0] == '#':
|
||||
case line[0] == '\t':
|
||||
if cur == "" || len(line) < 7 || line[1] == '\t' {
|
||||
continue // a subsystem, or a device before any vendor
|
||||
}
|
||||
pciTable.devices[cur+":"+line[1:5]] = strings.TrimSpace(line[5:])
|
||||
default:
|
||||
if len(line) >= 6 {
|
||||
cur = line[:4]
|
||||
pciTable.vendors[cur] = strings.TrimSpace(line[4:])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
vendor, device = strings.ToLower(vendor), strings.ToLower(device)
|
||||
return pciTable.vendors[vendor], pciTable.devices[vendor+":"+device]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,9 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
if c := cpuTemp(s.Temps); c > 0 {
|
||||
f.add("cpu.temp", "CPU temperature", "temperature", "°C", c, t, none)
|
||||
}
|
||||
if s.CPUPower > 0 {
|
||||
f.add("cpu.power", "CPU power", "power", "W", s.CPUPower, t, none)
|
||||
}
|
||||
for i, w := range []string{"1", "5", "15"} {
|
||||
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
|
||||
}
|
||||
@@ -131,6 +134,75 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
||||
}
|
||||
|
||||
for _, g := range s.GPUs {
|
||||
p := "gpu." + sensors.Seg(g.Name)
|
||||
lbl := []string{"name", g.Name, "driver", g.Driver}
|
||||
if g.Bus != "" {
|
||||
lbl = append(lbl, "bus", g.Bus)
|
||||
}
|
||||
if g.Integrated {
|
||||
lbl = append(lbl, "integrated", "1")
|
||||
}
|
||||
if g.UsageSrc != "" {
|
||||
f.add(p+".usage", g.Name, "usage", "%", g.Usage, t, pct, lbl...)
|
||||
}
|
||||
for _, e := range []string{"render", "video", "copy", "video-enhance", "compute"} {
|
||||
if v, ok := g.Engines[e]; ok {
|
||||
f.add(p+"."+sensors.Seg(e), g.Name+" "+e, "usage", "%", v, t, pct, lbl...)
|
||||
}
|
||||
}
|
||||
if g.Temp > 0 {
|
||||
f.add(p+".temp", g.Name+" temperature", "temperature", "°C", g.Temp, t, none, append(lbl, "source", g.TempSrc)...)
|
||||
}
|
||||
if g.PowerW > 0 || g.PowerCapW > 0 {
|
||||
b := none
|
||||
if g.PowerCapW > 0 {
|
||||
b = upTo(g.PowerCapW)
|
||||
}
|
||||
f.add(p+".power", g.Name+" power", "power", "W", g.PowerW, t, b, lbl...)
|
||||
}
|
||||
if g.FanRPM > 0 {
|
||||
f.add(p+".fan", g.Name+" fan", "fan", "rpm", float64(g.FanRPM), t, none, lbl...)
|
||||
}
|
||||
if g.MaxMHz > 0 {
|
||||
f.add(p+".freq", g.Name+" clock", "frequency", "MHz", g.MHz, t, upTo(g.MaxMHz), lbl...)
|
||||
}
|
||||
if g.MemTotal > 0 {
|
||||
f.add(p+".mem_used", g.Name+" memory used", "bytes", "B", float64(g.MemUsed), t, upTo(float64(g.MemTotal)), lbl...)
|
||||
f.add(p+".mem_total", g.Name+" memory", "bytes", "B", float64(g.MemTotal), t, none, lbl...)
|
||||
} else if g.MemUsed > 0 {
|
||||
f.add(p+".mem_used", g.Name+" memory used", "bytes", "B", float64(g.MemUsed), t, none, lbl...)
|
||||
}
|
||||
f.add(p+".clients", g.Name+" clients", "count", "", float64(len(g.Clients)), t, none, lbl...)
|
||||
}
|
||||
|
||||
if a := s.ARC; a != nil {
|
||||
p := "zfs.arc."
|
||||
cap := upTo(float64(a.Max))
|
||||
f.add(p+"size", "ARC size", "bytes", "B", float64(a.Size), t, cap)
|
||||
f.add(p+"target", "ARC target", "bytes", "B", float64(a.Target), t, cap)
|
||||
f.add(p+"min", "ARC floor", "bytes", "B", float64(a.Min), t, none)
|
||||
f.add(p+"max", "ARC ceiling", "bytes", "B", float64(a.Max), t, none)
|
||||
f.add(p+"mru", "ARC recently used", "bytes", "B", float64(a.MRU), t, cap)
|
||||
f.add(p+"mfu", "ARC frequently used", "bytes", "B", float64(a.MFU), t, cap)
|
||||
f.add(p+"metadata", "ARC metadata", "bytes", "B", float64(a.Metadata), t, cap)
|
||||
f.add(p+"data", "ARC data", "bytes", "B", float64(a.Data), t, cap)
|
||||
if a.HitPctKnown {
|
||||
f.add(p+"hit_pct", "ARC hit rate", "usage", "%", a.HitPct, t, pct)
|
||||
}
|
||||
f.add(p+"reads", "ARC reads", "rate", "/s", a.ReadsPerSec, t, none)
|
||||
f.add(p+"misses", "ARC misses", "rate", "/s", a.MissesPerSec, t, none)
|
||||
f.add(p+"lifetime_hit_pct", "ARC hit rate since boot", "usage", "%", a.LifetimeHitPct, t, pct)
|
||||
f.add(p+"no_grow", "ARC held by memory pressure", "state", "", a.NoGrow, t, none)
|
||||
if a.Compressed > 0 {
|
||||
f.add(p+"compress_ratio", "ARC compression", "ratio", "x", round1(float64(a.Uncompressed)/float64(a.Compressed)*10)/10, t, none)
|
||||
}
|
||||
if a.L2Size > 0 {
|
||||
f.add(p+"l2_size", "L2ARC size", "bytes", "B", float64(a.L2Size), t, none)
|
||||
f.add(p+"l2_hit_pct", "L2ARC hit rate", "usage", "%", a.L2HitPct, t, pct)
|
||||
}
|
||||
}
|
||||
|
||||
if u := s.UPS; u != nil {
|
||||
ut := s.upsT
|
||||
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
@@ -37,9 +37,14 @@ var systemdUnit string
|
||||
//go:embed hamlab.openrc
|
||||
var openrcScript string
|
||||
|
||||
//go:embed hamlab.procd
|
||||
var procdScript string
|
||||
|
||||
const (
|
||||
unitPath = "/etc/systemd/system/hamlab.service"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -228,6 +233,19 @@ func Install(args []string, version string) error {
|
||||
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"):
|
||||
initSys = "OpenRC"
|
||||
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)
|
||||
switch {
|
||||
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 healthy(cfg.Listen, cfg.TLS == "auto"):
|
||||
say("\nHam Lab is up: %s", url)
|
||||
@@ -294,9 +312,15 @@ func Uninstall(args []string) error {
|
||||
_ = run("systemctl", "daemon-reload")
|
||||
}
|
||||
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=")
|
||||
_ = run("rc-service", "hamlab", "stop")
|
||||
_ = run("rc-update", "del", "hamlab", "default")
|
||||
}
|
||||
os.Remove(openrcPath)
|
||||
}
|
||||
os.Remove(sudoersPath)
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package storage
|
||||
|
||||
// The ZFS caching tiers, for the ZFS tab: what the ARC has done since boot and how it is
|
||||
// tuned, and for each pool which cache devices it has — L2ARC, a separate log (SLOG), a
|
||||
// special allocation class, a dedup table — or, as is common, none of them, which the tab
|
||||
// should say plainly rather than leave a blank.
|
||||
//
|
||||
// The live numbers (size, the hit rate over the last interval) come from the sampler in
|
||||
// internal/metrics, which also publishes them as zfs.arc.* sensors; this is the slower,
|
||||
// fuller picture, read when the tab asks.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheDev is one member of a pool's cache, log, special, dedup or spare class.
|
||||
type CacheDev struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size,omitempty"`
|
||||
Alloc uint64 `json:"alloc,omitempty"`
|
||||
Health string `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// PoolCaching is what one pool has beyond its data vdevs, and the properties that steer
|
||||
// what is cached.
|
||||
type PoolCaching struct {
|
||||
Pool string `json:"pool"`
|
||||
Cache []CacheDev `json:"cache"` // L2ARC
|
||||
Log []CacheDev `json:"log"` // SLOG
|
||||
Special []CacheDev `json:"special"` // metadata / small blocks
|
||||
Dedup []CacheDev `json:"dedup"` // dedup table vdevs
|
||||
Spare []CacheDev `json:"spare"`
|
||||
// dataset properties at the pool root
|
||||
PrimaryCache string `json:"primarycache"`
|
||||
SecondaryCache string `json:"secondarycache"`
|
||||
Sync string `json:"sync"`
|
||||
LogBias string `json:"logbias"`
|
||||
DedupProp string `json:"dedup_prop"`
|
||||
Compression string `json:"compression"`
|
||||
RecordSize string `json:"recordsize"`
|
||||
DedupRatio string `json:"dedup_ratio"` // zpool's, e.g. "1.00x"
|
||||
}
|
||||
|
||||
// ARCDetail is the full picture.
|
||||
type ARCDetail struct {
|
||||
Available bool `json:"available"`
|
||||
MemTotal uint64 `json:"mem_total"`
|
||||
Tunables map[string]string `json:"tunables"` // zfs module parameters that shape the cache; "" when unset
|
||||
Lifetime struct {
|
||||
Hits, Misses uint64 `json:",omitempty"`
|
||||
DemandDataHits, DemandDataMisses uint64
|
||||
DemandMetaHits, DemandMetaMisses uint64
|
||||
PrefetchDataHits, PrefetchDataMisses uint64
|
||||
PrefetchMetaHits, PrefetchMetaMisses uint64
|
||||
L2Hits, L2Misses, L2ReadBytes, L2WriteBytes uint64
|
||||
L2Feeds, L2RebuildSize uint64
|
||||
EvictL2Eligible uint64 // bytes evicted from RAM that an L2ARC could have caught
|
||||
Deleted, EvictSkip, MutexMiss, Prune, Throttle uint64
|
||||
} `json:"lifetime"`
|
||||
Zfetch struct{ Hits, Misses, IOIssued uint64 } `json:"zfetch"` // the prefetcher
|
||||
ZIL struct {
|
||||
Commits uint64 `json:"commits"`
|
||||
NormalBytes, NormalWrite uint64 // written to the pool's own vdevs
|
||||
SlogBytes, SlogWrite uint64 // written to a separate log device
|
||||
} `json:"zil"`
|
||||
DBuf struct{ Size, Target, Hits, Misses uint64 } `json:"dbuf"` // the dbuf cache in front of the ARC
|
||||
Pools []PoolCaching `json:"pools"`
|
||||
}
|
||||
|
||||
const kstatDir = "/proc/spl/kstat/zfs/"
|
||||
|
||||
func readKstat(p string) map[string]uint64 {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]uint64{}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 3 || f[1] != "4" {
|
||||
continue
|
||||
}
|
||||
if v, err := strconv.ParseUint(f[2], 10, 64); err == nil {
|
||||
out[f[0]] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var arcTunables = []string{"zfs_arc_max", "zfs_arc_min", "zfs_arc_sys_free", "zfs_arc_meta_balance", "zfs_prefetch_disable", "l2arc_write_max", "l2arc_write_boost", "l2arc_noprefetch", "l2arc_rebuild_enabled", "zfs_dirty_data_max"}
|
||||
|
||||
const poolCachingEvery = 30 * time.Second // zpool list -v and zfs get are an exec each
|
||||
|
||||
// ARC reads the kstats now and the pool layout from a short cache.
|
||||
func (m *Manager) ARC(ctx context.Context) ARCDetail {
|
||||
d := ARCDetail{Tunables: map[string]string{}, Pools: []PoolCaching{}}
|
||||
k := readKstat(kstatDir + "arcstats")
|
||||
if k == nil {
|
||||
return d
|
||||
}
|
||||
d.Available = true
|
||||
l := &d.Lifetime
|
||||
l.Hits, l.Misses = k["hits"], k["misses"]
|
||||
l.DemandDataHits, l.DemandDataMisses = k["demand_data_hits"], k["demand_data_misses"]
|
||||
l.DemandMetaHits, l.DemandMetaMisses = k["demand_metadata_hits"], k["demand_metadata_misses"]
|
||||
l.PrefetchDataHits, l.PrefetchDataMisses = k["prefetch_data_hits"], k["prefetch_data_misses"]
|
||||
l.PrefetchMetaHits, l.PrefetchMetaMisses = k["prefetch_metadata_hits"], k["prefetch_metadata_misses"]
|
||||
l.L2Hits, l.L2Misses, l.L2ReadBytes, l.L2WriteBytes = k["l2_hits"], k["l2_misses"], k["l2_read_bytes"], k["l2_write_bytes"]
|
||||
l.L2Feeds, l.L2RebuildSize, l.EvictL2Eligible = k["l2_feeds"], k["l2_rebuild_size"], k["evict_l2_eligible"]
|
||||
l.Deleted, l.EvictSkip, l.MutexMiss, l.Prune, l.Throttle = k["deleted"], k["evict_skip"], k["mutex_miss"], k["arc_prune"], k["memory_throttle_count"]
|
||||
if z := readKstat(kstatDir + "zfetchstats"); z != nil {
|
||||
d.Zfetch.Hits, d.Zfetch.Misses, d.Zfetch.IOIssued = z["hits"], z["misses"], z["io_issued"]
|
||||
}
|
||||
if z := readKstat(kstatDir + "zil"); z != nil {
|
||||
d.ZIL.Commits = z["zil_commit_count"]
|
||||
d.ZIL.NormalBytes, d.ZIL.NormalWrite = z["zil_itx_metaslab_normal_bytes"], z["zil_itx_metaslab_normal_write"]
|
||||
d.ZIL.SlogBytes, d.ZIL.SlogWrite = z["zil_itx_metaslab_slog_bytes"], z["zil_itx_metaslab_slog_write"]
|
||||
}
|
||||
if z := readKstat(kstatDir + "dbufstats"); z != nil {
|
||||
d.DBuf.Size, d.DBuf.Target, d.DBuf.Hits, d.DBuf.Misses = z["cache_size_bytes"], z["cache_target_bytes"], z["hash_hits"], z["hash_misses"]
|
||||
}
|
||||
for _, t := range arcTunables {
|
||||
if b, err := os.ReadFile("/sys/module/zfs/parameters/" + t); err == nil {
|
||||
d.Tunables[t] = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 2 {
|
||||
d.MemTotal = u64(f[1]) << 10
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
d.Pools = m.poolCaching(ctx)
|
||||
return d
|
||||
}
|
||||
|
||||
var cachingMu sync.Mutex
|
||||
var cachingAt time.Time
|
||||
var cachingLast []PoolCaching
|
||||
|
||||
func (m *Manager) poolCaching(ctx context.Context) []PoolCaching {
|
||||
cachingMu.Lock()
|
||||
defer cachingMu.Unlock()
|
||||
if cachingLast != nil && time.Since(cachingAt) < poolCachingEvery {
|
||||
return cachingLast
|
||||
}
|
||||
out := []PoolCaching{}
|
||||
bin, ok := lookPath("zpool")
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
// zpool list -v needs no root; the vdev tree under each pool, with the class headers
|
||||
raw, code, err := m.runPlain(ctx, bin, "list", "-Hpv", "-o", "name,size,alloc,free,health")
|
||||
if err != nil || code != 0 {
|
||||
return out
|
||||
}
|
||||
out = parsePoolCaching(string(raw))
|
||||
if zfs, ok := lookPath("zfs"); ok {
|
||||
for i := range out {
|
||||
p := &out[i]
|
||||
if props, code, err := m.runPlain(ctx, zfs, "get", "-Hp", "-o", "property,value", "primarycache,secondarycache,sync,logbias,dedup,compression,recordsize", p.Pool); err == nil && code == 0 {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(props)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
switch f[0] {
|
||||
case "primarycache":
|
||||
p.PrimaryCache = f[1]
|
||||
case "secondarycache":
|
||||
p.SecondaryCache = f[1]
|
||||
case "sync":
|
||||
p.Sync = f[1]
|
||||
case "logbias":
|
||||
p.LogBias = f[1]
|
||||
case "dedup":
|
||||
p.DedupProp = f[1]
|
||||
case "compression":
|
||||
p.Compression = f[1]
|
||||
case "recordsize":
|
||||
p.RecordSize = humanSize(u64(f[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pool := range parsePools(func() []byte {
|
||||
b, _, _ := m.runPlain(ctx, bin, "list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup")
|
||||
return b
|
||||
}()) {
|
||||
for i := range out {
|
||||
if out[i].Pool == pool.Name {
|
||||
out[i].DedupRatio = pool.Dedup
|
||||
}
|
||||
}
|
||||
}
|
||||
cachingLast, cachingAt = out, time.Now()
|
||||
return out
|
||||
}
|
||||
|
||||
// runPlain runs a command as ourselves, never through sudo: these are read-only zpool/zfs
|
||||
// queries that need no root on Linux, and a poll must not write to the auth log.
|
||||
func (m *Manager) runPlain(ctx context.Context, bin string, args ...string) ([]byte, int, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, bin, args...).Output()
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return out, ee.ExitCode(), nil
|
||||
}
|
||||
return out, 0, err
|
||||
}
|
||||
|
||||
var cacheClasses = map[string]bool{"logs": true, "log": true, "cache": true, "special": true, "dedup": true, "spares": true, "spare": true}
|
||||
|
||||
// parsePoolCaching reads `zpool list -Hpv -o name,size,alloc,free,health`: a pool line, then
|
||||
// its vdevs indented, with a bare class name ("logs", "cache", "special", "dedup", "spares")
|
||||
// introducing each extra class. Only those classes' members are kept; the data vdevs are the
|
||||
// pool and are shown elsewhere.
|
||||
func parsePoolCaching(text string) []PoolCaching {
|
||||
var out []PoolCaching
|
||||
var cur *PoolCaching
|
||||
class := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
indented := strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t")
|
||||
f := strings.Split(strings.TrimSpace(raw), "\t")
|
||||
name := strings.TrimSpace(f[0])
|
||||
if !indented {
|
||||
out = append(out, PoolCaching{Pool: name, Cache: []CacheDev{}, Log: []CacheDev{}, Special: []CacheDev{}, Dedup: []CacheDev{}, Spare: []CacheDev{}})
|
||||
cur, class = &out[len(out)-1], ""
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
if cacheClasses[name] && (len(f) < 2 || f[1] == "-" || f[1] == "") {
|
||||
class = name
|
||||
continue
|
||||
}
|
||||
if class == "" {
|
||||
continue // a data vdev or one of its disks
|
||||
}
|
||||
// a mirror or raidz inside a class lists its members below it; keep them all, the
|
||||
// grouping is visible in the vdev tree on the pool card
|
||||
dev := CacheDev{Name: name}
|
||||
if len(f) >= 5 {
|
||||
dev.Size, dev.Alloc, dev.Health = u64(f[1]), u64(f[2]), strings.TrimSpace(f[4])
|
||||
if f[1] == "-" {
|
||||
dev.Size = 0
|
||||
}
|
||||
}
|
||||
switch class {
|
||||
case "logs", "log":
|
||||
cur.Log = append(cur.Log, dev)
|
||||
case "cache":
|
||||
cur.Cache = append(cur.Cache, dev)
|
||||
case "special":
|
||||
cur.Special = append(cur.Special, dev)
|
||||
case "dedup":
|
||||
cur.Dedup = append(cur.Dedup, dev)
|
||||
case "spares", "spare":
|
||||
cur.Spare = append(cur.Spare, dev)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []PoolCaching{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func humanSize(b uint64) string {
|
||||
switch {
|
||||
case b >= 1<<20 && b%(1<<20) == 0:
|
||||
return strconv.FormatUint(b>>20, 10) + "M"
|
||||
case b >= 1<<10 && b%(1<<10) == 0:
|
||||
return strconv.FormatUint(b>>10, 10) + "K"
|
||||
}
|
||||
return strconv.FormatUint(b, 10)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// zpool list -Hpv on a pool with every extra class; the data raidz and its disks are skipped
|
||||
const listV = "tank\t32000000000000\t23000000000000\t9000000000000\tONLINE\n" +
|
||||
"\traidz2-0\t32000000000000\t23000000000000\t9000000000000\tONLINE\n" +
|
||||
"\t\twwn-0x1\t4000000000000\t-\t-\tONLINE\n" +
|
||||
"\t\twwn-0x2\t4000000000000\t-\t-\tONLINE\n" +
|
||||
"\tspecial\t-\t-\t-\t-\n" +
|
||||
"\t\tmirror-1\t500000000000\t120000000000\t380000000000\tONLINE\n" +
|
||||
"\t\t\tnvme0n1p1\t500000000000\t-\t-\tONLINE\n" +
|
||||
"\t\t\tnvme1n1p1\t500000000000\t-\t-\tONLINE\n" +
|
||||
"\tlogs\t-\t-\t-\t-\n" +
|
||||
"\t\tnvme2n1p1\t16000000000\t4000000\t15996000000\tONLINE\n" +
|
||||
"\tcache\t-\t-\t-\t-\n" +
|
||||
"\t\tnvme2n1p2\t400000000000\t210000000000\t190000000000\tONLINE\n" +
|
||||
"\tspares\t-\t-\t-\t-\n" +
|
||||
"\t\twwn-0x9\t4000000000000\t-\t-\tAVAIL\n" +
|
||||
"scratch\t1000000000000\t1000000000\t999000000000\tONLINE\n" +
|
||||
"\tsda\t1000000000000\t1000000000\t999000000000\tONLINE\n"
|
||||
|
||||
func TestParsePoolCaching(t *testing.T) {
|
||||
got := parsePoolCaching(listV)
|
||||
if len(got) != 2 || got[0].Pool != "tank" || got[1].Pool != "scratch" {
|
||||
t.Fatalf("pools: %+v", got)
|
||||
}
|
||||
tank := got[0]
|
||||
if len(tank.Cache) != 1 || tank.Cache[0].Name != "nvme2n1p2" || tank.Cache[0].Alloc != 210000000000 {
|
||||
t.Errorf("cache: %+v", tank.Cache)
|
||||
}
|
||||
if len(tank.Log) != 1 || tank.Log[0].Name != "nvme2n1p1" {
|
||||
t.Errorf("log: %+v", tank.Log)
|
||||
}
|
||||
if len(tank.Special) != 3 || tank.Special[0].Name != "mirror-1" || tank.Special[0].Alloc != 120000000000 {
|
||||
t.Errorf("special: %+v", tank.Special)
|
||||
}
|
||||
if len(tank.Spare) != 1 || tank.Spare[0].Health != "AVAIL" {
|
||||
t.Errorf("spares: %+v", tank.Spare)
|
||||
}
|
||||
if len(tank.Dedup) != 0 {
|
||||
t.Errorf("no dedup class: %+v", tank.Dedup)
|
||||
}
|
||||
s := got[1]
|
||||
if len(s.Cache)+len(s.Log)+len(s.Special)+len(s.Spare) != 0 {
|
||||
t.Errorf("a plain pool has no extra classes: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanSize(t *testing.T) {
|
||||
for in, want := range map[uint64]string{131072: "128K", 1048576: "1M", 16384: "16K", 1000: "1000"} {
|
||||
if got := humanSize(in); got != want {
|
||||
t.Errorf("%d → %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLiveARC reads this machine's ZFS for real: HAMLAB_LIVE=1 go test -run TestLiveARC -v ./internal/storage
|
||||
func TestLiveARC(t *testing.T) {
|
||||
if os.Getenv("HAMLAB_LIVE") == "" {
|
||||
t.Skip("set HAMLAB_LIVE=1 to read the real machine")
|
||||
}
|
||||
d := New(false).ARC(context.Background())
|
||||
b, _ := json.MarshalIndent(d, "", " ")
|
||||
t.Logf("%s", b)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,16 @@ type UserState struct {
|
||||
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 {
|
||||
Terminals []Terminal `json:"terminals"`
|
||||
Sessions map[string]Session `json:"sessions"` // token -> session
|
||||
@@ -52,6 +62,17 @@ type State struct {
|
||||
// account. Kept so an old state file still reads.
|
||||
UI json.RawMessage `json:"ui,omitempty"`
|
||||
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"`
|
||||
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so
|
||||
// the next browser — or this one after it has forgotten them — is signed into the device
|
||||
// already. See internal/api/kvm.go.
|
||||
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -84,10 +84,21 @@ func newID() string {
|
||||
|
||||
// Create starts a new login shell. id may be empty.
|
||||
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 == "" {
|
||||
id = newID()
|
||||
}
|
||||
cmd := exec.Command(m.shell, "-l")
|
||||
if len(argv) > 0 {
|
||||
cmd = exec.Command(argv[0], argv[1:]...)
|
||||
}
|
||||
cmd.Dir = m.home
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1")
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
package vpn
|
||||
|
||||
// Adding, renaming and removing the devices that dial in.
|
||||
//
|
||||
// Only OpenWrt's UCI is written, because that is where the server here is configured and
|
||||
// because a half-supported second path is worse than an honest refusal. A wg-quick host is
|
||||
// reported as unmanaged and the buttons do not appear.
|
||||
//
|
||||
// Two things happen for every change, in the order that fails safest:
|
||||
//
|
||||
// - Adding: the configuration is written first and committed, then the peer is applied to
|
||||
// the running tunnel. If applying fails the configuration is rolled back, so the two never
|
||||
// disagree silently — a peer that works only after the next reboot is the worst outcome
|
||||
// of the three.
|
||||
// - Removing: the running tunnel first, because that is the moment access actually ends,
|
||||
// then the configuration. If the second half fails the caller is told plainly that the
|
||||
// device is off now but would return on a reboot.
|
||||
//
|
||||
// Nothing here restarts the network. `wg set` changes one peer on a live interface and
|
||||
// touches nothing else, which matters rather a lot when the interface belongs to the router
|
||||
// the house is behind.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NameRe is what a device may be called: a short slug, because it also names the config
|
||||
// section it is stored in.
|
||||
var NameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9 ._-]{0,30}$`)
|
||||
|
||||
// sectionRe guards the identifier that goes into a uci path.
|
||||
var sectionRe = regexp.MustCompile(`^[a-zA-Z0-9_]{1,48}$`)
|
||||
|
||||
const sectionPrefix = "wgpeer_"
|
||||
|
||||
var (
|
||||
ErrUnmanaged = errors.New("this tunnel is not configured through UCI, so Ham Lab will not edit it here")
|
||||
ErrNoRoom = errors.New("no free address is left in this tunnel's range")
|
||||
)
|
||||
|
||||
// NewPeer is what the caller asks for.
|
||||
type NewPeer struct {
|
||||
Iface string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
// Address is the tunnel address to give the device, with or without a prefix. Empty means
|
||||
// the next free one.
|
||||
Address string `json:"address"`
|
||||
// Endpoint is what the device will dial: the house's name or address, and the port.
|
||||
Endpoint string `json:"endpoint"`
|
||||
// DNS is what the device should use while connected. Empty leaves the line out.
|
||||
DNS string `json:"dns"`
|
||||
// Split routes only the home networks through the tunnel instead of everything.
|
||||
Split bool `json:"split"`
|
||||
}
|
||||
|
||||
// Created is a new device, and the one chance to read its configuration.
|
||||
type Created struct {
|
||||
Section string `json:"section"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Address string `json:"address"`
|
||||
// Config is the complete client profile. It holds the private key, which exists nowhere
|
||||
// else: it is generated here, handed over once and never stored.
|
||||
Config string `json:"config"`
|
||||
}
|
||||
|
||||
// uciPeer is one peer section as UCI holds it.
|
||||
type uciPeer struct {
|
||||
Section string
|
||||
Iface string // the wireguard_<iface> type it belongs to
|
||||
Desc string
|
||||
PublicKey string
|
||||
AllowedIPs []string
|
||||
}
|
||||
|
||||
// uciPeers reads every peer section. The type of a peer section names its interface
|
||||
// ("wireguard_wg0"), which is how a peer is tied to a tunnel.
|
||||
func uciPeers(ctx context.Context) ([]uciPeer, error) {
|
||||
if _, err := exec.LookPath("uci"); err != nil {
|
||||
return nil, ErrUnmanaged
|
||||
}
|
||||
b, err := exec.CommandContext(ctx, "uci", "-q", "show", "network").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
by := map[string]*uciPeer{}
|
||||
var order []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."), ".")
|
||||
v := strings.Trim(rhs, "'")
|
||||
section := parts[0]
|
||||
if len(parts) == 1 { // the section's own type
|
||||
if !strings.HasPrefix(v, "wireguard_") {
|
||||
continue
|
||||
}
|
||||
by[section] = &uciPeer{Section: section, Iface: strings.TrimPrefix(v, "wireguard_")}
|
||||
order = append(order, section)
|
||||
continue
|
||||
}
|
||||
p := by[section]
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
switch parts[1] {
|
||||
case "description":
|
||||
p.Desc = v
|
||||
case "public_key":
|
||||
p.PublicKey = v
|
||||
case "allowed_ips":
|
||||
p.AllowedIPs = strings.Fields(strings.ReplaceAll(v, ",", " "))
|
||||
}
|
||||
}
|
||||
out := make([]uciPeer, 0, len(order))
|
||||
for _, s := range order {
|
||||
out = append(out, *by[s])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Managed reports whether this tunnel's peers can be edited here, and labels each peer with
|
||||
// the section that holds it so the caller has a safe identifier to act on.
|
||||
func Managed(ctx context.Context, t *Tunnel) bool {
|
||||
peers, err := uciPeers(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
byKey := map[string]string{}
|
||||
for _, p := range peers {
|
||||
if p.Iface == t.Name {
|
||||
byKey[p.PublicKey] = p.Section
|
||||
found = true
|
||||
}
|
||||
}
|
||||
for i := range t.Peers {
|
||||
t.Peers[i].Section = byKey[t.Peers[i].PublicKey]
|
||||
}
|
||||
// A tunnel with no peers at all is still manageable if UCI is the thing configuring it.
|
||||
if !found {
|
||||
for _, line := range uciInterfaces(ctx) {
|
||||
if line == t.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// uciInterfaces is every interface UCI configures as wireguard.
|
||||
func uciInterfaces(ctx context.Context) []string {
|
||||
var out []string
|
||||
if _, err := exec.LookPath("uci"); err != nil {
|
||||
return out
|
||||
}
|
||||
b, err := exec.CommandContext(ctx, "uci", "-q", "show", "network").Output()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
lhs, rhs, ok := strings.Cut(line, "=")
|
||||
if !ok || strings.Trim(rhs, "'") != "wireguard" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(strings.TrimPrefix(lhs, "network."), ".")
|
||||
if len(parts) == 2 && parts[1] == "proto" {
|
||||
out = append(out, parts[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NextAddress is the lowest address in the tunnel's range that nothing is using. The
|
||||
// interface's own address is taken, as is every address any peer is allowed.
|
||||
func NextAddress(t Tunnel) (string, error) {
|
||||
var base netip.Prefix
|
||||
for _, a := range t.Addrs {
|
||||
p, err := netip.ParsePrefix(a)
|
||||
if err != nil || !p.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
base = p
|
||||
break
|
||||
}
|
||||
if !base.IsValid() {
|
||||
return "", errors.New("this tunnel has no IPv4 range to allocate from")
|
||||
}
|
||||
taken := map[netip.Addr]bool{base.Addr(): true}
|
||||
for _, p := range t.Peers {
|
||||
for _, a := range p.AllowedIPs {
|
||||
if ip, err := netip.ParsePrefix(strings.TrimSpace(a)); err == nil {
|
||||
taken[ip.Addr()] = true
|
||||
} else if ip, err := netip.ParseAddr(strings.TrimSpace(a)); err == nil {
|
||||
taken[ip] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ip := base.Masked().Addr().Next() // .0 is the network
|
||||
for base.Contains(ip) {
|
||||
next := ip.Next()
|
||||
if !base.Contains(next) { // the last address in the range is the broadcast
|
||||
break
|
||||
}
|
||||
if !taken[ip] {
|
||||
return ip.String(), nil
|
||||
}
|
||||
ip = next
|
||||
}
|
||||
return "", ErrNoRoom
|
||||
}
|
||||
|
||||
// slug turns a device name into a section name, which may hold rather less.
|
||||
func slug(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(name) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == ' ', r == '-', r == '.', r == '_':
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
s := strings.Trim(b.String(), "_")
|
||||
if s == "" {
|
||||
s = "peer"
|
||||
}
|
||||
if len(s) > 24 {
|
||||
s = s[:24]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// genKeys makes a private key, its public half and a pre-shared key. The private key is
|
||||
// returned to be handed over once and is never written anywhere by this daemon.
|
||||
func genKeys(ctx context.Context) (priv, pub, psk string, err error) {
|
||||
run := func(name string, stdin string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
out, err := cmd.Output()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
if priv, err = run("wg", "", "genkey"); err != nil {
|
||||
return "", "", "", fmt.Errorf("generating a key: %w", err)
|
||||
}
|
||||
if pub, err = run("wg", priv+"\n", "pubkey"); err != nil {
|
||||
return "", "", "", fmt.Errorf("deriving the public key: %w", err)
|
||||
}
|
||||
if psk, err = run("wg", "", "genpsk"); err != nil {
|
||||
return "", "", "", fmt.Errorf("generating a pre-shared key: %w", err)
|
||||
}
|
||||
return priv, pub, psk, nil
|
||||
}
|
||||
|
||||
// AddPeer creates a device: it writes the peer into the configuration, applies it to the
|
||||
// running tunnel, and returns the client profile once.
|
||||
func AddPeer(ctx context.Context, t Tunnel, n NewPeer) (*Created, error) {
|
||||
if !NameRe.MatchString(strings.TrimSpace(n.Name)) {
|
||||
return nil, errors.New("a device name is 1–31 letters, digits, spaces, dots, dashes or underscores")
|
||||
}
|
||||
name := strings.TrimSpace(n.Name)
|
||||
if strings.TrimSpace(n.Endpoint) == "" {
|
||||
return nil, errors.New("the device needs an endpoint to dial: the house's address or name, and the port")
|
||||
}
|
||||
endpoint, err := checkEndpoint(n.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addr := strings.TrimSpace(n.Address)
|
||||
if addr == "" {
|
||||
if addr, err = NextAddress(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
host, err := hostOf(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range t.Peers {
|
||||
for _, a := range p.AllowedIPs {
|
||||
if strings.HasPrefix(strings.TrimSpace(a), host+"/") {
|
||||
return nil, fmt.Errorf("%s is already used by %s", host, firstNonEmpty(p.Name, "another device"))
|
||||
}
|
||||
}
|
||||
}
|
||||
section := sectionPrefix + slug(name)
|
||||
existing, err := uciPeers(ctx)
|
||||
if err != nil {
|
||||
return nil, ErrUnmanaged
|
||||
}
|
||||
taken := map[string]bool{}
|
||||
for _, p := range existing {
|
||||
taken[p.Section] = true
|
||||
}
|
||||
for i := 2; taken[section]; i++ {
|
||||
section = fmt.Sprintf("%s%s%d", sectionPrefix, slug(name), i)
|
||||
}
|
||||
if !sectionRe.MatchString(section) {
|
||||
return nil, errors.New("that name cannot be turned into a configuration section")
|
||||
}
|
||||
|
||||
priv, pub, psk, err := genKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := host + "/32"
|
||||
|
||||
// the configuration first, so a peer can never be live but unrecorded
|
||||
set := [][]string{
|
||||
{"set", "network." + section + "=wireguard_" + t.Name},
|
||||
{"set", "network." + section + ".description=" + name},
|
||||
{"set", "network." + section + ".public_key=" + pub},
|
||||
{"set", "network." + section + ".preshared_key=" + psk},
|
||||
{"set", "network." + section + ".allowed_ips=" + allowed},
|
||||
}
|
||||
for _, args := range set {
|
||||
if err := uci(ctx, args...); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return nil, fmt.Errorf("writing the configuration: %w", err)
|
||||
}
|
||||
}
|
||||
if err := uci(ctx, "commit", "network"); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return nil, fmt.Errorf("saving the configuration: %w", err)
|
||||
}
|
||||
|
||||
// then the running tunnel; if this fails the configuration goes back as it was
|
||||
if err := wgSet(ctx, t.Name, pub, psk, allowed); err != nil {
|
||||
_ = uci(ctx, "delete", "network."+section)
|
||||
_ = uci(ctx, "commit", "network")
|
||||
return nil, fmt.Errorf("applying it to the running tunnel: %w", err)
|
||||
}
|
||||
|
||||
return &Created{
|
||||
Section: section, Name: name, PublicKey: pub, Address: host,
|
||||
Config: profile(priv, psk, t, host, endpoint, n.DNS, n.Split),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EditPeer changes a device's label or the address it is allowed. The section keeps the name
|
||||
// it was created with; the description is what is shown.
|
||||
func EditPeer(ctx context.Context, t Tunnel, section, name, address string) error {
|
||||
if !sectionRe.MatchString(section) {
|
||||
return errors.New("that is not a device this can edit")
|
||||
}
|
||||
peers, err := uciPeers(ctx)
|
||||
if err != nil {
|
||||
return ErrUnmanaged
|
||||
}
|
||||
var me *uciPeer
|
||||
for i := range peers {
|
||||
if peers[i].Section == section && peers[i].Iface == t.Name {
|
||||
me = &peers[i]
|
||||
}
|
||||
}
|
||||
if me == nil {
|
||||
return errors.New("no such device on this tunnel")
|
||||
}
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
if !NameRe.MatchString(name) {
|
||||
return errors.New("a device name is 1–31 letters, digits, spaces, dots, dashes or underscores")
|
||||
}
|
||||
if err := uci(ctx, "set", "network."+section+".description="+name); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return err
|
||||
}
|
||||
}
|
||||
allowed := ""
|
||||
if address = strings.TrimSpace(address); address != "" {
|
||||
host, err := hostOf(address)
|
||||
if err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return err
|
||||
}
|
||||
for _, p := range t.Peers {
|
||||
if p.Section == section {
|
||||
continue
|
||||
}
|
||||
for _, a := range p.AllowedIPs {
|
||||
if strings.HasPrefix(strings.TrimSpace(a), host+"/") {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return fmt.Errorf("%s is already used by %s", host, firstNonEmpty(p.Name, "another device"))
|
||||
}
|
||||
}
|
||||
}
|
||||
allowed = host + "/32"
|
||||
if err := uci(ctx, "set", "network."+section+".allowed_ips="+allowed); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := uci(ctx, "commit", "network"); err != nil {
|
||||
_ = uci(ctx, "revert", "network")
|
||||
return err
|
||||
}
|
||||
if allowed != "" { // the running tunnel has to be told, or the device keeps the old address
|
||||
if err := wgSet(ctx, t.Name, me.PublicKey, "", allowed); err != nil {
|
||||
return fmt.Errorf("saved, but the running tunnel refused the new address: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePeer removes a device. The running tunnel goes first, because that is the moment the
|
||||
// device actually loses its way in.
|
||||
func DeletePeer(ctx context.Context, t Tunnel, section string) error {
|
||||
if !sectionRe.MatchString(section) {
|
||||
return errors.New("that is not a device this can remove")
|
||||
}
|
||||
peers, err := uciPeers(ctx)
|
||||
if err != nil {
|
||||
return ErrUnmanaged
|
||||
}
|
||||
var me *uciPeer
|
||||
for i := range peers {
|
||||
if peers[i].Section == section && peers[i].Iface == t.Name {
|
||||
me = &peers[i]
|
||||
}
|
||||
}
|
||||
if me == nil {
|
||||
return errors.New("no such device on this tunnel")
|
||||
}
|
||||
if err := wgRemove(ctx, t.Name, me.PublicKey); err != nil {
|
||||
return fmt.Errorf("removing it from the running tunnel: %w", err)
|
||||
}
|
||||
if err := uci(ctx, "delete", "network."+section); err != nil {
|
||||
return fmt.Errorf("the device is disconnected, but removing it from the configuration failed, so it would come back on a reboot: %w", err)
|
||||
}
|
||||
if err := uci(ctx, "commit", "network"); err != nil {
|
||||
return fmt.Errorf("the device is disconnected, but saving the configuration failed, so it would come back on a reboot: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the small dangerous bits, each in one place ----
|
||||
|
||||
func uci(ctx context.Context, args ...string) error {
|
||||
out, err := exec.CommandContext(ctx, "uci", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("uci %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wgSet applies a peer to the running interface. The pre-shared key goes in through a file
|
||||
// because that is the only way wg takes one, so the file is made readable by nobody else and
|
||||
// removed immediately.
|
||||
func wgSet(ctx context.Context, iface, pub, psk, allowed string) error {
|
||||
args := []string{"set", iface, "peer", pub}
|
||||
if psk != "" {
|
||||
f, err := os.CreateTemp("", "hamlab-psk")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err := f.Chmod(0o600); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.WriteString(psk + "\n"); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
args = append(args, "preshared-key", f.Name())
|
||||
}
|
||||
args = append(args, "allowed-ips", allowed)
|
||||
out, err := exec.CommandContext(ctx, "wg", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wg set: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wgRemove(ctx context.Context, iface, pub string) error {
|
||||
out, err := exec.CommandContext(ctx, "wg", "set", iface, "peer", pub, "remove").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wg set: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// profile is the client's own configuration file, which is the only place its private key
|
||||
// ever appears.
|
||||
func profile(priv, psk string, t Tunnel, addr, endpoint, dns string, split bool) string {
|
||||
prefix := 24
|
||||
for _, a := range t.Addrs {
|
||||
if p, err := netip.ParsePrefix(a); err == nil && p.Addr().Is4() {
|
||||
prefix = p.Bits()
|
||||
break
|
||||
}
|
||||
}
|
||||
allowed := "0.0.0.0/0"
|
||||
if split {
|
||||
nets := []string{}
|
||||
for _, a := range t.Addrs {
|
||||
if p, err := netip.ParsePrefix(a); err == nil && p.Addr().Is4() {
|
||||
nets = append(nets, p.Masked().String())
|
||||
}
|
||||
}
|
||||
nets = append(nets, "10.0.0.0/8", "192.168.0.0/16", "172.16.0.0/12")
|
||||
allowed = strings.Join(dedupe(nets), ", ")
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\nAddress = %s/%d\n", priv, addr, prefix)
|
||||
if dns = strings.TrimSpace(dns); dns != "" {
|
||||
fmt.Fprintf(&b, "DNS = %s\n", dns)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n[Peer]\nPublicKey = %s\n", t.PublicKey)
|
||||
if psk != "" {
|
||||
fmt.Fprintf(&b, "PresharedKey = %s\n", psk)
|
||||
}
|
||||
fmt.Fprintf(&b, "Endpoint = %s\nAllowedIPs = %s\n", endpoint, allowed)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := in[:0:0]
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// hostOf takes an address with or without a prefix and returns the bare address.
|
||||
func hostOf(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if p, err := netip.ParsePrefix(s); err == nil {
|
||||
return p.Addr().String(), nil
|
||||
}
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%q is not an address", s)
|
||||
}
|
||||
return a.String(), nil
|
||||
}
|
||||
|
||||
// checkEndpoint makes sure what the device will dial looks like a host and a port.
|
||||
func checkEndpoint(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
host, port, err := net.SplitHostPort(s)
|
||||
if err != nil || host == "" {
|
||||
return "", fmt.Errorf("%q should be a name or address and a port, like home.example.com:51820", s)
|
||||
}
|
||||
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
||||
return "", fmt.Errorf("%q is not a port", port)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package vpn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func tunnel(addrs []string, peers ...string) Tunnel {
|
||||
t := Tunnel{Name: "wg0", PublicKey: "SERVERKEY=", Addrs: addrs}
|
||||
for _, p := range peers {
|
||||
t.Peers = append(t.Peers, Peer{PublicKey: p + "KEY=", AllowedIPs: []string{p}})
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestNextAddress(t *testing.T) {
|
||||
// .1 is the interface itself and .2 and .3 are taken, so the next free one is .4
|
||||
got, err := NextAddress(tunnel([]string{"172.21.46.1/24"}, "172.21.46.3/32", "172.21.46.2/32"))
|
||||
if err != nil || got != "172.21.46.4" {
|
||||
t.Errorf("NextAddress = %q, %v; want 172.21.46.4", got, err)
|
||||
}
|
||||
// a gap is filled before the end is reached
|
||||
got, _ = NextAddress(tunnel([]string{"172.21.46.1/24"}, "172.21.46.2/32", "172.21.46.4/32"))
|
||||
if got != "172.21.46.3" {
|
||||
t.Errorf("NextAddress = %q, want the gap at .3", got)
|
||||
}
|
||||
// an empty tunnel starts after the server
|
||||
got, _ = NextAddress(tunnel([]string{"10.9.0.1/24"}))
|
||||
if got != "10.9.0.2" {
|
||||
t.Errorf("NextAddress = %q, want 10.9.0.2", got)
|
||||
}
|
||||
// a full one says so rather than handing out a duplicate
|
||||
full := tunnel([]string{"10.9.0.1/30"}, "10.9.0.2/32")
|
||||
if _, err := NextAddress(full); err != ErrNoRoom {
|
||||
t.Errorf("err = %v, want ErrNoRoom on a full range", err)
|
||||
}
|
||||
// IPv6-only, or nothing at all, is refused rather than guessed
|
||||
if _, err := NextAddress(tunnel([]string{"fc00::1/64"})); err == nil {
|
||||
t.Error("want an error when there is no IPv4 range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlug(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"ip12": "ip12",
|
||||
"Mitchell's iPad": "mitchells_ipad",
|
||||
"my-phone": "my_phone",
|
||||
" spaced out ": "spaced__out",
|
||||
"!!!": "peer",
|
||||
strings.Repeat("a", 40): strings.Repeat("a", 24),
|
||||
} {
|
||||
if got := slug(in); got != want {
|
||||
t.Errorf("slug(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile(t *testing.T) {
|
||||
tun := tunnel([]string{"172.21.46.1/24"})
|
||||
got := profile("PRIV=", "PSK=", tun, "172.21.46.4", "home.example.com:51820", "1.1.1.1, 1.0.0.1", false)
|
||||
for _, want := range []string{
|
||||
"PrivateKey = PRIV=",
|
||||
"Address = 172.21.46.4/24", // the interface's own prefix, not a bare /32
|
||||
"DNS = 1.1.1.1, 1.0.0.1",
|
||||
"PublicKey = SERVERKEY=",
|
||||
"PresharedKey = PSK=",
|
||||
"Endpoint = home.example.com:51820",
|
||||
"AllowedIPs = 0.0.0.0/0",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("profile is missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// no DNS line at all when none was asked for, rather than an empty one
|
||||
if strings.Contains(profile("P=", "", tun, "172.21.46.4", "h:1", "", false), "DNS") {
|
||||
t.Error("an empty DNS should leave the line out")
|
||||
}
|
||||
// split routes the private ranges instead of everything
|
||||
split := profile("P=", "", tun, "172.21.46.4", "h:1", "", true)
|
||||
if strings.Contains(split, "0.0.0.0/0") {
|
||||
t.Errorf("a split tunnel must not route everything:\n%s", split)
|
||||
}
|
||||
if !strings.Contains(split, "172.21.46.0/24") {
|
||||
t.Errorf("a split tunnel should include the tunnel's own range:\n%s", split)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEndpoint(t *testing.T) {
|
||||
for _, ok := range []string{"bigham.dev:51820", "1.2.3.4:51820", "[2001:db8::1]:51820"} {
|
||||
if _, err := checkEndpoint(ok); err != nil {
|
||||
t.Errorf("checkEndpoint(%q) = %v, want it accepted", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "bigham.dev", ":51820", "bigham.dev:0", "bigham.dev:notaport", "bigham.dev:99999"} {
|
||||
if _, err := checkEndpoint(bad); err == nil {
|
||||
t.Errorf("checkEndpoint(%q) was accepted, want refused", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostOf(t *testing.T) {
|
||||
for in, want := range map[string]string{"172.21.46.4": "172.21.46.4", "172.21.46.4/32": "172.21.46.4", " 10.0.0.9/24 ": "10.0.0.9"} {
|
||||
got, err := hostOf(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("hostOf(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := hostOf("not-an-address"); err == nil {
|
||||
t.Error("hostOf accepted nonsense")
|
||||
}
|
||||
}
|
||||
|
||||
// The name is what becomes a configuration section, so it is the input worth being strict
|
||||
// about; everything refused here is refused before anything is written.
|
||||
func TestNameRe(t *testing.T) {
|
||||
for _, ok := range []string{"ip12", "My Phone", "a", "work-laptop.2"} {
|
||||
if !NameRe.MatchString(ok) {
|
||||
t.Errorf("%q should be a valid name", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", " leading", "semi;colon", "quote'd", "back`tick", "new\nline", "$(whoami)", strings.Repeat("a", 32)} {
|
||||
if NameRe.MatchString(bad) {
|
||||
t.Errorf("%q should be refused as a name", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// 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"`
|
||||
// Section is the configuration section holding this peer, and the identifier anything that
|
||||
// edits it uses. Empty when the peer is not in a configuration this can edit.
|
||||
Section string `json:"section,omitempty"`
|
||||
}
|
||||
|
||||
// Tunnel is one WireGuard interface.
|
||||
type Tunnel struct {
|
||||
Name string `json:"name"`
|
||||
// Managed is whether devices on this tunnel can be added and removed from here, which
|
||||
// today means its peers are configured through OpenWrt's UCI.
|
||||
Managed bool `json:"managed"`
|
||||
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)
|
||||
t.Managed = Managed(ctx, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Trim the PCI ID database to the graphics devices of the GPU vendors, for embedding in
|
||||
# the binary (internal/metrics/pciids_gpu.txt): a machine without pciutils — OpenWrt has
|
||||
# neither lspci nor pci.ids — then names its GPU exactly as every other machine does.
|
||||
#
|
||||
# scripts/pci-ids-gpu.sh [/path/to/pci.ids] default: /usr/share/misc/pci.ids
|
||||
#
|
||||
# Kept: the vendor lines and device lines (never subsystems) of Intel 8086, AMD 1002 and
|
||||
# NVIDIA 10de whose name is bracketed (how pci.ids writes a marketing name) or mentions
|
||||
# graphics, and everything of the three virtual-GPU vendors (virtio 1af4, VMware 15ad,
|
||||
# Bochs 1234). A non-GPU device slipping through costs a line; it is only ever consulted
|
||||
# for a card the kernel already drives as a GPU.
|
||||
set -eu
|
||||
src=${1:-/usr/share/misc/pci.ids}
|
||||
out=$(dirname "$0")/../internal/metrics/pciids_gpu.txt
|
||||
{
|
||||
echo "# Trimmed from pci.ids ($(grep -m1 -i 'version:' "$src" | sed 's/^#[[:space:]]*//')) by scripts/pci-ids-gpu.sh."
|
||||
echo "# Vendor lines and the graphics device lines of Intel, AMD and NVIDIA, plus the virtual GPUs."
|
||||
echo "# pci.ids is distributed under the 3-clause BSD license (https://pci-ids.ucw.cz/)."
|
||||
awk '
|
||||
/^[0-9a-f][0-9a-f][0-9a-f][0-9a-f] / {
|
||||
vendor = substr($0, 1, 4)
|
||||
keep = (vendor == "8086" || vendor == "1002" || vendor == "10de")
|
||||
all = (vendor == "1af4" || vendor == "15ad" || vendor == "1234")
|
||||
if (keep || all) print
|
||||
next
|
||||
}
|
||||
/^\t[0-9a-f][0-9a-f][0-9a-f][0-9a-f] / {
|
||||
if (all) { print; next }
|
||||
if (!keep) next
|
||||
if ($0 ~ /\[/ || $0 ~ /Graphics|Radeon|GeForce|Quadro|Tesla|Arc |Iris|Xe |GPU|Display|VGA|FirePro|Instinct|Data Center/) print
|
||||
}
|
||||
' "$src"
|
||||
} > "$out"
|
||||
echo "$out: $(wc -l < "$out") lines, $(wc -c < "$out") bytes"
|
||||
Generated
+7
@@ -11,6 +11,7 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -1551,6 +1552,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
|
||||
+2
-1
@@ -7,12 +7,13 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"icons": "node scripts/icons.mjs",
|
||||
"test": "node scripts/test-graphcols.mjs"
|
||||
"test": "node scripts/test-graphcols.mjs && node scripts/test-css-imports.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// node scripts/test-css-imports.mjs — every stylesheet the UI imports is one somebody has read.
|
||||
// A third-party stylesheet usually carries its demo page's global rules (body, *, button); on
|
||||
// 2026-10-09 one restyled every button in the app. Add a file here only once its rules are
|
||||
// known to touch nothing but its own component.
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
|
||||
const allowed = new Set(['./styles.css', '@xterm/xterm/css/xterm.css'])
|
||||
const src = new URL('../src', import.meta.url).pathname
|
||||
const files = []
|
||||
const walk = (d) => { for (const n of readdirSync(d)) { const p = join(d, n); if (statSync(p).isDirectory()) { if (n !== 'vendor') walk(p) } else if (/\.(ts|tsx)$/.test(n)) files.push(p) } }
|
||||
walk(src)
|
||||
const fails = []
|
||||
for (const f of files) {
|
||||
for (const m of readFileSync(f, 'utf8').matchAll(/import\s+(?:[^'"]*from\s+)?['"]([^'"]+\.css)['"]/g)) {
|
||||
if (!allowed.has(m[1])) fails.push(`${relative(src, f)} imports ${m[1]}`)
|
||||
}
|
||||
}
|
||||
if (fails.length) { console.error('css imports: not allowed:\n ' + fails.join('\n ')); process.exit(1) }
|
||||
console.log(`css imports: ok (${files.length} files)`)
|
||||
@@ -0,0 +1,150 @@
|
||||
// A textarea that knows what it is holding: line numbers in a gutter, YAML (or a .env)
|
||||
// coloured underneath the text, and the keys a compose file needs — Tab indents by two,
|
||||
// Shift+Tab outdents, Enter keeps the indentation of the line above (one step deeper after
|
||||
// a key or a list dash), and a phone, whose keyboard has none of those, gets a small bar of
|
||||
// them. The text itself stays in a real textarea, so selection, undo and the keyboard are
|
||||
// the browser's own; the colour is a <pre> drawn behind it, kept in step line for line.
|
||||
import { useEffect, useMemo, useRef, useState, ReactNode } from 'react'
|
||||
|
||||
export type Language = 'yaml' | 'env' | 'plain'
|
||||
|
||||
interface Props {
|
||||
value: string; onChange: (v: string) => void; language?: Language; className?: string
|
||||
readOnly?: boolean; placeholder?: string; spellCheck?: boolean
|
||||
}
|
||||
|
||||
const INDENT = ' '
|
||||
const coarse = () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches
|
||||
|
||||
// One line of YAML, split into spans by what each part is. Deliberately shallow: a compose
|
||||
// file is keys, scalars, lists and comments; anything cleverer is a parser, which this is not.
|
||||
function yamlLine(line: string): ReactNode[] {
|
||||
const out: ReactNode[] = []
|
||||
let i = 0
|
||||
const push = (cls: string, text: string) => { if (text) out.push(cls ? <span key={out.length} className={cls}>{text}</span> : text) }
|
||||
const m = /^(\s*)(-\s+)?/.exec(line)!
|
||||
push('', m[1]); push('y-dash', m[2] ?? ''); i = m[0].length
|
||||
const rest = line.slice(i)
|
||||
if (rest.startsWith('#')) { push('y-comment', rest); return out }
|
||||
// a key: up to the first colon that is followed by a space or the end
|
||||
const key = /^("[^"]*"|'[^']*'|[^\s:#][^:#]*?)(:)(?=\s|$)/.exec(rest)
|
||||
let tail = rest
|
||||
if (key) { push('y-key', key[1]); push('y-colon', ':'); tail = rest.slice(key[0].length) }
|
||||
// the value, with a trailing comment cut off
|
||||
let value = tail, comment = ''
|
||||
const c = / #.*$/.exec(tail) ?? (tail.startsWith('#') ? /#.*$/.exec(tail) : null)
|
||||
if (c) { value = tail.slice(0, c.index); comment = tail.slice(c.index) }
|
||||
const v = value.trim()
|
||||
const lead = value.slice(0, value.length - value.trimStart().length)
|
||||
push('', lead)
|
||||
if (!v) { /* nothing */ }
|
||||
else if (/^(["']).*\1$/.test(v)) push('y-string', v)
|
||||
else if (/^(true|false|yes|no|on|off|null|~)$/i.test(v)) push('y-bool', v)
|
||||
else if (/^[-+]?(\d[\d_]*(\.\d+)?|\.\d+)([eE][-+]?\d+)?$/.test(v) || /^0x[0-9a-fA-F]+$/.test(v)) push('y-number', v)
|
||||
else if (/^[&*!]/.test(v)) push('y-anchor', v)
|
||||
else if (/^[|>][-+]?\d*$/.test(v)) push('y-block', v)
|
||||
else push(key ? 'y-value' : (m[2] ? 'y-value' : ''), v)
|
||||
push('', value.slice(lead.length + v.length))
|
||||
push('y-comment', comment)
|
||||
return out
|
||||
}
|
||||
|
||||
function envLine(line: string): ReactNode[] {
|
||||
if (/^\s*#/.test(line)) return [<span key="c" className="y-comment">{line}</span>]
|
||||
const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_.-]*)(=)(.*)$/.exec(line)
|
||||
if (!m) return [line]
|
||||
return [m[1], <span key="k" className="y-key">{m[2]}</span>, <span key="e" className="y-colon">=</span>, <span key="v" className="y-string">{m[4]}</span>]
|
||||
}
|
||||
|
||||
function highlight(text: string, lang: Language): ReactNode[] {
|
||||
const lines = text.split('\n')
|
||||
const f = lang === 'yaml' ? yamlLine : lang === 'env' ? envLine : (l: string) => [l] as ReactNode[]
|
||||
return lines.map((l, i) => <div key={i} className="code-line">{f(l)}{l === '' ? '' : ''}</div>)
|
||||
}
|
||||
|
||||
// setText replaces [start, end) in a textarea with s, through the browser's own edit so Undo
|
||||
// still works, and leaves the caret where asked.
|
||||
function setText(ta: HTMLTextAreaElement, start: number, end: number, s: string, caret: number | [number, number]) {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(start, end)
|
||||
let done = false
|
||||
try { done = document.execCommand('insertText', false, s) } catch { /* not every browser */ }
|
||||
if (!done || ta.value.slice(start, start + s.length) !== s) { ta.setRangeText(s, start, end, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })) }
|
||||
if (Array.isArray(caret)) ta.setSelectionRange(caret[0], caret[1]); else ta.setSelectionRange(caret, caret)
|
||||
}
|
||||
|
||||
export default function CodeEditor({ value, onChange, language = 'yaml', className = '', readOnly, placeholder, spellCheck = false }: Props) {
|
||||
const ta = useRef<HTMLTextAreaElement>(null)
|
||||
const pre = useRef<HTMLPreElement>(null)
|
||||
const gutter = useRef<HTMLDivElement>(null)
|
||||
const [touch] = useState(coarse)
|
||||
const lines = useMemo(() => value.split('\n').length, [value])
|
||||
const painted = useMemo(() => highlight(value, language), [value, language])
|
||||
|
||||
// the colour and the numbers follow the text's own scrolling
|
||||
const sync = () => { const t = ta.current; if (!t) return; if (pre.current) { pre.current.scrollTop = t.scrollTop; pre.current.scrollLeft = t.scrollLeft } if (gutter.current) gutter.current.scrollTop = t.scrollTop }
|
||||
useEffect(sync, [value])
|
||||
|
||||
// lines touched by the selection, for indent and outdent
|
||||
const lineRange = (t: HTMLTextAreaElement) => {
|
||||
const s = t.value.lastIndexOf('\n', t.selectionStart - 1) + 1
|
||||
let e = t.value.indexOf('\n', Math.max(t.selectionEnd - (t.selectionEnd > t.selectionStart && t.value[t.selectionEnd - 1] === '\n' ? 1 : 0), t.selectionStart))
|
||||
if (e < 0) e = t.value.length
|
||||
return [s, e] as const
|
||||
}
|
||||
const indent = (out: boolean) => {
|
||||
const t = ta.current; if (!t || readOnly) return
|
||||
const [s, e] = lineRange(t)
|
||||
const block = t.value.slice(s, e)
|
||||
const sel = t.selectionStart !== t.selectionEnd || block.includes('\n')
|
||||
if (!sel && !out) { const at = t.selectionStart; setText(t, at, at, INDENT, at + INDENT.length); return }
|
||||
const next = block.split('\n').map((l) => (out ? l.replace(/^ {1,2}/, '') : INDENT + l)).join('\n')
|
||||
if (next === block) return
|
||||
const d0 = out ? -(block.length - next.length) : INDENT.length
|
||||
setText(t, s, e, next, sel ? [s, s + next.length] : [Math.max(s, t.selectionStart + d0), Math.max(s, t.selectionStart + d0)])
|
||||
}
|
||||
const comment = () => {
|
||||
const t = ta.current; if (!t || readOnly) return
|
||||
const [s, e] = lineRange(t)
|
||||
const block = t.value.slice(s, e), ls = block.split('\n')
|
||||
const all = ls.every((l) => /^\s*#/.test(l) || !l.trim())
|
||||
const next = ls.map((l) => (!l.trim() ? l : all ? l.replace(/^(\s*)# ?/, '$1') : l.replace(/^(\s*)/, '$1# '))).join('\n')
|
||||
setText(t, s, e, next, [s, s + next.length])
|
||||
}
|
||||
const newline = () => {
|
||||
const t = ta.current; if (!t || readOnly) return
|
||||
const at = t.selectionStart
|
||||
const lineStart = t.value.lastIndexOf('\n', at - 1) + 1
|
||||
const line = t.value.slice(lineStart, at)
|
||||
let ind = /^\s*/.exec(line)![0]
|
||||
// one step deeper after a key or a list item that opens a block
|
||||
if (language === 'yaml' && /(:|^\s*-)\s*(#.*)?$/.test(line) && line.trim() && !/^\s*#/.test(line)) ind += INDENT
|
||||
setText(t, at, t.selectionEnd, '\n' + ind, at + 1 + ind.length)
|
||||
}
|
||||
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); indent(e.shiftKey) }
|
||||
else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); newline() }
|
||||
else if (e.key === '/' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); comment() }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'code ' + className + (readOnly ? ' ro' : '')}>
|
||||
<div className="code-gutter" ref={gutter} aria-hidden="true">{Array.from({ length: lines }, (_, i) => <div key={i}>{i + 1}</div>)}</div>
|
||||
<div className="code-body">
|
||||
<pre className="code-paint" ref={pre} aria-hidden="true">{painted}</pre>
|
||||
<textarea ref={ta} className="code-text" value={value} onChange={(e) => onChange(e.target.value)} onScroll={sync} onKeyDown={onKey}
|
||||
readOnly={readOnly} placeholder={placeholder} spellCheck={spellCheck} autoCapitalize="off" autoCorrect="off" autoComplete="off" wrap="off" />
|
||||
</div>
|
||||
{touch && !readOnly && (
|
||||
<div className="code-keys" onMouseDown={(e) => e.preventDefault()}>
|
||||
<button type="button" title="outdent" onClick={() => indent(true)}>⇤</button>
|
||||
<button type="button" title="indent" onClick={() => indent(false)}>⇥</button>
|
||||
<button type="button" title="comment" onClick={comment}>#</button>
|
||||
<button type="button" title="list item" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '- ', t.selectionStart + 2) }}>-</button>
|
||||
<button type="button" title="colon" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, ': ', t.selectionStart + 2) }}>:</button>
|
||||
<button type="button" title="quotes" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '"' + t.value.slice(t.selectionStart, t.selectionEnd) + '"', t.selectionEnd + 1) }}>"</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-92
@@ -1,8 +1,8 @@
|
||||
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 './fmt'
|
||||
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 { 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 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 ----
|
||||
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 ----
|
||||
export default function Disks() {
|
||||
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
|
||||
const [zfs, setZfs] = useState<ZFSInfo | null>(null)
|
||||
const [hasSmart, setHasSmart] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
const [task, setTask] = useState<string | null>(null)
|
||||
@@ -194,7 +117,7 @@ export default function Disks() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
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') }
|
||||
}, [])
|
||||
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} />)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+452
-44
@@ -1,67 +1,120 @@
|
||||
// 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
|
||||
// come from the same container sensors everything else uses.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import StackNew, { DupFrom } from './StackNew'
|
||||
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
||||
import CodeEditor from './CodeEditor'
|
||||
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } from './ui'
|
||||
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
|
||||
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 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 }) {
|
||||
const [lines, setLines] = useState<{ t: string; m: string }[]>([])
|
||||
const [lines, setLines] = useState<DockerLogLine[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [live, setLive] = useState(false)
|
||||
const [follow, setFollow] = useState(true)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
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(() => {
|
||||
let stop = false; setLines([]); last.current = ''
|
||||
const tick = async () => {
|
||||
if (stop) return
|
||||
if (document.visibilityState === 'visible') {
|
||||
try {
|
||||
const r = await api.dockerLogs(id, last.current)
|
||||
const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines
|
||||
if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) }
|
||||
setErr('')
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
setLines([]); setErr(''); setLive(false); last.current = ''; pending.current = []
|
||||
seen.current = { set: new Set(), q: [] }
|
||||
let closed = false
|
||||
let sock: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let attempt = 0
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
const q = last.current ? `since=${encodeURIComponent(last.current)}` : 'n=300'
|
||||
// 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()
|
||||
return () => { stop = true }
|
||||
// a container that has stopped — or was never there — ends the stream at once, so
|
||||
// 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])
|
||||
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 (
|
||||
<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>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="logs-bar">
|
||||
<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) }}>
|
||||
{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>}
|
||||
</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 }) {
|
||||
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)
|
||||
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 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 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 (
|
||||
<div className="svc-detail">
|
||||
<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><span className="k">Image</span><span className="mono small">{c.image}</span></div>
|
||||
<div><span className="k">Status</span><span>{c.status}</span></div>
|
||||
@@ -72,27 +125,181 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
|
||||
<div className="svc-actions">
|
||||
{!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" onClick={shell}>Shell</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>}
|
||||
</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 === 'stats' && <Stats seg={seg} live={live} />}
|
||||
{tab === 'health' && <Health id={c.name} />}
|
||||
{tab === 'events' && <Events id={c.name} />}
|
||||
{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">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>}
|
||||
{!!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>}
|
||||
{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>)}
|
||||
<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>
|
||||
{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 className="muted">names only</span></span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ') || '—'}</span></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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }[] {
|
||||
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))
|
||||
@@ -120,6 +327,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
|
||||
// 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 }) {
|
||||
const [st, setSt] = useState<Stack | null>(null)
|
||||
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
||||
@@ -140,17 +348,17 @@ 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') }
|
||||
}
|
||||
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 (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<CloseBtn onClick={onBack} />
|
||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
|
||||
{file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</button></div> :
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||||
<CodeEditor className="stack-editor" language={file === 'env' ? 'env' : 'yaml'} value={text} onChange={setText} />}
|
||||
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
|
||||
@@ -159,6 +367,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>
|
||||
{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" 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>}
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
@@ -203,6 +412,12 @@ export default function Docker() {
|
||||
// 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 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.available) return <div className="dash"><div className="notice">{d.message}</div></div>
|
||||
const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
|
||||
@@ -210,12 +425,12 @@ export default function Docker() {
|
||||
const shownTab: Tab = selStack ? 'stacks' : tab
|
||||
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 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 (
|
||||
<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-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 === '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" />
|
||||
@@ -224,6 +439,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>
|
||||
</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 === '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 === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
||||
</div>
|
||||
@@ -231,7 +447,7 @@ export default function Docker() {
|
||||
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
|
||||
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
|
||||
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
|
||||
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
|
||||
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? st.host_file : st.note}</div></div>
|
||||
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -242,7 +458,7 @@ export default function Docker() {
|
||||
{list.map((c) => (
|
||||
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
|
||||
<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>
|
||||
))}
|
||||
@@ -260,16 +476,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> })}
|
||||
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
||||
)}
|
||||
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && (
|
||||
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <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>
|
||||
{shownTab === 'networks' && (
|
||||
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
|
||||
<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>
|
||||
{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>))}
|
||||
{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>)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -147,13 +147,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
})
|
||||
|
||||
// ---- actions ----
|
||||
const act = async (label: string, fn: () => Promise<unknown>) => {
|
||||
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
|
||||
try { await fn(); toast(label); await load(); return true } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
|
||||
}
|
||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||
const touch = async () => { const n = await askText('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
||||
const touch = async () => { const n = await askText('New file name'); if (okName(n) && await act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo))) setOpen(join(path, n.trim())) } // opened only once it exists
|
||||
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||
const move = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
||||
const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
||||
|
||||
@@ -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)}` : ''].filter(Boolean).join(' · ')}</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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { describeCron } from './crontext'
|
||||
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
||||
import { Logs } from './Services'
|
||||
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`
|
||||
}
|
||||
|
||||
// Plain-English reading of the common five-field shapes; anything else shows the raw expression.
|
||||
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 describe = (expr: string) => describeCron(expr) || expr
|
||||
|
||||
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 ----
|
||||
export default function Cron() {
|
||||
export default function Jobs() {
|
||||
const [ov, setOv] = useState<CronOverview | null>(null)
|
||||
const [err, setErr] = 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 setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
||||
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>
|
||||
|
||||
return (
|
||||
<div className={'cron' + (sel || editing ? ' has-sel' : '')}>
|
||||
<div className={'cron jobs' + (sel || editing ? ' has-sel' : '')}>
|
||||
<div className="cron-list">
|
||||
<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>
|
||||
@@ -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>}
|
||||
<div className="cron-rows">
|
||||
{!ov && !err && <Loading label="Reading crontabs…" />}
|
||||
{ov && <h3>Cron jobs · {jobs.length}</h3>}
|
||||
{ov && <h3>Scheduled jobs · {jobs.length}</h3>}
|
||||
{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 }) }}>
|
||||
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
|
||||
+187
@@ -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
@@ -2,6 +2,19 @@ import { FormEvent, useState } from 'react'
|
||||
import { api } from './api'
|
||||
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
|
||||
|
||||
export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||
@@ -9,6 +22,9 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||
const [pw, setPw] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
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) {
|
||||
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} />
|
||||
<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} />
|
||||
<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} />
|
||||
<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>
|
||||
{err && <div className="error">{err}</div>}
|
||||
</form>
|
||||
|
||||
@@ -54,7 +54,10 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
||||
try {
|
||||
let res = await api.updateAllMachines()
|
||||
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
||||
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
||||
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) {
|
||||
const forced = await api.updateAllMachines(true)
|
||||
res = res.filter((r) => !forced.some((f) => f.name === r.name)).concat(forced) // one line per machine, the forced answer winning
|
||||
}
|
||||
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
||||
setTimeout(load, 4000)
|
||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+12
-3
@@ -16,9 +16,16 @@ function fmtCPU(ns: number): string {
|
||||
const s = ns / 1e9
|
||||
return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s`
|
||||
}
|
||||
// systemd writes "Fri 2026-10-02 23:48:25 PDT", which Chromium parses and Firefox does not; read
|
||||
// the fields directly, in the browser's own zone (the machine's, on a LAN), and fall back to Date.parse.
|
||||
function parseSystemdTime(ts: string): number {
|
||||
const m = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(ts)
|
||||
if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime()
|
||||
return Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
|
||||
}
|
||||
function since(ts?: string): string {
|
||||
if (!ts) return ''
|
||||
const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
|
||||
const t = parseSystemdTime(ts)
|
||||
if (isNaN(t)) return ts
|
||||
const s = Math.max(0, (Date.now() - t) / 1000)
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
@@ -217,7 +224,9 @@ export default function Services() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
{err && units.length === 0 && loaded
|
||||
? <div className="notice" style={{ margin: 8 }}>{err}</div> // a host with no systemd, or one that is unreachable: not a filter result
|
||||
: err && <div className="error">{err}</div>}
|
||||
<div className="svc-rows">
|
||||
{!loaded && <Loading label="Listing services…" />}
|
||||
{shown.map((u) => (
|
||||
@@ -230,7 +239,7 @@ export default function Services() {
|
||||
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
{loaded && shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
{loaded && shown.length === 0 && !(err && units.length === 0) && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
</div>
|
||||
</div>
|
||||
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</div>}
|
||||
|
||||
@@ -198,7 +198,7 @@ function Updates({ me }: { me: Me }) {
|
||||
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
|
||||
</div>
|
||||
)}
|
||||
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>This is the newest published build.</div>}
|
||||
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>{st.current.replace(/^v/, '') === st.latest.replace(/^v/, '') ? 'This is the newest published build.' : `This build is newer than the last published release (${st.latest}).`}</div>}
|
||||
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
|
||||
<div className="svc-actions" style={{ marginTop: 10 }}>
|
||||
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
|
||||
|
||||
+86
-15
@@ -8,21 +8,28 @@ import Gallery from './Gallery'
|
||||
import Docker from './Docker'
|
||||
import Alerts from './Alerts'
|
||||
import Services from './Services'
|
||||
import Cron from './Cron'
|
||||
import Jobs from './Jobs'
|
||||
import Updates from './Updates'
|
||||
import Disks from './Disks'
|
||||
import Hardware from './Hardware'
|
||||
import Network from './Network'
|
||||
import VPN from './VPN'
|
||||
import ZFS from './ZFS'
|
||||
import Virtualization from './Virtualization'
|
||||
import KVM from './KVM'
|
||||
import Files from './Files'
|
||||
import Top from './Top'
|
||||
import Machines from './Machines'
|
||||
import Settings from './Settings'
|
||||
import Space from './Space'
|
||||
import Usage from './Usage'
|
||||
import Users from './Users'
|
||||
import MachineSwitcher from './MachineSwitcher'
|
||||
import { MachineStatus, setMachine } from './api'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import Power from './Power'
|
||||
import { applyAccent } from './accents'
|
||||
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
||||
import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast, useEscape } from './ui'
|
||||
import { fmtLeft, useTimeLeft } from './guest'
|
||||
|
||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||
const FolderIcon = (
|
||||
@@ -49,33 +56,58 @@ const BellIcon = (
|
||||
</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[] = [
|
||||
{ id: 'procs', label: 'Processes', icon: '▚' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '◈' },
|
||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||
{ id: 'cron', label: 'Cron', icon: ClockIcon },
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||
{ id: 'space', label: 'Space', icon: '▧' },
|
||||
{ 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: 'virt', label: 'Virtualization', icon: '▣' },
|
||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||
{ id: 'hardware', label: 'Hardware', 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' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', 'users', 'settings']
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'virt' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', '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
|
||||
// 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 {
|
||||
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
|
||||
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
|
||||
if (!m) return null
|
||||
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 {
|
||||
@@ -93,6 +125,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||
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
|
||||
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
||||
const title = titleOf(machine, machines, hubMe.host)
|
||||
@@ -102,6 +137,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
|
||||
const [drawer, setDrawer] = useState(false)
|
||||
useEscape(() => setDrawer(false), drawer) // the phone's drawer closes like every other overlay
|
||||
const [online, setOnline] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const handles = useRef<Record<string, TermHandle>>({})
|
||||
@@ -116,6 +152,32 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
|
||||
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.
|
||||
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])
|
||||
@@ -249,6 +311,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={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
||||
<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>}
|
||||
<Btn className="link" onClick={logout}>logout</Btn>
|
||||
</header>
|
||||
@@ -260,19 +323,27 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
||||
{view === 'settings' && <Settings me={hubMe} />}
|
||||
{view === 'users' && <Users me={hubMe} />}
|
||||
{view === 'space' && <Space key={machine ?? ''} />}
|
||||
{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 />}
|
||||
{view === 'docker' && <Docker />}
|
||||
{view === 'virt' && <Virtualization />}
|
||||
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
||||
{view === 'procs' && <Top />}
|
||||
{view === 'services' && <Services />}
|
||||
{view === 'cron' && <Cron />}
|
||||
{view === 'jobs' && <Jobs />}
|
||||
{view === 'updates' && <Updates />}
|
||||
{view === 'disks' && <Disks />}
|
||||
{view === 'hardware' && <Hardware />}
|
||||
{view === 'network' && <Network />}
|
||||
{view === 'vpn' && <VPN />}
|
||||
{view === 'zfs' && <ZFS />}
|
||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||
</PageBoundary>
|
||||
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||
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)} />}
|
||||
@@ -298,7 +369,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 === '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 === 'virt' ? 'Virtualization' : 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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api, StackHome } from './api'
|
||||
import { askYes, Btn, Loading, useEscape } from './ui'
|
||||
import CodeEditor from './CodeEditor'
|
||||
|
||||
interface Form { image: string; ports: string; volumes: string; env: string; labels: string; restart: string; command: string; network: string }
|
||||
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', labels: '', restart: 'unless-stopped', command: '', network: '' }
|
||||
@@ -100,7 +101,7 @@ export default function StackNew({ mode, from, networks = [], taken, onClose, on
|
||||
{homes.length > 1 && <label className="muted">Where
|
||||
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{sameTail(h) ? h.dir : tail(h.dir)} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
|
||||
</label>}
|
||||
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
|
||||
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
|
||||
{!from && <div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={toCompose}>Compose</button></div>}
|
||||
{how === 'form' ? <>
|
||||
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
|
||||
@@ -119,7 +120,7 @@ export default function StackNew({ mode, from, networks = [], taken, onClose, on
|
||||
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
|
||||
<pre className="unit-file stack-preview">{built}</pre>
|
||||
</> : <>
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
<CodeEditor className="stack-editor" language="yaml" value={text} onChange={setText} />
|
||||
{from?.hasEnv && <label className="check"><input type="checkbox" checked={copyEnv} onChange={(e) => setCopyEnv(e.target.checked)} /> <span>Copy {from.name}'s .env too <span className="muted">— copied on the machine itself; its values are never shown here</span></span></label>}
|
||||
{!(from && copyEnv) && <label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
|
||||
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
|
||||
|
||||
+106
-13
@@ -4,7 +4,7 @@
|
||||
// /api/procs for the table — so this works on any machine the console can see, whether or not
|
||||
// btop is installed on it.
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, ProcTable, Proc, Sample } from './api'
|
||||
import { api, GPU, ProcTable, Proc, Sample } from './api'
|
||||
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
|
||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
||||
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
||||
@@ -186,7 +186,7 @@ function braille(values: number[], cells = 7, max = 100): string {
|
||||
}
|
||||
|
||||
// ---- the curves this page keeps ----
|
||||
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util'
|
||||
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util,gpu.*.usage'
|
||||
|
||||
function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
|
||||
const cur = useRef<Map<string, number[]>>(new Map())
|
||||
@@ -216,7 +216,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
|
||||
const n = binN.current
|
||||
for (const [id, v] of Object.entries(live)) {
|
||||
if (typeof v !== 'number') continue
|
||||
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$/.test(id)) continue
|
||||
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue
|
||||
const arr = cur.current.get(id) ?? []
|
||||
if (!cur.current.has(id)) cur.current.set(id, arr)
|
||||
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
|
||||
@@ -280,7 +280,7 @@ export default function Top() {
|
||||
// how many points the history window holds at this rate: the width of every graph
|
||||
const cap = Math.max(2, Math.min(MAX, Math.round((historySec * 1000) / strideMs(historySec, refreshMs))))
|
||||
|
||||
const [sort, setSort] = useState<'cpu' | 'mem' | 'pid' | 'name'>('cpu')
|
||||
const [sort, setSort] = useState<'cpu' | 'gpu' | 'mem' | 'pid' | 'name'>('cpu')
|
||||
const [rev, setRev] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sel, setSel] = useState<number | null>(null)
|
||||
@@ -303,8 +303,8 @@ export default function Top() {
|
||||
if (!pt) return []
|
||||
const f = filter.trim().toLowerCase()
|
||||
const list = pt.procs.filter((p) => !f || p.name.toLowerCase().includes(f) || (p.cmd ?? '').toLowerCase().includes(f) || p.user.toLowerCase().includes(f) || String(p.pid) === f)
|
||||
const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
|
||||
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a)))
|
||||
const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'gpu' ? (p.gpu ?? 0) : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
|
||||
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a) || b.cpu - a.cpu))
|
||||
return rev ? list.reverse() : list
|
||||
}, [pt, filter, sort, rev])
|
||||
|
||||
@@ -342,10 +342,19 @@ export default function Top() {
|
||||
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
|
||||
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
|
||||
// the GPUs, with each one's sensor stem: the same rule the server uses, so two identical
|
||||
// cards are gpu.<name> and gpu.<name>_2 here as there
|
||||
const gpus = (pt?.gpus ?? []).map((g, i, all) => {
|
||||
const seg = segOf(g.name), n = all.slice(0, i).filter((o) => segOf(o.name) === seg).length
|
||||
return { g, stem: 'gpu.' + seg + (n ? `_${n + 1}` : '') }
|
||||
})
|
||||
// a GPU with nothing to read can't light a process's Gpu% either: the column, and its sort,
|
||||
// are for a machine where at least one GPU says something
|
||||
const gpuCol = gpus.some(({ g }) => !unmeasured(g))
|
||||
|
||||
return (
|
||||
<Pal.Provider value={pal}>
|
||||
<div className="top" style={pal.vars as React.CSSProperties}>
|
||||
<div className={'top' + (gpus.length ? ' has-gpu' : '') + (gpus.length && !gpuCol ? ' gpu-slim' : '')} style={pal.vars as React.CSSProperties}>
|
||||
<Box n={1} name="cpu" className="top-cpu" right={<>
|
||||
<span className="t-clock">{clock.toLocaleTimeString([], { hour12: false })}</span>
|
||||
<span className="t-rate">
|
||||
@@ -360,7 +369,8 @@ export default function Top() {
|
||||
<div className="t-up">up {upfor(uptime)}</div>
|
||||
</div>
|
||||
<div className="t-cores">
|
||||
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}</div>
|
||||
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
|
||||
{pt?.cpu_power_w ? <span title="the CPU's own power reading: cores, built-in graphics and memory controller, not the whole machine">{pt.cpu_mhz ? ' · ' : ''}{pt.cpu_power_w < 10 ? pt.cpu_power_w.toFixed(1) : Math.round(pt.cpu_power_w)} W</span> : null}</div>
|
||||
<div className="t-core">
|
||||
<span className="t-core-n">CPU</span>
|
||||
<Meter pct={cpu} grad="cpu" />
|
||||
@@ -383,6 +393,16 @@ export default function Top() {
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{gpus.length > 0 && (
|
||||
<div className="top-gpus">
|
||||
{gpus.map(({ g, stem }, i) => (
|
||||
<Box key={stem} name={`gpu${i}`} className={'top-gpu' + (unmeasured(g) ? ' slim' : '')} right={<span className="t-gpu-name" title={[g.name, g.driver, g.bus].filter(Boolean).join(' · ')}>{g.name}</span>}>
|
||||
{unmeasured(g) ? <GPULine g={g} /> : <GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} cap={cap} />}
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box n={2} name="mem" className="top-mem">
|
||||
<div className="t-row"><b>Total:</b><span className="spacer" />{bytes(mem.total)}</div>
|
||||
{([['Used', mem.used, 'used'], ['Available', mem.avail, 'avail'], ['Cached', cached, 'cached'], ['Free', free, 'free']] as const).map(([k, v, g]) => (
|
||||
@@ -445,17 +465,17 @@ export default function Top() {
|
||||
|
||||
<Box n={5} name="proc" className="top-proc" right={<>
|
||||
<span className="t-sorts">
|
||||
{(['pid', 'name', 'mem', 'cpu'] as const).map((k) => <button key={k} className={sort === k ? 'on' : ''} onClick={() => setSort(k)}>{k}</button>)}
|
||||
{(['pid', 'name', 'mem', ...(gpuCol ? ['gpu' as const] : []), 'cpu'] as const).map((k) => <button key={k} className={sort === k ? 'on' : ''} onClick={() => setSort(k)}>{k}</button>)}
|
||||
<button className={rev ? 'on' : ''} onClick={() => setRev(!rev)}>reverse</button>
|
||||
</span>
|
||||
</>}>
|
||||
<div className="t-proc">
|
||||
<div className={'t-proc' + (gpuCol ? ' has-gpu' : '')}>
|
||||
<input className="t-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
<div className="t-thead">
|
||||
<span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-cmd">Command:</span>
|
||||
<span className="c-thr">Threads:</span><span className="c-user">User:</span><span className="c-mem">MemB</span><span className="c-g" /><span className="c-cpu">Cpu%</span>
|
||||
<span className="c-thr">Threads:</span><span className="c-user">User:</span><span className="c-mem">MemB</span><span className="c-g" />{gpuCol && <span className="c-gpu">Gpu%</span>}<span className="c-cpu">Cpu%</span>
|
||||
</div>
|
||||
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} empty={err || 'no process matches'} />
|
||||
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} gpu={gpuCol} empty={err || 'no process matches'} />
|
||||
<div className="t-foot">
|
||||
{sel !== null && procs.find((p) => p.pid === sel) ? (() => { const p = procs.find((x) => x.pid === sel)!; return <>
|
||||
<span className="t-dim">{p.pid} {p.name}</span><span className="spacer" />
|
||||
@@ -473,7 +493,7 @@ export default function Top() {
|
||||
|
||||
// The rows are the heaviest thing on the page — hundreds of them — so they are redrawn when the
|
||||
// table is read again, not on every tick of the curves above them.
|
||||
const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; empty: string }) {
|
||||
const Rows = memo(function Rows({ procs, sel, onSel, curves, gpu, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; gpu: boolean; empty: string }) {
|
||||
return (
|
||||
<div className="t-rows">
|
||||
{procs.map((p) => {
|
||||
@@ -487,6 +507,7 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
|
||||
<span className="c-user">{p.user}</span>
|
||||
<span className="c-mem">{shortBytes(p.rss)}</span>
|
||||
<span className="c-g">{braille(c, 7, Math.max(5, ...(c.length ? c : [0])))}</span>
|
||||
{gpu && <span className={'c-gpu' + (p.gpu ? ' lit' : '')}>{p.gpu ? p.gpu.toFixed(1) : ''}</span>}
|
||||
<span className="c-cpu">{p.cpu.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -496,6 +517,78 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
|
||||
)
|
||||
})
|
||||
|
||||
// One GPU, laid out like the cpu box: the busy curve on the left; on the right the device's
|
||||
// meters — busy, each engine, temperature — then clock, power and memory, then the processes
|
||||
// that have it open with their share. What cannot be measured on this card is left out rather
|
||||
// than shown as zero: an Arc on i915 has no temperature sensor, Asahi has no busy counter.
|
||||
const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'], ['video', 'video'], ['video-enhance', 'enhance'], ['copy', 'copy']]
|
||||
function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; tick: number; advance: number; cap: number }) {
|
||||
const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
|
||||
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined
|
||||
const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts'
|
||||
: g.usage_src === 'idle' ? 'busy, from idle residency' : g.usage_src === 'busy' ? 'busy, as the driver reports it' : g.usage_src === 'clients' ? 'the busiest engine, summed over its clients' : 'this driver exposes no activity counter'
|
||||
return (
|
||||
<div className="t-gpu">
|
||||
<div className="t-gpu-graph">
|
||||
<Graph values={values} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
|
||||
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
|
||||
</div>
|
||||
<div className="t-gpu-stats">
|
||||
<div className="t-cores-head"><b>{g.name}</b><span className="spacer" />{g.driver}{g.integrated ? ' · integrated' : ''}</div>
|
||||
<div className="t-core">
|
||||
<span className="t-core-n">GPU</span>
|
||||
<Meter pct={g.usage_src ? g.usage : 0} grad="cpu" />
|
||||
<span className="t-core-p">{g.usage_src ? `${Math.round(g.usage)}%` : '—'}</span>
|
||||
<span className="t-core-t">{temp !== undefined && <Meter pct={Math.min(100, temp)} grad="temp" />}</span>
|
||||
<span className="t-core-c" title={temp !== undefined ? (g.temp_src === 'package' ? 'the CPU package: an integrated GPU shares the die, and this is its only sensor' : "the card's own sensor") : 'no temperature sensor on this device'}>{temp !== undefined ? `${Math.round(temp)}°C${g.temp_src === 'package' ? '*' : ''}` : ''}</span>
|
||||
</div>
|
||||
{engines.map(([k, label]) => (
|
||||
<div className="t-core" key={k}>
|
||||
<span className="t-core-n t-eng">{label}</span>
|
||||
<Meter pct={g.engines![k]} grad="cpu" />
|
||||
<span className="t-core-p">{Math.round(g.engines![k])}%</span>
|
||||
<span className="t-core-t" /><span className="t-core-c" />
|
||||
</div>
|
||||
))}
|
||||
<div className="t-gpu-facts">
|
||||
{(g.mhz !== undefined || g.max_mhz) ? <div className="t-row t-sub"><span>Clock</span><span className="spacer" />
|
||||
<span className="t-fact" title={g.req_mhz ? `${Math.round(g.req_mhz)} MHz requested${g.max_mhz ? `, ${Math.round(g.max_mhz)} MHz max` : ''}` : undefined}>{Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz</span></div> : null}
|
||||
{(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ? <div className="t-row t-sub"><span>Power</span><span className="spacer" />
|
||||
<span className="t-fact">{(g.power_w ?? 0).toFixed(1)}{g.power_cap_w ? ` / ${Math.round(g.power_cap_w)}` : ''} W</span></div> : null}
|
||||
{g.fan_rpm ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{g.fan_rpm} rpm</span></div> : null}
|
||||
{g.mem_total ? <div className="t-row t-sub"><span>Mem</span><span className="t-pct">{Math.round(((g.mem_used ?? 0) / g.mem_total) * 100)}%</span><Meter pct={((g.mem_used ?? 0) / g.mem_total) * 100} grad="used" /><span className="t-amt">{bytes(g.mem_used ?? 0, 1)}</span></div>
|
||||
: g.mem_used ? <div className="t-row t-sub"><span>Mem</span><span className="spacer" /><span className="t-fact" title="resident memory of the processes that have it open">{bytes(g.mem_used, 1)}</span></div> : null}
|
||||
</div>
|
||||
<div className="t-gpu-clients">
|
||||
<div className="t-thead"><span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-mem">MemB</span><span className="c-cpu">Gpu%</span></div>
|
||||
{g.clients.map((c) => (
|
||||
<div className="t-crow" key={c.pid} title={c.engines ? Object.entries(c.engines).map(([k, v]) => `${k} ${v.toFixed(1)}%`).join(' ') : undefined}>
|
||||
<span className="c-pid">{c.pid}</span><span className="c-name">{c.name}</span><span className="c-mem">{c.mem ? shortBytes(c.mem) : ''}</span><span className="c-cpu">{c.usage.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
{g.clients.length === 0 && <div className="t-dim t-crow-none">{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden: the console has no root here'}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Nothing on this GPU can be read — no busy counter, engine, temperature, clock, power, memory,
|
||||
// nor a process with it open: the Mac's Asahi, today. Its box would be a graph that can never
|
||||
// draw beside a column of dashes, so it is one line instead. A client opening it brings the box back.
|
||||
const unmeasured = (g: GPU) => !g.usage_src && !g.engines && !(g.temp && g.temp > 0) && !g.mhz && !g.max_mhz && !g.power_w && !g.power_cap_w && !g.fan_rpm && !g.mem_total && !g.mem_used && g.clients.length === 0
|
||||
function GPULine({ g }: { g: GPU }) {
|
||||
return (
|
||||
<div className="t-row t-gpu-line">
|
||||
<span>{g.driver}{g.integrated ? ' · integrated' : ''}</span>
|
||||
{g.stalled
|
||||
? <span className="t-dim" title="a sysfs read of this card stopped answering">readings paused until the console restarts</span>
|
||||
: <span className="t-dim" title="the driver exposes no busy counter, clock, temperature, power or memory reading">nothing measurable</span>}
|
||||
<span className="t-dim t-gpu-who" title={g.clients_ok ? undefined : 'the console has no root here'}>{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One physical drive. `used` is the bytes of the filesystems on it, left out when none can
|
||||
// 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 }) {
|
||||
|
||||
+6
-2
@@ -52,7 +52,11 @@ export default function Updates() {
|
||||
setOpen(p.name)
|
||||
if (!changelog[p.name]) {
|
||||
try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
|
||||
catch (e) { setChangelog((c) => ({ ...c, [p.name]: e instanceof Error ? e.message : 'unavailable' })) }
|
||||
catch (e) {
|
||||
const m = e instanceof Error ? e.message : 'unavailable'
|
||||
// apt's own words for a third-party repository that publishes none are a wall of "E: Failed to fetch"
|
||||
setChangelog((c) => ({ ...c, [p.name]: /changelog unavailable|failed to fetch/i.test(m) ? 'No changelog is published for this package — its repository does not provide one.' : m }))
|
||||
}
|
||||
}
|
||||
}
|
||||
const toggle = (n: string) => setSel((s) => { const x = new Set(s); if (x.has(n)) x.delete(n); else x.add(n); return x })
|
||||
@@ -99,7 +103,7 @@ export default function Updates() {
|
||||
<span className="n">{p.name}{p.security && <span className="badge bad">security</span>}</span>
|
||||
<span className="d">{p.description}</span>
|
||||
</span>
|
||||
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span>
|
||||
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}{p.suite && <span className="muted"> · {p.suite}</span>}</span>
|
||||
</div>
|
||||
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 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:
|
||||
// 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.
|
||||
@@ -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 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 [err, setErr] = useState('')
|
||||
const [sel, setSel] = useHashSel('space')
|
||||
const [sel, setSel] = useHashSel('usage')
|
||||
const [tab, setTab] = useState<'tree' | 'files'>('tree')
|
||||
const rate = useRefreshMs()
|
||||
|
||||
+93
-3
@@ -3,19 +3,98 @@
|
||||
// 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 { 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 { fmtLeft } from './guest'
|
||||
|
||||
export default function Users({ me }: { me: Me }) {
|
||||
return (
|
||||
<div className="dash settings">
|
||||
<Account me={me} />
|
||||
<Sessions />
|
||||
<Guests />
|
||||
<Accounts me={me} />
|
||||
</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 }) {
|
||||
const [cur, setCur] = useState('')
|
||||
const [pw1, setPw1] = useState('')
|
||||
@@ -54,6 +133,13 @@ function Sessions() {
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const others = list?.filter((s) => !s.current).length ?? 0
|
||||
// a day unused is the line between a device that is used and a script that signed in once
|
||||
const IDLE_H = 24
|
||||
const idle = list?.filter((s) => !s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3).length ?? 0
|
||||
const revokeIdle = async () => {
|
||||
if (!await askYes(`Sign out ${idle} ${idle === 1 ? 'session' : 'sessions'} not used in the last ${IDLE_H} hours? Devices used since then, and this one, stay signed in.`, 'Revoke')) return
|
||||
try { const r = await api.revokeIdleSessions(IDLE_H); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const revokeOthers = async () => {
|
||||
if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return
|
||||
try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
@@ -66,11 +152,15 @@ function Sessions() {
|
||||
{list && list.map((s) => (
|
||||
<div className="row" key={s.id}>
|
||||
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
|
||||
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
|
||||
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}{!s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3 ? <span className="badge" style={{ marginLeft: 6 }}>idle</span> : null}</span>
|
||||
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
|
||||
</div>
|
||||
))}
|
||||
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
|
||||
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}>
|
||||
{idle > 0 && <Btn className="btn" onClick={revokeIdle}>Sign out {idle} idle</Btn>}
|
||||
<Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn>
|
||||
<span className="muted small">A session unused for a week signs itself out.</span>
|
||||
</div>}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
// 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 { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import qrcode from 'qrcode-generator'
|
||||
import { api, CreatedVPNPeer, VPNReport, VPNPeer, VPNTunnel } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { Btn, Loading, askText, toast, useEscape } 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, iface, editable, onChanged }: { p: VPNPeer; server: boolean; iface: string; editable: boolean; onChanged: () => void }) {
|
||||
const rename = async () => {
|
||||
const name = await askText(`Rename ${p.name || 'this device'}`, p.name ?? '')
|
||||
if (!name || name.trim() === p.name) return
|
||||
try { await api.editVPNPeer(iface, p.section!, { name: name.trim() }); toast('renamed'); onChanged() }
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const remove = async () => {
|
||||
const typed = await askText(`Remove ${p.name}? Its profile stops working at once and cannot be brought back — the device would need a new one.\n\nType its name to confirm.`, '', 'Remove')
|
||||
if (typed === null) return
|
||||
try { await api.removeVPNPeer(iface, p.section!, typed.trim()); toast(`${p.name} removed`); onChanged() }
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
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>
|
||||
{editable && p.section && <>
|
||||
<Btn className="btn" onClick={rename}>Rename</Btn>
|
||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||
</>}
|
||||
</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, onChanged }: { t: VPNTunnel; onChanged: () => void }) {
|
||||
const server = t.kind === 'server'
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [made, setMade] = useState<CreatedVPNPeer | null>(null)
|
||||
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>
|
||||
{t.managed && server && <Btn className="btn on" onClick={() => { setMade(null); setAdding(true) }}>+ Add device</Btn>}
|
||||
</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>
|
||||
{adding && <AddDevice t={t} onClose={() => setAdding(false)} onMade={(c) => { setAdding(false); setMade(c); onChanged() }} />}
|
||||
{made && <Profile made={made} onClose={() => setMade(null)} />}
|
||||
{peers.map((p) => <Peer key={p.public_key} p={p} server={server} iface={t.name} editable={!!t.managed} onChanged={onChanged} />)}
|
||||
{peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</div></section>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The form for a new device. The endpoint is what the device will dial from outside, so it is
|
||||
// the one thing Ham Lab cannot work out for itself — remembered per machine once given.
|
||||
function AddDevice({ t, onClose, onMade }: { t: VPNTunnel; onClose: () => void; onMade: (c: CreatedVPNPeer) => void }) {
|
||||
const key = `hamlab.wg.endpoint.${t.name}`
|
||||
const [name, setName] = useState('')
|
||||
const [endpoint, setEndpoint] = useState(localStorage.getItem(key) ?? '')
|
||||
const [address, setAddress] = useState('')
|
||||
const [dns, setDns] = useState('1.1.1.1, 1.0.0.1')
|
||||
const [split, setSplit] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
const made = await api.addVPNPeer(t.name, { name: name.trim(), endpoint: endpoint.trim(), address: address.trim(), dns: dns.trim(), split })
|
||||
localStorage.setItem(key, endpoint.trim())
|
||||
onMade(made)
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<form className="card addform" onSubmit={submit} style={{ marginTop: 8 }}>
|
||||
<label>Device name <input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="phone" spellCheck={false} required /></label>
|
||||
<label>What it dials <input value={endpoint} onChange={(e) => setEndpoint(e.target.value)} placeholder="home.example.com:51820" spellCheck={false} autoCapitalize="off" required /></label>
|
||||
<label>Address in the tunnel <input value={address} onChange={(e) => setAddress(e.target.value)} placeholder="the next free one" spellCheck={false} /></label>
|
||||
<label>DNS while connected <input value={dns} onChange={(e) => setDns(e.target.value)} placeholder="none" spellCheck={false} /></label>
|
||||
<label className="row-check"><input type="checkbox" checked={split} onChange={(e) => setSplit(e.target.checked)} /> Only route the home networks, not everything</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button className="btn on" disabled={busy || !name.trim() || !endpoint.trim()}>Create device</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// The profile, once. Everything a device needs is in it, including a private key that exists
|
||||
// nowhere else — so it is shown until dismissed, with the QR the WireGuard apps expect.
|
||||
function Profile({ made, onClose }: { made: CreatedVPNPeer; onClose: () => void }) {
|
||||
const qr = qrcode(0, 'M')
|
||||
qr.addData(made.config)
|
||||
qr.make()
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(made.config); toast('profile copied') }
|
||||
catch { toast('select and copy it by hand', 'err') }
|
||||
}
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([made.config], { type: 'text/plain' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${made.name.replace(/[^a-zA-Z0-9._-]+/g, '-')}.conf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
return (
|
||||
<section className="card fresh vpn-profile" style={{ marginTop: 8 }}>
|
||||
<div className="sub-head"><span className="dname">{made.name}</span><span className="badge ok">created</span><span className="spacer" /><span className="muted small mono">{made.address}</span></div>
|
||||
<div className="vpn-qr">
|
||||
<img src={qr.createDataURL(5, 8)} alt={`WireGuard profile for ${made.name}`} width={220} height={220} />
|
||||
<div>
|
||||
<div className="muted small">Scan it in the WireGuard app, or copy the profile into one.</div>
|
||||
<pre className="mono vpn-conf">{made.config}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||
<Btn className="btn on" onClick={copy}>Copy profile</Btn>
|
||||
<Btn className="btn" onClick={download}>Download .conf</Btn>
|
||||
<span className="muted small">Shown once. The private key in it is not stored here — if it is lost, make the device again.</span>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={onClose}>Done</button>
|
||||
</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} onChanged={load} />)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// The graphical console of a VM: spice-html5 (vendored, loaded on demand) drawing the SPICE
|
||||
// stream that Ham Lab bridges from Incus into a canvas. Used for installers from an ISO, and
|
||||
// anything else with a screen rather than a serial line.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { wsURLPath } from './api'
|
||||
import { Btn, useEscape } from './ui'
|
||||
// No spice.css: upstream's stylesheet is for its own demo page and sets body, *, button and
|
||||
// canvas for the whole document. Imported here it restyled every button in Ham Lab. The
|
||||
// console is styled by the .vga rules in styles.css.
|
||||
|
||||
type Spice = typeof import('./vendor/spice-html5/src/main.js')
|
||||
|
||||
export default function VgaConsole({ name, onClose }: { name: string; onClose: () => void }) {
|
||||
const [state, setState] = useState<'loading' | 'connecting' | 'connected' | 'error'>('loading')
|
||||
const [err, setErr] = useState('')
|
||||
const [fit, setFit] = useState(true)
|
||||
const [size, setSize] = useState('')
|
||||
const conn = useRef<InstanceType<Spice['SpiceMainConn']> | null>(null)
|
||||
const mod = useRef<Spice | null>(null)
|
||||
const area = useRef<HTMLDivElement>(null)
|
||||
const screen = useRef<HTMLDivElement>(null)
|
||||
useEscape(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
let dead = false
|
||||
import('./vendor/spice-html5/src/main.js').then((m) => {
|
||||
if (dead) return
|
||||
mod.current = m
|
||||
setState('connecting')
|
||||
try {
|
||||
conn.current = new m.SpiceMainConn({
|
||||
uri: wsURLPath(`/api/virt/instances/${encodeURIComponent(name)}/vga`),
|
||||
screen_id: 'spice-screen', message_id: 'spice-message',
|
||||
onerror: (e) => { setState('error'); setErr(e instanceof Error ? e.message : (e as Event & { reason?: string })?.reason || 'the console closed') },
|
||||
onsuccess: () => setState('connected'),
|
||||
onagent: () => { try { m.handle_resize() } catch { /* no agent in the guest yet */ } },
|
||||
})
|
||||
} catch (e) { setState('error'); setErr(String(e)) }
|
||||
}).catch((e) => { setState('error'); setErr('could not load the SPICE viewer: ' + String(e)) })
|
||||
return () => { dead = true; try { conn.current?.stop() } catch { /* already gone */ } conn.current = null }
|
||||
}, [name])
|
||||
|
||||
// The guest draws at its own resolution; scale the canvas to the pane unless asked not to.
|
||||
useEffect(() => {
|
||||
const a = area.current, s = screen.current
|
||||
if (!a || !s) return
|
||||
const apply = () => {
|
||||
const c = s.querySelector('canvas')
|
||||
if (!c) return
|
||||
setSize(`${c.width}×${c.height}`)
|
||||
c.tabIndex = 1
|
||||
if (!fit) { s.style.transform = ''; s.style.width = ''; s.style.height = ''; return }
|
||||
const k = Math.min(1, (a.clientWidth - 8) / c.width, (a.clientHeight - 8) / c.height)
|
||||
s.style.transformOrigin = 'top left'
|
||||
s.style.transform = `scale(${k})`
|
||||
s.style.width = `${c.width}px`; s.style.height = `${c.height}px`
|
||||
}
|
||||
apply()
|
||||
const ro = new ResizeObserver(apply); ro.observe(a)
|
||||
const mo = new MutationObserver(apply); mo.observe(s, { childList: true, attributes: true, subtree: true, attributeFilter: ['width', 'height'] })
|
||||
return () => { ro.disconnect(); mo.disconnect() }
|
||||
}, [fit, state])
|
||||
|
||||
const cad = () => { if (conn.current && mod.current) mod.current.sendCtrlAltDel(conn.current) }
|
||||
const focus = () => screen.current?.querySelector('canvas')?.focus()
|
||||
return (
|
||||
<div className="modal-back vga-back" onClick={onClose}>
|
||||
<div className="vga" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="logs-bar vga-bar">
|
||||
<span className={'dot ' + (state === 'connected' ? 'ok' : state === 'error' ? 'bad' : 'warn')} />
|
||||
<span className="dname">{name}</span>
|
||||
<span className="muted small">{state === 'connected' ? `VGA · ${size || 'waiting for the first frame'}` : state === 'error' ? err : state === 'loading' ? 'loading the viewer…' : 'connecting…'}</span>
|
||||
<span className="spacer" />
|
||||
<Btn className="btn" disabled={state !== 'connected'} onClick={cad} title="send Ctrl+Alt+Del to the guest">Ctrl+Alt+Del</Btn>
|
||||
<button className={'btn' + (fit ? ' on' : '')} onClick={() => setFit(!fit)} title="scale the screen to this window, or show it pixel for pixel">Fit</button>
|
||||
<button className="btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<div className="vga-area" ref={area} onClick={focus}>
|
||||
<div id="spice-area"><div id="spice-screen" ref={screen} /></div>
|
||||
<div id="spice-message" className="spice-message vga-msg" />
|
||||
</div>
|
||||
<div className="muted small vga-hint">Click the screen to type into it. Keys go to the guest while it has focus; Escape with focus elsewhere closes this.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
// Virtualization: LXC containers and KVM virtual machines through Incus, per machine. The
|
||||
// page has four states — ready, repair, installable, unavailable — decided by what the
|
||||
// selected machine can do, never by a rule about what should run where. Everything the
|
||||
// tab makes lives under Ham Lab's own folder on that machine.
|
||||
import { FormEvent, Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { api, ExportsView, LibraryFile, TaskInfo, VirtImage, VirtInstance, VirtOverview, VirtStatus } from './api'
|
||||
import VgaConsole from './VgaConsole'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { askText, askYes, Btn, CloseBtn, Loading, toast, useEscape } from './ui'
|
||||
|
||||
type Tab = 'instances' | 'images' | 'library'
|
||||
const GiB = 1 << 30
|
||||
const dot = (i: VirtInstance) => (i.status === 'Running' ? 'ok' : i.status === 'Stopped' ? '' : i.status === 'Error' ? 'bad' : 'warn')
|
||||
const kind = (i: VirtInstance) => (i.type === 'virtual-machine' ? 'VM' : 'LXC')
|
||||
const when = (iso?: string) => { if (!iso) return '—'; const d = new Date(iso); return isNaN(d.getTime()) || d.getFullYear() < 1980 ? '—' : d.toLocaleString() }
|
||||
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'failed')
|
||||
const imgLabel = (im: VirtImage) => im.aliases[0] ?? im.fingerprint.slice(0, 12)
|
||||
|
||||
export default function Virtualization() {
|
||||
const [ov, setOv] = useState<VirtOverview | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [tab, setTab] = useState<Tab>('instances')
|
||||
const [sel, setSel] = useHashSel('virt')
|
||||
const [making, setMaking] = useState<null | { image?: string; vm?: boolean }>(null)
|
||||
const [fromIso, setFromIso] = useState<string | null>(null)
|
||||
const [task, setTask] = useState<{ id: string; title: string } | null>(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
const rate = useRefreshMs()
|
||||
const load = useCallback(async () => { try { setOv(await api.virt()); setErr('') } catch (e) { setErr(errMsg(e)) } }, [])
|
||||
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
|
||||
const startTask = (t: TaskInfo) => setTask({ id: t.id, title: t.title })
|
||||
|
||||
if (!ov) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Virtualization tab. Update it from Machines." : err}</div> : <Loading label="Asking Incus…" />}</div>
|
||||
const st = ov.status
|
||||
if (st.state !== 'ready') return <Gate st={st} onChanged={load} />
|
||||
const instances = ov.instances ?? []
|
||||
const images = ov.images ?? []
|
||||
const cur = instances.find((i) => i.name === sel)
|
||||
const running = instances.filter((i) => i.status === 'Running').length
|
||||
const f = filter.trim().toLowerCase()
|
||||
const shown = instances.filter((i) => !f || `${i.name} ${i.image} ${i.type} ${i.status}`.toLowerCase().includes(f))
|
||||
const libCount = ov.library.images.length + ov.library.isos.length
|
||||
return (
|
||||
<div className={'svc virt' + (cur && tab === 'instances' ? ' has-sel' : '') + (tab !== 'instances' ? ' wide' : '')}>
|
||||
<div className="svc-list">
|
||||
<div className="svc-tools">
|
||||
<div className="chips">
|
||||
{(['instances', 'images', 'library'] as Tab[]).map((t) => (
|
||||
<button key={t} className={'chip' + (tab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>
|
||||
{t}{t === 'instances' ? ` · ${running}/${instances.length}` : t === 'images' ? ` · ${images.length}` : ` · ${libCount}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'instances' && (
|
||||
<div className="svc-actions">
|
||||
<button className="btn on" onClick={() => setMaking({})}>+ New instance</button>
|
||||
<span className="spacer" />
|
||||
<span className="muted small">{st.kvm ? 'containers and VMs' : 'containers only — no KVM on this machine'}</span>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'instances' && instances.length > 3 && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter instances" />}
|
||||
{ov.error && <div className="error">{ov.error}</div>}
|
||||
{task && <TaskOutput id={task.id} onDone={(t) => { load(); if (t.exit_code === 0 && !t.error) toast(`${task.title}: done`); else toast(`${task.title}: failed`, 'err') }} />}
|
||||
{task && <div className="svc-actions"><button className="btn" onClick={() => setTask(null)}>Hide output</button></div>}
|
||||
</div>
|
||||
{tab === 'instances' && (
|
||||
<div className="svc-rows">
|
||||
{shown.map((i) => (
|
||||
<div key={i.name} className={'svc-row' + (i.name === sel ? ' active' : '')} onClick={() => setSel(i.name)}>
|
||||
<span className={'sdot ' + dot(i)} />
|
||||
<div className="svc-name"><div className="n">{i.name} <span className="badge">{kind(i)}</span></div><div className="d">{i.image || '—'}{i.ipv4.length ? ` · ${i.ipv4.join(' ')}` : ''}</div></div>
|
||||
<div className="svc-state">{i.status === 'Running' ? <>{fmtBytes(i.mem_usage)}<br />{i.cpu_pct.toFixed(0)}%</> : i.status.toLowerCase()}</div>
|
||||
</div>
|
||||
))}
|
||||
{instances.length === 0 && <div className="muted small" style={{ padding: 12 }}>Nothing here yet. “+ New instance” makes a container{st.kvm ? ' or a VM' : ''} from the catalogue.</div>}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'images' && <Images st={st} images={images} instances={instances} onTask={startTask} onNew={(image, vm) => { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />}
|
||||
{tab === 'library' && <Library st={st} ov={ov} onTask={startTask} onChanged={load} onNewVM={(iso) => setFromIso(iso)} />}
|
||||
</div>
|
||||
{tab === 'instances' && (cur ? <Detail i={cur} st={st} onBack={() => setSel('')} onChanged={load} onTask={startTask} /> : <Summary ov={ov} />)}
|
||||
{making && <NewInstance st={st} ov={ov} preset={making} onClose={() => setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />}
|
||||
{fromIso !== null && <NewVMFromISO st={st} ov={ov} iso={fromIso} onClose={() => setFromIso(null)} onStarted={(t) => { setFromIso(null); startTask(t); setTab('instances') }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the three states before "ready" ----
|
||||
|
||||
function Gate({ st, onChanged }: { st: VirtStatus; onChanged: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [task, setTask] = useState('')
|
||||
const [waiting, setWaiting] = useState(false)
|
||||
// the install ends by restarting Ham Lab, so poll until it answers again
|
||||
useEffect(() => {
|
||||
if (!waiting) return
|
||||
const t = setInterval(async () => { try { const o = await api.virt(); if (o.status.state === 'ready') { setWaiting(false); onChanged() } } catch { /* restarting */ } }, 3000)
|
||||
return () => clearInterval(t)
|
||||
}, [waiting, onChanged])
|
||||
const headline = st.state === 'installable'
|
||||
? `Incus isn't installed on this machine. Ham Lab can install it with ${st.pm}: ${st.kvm ? 'containers and virtual machines' : 'containers only, since this kernel has no KVM'}.`
|
||||
: st.reason
|
||||
return (
|
||||
<div className="dash">
|
||||
<div className="dash-bar"><h2 className="section">Virtualization</h2></div>
|
||||
<section className="card">
|
||||
<div className="notice">{headline}</div>
|
||||
<div className="props" style={{ marginTop: 8 }}>
|
||||
<div><span className="k">KVM</span><span>{st.kvm ? 'yes — virtual machines possible' : 'no — containers only'}</span></div>
|
||||
<div><span className="k">Architecture</span><span className="mono">{st.arch}</span></div>
|
||||
{st.pm && <div><span className="k">Packages</span><span>{st.pm}</span></div>}
|
||||
<div><span className="k">Pool drivers</span><span>{st.drivers.join(', ')}</span></div>
|
||||
<div><span className="k">Memory</span><span>{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}</span></div>
|
||||
<div><span className="k">Disk</span><span>{fmtBytes(st.disk_free)} free where Ham Lab's data lives</span></div>
|
||||
<div><span className="k">Docker</span><span>{st.docker ? 'present — its FORWARD policy needs two rules, Install adds them' : 'not here'}</span></div>
|
||||
<div><span className="k">Data</span><span className="mono small">{st.dir}</span></div>
|
||||
{st.layout && <div><span className="k">Last attempt</span><span>{when(st.layout.attempted)} · {st.layout.driver} pool · {st.layout.subnet}</span></div>}
|
||||
</div>
|
||||
{(st.state === 'installable' || st.state === 'repair') && !task && (
|
||||
<div className="svc-actions"><Btn className="btn on" onClick={() => setOpen(true)}>{st.state === 'repair' ? 'Finish setup' : 'Install Incus'}</Btn></div>
|
||||
)}
|
||||
{task && <TaskOutput id={task} onDone={(t) => { if (t.exit_code === 0 && !t.error) setWaiting(true); else { toast('install failed — the output says where', 'err'); setTask('') } }} />}
|
||||
{waiting && <div className="muted small" style={{ marginTop: 8 }}>Ham Lab is restarting to pick up its new group. The page reconnects by itself.</div>}
|
||||
</section>
|
||||
{open && <InstallDialog st={st} onClose={() => setOpen(false)} onStarted={(id) => { setOpen(false); setTask(id) }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: () => void; onStarted: (id: string) => void }) {
|
||||
const [driver, setDriver] = useState(st.layout?.driver ?? st.drivers[0] ?? 'dir')
|
||||
const [size, setSize] = useState(String(st.layout?.pool_size_gib || st.pool_size_gib || 20))
|
||||
const [subnet, setSubnet] = useState(st.layout?.subnet || st.subnet || '172.16.100.1/24')
|
||||
const [docker, setDocker] = useState(st.docker)
|
||||
const [arc, setArc] = useState(st.layout?.arc_max_gib ? String(st.layout.arc_max_gib) : '')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEscape(onClose)
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr(''); setBusy(true)
|
||||
try { const t = await api.virtInstall({ driver, pool_size_gib: Number(size) || 0, subnet: subnet.trim(), docker_rules: docker, arc_max_gib: Number(arc) || 0 }); onStarted(t.id) }
|
||||
catch (e) { setErr(errMsg(e)); setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h3>{st.state === 'repair' ? 'Finish setting up Incus' : 'Install Incus'}</h3>
|
||||
<div className="notice">Everything goes under <span className="mono">{st.dir}</span>: the pool's backing file, ISOs, images, exports. Where is not a choice; how big is.</div>
|
||||
<label className="muted">Pool driver
|
||||
<select value={driver} onChange={(e) => setDriver(e.target.value)}>
|
||||
{st.drivers.map((d) => <option key={d} value={d}>{d}{d === 'zfs' ? ' — snapshots and clones are instant' : d === 'btrfs' ? ' — snapshots, on a loop file' : ' — plain files, no snapshots to speak of'}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{driver !== 'dir' && (
|
||||
<label className="muted">Pool size, GiB — a sparse file, grows as used · {fmtBytes(st.disk_free)} free
|
||||
<input type="number" min={5} max={Math.floor(st.disk_free / GiB)} value={size} onChange={(e) => setSize(e.target.value)} required />
|
||||
</label>
|
||||
)}
|
||||
<label className="muted">Bridge address — picked clear of this machine's routes and Docker's pools
|
||||
<input className="mono" value={subnet} onChange={(e) => setSubnet(e.target.value)} spellCheck={false} required />
|
||||
</label>
|
||||
{st.docker && (
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={docker} onChange={(e) => setDocker(e.target.checked)} style={{ width: 'auto' }} />
|
||||
Add the two DOCKER-USER rules that let the bridge's traffic through Docker's FORWARD drop
|
||||
</label>
|
||||
)}
|
||||
{st.zfs_host && (
|
||||
<label className="muted">Cap the ZFS ARC, GiB — blank leaves it at {st.arc_max_gib} GiB; a cap makes guest memory deterministic
|
||||
<input type="number" min={1} max={Math.floor(st.mem_total / GiB)} value={arc} onChange={(e) => setArc(e.target.value)} placeholder="leave as is" />
|
||||
</label>
|
||||
)}
|
||||
<div className="muted small">Ham Lab's account ({st.user}) joins the incus-admin group and Ham Lab restarts at the end.</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on" disabled={busy}>{busy ? 'Starting…' : 'Install'}</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the right-hand pane when nothing is selected: what this machine's Incus is ----
|
||||
|
||||
function Summary({ ov }: { ov: VirtOverview }) {
|
||||
const st = ov.status
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<div className="svc-head"><span className="dname">Incus {st.version}</span><span className="badge">{st.arch}</span>{!st.kvm && <span className="badge warn">no KVM</span>}</div>
|
||||
<div className="props">
|
||||
<div><span className="k">Data</span><span className="mono small">{st.dir}</span></div>
|
||||
<div><span className="k">Memory</span><span>{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}</span></div>
|
||||
{st.layout && <div><span className="k">Installed</span><span>{when(st.layout.attempted)} by Ham Lab {st.layout.hamlab_version ?? ''}</span></div>}
|
||||
{!st.layout && <div><span className="k">Installed</span><span>outside Ham Lab — pools and networks below are as found</span></div>}
|
||||
</div>
|
||||
<h3 className="stack-head">Storage pools</h3>
|
||||
<table className="virt-table"><thead><tr><th>Pool</th><th>Driver</th><th>Source</th><th className="num">Used</th><th className="num">Of</th><th className="num">Users</th></tr></thead>
|
||||
<tbody>{(ov.pools ?? []).map((p) => <tr key={p.name}><td>{p.name}</td><td>{p.driver}</td><td className="mono small">{p.source}</td><td className="num">{p.total ? fmtBytes(p.used) : '—'}</td><td className="num">{p.total ? fmtBytes(p.total) : '—'}</td><td className="num">{p.used_by}</td></tr>)}</tbody></table>
|
||||
<h3 className="stack-head">Networks</h3>
|
||||
<table className="virt-table"><thead><tr><th>Network</th><th>Type</th><th>Address</th><th>NAT</th><th className="num">Users</th></tr></thead>
|
||||
<tbody>{(ov.networks ?? []).filter((n) => n.managed).map((n) => <tr key={n.name}><td>{n.name}</td><td>{n.type}</td><td className="mono small">{n.address ?? '—'}</td><td>{n.nat ? 'yes' : 'no'}</td><td className="num">{n.used_by}</td></tr>)}</tbody></table>
|
||||
<div className="muted small">Pick an instance on the left, or make one. Consoles open as tabs on the Terminal page.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- one instance ----
|
||||
|
||||
function Detail({ i, st, onBack, onChanged, onTask }: { i: VirtInstance; st: VirtStatus; onBack: () => void; onChanged: () => void; onTask: (t: TaskInfo) => void }) {
|
||||
const [tab, setTab] = useState<'overview' | 'snapshots' | 'mounts' | 'config'>('overview')
|
||||
const [vga, setVga] = useState(false)
|
||||
const running = i.status === 'Running'
|
||||
const isVM = i.type === 'virtual-machine'
|
||||
const stopped = i.status === 'Stopped'
|
||||
const act = (a: 'start' | 'stop' | 'restart' | 'kill') => async () => { try { await api.virtAction(i.name, a); toast(`${i.name}: ${a} ok`); onChanged() } catch (e) { toast(`${i.name}: ${errMsg(e)}`, 'err') } }
|
||||
const term = (k: 'console' | 'exec') => async () => {
|
||||
try { const t = await api.virtTerminal(i.name, k); toast(`${k === 'exec' ? 'shell' : 'console'} open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) }
|
||||
catch (e) { toast(errMsg(e), 'err') }
|
||||
}
|
||||
const rename = async () => { const to = await askText(`Rename ${i.name} to`, i.name, 'Rename'); if (!to || to === i.name) return; try { await api.virtRename(i.name, to); toast('renamed'); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||
const del = async () => {
|
||||
const typed = await askText(`Delete ${i.name}${running ? ', which is running' : ''}? Its disk and ${i.snapshots.length} snapshot${i.snapshots.length === 1 ? '' : 's'} go with it. Type its name to confirm.`, '', 'Delete')
|
||||
if (typed === null) return
|
||||
if (typed !== i.name) { toast('name did not match', 'err'); return }
|
||||
try { await api.virtDelete(i.name, running); toast(`${i.name} deleted`); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||
}
|
||||
const publish = async () => {
|
||||
const alias = await askText(`Publish ${i.name} as an image — an alias for it`, `${i.name}-image`, 'Publish')
|
||||
if (!alias) return
|
||||
try { onTask(await api.virtImagePublish(i.name, alias)) } catch (e) { toast(errMsg(e), 'err') }
|
||||
}
|
||||
const snap = async () => {
|
||||
const name = await askText(`Snapshot ${i.name} — a name, or blank for the date`, '', 'Snapshot')
|
||||
if (name === null) return
|
||||
try { const r = await api.virtSnapshot(i.name, name); toast(`snapshot ${r.name} taken`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||
}
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<CloseBtn onClick={onBack} />
|
||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(i)} /><span className="dname">{i.name}</span><span className="badge">{kind(i)}</span><span className={'badge' + (i.status === 'Error' ? ' bad' : '')}>{i.status.toLowerCase()}</span></div>
|
||||
<div className="props">
|
||||
<div><span className="k">Image</span><span>{i.image || (Object.keys(i.devices).some((d) => d.startsWith('iso')) ? 'none — installed from an ISO' : 'none')}{i.arch ? <span className="muted"> · {i.arch}</span> : ''}</span></div>
|
||||
{i.description && <div><span className="k">About</span><span>{i.description}</span></div>}
|
||||
{running && <div><span className="k">Using</span><span>{i.cpu_pct.toFixed(0)}% of a core · {fmtBytes(i.mem_usage)}{i.mem_peak ? ` (peak ${fmtBytes(i.mem_peak)})` : ''}{i.processes > 0 ? ` · ${i.processes} processes` : ''}</span></div>}
|
||||
<div><span className="k">Limits</span><span>{i.cpu ? `${i.cpu} CPU` : 'all CPUs'} · {i.memory || 'unlimited memory'}</span></div>
|
||||
{i.disk_total > 0 && <div><span className="k">Disk</span><span>{fmtBytes(i.disk_usage)} of {fmtBytes(i.disk_total)}</span></div>}
|
||||
<div><span className="k">Network</span><span className="mono small">{i.ipv4.length ? i.ipv4.join(' ') : running ? 'no address yet' : '—'}</span></div>
|
||||
<div><span className="k">Created</span><span>{when(i.created)}</span></div>
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
|
||||
{running && <Btn className="btn" onClick={term('exec')}>Shell</Btn>}
|
||||
<Btn className="btn" onClick={term('console')} title="the serial console: boot output, and a login prompt where the guest has one on it">Console</Btn>
|
||||
{isVM && running && <Btn className="btn" onClick={() => setVga(true)} title="the VM's screen, keyboard and mouse — what an installer needs">VGA</Btn>}
|
||||
{running && <Btn className="btn" confirm={`Restart ${i.name}?`} onClick={act('restart')}>Restart</Btn>}
|
||||
{running && <Btn className="btn danger" confirm={`Stop ${i.name}? It is asked to shut down cleanly.`} onClick={act('stop')}>Stop</Btn>}
|
||||
{running && <Btn className="btn danger" confirm={`Force stop ${i.name}? Like pulling the plug — anything unsaved inside is lost.`} onClick={act('kill')}>Force stop</Btn>}
|
||||
<Btn className="btn" onClick={snap}>Snapshot</Btn>
|
||||
<Btn className="btn" onClick={async () => { try { onTask(await api.virtExportsRun(i.name)) } catch (e) { toast(errMsg(e), 'err') } }} title="a tarball of it in the library's exports folder">Export</Btn>
|
||||
{stopped && <Btn className="btn" onClick={rename}>Rename</Btn>}
|
||||
{stopped && <Btn className="btn" onClick={publish} title="make an image of it, to launch more like it">Publish</Btn>}
|
||||
<Btn className="btn danger" onClick={del}>Delete…</Btn>
|
||||
</div>
|
||||
<div className="tabs">
|
||||
<button className={tab === 'overview' ? 'on' : ''} onClick={() => setTab('overview')}>Limits</button>
|
||||
<button className={tab === 'snapshots' ? 'on' : ''} onClick={() => setTab('snapshots')}>Snapshots · {i.snapshots.length}</button>
|
||||
<button className={tab === 'mounts' ? 'on' : ''} onClick={() => setTab('mounts')}>Disks · {Object.values(i.devices).filter((d) => d.type === 'disk').length}</button>
|
||||
<button className={tab === 'config' ? 'on' : ''} onClick={() => setTab('config')}>Config</button>
|
||||
</div>
|
||||
{tab === 'mounts' && <Mounts i={i} st={st} onChanged={onChanged} />}
|
||||
{vga && <VgaConsole name={i.name} onClose={() => setVga(false)} />}
|
||||
{tab === 'overview' && <Limits i={i} st={st} onChanged={onChanged} />}
|
||||
{tab === 'snapshots' && <Snapshots i={i} onChanged={onChanged} />}
|
||||
{tab === 'config' && (
|
||||
<>
|
||||
<div className="virt-kv">{Object.entries(i.config).sort().map(([k, v]) => <Fragment key={k}><span className="muted mono">{k}</span><span className="mono">{v}</span></Fragment>)}</div>
|
||||
<h3 className="stack-head">Devices</h3>
|
||||
<div className="virt-kv">{Object.entries(i.devices).sort().map(([d, cfg]) => <Fragment key={d}><span className="muted mono">{d}</span><span className="mono">{Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join(' ')}</span></Fragment>)}</div>
|
||||
<div className="muted small">Profiles: {i.profiles.join(', ') || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Limits({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) {
|
||||
const [cpu, setCpu] = useState(i.cpu ?? '')
|
||||
const [mem, setMem] = useState(i.memory ?? '')
|
||||
const [auto, setAuto] = useState(i.config['boot.autostart'] ?? '')
|
||||
const [desc, setDesc] = useState(i.description ?? '')
|
||||
useEffect(() => { setCpu(i.cpu ?? ''); setMem(i.memory ?? ''); setAuto(i.config['boot.autostart'] ?? ''); setDesc(i.description ?? '') }, [i.name, i.cpu, i.memory, i.description, i.config])
|
||||
const changed = cpu !== (i.cpu ?? '') || mem !== (i.memory ?? '') || auto !== (i.config['boot.autostart'] ?? '') || desc !== (i.description ?? '')
|
||||
const save = async () => { try { await api.virtConfig(i.name, { 'limits.cpu': cpu, 'limits.memory': mem, 'boot.autostart': auto, description: desc }); toast('saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="virt-form-row">
|
||||
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder="all" /></label>
|
||||
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder="unlimited · e.g. 2GiB" /></label>
|
||||
</div>
|
||||
<div className="virt-form-row">
|
||||
<label className="muted">Start with the machine
|
||||
<select value={auto} onChange={(e) => setAuto(e.target.value)}><option value="">default (as it was when the host went down)</option><option value="true">yes</option><option value="false">no</option></select>
|
||||
</label>
|
||||
<label className="muted">Description<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label>
|
||||
</div>
|
||||
<div className="muted small">{i.type === 'container' ? 'Containers take new limits live.' : 'A VM takes CPU and memory changes live where its agent is running, otherwise on its next start.'} {fmtBytes(st.mem_avail)} is free on this machine right now.</div>
|
||||
<div className="svc-actions"><Btn className="btn on" disabled={!changed} onClick={save}>Save</Btn></div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Snapshots({ i, onChanged }: { i: VirtInstance; onChanged: () => void }) {
|
||||
const [sched, setSched] = useState(i.schedule ?? '')
|
||||
const [exp, setExp] = useState(i.expiry ?? '')
|
||||
useEffect(() => { setSched(i.schedule ?? ''); setExp(i.expiry ?? '') }, [i.name, i.schedule, i.expiry])
|
||||
const saveSched = async () => { try { await api.virtConfig(i.name, { 'snapshots.schedule': sched.trim(), 'snapshots.expiry': exp.trim() }); toast('schedule saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||
const on = async (s: string, a: 'restore' | 'delete') => {
|
||||
const ok = await askYes(a === 'restore' ? `Restore ${i.name} to snapshot ${s}? Everything since it is lost${i.status === 'Running' ? '; it is stopped first' : ''}.` : `Delete snapshot ${s} of ${i.name}?`, a === 'restore' ? 'Restore' : 'Delete')
|
||||
if (!ok) return
|
||||
try { await api.virtSnapshotAction(i.name, s, a); toast(`${a} ${s}: ok`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{i.snapshots.length === 0 && <div className="muted small">No snapshots. “Snapshot” above takes one now; a schedule below keeps taking them.</div>}
|
||||
{i.snapshots.length > 0 && (
|
||||
<table className="virt-table"><thead><tr><th>Snapshot</th><th>Taken</th><th>Expires</th><th className="num">Size</th><th /></tr></thead>
|
||||
<tbody>{[...i.snapshots].sort((a, b) => b.created.localeCompare(a.created)).map((s) => (
|
||||
<tr key={s.name}><td className="mono">{s.name}</td><td>{when(s.created)}</td><td>{s.expires ? when(s.expires) : 'never'}</td><td className="num">{s.size ? fmtBytes(s.size) : '—'}</td>
|
||||
<td className="num"><Btn className="btn" onClick={() => on(s.name, 'restore')}>Restore</Btn> <Btn className="btn danger" onClick={() => on(s.name, 'delete')}>Delete</Btn></td></tr>
|
||||
))}</tbody></table>
|
||||
)}
|
||||
<section className="card">
|
||||
<div className="virt-form-row">
|
||||
<label className="muted">Schedule — @hourly, @daily, @weekly, or a cron line<input className="mono" value={sched} onChange={(e) => setSched(e.target.value)} placeholder="none" spellCheck={false} /></label>
|
||||
<label className="muted">Keep for — 1d, 2w, 3M…<input className="mono" value={exp} onChange={(e) => setExp(e.target.value)} placeholder="forever" spellCheck={false} /></label>
|
||||
</div>
|
||||
<div className="muted small">Incus takes and expires these itself, so they carry on whether or not this console is up.</div>
|
||||
<div className="svc-actions"><Btn className="btn on" disabled={sched === (i.schedule ?? '') && exp === (i.expiry ?? '')} onClick={saveSched}>Save schedule</Btn></div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- making one ----
|
||||
|
||||
function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; preset: { image?: string; vm?: boolean }; onClose: () => void; onStarted: (t: TaskInfo) => void }) {
|
||||
const [vm, setVm] = useState(!!preset.vm && st.kvm)
|
||||
const [image, setImage] = useState(preset.image ?? '')
|
||||
const [name, setName] = useState('')
|
||||
const [cpu, setCpu] = useState('')
|
||||
const [mem, setMem] = useState('')
|
||||
const [disk, setDisk] = useState('')
|
||||
const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge')
|
||||
const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '')
|
||||
const [cloud, setCloud] = useState('')
|
||||
const [showCloud, setShowCloud] = useState(false)
|
||||
const [desc, setDesc] = useState('')
|
||||
const [start, setStart] = useState(true)
|
||||
const [q, setQ] = useState('')
|
||||
const [remote, setRemote] = useState<VirtImage[] | null>(null)
|
||||
const [remoteErr, setRemoteErr] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const type = vm ? 'virtual-machine' : 'container'
|
||||
useEscape(onClose)
|
||||
useEffect(() => { setRemote(null); setRemoteErr(''); api.virtRemote(type).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type])
|
||||
const local = (ov.images ?? []).filter((im) => im.type === type)
|
||||
const picks = useMemo(() => {
|
||||
const f = q.trim().toLowerCase()
|
||||
const match = (im: VirtImage) => !f || `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release}`.toLowerCase().includes(f)
|
||||
const seen = new Set<string>()
|
||||
const out: { ref: string; label: string; sub: string; here: boolean }[] = []
|
||||
for (const im of local) { if (!match(im)) continue; const ref = imgLabel(im); if (seen.has(ref)) continue; seen.add(ref); out.push({ ref, label: ref, sub: `${im.description} · on this machine · ${fmtBytes(im.size)}`, here: true }) }
|
||||
for (const im of remote ?? []) {
|
||||
const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0]
|
||||
if (!a || !match(im)) continue
|
||||
const ref = `images:${a}`
|
||||
if (seen.has(ref)) continue
|
||||
seen.add(ref); out.push({ ref, label: a, sub: `${im.description}${im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · ${fmtBytes(im.size)}`, here: false })
|
||||
}
|
||||
return out.slice(0, 60)
|
||||
}, [q, local, remote])
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr(''); setBusy(true)
|
||||
try { onStarted(await api.virtCreate({ name: name.trim(), image: image.trim(), vm, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), network: net, cloud_init: showCloud ? cloud : '', profiles: [], description: desc.trim(), start })) }
|
||||
catch (e) { setErr(errMsg(e)); setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h3>New instance</h3>
|
||||
<div className="chips">
|
||||
<button type="button" className={'chip' + (!vm ? ' on' : '')} onClick={() => setVm(false)}>container</button>
|
||||
<button type="button" className={'chip' + (vm ? ' on' : '')} disabled={!st.kvm} title={st.kvm ? '' : 'no KVM on this machine'} onClick={() => setVm(true)}>virtual machine</button>
|
||||
</div>
|
||||
<label className="muted">Image — from this machine, or the public catalogue for {st.arch}
|
||||
<input className="mono" value={image} onChange={(e) => { setImage(e.target.value); setQ(e.target.value.replace(/^images:/, '')) }} placeholder="images:debian/13" spellCheck={false} autoCapitalize="off" autoCorrect="off" required />
|
||||
</label>
|
||||
<div className="virt-pick">
|
||||
{remote === null && !remoteErr && <div className="muted small" style={{ padding: 8 }}>Reading the catalogue…</div>}
|
||||
{remoteErr && <div className="error" style={{ margin: 8 }}>catalogue: {remoteErr}</div>}
|
||||
{picks.map((p) => (
|
||||
<div key={p.ref} className={'svc-row' + (image === p.ref ? ' active' : '')} onClick={() => setImage(p.ref)}>
|
||||
<div className="svc-name"><div className="n">{p.label}{p.here && <span className="badge">here</span>}</div><div className="d">{p.sub}</div></div>
|
||||
</div>
|
||||
))}
|
||||
{remote && picks.length === 0 && <div className="muted small" style={{ padding: 8 }}>Nothing in the catalogue matches.</div>}
|
||||
</div>
|
||||
<div className="virt-form-row">
|
||||
<label className="muted">Name<input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-zA-Z][a-zA-Z0-9\-]{0,62}" placeholder="web-1" spellCheck={false} autoCapitalize="off" autoCorrect="off" required /></label>
|
||||
<label className="muted">Network<select value={net} onChange={(e) => setNet(e.target.value)}>{nets.map((n) => <option key={n.name} value={n.name}>{n.name}{n.address ? ` · ${n.address}` : ''}</option>)}</select></label>
|
||||
</div>
|
||||
<div className="virt-form-row">
|
||||
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder={vm ? '2' : 'all'} /></label>
|
||||
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder={vm ? '2GiB' : 'unlimited'} /></label>
|
||||
<label className="muted">Root disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} placeholder={vm ? '20GiB' : 'pool default'} /></label>
|
||||
</div>
|
||||
<div className="muted small">{fmtBytes(st.mem_avail)} free on this machine.{vm ? ' A VM’s memory is a reservation.' : ''}</div>
|
||||
<label className="muted">Description — optional<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label>
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={showCloud} onChange={(e) => setShowCloud(e.target.checked)} style={{ width: 'auto' }} />cloud-init user-data (an SSH key, packages, a first command — needs a /cloud image)
|
||||
</label>
|
||||
{showCloud && <div className="svc-actions"><Btn className="btn" onClick={async () => { try { const k = await api.virtSSHKeys(); if (!k.keys.length) { toast('no public keys found for ' + k.user, 'info'); return } setCloud(`#cloud-config\nusers:\n - name: ${k.user}\n groups: [sudo]\n shell: /bin/bash\n sudo: ALL=(ALL) NOPASSWD:ALL\n ssh_authorized_keys:\n${k.keys.map((x) => ' - ' + x).join('\n')}\npackage_update: true\npackages: [curl, vim]\n`) } catch (e) { toast(errMsg(e), 'err') } }} title="a cloud-config that makes your account inside, with the public keys this machine's Ham Lab account holds">Use my SSH keys</Btn></div>}
|
||||
{showCloud && <textarea className="mono" rows={6} value={cloud} onChange={(e) => setCloud(e.target.value)} placeholder={'#cloud-config\nusers:\n - name: core\n ssh_authorized_keys:\n - ssh-ed25519 AAAA…\n sudo: ALL=(ALL) NOPASSWD:ALL\npackages: [curl]'} spellCheck={false} />}
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={start} onChange={(e) => setStart(e.target.checked)} style={{ width: 'auto' }} />Start it once made
|
||||
</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on" disabled={busy || !image.trim() || !name.trim()}>{busy ? 'Starting…' : start ? 'Create and start' : 'Create'}</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- images: the local store, the catalogue, and files brought in ----
|
||||
|
||||
function Images({ st, images, instances, onTask, onNew, onChanged }: { st: VirtStatus; images: VirtImage[]; instances: VirtInstance[]; onTask: (t: TaskInfo) => void; onNew: (image: string, vm: boolean) => void; onChanged: () => void }) {
|
||||
const [q, setQ] = useState('')
|
||||
const [type, setType] = useState<'container' | 'virtual-machine'>('container')
|
||||
const [remote, setRemote] = useState<VirtImage[] | null>(null)
|
||||
const [remoteErr, setRemoteErr] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
const [itype, setItype] = useState<'container' | 'virtual-machine'>('container')
|
||||
const loadRemote = useCallback((refresh = false) => { setRemote(null); setRemoteErr(''); api.virtRemote(type, refresh).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type])
|
||||
useEffect(() => { loadRemote() }, [loadRemote])
|
||||
const usedBy = (im: VirtImage) => instances.filter((i) => i.config['volatile.base_image'] === im.fingerprint || im.aliases.includes(i.image)).map((i) => i.name)
|
||||
const f = q.trim().toLowerCase()
|
||||
const found = (remote ?? []).filter((im) => f && `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release} ${im.variant}`.toLowerCase().includes(f)).slice(0, 40)
|
||||
const del = async (im: VirtImage) => {
|
||||
if (!(await askYes(`Delete the image ${imgLabel(im)} from this machine's store? Instances made from it are not affected; the file in the library, if any, stays.`, 'Delete'))) return
|
||||
try { await api.virtImageDelete(im.fingerprint); toast('image deleted'); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||
}
|
||||
const imp = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtImageImport(path.trim(), alias.trim(), itype)); setPath(''); setAlias('') } catch (er) { toast(errMsg(er), 'err') } }
|
||||
return (
|
||||
<div className="svc-rows" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dname">On this machine</span><span className="spacer" /><span className="muted small">{images.length} image{images.length === 1 ? '' : 's'} · {fmtBytes(images.reduce((a, im) => a + im.size, 0))}</span></div>
|
||||
{images.length === 0 && <div className="muted small">No images yet. Make an instance and its image is kept here; or download one below so the next one is instant.</div>}
|
||||
{images.length > 0 && (
|
||||
<table className="virt-table"><thead><tr><th>Image</th><th>Type</th><th>What</th><th className="num">Size</th><th>Used by</th><th /></tr></thead>
|
||||
<tbody>{images.map((im) => (
|
||||
<tr key={im.fingerprint}><td className="mono">{im.aliases.join(', ') || im.fingerprint.slice(0, 12)}</td><td>{im.type === 'virtual-machine' ? 'VM' : 'LXC'}</td><td>{im.description}{im.cached ? <span className="muted small"> · cached from {im.source}</span> : ''}</td><td className="num">{fmtBytes(im.size)}</td><td className="small">{usedBy(im).join(' ') || '—'}</td>
|
||||
<td className="num"><Btn className="btn" onClick={() => onNew(imgLabel(im), im.type === 'virtual-machine')}>New instance</Btn> <Btn className="btn danger" onClick={() => del(im)}>Delete</Btn></td></tr>
|
||||
))}</tbody></table>
|
||||
)}
|
||||
</section>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dname">Download from the catalogue</span><span className="spacer" />
|
||||
<div className="chips"><button className={'chip' + (type === 'container' ? ' on' : '')} onClick={() => setType('container')}>containers</button><button className={'chip' + (type === 'virtual-machine' ? ' on' : '')} disabled={!st.kvm} onClick={() => setType('virtual-machine')}>VMs</button></div>
|
||||
<Btn className="btn" onClick={() => loadRemote(true)} title="the catalogue is kept for an hour">Refresh</Btn></div>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`search images.linuxcontainers.org for ${st.arch} — debian, ubuntu 24.04, alpine…`} />
|
||||
{remote === null && !remoteErr && <div className="muted small">Reading the catalogue…</div>}
|
||||
{remoteErr && <div className="error">{remoteErr}</div>}
|
||||
{remote && !f && <div className="muted small">{remote.length} {type === 'virtual-machine' ? 'VM' : 'container'} images for {st.arch}. Type to search. A download keeps the image in the store and a copy of it in the library.</div>}
|
||||
{found.length > 0 && (
|
||||
<div className="virt-pick">{found.map((im) => { const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0]; return (
|
||||
<div key={im.fingerprint} className="svc-row" style={{ cursor: 'default' }}>
|
||||
<div className="svc-name"><div className="n">{a}</div><div className="d">{im.description}{im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · {fmtBytes(im.size)}</div></div>
|
||||
<Btn className="btn" onClick={async () => { try { onTask(await api.virtImageDownload(a)) } catch (e) { toast(errMsg(e), 'err') } }}>Download</Btn>
|
||||
</div>) })}</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dname">Import a file from this machine</span></div>
|
||||
<form onSubmit={imp} className="virt-form-row" style={{ flexWrap: 'wrap' }}>
|
||||
<label className="muted" style={{ flex: '2 1 240px' }}>Path — a template tarball, or a qcow2 / raw disk for a VM<input className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder="/path/to/image.tar.xz" spellCheck={false} required /></label>
|
||||
<label className="muted">Alias<input className="mono" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="my-template" spellCheck={false} required /></label>
|
||||
<label className="muted">Type<select value={itype} onChange={(e) => setItype(e.target.value as 'container' | 'virtual-machine')}><option value="container">container template</option><option value="virtual-machine" disabled={!st.kvm}>VM disk image</option></select></label>
|
||||
<div className="svc-actions" style={{ alignSelf: 'flex-end' }}><button className="btn on" disabled={!path.trim() || !alias.trim()}>Import</button></div>
|
||||
</form>
|
||||
<div className="muted small">The file is copied into <span className="mono">{st.dir}/images/</span> first — the library is Ham Lab's — then loaded into Incus.</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Mounts({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) {
|
||||
const [src, setSrc] = useState('')
|
||||
const [dst, setDst] = useState('')
|
||||
const [dev, setDev] = useState('')
|
||||
const [ro, setRo] = useState(false)
|
||||
const [shift, setShift] = useState(true)
|
||||
const isos = st.dir + '/isos/'
|
||||
const disks = Object.entries(i.devices).filter(([, d]) => d.type === 'disk').sort()
|
||||
const add = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try { const r = await api.virtMountAdd(i.name, { device: dev.trim(), source: src.trim(), path: dst.trim(), readonly: ro, shift }); toast(`mounted as ${r.device}`); setSrc(''); setDst(''); setDev(''); onChanged() } catch (er) { toast(errMsg(er), 'err') }
|
||||
}
|
||||
const remove = async (d: string, iso: boolean) => {
|
||||
if (!(await askYes(iso ? `Eject ${d} from ${i.name}?` : `Remove the mount ${d} from ${i.name}? Nothing on the host is touched.`, iso ? 'Eject' : 'Remove'))) return
|
||||
try { if (iso) await api.virtISODetach(i.name, d); else await api.virtMountRemove(i.name, d); toast('done'); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<table className="virt-table"><thead><tr><th>Device</th><th>Source</th><th>Inside</th><th /></tr></thead>
|
||||
<tbody>{disks.map(([name, d]) => { const iso = (d.source ?? '').startsWith(isos); return (
|
||||
<tr key={name}><td className="mono">{name}{iso && <span className="badge">ISO</span>}{d['boot.priority'] && <span className="badge">boots first</span>}</td>
|
||||
<td className="mono small">{name === 'root' ? `pool ${d.pool}${d.size ? ` · ${d.size}` : ''}` : iso ? (d.source ?? '').slice(isos.length) : d.source}</td>
|
||||
<td className="mono small">{iso ? 'CD-ROM' : d.path}{d.readonly === 'true' ? ' · read only' : ''}{d.shift === 'true' ? ' · shifted' : ''}</td>
|
||||
<td className="num">{name !== 'root' && <Btn className="btn danger" onClick={() => remove(name, iso)}>{iso ? 'Eject' : 'Remove'}</Btn>}</td></tr>) })}</tbody></table>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dname">Mount a folder from this machine</span></div>
|
||||
<form onSubmit={add}>
|
||||
<div className="virt-form-row">
|
||||
<label className="muted">Host path<input className="mono" value={src} onChange={(e) => setSrc(e.target.value)} placeholder="/tank/media" spellCheck={false} required /></label>
|
||||
<label className="muted">Path inside<input className="mono" value={dst} onChange={(e) => setDst(e.target.value)} placeholder="/mnt/media" spellCheck={false} required /></label>
|
||||
<label className="muted">Device name — optional<input className="mono" value={dev} onChange={(e) => setDev(e.target.value)} placeholder="mnt-media" spellCheck={false} /></label>
|
||||
</div>
|
||||
<div className="virt-form-row" style={{ alignItems: 'center' }}>
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={ro} onChange={(e) => setRo(e.target.checked)} style={{ width: 'auto' }} />read only</label>
|
||||
{i.type === 'container' && <label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }} title="map the host's file ownership into the container's user range, so files look owned by the same names inside"><input type="checkbox" checked={shift} onChange={(e) => setShift(e.target.checked)} style={{ width: 'auto' }} />shift ownership</label>}
|
||||
<span className="spacer" />
|
||||
<button className="btn on" disabled={!src.trim() || !dst.trim()}>Mount</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="muted small">{i.type === 'container' ? 'A bind mount; it appears at once, running or not.' : 'Shared into the VM over virtiofs; its agent mounts it. Appears at once on a running VM with the agent, otherwise at the next start.'} The data stays where it is — this is the instance’s device, not a copy.</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NewVMFromISO({ st, ov, iso, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; iso: string; onClose: () => void; onStarted: (t: TaskInfo) => void }) {
|
||||
const isos = ov.library.isos
|
||||
const [pick, setPick] = useState(iso || isos[0]?.name || '')
|
||||
const [name, setName] = useState('')
|
||||
const [cpu, setCpu] = useState('2')
|
||||
const [mem, setMem] = useState('2GiB')
|
||||
const [disk, setDisk] = useState('20GiB')
|
||||
const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge')
|
||||
const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '')
|
||||
const [sb, setSb] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEscape(onClose)
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr(''); setBusy(true)
|
||||
try { onStarted(await api.virtFromISO({ name: name.trim(), iso: pick, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), network: net, secureboot: sb, start: true })) }
|
||||
catch (e) { setErr(errMsg(e)); setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h3>New VM from an ISO</h3>
|
||||
<label className="muted">ISO<select value={pick} onChange={(e) => setPick(e.target.value)}>{isos.map((f) => <option key={f.name} value={f.name}>{f.name} · {fmtBytes(f.size)}</option>)}</select></label>
|
||||
<div className="virt-form-row">
|
||||
<label className="muted">Name<input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-zA-Z][a-zA-Z0-9\-]{0,62}" placeholder="win11" spellCheck={false} autoCapitalize="off" autoCorrect="off" required /></label>
|
||||
<label className="muted">Network<select value={net} onChange={(e) => setNet(e.target.value)}>{nets.map((n) => <option key={n.name} value={n.name}>{n.name}{n.address ? ` · ${n.address}` : ''}</option>)}</select></label>
|
||||
</div>
|
||||
<div className="virt-form-row">
|
||||
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} /></label>
|
||||
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} /></label>
|
||||
<label className="muted">Disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} /></label>
|
||||
</div>
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={sb} onChange={(e) => setSb(e.target.checked)} style={{ width: 'auto' }} />Secure Boot — Windows, Debian, Ubuntu and Fedora boot with it; Alpine, Arch, TrueNAS and most others need it off
|
||||
</label>
|
||||
<div className="muted small">An empty VM with the ISO as its first boot device, started, so the installer is on the VGA console. {fmtBytes(st.mem_avail)} free here; a VM’s memory is a reservation. Windows also wants the virtio drivers ISO attached as a second drive — add it from the VM’s Disks tab.</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on" disabled={busy || !pick || !name.trim()}>{busy ? 'Starting…' : 'Create and boot the ISO'}</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Library({ st, ov, onTask, onChanged, onNewVM }: { st: VirtStatus; ov: VirtOverview; onTask: (t: TaskInfo) => void; onChanged: () => void; onNewVM: (iso: string) => void }) {
|
||||
const lib = ov.library
|
||||
const vms = (ov.instances ?? []).filter((i) => i.type === 'virtual-machine')
|
||||
const [url, setUrl] = useState(''); const [sha, setSha] = useState(''); const [path, setPath] = useState('')
|
||||
const [upload, setUpload] = useState<{ name: string; pct: number } | null>(null)
|
||||
const [exp, setExp] = useState<ExportsView | null>(null)
|
||||
const [cfg, setCfg] = useState<ExportsView['config'] | null>(null)
|
||||
const loadExports = useCallback(() => api.virtExports().then((v) => { setExp(v); setCfg(v.config) }).catch(() => setExp(null)), [])
|
||||
useEffect(() => { loadExports() }, [loadExports])
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return
|
||||
for (const f of Array.from(files)) {
|
||||
if (!/\.(iso|img)$/i.test(f.name)) { toast(`${f.name}: only .iso or .img files`, 'err'); continue }
|
||||
setUpload({ name: f.name, pct: 0 })
|
||||
try { await api.fileUpload(st.dir + '/isos', f, false, (pct) => setUpload({ name: f.name, pct })); toast(`uploaded ${f.name}`) } catch (e) { toast(`${f.name}: ${errMsg(e)}`, 'err') }
|
||||
}
|
||||
setUpload(null); onChanged()
|
||||
}
|
||||
const fetchISO = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtISOFetch(url.trim(), sha.trim(), '')); setUrl(''); setSha('') } catch (er) { toast(errMsg(er), 'err') } }
|
||||
const importISO = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtISOImport(path.trim())); setPath('') } catch (er) { toast(errMsg(er), 'err') } }
|
||||
const attach = async (f: LibraryFile) => {
|
||||
if (vms.length === 0) { toast('no VM to attach it to — make one first, or use New VM from ISO', 'info'); return }
|
||||
const vm = vms.length === 1 ? vms[0].name : await askText(`Attach ${f.name} to which VM? (${vms.map((v) => v.name).join(', ')})`, vms[0].name, 'Attach')
|
||||
if (!vm) return
|
||||
const first = await askYes(`Boot ${vm} from ${f.name} first, ahead of its disk? Say no to just make it available as a CD-ROM.`, 'Boot first')
|
||||
try { const r = await api.virtISOAttach(vm, f.name, first); toast(`${f.name} in ${vm} as ${r.device}`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||
}
|
||||
const del = async (f: LibraryFile) => { if (!(await askYes(`Delete ${f.name} from the library?`, 'Delete'))) return; try { await api.virtISODelete(f.name); toast('deleted'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||
const rename = async (f: LibraryFile) => { const to = await askText(`Rename ${f.name} to`, f.name, 'Rename'); if (!to || to === f.name) return; try { await api.virtISORename(f.name, to); toast('renamed'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||
const saveExports = async () => { if (!cfg) return; try { await api.virtExportsPut(cfg); toast(cfg.enabled ? 'exports scheduled' : 'exports paused'); loadExports() } catch (e) { toast(errMsg(e), 'err') } }
|
||||
const delExport = async (inst: string, name: string) => { if (!(await askYes(`Delete the export ${name}?`, 'Delete'))) return; try { await api.virtExportDelete(inst, name); loadExports() } catch (e) { toast(errMsg(e), 'err') } }
|
||||
const table = (files: LibraryFile[]) => files.length === 0 ? <div className="muted small">empty</div> : (
|
||||
<table className="virt-table"><thead><tr><th>File</th><th className="num">Size</th><th>Changed</th></tr></thead>
|
||||
<tbody>{files.map((f) => <tr key={f.name}><td className="mono">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td>{when(f.modified)}</td></tr>)}</tbody></table>
|
||||
)
|
||||
return (
|
||||
<div className="svc-rows" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dname">ISOs</span><span className="spacer" /><span className="muted small mono">{st.dir}/isos</span></div>
|
||||
{lib.isos.length === 0 && <div className="muted small">No ISOs yet. Upload one, fetch one by URL, or import a file already on this machine.</div>}
|
||||
{lib.isos.length > 0 && (
|
||||
<table className="virt-table"><thead><tr><th>File</th><th className="num">Size</th><th>In</th><th /></tr></thead>
|
||||
<tbody>{lib.isos.map((f) => (
|
||||
<tr key={f.name}><td className="mono">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td className="small">{f.attached_to?.join(' ') || '—'}</td>
|
||||
<td className="num" style={{ whiteSpace: 'normal' }}>
|
||||
{st.kvm && <Btn className="btn on" onClick={() => onNewVM(f.name)}>New VM</Btn>} {st.kvm && <Btn className="btn" onClick={() => attach(f)}>Attach…</Btn>} <Btn className="btn" onClick={() => rename(f)}>Rename</Btn> <Btn className="btn danger" disabled={!!f.attached_to?.length} title={f.attached_to?.length ? 'attached — eject it first' : ''} onClick={() => del(f)}>Delete</Btn>
|
||||
</td></tr>
|
||||
))}</tbody></table>
|
||||
)}
|
||||
<div className="virt-form-row" style={{ flexWrap: 'wrap', marginTop: 8 }}>
|
||||
<label className="btn" style={{ alignSelf: 'flex-end' }}>{upload ? `${upload.name} · ${upload.pct}%` : 'Upload…'}<input type="file" accept=".iso,.img" multiple style={{ display: 'none' }} onChange={(e) => uploadFiles(e.target.files)} /></label>
|
||||
<form onSubmit={fetchISO} className="virt-form-row" style={{ flex: '2 1 320px' }}>
|
||||
<label className="muted" style={{ flex: 3 }}>Fetch from a URL<input className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…/something.iso" spellCheck={false} /></label>
|
||||
<label className="muted" style={{ flex: 2 }}>sha256 — optional<input className="mono" value={sha} onChange={(e) => setSha(e.target.value)} spellCheck={false} /></label>
|
||||
<button className="btn" style={{ alignSelf: 'flex-end' }} disabled={!url.trim()}>Fetch</button>
|
||||
</form>
|
||||
<form onSubmit={importISO} className="virt-form-row" style={{ flex: '1 1 260px' }}>
|
||||
<label className="muted" style={{ flex: 1 }}>Import a file on this machine — copied in<input className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder="/tank/software/isos/…/x.iso" spellCheck={false} /></label>
|
||||
<button className="btn" style={{ alignSelf: 'flex-end' }} disabled={!path.trim()}>Import</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="muted small">{st.kvm ? 'An ISO attaches to a VM as a CD-ROM, live or stopped; “New VM” makes an empty VM that boots it, for an installer on the VGA console.' : 'This machine has no KVM, so there is nothing here to boot an ISO.'}</div>
|
||||
</section>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dname">Exports</span><span className="spacer" /><span className="muted small mono">{st.dir}/exports</span></div>
|
||||
{cfg && (
|
||||
<div className="virt-form-row" style={{ flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: '0 0 auto' }}><input type="checkbox" checked={cfg.enabled} onChange={(e) => setCfg({ ...cfg, enabled: e.target.checked })} style={{ width: 'auto' }} />nightly</label>
|
||||
<label className="muted" style={{ flex: '0 0 90px' }}>at<input className="mono" type="time" value={`${String(cfg.hour).padStart(2, '0')}:${String(cfg.minute).padStart(2, '0')}`} onChange={(e) => { const [h, m] = e.target.value.split(':').map(Number); if (!isNaN(h) && !isNaN(m)) setCfg({ ...cfg, hour: h, minute: m }) }} /></label>
|
||||
<label className="muted" style={{ flex: '0 0 110px' }}>keep per instance<input className="mono" type="number" min={1} max={100} value={cfg.keep} onChange={(e) => setCfg({ ...cfg, keep: Number(e.target.value) || 1 })} /></label>
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: '0 0 auto' }}><input type="checkbox" checked={!cfg.instance_only} onChange={(e) => setCfg({ ...cfg, instance_only: !e.target.checked })} style={{ width: 'auto' }} />include snapshots</label>
|
||||
<span className="spacer" />
|
||||
<Btn className="btn on" disabled={JSON.stringify(cfg) === JSON.stringify(exp?.config)} onClick={saveExports}>Save</Btn>
|
||||
<Btn className="btn" onClick={async () => { try { onTask(await api.virtExportsRun()) } catch (e) { toast(errMsg(e), 'err') } }}>Export all now</Btn>
|
||||
</div>
|
||||
)}
|
||||
{exp && <div className="muted small" style={{ marginTop: 6 }}>{exp.config.enabled ? <>Cron line in <span className="mono">{exp.cron_file}</span>, next {exp.next ? new Date(exp.next).toLocaleString() : '—'}.</> : 'Not scheduled.'} {exp.last ? <>Last run {when(exp.last.at)}: {exp.last.exported.length} exported, {exp.last.pruned.length} pruned{exp.last.errors?.length ? <span className="warn-text">, {exp.last.errors.length} failed: {exp.last.errors.join('; ')}</span> : ''}.</> : 'Never run.'} Every instance as a tarball Incus can import again; the folder is backed up with the rest of Ham Lab’s data.</div>}
|
||||
{exp && exp.files.length > 0 && (
|
||||
<table className="virt-table" style={{ marginTop: 8 }}><thead><tr><th>Instance</th><th>Export</th><th className="num">Size</th><th>Taken</th><th /></tr></thead>
|
||||
<tbody>{exp.files.map((f) => <tr key={f.instance + f.name}><td>{f.instance}</td><td className="mono small">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td>{when(f.modified)}</td><td className="num"><Btn className="btn danger" onClick={() => delExport(f.instance, f.name)}>Delete</Btn></td></tr>)}</tbody></table>
|
||||
)}
|
||||
</section>
|
||||
<section className="card"><div className="sub-head"><span className="dname">Images</span><span className="spacer" /><span className="muted small mono">{st.dir}/images</span></div>{table(lib.images)}
|
||||
<div className="muted small">Every image downloaded, imported or published lands here as a file, so the library is whole without Incus and can be reloaded into a fresh install.</div></section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
// 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, Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ARCView, CacheDev, PoolCaching, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { fetchHistories } from './sensors'
|
||||
import Sparkline from './Sparkline'
|
||||
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() {
|
||||
// zfSnap's name is the machine's local time, and snapMeta reads it back as local time — so the
|
||||
// default is built from local fields, not toISOString(), which is UTC and read seven hours wrong.
|
||||
const n = new Date(), two = (x: number) => String(x).padStart(2, '0')
|
||||
const def = `${n.getFullYear()}-${two(n.getMonth() + 1)}-${two(n.getDate())}_${two(n.getHours())}.${two(n.getMinutes())}.${two(n.getSeconds())}`
|
||||
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} {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-[a-zA-Z]*r\b/.test(cmd), // -r, or -r folded into another flag group
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the cache: the ARC in RAM, and what each pool has in front of its disks ----
|
||||
//
|
||||
// Two kinds of number. The live ones (size, the hit rate over the last sampling interval)
|
||||
// come from the sampler, which also keeps their history for the sparklines; the since-boot
|
||||
// counters, the tunables and the pool layout come with the same request from the storage
|
||||
// package. A tier a pool does not have is named and said to be absent, with what that means,
|
||||
// because "nothing shown" and "none fitted" are different answers.
|
||||
|
||||
const hitPct = (h: number, m: number) => (h + m > 0 ? (h / (h + m)) * 100 : NaN)
|
||||
const fmtPct = (v: number) => (Number.isNaN(v) ? '—' : `${v.toFixed(1)}%`)
|
||||
const fmtN = (n: number) => n.toLocaleString()
|
||||
const cssVar = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || '#3987e5'
|
||||
|
||||
function Tier({ name, what, devs, none, note }: { name: string; what: string; devs: CacheDev[]; none: string; note?: string }) {
|
||||
return (
|
||||
<div className="arc-tier">
|
||||
<span className="k" title={what}>{name}</span>
|
||||
<span className="arc-tier-v">
|
||||
{devs.length === 0 ? <span className="none">{none}</span> : devs.map((x) => (
|
||||
<span key={x.name} className="mono" title={x.name}>
|
||||
{x.name}{x.size ? ` · ${fmtBytes(x.alloc ?? 0)} of ${fmtBytes(x.size)} used` : ''}{x.health && x.health !== 'ONLINE' ? ` · ${x.health}` : ''}
|
||||
</span>
|
||||
))}
|
||||
{devs.length > 0 && note && <span className="muted small">{note}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PoolTiers({ p, v }: { p: PoolCaching; v: ARCView }) {
|
||||
const L = v.detail.lifetime, z = v.detail.zil, a = v.live
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className="dname">{p.pool}</span>
|
||||
<span className="badge">caching tiers</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted small mono" title="primarycache: what the ARC may hold for this pool · secondarycache: what an L2ARC may hold">primarycache={p.primarycache || '?'} · secondarycache={p.secondarycache || '?'}</span>
|
||||
</div>
|
||||
<div className="arc-tiers">
|
||||
<Tier name="L2ARC" what="cache devices: a second-level read cache on flash, fed with what the ARC evicts" devs={p.cache}
|
||||
none={`none — a read that misses RAM goes to the pool's disks. ${fmtBytes(L.EvictL2Eligible)} evicted from RAM since boot would have been eligible for one.`}
|
||||
note={a ? `${fmtBytes(a.l2_size)} cached (${fmtBytes(a.l2_asize)} on device) · ${fmtPct(hitPct(L.L2Hits, L.L2Misses))} hit since boot · ${fmtBytes(L.L2ReadBytes)} read, ${fmtBytes(L.L2WriteBytes)} written` : undefined} />
|
||||
<Tier name="SLOG" what="log devices: where synchronous writes are committed before they reach the pool" devs={p.log}
|
||||
none={`none — the intent log is on the pool's own vdevs: ${fmtN(z.commits)} commits, ${fmtBytes(z.NormalWrite)} written to it since boot`}
|
||||
note={`${fmtN(z.commits)} commits · ${fmtBytes(z.SlogWrite)} written to the log device since boot${z.NormalWrite ? `, ${fmtBytes(z.NormalWrite)} to the pool` : ''}`} />
|
||||
<Tier name="Special" what="special allocation class: metadata and small blocks kept on their own (faster) vdev" devs={p.special}
|
||||
none="none — metadata lives with the data on the same vdevs" />
|
||||
<Tier name="Dedup" what="deduplication: a table of every block's checksum, in the ARC and on disk" devs={p.dedup}
|
||||
none={!p.dedup_prop || p.dedup_prop === 'off' ? `off · ratio ${p.dedup_ratio || '1.00x'}` : `${p.dedup_prop} · ratio ${p.dedup_ratio} · the table lives in the ARC and on the data vdevs`} />
|
||||
{p.spare.length > 0 && <Tier name="Spares" what="hot spares: take over when a vdev member fails" devs={p.spare} none="" />}
|
||||
</div>
|
||||
<div className="muted small mono" style={{ marginTop: 8 }}>sync={p.sync || '?'} · logbias={p.logbias || '?'} · compression={p.compression || '?'} · recordsize={p.recordsize || '?'}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ARCSection() {
|
||||
const [v, setV] = useState<ARCView | null>(null)
|
||||
const [hist, setHist] = useState<{ size: number[]; hit: number[] }>({ size: [], hit: [] })
|
||||
const [err, setErr] = useState('')
|
||||
const rate = useRefreshMs()
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
const poll = async () => {
|
||||
try { const r = await api.zfsARC(); if (!stop) { setV(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
|
||||
try {
|
||||
const hs = await fetchHistories('zfs.arc.size,zfs.arc.hit_pct')
|
||||
if (!stop) setHist({ size: hs.find((h) => h.id === 'zfs.arc.size')?.v ?? [], hit: hs.find((h) => h.id === 'zfs.arc.hit_pct')?.v ?? [] })
|
||||
} catch { /* the sparklines stay empty; the numbers still show */ }
|
||||
}
|
||||
poll()
|
||||
const t = setInterval(poll, Math.max(2000, rate))
|
||||
return () => { stop = true; clearInterval(t) }
|
||||
}, [rate])
|
||||
|
||||
if (err && !v) return <div className="error">{err}</div>
|
||||
if (!v) return <div className="muted"><Spinner /> reading the cache…</div>
|
||||
const d = v.detail, a = v.live
|
||||
if (!d.available || !a) return <div className="notice">The ZFS module reports no ARC statistics on this machine.</div>
|
||||
|
||||
const L = d.lifetime
|
||||
const ratio = a.compressed > 0 ? a.uncompressed / a.compressed : 0
|
||||
const maxSet = d.tunables.zfs_arc_max && d.tunables.zfs_arc_max !== '0'
|
||||
const atFloor = a.target <= a.min * 1.02
|
||||
const rows: [string, number, number][] = [
|
||||
['Demand data', L.DemandDataHits, L.DemandDataMisses], ['Demand metadata', L.DemandMetaHits, L.DemandMetaMisses],
|
||||
['Prefetch data', L.PrefetchDataHits, L.PrefetchDataMisses], ['Prefetch metadata', L.PrefetchMetaHits, L.PrefetchMetaMisses],
|
||||
]
|
||||
return (
|
||||
<>
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (a.no_grow ? 'warn' : 'ok')} />
|
||||
<span className="dname">ARC</span>
|
||||
<span className="badge">in RAM</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{fmtBytes(a.size)} / {fmtBytes(a.max)} · {Math.round((a.size / a.max) * 100)}%</span>
|
||||
</div>
|
||||
<Meter frac={a.size / a.max} cls="" />
|
||||
{a.no_grow && (
|
||||
<div className="notice attn" style={{ marginTop: 8 }}>
|
||||
<b>Held by memory pressure.</b> ZFS is not letting the cache grow (<span className="mono">arc_no_grow</span>): the system is short of free memory
|
||||
{atFloor ? <>, and the target sits at its floor of <b>{fmtBytes(a.min)}</b> against a ceiling of {fmtBytes(a.max)}</> : ''}.
|
||||
{' '}Whatever no longer fits is read from the disks.
|
||||
</div>
|
||||
)}
|
||||
<div className="arc-sparks">
|
||||
<div>
|
||||
<div className="muted small">Size · ceiling {fmtBytes(a.max)}</div>
|
||||
<Sparkline series={[{ name: 'size', values: hist.size, color: cssVar('--s1') }]} max={a.max} format={fmtBytes} height={48} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="muted small">Hit rate · {a.hit_pct_known ? `${a.hit_pct.toFixed(1)}% now` : 'no reads yet'} · {fmtN(Math.round(a.reads_per_sec))} reads/s</div>
|
||||
<Sparkline series={[{ name: 'hit rate', values: hist.hit, color: cssVar('--ok') }]} max={100} format={(x) => `${x.toFixed(1)}%`} height={48} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="props" style={{ marginTop: 8 }}>
|
||||
<div><span className="k">Target</span><span title="c: what the ARC may hold right now, between its floor (c_min) and ceiling (c_max)">{fmtBytes(a.target)} · floor {fmtBytes(a.min)} · ceiling {fmtBytes(a.max)} {maxSet ? '(zfs_arc_max)' : '(default: half of RAM)'}</span></div>
|
||||
<div><span className="k">Hit rate</span><span>{a.hit_pct_known ? `${a.hit_pct.toFixed(1)}% now` : 'idle'} · {a.lifetime_hit_pct.toFixed(1)}% since boot · {fmtN(a.hits)} hits, {fmtN(a.misses)} misses</span></div>
|
||||
<div><span className="k">Holds</span><span title="MRU: seen once · MFU: seen again · dirty: written, not yet on disk">MRU {fmtBytes(a.mru)} · MFU {fmtBytes(a.mfu)} · dirty {fmtBytes(a.anon)}</span></div>
|
||||
<div><span className="k">Of which</span><span>data {fmtBytes(a.data)} · metadata {fmtBytes(a.metadata)} · headers {fmtBytes(a.headers)}</span></div>
|
||||
<div><span className="k">Compressed</span><span>{ratio > 0 ? `${fmtBytes(a.uncompressed)} held in ${fmtBytes(a.compressed)} · ${ratio.toFixed(2)}x` : 'not compressed'}</span></div>
|
||||
<div><span className="k">Ghosts</span><span title="what was evicted recently and is still remembered, so a re-read can grow the right list">MRU {fmtBytes(a.mru_ghost)} · MFU {fmtBytes(a.mfu_ghost)}</span></div>
|
||||
<div><span className="k">Prefetch</span><span title="zfetch: the read-ahead that guesses the next blocks of a sequential read">{d.tunables.zfs_prefetch_disable === '1' ? 'disabled (zfs_prefetch_disable)' : `${fmtPct(hitPct(d.zfetch.Hits, d.zfetch.Misses))} of ${fmtN(d.zfetch.Hits + d.zfetch.Misses)} predictions since boot`}</span></div>
|
||||
<div><span className="k">dbuf cache</span><span title="the small cache of decompressed buffers in front of the ARC">{fmtBytes(d.dbuf.Size)} of {fmtBytes(d.dbuf.Target)} · {fmtPct(hitPct(d.dbuf.Hits, d.dbuf.Misses))} hits</span></div>
|
||||
<div><span className="k">Pressure</span><span title="since boot: throttles are allocations ZFS made wait for memory; prunes are metadata evictions asked of the filesystems">{fmtN(a.throttles)} throttles · {fmtN(a.prunes)} prunes · {fmtN(a.evict_skip)} evict skips</span></div>
|
||||
</div>
|
||||
<div className="arc-break">
|
||||
<span className="head">Since boot</span><span className="head num">hit rate</span><span className="head num">hits</span><span className="head num">misses</span>
|
||||
{rows.map(([k, h, m]) => <Fragment key={k}><span>{k}</span><span className="num">{fmtPct(hitPct(h, m))}</span><span className="num">{fmtN(h)}</span><span className="num">{fmtN(m)}</span></Fragment>)}
|
||||
<span><b>All reads</b></span><span className="num"><b>{fmtPct(hitPct(L.Hits, L.Misses))}</b></span><span className="num">{fmtN(L.Hits)}</span><span className="num">{fmtN(L.Misses)}</span>
|
||||
</div>
|
||||
</section>
|
||||
{d.pools.length > 0 && <div className="cards" style={{ marginTop: 8 }}>{d.pools.map((p) => <PoolTiers key={p.pool} p={p} v={v} />)}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 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">Cache</h2><span className="spacer" />
|
||||
<span className="muted small">the ARC in RAM, and what each pool has in front of its disks</span>
|
||||
</div>
|
||||
<ARCSection />
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
+206
-7
@@ -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 Me { 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 }
|
||||
// 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
|
||||
@@ -72,9 +72,20 @@ export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: num
|
||||
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; 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 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; gpu?: 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 ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; uptime: number; procs: Proc[]; total: number; running: number }
|
||||
// A graphics device: usage_src says what measures it ("idle" residency, the driver's own "busy", or
|
||||
// the busiest "clients" engine) and is absent when nothing does; temp_src is "gpu" for the device's own
|
||||
// sensor or "package" for the CPU die an integrated GPU shares.
|
||||
export interface GPUClient { pid: number; name: string; usage: number; engines?: Record<string, number>; mem?: number }
|
||||
export interface GPU {
|
||||
name: string; driver: string; bus?: string; integrated?: boolean
|
||||
usage: number; usage_src?: 'idle' | 'busy' | 'clients'; engines?: Record<string, number>
|
||||
temp?: number; temp_src?: 'gpu' | 'package'; power_w?: number; power_cap_w?: number; fan_rpm?: number
|
||||
mhz?: number; req_mhz?: number; max_mhz?: number; mem_used?: number; mem_total?: number
|
||||
clients: GPUClient[]; clients_ok: boolean; stalled?: boolean
|
||||
}
|
||||
export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number }
|
||||
export interface Sample {
|
||||
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
|
||||
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
||||
@@ -109,9 +120,42 @@ 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 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 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 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 Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; editable: boolean; note?: string; has_env: boolean }
|
||||
export interface StackHome { dir: string; inner: string; via: string; stacks: number; default: boolean }
|
||||
export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string }
|
||||
export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: string }
|
||||
export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string }
|
||||
@@ -125,6 +169,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 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 }
|
||||
// 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 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> }
|
||||
@@ -133,11 +180,98 @@ 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 }
|
||||
|
||||
// ---- 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
|
||||
// The configuration section holding it, and the id anything that edits it uses. Absent when
|
||||
// the peer is not in a configuration Ham Lab can edit.
|
||||
section?: string
|
||||
}
|
||||
export interface VPNTunnel {
|
||||
name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number
|
||||
fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: string
|
||||
// Whether devices on this tunnel can be added and removed from here.
|
||||
managed?: boolean
|
||||
}
|
||||
export interface NewVPNPeer { name: string; address?: string; endpoint: string; dns?: string; split?: boolean }
|
||||
// A device just made, and the one chance to read its profile: the private key in it is
|
||||
// generated for that call and kept nowhere.
|
||||
export interface CreatedVPNPeer { section: string; name: string; public_key: string; address: string; config: 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 }
|
||||
// The ARC as the sampler sees it (sizes in bytes; hit_pct over the last sampling interval).
|
||||
export interface ARCLive {
|
||||
size: number; target: number; min: number; max: number
|
||||
mru: number; mfu: number; anon: number; data: number; metadata: number; meta_used: number; headers: number
|
||||
compressed: number; uncompressed: number; mru_ghost: number; mfu_ghost: number; l2_size: number; l2_asize: number
|
||||
reads_per_sec: number; misses_per_sec: number; hit_pct: number; hit_pct_known: boolean; l2_hit_pct: number
|
||||
hits: number; misses: number; lifetime_hit_pct: number; no_grow: boolean; throttles: number; prunes: number; evict_skip: number
|
||||
}
|
||||
export interface CacheDev { name: string; size?: number; alloc?: number; health?: string }
|
||||
export interface PoolCaching {
|
||||
pool: string; cache: CacheDev[]; log: CacheDev[]; special: CacheDev[]; dedup: CacheDev[]; spare: CacheDev[]
|
||||
primarycache: string; secondarycache: string; sync: string; logbias: string; dedup_prop: string; compression: string; recordsize: string; dedup_ratio: string
|
||||
}
|
||||
export interface ARCDetail {
|
||||
available: boolean; mem_total: number; tunables: Record<string, string>
|
||||
lifetime: { Hits: number; Misses: number; DemandDataHits: number; DemandDataMisses: number; DemandMetaHits: number; DemandMetaMisses: number; PrefetchDataHits: number; PrefetchDataMisses: number; PrefetchMetaHits: number; PrefetchMetaMisses: number; L2Hits: number; L2Misses: number; L2ReadBytes: number; L2WriteBytes: number; L2Feeds: number; L2RebuildSize: number; EvictL2Eligible: number; Deleted: number; EvictSkip: number; MutexMiss: number; Prune: number; Throttle: number }
|
||||
zfetch: { Hits: number; Misses: number; IOIssued: number }
|
||||
zil: { commits: number; NormalBytes: number; NormalWrite: number; SlogBytes: number; SlogWrite: number }
|
||||
dbuf: { Size: number; Target: number; Hits: number; Misses: number }
|
||||
pools: PoolCaching[]
|
||||
}
|
||||
export interface ARCView { detail: ARCDetail; live: ARCLive | 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 FileInfo extends FileEntry { path: string; text: boolean }
|
||||
|
||||
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
|
||||
|
||||
// ---- Virtualization: Incus, per machine ----
|
||||
export interface VirtLayout { driver: string; pool_source: string; pool_size_gib?: number; bridge: string; subnet: string; docker_rules: boolean; arc_max_gib?: number; attempted: string; hamlab_version?: string }
|
||||
export interface VirtStatus { state: 'ready' | 'repair' | 'installable' | 'unavailable'; reason?: string; kvm: boolean; arch: string; version?: string; pm?: string; drivers: string[]; docker: boolean; zfs_host: boolean; arc_max_gib?: number; mem_total: number; mem_avail: number; disk_free: number; dir: string; subnet: string; pool_size_gib?: number; layout?: VirtLayout; user: string; root: boolean; unit?: string; btrfs_tools: boolean }
|
||||
export interface VirtSnapshot { name: string; created: string; expires?: string; size?: number }
|
||||
export interface VirtInstance { name: string; type: 'container' | 'virtual-machine'; status: string; arch: string; description?: string; image: string; os?: string; release?: string; cpu?: string; memory?: string; created: string; last_used: string; ipv4: string[]; snapshots: VirtSnapshot[]; profiles: string[]; ephemeral: boolean; cpu_pct: number; mem_usage: number; mem_peak: number; disk_usage: number; disk_total: number; pid: number; processes: number; config: Record<string, string>; devices: Record<string, Record<string, string>>; schedule?: string; expiry?: string }
|
||||
export interface VirtImage { fingerprint: string; aliases: string[]; arch: string; type: string; size: number; created: string; last_used: string; description: string; os?: string; release?: string; variant?: string; cached: boolean; source?: string; public: boolean }
|
||||
export interface VirtPool { name: string; driver: string; source: string; used: number; total: number; used_by: number }
|
||||
export interface VirtNetwork { name: string; type: string; managed: boolean; address?: string; nat: boolean; used_by: number }
|
||||
export interface LibraryFile { name: string; size: number; modified: string; attached_to?: string[] }
|
||||
export interface ExportConfig { enabled: boolean; hour: number; minute: number; keep: number; instance_only: boolean; instances?: string[]; updated?: string }
|
||||
export interface ExportRun { at: string; exported: string[]; pruned: string[]; errors?: string[]; seconds: number }
|
||||
export interface ExportFile { instance: string; name: string; size: number; modified: string }
|
||||
export interface ExportsView { config: ExportConfig; cron: string; next?: string; last: ExportRun | null; files: ExportFile[]; dir: string; cron_file: string }
|
||||
export interface FromISO { name: string; iso: string; cpu: string; memory: string; disk: string; network: string; secureboot: boolean; start: boolean }
|
||||
export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; networks?: VirtNetwork[]; library: { images: LibraryFile[]; isos: LibraryFile[] } }
|
||||
export interface VirtInstallOpts { driver: string; pool_size_gib: number; subnet: string; docker_rules: boolean; arc_max_gib: number }
|
||||
export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean }
|
||||
|
||||
export const api = {
|
||||
procs: () => req<ProcTable>('GET', '/api/procs'),
|
||||
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
|
||||
@@ -179,6 +313,16 @@ 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'),
|
||||
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||||
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)}`),
|
||||
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 }),
|
||||
@@ -212,9 +356,13 @@ export const api = {
|
||||
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),
|
||||
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),
|
||||
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
||||
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
||||
revokeIdleSessions: (hours = 24) => req<{ revoked: number }>('POST', `/api/sessions/revoke-idle?hours=${hours}`, undefined, true),
|
||||
logout: () => req<void>('POST', '/api/logout', undefined, true),
|
||||
meHere: () => req<Me>('GET', '/api/me', undefined, true),
|
||||
me: () => req<Me>('GET', '/api/me'),
|
||||
@@ -228,10 +376,30 @@ export const api = {
|
||||
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),
|
||||
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'),
|
||||
addVPNPeer: (iface: string, p: NewVPNPeer) => req<CreatedVPNPeer>('POST', `/api/vpn/${encodeURIComponent(iface)}/peers`, p),
|
||||
editVPNPeer: (iface: string, section: string, p: { name?: string; address?: string }) =>
|
||||
req<void>('PATCH', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, p),
|
||||
removeVPNPeer: (iface: string, section: string, confirm: string) =>
|
||||
req<void>('DELETE', `/api/vpn/${encodeURIComponent(iface)}/peers/${encodeURIComponent(section)}`, { confirm }),
|
||||
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'),
|
||||
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`),
|
||||
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'),
|
||||
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),
|
||||
@@ -241,11 +409,13 @@ export const api = {
|
||||
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 }),
|
||||
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`),
|
||||
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}`),
|
||||
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),
|
||||
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
||||
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
|
||||
@@ -257,6 +427,35 @@ export const api = {
|
||||
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||||
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
|
||||
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
|
||||
virt: () => req<VirtOverview>('GET', '/api/virt'),
|
||||
virtInstall: (o: VirtInstallOpts) => req<TaskInfo>('POST', '/api/virt/install', o),
|
||||
virtRemote: (type: string, refresh = false) => req<{ images: VirtImage[] }>('GET', `/api/virt/images/remote?type=${type}${refresh ? '&refresh=1' : ''}`),
|
||||
virtImageDownload: (alias: string) => req<TaskInfo>('POST', '/api/virt/images/download', { alias }),
|
||||
virtImageImport: (path: string, alias: string, type: string) => req<TaskInfo>('POST', '/api/virt/images/import', { path, alias, type }),
|
||||
virtImagePublish: (instance: string, alias: string) => req<TaskInfo>('POST', '/api/virt/images/publish', { instance, alias }),
|
||||
virtImageDelete: (ref: string) => req<void>('POST', '/api/virt/images/delete', { ref }),
|
||||
virtCreate: (c: VirtCreate) => req<TaskInfo>('POST', '/api/virt/instances', c),
|
||||
virtAction: (name: string, action: 'start' | 'stop' | 'restart' | 'kill') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${action}`),
|
||||
virtDelete: (name: string, force: boolean) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/delete`, { confirm: name, force }),
|
||||
virtTerminal: (name: string, kind: 'console' | 'exec') => req<{ id: string; name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${kind}`),
|
||||
virtRename: (name: string, to: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/rename`, { name: to }),
|
||||
virtConfig: (name: string, cfg: Record<string, string>) => req<void>('PUT', `/api/virt/instances/${encodeURIComponent(name)}/config`, cfg),
|
||||
virtSnapshot: (name: string, snap: string) => req<{ name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots`, { name: snap }),
|
||||
virtSnapshotAction: (name: string, snap: string, action: 'restore' | 'delete') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots/${encodeURIComponent(snap)}/${action}`),
|
||||
virtISOFetch: (url: string, sha256: string, name: string) => req<TaskInfo>('POST', '/api/virt/isos/fetch', { url, sha256, name }),
|
||||
virtISOImport: (path: string) => req<TaskInfo>('POST', '/api/virt/isos/import', { path }),
|
||||
virtISODelete: (name: string) => req<void>('POST', '/api/virt/isos/delete', { name }),
|
||||
virtISORename: (name: string, to: string) => req<void>('POST', '/api/virt/isos/rename', { name, to }),
|
||||
virtISOAttach: (inst: string, iso: string, bootFirst: boolean) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso`, { iso, boot_first: bootFirst }),
|
||||
virtISODetach: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso/detach`, { device }),
|
||||
virtFromISO: (f: FromISO) => req<TaskInfo>('POST', '/api/virt/instances/from-iso', f),
|
||||
virtMountAdd: (inst: string, m: { device: string; source: string; path: string; readonly: boolean; shift: boolean }) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts`, m),
|
||||
virtMountRemove: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts/remove`, { device }),
|
||||
virtExports: () => req<ExportsView>('GET', '/api/virt/exports'),
|
||||
virtExportsPut: (c: ExportConfig) => req<void>('PUT', '/api/virt/exports', c),
|
||||
virtExportsRun: (instance = '') => req<TaskInfo>('POST', '/api/virt/exports/run', { instance }),
|
||||
virtExportDelete: (instance: string, name: string) => req<void>('POST', '/api/virt/exports/delete', { instance, name }),
|
||||
virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'),
|
||||
}
|
||||
|
||||
export function wsURLPath(path: string): string {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -5,8 +5,25 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
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[] {
|
||||
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) {
|
||||
|
||||
@@ -8,7 +8,7 @@ export type Values = Record<string, number | boolean | string>
|
||||
export type Catalog = Map<string, Sensor>
|
||||
|
||||
// Everything the modules draw, as one stream query.
|
||||
export const MONITOR_SHAPE = ['cpu.*', 'load.*', 'uptime', 'hamlab.*', 'mem.*', 'swap.*', 'temp.*', 'fan.*', 'net.*', 'disk.*', 'fs.*', 'ups.*']
|
||||
export const MONITOR_SHAPE = ['cpu.*', 'load.*', 'uptime', 'hamlab.*', 'mem.*', 'swap.*', 'temp.*', 'fan.*', 'net.*', 'disk.*', 'fs.*', 'ups.*', 'gpu.*']
|
||||
// The stream answers a list with a list; the modules want one flat map.
|
||||
export function mergeValues(parts: unknown[]): Values {
|
||||
const out: Values = {}
|
||||
|
||||
+188
-5
@@ -20,7 +20,14 @@ 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 button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; }
|
||||
.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; }
|
||||
.svc-list > .error, .cron-list > .error { margin: 8px 12px; }
|
||||
|
||||
/* layout */
|
||||
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
|
||||
@@ -94,6 +101,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 .spark { margin-top: 8px; }
|
||||
.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 .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; }
|
||||
@@ -163,11 +174,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-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-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 > 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; }
|
||||
/* 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 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); }
|
||||
@@ -177,6 +205,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.log-lines { flex: 1; overflow: auto; padding: 6px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.log { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; }
|
||||
.log .lt { color: var(--muted); flex-shrink: 0; } .log .li { color: var(--accent2); flex-shrink: 0; }
|
||||
/* a phone has no room for three columns: the stamp and the unit go on one line, the message underneath */
|
||||
@media (max-width: 560px) { .log { flex-wrap: wrap; gap: 0 8px; padding: 2px 0; border-bottom: 1px solid var(--border); } .log .lm { flex-basis: 100%; } .log .lt { font-size: 11px; } }
|
||||
.log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); }
|
||||
@media (max-width: 800px) {
|
||||
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
|
||||
@@ -285,14 +315,22 @@ 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; }
|
||||
@media (max-width: 800px) { .ds-row { grid-template-columns: minmax(90px, 1fr) 84px 100px; min-width: 0; gap: 6px; padding: 6px 8px; } .ds-row > :nth-child(3), .ds-row > :nth-child(5), .ds-row > :nth-child(6) { display: none; } .ds-row .ds-name, .ds-row.head > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ds-row.head { letter-spacing: 0; } }
|
||||
.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); }
|
||||
@@ -388,10 +426,37 @@ 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:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
||||
/* VPN tab: the profile handed over once, with its QR beside the file itself. */
|
||||
.vpn-profile .vpn-qr { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.vpn-profile .vpn-qr img { background: #fff; border-radius: 6px; padding: 6px; flex: none; }
|
||||
.vpn-profile .vpn-conf { margin: 6px 0 0; padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; user-select: all; }
|
||||
.vpn-peer .sub-head .btn { padding: 2px 8px; }
|
||||
.addform label.row-check { flex-direction: row; align-items: center; gap: 8px; }
|
||||
.addform label.row-check input { width: auto; }
|
||||
|
||||
/* 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 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); }
|
||||
/* 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 */
|
||||
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
@@ -542,7 +607,28 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.docker .svc-list { min-width: 0; max-width: 100%; } .docker .svc-rows { overflow-x: hidden; } .docker .svc-name .d { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.docker:not(.has-sel) .svc-list { flex: 1 1 0; }
|
||||
.stack-editor { flex: 1; min-height: 46vh; font-size: 13px; tab-size: 2; white-space: pre; overflow: auto; }
|
||||
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } }
|
||||
|
||||
/* The code editor: a gutter of line numbers, a painted copy of the text behind a textarea
|
||||
whose own text is transparent, both in the same monospace at the same size so they line up
|
||||
glyph for glyph. .code takes the sizing class the textarea used to (.stack-editor), so it
|
||||
sits where that did. */
|
||||
.code { display: flex; align-items: stretch; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; position: relative; padding: 0; min-height: 260px; }
|
||||
.code.ro .code-text { color: var(--muted); }
|
||||
.code-gutter { flex: 0 0 auto; min-width: 2.6em; padding: 10px 6px 10px 0; text-align: right; color: var(--muted); opacity: .7; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
|
||||
.code-body { flex: 1; position: relative; min-width: 0; }
|
||||
.code-paint, .code-text { margin: 0; padding: 10px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; tab-size: 2; white-space: pre; letter-spacing: 0; word-spacing: 0; }
|
||||
.code-paint { position: absolute; inset: 0; overflow: hidden; color: var(--fg); pointer-events: none; }
|
||||
.code-text { position: absolute; inset: 0; width: 100%; height: 100%; background: transparent; color: transparent; caret-color: var(--fg); border: 0; outline: 0; resize: none; overflow: auto; }
|
||||
.code-text::placeholder { color: var(--muted); }
|
||||
.code-text::selection { background: rgba(127, 127, 127, .35); }
|
||||
.stack-editor.code, .stack-editor .code-paint, .stack-editor .code-text, .stack-editor .code-gutter { font-size: 13px; }
|
||||
.y-key { color: var(--accent2); } .y-colon, .y-dash { color: var(--muted); } .y-string { color: var(--ok); } .y-number, .y-bool { color: var(--s2); } .y-comment { color: var(--muted); font-style: italic; } .y-anchor, .y-block { color: var(--accent); } .y-value { color: var(--fg); }
|
||||
/* a phone has no Tab key: a bar of the strokes a compose file needs, pinned to the editor's foot */
|
||||
.code-keys { position: absolute; left: 0; right: 0; bottom: 0; display: flex; gap: 6px; padding: 6px 8px; background: var(--bg2); border-top: 1px solid var(--border); z-index: 1; }
|
||||
.code-keys button { min-width: 40px; height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg3); color: var(--fg); font: 15px ui-monospace, monospace; }
|
||||
.code-keys ~ * { display: none; }
|
||||
@media (pointer: coarse) { .code .code-body { padding-bottom: 45px; } .code .code-gutter { padding-bottom: 55px; } }
|
||||
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } .stack-editor.code, .stack-editor .code-paint, .stack-editor .code-text, .stack-editor .code-gutter { font-size: 12px; } }
|
||||
|
||||
/* stacks: diff, images, .env */
|
||||
.stack-extra { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--border); padding-top: 10px; } .stack-extra .sub-head .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
@@ -575,6 +661,31 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.top-mem, .top-disks { min-height: 165px; } /* a grid row set to auto will otherwise squeeze them to nothing */
|
||||
.top-cpu { grid-column: 1 / -1; } .top-mem { grid-column: 1; grid-row: 2; } .top-disks { grid-column: 2; grid-row: 2; }
|
||||
.top-net { grid-column: 1 / 3; grid-row: 3; } .top-proc { grid-column: 3; grid-row: 2 / 4; }
|
||||
/* a machine with a GPU gets a row of gpu boxes under the cpu, as btop lays it out; the rest move down one */
|
||||
.top.has-gpu { grid-template-rows: minmax(200px, 1fr) minmax(210px, auto) auto minmax(170px, 1fr); }
|
||||
.top-gpus { grid-column: 1 / -1; grid-row: 2; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; min-width: 0; }
|
||||
.top.has-gpu .top-mem, .top.has-gpu .top-disks { grid-row: 3; } .top.has-gpu .top-net { grid-row: 4; } .top.has-gpu .top-proc { grid-row: 3 / 5; }
|
||||
.top-gpu { min-height: 210px; }
|
||||
/* a GPU with nothing to measure is one line, and on a machine with no other its row is only as
|
||||
tall as that line. Same weight as .top.has-gpu and after it, so the narrower layouts' own
|
||||
.top.has-gpu, further down, still wins there (their gpu row is already auto). */
|
||||
.top.gpu-slim { grid-template-rows: minmax(200px, 1fr) auto auto minmax(170px, 1fr); }
|
||||
.top-gpu.slim { align-self: start; } /* beside a full gpu box it keeps its one line, not that box's height */
|
||||
.t-row.t-gpu-line { flex-wrap: wrap; gap: 0 14px; } .t-gpu-line > span { white-space: nowrap; } .t-gpu-who { margin-left: auto; } /* on a phone it wraps to its own line, still on the right */
|
||||
.t-gpu { display: flex; gap: 8px; min-width: 0; flex: 1; min-height: 0; }
|
||||
/* the graph and its caption share this box: tall enough for both, so the caption never lands on the stats */
|
||||
.t-gpu-graph { flex: 1; min-width: 0; min-height: 130px; display: flex; flex-direction: column; }
|
||||
.t-gpu-stats { width: 310px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 3px; padding: 4px 6px; display: flex; flex-direction: column; gap: 1px; overflow-y: auto; scrollbar-width: thin; }
|
||||
.t-gpu-stats .t-core-n { width: 58px; } /* room for "enhance" — the cpu box's 30px fits "C15", not an engine's name */
|
||||
.t-gpu-name { color: var(--fg); overflow: hidden; text-overflow: ellipsis; max-width: 42vw; }
|
||||
.t-core-n.t-eng { color: var(--muted); font-size: 12px; }
|
||||
.t-gpu-facts { margin-top: 2px; } .t-gpu-facts .t-row { gap: 6px; } .t-fact { color: var(--fg); white-space: nowrap; }
|
||||
.t-gpu-facts .tm { flex: 1; }
|
||||
.t-gpu-clients { margin-top: 4px; border-top: 1px solid var(--border); padding-top: 2px; }
|
||||
.t-gpu-clients .t-thead, .t-crow { display: grid; grid-template-columns: 52px minmax(0, 1fr) 50px 46px; gap: 7px; align-items: center; white-space: nowrap; }
|
||||
.t-gpu-clients .t-thead { border-bottom: 0; padding-right: 0; }
|
||||
.t-crow > span { min-width: 0; overflow: hidden; } .t-crow .c-name { color: var(--ok); text-overflow: ellipsis; } .t-crow .c-cpu { color: var(--fg); }
|
||||
.t-crow-none { display: block; font-size: 12px; padding: 1px 0; }
|
||||
.tbox { position: relative; border: 1px solid var(--border); border-radius: 3px; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.tbox-in { flex: 1; min-height: 0; min-width: 0; padding: 9px 8px 6px; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.tbox-name, .tbox-right { position: absolute; top: -9px; background: var(--term-bg); padding: 0 4px; font-size: 12px; white-space: nowrap; z-index: 1; }
|
||||
@@ -599,7 +710,12 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.t-load { display: flex; margin-top: 2px; }
|
||||
|
||||
/* the dot graph and the dotted meter */
|
||||
.tg { width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden; }
|
||||
/* The canvas is taken out of flow. Its drawing size is set from the box it sits in; if it also
|
||||
sat *in* that box and the box's height was ever content-sized, each resize would set a bigger
|
||||
canvas, which would make a bigger box, which would set a bigger canvas — the Processes tab
|
||||
growing without end, as it did when the gpu box landed in an auto-height row. */
|
||||
.tg { position: relative; width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.tg canvas { position: absolute; inset: 0; }
|
||||
.tm { position: relative; display: inline-block; height: 9px; flex: 1; min-width: 24px; vertical-align: middle; }
|
||||
.tm-t, .tm-f { position: absolute; inset: 0; -webkit-mask-image: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 5px); mask-image: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 5px); }
|
||||
.tm-t { background: var(--border); }
|
||||
@@ -638,16 +754,22 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.t-prow .c-user { text-overflow: ellipsis; }
|
||||
.c-pid, .c-thr, .c-mem, .c-cpu { text-align: right; } .t-thead .c-pid, .t-thead .c-name, .t-thead .c-cmd, .t-thead .c-user { text-align: left; }
|
||||
.c-g { color: var(--muted); text-align: right; }
|
||||
/* the Gpu% column, on a machine that has one: dim at rest, lit on the rows that are using it */
|
||||
.c-gpu { text-align: right; color: var(--muted); } .c-gpu.lit { color: var(--t-magenta); }
|
||||
.t-proc.has-gpu .t-thead, .t-proc.has-gpu .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 56px 42px 42px; }
|
||||
@container tproc (max-width: 640px) {
|
||||
.t-thead, .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 42px; }
|
||||
.t-proc.has-gpu .t-thead, .t-proc.has-gpu .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 42px 42px; }
|
||||
.t-thead .c-g, .t-prow .c-g { display: none; }
|
||||
}
|
||||
@container tproc (max-width: 560px) {
|
||||
.t-thead, .t-prow { grid-template-columns: 58px 110px minmax(0, 1fr) 58px 50px 42px; }
|
||||
.t-proc.has-gpu .t-thead, .t-proc.has-gpu .t-prow { grid-template-columns: 58px 110px minmax(0, 1fr) 58px 50px 42px 42px; }
|
||||
.t-thead .c-thr, .t-prow .c-thr { display: none; }
|
||||
}
|
||||
@container tproc (max-width: 470px) {
|
||||
.t-thead, .t-prow { grid-template-columns: 52px minmax(0, 1fr) 52px 48px 40px; gap: 6px; }
|
||||
.t-proc.has-gpu .t-thead, .t-proc.has-gpu .t-prow { grid-template-columns: 52px minmax(0, 1fr) 52px 48px 40px 40px; }
|
||||
.t-thead .c-cmd, .t-prow .c-cmd { display: none; }
|
||||
}
|
||||
.t-foot { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--border); padding-top: 3px; margin-top: 2px; font-size: 12px; }
|
||||
@@ -660,15 +782,30 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.top-mem, .top-disks { min-height: 220px; }
|
||||
.top-cpu { grid-column: 1 / -1; grid-row: 1; } .top-mem { grid-column: 1; grid-row: 2; } .top-disks { grid-column: 2; grid-row: 2; }
|
||||
.top-net { grid-column: 1 / -1; grid-row: 3; } .top-proc { grid-column: 1 / -1; grid-row: 4; }
|
||||
.top.has-gpu { grid-template-rows: minmax(230px, auto) auto minmax(220px, auto) minmax(200px, auto) minmax(420px, 1fr); }
|
||||
.top.has-gpu .top-mem, .top.has-gpu .top-disks { grid-row: 3; } .top.has-gpu .top-net { grid-row: 4; } .top.has-gpu .top-proc { grid-row: 5; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.top { display: flex; flex-direction: column; font-size: 12px; }
|
||||
.top > .tbox { flex: 0 0 auto; }
|
||||
.top-gpus { display: flex; flex-direction: column; flex: 0 0 auto; } .top-gpu.slim { align-self: stretch; }
|
||||
.t-gpu { flex-direction: column; } .t-gpu-graph { height: 130px; flex: 0 0 130px; } .t-gpu-stats { width: auto; max-height: none; }
|
||||
.t-cpu { flex-direction: column; } .t-cpu-graph { height: 130px; flex: 0 0 130px; } .t-cores { width: auto; max-height: 40vh; }
|
||||
.t-net { flex-direction: column; } .t-net-nums { width: auto; } .t-net-graphs { flex: 0 0 130px; }
|
||||
.top-proc { height: 70vh; min-height: 420px; }
|
||||
}
|
||||
|
||||
/* ---- ZFS: the ARC card and each pool's caching tiers ---- */
|
||||
.arc-sparks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 8px; } .arc-sparks .small { margin-bottom: 2px; }
|
||||
@media (max-width: 640px) { .arc-sparks { grid-template-columns: 1fr; } }
|
||||
.arc-break { display: grid; grid-template-columns: minmax(140px, 1fr) 80px 110px 110px; gap: 2px 10px; font-size: 12px; margin-top: 10px; }
|
||||
.arc-break .head { color: var(--muted); } .arc-break .num { text-align: right; }
|
||||
@media (max-width: 640px) { .arc-break { grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 2px 8px; font-size: 11px; } .arc-break > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } }
|
||||
.arc-tiers { display: flex; flex-direction: column; gap: 5px; font-size: 13px; margin-top: 4px; }
|
||||
.arc-tier { display: flex; gap: 8px; min-width: 0; } .arc-tier .k { color: var(--muted); width: 80px; flex-shrink: 0; cursor: help; }
|
||||
.arc-tier-v { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .arc-tier-v > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.arc-tier-v .none { color: var(--muted); white-space: normal; }
|
||||
|
||||
/* ---- Space: what is using a disk ---- */
|
||||
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||
.space-bar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); }
|
||||
@@ -690,6 +827,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.space .warn { color: var(--s2); }
|
||||
.space-tabs { padding: 8px 12px 0; }
|
||||
.space-split { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 8px 12px 12px; gap: 8px; }
|
||||
@media (max-width: 800px) { .space-split { overflow-y: auto; padding: 8px; } .space-tree { flex: 0 0 auto; height: 40vh; } .space-map { flex: 0 0 auto; height: 60vh; } }
|
||||
.space-tree { flex: 1 1 55%; min-height: 120px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: var(--bg2); overflow: hidden; }
|
||||
.space-rows { flex: 1; overflow: auto; min-height: 0; }
|
||||
.space-row { display: grid; grid-template-columns: minmax(160px, 1fr) 84px 120px 70px 64px 88px; gap: 8px; align-items: center; padding: 3px 8px; font-size: 13px; border-bottom: 1px solid var(--border); cursor: pointer; white-space: nowrap; }
|
||||
@@ -729,3 +867,48 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.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); }
|
||||
|
||||
/* ---- Virtualization ---- */
|
||||
.virt .svc-tools .task { max-height: 45vh; }
|
||||
.virt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.virt-table th, .virt-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.virt-table th { font-weight: 500; opacity: .7; }
|
||||
.virt-table td.num, .virt-table th.num { text-align: right; white-space: nowrap; }
|
||||
.virt-pick { max-height: 240px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.virt-pick .svc-row { padding: 6px 10px; }
|
||||
.virt-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; font-size: 12px; word-break: break-all; }
|
||||
.virt-form-row { display: flex; gap: 8px; }
|
||||
.virt-form-row > label { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.virt-form-row > label > input, .virt-form-row > label > select { width: 100%; box-sizing: border-box; }
|
||||
.virt .modal { max-width: 640px; }
|
||||
.vga-back { align-items: stretch; padding: 10px; }
|
||||
.vga { display: flex; flex-direction: column; gap: 6px; width: 100%; height: 100%; max-width: none; max-height: none; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
|
||||
.vga-bar { flex-shrink: 0; }
|
||||
.vga-area { flex: 1; min-height: 0; overflow: auto; background: #000; border-radius: 6px; display: flex; align-items: flex-start; justify-content: flex-start; padding: 4px; }
|
||||
.vga-area #spice-area { display: inline-block; }
|
||||
.vga-area #spice-screen { display: inline-block; line-height: 0; }
|
||||
.vga-area canvas { outline: none; }
|
||||
.vga-msg { display: none; }
|
||||
.vga-hint { flex-shrink: 0; }
|
||||
|
||||
+38
-1
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { Component, ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// ---- spinners ----
|
||||
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 }) {
|
||||
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 }
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+674
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user