Compare commits
70
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
|
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
|
is right, and the terminal belongs to the machine asked; `sockets.cjs` checks that ownership
|
||||||
rather than the noise.
|
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.
|
# Commit first, then deploy: the version is the commit the binary was built from, counted
|
||||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
# 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
|
GO ?= go
|
||||||
PREFIX ?= /usr/local/bin
|
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. |
|
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
||||||
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
||||||
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
||||||
|
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
|
||||||
|
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
||||||
|
| 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>/` are the standing exception. |
|
||||||
|
|
||||||
## Layout
|
## 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
|
the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms
|
||||||
and 10 min at 2 s. Still to do: persistent
|
and 10 min at 2 s. Still to do: persistent
|
||||||
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
|
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
|
||||||
|
- **Updating from the Updates tab** — **shipped 2026-09-26**: the "what is out of date" list
|
||||||
|
was read-only, so seeing a newer image and doing something about it were different places.
|
||||||
|
Each row now has **Update**, and the header has **Update all**. Updating is per stack —
|
||||||
|
that is the unit compose deals in — so a row acts on whichever stacks use that image, and
|
||||||
|
several stacks run through a queue one at a time rather than recreating half the machine at
|
||||||
|
once, each with its task output on screen. A row that succeeds says "updated just now"
|
||||||
|
instead of re-checking, since a check is a registry request.
|
||||||
|
- **Recreate, and the Updates tab at full width** (2026-09-26): a stack's actions gained
|
||||||
|
**Recreate** (`up -d --force-recreate`), the only way out of a container Docker refuses to
|
||||||
|
start — one pinned to a network that has since been recreated answers "network … not
|
||||||
|
found" to Deploy and Update alike, because as far as compose is concerned nothing about it
|
||||||
|
changed. Found the hard way when a SiYuan update landed in exactly that state. The Updates
|
||||||
|
tab also stopped rendering as a list-and-detail pair: it is a page, not a list, so on a
|
||||||
|
wide window it now sits under the tab strip at full width instead of being squeezed into
|
||||||
|
the right-hand half beside an empty column.
|
||||||
|
- **Cron in English** (`crontext.ts`, 2026-09-26): schedules read "daily at 3:15am" rather
|
||||||
|
than `15 3 * * *`, with the expression kept beside it. Handles lists, ranges, steps, day
|
||||||
|
and month names, `@daily` and friends, weekdays/weekends, midnight and noon — and declines
|
||||||
|
to describe anything it is not sure of, because a confident wrong sentence is worse than
|
||||||
|
five fields. The Jobs tab had its own smaller version of this; it now uses this one.
|
||||||
|
|
||||||
|
- **Hardware tab** — **shipped 2026-09-25** (`internal/hardware`, `internal/api/hardware.go`,
|
||||||
|
`Hardware.tsx`): what the machine is physically made of, per machine like every other page.
|
||||||
|
Four headline tiles (processor, memory, storage, graphics) over cards for each: the
|
||||||
|
processor with its cores, clock range, socket, caches, microcode and the handful of feature
|
||||||
|
flags anyone asks about; **every memory slot** with size, type, form factor, rated and
|
||||||
|
running speed, maker, part number, serial, rank and voltage, and the empty ones marked
|
||||||
|
empty; each drive with model, size, bus, firmware and serial; each display adapter with its
|
||||||
|
driver, PCI address and video memory; and the board itself — model, firmware and date,
|
||||||
|
serial, kernel. Read from what Debian already has (dmidecode for what only the firmware
|
||||||
|
knows, lscpu, lspci, sysfs) and cached for ten minutes, since none of it changes under a
|
||||||
|
running kernel; "Re-read" is there for the one thing that does, a drive plugged in.
|
||||||
|
Every reader may come back empty and the page says what it could not read and why: a
|
||||||
|
machine with no dmidecode, no root, or no PCI bus still gets a page. Verified on `core`
|
||||||
|
(SMBIOS, Intel graphics) and on the Mac mini (Asahi: no SMBIOS memory table, a
|
||||||
|
device-tree GPU, heterogeneous cores — where lscpu's "cores per socket" counts one
|
||||||
|
cluster, so without SMT the thread count is the honest core count).
|
||||||
|
|
||||||
- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
|
- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
|
||||||
`internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`;
|
`internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`;
|
||||||
reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID;
|
reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID;
|
||||||
@@ -259,6 +301,44 @@ 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`
|
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
|
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
|
||||||
Top sat rather than falling to the bottom.
|
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).
|
||||||
|
- **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
|
- **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
|
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
|
(`Dashboard.tsx` deleted), and with it `/api/prefs/dashboard`, `applyPrefs`, and the
|
||||||
@@ -276,6 +356,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
|
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
|
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`.
|
prefs, which the hub fans out, remembered per machine as `sampler_ms`.
|
||||||
|
- **ZFS tab, with scheduled snapshots** — **shipped 2026-09-24** (`internal/storage/snapshots.go`,
|
||||||
|
`internal/api/zfs.go`, `ZFS.tsx`). Pools, datasets and snapshots moved out of Disks —
|
||||||
|
Disks is the hardware, ZFS is the filesystem on top of it — and the tab gained what it was
|
||||||
|
split out for: **snapshot schedules**. Hourly, daily, weekly or monthly, per dataset,
|
||||||
|
optionally recursive, with a **retention period** and a floor that keeps the newest few
|
||||||
|
whatever their age. A missed window (the machine was off) runs once on return rather than
|
||||||
|
being skipped.
|
||||||
|
**The naming is zfSnap's, deliberately.** `grid` has run zfSnap v1.11.1 from root cron at
|
||||||
|
02:30 with a 6-week TTL since before this repo existed, and it works. So rather than
|
||||||
|
invent a second convention for two tools to disagree over, this writes and reads exactly
|
||||||
|
what zfSnap does — `tank@2026-09-24_02.30.01--6w`, **retention encoded in the name**. Three
|
||||||
|
consequences: Ham Lab shows and expires the snapshots zfSnap already took; the two can run
|
||||||
|
side by side without fighting, since both only remove what has passed the expiry written
|
||||||
|
into its own name; and changing a schedule never re-dates old snapshots, because each
|
||||||
|
carries the rule it was taken under. The page **detects the existing cron job**
|
||||||
|
(zfSnap, zfs-auto-snapshot, sanoid, or a bare `zfs snapshot`) and says so, warning if a Ham
|
||||||
|
Lab schedule is running as well.
|
||||||
|
**Nothing is destroyed unseen.** Only names that parse as a stamp and a TTL are ever
|
||||||
|
candidates for expiry, so a snapshot taken by hand is invisible to a schedule; Run now
|
||||||
|
says that expired snapshots go with it; and a snapshot deleted by hand is confirmed first
|
||||||
|
and needs its full name echoed back by the caller. (A "what would it delete?" preview
|
||||||
|
shipped and was removed the same day as redundant — Mitchell's call.)
|
||||||
|
The datasets table is a **snapshot explorer**: each dataset shows how many snapshots it has
|
||||||
|
and what they cost, and opens into the snapshots themselves — when each was taken, **when
|
||||||
|
it expires** (read from the TTL in its own name, so no server call), its size, and Delete.
|
||||||
|
Deleting takes the same-named snapshot on descendant datasets too, which is how a recursive
|
||||||
|
schedule made it.
|
||||||
|
**A schedule is a real cron job** (2026-09-24, Mitchell's call after the first cut ran it
|
||||||
|
from a ticker inside the daemon). Ham Lab writes `/etc/cron.d/hamlab-zfs`, owns it, and
|
||||||
|
rewrites it whole on every change; each line calls `hamlab zfs-snapshot <id>` as the
|
||||||
|
account Ham Lab runs as, reaching zfs through sudo as the daemon does. Snapshots are
|
||||||
|
backup-shaped and must not stop because a console is down, upgrading, or uninstalled — and
|
||||||
|
a cron line is greppable by anyone looking at the machine the normal way. **A cron
|
||||||
|
expression is the single description of when a schedule fires**: the four common shapes
|
||||||
|
render to one, `custom` states one outright, and the next run is read back with the same
|
||||||
|
evaluator the Jobs tab uses on everyone else's lines, so the page, the file and the
|
||||||
|
prediction cannot disagree. A run's outcome is written to `zfs-runs/<id>.json` beside the
|
||||||
|
config, never to `state.json`, because the cron job is a separate process and the daemon
|
||||||
|
would clobber it. An existing external job can be **adopted** — its schedule, retention
|
||||||
|
and dataset read out of the cron line into a new Ham Lab schedule.
|
||||||
|
**zfSnap is retired on `grid`** as of that day: Ham Lab now takes the nightly `tank`
|
||||||
|
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
||||||
|
had taken left untouched and fully readable here — which is what adopting its naming
|
||||||
|
bought. See `hamlet/docs/hosts.md`.
|
||||||
|
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`).
|
||||||
|
Paste a device's address; its own web page appears in the tab. **Nothing in it knows
|
||||||
|
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid`
|
||||||
|
at `10.0.0.217`, but none of that is in the code.
|
||||||
|
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
|
||||||
|
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
|
||||||
|
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
|
||||||
|
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
|
||||||
|
**The general problem, and the answer.** A device UI asks for its assets by absolute path
|
||||||
|
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
|
||||||
|
bodies per device would not be general. So the prefix is only the entry point: any request
|
||||||
|
Ham Lab does not serve itself is routed to whichever KVM the asking page belongs to, read
|
||||||
|
from the **Referer**, falling back to a **cookie** when there is none (a WebSocket upgrade).
|
||||||
|
Ham Lab's own routes always match first, so a device can never shadow the console, and the
|
||||||
|
fallback is gated on a live session. The device never receives Ham Lab's session cookie,
|
||||||
|
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
|
||||||
|
now. Redirects to the device's root are rewritten back through the prefix.
|
||||||
|
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
|
||||||
|
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
|
||||||
|
refused before it loaded.
|
||||||
|
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
|
||||||
|
does not need to, since one with focus receives them directly. Fullscreen plus
|
||||||
|
`navigator.keyboard.lock()` is what takes Escape, Tab and Ctrl-W back from the browser;
|
||||||
|
that is Chromium-only, and the tab says which version you are getting rather than
|
||||||
|
pretending.
|
||||||
|
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
|
||||||
|
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
|
||||||
|
to `grid` and you get grid's; adding a second for a machine replaces the first. The
|
||||||
|
addresses are still kept **on the hub** rather than on each machine, because a KVM earns
|
||||||
|
its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub
|
||||||
|
proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at
|
||||||
|
all. Not done: holding the device's own login on the hub, so its page does not ask for a
|
||||||
|
second password.
|
||||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||||
@@ -321,9 +478,16 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
replace, exec in place. Config default moved to `~/.config/hamlab` (existing
|
replace, exec in place. Config default moved to `~/.config/hamlab` (existing
|
||||||
`~/apps/hamlab` still found by the installer and the CLI). Tested in
|
`~/apps/hamlab` still found by the installer and the CLI). Tested in
|
||||||
containers: Debian 12, Ubuntu 22.04, Fedora 40, Rocky 8, openSUSE Leap 15.5, Alpine
|
containers: Debian 12, Ubuntu 22.04, Fedora 40, Rocky 8, openSUSE Leap 15.5, Alpine
|
||||||
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. Not done: an OpenRC
|
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. **procd (OpenWrt) added
|
||||||
backend for the Services tab; runit/s6/sysvinit service files; public hosting for
|
2026-09-26** (`internal/setup/hamlab.procd`): detected on `/sbin/procd` + `/etc/rc.common`
|
||||||
`install.sh` (the Gitea repo is private, so the one-liner needs `HAMLAB_TOKEN`).
|
ahead of the OpenRC branch, installed to the same `/etc/init.d/hamlab` path and told apart
|
||||||
|
from OpenRC at uninstall by `USE_PROCD=1` in the file. Verified on the new router
|
||||||
|
(`gate`, OpenWrt 25.12.5, running as root): `S95`/`K10` symlinks, procd supervising the
|
||||||
|
instance, Docker tab and stacks working through the hub. The Updates tab needed no change
|
||||||
|
— apk-tools 3 answers `apk version -l '<'` the same way Alpine's does. Not done: an
|
||||||
|
OpenRC or procd backend for the Services tab (it is systemd-only and says so cleanly);
|
||||||
|
runit/s6/sysvinit service files; public hosting for `install.sh` (the Gitea repo is
|
||||||
|
private, so the one-liner needs `HAMLAB_TOKEN`).
|
||||||
- **Users, sessions and settings that follow the account** — **shipped 2026-09-22**
|
- **Users, sessions and settings that follow the account** — **shipped 2026-09-22**
|
||||||
(`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`,
|
(`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`,
|
||||||
`Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's
|
`Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's
|
||||||
@@ -463,13 +627,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
|
`POST /api/password`, current password required), Access tokens (list/revoke the tokens
|
||||||
hubs hold), About (version, host, arch, uptime, certificate).
|
hubs hold), About (version, host, arch, uptime, certificate).
|
||||||
|
|
||||||
|
## Network tab (2026-09-27)
|
||||||
|
|
||||||
|
A **Network** tab, per machine like every other: the interfaces as the kernel has them
|
||||||
|
(addresses, MAC, MTU, negotiated link speed, carrier, byte totals, live rates from the
|
||||||
|
sampler's own sensors), the default route and resolvers, what the internet sees of this
|
||||||
|
connection, and an **internet speed test**.
|
||||||
|
|
||||||
|
Decisions worth keeping:
|
||||||
|
|
||||||
|
- **Measured by the daemon, not the browser.** The test runs on the machine in the switcher,
|
||||||
|
so the figure is that machine's line — the router's, if you switch to `gate` — and it
|
||||||
|
carries on if the phone locks or the tab closes. The page polls a run it is not driving.
|
||||||
|
- **No tool to install.** It measures against Cloudflare's public speed endpoints
|
||||||
|
(`__down`, `__up`, `cdn-cgi/trace`), which is what makes it work identically on Debian and
|
||||||
|
on OpenWrt, where `opkg install` costs flash. That is the feature's one external
|
||||||
|
dependency, and it is named in `internal/network/speedtest.go`.
|
||||||
|
- **Several streams, HTTP/2 off, and the ramp thrown away.** One stream over one connection
|
||||||
|
measures a congestion window; HTTP/2 would put every stream back on one connection and do
|
||||||
|
the same. The first second of each phase is measured and discarded.
|
||||||
|
- **Latency is the round trip less the far end's own Server-Timing.** A zero-byte response
|
||||||
|
is mostly Cloudflare's worker running — 20 to 45 ms of it against a few ms of wire. Left
|
||||||
|
in, it reported 31 ms for a 7 ms line. Jitter is the mean change between consecutive
|
||||||
|
probes, not a standard deviation, so one slow probe doesn't run away with it.
|
||||||
|
- **A refused phase is not a number.** The far end rate-limits by volume, and a test moves a
|
||||||
|
few hundred megabytes; several in a row earn 429s, and the block observed while building this
|
||||||
|
lasted over twenty minutes. So: a 20-second cooldown counted from the last *attempt* (a
|
||||||
|
refused test still used the line), a stream that retries twice before giving up, the rate
|
||||||
|
computed over the window in which bytes were actually still arriving, and a plain sentence
|
||||||
|
when it was cut short. Coasting to the end of the clock with nothing arriving reported a
|
||||||
|
third of the truth.
|
||||||
|
- **A second download source, for when the first refuses.** A plain 100 MB file on Linode's
|
||||||
|
Seattle mirror, which has no opinion about volume. It is only reached after a refusal, and
|
||||||
|
every result names the source it used — that mirror is in one place, so on a machine far from
|
||||||
|
Seattle it would read low, and a number that quietly came from somewhere else would be a lie.
|
||||||
|
Upload stays on Cloudflare, which has never been the one to refuse.
|
||||||
|
- **The last result is published as sensors** (`net.internet.*`), so the line is a widget, a
|
||||||
|
graph, an alert or a Prometheus series like anything else. Each carries the time the test
|
||||||
|
ran, not the time it was asked for.
|
||||||
|
- **Not scheduled.** Nothing takes a speed test on its own. A nightly one recorded to the
|
||||||
|
history is the obvious next step and deliberately not built yet — the cron shape for it
|
||||||
|
already exists twice over (ZFS snapshots, Docker image updates).
|
||||||
|
|
||||||
|
## VPN tab (2026-09-27)
|
||||||
|
|
||||||
|
A **VPN** tab beside Network, per machine: every WireGuard tunnel on the machine in the
|
||||||
|
switcher, and the devices on it — name, address, last handshake, where it connects from, and
|
||||||
|
what it has moved.
|
||||||
|
|
||||||
|
- **Per machine is the point, not a limitation.** The tunnel people dial into and a tunnel a
|
||||||
|
machine dials out through are on different boxes here (`gate` and `hamtv`), and a combined
|
||||||
|
view would invite exactly the question "which box is this on".
|
||||||
|
- **The kernel is the truth, the config only supplies names.** `wg show all dump` has the
|
||||||
|
handshakes and counters and knows nothing but public keys; names come from the `# comment`
|
||||||
|
above a peer in a wg-quick file, or a UCI section's `description` on OpenWrt. Both are read
|
||||||
|
only to label what the kernel already said.
|
||||||
|
- **Server or client is worked out, not declared.** A peer allowed `0.0.0.0/0` that has an
|
||||||
|
endpoint is a way out, so a tunnel holding one is this machine dialling out; a tunnel with a
|
||||||
|
listen port and peers is one being dialled. Anything else says neither rather than guessing.
|
||||||
|
- **"Online" is a handshake in the last three minutes**, and the tab says so. WireGuard is
|
||||||
|
silent when idle; there is no connection to be in, and a tab claiming otherwise would be
|
||||||
|
lying twice a day.
|
||||||
|
- **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
|
## To do
|
||||||
|
|
||||||
- **Deprecate copyparty** — asked for 2026-09-23, not started. `copyparty` on `grid`
|
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
|
||||||
(`copyparty.service`, `:3923`, `/etc/copyparty.conf`, serving all of `/tank` as user
|
polish, and deprecating copyparty), merged 2026-09-24: they are the same tab and the same
|
||||||
`core` with one account behind NPM) overlaps the **Files** tab almost entirely. The aim
|
work. `copyparty` on `grid` (`copyparty.service`, `:3923`, `/etc/copyparty.conf`, serving
|
||||||
is to stop the service and keep only Ham Lab. What Files must gain first, in the order
|
all of `/tank` as user `core` with one account behind NPM) overlaps **Files** almost
|
||||||
that matters:
|
entirely. The aim is to stop the service and keep only Ham Lab, with general ease-of-use
|
||||||
|
polish along the way. What Files must gain first, in the order that matters:
|
||||||
- **Multi-select and bulk actions.** copyparty selects many rows and moves, deletes or
|
- **Multi-select and bulk actions.** copyparty selects many rows and moves, deletes or
|
||||||
downloads them together; Files acts on one row at a time.
|
downloads them together; Files acts on one row at a time.
|
||||||
- **Download a folder**, and a multi-file download, as a streamed zip or tar — the thing
|
- **Download a folder**, and a multi-file download, as a streamed zip or tar — the thing
|
||||||
@@ -489,69 +734,68 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
`hamlet`'s `docs/hosts.md` bare-metal list, `docs/gotchas.md` (the "`docker ps` is not
|
`hamlet`'s `docs/hosts.md` bare-metal list, `docs/gotchas.md` (the "`docker ps` is not
|
||||||
the inventory" note names it) and the `grid` row in `CLAUDE.md`.
|
the inventory" note names it) and the `grid` row in `CLAUDE.md`.
|
||||||
|
|
||||||
- **Deprecate Dockhand** — asked for 2026-09-23, not started; the *homelab* half of this
|
- **Grow the Docker tab until Dockhand can go** — asked for 2026-09-23 and widened
|
||||||
(migrating every stack off Dockhand's data dir, then removing the container) is
|
2026-09-24 to take in Docker tab polish and feature work, on the same reasoning as the
|
||||||
`hamlet`'s `docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator",
|
Files item: it is one tab, and the gaps that block retiring the old tool and the things
|
||||||
agreed 2026-09-20. This entry is the *app* half only: what Ham Lab must do before that
|
that would simply make it better are the same body of work. The *homelab* half —
|
||||||
migration can start. Its Stacks page already creates, edits, deploys, updates, restarts,
|
the cutover itself, then moving every stack off Dockhand's data dir — is `hamlet`'s
|
||||||
stops, downs and deletes stacks on every machine, so the gaps are Dockhand's extras:
|
`docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", agreed
|
||||||
- **Git-sourced stacks.** Dockhand's `stack_sources` table points a stack at a repo and
|
2026-09-20. This entry is the *app* half.
|
||||||
pulls it. Ham Lab has no equivalent. Decide whether to build it or to drop it — most
|
|
||||||
stacks here have no row in that table anyway, so dropping it may be honest.
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
- **Rename the Space tab** — asked for 2026-09-23, not started. "Space" is vague; the tab
|
**Done 2026-09-24 — the eight Mitchell picked from the inventory**, all in the Docker tab:
|
||||||
is WizTree for a disk or pool. Candidates: **Usage**, **Disk Usage**, **Storage**. Note
|
per-container **Stats**, **Health** (with the last few probe outputs), **Events** (Ham Lab
|
||||||
`Disks` already exists and `Storage` would muddle with it, so Usage is probably the one.
|
keeps its own 4000-entry ring, since `docker events` forgets after about a minute),
|
||||||
The rename touches the rail label, the `View` id, the hash route (`#/<machine>/space`,
|
**Shell** (`docker exec`, handed to the Terminal tab) and **Limits** (`docker update` for
|
||||||
which people may have bookmarked — decide whether to keep `space` as the id and change
|
memory, CPU and restart policy); an **updates** tab giving the whole machine at once with
|
||||||
only the label), the footer's page name and `Space.tsx`'s own strings.
|
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
|
- **Left from the Dockhand inventory, both to decide rather than build.**
|
||||||
section inside `Disks.tsx`: pools with health, capacity, fragmentation, scrub controls, a
|
- *Git-sourced stacks.* Dockhand's `stack_sources` table points a stack at a repo and
|
||||||
dataset tree and per-dataset snapshot lists with "Snapshot now". Moving it out makes room
|
pulls it. Its `git_repositories` and `git_stacks` tables are **empty**, so nothing
|
||||||
for what is actually wanted: **scheduled snapshots with a retention policy**, configured
|
here uses it — dropping it is the honest answer unless Mitchell says otherwise.
|
||||||
from the console rather than by hand. What exists to build on — `grid` runs `zfSnap`
|
- *Webhooks, OIDC, LDAP, vulnerability scans.* All four tables empty. Nothing to move.
|
||||||
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.
|
|
||||||
|
|
||||||
- **Rename Cron to something generic** — asked for 2026-09-23, not started. Mitchell's
|
**Done 2026-09-25 — the two gaps in the tab itself.**
|
||||||
reasoning: the Services tab is systemd but "Services" is the right generic name for it,
|
- *Logs are followed over a held connection*, the way the Services tab follows a journal
|
||||||
so the scheduling tab should be named for the job it does, not the tool. **Scheduled** or
|
(`internal/api/docker_logs.go`, `/api/docker/containers/{id}/logs/tail`): one
|
||||||
**Jobs**. The tab already covers more than cron — `/etc/crontab`, `/etc/cron.d`, user
|
`docker logs -f` per viewer, stdout and stderr both, stderr coloured as an error. A
|
||||||
crontabs, `run-parts` directories *and* systemd timers — so the generic name is the
|
reconnect resumes from the last line's timestamp, so a container that restarts or a
|
||||||
honest one. Same rename surface as the Space tab: rail label, `View` id, hash route,
|
phone that slept doesn't replay what is already on screen; the client dedupes by line
|
||||||
footer name.
|
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
|
- **The `*:2375` question, which this is the moment to settle.** A stack deployed to
|
||||||
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
|
`grid` or the Mac still goes over `tcp://<node>:2375` from the hub. Decide whether the
|
||||||
which is the comparison Ham Lab has invited since its first line. Nothing exists yet.
|
hub should instead ask the node's own Ham Lab to deploy locally, which would end the
|
||||||
What a future session has to settle before writing code: which stack (libvirt/KVM +
|
unauthenticated Docker socket exposure — its own item in `docs/pending.md`, currently
|
||||||
`virsh` is the obvious one for VMs; LXC or Incus for containers, and Incus covers both);
|
filed under "decided, no action", and this is the change that would make closing it
|
||||||
whether it manages what is already installed or installs it; where disk images live
|
free.
|
||||||
(`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
|
- **A Virtualization tab** — asked for 2026-09-23; **scoped 2026-10-09 in `VIRTUALIZATION.md`**,
|
||||||
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
|
not started. Incus 6.0 LTS as the engine; the tab detects per machine whether Incus is
|
||||||
at all, which is every host here today. Worth scoping as its own plan before starting.
|
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
|
## 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 | **A** | `internal/api/stacks.go:483-509` `nodeDocker()` | 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 | **A** | `internal/api/stacks.go:439-479` `hubStacks()`, `:1036-1074` `homesFor()`, `web/src/Docker.tsx` "kept on `<hub>`, deployed to this machine over `tcp://…`" | 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 | **B** | `internal/api/stacks.go:290-382` `homesOf()`; `:373-376` fallback `~/stacks` | 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 | **B** | `internal/api/stacks.go:801` | Inside a `Via` container the command is literally `docker compose`, even when `dockerTool()` is `podman`. | Use the detected tool. |
|
||||||
|
| 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|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 | **B** | 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).
|
||||||
|
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.
|
||||||
|
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,
|
users` lists them, `hamlab user-rm <user>` removes one. Machine settings — refresh rate,
|
||||||
graph history, alert rules — stay the machine's and are shared by everyone.
|
graph history, alert rules — stay the machine's and are shared by everyone.
|
||||||
|
|
||||||
|
**Guest logins**, for showing somebody the console: Users → Guest logins makes a
|
||||||
|
throwaway user name and password, good for half an hour by default (up to 24 hours), and
|
||||||
|
hands you the pair to paste into a message. A guest is an ordinary account with a clock on
|
||||||
|
it — the whole console, every machine, the terminal included — that stops working when the
|
||||||
|
time is up, wherever it is signed in, or the moment you revoke it. There is no reduced
|
||||||
|
guest: give one to somebody you would hand the password to, and revoke it if you change
|
||||||
|
your mind.
|
||||||
|
|
||||||
What adapts to the host, rather than assuming Debian:
|
What adapts to the host, rather than assuming Debian:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -62,6 +70,8 @@ What adapts to the host, rather than assuming Debian:
|
|||||||
| Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) |
|
| Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) |
|
||||||
| Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd |
|
| Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd |
|
||||||
| Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there |
|
| Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there |
|
||||||
|
| Network | `/proc` and `/sys` for the interfaces, addresses, link speed and default route. The speed test needs no tool installed: it measures against Cloudflare's public speed endpoints, so it works the same on Debian and on the OpenWrt router |
|
||||||
|
| VPN | WireGuard tunnels and their peers, from `wg show` where wireguard-tools is installed; peer names from wg-quick `.conf` comments or OpenWrt UCI descriptions. Read-only, and it never reads a private or pre-shared key |
|
||||||
| Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed |
|
| Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed |
|
||||||
| Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start |
|
| Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start |
|
||||||
| Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept |
|
| Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept |
|
||||||
|
|||||||
+21
@@ -16,9 +16,12 @@ cpu.usage cpu.core.<n>.usage cpu.temp load.1|5|15 uptime
|
|||||||
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
||||||
temp.<chip>.<label> fan.<chip>.<label>
|
temp.<chip>.<label> fan.<chip>.<label>
|
||||||
net.<iface>.rx|tx|rx_total|tx_total|up
|
net.<iface>.rx|tx|rx_total|tx_total|up
|
||||||
|
net.internet.download|upload|latency|jitter|tested|ip|edge
|
||||||
disk.<dev>.read|write|util|size|temp
|
disk.<dev>.read|write|util|size|temp
|
||||||
fs.<mount>.size|used|used_pct
|
fs.<mount>.size|used|used_pct
|
||||||
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
||||||
|
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` →
|
- Segments are lower case `[a-z0-9_-]`: `Package id 0` → `package_id_0`, `/mnt/tank` →
|
||||||
@@ -26,6 +29,24 @@ ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
|||||||
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between
|
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between
|
||||||
boots). Two identical chips collide; the second gets `_2`.
|
boots). Two identical chips collide; the second gets `_2`.
|
||||||
- Which sensors exist differs per machine. That is the point of the catalog.
|
- Which sensors exist differs per machine. That is the point of the catalog.
|
||||||
|
- `net.internet.*` is the Network tab's speed test: `download` and `upload` in Mbps,
|
||||||
|
`latency` and `jitter` in ms, `tested` the age of the reading in seconds, and `ip` and
|
||||||
|
`edge` the public address and the Cloudflare edge as strings. They appear only once a test
|
||||||
|
has run on that machine, and each one's `t` is when it ran — so a widget or an alert on
|
||||||
|
them is reading a measurement from that moment, not a live value. Nothing takes a speed
|
||||||
|
test on its own; something has to press the button.
|
||||||
|
- `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
|
## A sensor
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# Virtualization tab — plan (2026-10-09; phase 1 built the same day)
|
||||||
|
|
||||||
|
**Status:** phase 1 is **deployed to all five machines as v0.1.0-184** (2026-10-09) and
|
||||||
|
**Install has run for real on `grid`**: Incus 6.0.4, a zfs pool on a 197 GiB sparse file in
|
||||||
|
`~/apps/hamlab/virt/`, `incusbr0` at `172.16.100.1/24`, the two `DOCKER-USER` rules, the
|
||||||
|
`incus-admin` group and Ham Lab's restart — one task, 90 seconds, exit 0. Verified
|
||||||
|
afterwards: a Debian 13 container and a Debian 13 VM created from the hub, both got a lease,
|
||||||
|
DNS and the internet through Docker's `FORWARD DROP`, the VM's agent answered `exec`;
|
||||||
|
snapshot create/restore/delete, limits and a snapshot schedule, a shell as a Terminal tab
|
||||||
|
through the hub, the catalogue (98 x86_64 container images, 1.1 s then cached), and the
|
||||||
|
refusals (bad names, wrong delete confirmation, a size that isn't one). Both test instances
|
||||||
|
were deleted again; the two Debian images stay in the store. One bug found and fixed on the
|
||||||
|
way: `description` is an instance field, not a config key (v0.1.0-184). Detection on the
|
||||||
|
other four: `core`, the Mac mini and `hamtv` show **Install**; `gate` shows **unavailable**
|
||||||
|
(OpenWrt's apk has no incus, no KVM). Not built yet from the phase-1 list: the `instance.*`
|
||||||
|
sensors and the Alerts default — next session. The write-back for `grid` is in
|
||||||
|
`hamlet/docs/hosts.md`, `docker.md` and `gotchas.md`.
|
||||||
|
|
||||||
|
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** | 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/
|
||||||
+116
-1
@@ -4,6 +4,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -31,6 +32,7 @@ import (
|
|||||||
"hamlab/internal/files"
|
"hamlab/internal/files"
|
||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
"hamlab/internal/nodes"
|
"hamlab/internal/nodes"
|
||||||
|
"hamlab/internal/priv"
|
||||||
"hamlab/internal/services"
|
"hamlab/internal/services"
|
||||||
"hamlab/internal/setup"
|
"hamlab/internal/setup"
|
||||||
"hamlab/internal/space"
|
"hamlab/internal/space"
|
||||||
@@ -59,6 +61,8 @@ func main() {
|
|||||||
serve run the console in the foreground (what the service runs)
|
serve run the console in the foreground (what the service runs)
|
||||||
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
|
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
|
||||||
users list the accounts
|
users list the accounts
|
||||||
|
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
|
||||||
|
docker-update <id> run one image-update schedule (what its cron line calls)
|
||||||
user-rm <user> remove an account and sign it out everywhere
|
user-rm <user> remove an account and sign it out everywhere
|
||||||
check verify the config and state load, then exit
|
check verify the config and state load, then exit
|
||||||
version
|
version
|
||||||
@@ -78,6 +82,8 @@ func main() {
|
|||||||
os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
|
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
|
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))
|
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 "install", "uninstall":
|
case "install", "uninstall":
|
||||||
var err error
|
var err error
|
||||||
if cmd == "install" {
|
if cmd == "install" {
|
||||||
@@ -129,6 +135,14 @@ func main() {
|
|||||||
if err := passwd(cfg, flag.Arg(1)); err != nil {
|
if err := passwd(cfg, flag.Arg(1)); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
case "docker-update":
|
||||||
|
if err := dockerUpdateJob(cfg, flag.Arg(1)); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
case "zfs-snapshot":
|
||||||
|
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
case "users":
|
case "users":
|
||||||
migrateUsers(cfg)
|
migrateUsers(cfg)
|
||||||
if len(cfg.Users) == 0 {
|
if len(cfg.Users) == 0 {
|
||||||
@@ -139,7 +153,14 @@ func main() {
|
|||||||
if !u.LastSeen.IsZero() {
|
if !u.LastSeen.IsZero() {
|
||||||
seen = u.LastSeen.Local().Format("2006-01-02 15:04")
|
seen = u.LastSeen.Local().Format("2006-01-02 15:04")
|
||||||
}
|
}
|
||||||
fmt.Printf("%-20s created %s last seen %s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen)
|
note := ""
|
||||||
|
if u.Guest { // a temporary look-only login, not one of the machine's accounts
|
||||||
|
note = " guest, until " + u.Expires.Local().Format("15:04")
|
||||||
|
if u.Expired(time.Now()) {
|
||||||
|
note = " guest, expired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("%-20s created %s last seen %s%s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen, note)
|
||||||
}
|
}
|
||||||
case "user-rm":
|
case "user-rm":
|
||||||
if err := userRm(cfg, flag.Arg(1)); err != nil {
|
if err := userRm(cfg, flag.Arg(1)); err != nil {
|
||||||
@@ -178,6 +199,100 @@ func main() {
|
|||||||
|
|
||||||
// migrateUsers gives a pre-users config its first account, named after this OS user, so
|
// migrateUsers gives a pre-users config its first account, named after this OS user, so
|
||||||
// the CLI sees the same accounts the daemon will.
|
// the CLI sees the same accounts the daemon will.
|
||||||
|
// dockerUpdateJob pulls a stack's images, and deploys them when the schedule says to. This
|
||||||
|
// is what the cron line Ham Lab writes actually calls, so image updates keep happening
|
||||||
|
// whether or not the console is up.
|
||||||
|
func dockerUpdateJob(cfg *config.Config, id string) error {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return errors.New("usage: hamlab docker-update <schedule-id>")
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var raw json.RawMessage
|
||||||
|
st.View(func(s *store.State) { raw = s.DockerJobs })
|
||||||
|
var d struct {
|
||||||
|
Jobs []api.UpdateJob `json:"jobs"`
|
||||||
|
}
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &d); err != nil {
|
||||||
|
return fmt.Errorf("reading the schedules: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, j := range d.Jobs {
|
||||||
|
if j.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A pull that never finishes must not leave a cron job resident until the next reboot.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
out, runErr := api.RunUpdate(ctx, st, j, api.DockerTool())
|
||||||
|
rec := storage.RunRecord{At: time.Now(), Taken: j.Stack}
|
||||||
|
if runErr != nil {
|
||||||
|
rec.Error = runErr.Error()
|
||||||
|
}
|
||||||
|
if err := storage.WriteRun(cfg.DataDir(), "docker-"+id, rec); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "recording the run:", err)
|
||||||
|
}
|
||||||
|
fmt.Print(out)
|
||||||
|
return runErr
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no image-update schedule %q — it may have been removed; this cron line is stale", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zfsSnapshot runs one snapshot schedule: take it, then expire what has aged out. This is
|
||||||
|
// what the cron line Ham Lab writes actually calls, so a schedule keeps running whether or
|
||||||
|
// not the console is up — and keeps running if Ham Lab is never opened again.
|
||||||
|
//
|
||||||
|
// It deliberately does not write state.json. The daemon owns that file and would clobber
|
||||||
|
// anything written from out here; the outcome goes beside it instead, in zfs-runs/.
|
||||||
|
func zfsSnapshot(cfg *config.Config, id string) error {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return errors.New("usage: hamlab zfs-snapshot <schedule-id>")
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var raw json.RawMessage
|
||||||
|
st.View(func(s *store.State) { raw = s.ZFS })
|
||||||
|
var z struct {
|
||||||
|
Policies []storage.Policy `json:"policies"`
|
||||||
|
}
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &z); err != nil {
|
||||||
|
return fmt.Errorf("reading the schedules: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var p *storage.Policy
|
||||||
|
for i := range z.Policies {
|
||||||
|
if z.Policies[i].ID == id {
|
||||||
|
p = &z.Policies[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
return fmt.Errorf("no snapshot schedule %q — it may have been removed; this cron line is stale", id)
|
||||||
|
}
|
||||||
|
res, runErr := storage.New(priv.Available()).Run(context.Background(), p)
|
||||||
|
rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned}
|
||||||
|
if runErr != nil {
|
||||||
|
rec.Error = runErr.Error()
|
||||||
|
}
|
||||||
|
if err := storage.WriteRun(cfg.DataDir(), id, rec); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "recording the run:", err)
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
return runErr
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: took %s", p.Name, res.Taken)
|
||||||
|
if len(res.Pruned) > 0 {
|
||||||
|
fmt.Printf(", expired %d", len(res.Pruned))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func migrateUsers(cfg *config.Config) {
|
func migrateUsers(cfg *config.Config) {
|
||||||
name := os.Getenv("USER")
|
name := os.Getenv("USER")
|
||||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||||
|
|||||||
+245
-10
@@ -3,8 +3,8 @@ package api
|
|||||||
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
|
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
|
||||||
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
|
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
|
||||||
// It drives the docker CLI (or podman), as the user first and as root if that is refused.
|
// It drives the docker CLI (or podman), as the user first and as root if that is refused.
|
||||||
// Creating, removing and pruning are deliberately absent: whatever deployed the stacks
|
// What can be removed here is what nothing uses: an untagged image, a volume no container
|
||||||
// (Dockhand, compose) stays the one place that changes what exists.
|
// holds, a network nothing is attached to. Compose stays the one place a stack is defined.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -49,6 +49,10 @@ func dockerTool() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dockerRun runs the tool as this user, and again as root if the socket said no.
|
// dockerRun runs the tool as this user, and again as root if the socket said no.
|
||||||
|
// DockerTool is whichever of docker or podman this machine has, for callers outside the
|
||||||
|
// package — the scheduled updates the CLI runs.
|
||||||
|
func DockerTool() string { return dockerTool() }
|
||||||
|
|
||||||
func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
tool := dockerTool()
|
tool := dockerTool()
|
||||||
if tool == "" {
|
if tool == "" {
|
||||||
@@ -72,6 +76,18 @@ func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dockerWhy picks the line worth showing out of a failed command. Some subcommands —
|
||||||
|
// `network rm` among them — print the real reason first and then an aggregated "exit
|
||||||
|
// status 1", which is what dockerRun's last-line rule would otherwise hand back.
|
||||||
|
func dockerWhy(out []byte, err error) string {
|
||||||
|
for _, l := range strings.Split(string(out), "\n") {
|
||||||
|
if l = strings.TrimSpace(l); strings.HasPrefix(l, "Error") || strings.Contains(l, "daemon") {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func jsonLines(out []byte, each func(map[string]any)) {
|
func jsonLines(out []byte, each func(map[string]any)) {
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
@@ -135,23 +151,30 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
// Who uses what, from one inspect of every container: an image or a volume nothing uses is
|
// Who uses what, from one inspect of every container: an image, a volume or a network
|
||||||
// the only kind this page offers to remove.
|
// nothing uses is the only kind this page offers to remove.
|
||||||
imageUsed, volumeUsers := map[string]bool{}, map[string][]string{}
|
imageUsed := map[string]bool{}
|
||||||
|
volumeUsers, networkUsers := map[string][]string{}, map[string][]string{}
|
||||||
if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 {
|
if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 {
|
||||||
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}`
|
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}|{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}`
|
||||||
if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil {
|
if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil {
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
p := strings.SplitN(line, "|", 3)
|
p := strings.SplitN(line, "|", 4)
|
||||||
if len(p) < 3 {
|
if len(p) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
name := strings.TrimPrefix(p[0], "/")
|
||||||
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
|
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
|
||||||
imageUsed[id[:12]] = true
|
imageUsed[id[:12]] = true
|
||||||
}
|
}
|
||||||
for _, v := range strings.Split(p[2], ",") {
|
for _, v := range strings.Split(p[2], ",") {
|
||||||
if v != "" {
|
if v != "" {
|
||||||
volumeUsers[v] = append(volumeUsers[v], strings.TrimPrefix(p[0], "/"))
|
volumeUsers[v] = append(volumeUsers[v], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range strings.Split(p[3], ",") {
|
||||||
|
if n != "" {
|
||||||
|
networkUsers[n] = append(networkUsers[n], name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,8 +194,18 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
v["used_by"] = users
|
v["used_by"] = users
|
||||||
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
|
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
|
||||||
}
|
}
|
||||||
|
networks := list("network", "ls", "--format", "{{json .}}")
|
||||||
|
for _, n := range networks {
|
||||||
|
users := networkUsers[dstr(n, "Name")]
|
||||||
|
if users == nil {
|
||||||
|
users = []string{}
|
||||||
|
}
|
||||||
|
sort.Strings(users)
|
||||||
|
n["used_by"] = users
|
||||||
|
n["predefined"] = predefinedNetwork[dstr(n, "Name")]
|
||||||
|
}
|
||||||
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
|
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
|
||||||
"images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")})
|
"images": images, "volumes": volumes, "networks": networks})
|
||||||
}
|
}
|
||||||
|
|
||||||
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
||||||
@@ -394,3 +427,205 @@ func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("docker: volume prune (from %s)", clientIP(r))
|
log.Printf("docker: volume prune (from %s)", clientIP(r))
|
||||||
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The three networks Docker makes itself. They hold nothing, cannot be removed, and
|
||||||
|
// their absence would break every container that asks for one by name.
|
||||||
|
var predefinedNetwork = map[string]bool{"bridge": true, "host": true, "none": true}
|
||||||
|
|
||||||
|
// networkRemove deletes one network. Unlike a volume there is no data in it, and unlike an
|
||||||
|
// image nothing has to be downloaded again — a stack that wants it back gets it recreated
|
||||||
|
// on the next deploy. Docker refuses one a container is still attached to, which is the
|
||||||
|
// check that matters, so this asks for no second confirmation.
|
||||||
|
func (s *Server) networkRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct{ Name string }
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) {
|
||||||
|
jsonErr(w, 400, "which network?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if predefinedNetwork[body.Name] {
|
||||||
|
jsonErr(w, 400, body.Name+" is one of Docker's own networks and can't be removed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := dockerRun(ctx, "network", "rm", body.Name); err != nil {
|
||||||
|
jsonErr(w, 409, dockerWhy(out, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker: network rm %s (from %s)", body.Name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkPrune removes every network no container is attached to. A stack that is merely
|
||||||
|
// down counts as unattached, so its network goes too — and comes back on the next deploy,
|
||||||
|
// with a new subnet. That is the whole cost, and the caller is told it.
|
||||||
|
func (s *Server) networkPrune(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
out, err := dockerRun(ctx, "network", "prune", "-f")
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
log.Printf("docker: network prune (from %s)", clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- a container's health, its recent life, its console, and its limits ----
|
||||||
|
|
||||||
|
// dockerHealth is the health check's verdict and the last few probe results. Docker keeps
|
||||||
|
// the last five itself, which is all anyone wants: is it failing, and what did it say.
|
||||||
|
func (s *Server) dockerHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := dockerRun(ctx, "inspect", "--format", "{{json .State}}", id)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var st struct {
|
||||||
|
Status string `json:"Status"`
|
||||||
|
Health *struct {
|
||||||
|
Status string `json:"Status"`
|
||||||
|
FailingStreak int `json:"FailingStreak"`
|
||||||
|
Log []struct {
|
||||||
|
Start time.Time `json:"Start"`
|
||||||
|
End time.Time `json:"End"`
|
||||||
|
ExitCode int `json:"ExitCode"`
|
||||||
|
Output string `json:"Output"`
|
||||||
|
} `json:"Log"`
|
||||||
|
} `json:"Health"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(out, &st) != nil {
|
||||||
|
jsonErr(w, 500, "could not read the container's state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.Health == nil {
|
||||||
|
jsonOK(w, 200, map[string]any{"configured": false, "state": st.Status})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{
|
||||||
|
"configured": true, "state": st.Status, "status": st.Health.Status,
|
||||||
|
"failing_streak": st.Health.FailingStreak, "log": st.Health.Log,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerExec opens a shell inside a container, as a terminal session like any other — so
|
||||||
|
// it reattaches, resizes and scrolls back the same way, and works on a managed machine
|
||||||
|
// through the same proxy.
|
||||||
|
func (s *Server) dockerExec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tool := dockerTool()
|
||||||
|
if tool == "" {
|
||||||
|
jsonErr(w, 500, "neither docker nor podman is installed here")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Shell string `json:"shell"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||||
|
shell := strings.TrimSpace(body.Shell)
|
||||||
|
if shell == "" {
|
||||||
|
// most images have sh; bash is nicer where it exists, so try it first and fall back
|
||||||
|
shell = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi"
|
||||||
|
} else if strings.ContainsAny(shell, "\n\r") {
|
||||||
|
jsonErr(w, 400, "bad shell")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
argv := []string{tool, "exec", "-it", id, "sh", "-c", shell}
|
||||||
|
if priv.Available() && !dockerReachable() {
|
||||||
|
argv = priv.Wrap(argv...)
|
||||||
|
}
|
||||||
|
sess, err := s.mgr.CreateCmd("", "sh "+shortName(id), argv)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker exec into %s as terminal %s by %s", id, sess.ID, clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"id": sess.ID, "name": "sh " + shortName(id)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortName(id string) string {
|
||||||
|
if len(id) > 20 {
|
||||||
|
return id[:20]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerReachable reports whether docker answers without help.
|
||||||
|
func dockerReachable() bool {
|
||||||
|
tool := dockerTool()
|
||||||
|
if tool == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return exec.CommandContext(ctx, tool, "version", "--format", "{{.Server.Version}}").Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerUpdate changes a running container's limits and restart policy. `docker update`
|
||||||
|
// does this live, without recreating it and without touching the compose file — which is
|
||||||
|
// also the catch, and the caller is told: a deploy from compose puts back whatever compose
|
||||||
|
// says.
|
||||||
|
func (s *Server) dockerUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Memory string `json:"memory"` // "512m", "2g", or "" to leave alone; "0" removes the limit
|
||||||
|
CPUs string `json:"cpus"` // "1.5", or "0" for no limit
|
||||||
|
Restart string `json:"restart"` // no | on-failure | unless-stopped | always
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := []string{"update"}
|
||||||
|
if m := strings.TrimSpace(body.Memory); m != "" {
|
||||||
|
if !regexp.MustCompile(`^[0-9]+([bkmgBKMG])?$`).MatchString(m) {
|
||||||
|
jsonErr(w, 400, "memory: a number, optionally with b, k, m or g — 0 removes the limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, "--memory", m, "--memory-swap", m)
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(body.CPUs); c != "" {
|
||||||
|
if !regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`).MatchString(c) {
|
||||||
|
jsonErr(w, 400, "cpus: a number like 1.5 — 0 removes the limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, "--cpus", c)
|
||||||
|
}
|
||||||
|
if p := strings.TrimSpace(body.Restart); p != "" {
|
||||||
|
switch p {
|
||||||
|
case "no", "on-failure", "unless-stopped", "always":
|
||||||
|
default:
|
||||||
|
jsonErr(w, 400, "restart: no, on-failure, unless-stopped or always")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, "--restart", p)
|
||||||
|
}
|
||||||
|
if len(args) == 1 {
|
||||||
|
jsonErr(w, 400, "nothing to change")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := dockerRun(ctx, append(args, id)...); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker update %s %v by %s", id, args[1:], clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,351 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Scheduled image updates.
|
||||||
|
//
|
||||||
|
// Dockhand kept three containers up to date nightly — unbound, nginx and pihole, which is
|
||||||
|
// DNS and ingress — and that is the one thing it does that Ham Lab did not. This replaces
|
||||||
|
// it, and deliberately in the same shape as the ZFS snapshot schedules: a real line in a
|
||||||
|
// cron file Ham Lab owns, calling the binary, rather than a ticker inside the daemon.
|
||||||
|
// Updating an image is not something that should stop happening because a web console is
|
||||||
|
// down, and a cron line is visible to anyone looking at the machine the normal way.
|
||||||
|
//
|
||||||
|
// A schedule stores everything the run needs — project, compose files, how compose has to
|
||||||
|
// be invoked for that stack — so the cron job is self-contained and does not have to
|
||||||
|
// rediscover the world before it can act.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
|
"hamlab/internal/cron"
|
||||||
|
"hamlab/internal/storage"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateJob is a standing instruction to pull a stack's images, and optionally deploy them.
|
||||||
|
type UpdateJob struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Stack string `json:"stack"`
|
||||||
|
Machine string `json:"machine,omitempty"` // for display; the job runs where it was made
|
||||||
|
// Services narrows it to some of the stack's services; empty is all of them.
|
||||||
|
Services []string `json:"services,omitempty"`
|
||||||
|
Cron string `json:"cron"`
|
||||||
|
// Apply says whether a newer image is deployed or only fetched. Check-only still pulls,
|
||||||
|
// so the Docker tab shows what is behind without a registry round trip.
|
||||||
|
Apply bool `json:"apply"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// How to run compose for this stack, captured when the schedule was made.
|
||||||
|
Spec composeSpec `json:"spec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type composeSpec struct {
|
||||||
|
Project string `json:"project"`
|
||||||
|
Files []string `json:"files"`
|
||||||
|
Via string `json:"via,omitempty"`
|
||||||
|
Remote string `json:"remote,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c composeSpec) toSpec(tool string) compose.Spec {
|
||||||
|
return compose.Spec{Project: c.Project, Files: c.Files, Via: c.Via, Remote: c.Remote, Tool: tool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dockerCronFile = "/etc/cron.d/hamlab-docker"
|
||||||
|
|
||||||
|
type dockerSchedState struct {
|
||||||
|
Jobs []UpdateJob `json:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsLoad() dockerSchedState {
|
||||||
|
var raw json.RawMessage
|
||||||
|
s.store.View(func(st *store.State) { raw = st.DockerJobs })
|
||||||
|
var d dockerSchedState
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &d)
|
||||||
|
}
|
||||||
|
if d.Jobs == nil {
|
||||||
|
d.Jobs = []UpdateJob{}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsSave(d dockerSchedState) error {
|
||||||
|
b, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.Update(func(st *store.State) { st.DockerJobs = b })
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsCronText renders the schedules as cron lines. Same shape as the ZFS file: Ham Lab owns
|
||||||
|
// it whole and rewrites it on every change.
|
||||||
|
func dsCronText(jobs []UpdateJob, user, exe, config string) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# Ham Lab image-update schedules — written by the Docker tab, rewritten whole on every change.\n")
|
||||||
|
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
|
||||||
|
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||||
|
for i := range jobs {
|
||||||
|
j := &jobs[i]
|
||||||
|
if !j.Enabled {
|
||||||
|
b.WriteString("# " + j.Name + " — paused\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expr := strings.Join(strings.Fields(j.Cron), " ")
|
||||||
|
if err := cron.ValidSchedule(expr); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
what := "pull only"
|
||||||
|
if j.Apply {
|
||||||
|
what = "pull and deploy"
|
||||||
|
}
|
||||||
|
b.WriteString("# " + j.Name + " — " + j.Stack + ", " + what + "\n")
|
||||||
|
b.WriteString(expr + " " + user + " " + exe + " -config " + config + " docker-update " + j.ID + "\n\n")
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsSyncCron(ctx context.Context, d dockerSchedState) error {
|
||||||
|
live := false
|
||||||
|
for i := range d.Jobs {
|
||||||
|
if d.Jobs[i].Enabled {
|
||||||
|
live = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !live {
|
||||||
|
return s.storage.RemoveRootFile(ctx, dockerCronFile)
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exe, _ = filepath.EvalSymlinks(exe)
|
||||||
|
text, err := dsCronText(d.Jobs, s.user, exe, s.cfg.Path())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.storage.WriteRootFile(ctx, dockerCronFile, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoint keeps a schedule working when what deployed its stack has been removed.
|
||||||
|
//
|
||||||
|
// A schedule captures how to invoke compose at the moment it is made, so the cron job never
|
||||||
|
// has to rediscover the world at 3am. But "run compose inside this container" stops being
|
||||||
|
// true the day that container is deleted — which is exactly what happens when an
|
||||||
|
// orchestrator is retired, and would otherwise break every schedule made before it went.
|
||||||
|
// So: if the container named is not running, the files are looked up again, through live
|
||||||
|
// mounts and remembered ones both, and compose runs on the host instead.
|
||||||
|
func repoint(ctx context.Context, db *store.Store, spec compose.Spec) compose.Spec {
|
||||||
|
if spec.Via == "" || db == nil {
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
if out, err := dockerRun(ctx, "inspect", "-f", "{{.State.Running}}", spec.Via); err == nil && strings.TrimSpace(string(out)) == "true" {
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
st := stack{Name: spec.Project, File: spec.Files[0], Files: spec.Files}
|
||||||
|
st.resolve(mountsSeen(ctx, db)) // read-only: this also runs from cron, where the daemon owns state.json
|
||||||
|
if !st.Editable || st.Via != "" {
|
||||||
|
return spec // still only reachable through a container, or not reachable at all
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %s: %s is gone, running compose here against %s", spec.Project, spec.Via, st.HostFiles[0])
|
||||||
|
spec.Files, spec.Via = st.HostFiles, ""
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUpdate pulls, and deploys when the schedule says to. Shared with the CLI so the button
|
||||||
|
// and the cron line cannot drift apart.
|
||||||
|
func RunUpdate(ctx context.Context, db *store.Store, j UpdateJob, tool string) (string, error) {
|
||||||
|
if len(j.Spec.Files) == 0 {
|
||||||
|
return "", errors.New("this schedule has no compose file recorded — open it in the Docker tab and save it again")
|
||||||
|
}
|
||||||
|
spec := repoint(ctx, db, j.Spec.toSpec(tool))
|
||||||
|
var log strings.Builder
|
||||||
|
run := func(args ...string) error {
|
||||||
|
cmd := compose.Cmd(ctx, spec, args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
log.Write(out)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
args := append([]string{"pull"}, j.Services...)
|
||||||
|
if err := run(args...); err != nil {
|
||||||
|
return log.String(), err
|
||||||
|
}
|
||||||
|
if !j.Apply {
|
||||||
|
return log.String(), nil
|
||||||
|
}
|
||||||
|
up := append([]string{"up", "-d"}, j.Services...)
|
||||||
|
if err := run(up...); err != nil {
|
||||||
|
return log.String(), err
|
||||||
|
}
|
||||||
|
return log.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- endpoints ----
|
||||||
|
|
||||||
|
func (s *Server) dsList(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
d := s.dsLoad()
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]map[string]any, 0, len(d.Jobs))
|
||||||
|
for _, j := range d.Jobs {
|
||||||
|
m := map[string]any{"job": j, "last": storage.ReadRun(s.cfg.DataDir(), "docker-"+j.ID)}
|
||||||
|
if j.Enabled {
|
||||||
|
if n, ok := cron.NextRun(strings.Join(strings.Fields(j.Cron), " "), now); ok {
|
||||||
|
m["next"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{"jobs": out, "file": dockerCronFile})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var j UpdateJob
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&j); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.Cron = strings.Join(strings.Fields(j.Cron), " ")
|
||||||
|
if err := cron.ValidSchedule(j.Cron); err != nil {
|
||||||
|
jsonErr(w, 400, "schedule: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(j.Stack) == "" {
|
||||||
|
jsonErr(w, 400, "which stack?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// resolve the stack now, so the cron job never has to
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
stacks, err := s.findStacks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, st := range stacks {
|
||||||
|
if st.Name == j.Stack {
|
||||||
|
// seenFiles is the list as compose itself sees it: host paths normally, the
|
||||||
|
// orchestrator container's own paths when compose runs inside one. Getting this
|
||||||
|
// backwards gives a cron line that works from the console and fails at 3am.
|
||||||
|
j.Spec = composeSpec{Project: st.Name, Files: seenFiles(st, -1, ""), Via: st.Via, Remote: st.Remote}
|
||||||
|
if len(j.Spec.Files) == 0 {
|
||||||
|
jsonErr(w, 409, "that stack's compose file isn't anywhere this console can reach")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no stack called "+j.Stack+" on this machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(j.Name) == "" {
|
||||||
|
j.Name = j.Stack
|
||||||
|
}
|
||||||
|
d := s.dsLoad()
|
||||||
|
replaced := false
|
||||||
|
for i := range d.Jobs {
|
||||||
|
if d.Jobs[i].ID == j.ID && j.ID != "" {
|
||||||
|
d.Jobs[i] = j
|
||||||
|
replaced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.ID = hex.EncodeToString(b)
|
||||||
|
d.Jobs = append(d.Jobs, j)
|
||||||
|
}
|
||||||
|
if err := s.dsSave(d); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.dsSyncCron(r.Context(), d); err != nil {
|
||||||
|
jsonErr(w, 500, "saved, but writing "+dockerCronFile+" failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %q saved by %s (%s, %q, %s)", j.Name, clientIP(r), j.Stack, j.Cron, map[bool]string{true: "pull and deploy", false: "pull only"}[j.Apply])
|
||||||
|
jsonOK(w, 200, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
d := s.dsLoad()
|
||||||
|
kept := d.Jobs[:0]
|
||||||
|
found := false
|
||||||
|
for _, j := range d.Jobs {
|
||||||
|
if j.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, j)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Jobs = kept
|
||||||
|
if err := s.dsSave(d); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.dsSyncCron(r.Context(), d); err != nil {
|
||||||
|
jsonErr(w, 500, "removed, but writing "+dockerCronFile+" failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storage.ForgetRun(s.cfg.DataDir(), "docker-"+id)
|
||||||
|
log.Printf("docker schedule %s removed by %s", id, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
var job UpdateJob
|
||||||
|
found := false
|
||||||
|
for _, j := range s.dsLoad().Jobs {
|
||||||
|
if j.ID == id {
|
||||||
|
job, found = j, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Outliving the request: a pull the browser gave up on still finishes, but not forever.
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
out, err := RunUpdate(ctx, s.store, job, dockerTool())
|
||||||
|
rec := storage.RunRecord{At: time.Now(), Taken: job.Stack}
|
||||||
|
if err != nil {
|
||||||
|
rec.Error = err.Error()
|
||||||
|
}
|
||||||
|
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
|
||||||
|
stackUpdatesForget("/" + job.Stack)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %q run by %s", job.Name, clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"output": tail(out, 4000)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
+165
-22
@@ -33,7 +33,10 @@ import (
|
|||||||
"hamlab/internal/config"
|
"hamlab/internal/config"
|
||||||
"hamlab/internal/cron"
|
"hamlab/internal/cron"
|
||||||
"hamlab/internal/files"
|
"hamlab/internal/files"
|
||||||
|
"hamlab/internal/hardware"
|
||||||
|
"hamlab/internal/incus"
|
||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
|
"hamlab/internal/network"
|
||||||
"hamlab/internal/nodes"
|
"hamlab/internal/nodes"
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
"hamlab/internal/sensors"
|
"hamlab/internal/sensors"
|
||||||
@@ -67,8 +70,13 @@ type Server struct {
|
|||||||
tasks *tasks.Manager
|
tasks *tasks.Manager
|
||||||
updates *updates.Manager
|
updates *updates.Manager
|
||||||
storage *storage.Manager
|
storage *storage.Manager
|
||||||
|
hardware *hardware.Manager
|
||||||
space *space.Manager
|
space *space.Manager
|
||||||
|
net *network.Watcher
|
||||||
update updateState
|
update updateState
|
||||||
|
events eventLog
|
||||||
|
kvms kvmProxies
|
||||||
|
virt *incus.Manager
|
||||||
fsLocal files.FS
|
fsLocal files.FS
|
||||||
fsSudo files.FS // nil without passwordless sudo
|
fsSudo files.FS // nil without passwordless sudo
|
||||||
nodes *nodes.Manager
|
nodes *nodes.Manager
|
||||||
@@ -85,7 +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 {
|
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
|
||||||
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
|
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
|
||||||
started: time.Now(), user: os.Getenv("USER")}
|
hardware: hardware.New(), started: time.Now(), user: os.Getenv("USER")}
|
||||||
self, err := os.Executable()
|
self, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
self = "hamlab"
|
self = "hamlab"
|
||||||
@@ -98,7 +106,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
if s.user == "" {
|
if s.user == "" {
|
||||||
s.user = "?"
|
s.user = "?"
|
||||||
}
|
}
|
||||||
|
s.virt = incus.New(cfg.DataDir(), s.user, version)
|
||||||
s.migrateUsers()
|
s.migrateUsers()
|
||||||
|
// The speed test and the outside view. Its history outlives a restart, and its last result
|
||||||
|
// is published as sensors, so the line is graphable and alertable like anything else.
|
||||||
|
s.net = network.NewWatcher(s.keepResult)
|
||||||
|
s.net.Test.Load(s.loadNetwork())
|
||||||
|
s.sensors.Add(netSensors{s.net})
|
||||||
s.sensors.Add(newStatusSensors(s))
|
s.sensors.Add(newStatusSensors(s))
|
||||||
s.alerts = newAlertEngine(s)
|
s.alerts = newAlertEngine(s)
|
||||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||||
@@ -136,8 +150,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
}
|
}
|
||||||
go s.keepFleetInStep()
|
go s.keepFleetInStep()
|
||||||
}
|
}
|
||||||
|
go s.watchGuests()
|
||||||
go s.watchForUpdates(context.Background())
|
go s.watchForUpdates(context.Background())
|
||||||
|
go s.watchDockerEvents(context.Background())
|
||||||
|
|
||||||
|
s.mux = s.routes()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes is the whole HTTP surface. A method of its own, rather than inline in New, so a
|
||||||
|
// test can build the real router without the machinery behind it — which is what the guest
|
||||||
|
// gate is checked against, since it decides a request by asking this router where it would go.
|
||||||
|
func (s *Server) routes() *http.ServeMux {
|
||||||
m := http.NewServeMux()
|
m := http.NewServeMux()
|
||||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
||||||
m.HandleFunc("POST /api/login", s.login)
|
m.HandleFunc("POST /api/login", s.login)
|
||||||
@@ -184,11 +208,19 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
|
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
|
||||||
m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove))
|
m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove))
|
||||||
m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune))
|
m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune))
|
||||||
|
m.HandleFunc("POST /api/docker/networks/remove", s.auth(s.networkRemove))
|
||||||
|
m.HandleFunc("POST /api/docker/networks/prune", s.auth(s.networkPrune))
|
||||||
m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
|
m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
|
||||||
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
|
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
|
||||||
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
||||||
m.HandleFunc("GET /api/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
m.HandleFunc("GET /api/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
||||||
m.HandleFunc("GET /api/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
m.HandleFunc("GET /api/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
||||||
|
m.HandleFunc("GET /api/docker/updates", s.auth(s.machineUpdates))
|
||||||
|
m.HandleFunc("GET /api/docker/schedules", s.auth(s.dsList))
|
||||||
|
m.HandleFunc("PUT /api/docker/schedules", s.auth(s.dsPut))
|
||||||
|
m.HandleFunc("DELETE /api/docker/schedules/{id}", s.auth(s.dsDelete))
|
||||||
|
m.HandleFunc("POST /api/docker/schedules/{id}/run", s.auth(s.dsRun))
|
||||||
|
m.HandleFunc("GET /api/nodes/{node}/docker/updates", s.auth(s.machineUpdates))
|
||||||
// The same, asked of a node through this hub: the hub answers for the stacks whose files
|
// The same, asked of a node through this hub: the hub answers for the stacks whose files
|
||||||
// it holds (deployed to the node over tcp) and passes everything else to the node.
|
// it holds (deployed to the node over tcp) and passes everything else to the node.
|
||||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
||||||
@@ -204,7 +236,26 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
||||||
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
|
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
|
||||||
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
|
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
|
||||||
|
m.HandleFunc("GET /api/docker/containers/{id}/logs/tail", s.auth(s.dockerTail))
|
||||||
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
|
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
|
||||||
|
m.HandleFunc("GET /api/docker/containers/{id}/health", s.auth(s.dockerHealth))
|
||||||
|
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
|
||||||
|
m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec))
|
||||||
|
m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate))
|
||||||
|
m.HandleFunc("GET /api/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/{name}/{action}", s.auth(s.virtAction))
|
||||||
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
|
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
|
||||||
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
|
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
|
||||||
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
|
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
|
||||||
@@ -245,12 +296,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/rename", s.auth(s.filesRename))
|
||||||
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
||||||
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
||||||
|
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
||||||
|
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
|
||||||
|
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
|
||||||
|
m.HandleFunc("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", s.auth(s.storageOverview))
|
||||||
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
|
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
|
||||||
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
|
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
|
||||||
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
|
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
|
||||||
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
||||||
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
||||||
|
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
|
||||||
|
m.HandleFunc("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", s.auth(s.spaceOverview))
|
||||||
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
|
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
|
||||||
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
|
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
|
||||||
@@ -265,9 +331,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("POST /api/users", s.auth(s.addUser))
|
||||||
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
||||||
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
|
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
|
||||||
|
m.HandleFunc("GET /api/guests", s.auth(s.listGuests))
|
||||||
|
m.HandleFunc("POST /api/guests", s.auth(s.addGuest))
|
||||||
|
m.HandleFunc("DELETE /api/guests/{name}", s.auth(s.revokeGuest))
|
||||||
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
||||||
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
||||||
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
||||||
|
m.HandleFunc("POST /api/sessions/revoke-idle", s.auth(s.revokeIdleSessions))
|
||||||
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
|
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
|
||||||
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
|
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
|
||||||
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
|
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
|
||||||
@@ -279,10 +349,22 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
||||||
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
||||||
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
||||||
m.HandleFunc("/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)
|
m.HandleFunc("/", s.static)
|
||||||
s.mux = m
|
return m
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sensorPath reports whether a path belongs to the read-only sensor API.
|
// sensorPath reports whether a path belongs to the read-only sensor API.
|
||||||
@@ -302,9 +384,16 @@ func sensorPath(p string) bool {
|
|||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
// The console must never be framed. An embedded widget exists to be: it is a page
|
// The console must never be framed. Two exceptions: an embedded widget exists to be
|
||||||
// that can only read sensors, and only with a read-only token in its URL.
|
// (a page that can only read sensors, and only with a read-only token in its URL),
|
||||||
if r.URL.Path != "/embed/widget" {
|
// and a proxied KVM is framed by the console itself — 'none' forbids that even from
|
||||||
|
// the same origin, so those paths say 'self' instead of opening up.
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/embed/widget":
|
||||||
|
case strings.HasPrefix(r.URL.Path, kvmPrefix):
|
||||||
|
h.Set("X-Frame-Options", "SAMEORIGIN")
|
||||||
|
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
|
||||||
|
default:
|
||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
}
|
}
|
||||||
@@ -350,14 +439,56 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signedIn reports whether a request carries a live browser session, without the
|
||||||
|
// side effects of the auth middleware.
|
||||||
|
func (s *Server) signedIn(r *http.Request) bool {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := s.sessions.Lookup(c.Value, clientKey(r))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieActor resolves the session cookie to who is calling. answered means the caller has
|
||||||
|
// already been sent an error and the handler must not run: the account behind a live session
|
||||||
|
// has gone away, or a guest login's time is up — so a console left open on a visitor's phone
|
||||||
|
// stops at the expiry, not at whatever they click next.
|
||||||
|
func (s *Server) cookieActor(w http.ResponseWriter, r *http.Request) (a actor, ok, answered bool) {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return actor{}, false, false
|
||||||
|
}
|
||||||
|
ses, live := s.sessions.Lookup(c.Value, clientKey(r))
|
||||||
|
if !live {
|
||||||
|
return actor{}, false, false
|
||||||
|
}
|
||||||
|
var u config.User
|
||||||
|
var found bool
|
||||||
|
s.cfg.View(func(cf *config.Config) { u, found = cf.User(ses.User) })
|
||||||
|
if !found || u.Expired(time.Now()) {
|
||||||
|
s.sessions.Delete(c.Value)
|
||||||
|
s.setCookie(w, r, "", -1)
|
||||||
|
if found {
|
||||||
|
jsonErr(w, 401, "this guest login has expired")
|
||||||
|
} else {
|
||||||
|
jsonErr(w, 401, "that account no longer exists")
|
||||||
|
}
|
||||||
|
return actor{}, false, true
|
||||||
|
}
|
||||||
|
return actor{User: ses.User, Session: ses.ID, token: c.Value, Guest: u.Guest, Expires: u.Expires}, true, false
|
||||||
|
}
|
||||||
|
|
||||||
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
||||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if c, err := r.Cookie(cookieName); err == nil {
|
a, ok, answered := s.cookieActor(w, r)
|
||||||
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok {
|
if answered {
|
||||||
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
|
return
|
||||||
return
|
}
|
||||||
}
|
if ok {
|
||||||
|
next(w, withActor(r, a))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||||
var hashes, names []string
|
var hashes, names []string
|
||||||
@@ -391,11 +522,11 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ok, trusted := false, false
|
ok, trusted := false, false
|
||||||
var patterns []string
|
var patterns []string
|
||||||
if c, err := r.Cookie(cookieName); err == nil {
|
if a, live, answered := s.cookieActor(w, r); answered {
|
||||||
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
|
return
|
||||||
ok, trusted = true, true
|
} else if live {
|
||||||
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
|
ok, trusted = true, true
|
||||||
}
|
r = withActor(r, a)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
||||||
@@ -864,11 +995,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
// name is allowed only while there is exactly one account — what a client from before
|
// name is allowed only while there is exactly one account — what a client from before
|
||||||
// user names sends, and what a hub pairing with a one-user node may leave out.
|
// user names sends, and what a hub pairing with a one-user node may leave out.
|
||||||
func (s *Server) checkLogin(username, password string) (string, bool) {
|
func (s *Server) checkLogin(username, password string) (string, bool) {
|
||||||
var users []config.User
|
var users, real []config.User
|
||||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
s.cfg.View(func(c *config.Config) { users, real = append(users, c.Users...), c.Accounts() })
|
||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
if username == "" && len(users) == 1 {
|
if username == "" && len(real) == 1 { // guest logins are not the account an empty name means
|
||||||
username = users[0].Name
|
username = real[0].Name
|
||||||
}
|
}
|
||||||
if username == "" {
|
if username == "" {
|
||||||
auth.CheckUser(nil, "", password) // take as long as a real check
|
auth.CheckUser(nil, "", password) // take as long as a real check
|
||||||
@@ -1003,8 +1134,14 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
addr = h // the address this console listens on, for the machine menu
|
addr = h // the address this console listens on, for the machine menu
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
me := actorOf(r)
|
||||||
|
var guestUntil any
|
||||||
|
if me.Guest {
|
||||||
|
guestUntil = me.Expires
|
||||||
|
}
|
||||||
jsonOK(w, 200, map[string]any{
|
jsonOK(w, 200, map[string]any{
|
||||||
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
|
"host": s.hostName(), "user": s.user, "account": me.User, "version": s.version,
|
||||||
|
"guest": me.Guest, "guest_until": guestUntil,
|
||||||
"load": load, "uptime": uptime, "service_started": s.started,
|
"load": load, "uptime": uptime, "service_started": s.started,
|
||||||
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
||||||
"update_available": updateVersion,
|
"update_available": updateVersion,
|
||||||
@@ -2440,6 +2577,12 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A path with no file of ours behind it may belong to a KVM's page, which asks for its
|
||||||
|
// own assets by absolute path. Checked here rather than in the router because this
|
||||||
|
// handler is the catch-all — and gated on a session, since this handler is public.
|
||||||
|
if s.signedIn(r) && s.kvmFallback(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// SPA fallback
|
// SPA fallback
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
http.ServeFileFS(w, r, s.ui, "index.html")
|
http.ServeFileFS(w, r, s.ui, "index.html")
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type spaceTarget struct {
|
|||||||
Layout string `json:"layout,omitempty"` // pool: raidz2, mirror, …
|
Layout string `json:"layout,omitempty"` // pool: raidz2, mirror, …
|
||||||
Health string `json:"health,omitempty"` // pool: ONLINE, DEGRADED, …
|
Health string `json:"health,omitempty"` // pool: ONLINE, DEGRADED, …
|
||||||
Size uint64 `json:"size"`
|
Size uint64 `json:"size"`
|
||||||
Used uint64 `json:"used,omitempty"` // pool: allocated
|
Used uint64 `json:"used,omitempty"` // pool: allocated
|
||||||
Disks []string `json:"disks,omitempty"` // pool: its member disks
|
Disks []string `json:"disks,omitempty"` // pool: its member disks
|
||||||
Roots []string `json:"roots"` // the mountpoints a scan walks
|
Roots []string `json:"roots"` // the mountpoints a scan walks
|
||||||
Scan *space.Scan `json:"scan,omitempty"` // the last scan, if any
|
Scan *space.Scan `json:"scan,omitempty"` // the last scan, if any
|
||||||
|
|||||||
+216
-58
@@ -12,6 +12,12 @@ package api
|
|||||||
// relative paths, the .env beside them and the labels all stay exactly as they were
|
// relative paths, the .env beside them and the labels all stay exactly as they were
|
||||||
// deployed — else on the host.
|
// deployed — else on the host.
|
||||||
//
|
//
|
||||||
|
// Those mounts are also written down. An orchestrator can be removed, and when it is, the
|
||||||
|
// containers it left behind still carry labels naming paths inside a filesystem that no
|
||||||
|
// longer exists; without a record of where that filesystem was mounted, every stack on the
|
||||||
|
// machine would become unfindable at once. A remembered mount resolves with no container to
|
||||||
|
// run in, which is right: compose runs on the host against the host's copy of the file.
|
||||||
|
//
|
||||||
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
|
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
|
||||||
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
|
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
|
||||||
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
||||||
@@ -39,7 +45,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
|
"hamlab/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stack struct {
|
type stack struct {
|
||||||
@@ -89,6 +97,100 @@ func stackMounts(ctx context.Context) []mountMap {
|
|||||||
return maps
|
return maps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mountsFor is stackMounts plus memory. An orchestrator that deployed everything here and
|
||||||
|
// has since been removed takes the only record of where its files really are with it: the
|
||||||
|
// containers it left behind are labelled with paths inside *its* filesystem. So every map
|
||||||
|
// seen is written down, and a path that no live container explains is looked for in what
|
||||||
|
// was seen before. A remembered map resolves with no `via`, which is correct — the
|
||||||
|
// container is gone, so compose runs on the host, against the host's copy of the file.
|
||||||
|
func mountsFor(ctx context.Context, db *store.Store) []mountMap {
|
||||||
|
live := stackMounts(ctx)
|
||||||
|
rememberMounts(db, live)
|
||||||
|
return append(live, recallMounts(db, live)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountsSeen is the same without writing anything down. The daemon owns state.json; a cron
|
||||||
|
// job that wrote to it would be clobbered by the next thing the daemon saved, so out there
|
||||||
|
// the record is read and never added to.
|
||||||
|
func mountsSeen(ctx context.Context, db *store.Store) []mountMap {
|
||||||
|
live := stackMounts(ctx)
|
||||||
|
return append(live, recallMounts(db, live)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rememberMounts folds what is mounted now into what was mounted before, and forgets a map
|
||||||
|
// whose source has gone from disk — that one is not coming back, and keeping it would make
|
||||||
|
// a stack look editable when its file no longer exists.
|
||||||
|
//
|
||||||
|
// This is on the path of every stack listing, which the Docker tab polls, so it reads first
|
||||||
|
// and only writes when the set actually changed.
|
||||||
|
func rememberMounts(db *store.Store, live []mountMap) {
|
||||||
|
if len(live) == 0 {
|
||||||
|
return // docker is down or answered nothing; that is not evidence of anything
|
||||||
|
}
|
||||||
|
key := func(c, d string) string { return c + "\x00" + d }
|
||||||
|
var have []store.RememberedMount
|
||||||
|
db.View(func(st *store.State) { have = append([]store.RememberedMount(nil), st.Mounts...) })
|
||||||
|
at := map[string]int{}
|
||||||
|
for i, m := range have {
|
||||||
|
at[key(m.Container, m.Dest)] = i
|
||||||
|
}
|
||||||
|
now, add, moved := time.Now(), 0, false
|
||||||
|
for _, m := range live {
|
||||||
|
i, ok := at[key(m.container, m.dest)]
|
||||||
|
if !ok {
|
||||||
|
add++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if have[i].Src != m.src {
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pruning is the expensive half — a stat, or a sudo test(1), per remembered map — so it
|
||||||
|
// only happens when something else has already made a write necessary.
|
||||||
|
if add == 0 && !moved {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = db.Update(func(st *store.State) {
|
||||||
|
at := map[string]int{}
|
||||||
|
for i, m := range st.Mounts {
|
||||||
|
at[key(m.Container, m.Dest)] = i
|
||||||
|
}
|
||||||
|
for _, m := range live {
|
||||||
|
if i, ok := at[key(m.container, m.dest)]; ok {
|
||||||
|
st.Mounts[i].Src, st.Mounts[i].Seen = m.src, now
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st.Mounts = append(st.Mounts, store.RememberedMount{Container: m.container, Dest: m.dest, Src: m.src, Seen: now})
|
||||||
|
}
|
||||||
|
kept := st.Mounts[:0]
|
||||||
|
for _, m := range st.Mounts {
|
||||||
|
if readable(m.Src) {
|
||||||
|
kept = append(kept, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Mounts = kept
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recallMounts is what was seen before and is not running now.
|
||||||
|
func recallMounts(db *store.Store, live []mountMap) []mountMap {
|
||||||
|
var remembered []store.RememberedMount
|
||||||
|
db.View(func(st *store.State) { remembered = append([]store.RememberedMount(nil), st.Mounts...) })
|
||||||
|
now := map[string]bool{}
|
||||||
|
for _, m := range live {
|
||||||
|
now[m.container+"\x00"+m.dest] = true
|
||||||
|
}
|
||||||
|
var out []mountMap
|
||||||
|
for _, m := range remembered {
|
||||||
|
if now[m.Container+"\x00"+m.Dest] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, mountMap{"", filepath.Clean(m.Dest), filepath.Clean(m.Src)}) // no container: run compose here
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return len(out[i].dest) > len(out[j].dest) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func readable(p string) bool {
|
func readable(p string) bool {
|
||||||
if _, err := os.Stat(p); err == nil {
|
if _, err := os.Stat(p); err == nil {
|
||||||
return true
|
return true
|
||||||
@@ -172,7 +274,7 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maps := stackMounts(ctx)
|
maps := mountsFor(ctx, s.store)
|
||||||
stacks := []stack{}
|
stacks := []stack{}
|
||||||
for _, st := range by {
|
for _, st := range by {
|
||||||
sort.Strings(st.Containers)
|
sort.Strings(st.Containers)
|
||||||
@@ -353,7 +455,7 @@ func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if remote == "" {
|
if remote == "" {
|
||||||
remote, maps = s.nodeDocker(ctx, node), stackMounts(ctx)
|
remote, maps = s.nodeDocker(ctx, node), mountsFor(ctx, s.store)
|
||||||
}
|
}
|
||||||
if remote == "-" {
|
if remote == "-" {
|
||||||
continue
|
continue
|
||||||
@@ -511,24 +613,13 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
// composeCmd is `docker compose …` for this stack, run where its files make sense and
|
// composeCmd is `docker compose …` for this stack, run where its files make sense and
|
||||||
// pointed at the Docker that runs its containers. files are given as compose will see them.
|
// pointed at the Docker that runs its containers. files are given as compose will see them.
|
||||||
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
|
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
|
||||||
tool := dockerTool()
|
return compose.Cmd(ctx, specOf(st, files), args...)
|
||||||
base := []string{"compose", "-p", st.Name}
|
}
|
||||||
for _, f := range files {
|
|
||||||
base = append(base, "-f", f)
|
// specOf is a stack as the compose runner needs it — and as a scheduled update stores it,
|
||||||
}
|
// so the cron job and the console invoke compose identically.
|
||||||
if st.Via != "" {
|
func specOf(st stack, files []string) compose.Spec {
|
||||||
pre := []string{"exec"}
|
return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Remote: st.Remote, Tool: dockerTool()}
|
||||||
if st.Remote != "" {
|
|
||||||
pre = append(pre, "-e", "DOCKER_HOST="+st.Remote)
|
|
||||||
}
|
|
||||||
return exec.CommandContext(ctx, tool, append(append(append(pre, st.Via, "docker"), base...), args...)...)
|
|
||||||
}
|
|
||||||
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
|
|
||||||
cmd.Dir = filepath.Dir(files[0])
|
|
||||||
if st.Remote != "" {
|
|
||||||
cmd.Env = append(os.Environ(), "DOCKER_HOST="+st.Remote)
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate.
|
// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate.
|
||||||
@@ -734,6 +825,12 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
script = base("up -d --remove-orphans") + remember
|
script = base("up -d --remove-orphans") + remember
|
||||||
case "update":
|
case "update":
|
||||||
script = base("pull") + " && " + base("up -d --remove-orphans") + remember
|
script = base("pull") + " && " + base("up -d --remove-orphans") + remember
|
||||||
|
case "recreate":
|
||||||
|
// The way out of a container Docker will no longer start — most often one pinned to a
|
||||||
|
// network that has since been recreated, which answers "network … not found" to every
|
||||||
|
// ordinary deploy. `up -d` leaves such a container alone because its definition has
|
||||||
|
// not changed; only --force-recreate builds it again.
|
||||||
|
script = base("up -d --force-recreate --remove-orphans") + remember
|
||||||
case "restart":
|
case "restart":
|
||||||
script = base("restart")
|
script = base("restart")
|
||||||
case "stop":
|
case "stop":
|
||||||
@@ -741,7 +838,7 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
|
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
|
||||||
script = base("down --remove-orphans")
|
script = base("down --remove-orphans")
|
||||||
default:
|
default:
|
||||||
jsonErr(w, 400, "deploy, update, restart, stop or down")
|
jsonErr(w, 400, "deploy, update, recreate, restart, stop or down")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
|
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
|
||||||
@@ -788,6 +885,103 @@ var (
|
|||||||
|
|
||||||
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
||||||
|
|
||||||
|
// checkImages asks the registry whether each image is still the one we hold. remote is a
|
||||||
|
// DOCKER_HOST for a machine whose images live elsewhere, "" for this one. Each image is one
|
||||||
|
// registry request, which Docker Hub counts — so this is only ever called when asked for.
|
||||||
|
func checkImages(ctx context.Context, images []string, remote string) []imageCheck {
|
||||||
|
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
||||||
|
if remote != "" {
|
||||||
|
args = append([]string{"-H", remote}, args...)
|
||||||
|
}
|
||||||
|
return dockerRun(ctx, args...)
|
||||||
|
}
|
||||||
|
res := []imageCheck{}
|
||||||
|
for _, img := range images {
|
||||||
|
ic := imageCheck{Image: img, State: "unknown"}
|
||||||
|
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||||
|
var have []string
|
||||||
|
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
||||||
|
ic.Detail = "built or loaded here, not pulled from a registry"
|
||||||
|
res = append(res, ic)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rem, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
|
||||||
|
digest := strings.TrimSpace(string(rem))
|
||||||
|
if i := strings.LastIndex(digest, "\n"); i >= 0 {
|
||||||
|
digest = digest[i+1:]
|
||||||
|
}
|
||||||
|
if err != nil || !strings.HasPrefix(digest, "sha256:") {
|
||||||
|
ic.Detail = "the registry didn't answer"
|
||||||
|
if err != nil {
|
||||||
|
ic.Detail += ": " + err.Error()
|
||||||
|
}
|
||||||
|
res = append(res, ic)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ic.State = "update"
|
||||||
|
for _, h := range have {
|
||||||
|
if strings.HasSuffix(h, "@"+digest) {
|
||||||
|
ic.State = "current"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res = append(res, ic)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// machineUpdates checks every image on this machine in one pass, so the container list can
|
||||||
|
// be coloured and the fleet can be asked "what is behind?" without walking stack by stack.
|
||||||
|
func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
node := r.PathValue("node")
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
stacks, err := s.findStacks(ctx)
|
||||||
|
if node != "" {
|
||||||
|
stacks, err = s.hubStacks(ctx, node)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// one entry per image, however many stacks use it
|
||||||
|
type want struct{ remote string }
|
||||||
|
imgs := map[string]want{}
|
||||||
|
byImage := map[string][]string{} // image -> stacks
|
||||||
|
for _, st := range stacks {
|
||||||
|
for _, img := range st.Images {
|
||||||
|
imgs[img] = want{remote: st.Remote}
|
||||||
|
byImage[img] = append(byImage[img], st.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh := r.URL.Query().Get("refresh") == "1"
|
||||||
|
key := "machine:" + node
|
||||||
|
updMu.Lock()
|
||||||
|
c, ok := updCache[key]
|
||||||
|
updMu.Unlock()
|
||||||
|
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||||
|
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli(), "stacks": byImage})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !refresh {
|
||||||
|
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0, "stacks": byImage})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
remote := ""
|
||||||
|
for img, wnt := range imgs {
|
||||||
|
list = append(list, img)
|
||||||
|
if wnt.remote != "" {
|
||||||
|
remote = wnt.remote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(list)
|
||||||
|
res := checkImages(ctx, list, remote)
|
||||||
|
updMu.Lock()
|
||||||
|
updCache[key] = updAnswer{time.Now(), res}
|
||||||
|
updMu.Unlock()
|
||||||
|
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli(), "stacks": byImage})
|
||||||
|
}
|
||||||
|
|
||||||
// stackUpdates asks each image's registry for the digest its tag points at now and compares
|
// stackUpdates asks each image's registry for the digest its tag points at now and compares
|
||||||
// it with the digest that was pulled. It is asked for, never polled: every answer is a
|
// it with the digest that was pulled. It is asked for, never polled: every answer is a
|
||||||
// registry request, and Docker Hub counts those. Answers are kept for six hours.
|
// registry request, and Docker Hub counts those. Answers are kept for six hours.
|
||||||
@@ -816,43 +1010,7 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
res := checkImages(ctx, st.Images, st.Remote)
|
||||||
if st.Remote != "" {
|
|
||||||
args = append([]string{"-H", st.Remote}, args...)
|
|
||||||
}
|
|
||||||
return dockerRun(ctx, args...)
|
|
||||||
}
|
|
||||||
res := []imageCheck{}
|
|
||||||
for _, img := range st.Images {
|
|
||||||
ic := imageCheck{Image: img, State: "unknown"}
|
|
||||||
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
|
||||||
var have []string
|
|
||||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
|
||||||
ic.Detail = "built or loaded here, not pulled from a registry"
|
|
||||||
res = append(res, ic)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remote, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
|
|
||||||
digest := strings.TrimSpace(string(remote))
|
|
||||||
if i := strings.LastIndex(digest, "\n"); i >= 0 {
|
|
||||||
digest = digest[i+1:]
|
|
||||||
}
|
|
||||||
if err != nil || !strings.HasPrefix(digest, "sha256:") {
|
|
||||||
ic.Detail = "the registry didn't answer"
|
|
||||||
if err != nil {
|
|
||||||
ic.Detail += ": " + err.Error()
|
|
||||||
}
|
|
||||||
res = append(res, ic)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ic.State = "update"
|
|
||||||
for _, h := range have {
|
|
||||||
if strings.HasSuffix(h, "@"+digest) {
|
|
||||||
ic.State = "current"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res = append(res, ic)
|
|
||||||
}
|
|
||||||
updMu.Lock()
|
updMu.Lock()
|
||||||
updCache[key] = updAnswer{time.Now(), res}
|
updCache[key] = updAnswer{time.Now(), res}
|
||||||
updMu.Unlock()
|
updMu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A stack deployed by an orchestrator running in a container records the path its compose
|
||||||
|
// file had inside that container. Resolving it has to work both while the orchestrator is
|
||||||
|
// running — compose is then run inside it — and after it has been removed, where the same
|
||||||
|
// mount is only remembered and compose has to run on the host instead.
|
||||||
|
func TestResolveThroughMounts(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(dir, "compose.yaml")
|
||||||
|
if err := os.WriteFile(file, []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inner := "/app/data/stacks/app/compose.yaml"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, container, wantVia string
|
||||||
|
}{
|
||||||
|
{"orchestrator running", "orch", "orch"},
|
||||||
|
{"orchestrator removed", "", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
st := stack{Name: "app", File: inner, Files: []string{inner}}
|
||||||
|
st.resolve([]mountMap{{tc.container, "/app/data", filepath.Join(root, "orch")}})
|
||||||
|
if !st.Editable {
|
||||||
|
t.Fatalf("not editable: %s", st.Note)
|
||||||
|
}
|
||||||
|
if st.HostFile != file {
|
||||||
|
t.Errorf("host file = %q, want %q", st.HostFile, file)
|
||||||
|
}
|
||||||
|
if st.Via != tc.wantVia {
|
||||||
|
t.Errorf("via = %q, want %q", st.Via, tc.wantVia)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing explains the path: the stack is listed, but read-only and says why.
|
||||||
|
st := stack{Name: "app", File: inner, Files: []string{inner}}
|
||||||
|
st.resolve(nil)
|
||||||
|
if st.Editable || st.Note == "" {
|
||||||
|
t.Errorf("unresolvable stack should be read-only with a note, got editable=%v note=%q", st.Editable, st.Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every file of a multi-file stack must resolve through the same place. Redeploying from
|
||||||
|
// the first file alone would silently drop whatever the override set.
|
||||||
|
func TestResolveRefusesSplitStack(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st := stack{Name: "app", File: "/app/data/stacks/app/compose.yaml", Files: []string{
|
||||||
|
"/app/data/stacks/app/compose.yaml",
|
||||||
|
"/app/data/stacks/app/override.yaml", // never written: not on disk
|
||||||
|
}}
|
||||||
|
st.resolve([]mountMap{{"orch", "/app/data", filepath.Join(root, "orch")}})
|
||||||
|
if st.Editable {
|
||||||
|
t.Error("a stack with a file that cannot be found should not be editable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A schedule made while an orchestrator was running says "run compose inside that
|
||||||
|
// container". When the container is gone the schedule must still work, against the same
|
||||||
|
// files on the host — otherwise retiring the orchestrator silently breaks every scheduled
|
||||||
|
// update made before it went.
|
||||||
|
func TestRepointSurvivesAMissingContainer(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
host := filepath.Join(dir, "compose.yaml")
|
||||||
|
if err := os.WriteFile(host, []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := store.Open(filepath.Join(root, "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Update(func(st *store.State) {
|
||||||
|
st.Mounts = []store.RememberedMount{{Container: "gone-orchestrator", Dest: "/app/data", Src: filepath.Join(root, "orch")}}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
in := compose.Spec{Project: "app", Files: []string{"/app/data/stacks/app/compose.yaml"}, Via: "gone-orchestrator", Tool: "docker"}
|
||||||
|
got := repoint(context.Background(), db, in)
|
||||||
|
if got.Via != "" {
|
||||||
|
t.Errorf("via = %q, want it dropped", got.Via)
|
||||||
|
}
|
||||||
|
if len(got.Files) != 1 || got.Files[0] != host {
|
||||||
|
t.Errorf("files = %v, want [%s]", got.Files, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing remembered: the schedule is left exactly as it was rather than pointed at a
|
||||||
|
// guess. It will fail loudly, which is better than deploying the wrong file.
|
||||||
|
empty, err := store.Open(filepath.Join(root, "empty.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := repoint(context.Background(), empty, in); got.Via != in.Via || len(got.Files) != 1 || got.Files[0] != in.Files[0] {
|
||||||
|
t.Errorf("unresolvable spec was changed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-2
@@ -12,6 +12,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ type actor struct {
|
|||||||
Session string // session ID, "" for a token
|
Session string // session ID, "" for a token
|
||||||
token string // the raw cookie token, so a handler can spare the current session
|
token string // the raw cookie token, so a handler can spare the current session
|
||||||
Token string // token name, "" for a session
|
Token string // token name, "" for a session
|
||||||
|
// Guest marks a temporary look-around login, and Expires is when it stops working. What
|
||||||
|
// a guest may reach is decided in the middleware, not here; see internal/api/guests.go.
|
||||||
|
Guest bool
|
||||||
|
Expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
@@ -44,6 +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.
|
// who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub.
|
||||||
func (a actor) String() string {
|
func (a actor) String() string {
|
||||||
switch {
|
switch {
|
||||||
|
case a.User != "" && a.Guest:
|
||||||
|
return "guest:" + a.User
|
||||||
case a.User != "":
|
case a.User != "":
|
||||||
return a.User
|
return a.User
|
||||||
case a.Token != "":
|
case a.Token != "":
|
||||||
@@ -59,7 +66,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
counts := s.sessions.Count()
|
counts := s.sessions.Count()
|
||||||
var out []map[string]any
|
var out []map[string]any
|
||||||
s.cfg.View(func(c *config.Config) {
|
s.cfg.View(func(c *config.Config) {
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Accounts() { // guest logins are listed on their own, see listGuests
|
||||||
var seen any
|
var seen any
|
||||||
if !u.LastSeen.IsZero() {
|
if !u.LastSeen.IsZero() {
|
||||||
seen = u.LastSeen
|
seen = u.LastSeen
|
||||||
@@ -84,6 +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")
|
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(name, guestPrefix) {
|
||||||
|
jsonErr(w, 400, "names starting with "+guestPrefix+" belong to guest logins")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(body.Password) < auth.MinPasswordLen {
|
if len(body.Password) < auth.MinPasswordLen {
|
||||||
jsonErr(w, 400, "password must be at least 12 characters")
|
jsonErr(w, 400, "password must be at least 12 characters")
|
||||||
return
|
return
|
||||||
@@ -141,7 +152,7 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
found, last := false, false
|
found, last := false, false
|
||||||
if err := s.cfg.Update(func(c *config.Config) {
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
if len(c.Users) <= 1 {
|
if len(c.Accounts()) <= 1 { // guest logins don't keep the last real account alive
|
||||||
last = true
|
last = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -254,6 +265,29 @@ func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(204)
|
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) {
|
func (s *Server) revokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
a := actorOf(r)
|
a := actorOf(r)
|
||||||
if a.User == "" {
|
if a.User == "" {
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// The Virtualization tab: LXC containers and KVM virtual machines through Incus, per
|
||||||
|
// machine. Three page states — ready, repairable, installable, or unavailable with the
|
||||||
|
// reason — and every action lands on the machine that was asked. Long work (install, create,
|
||||||
|
// image downloads, publish) runs as a followed task; consoles are terminal sessions like
|
||||||
|
// any other, so they reattach and work through the hub's proxy.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/incus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readBody(r *http.Request, v any, max int64) error {
|
||||||
|
return json.NewDecoder(io.LimitReader(r.Body, max)).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// virtOverview is the page: the machine's state, and when Incus answers, everything on it.
|
||||||
|
func (s *Server) virtOverview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st := s.virt.Detect(r.Context())
|
||||||
|
out := map[string]any{"status": st}
|
||||||
|
if st.State == "ready" {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
inst, err := s.virt.Instances(ctx)
|
||||||
|
if err != nil {
|
||||||
|
out["error"] = err.Error()
|
||||||
|
}
|
||||||
|
out["instances"] = inst
|
||||||
|
imgs, _ := s.virt.Images(ctx)
|
||||||
|
out["images"] = imgs
|
||||||
|
out["pools"] = s.virt.Pools(ctx)
|
||||||
|
out["networks"] = s.virt.Networks(ctx)
|
||||||
|
}
|
||||||
|
out["library"] = map[string]any{"images": s.virt.Library(s.virt.ImagesDir()), "isos": s.virt.Library(s.virt.ISOsDir())}
|
||||||
|
jsonOK(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var o incus.InstallOpts
|
||||||
|
if err := readBody(r, &o, 16<<10); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := s.virt.Detect(r.Context())
|
||||||
|
if st.State != "installable" && st.State != "repair" {
|
||||||
|
jsonErr(w, 409, "nothing to install here: "+st.State)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.virt.EnsureDirs(); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
script, lay, err := s.virt.InstallScript(st, o)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.virt.SaveLayout(lay); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// kept beside the layout, so what was run is on record
|
||||||
|
_ = os.WriteFile(filepath.Join(s.virt.Dir(), "install.sh"), []byte(script), 0o700)
|
||||||
|
t, err := s.tasks.Start("virt:install", "install Incus", s.virt.ShellCmd(r.Context(), script))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 409, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: install started as task %s — %s pool, %s (from %s)", t.ID, lay.Driver, lay.Subnet, clientIP(r))
|
||||||
|
jsonOK(w, 202, t.Snapshot(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtReady(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if st := s.virt.Detect(r.Context()); st.State != "ready" {
|
||||||
|
jsonErr(w, 409, "Incus isn't ready on this machine")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- images ----
|
||||||
|
|
||||||
|
func (s *Server) virtRemoteImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := s.virt.Remote(r.Context(), r.URL.Query().Get("refresh") == "1")
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 502, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t := r.URL.Query().Get("type"); t == "container" || t == "virtual-machine" {
|
||||||
|
var f []incus.Image
|
||||||
|
for _, im := range list {
|
||||||
|
if im.Type == t {
|
||||||
|
f = append(f, im)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list = f
|
||||||
|
}
|
||||||
|
if list == nil {
|
||||||
|
list = []incus.Image{}
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{"images": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtImageDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
}
|
||||||
|
_ = readBody(r, &body, 4096)
|
||||||
|
alias := strings.TrimSpace(body.Alias)
|
||||||
|
if !incus.ValidImage(alias) || strings.Contains(alias, ":") {
|
||||||
|
jsonErr(w, 400, "alias: like debian/13 or ubuntu/24.04/cloud")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
local := strings.ReplaceAll(alias, "/", "-")
|
||||||
|
// copied into the local store under a flat alias, then exported to the library so the
|
||||||
|
// file is Ham Lab's whether or not Incus keeps its cache
|
||||||
|
script := `set -e; incus image copy "images:$1" local: --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"`
|
||||||
|
t, err := s.tasks.Start("virt:image", "download "+alias, s.virt.ShellCmd(r.Context(), script, alias, local, s.virt.ImagesDir()))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 409, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: image %s download as task %s (from %s)", alias, t.ID, clientIP(r))
|
||||||
|
jsonOK(w, 202, t.Snapshot(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtImageImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Type string `json:"type"` // container | virtual-machine
|
||||||
|
}
|
||||||
|
_ = readBody(r, &body, 8192)
|
||||||
|
alias := strings.TrimSpace(body.Alias)
|
||||||
|
if !incus.ValidImage(alias) || strings.Contains(alias, ":") {
|
||||||
|
jsonErr(w, 400, "alias: letters, digits, dots, dashes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := filepath.Clean(body.Path)
|
||||||
|
st, err := os.Stat(p)
|
||||||
|
if !filepath.IsAbs(p) || err != nil || !st.Mode().IsRegular() {
|
||||||
|
jsonErr(w, 400, "path: a file on this machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dest := filepath.Join(s.virt.ImagesDir(), filepath.Base(p))
|
||||||
|
var script string
|
||||||
|
if body.Type == "virtual-machine" {
|
||||||
|
// a bare disk image needs the metadata Incus wants beside it; a small tarball Ham Lab writes
|
||||||
|
script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; META="$3/$4.metadata.tar.gz"; T=$(mktemp -d); printf 'architecture: %s\ncreation_date: %s\nproperties:\n description: "%s"\n os: custom\n' "$5" "$(date +%s)" "$4" > "$T/metadata.yaml"; tar -C "$T" -czf "$META" metadata.yaml; rm -rf "$T"; incus image import "$META" "$2" --alias "$4"; echo "imported $4 from $2"`
|
||||||
|
} else {
|
||||||
|
script = `set -e; [ "$1" = "$2" ] || cp -n "$1" "$2"; incus image import "$2" --alias "$4"; echo "imported $4 from $2"`
|
||||||
|
}
|
||||||
|
t, err := s.tasks.Start("virt:image", "import "+alias, s.virt.ShellCmd(r.Context(), script, p, dest, s.virt.ImagesDir(), alias, incus.IncusArch()))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 409, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: image import %s from %s as task %s (from %s)", alias, p, t.ID, clientIP(r))
|
||||||
|
jsonOK(w, 202, t.Snapshot(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtImagePublish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
}
|
||||||
|
_ = readBody(r, &body, 4096)
|
||||||
|
alias := strings.TrimSpace(body.Alias)
|
||||||
|
if !incus.ValidName(body.Instance) || !incus.ValidImage(alias) || strings.Contains(alias, ":") {
|
||||||
|
jsonErr(w, 400, "an instance and an alias")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
script := `set -e; incus publish "$1" --alias "$2"; cd "$3" && incus image export "$2" "$2" && echo "exported to $3/$2"`
|
||||||
|
t, err := s.tasks.Start("virt:image", "publish "+body.Instance+" as "+alias, s.virt.ShellCmd(r.Context(), script, body.Instance, alias, s.virt.ImagesDir()))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 409, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: publish %s as %s, task %s (from %s)", body.Instance, alias, t.ID, clientIP(r))
|
||||||
|
jsonOK(w, 202, t.Snapshot(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtImageDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
}
|
||||||
|
_ = readBody(r, &body, 4096)
|
||||||
|
if !incus.ValidImage(body.Ref) {
|
||||||
|
jsonErr(w, 400, "bad image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := s.virt.Cmd(ctx, "image", "delete", body.Ref).CombinedOutput(); err != nil {
|
||||||
|
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: image %s deleted (from %s)", body.Ref, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- instances ----
|
||||||
|
|
||||||
|
func (s *Server) virtCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var sp incus.CreateSpec
|
||||||
|
if err := readBody(r, &sp, 512<<10); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := s.virt.Detect(r.Context())
|
||||||
|
if st.State != "ready" {
|
||||||
|
jsonErr(w, 409, "Incus isn't ready on this machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args, err := incus.CreateArgs(sp, st.KVM)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if inst, err := s.virt.Instances(r.Context()); err == nil {
|
||||||
|
for _, in := range inst {
|
||||||
|
if in.Name == sp.Name {
|
||||||
|
jsonErr(w, 409, "there is already an instance called "+sp.Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kind := "container"
|
||||||
|
if sp.VM {
|
||||||
|
kind = "VM"
|
||||||
|
}
|
||||||
|
t, err := s.tasks.Start("virt:create", fmt.Sprintf("create %s %s", kind, sp.Name), s.virt.Cmd(r.Context(), args...))
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 409, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: create %s %s from %s as task %s (from %s)", kind, sp.Name, sp.Image, t.ID, clientIP(r))
|
||||||
|
jsonOK(w, 202, t.Snapshot(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtInstance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if !incus.ValidName(name) {
|
||||||
|
jsonErr(w, 400, "bad name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := s.virt.Instances(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 502, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, in := range list {
|
||||||
|
if in.Name == name {
|
||||||
|
jsonOK(w, 200, in)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonErr(w, 404, "no such instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
// virtAction is start / stop / restart / kill / delete, and the two consoles.
|
||||||
|
func (s *Server) virtAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name, action := r.PathValue("name"), r.PathValue("action")
|
||||||
|
if !incus.ValidName(name) {
|
||||||
|
jsonErr(w, 400, "bad name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var args []string
|
||||||
|
switch action {
|
||||||
|
case "start":
|
||||||
|
args = []string{"start", name}
|
||||||
|
case "stop":
|
||||||
|
args = []string{"stop", name}
|
||||||
|
case "restart":
|
||||||
|
args = []string{"restart", name}
|
||||||
|
case "kill":
|
||||||
|
args = []string{"stop", "--force", name}
|
||||||
|
case "delete":
|
||||||
|
var body struct {
|
||||||
|
Confirm string `json:"confirm"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
}
|
||||||
|
_ = readBody(r, &body, 4096)
|
||||||
|
if body.Confirm != name {
|
||||||
|
jsonErr(w, 400, "type the instance's name to delete it")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = []string{"delete", name}
|
||||||
|
if body.Force {
|
||||||
|
args = []string{"delete", "--force", name}
|
||||||
|
}
|
||||||
|
case "console", "exec":
|
||||||
|
s.virtTerminal(w, r, name, action)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
jsonErr(w, 400, "unknown action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil {
|
||||||
|
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: %s %s (from %s)", action, name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// virtTerminal opens the serial console or a shell as a terminal session.
|
||||||
|
func (s *Server) virtTerminal(w http.ResponseWriter, r *http.Request, name, kind string) {
|
||||||
|
var argv []string
|
||||||
|
title := ""
|
||||||
|
if kind == "console" {
|
||||||
|
argv = s.virt.Argv(r.Context(), "console", name)
|
||||||
|
title = "console " + name
|
||||||
|
} else {
|
||||||
|
argv = s.virt.Argv(r.Context(), "exec", name, "--", "sh", "-c", "if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi")
|
||||||
|
title = "sh " + name
|
||||||
|
}
|
||||||
|
sess, err := s.mgr.CreateCmd("", title, argv)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: %s of %s as terminal %s (from %s)", kind, name, sess.ID, clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"id": sess.ID, "name": title})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtRename(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
_ = readBody(r, &body, 4096)
|
||||||
|
if !incus.ValidName(name) || !incus.ValidName(body.Name) {
|
||||||
|
jsonErr(w, 400, "bad name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := s.virt.Cmd(ctx, "rename", name, body.Name).CombinedOutput(); err != nil {
|
||||||
|
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: renamed %s to %s (from %s)", name, body.Name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// virtConfig sets limits, the snapshot schedule, or a description — the config keys the page
|
||||||
|
// edits, each validated for its shape. An empty value unsets the key.
|
||||||
|
func (s *Server) virtConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
var body map[string]string
|
||||||
|
if err := readBody(r, &body, 16<<10); err != nil || !incus.ValidName(name) {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var set, unset []string
|
||||||
|
desc, hasDesc := body["description"]
|
||||||
|
delete(body, "description")
|
||||||
|
if hasDesc && (len(desc) > 200 || strings.ContainsAny(desc, "\n\r")) {
|
||||||
|
jsonErr(w, 400, "bad value for description")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for k, v := range body {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
ok := false
|
||||||
|
switch k {
|
||||||
|
case "limits.cpu":
|
||||||
|
ok = incus.ValidCPU(v)
|
||||||
|
case "limits.memory":
|
||||||
|
ok = incus.ValidSize(v)
|
||||||
|
case "snapshots.schedule":
|
||||||
|
ok = incus.ValidCron(v)
|
||||||
|
case "snapshots.expiry":
|
||||||
|
ok = incus.ValidExpiry(v)
|
||||||
|
case "boot.autostart":
|
||||||
|
ok = v == "" || v == "true" || v == "false"
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 400, "bad value for "+k)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
unset = append(unset, k)
|
||||||
|
} else {
|
||||||
|
set = append(set, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if len(set) > 0 {
|
||||||
|
if out, err := s.virt.Cmd(ctx, append([]string{"config", "set", name}, set...)...).CombinedOutput(); err != nil {
|
||||||
|
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range unset {
|
||||||
|
_, _ = s.virt.Cmd(ctx, "config", "unset", name, k).CombinedOutput() // unsetting what isn't set is fine
|
||||||
|
}
|
||||||
|
if hasDesc {
|
||||||
|
// the description is a field of the instance, not a config key, so it goes in through the API
|
||||||
|
patch, _ := json.Marshal(map[string]string{"description": strings.TrimSpace(desc)})
|
||||||
|
if out, err := s.virt.Cmd(ctx, "query", "-X", "PATCH", "-d", string(patch), "/1.0/instances/"+name).CombinedOutput(); err != nil {
|
||||||
|
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("virt: config %s set %v unset %v (from %s)", name, set, unset, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtSnapshotCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
_ = readBody(r, &body, 4096)
|
||||||
|
snap := strings.TrimSpace(body.Name)
|
||||||
|
if snap == "" {
|
||||||
|
snap = time.Now().Format("2006-01-02_15.04.05")
|
||||||
|
}
|
||||||
|
if !incus.ValidName(name) || !incus.ValidSnap(snap) {
|
||||||
|
jsonErr(w, 400, "bad name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := s.virt.Cmd(ctx, "snapshot", "create", name, snap).CombinedOutput(); err != nil {
|
||||||
|
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: snapshot %s/%s (from %s)", name, snap, clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"name": snap})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) virtSnapshotAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name, snap, action := r.PathValue("name"), r.PathValue("snap"), r.PathValue("action")
|
||||||
|
if !incus.ValidName(name) || !incus.ValidSnap(snap) {
|
||||||
|
jsonErr(w, 400, "bad name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var args []string
|
||||||
|
switch action {
|
||||||
|
case "restore":
|
||||||
|
args = []string{"snapshot", "restore", name, snap}
|
||||||
|
case "delete":
|
||||||
|
args = []string{"snapshot", "delete", name, snap}
|
||||||
|
default:
|
||||||
|
jsonErr(w, 400, "unknown action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.virtReady(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := s.virt.Cmd(ctx, args...).CombinedOutput(); err != nil {
|
||||||
|
jsonErr(w, 409, strings.TrimPrefix(strings.TrimSpace(string(out)), "Error: "))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("virt: snapshot %s %s/%s (from %s)", action, name, snap, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
@@ -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
|
argonKeyLen = 32
|
||||||
|
|
||||||
SessionTTL = 30 * 24 * time.Hour
|
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) {
|
func HashPassword(pw string) (string, error) {
|
||||||
@@ -113,11 +117,14 @@ var dummyHash = func() string {
|
|||||||
return h
|
return h
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// CheckUser verifies a password for one of users. A missing user fails in the same time
|
// CheckUser verifies a password for one of users. A missing user — or a guest login whose
|
||||||
// as a wrong password.
|
// time is up — fails in the same time as a wrong password.
|
||||||
func CheckUser(users []config.User, name, pw string) bool {
|
func CheckUser(users []config.User, name, pw string) bool {
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if u.Name == name {
|
if u.Name == name {
|
||||||
|
if u.Expired(time.Now()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
return VerifyPassword(u.Hash, pw)
|
return VerifyPassword(u.Hash, pw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +132,51 @@ func CheckUser(users []config.User, name, pw string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- guest logins ----
|
||||||
|
|
||||||
|
// guestAlphabet leaves out every letter and digit that can be misread in a screenshot or
|
||||||
|
// over the phone: i, l, o, 0 and 1. A guest login is read aloud and typed by hand.
|
||||||
|
const guestAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||||
|
|
||||||
|
// randomFrom returns n characters of guestAlphabet, chosen without modulo bias.
|
||||||
|
func randomFrom(n int) (string, error) {
|
||||||
|
out := make([]byte, 0, n)
|
||||||
|
buf := make([]byte, n)
|
||||||
|
limit := byte(256 - 256%len(guestAlphabet)) // the top of the last whole run of the alphabet
|
||||||
|
for len(out) < n {
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, b := range buf {
|
||||||
|
if b >= limit {
|
||||||
|
continue // would favour the first few letters; ask for another byte instead
|
||||||
|
}
|
||||||
|
out = append(out, guestAlphabet[int(b)%len(guestAlphabet)])
|
||||||
|
if len(out) == n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuestPassword is a password for a guest login: four groups of four, ~78 bits, in an
|
||||||
|
// alphabet with nothing ambiguous in it.
|
||||||
|
func GuestPassword() (string, error) {
|
||||||
|
var groups []string
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
g, err := randomFrom(4)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
return strings.Join(groups, "-"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuestSuffix is the tail of a generated guest name.
|
||||||
|
func GuestSuffix() (string, error) { return randomFrom(4) }
|
||||||
|
|
||||||
// ---- sessions ----
|
// ---- sessions ----
|
||||||
|
|
||||||
// Sessions is a token store backed by the state file. Expiry is sliding.
|
// Sessions is a token store backed by the state file. Expiry is sliding.
|
||||||
@@ -141,12 +193,26 @@ func NewSessions(st *store.Store) *Sessions {
|
|||||||
func pruneExpired(state *store.State) {
|
func pruneExpired(state *store.State) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for tok, ses := range state.Sessions {
|
for tok, ses := range state.Sessions {
|
||||||
if ses.Expires.Before(now) {
|
if !live(ses, now) {
|
||||||
delete(state.Sessions, tok)
|
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) {
|
func randomHex(n int) (string, error) {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
if _, err := rand.Read(b); err != nil {
|
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
|
ok, touch := false, false
|
||||||
s.st.View(func(state *store.State) {
|
s.st.View(func(state *store.State) {
|
||||||
found, have := state.Sessions[tok]
|
found, have := state.Sessions[tok]
|
||||||
if have && found.Expires.After(time.Now()) {
|
if have && live(found, time.Now()) {
|
||||||
ses, ok = found, true
|
ses, ok = found, true
|
||||||
touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour
|
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) {
|
s.st.View(func(state *store.State) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, ses := range state.Sessions {
|
for _, ses := range state.Sessions {
|
||||||
if ses.User == user && ses.Expires.After(now) {
|
if ses.User == user && live(ses, now) {
|
||||||
out = append(out, ses)
|
out = append(out, ses)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,7 +297,7 @@ func (s *Sessions) Count() map[string]int {
|
|||||||
s.st.View(func(state *store.State) {
|
s.st.View(func(state *store.State) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, ses := range state.Sessions {
|
for _, ses := range state.Sessions {
|
||||||
if ses.Expires.After(now) {
|
if live(ses, now) {
|
||||||
n[ses.User]++
|
n[ses.User]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,14 +333,33 @@ func (s *Sessions) RevokeOthers(user, keep string) int {
|
|||||||
return n
|
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).
|
// RevokeUser ends every session of user (the account is going away).
|
||||||
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
|
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
|
||||||
|
|
||||||
// Limiter is a single global backoff: after 5 failures, each further failure
|
|
||||||
// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
|
|
||||||
// Limiter locks out a client after repeated failed logins, per client address, with a
|
// Limiter locks out a client after repeated failed logins, per client address, with a
|
||||||
// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one
|
// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one
|
||||||
// address never blocks another.
|
// address never blocks another. Past perClientLock failures, each further failure doubles a
|
||||||
|
// lockout starting at 30s, capped at 10 minutes.
|
||||||
type Limiter struct {
|
type Limiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
clients map[string]*bucket
|
clients map[string]*bucket
|
||||||
@@ -290,7 +375,11 @@ type bucket struct {
|
|||||||
var ErrLocked = errors.New("too many failed logins")
|
var ErrLocked = errors.New("too many failed logins")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
perClientLock = 5 // failures before a client is locked
|
// perClientLock is how many failures one address gets before it is made to wait. Set high
|
||||||
|
// on purpose: a guest login is a generated password read off a message and typed by hand
|
||||||
|
// on a phone, and being locked out for fumbling it is worse than the brute-force risk —
|
||||||
|
// which the doubling backoff past this point, and the global backstop, still cover.
|
||||||
|
perClientLock = 20
|
||||||
globalLock = 100 // failures (all clients) in the window before everyone waits a minute
|
globalLock = 100 // failures (all clients) in the window before everyone waits a minute
|
||||||
globalWindow = 5 * time.Minute
|
globalWindow = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Package compose runs docker compose the way a stack was deployed — which is not always
|
||||||
|
// simply "on this host". A stack deployed by an orchestrator running inside its own
|
||||||
|
// container records paths in that container's filesystem, so compose has to run in there;
|
||||||
|
// a stack deployed to another machine over tcp needs DOCKER_HOST pointed at it. Both the
|
||||||
|
// console and the scheduled updates it writes must invoke it identically, or a redeploy
|
||||||
|
// from one would not match the other's labels.
|
||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spec is everything needed to run compose for one stack.
|
||||||
|
type Spec struct {
|
||||||
|
Project string // -p
|
||||||
|
Files []string // -f, in order
|
||||||
|
Via string // a container to run compose inside; "" is this host
|
||||||
|
Remote string // a DOCKER_HOST; "" is this machine's Docker
|
||||||
|
Tool string // "docker" or "podman"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd builds the command. args are compose's own, e.g. "pull" or "up", "-d".
|
||||||
|
func Cmd(ctx context.Context, s Spec, args ...string) *exec.Cmd {
|
||||||
|
tool := s.Tool
|
||||||
|
if tool == "" {
|
||||||
|
tool = "docker"
|
||||||
|
}
|
||||||
|
base := []string{"compose", "-p", s.Project}
|
||||||
|
for _, f := range s.Files {
|
||||||
|
base = append(base, "-f", f)
|
||||||
|
}
|
||||||
|
if s.Via != "" {
|
||||||
|
pre := []string{"exec"}
|
||||||
|
if s.Remote != "" {
|
||||||
|
pre = append(pre, "-e", "DOCKER_HOST="+s.Remote)
|
||||||
|
}
|
||||||
|
return exec.CommandContext(ctx, tool, append(append(append(pre, s.Via, "docker"), base...), args...)...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
|
||||||
|
if len(s.Files) > 0 {
|
||||||
|
cmd.Dir = filepath.Dir(s.Files[0])
|
||||||
|
}
|
||||||
|
if s.Remote != "" {
|
||||||
|
cmd.Env = append(os.Environ(), "DOCKER_HOST="+s.Remote)
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -46,6 +46,22 @@ type User struct {
|
|||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||||
|
// Guest marks a temporary login made to show somebody the console: it may only read,
|
||||||
|
// it expires by itself at Expires, and it is not one of the machine's accounts — it is
|
||||||
|
// left out of the account list, does not count towards "the last account", and cannot
|
||||||
|
// be the one an empty user name means. See internal/api/guests.go.
|
||||||
|
Guest bool `json:"guest,omitempty"`
|
||||||
|
// Expires is when a guest login stops working. A guest without one has already
|
||||||
|
// expired, which is the safe way to read a half-written file.
|
||||||
|
Expires time.Time `json:"expires,omitempty"`
|
||||||
|
// IssuedBy is the account that made this guest login.
|
||||||
|
IssuedBy string `json:"issued_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired reports whether a guest login's time is up. A real account never expires; a
|
||||||
|
// guest with no expiry at all counts as expired rather than as forever.
|
||||||
|
func (u User) Expired(now time.Time) bool {
|
||||||
|
return u.Guest && (u.Expires.IsZero() || now.After(u.Expires))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
|
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
|
||||||
@@ -61,15 +77,33 @@ type UpdateSource struct {
|
|||||||
Check string `json:"check,omitempty"`
|
Check string `json:"check,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access
|
||||||
|
// to a machine, including when that machine has no OS running. Ham Lab holds the list on
|
||||||
|
// the hub and proxies each one, because a KVM matters exactly when the machine it is
|
||||||
|
// attached to cannot answer for itself.
|
||||||
|
type KVM struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// URL is the device's own address, as you would type it into a browser on the LAN.
|
||||||
|
URL string `json:"url"`
|
||||||
|
// Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
|
||||||
|
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
|
||||||
|
// for a node's KVM, because a KVM earns its keep when the machine is down and cannot
|
||||||
|
// answer for itself.
|
||||||
|
Machine string `json:"machine"`
|
||||||
|
Added time.Time `json:"added"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Listen string `json:"listen"`
|
Listen string `json:"listen"`
|
||||||
// PasswordHash is the single login of installs before users existed. On load it becomes
|
// PasswordHash is the single login of installs before users existed. On load it becomes
|
||||||
// the first user (see MigrateUsers) and is then dropped from the file.
|
// the first user (see MigrateUsers) and is then dropped from the file.
|
||||||
PasswordHash string `json:"password_hash,omitempty"`
|
PasswordHash string `json:"password_hash,omitempty"`
|
||||||
Users []User `json:"users,omitempty"`
|
Users []User `json:"users,omitempty"`
|
||||||
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
||||||
Tokens []Token `json:"tokens,omitempty"`
|
Tokens []Token `json:"tokens,omitempty"`
|
||||||
Nodes []Node `json:"nodes,omitempty"`
|
Nodes []Node `json:"nodes,omitempty"`
|
||||||
|
KVMs []KVM `json:"kvms,omitempty"`
|
||||||
// Updates: where a newer build comes from. See UpdateSource.
|
// Updates: where a newer build comes from. See UpdateSource.
|
||||||
Updates UpdateSource `json:"update,omitempty"`
|
Updates UpdateSource `json:"update,omitempty"`
|
||||||
|
|
||||||
@@ -127,6 +161,18 @@ func (c *Config) MigrateUsers(name string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts is every real account: the machine's users, without the guest logins.
|
||||||
|
// Call under View/Update or on a config nobody else holds.
|
||||||
|
func (c *Config) Accounts() []User {
|
||||||
|
out := make([]User, 0, len(c.Users))
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if !u.Guest {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// User finds an account by name. Call under View/Update or on a config nobody else holds.
|
// User finds an account by name. Call under View/Update or on a config nobody else holds.
|
||||||
func (c *Config) User(name string) (User, bool) {
|
func (c *Config) User(name string) (User, bool) {
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Users {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Package cron reads and writes what the UI calls the **Jobs** tab (named "Cron" until
|
||||||
|
// 2026-09-24; the package and its /api/cron endpoints kept the old name).
|
||||||
|
//
|
||||||
// Package cron reads every scheduled job on the host into one list: /etc/crontab,
|
// Package cron reads every scheduled job on the host into one list: /etc/crontab,
|
||||||
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
|
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
|
||||||
// timers. It computes next runs from the cron expressions, pulls last runs from the
|
// timers. It computes next runs from the cron expressions, pulls last runs from the
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ func (s *schedule) dayMatches(t time.Time) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NextRun is the first time strictly after `from` that a cron expression fires. Exported so
|
||||||
|
// anything that writes a cron line — the ZFS snapshot schedules — can say when it runs next
|
||||||
|
// using the same evaluator that reads everyone else's.
|
||||||
|
func NextRun(expr string, from time.Time) (time.Time, bool) { return nextRun(expr, from) }
|
||||||
|
|
||||||
|
// ValidSchedule reports whether a cron expression parses.
|
||||||
|
func ValidSchedule(expr string) error {
|
||||||
|
_, err := parseSchedule(expr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// nextRun finds the first time strictly after `from` that the schedule fires.
|
// nextRun finds the first time strictly after `from` that the schedule fires.
|
||||||
func nextRun(expr string, from time.Time) (time.Time, bool) {
|
func nextRun(expr string, from time.Time) (time.Time, bool) {
|
||||||
s, err := parseSchedule(expr)
|
s, err := parseSchedule(expr)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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,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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-8
@@ -90,6 +90,15 @@ type FS struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Size uint64 `json:"size"`
|
Size uint64 `json:"size"`
|
||||||
Used uint64 `json:"used"`
|
Used uint64 `json:"used"`
|
||||||
|
// Remote is set when the filesystem lives on another machine — a CIFS or NFS share,
|
||||||
|
// sshfs, and so on. Its bytes are somebody else's disk, so a panel about this machine's
|
||||||
|
// hardware leaves it out, while the sensor stays for anyone who does want it.
|
||||||
|
Remote bool `json:"remote,omitempty"`
|
||||||
|
// Disk is the physical drive these bytes are on ("nvme0", "sda"), named as the sampler
|
||||||
|
// names it; empty when that cannot be
|
||||||
|
// said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and
|
||||||
|
// a remote mount is on none of them.
|
||||||
|
Disk string `json:"disk,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UPS struct {
|
type UPS struct {
|
||||||
@@ -113,9 +122,10 @@ type Proc struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Cmd string `json:"cmd,omitempty"`
|
Cmd string `json:"cmd,omitempty"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
CPU float64 `json:"cpu"` // percent of one core
|
CPU float64 `json:"cpu"` // percent of one core
|
||||||
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
|
GPU float64 `json:"gpu,omitempty"` // its busiest GPU engine, percent; summed over the GPUs it has open
|
||||||
RSS uint64 `json:"rss"` // bytes
|
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
|
||||||
|
RSS uint64 `json:"rss"` // bytes
|
||||||
Threads int `json:"threads"`
|
Threads int `json:"threads"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Nice int `json:"nice"`
|
Nice int `json:"nice"`
|
||||||
@@ -136,6 +146,8 @@ type Sample struct {
|
|||||||
Disks []Disk `json:"disks"`
|
Disks []Disk `json:"disks"`
|
||||||
FS []FS `json:"fs"`
|
FS []FS `json:"fs"`
|
||||||
UPS *UPS `json:"ups"`
|
UPS *UPS `json:"ups"`
|
||||||
|
GPUs []GPU `json:"gpus"`
|
||||||
|
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module
|
||||||
Uptime float64 `json:"uptime"`
|
Uptime float64 `json:"uptime"`
|
||||||
|
|
||||||
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
|
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
|
||||||
@@ -164,6 +176,8 @@ type Collector struct {
|
|||||||
procAt time.Time
|
procAt time.Time
|
||||||
lastTable *ProcTable
|
lastTable *ProcTable
|
||||||
upsProbe func() *UPS // nil when no UPS daemon is installed
|
upsProbe func() *UPS // nil when no UPS daemon is installed
|
||||||
|
gpu gpuState
|
||||||
|
prevARC arcPrev
|
||||||
|
|
||||||
subMu sync.Mutex
|
subMu sync.Mutex
|
||||||
subs map[chan struct{}]struct{} // told after every sample; see Subscribe
|
subs map[chan struct{}]struct{} // told after every sample; see Subscribe
|
||||||
@@ -347,6 +361,8 @@ func (c *Collector) sample() {
|
|||||||
c.lastUPS, c.upsAt = c.upsProbe(), now
|
c.lastUPS, c.upsAt = c.upsProbe(), now
|
||||||
}
|
}
|
||||||
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
|
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
|
||||||
|
s.GPUs = c.gpus(now, cpuTemp(s.Temps))
|
||||||
|
s.ARC = c.arc(now)
|
||||||
|
|
||||||
if first {
|
if first {
|
||||||
return // deltas are meaningless on the priming pass
|
return // deltas are meaningless on the priming pass
|
||||||
@@ -766,6 +782,15 @@ func foldNVMe(devs []Disk) []Disk {
|
|||||||
|
|
||||||
// Kernel/virtual filesystems that aren't storage. Anything else that's mounted is
|
// Kernel/virtual filesystems that aren't storage. Anything else that's mounted is
|
||||||
// shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change.
|
// shown, so new real filesystems (bcachefs, exotic FUSE) work without a code change.
|
||||||
|
// remoteFS is every filesystem type whose storage is not in this machine.
|
||||||
|
var remoteFS = map[string]bool{
|
||||||
|
"cifs": true, "smb3": true, "smbfs": true, "nfs": true, "nfs4": true, "afs": true,
|
||||||
|
"ceph": true, "glusterfs": true, "9p": true, "afpfs": true, "davfs": true,
|
||||||
|
"fuse.sshfs": true, "sshfs": true, "fuse.rclone": true, "fuse.s3fs": true,
|
||||||
|
"fuse.davfs2": true, "fuse.curlftpfs": true, "fuse.gcsfuse": true, "beegfs": true,
|
||||||
|
"lustre": true, "orangefs": true, "pvfs2": true, "coda": true, "ncpfs": true,
|
||||||
|
}
|
||||||
|
|
||||||
var pseudoFS = map[string]bool{
|
var pseudoFS = map[string]bool{
|
||||||
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true,
|
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "ramfs": true,
|
||||||
"cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true,
|
"cgroup": true, "cgroup2": true, "pstore": true, "bpf": true, "securityfs": true, "debugfs": true,
|
||||||
@@ -812,7 +837,7 @@ func filesystems() []FS {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
bs := uint64(st.Bsize)
|
bs := uint64(st.Bsize)
|
||||||
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs}
|
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0])}
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
case r := <-done:
|
case r := <-done:
|
||||||
@@ -820,12 +845,52 @@ func filesystems() []FS {
|
|||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
case <-time.After(3 * time.Second):
|
case <-time.After(3 * time.Second):
|
||||||
out = append(out, FS{Mount: mount, Type: fs[2]}) // unreachable: size 0 signals it
|
out = append(out, FS{Mount: mount, Type: fs[2], Remote: remoteFS[fs[2]]}) // unreachable: size 0 signals it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// diskOf names the physical drive a mount's source device sits on — "/dev/nvme0n1p2" is on
|
||||||
|
// "nvme0n1". It follows one layer of device-mapper or md (LUKS on a partition, say) but
|
||||||
|
// gives up when a thing spans several drives, because then no single drive is the answer.
|
||||||
|
func diskOf(source string) string {
|
||||||
|
if !strings.HasPrefix(source, "/dev/") {
|
||||||
|
return "" // a ZFS pool, a network share, a tmpfs: not a block device at all
|
||||||
|
}
|
||||||
|
d := parentDisk(strings.TrimPrefix(source, "/dev/"), 0)
|
||||||
|
// the sampler reports one drive per NVMe controller, so name it the same way
|
||||||
|
if m := nsRe.FindStringSubmatch(d); m != nil {
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func parentDisk(name string, depth int) string {
|
||||||
|
if name == "" || depth > 4 || strings.Contains(name, "/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base := "/sys/class/block/" + name
|
||||||
|
if _, err := os.Stat(base); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// a partition: its parent directory in sysfs is the whole drive
|
||||||
|
if _, err := os.Stat(base + "/partition"); err == nil {
|
||||||
|
if target, err := os.Readlink(base); err == nil {
|
||||||
|
return filepath.Base(filepath.Dir(target))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// device-mapper or md: one drive underneath is an answer, several is not
|
||||||
|
if ents, err := os.ReadDir(base + "/slaves"); err == nil && len(ents) > 0 {
|
||||||
|
if len(ents) > 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parentDisk(ents[0].Name(), depth+1)
|
||||||
|
}
|
||||||
|
return name // a whole drive, used without partitioning
|
||||||
|
}
|
||||||
|
|
||||||
// ---- UPS ----
|
// ---- UPS ----
|
||||||
|
|
||||||
func runOut(args ...string) (string, bool) {
|
func runOut(args ...string) (string, bool) {
|
||||||
@@ -1054,6 +1119,7 @@ type ProcTable struct {
|
|||||||
Mem Mem `json:"mem"`
|
Mem Mem `json:"mem"`
|
||||||
Swap Mem `json:"swap"`
|
Swap Mem `json:"swap"`
|
||||||
Net []Net `json:"net"`
|
Net []Net `json:"net"`
|
||||||
|
GPUs []GPU `json:"gpus"`
|
||||||
Uptime float64 `json:"uptime"`
|
Uptime float64 `json:"uptime"`
|
||||||
Procs []Proc `json:"procs"`
|
Procs []Proc `json:"procs"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
@@ -1216,6 +1282,20 @@ func (c *Collector) Processes() ProcTable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.prevProc, c.procAt = cur, now
|
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 {
|
sort.Slice(procs, func(i, j int) bool {
|
||||||
if procs[i].CPU != procs[j].CPU {
|
if procs[i].CPU != procs[j].CPU {
|
||||||
return procs[i].CPU > procs[j].CPU
|
return procs[i].CPU > procs[j].CPU
|
||||||
@@ -1223,7 +1303,7 @@ func (c *Collector) Processes() ProcTable {
|
|||||||
return procs[i].RSS > procs[j].RSS
|
return procs[i].RSS > procs[j].RSS
|
||||||
})
|
})
|
||||||
t := ProcTable{T: now.UnixMilli(), Procs: procs, Total: len(procs), Uptime: uptime(), Load: loadavg(),
|
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 {
|
for _, p := range procs {
|
||||||
if p.State == "R" {
|
if p.State == "R" {
|
||||||
t.Running++
|
t.Running++
|
||||||
@@ -1272,12 +1352,32 @@ func Signal(pid int, sig syscall.Signal) error {
|
|||||||
|
|
||||||
// ---- small helpers ----
|
// ---- 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 {
|
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 {
|
if err != nil {
|
||||||
return ""
|
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) {
|
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
@@ -117,9 +117,87 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
|||||||
|
|
||||||
for _, x := range s.FS {
|
for _, x := range s.FS {
|
||||||
p := "fs." + sensors.Seg(x.Mount)
|
p := "fs." + sensors.Seg(x.Mount)
|
||||||
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, "mount", x.Mount, "fstype", x.Type)
|
// remote says the bytes are on another machine (a CIFS or NFS share), so a view of
|
||||||
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), "mount", x.Mount, "fstype", x.Type)
|
// this machine's hardware can leave it out without losing the sensor
|
||||||
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, "mount", x.Mount, "fstype", x.Type)
|
lbl := []string{"mount", x.Mount, "fstype", x.Type}
|
||||||
|
if x.Remote {
|
||||||
|
lbl = append(lbl, "remote", "1")
|
||||||
|
}
|
||||||
|
if x.Disk != "" {
|
||||||
|
lbl = append(lbl, "disk", x.Disk)
|
||||||
|
}
|
||||||
|
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
|
||||||
|
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
|
||||||
|
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if u := s.UPS; u != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+30
-6
@@ -37,9 +37,14 @@ var systemdUnit string
|
|||||||
//go:embed hamlab.openrc
|
//go:embed hamlab.openrc
|
||||||
var openrcScript string
|
var openrcScript string
|
||||||
|
|
||||||
|
//go:embed hamlab.procd
|
||||||
|
var procdScript string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
unitPath = "/etc/systemd/system/hamlab.service"
|
unitPath = "/etc/systemd/system/hamlab.service"
|
||||||
openrcPath = "/etc/init.d/hamlab"
|
openrcPath = "/etc/init.d/hamlab"
|
||||||
|
// procd (OpenWrt) uses the same path as OpenRC; the two are told apart by content.
|
||||||
|
procdPath = "/etc/init.d/hamlab"
|
||||||
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,6 +233,19 @@ func Install(args []string, version string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case exists("/sbin/procd") && exists("/etc/rc.common"):
|
||||||
|
initSys = "procd"
|
||||||
|
if err := os.WriteFile(procdPath, []byte(fill(procdScript, vars)), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := run(procdPath, "enable"); err != nil {
|
||||||
|
say("warning: %s enable: %v", procdPath, err)
|
||||||
|
}
|
||||||
|
if !o.noStart {
|
||||||
|
if err := run(procdPath, "restart"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
||||||
initSys = "OpenRC"
|
initSys = "OpenRC"
|
||||||
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
||||||
@@ -258,7 +276,7 @@ func Install(args []string, version string) error {
|
|||||||
url := scheme + "://" + browseAddr(cfg.Listen)
|
url := scheme + "://" + browseAddr(cfg.Listen)
|
||||||
switch {
|
switch {
|
||||||
case initSys == "":
|
case initSys == "":
|
||||||
say("\nNo systemd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
|
say("\nNo systemd, procd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
|
||||||
case o.noStart:
|
case o.noStart:
|
||||||
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
||||||
say("\nHam Lab is up: %s", url)
|
say("\nHam Lab is up: %s", url)
|
||||||
@@ -294,9 +312,15 @@ func Uninstall(args []string) error {
|
|||||||
_ = run("systemctl", "daemon-reload")
|
_ = run("systemctl", "daemon-reload")
|
||||||
}
|
}
|
||||||
if b, err := os.ReadFile(openrcPath); err == nil {
|
if b, err := os.ReadFile(openrcPath); err == nil {
|
||||||
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
|
if strings.Contains(string(b), "USE_PROCD=1") {
|
||||||
_ = run("rc-service", "hamlab", "stop")
|
dataDir = configDirFrom(string(b), "HAMLAB_CONFIG=")
|
||||||
_ = run("rc-update", "del", "hamlab", "default")
|
_ = 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(openrcPath)
|
||||||
}
|
}
|
||||||
os.Remove(sudoersPath)
|
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.
|
// backs, kept as a tree of directories and the files worth seeing, the way WizTree does it.
|
||||||
//
|
//
|
||||||
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
|
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-2
@@ -44,6 +44,16 @@ type UserState struct {
|
|||||||
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RememberedMount is one bind mount of a container that was running when Ham Lab last
|
||||||
|
// looked. A compose stack records the path its file had *inside* whatever deployed it, so
|
||||||
|
// when that container is gone the path is the only way back to the file on disk.
|
||||||
|
type RememberedMount struct {
|
||||||
|
Container string `json:"container"`
|
||||||
|
Dest string `json:"dest"` // the path inside the container
|
||||||
|
Src string `json:"src"` // the path on the machine
|
||||||
|
Seen time.Time `json:"seen"`
|
||||||
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Terminals []Terminal `json:"terminals"`
|
Terminals []Terminal `json:"terminals"`
|
||||||
Sessions map[string]Session `json:"sessions"` // token -> session
|
Sessions map[string]Session `json:"sessions"` // token -> session
|
||||||
@@ -52,8 +62,19 @@ type State struct {
|
|||||||
// account. Kept so an old state file still reads.
|
// account. Kept so an old state file still reads.
|
||||||
UI json.RawMessage `json:"ui,omitempty"`
|
UI json.RawMessage `json:"ui,omitempty"`
|
||||||
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
||||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
|
||||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserState returns the prefs of an account, making an empty one if needed.
|
// UserState returns the prefs of an account, making an empty one if needed.
|
||||||
|
|||||||
@@ -84,10 +84,21 @@ func newID() string {
|
|||||||
|
|
||||||
// Create starts a new login shell. id may be empty.
|
// Create starts a new login shell. id may be empty.
|
||||||
func (m *Manager) Create(id, name string) (*Session, error) {
|
func (m *Manager) Create(id, name string) (*Session, error) {
|
||||||
|
return m.CreateCmd(id, name, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCmd is Create with something other than a login shell: argv nil is the shell, and
|
||||||
|
// anything else runs that instead — `docker exec -it …` for a container's console. Every
|
||||||
|
// session is the same thing afterwards, so attach, resize, the scrollback ring and the tab
|
||||||
|
// strip work without knowing which it is.
|
||||||
|
func (m *Manager) CreateCmd(id, name string, argv []string) (*Session, error) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
id = newID()
|
id = newID()
|
||||||
}
|
}
|
||||||
cmd := exec.Command(m.shell, "-l")
|
cmd := exec.Command(m.shell, "-l")
|
||||||
|
if len(argv) > 0 {
|
||||||
|
cmd = exec.Command(argv[0], argv[1:]...)
|
||||||
|
}
|
||||||
cmd.Dir = m.home
|
cmd.Dir = m.home
|
||||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1")
|
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1")
|
||||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
|
||||||
|
|||||||
@@ -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-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -1551,6 +1552,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { 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 { fmtBytes, fmtRate } from './fmt'
|
||||||
import TaskOutput from './TaskOutput'
|
import TaskOutput from './TaskOutput'
|
||||||
import { Btn, Loading, Spinner, askText, toast } from './ui'
|
import { askText, Btn, Loading, Meter, Spinner, toast } from './ui'
|
||||||
import { useRefreshMs } from './prefs'
|
import { useRefreshMs } from './prefs'
|
||||||
import { Sensor, useSensorStream } from './sensors'
|
import { Sensor, useSensorStream } from './sensors'
|
||||||
|
|
||||||
@@ -11,10 +11,6 @@ const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb
|
|||||||
const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`)
|
const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`)
|
||||||
const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
|
const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
|
||||||
|
|
||||||
function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
|
||||||
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
|
|
||||||
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- SMART panel ----
|
// ---- SMART panel ----
|
||||||
function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
|
function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
|
||||||
@@ -109,82 +105,9 @@ function DeviceCard({ d, live, forceOpen }: { d: StorageDevice; live?: { read_bp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ZFS ----
|
|
||||||
function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) {
|
|
||||||
const scrubbing = /scrub in progress/i.test(p.scan ?? '')
|
|
||||||
const paused = /scrub paused/i.test(p.scan ?? '')
|
|
||||||
const [cfg, setCfg] = useState(false)
|
|
||||||
const act = (a: 'start' | 'pause' | 'stop') => async () => {
|
|
||||||
try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<div className="sub-head">
|
|
||||||
<span className={'dot ' + (p.health === 'ONLINE' ? 'ok' : p.health === 'DEGRADED' ? 'warn' : 'bad')} />
|
|
||||||
<span className="dname">{p.name}</span>
|
|
||||||
<span className="badge">{p.health}</span>
|
|
||||||
<span className="spacer" />
|
|
||||||
<span className="num">{fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%</span>
|
|
||||||
</div>
|
|
||||||
<Meter frac={p.cap / 100} cls={p.cap >= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
|
|
||||||
<div className="props" style={{ marginTop: 8 }}>
|
|
||||||
<div><span className="k">Free</span><span>{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}</span></div>
|
|
||||||
{p.scan && <div><span className="k">Scan</span><span style={{ whiteSpace: 'normal' }}>{p.scan}</span></div>}
|
|
||||||
{p.errors && <div><span className="k">Errors</span><span className={p.errors.startsWith('No known') ? '' : 'warn-text'}>{p.errors}</span></div>}
|
|
||||||
</div>
|
|
||||||
<div className="svc-actions">
|
|
||||||
{!scrubbing && <Btn className="btn" confirm={`Start a scrub of ${p.name}? It reads the whole pool and can take many hours.`} onClick={act('start')}>{paused ? 'Resume scrub' : 'Scrub'}</Btn>}
|
|
||||||
{scrubbing && <Btn className="btn" onClick={act('pause')}>Pause scrub</Btn>}
|
|
||||||
{(scrubbing || paused) && <Btn className="btn" confirm="Stop the scrub?" onClick={act('stop')}>Stop scrub</Btn>}
|
|
||||||
<button className="btn" onClick={() => setCfg(!cfg)}>{cfg ? 'Hide vdevs' : 'Vdevs'}</button>
|
|
||||||
</div>
|
|
||||||
{cfg && <pre className="unit-file">{p.config}</pre>}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
|
|
||||||
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
async function toggle() {
|
|
||||||
setOpen(!open)
|
|
||||||
if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } }
|
|
||||||
}
|
|
||||||
async function snapshot() {
|
|
||||||
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
|
|
||||||
const name = await askText(`Snapshot name for ${d.name}@`, def)
|
|
||||||
if (!name) return
|
|
||||||
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
|
|
||||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
||||||
}
|
|
||||||
const depth = d.name.split('/').length - 1
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
|
|
||||||
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
|
|
||||||
<span className="num">{fmtBytes(d.used)}</span>
|
|
||||||
<span className="num muted">{fmtBytes(d.refer)}</span>
|
|
||||||
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
|
|
||||||
<span className="num muted">{d.snapshots}</span>
|
|
||||||
<span className="num muted">{d.compressratio}</span>
|
|
||||||
<span className="ds-mount muted">{d.mount}</span>
|
|
||||||
</div>
|
|
||||||
{open && (
|
|
||||||
<div className="ds-snaps">
|
|
||||||
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div>
|
|
||||||
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
|
|
||||||
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- page ----
|
// ---- page ----
|
||||||
export default function Disks() {
|
export default function Disks() {
|
||||||
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
|
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
|
||||||
const [zfs, setZfs] = useState<ZFSInfo | null>(null)
|
|
||||||
const [hasSmart, setHasSmart] = useState(true)
|
const [hasSmart, setHasSmart] = useState(true)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [task, setTask] = useState<string | null>(null)
|
const [task, setTask] = useState<string | null>(null)
|
||||||
@@ -194,7 +117,7 @@ export default function Disks() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.storage()
|
const r = await api.storage()
|
||||||
setDevs(r.devices); setZfs(r.zfs); setHasSmart(r.smartctl); setErr('')
|
setDevs(r.devices); setHasSmart(r.smartctl); setErr('')
|
||||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
}, [])
|
}, [])
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
@@ -237,18 +160,6 @@ export default function Disks() {
|
|||||||
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
|
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dash-bar"><h2 className="section">ZFS</h2></div>
|
|
||||||
{zfs && !zfs.available && <div className="notice">{zfs.message || 'No ZFS pools on this host.'}</div>}
|
|
||||||
{zfs?.available && zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
|
||||||
{zfs?.available && zfs.pools.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
|
||||||
<section className="card datasets">
|
|
||||||
<div className="ds-row head"><span>Dataset</span><span className="num">Used</span><span className="num">Refer</span><span className="num">In snaps</span><span className="num">Snaps</span><span className="num">Ratio</span><span>Mount</span></div>
|
|
||||||
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+450
-42
@@ -1,67 +1,120 @@
|
|||||||
// Docker: containers grouped by the stack that deployed them, with start / stop / restart,
|
// Docker: containers grouped by the stack that deployed them, with start / stop / restart,
|
||||||
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
|
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
|
||||||
// come from the same container sensors everything else uses.
|
// come from the same container sensors everything else uses.
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
|
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
|
||||||
import TaskOutput from './TaskOutput'
|
import TaskOutput from './TaskOutput'
|
||||||
import StackNew, { DupFrom } from './StackNew'
|
import StackNew, { DupFrom } from './StackNew'
|
||||||
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
import CodeEditor from './CodeEditor'
|
||||||
|
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
||||||
import { useHashSel } from './hashsel'
|
import { useHashSel } from './hashsel'
|
||||||
import { useRefreshMs } from './prefs'
|
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
|
||||||
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } from './ui'
|
import Sparkline from './Sparkline'
|
||||||
|
import { fmtBytes } from './fmt'
|
||||||
|
import { describeCron } from './crontext'
|
||||||
|
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast, useEscape } from './ui'
|
||||||
|
|
||||||
type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks'
|
type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'networks'
|
||||||
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
|
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
|
||||||
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
|
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
|
||||||
|
|
||||||
|
// A container's output, followed over a held connection the way the Services tab follows a
|
||||||
|
// journal. A reconnect resumes from the last line's timestamp, so a container that restarts
|
||||||
|
// — or a phone that slept — doesn't replay what is already on screen.
|
||||||
function LogView({ id }: { id: string }) {
|
function LogView({ id }: { id: string }) {
|
||||||
const [lines, setLines] = useState<{ t: string; m: string }[]>([])
|
const [lines, setLines] = useState<DockerLogLine[]>([])
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
const [live, setLive] = useState(false)
|
||||||
const [follow, setFollow] = useState(true)
|
const [follow, setFollow] = useState(true)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
const box = useRef<HTMLDivElement>(null)
|
const box = useRef<HTMLDivElement>(null)
|
||||||
const last = useRef('')
|
const last = useRef('')
|
||||||
|
const pausedRef = useRef(false)
|
||||||
|
pausedRef.current = paused
|
||||||
|
const pending = useRef<DockerLogLine[]>([])
|
||||||
|
// What a resume has already shown: `--since` is inclusive, and two lines can share a
|
||||||
|
// timestamp (a container writing to both streams at once), so the boundary is a set of
|
||||||
|
// lines rather than a moment. Bounded — only the lines around a reconnect can repeat.
|
||||||
|
const seen = useRef<{ set: Set<string>; q: string[] }>({ set: new Set(), q: [] })
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let stop = false; setLines([]); last.current = ''
|
setLines([]); setErr(''); setLive(false); last.current = ''; pending.current = []
|
||||||
const tick = async () => {
|
seen.current = { set: new Set(), q: [] }
|
||||||
if (stop) return
|
let closed = false
|
||||||
if (document.visibilityState === 'visible') {
|
let sock: WebSocket | null = null
|
||||||
try {
|
let retry: number | undefined
|
||||||
const r = await api.dockerLogs(id, last.current)
|
let attempt = 0
|
||||||
const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines
|
const connect = () => {
|
||||||
if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) }
|
if (closed) return
|
||||||
setErr('')
|
const q = last.current ? `since=${encodeURIComponent(last.current)}` : 'n=300'
|
||||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
// fixed when the log opens: a reconnect must not follow the console to another machine
|
||||||
|
const s = new WebSocket(wsURLPath(`/api/docker/containers/${encodeURIComponent(id)}/logs/tail?${q}`))
|
||||||
|
sock = s
|
||||||
|
s.onopen = () => { attempt = 0; setLive(true); setErr('') }
|
||||||
|
s.onmessage = (ev) => {
|
||||||
|
const l = JSON.parse(ev.data) as DockerLogLine
|
||||||
|
if (l.t) {
|
||||||
|
const k = l.t + '|' + (l.err ? 'e' : 'o') + '|' + l.m
|
||||||
|
const { set, q } = seen.current
|
||||||
|
if (set.has(k)) return
|
||||||
|
set.add(k); q.push(k)
|
||||||
|
if (q.length > 1000) for (const old of q.splice(0, 500)) set.delete(old)
|
||||||
|
if (l.t > last.current) last.current = l.t
|
||||||
|
}
|
||||||
|
if (pausedRef.current) { pending.current.push(l); return }
|
||||||
|
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), l] : [...prev, l]))
|
||||||
}
|
}
|
||||||
window.setTimeout(tick, 2000)
|
// 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() }
|
||||||
}
|
}
|
||||||
tick()
|
connect()
|
||||||
return () => { stop = true }
|
return () => { closed = true; clearTimeout(retry); sock?.close() }
|
||||||
}, [id])
|
}, [id])
|
||||||
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow])
|
// Resume: flush what arrived while paused.
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || pending.current.length === 0) return
|
||||||
|
const p = pending.current; pending.current = []
|
||||||
|
setLines((prev) => [...prev, ...p].slice(-2000))
|
||||||
|
}, [paused])
|
||||||
|
useEffect(() => { if (follow && !paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow, paused])
|
||||||
return (
|
return (
|
||||||
<div className="logs">
|
<div className="logs">
|
||||||
<div className="logs-bar"><span className="muted">{lines.length} lines</span><span className="spacer" /><button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button><button className="chip" onClick={() => setLines([])}>Clear</button></div>
|
<div className="logs-bar">
|
||||||
{err && <div className="error">{err}</div>}
|
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'} · {lines.length} lines{err && ` · ${err}`}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button>
|
||||||
|
<button className="chip" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
|
||||||
|
<button className="chip" onClick={() => setLines([])}>Clear</button>
|
||||||
|
</div>
|
||||||
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
||||||
{lines.map((l, i) => <div className="log" key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
{lines.map((l, i) => <div className={'log' + (l.err ? ' err' : '')} key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
||||||
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a container is doing, and what can be done to it: its numbers over time, its health
|
||||||
|
// checks, what has happened to it lately, a shell inside it, and its limits.
|
||||||
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
|
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
|
||||||
const [tab, setTab] = useState<'logs' | 'inspect'>('logs')
|
const [tab, setTab] = useState<'logs' | 'stats' | 'health' | 'events' | 'inspect'>('logs')
|
||||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
||||||
useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name])
|
const [limits, setLimits] = useState(false)
|
||||||
|
useEffect(() => { setInfo(null); if (tab === 'inspect' || limits) api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, limits, c.name])
|
||||||
const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
|
const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
|
||||||
const running = c.state === 'running'
|
const running = c.state === 'running'
|
||||||
const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
const seg = c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')
|
||||||
|
const s = (k: string) => live[`container.${seg}.${k}`]
|
||||||
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
|
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
|
||||||
|
const shell = async () => {
|
||||||
|
try { const t = await api.dockerExec(c.name); toast(`shell open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="svc-detail">
|
<div className="svc-detail">
|
||||||
<CloseBtn onClick={onBack} />
|
<CloseBtn onClick={onBack} />
|
||||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div>
|
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span>{c.health && c.health !== 'none' && <span className={'badge' + (c.health === 'healthy' ? '' : ' bad')}>{c.health}</span>}</div>
|
||||||
<div className="props">
|
<div className="props">
|
||||||
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
|
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
|
||||||
<div><span className="k">Status</span><span>{c.status}</span></div>
|
<div><span className="k">Status</span><span>{c.status}</span></div>
|
||||||
@@ -72,27 +125,181 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
|
|||||||
<div className="svc-actions">
|
<div className="svc-actions">
|
||||||
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
|
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
|
||||||
{!running && <Btn className="btn danger" confirm={`Remove the container ${c.name}? Its image, volumes and bind-mounted data stay; anything written inside the container itself is lost.${c.stack ? ` Deploying the ${c.stack} stack makes it again.` : ''}`} onClick={async () => { await act('remove')(); onBack() }}>Remove</Btn>}
|
{!running && <Btn className="btn danger" confirm={`Remove the container ${c.name}? Its image, volumes and bind-mounted data stay; anything written inside the container itself is lost.${c.stack ? ` Deploying the ${c.stack} stack makes it again.` : ''}`} onClick={async () => { await act('remove')(); onBack() }}>Remove</Btn>}
|
||||||
|
{running && <Btn className="btn" onClick={shell}>Shell</Btn>}
|
||||||
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
|
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
|
||||||
|
<Btn className="btn" onClick={() => setLimits(true)}>Limits</Btn>
|
||||||
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
|
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
|
||||||
</div>
|
</div>
|
||||||
<div className="tabs"><button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button><button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button></div>
|
<div className="tabs">
|
||||||
|
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
|
||||||
|
<button className={tab === 'stats' ? 'on' : ''} onClick={() => setTab('stats')}>Stats</button>
|
||||||
|
<button className={tab === 'health' ? 'on' : ''} onClick={() => setTab('health')}>Health</button>
|
||||||
|
<button className={tab === 'events' ? 'on' : ''} onClick={() => setTab('events')}>Events</button>
|
||||||
|
<button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button>
|
||||||
|
</div>
|
||||||
{tab === 'logs' && <LogView id={c.name} />}
|
{tab === 'logs' && <LogView id={c.name} />}
|
||||||
|
{tab === 'stats' && <Stats seg={seg} live={live} />}
|
||||||
|
{tab === 'health' && <Health id={c.name} />}
|
||||||
|
{tab === 'events' && <Events id={c.name} />}
|
||||||
{tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : (
|
{tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : (
|
||||||
<div className="props">
|
<div className="props stacked">
|
||||||
<div><span className="k">Restarts</span><span>{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×</span></div>
|
<div><span className="k">Restarts</span><span>{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×</span></div>
|
||||||
<div><span className="k">Network</span><span className="mono small">{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}</span></div>
|
<div><span className="k">Network</span><span className="mono small">{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}</span></div>
|
||||||
{Number(info.memory_limit) > 0 && <div><span className="k">Mem limit</span><span>{fmtValue(Number(info.memory_limit), 'B')}</span></div>}
|
{Number(info.memory_limit) > 0 && <div><span className="k">Mem limit</span><span>{fmtValue(Number(info.memory_limit), 'B')}</span></div>}
|
||||||
{!!info.error && <div><span className="k">Error</span><span className="warn-text">{String(info.error)}</span></div>}
|
{!!info.error && <div><span className="k">Error</span><span className="warn-text">{String(info.error)}</span></div>}
|
||||||
{!!info.compose_file && <div><span className="k">Compose</span><span className="mono small">{String(info.compose_file)}</span></div>}
|
{!!info.compose_file && <div><span className="k">Compose</span><span className="mono small">{String(info.compose_file)}</span></div>}
|
||||||
{mounts.map((m) => <div key={m.Destination}><span className="k">Mount</span><span className="mono small">{m.Source} → {m.Destination}{m.RW ? '' : ' (ro)'}</span></div>)}
|
{mounts.length > 0 && <div><span className="k">Mounts</span><span className="mono small">{mounts.map((m) => <div key={m.Destination}>{m.Source} → {m.Destination}{m.RW ? '' : ' (read only)'}</div>)}</span></div>}
|
||||||
<div><span className="k">Env</span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ')} <span className="muted">(names only)</span></span></div>
|
<div><span className="k">Env <span className="muted">names only</span></span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ') || '—'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{limits && <LimitsDialog c={c} info={info} onClose={() => setLimits(false)} onSaved={() => { setLimits(false); onChanged() }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU and memory over time, from the container sensors the sampler already keeps.
|
||||||
|
function Stats({ seg, live }: { seg: string; live: Record<string, Sensor> }) {
|
||||||
|
const [hist, setHist] = useState<{ id: string; t: number[]; v: number[] }[] | null>(null)
|
||||||
|
const historySec = useHistorySec()
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
fetchHistories(`container.${seg}.cpu,container.${seg}.mem`)
|
||||||
|
.then((h) => { if (alive) setHist(h) })
|
||||||
|
.catch(() => { if (alive) setHist([]) })
|
||||||
|
return () => { alive = false }
|
||||||
|
}, [seg])
|
||||||
|
const css = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || '#3987e5'
|
||||||
|
const of = (k: string) => hist?.find((h) => h.id === `container.${seg}.${k}`)?.v ?? []
|
||||||
|
const cpu = of('cpu'), mem = of('mem')
|
||||||
|
if (hist === null) return <Loading label="Reading the history…" />
|
||||||
|
if (cpu.length < 2 && mem.length < 2) return <div className="notice">No history yet for this container — it is sampled while it runs, so give it a moment.</div>
|
||||||
|
return (
|
||||||
|
<div className="cards">
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">CPU</span><span className="spacer" /><span className="num">{fmtValue(live[`container.${seg}.cpu`]?.value, '%')} of a core</span></div>
|
||||||
|
<Sparkline series={[{ name: 'cpu', values: cpu, color: css('--s1') }]} format={(v) => `${v.toFixed(1)}%`} height={64} />
|
||||||
|
<div className="muted small">the last {fmtWindowSec(historySec)}</div>
|
||||||
|
</section>
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">Memory</span><span className="spacer" /><span className="num">{fmtValue(live[`container.${seg}.mem`]?.value, 'B')}</span></div>
|
||||||
|
<Sparkline series={[{ name: 'mem', values: mem, color: css('--s2') }]} format={(v) => fmtBytes(v)} height={64} />
|
||||||
|
<div className="muted small">the last {fmtWindowSec(historySec)}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The health check's verdict, and what its last few probes actually said.
|
||||||
|
function Health({ id }: { id: string }) {
|
||||||
|
const [h, setH] = useState<DockerHealth | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
useEffect(() => { setH(null); api.dockerHealth(id).then(setH).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id])
|
||||||
|
if (err) return <div className="error">{err}</div>
|
||||||
|
if (!h) return <Loading label="Asking…" />
|
||||||
|
if (!h.configured) return <div className="notice">This image defines no health check, so Docker only knows whether the process is running — which it is ({h.state}). A health check is a <span className="mono">healthcheck:</span> block in the compose file.</div>
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">Health</span><span className={h.status === 'healthy' ? '' : 'warn-text'}>{h.status}</span></div>
|
||||||
|
<div><span className="k">State</span><span>{h.state}</span></div>
|
||||||
|
{!!h.failing_streak && <div><span className="k">Failing</span><span className="warn-text">{h.failing_streak} probe{h.failing_streak === 1 ? '' : 's'} in a row</span></div>}
|
||||||
|
</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 8 }}>The last {h.log?.length ?? 0} probes — Docker keeps five.</div>
|
||||||
|
{(h.log ?? []).slice().reverse().map((p, i) => (
|
||||||
|
<section className="card" key={i} style={{ marginTop: 6 }}>
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className={'sdot ' + (p.ExitCode === 0 ? 'ok' : 'bad')} />
|
||||||
|
<span>{new Date(p.Start).toLocaleString()}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small">exit {p.ExitCode} · {Math.max(0, new Date(p.End).getTime() - new Date(p.Start).getTime())} ms</span>
|
||||||
|
</div>
|
||||||
|
{p.Output.trim() && <pre className="run-out" style={{ maxHeight: 140 }}>{p.Output.trim()}</pre>}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What has happened to this container lately, asked of Docker rather than kept here.
|
||||||
|
function Events({ id }: { id: string }) {
|
||||||
|
const [ev, setEv] = useState<DockerEvent[] | null>(null)
|
||||||
|
const [kept, setKept] = useState(0)
|
||||||
|
const [since, setSince] = useState('7d')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
useEffect(() => { setEv(null); setErr(''); api.dockerEvents(id, since).then((r) => { setEv(r.events); setKept(r.kept) }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id, since])
|
||||||
|
const bad = (a: string, h?: string) => /die|kill|oom|destroy/.test(a) || (h !== undefined && h !== 'healthy')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="chips" style={{ marginBottom: 8 }}>
|
||||||
|
{['24h', '7d', '30d'].map((x) => <button key={x} className={'chip' + (since === x ? ' on' : '')} onClick={() => setSince(x)}>{x}</button>)}
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
{!ev && !err && <Loading label="Asking Docker…" />}
|
||||||
|
{ev && ev.length === 0 && <div className="notice">Nothing for this container in the last {since}. Ham Lab keeps its own record — Docker's own only reaches back a few minutes — so history starts from when this version was installed. {kept} events kept across every container.</div>}
|
||||||
|
{ev && ev.length > 0 && (
|
||||||
|
<div className="snap-list">
|
||||||
|
{ev.map((e, i) => (
|
||||||
|
<div className="snap" key={i}>
|
||||||
|
<span className={bad(e.action, e.health) ? 'warn-text' : ''}>{e.action}{e.health ? `: ${e.health}` : ''}</span>
|
||||||
|
<span className="muted">{new Date(e.t).toLocaleString()}</span>
|
||||||
|
<span className="muted small">{e.exit_code ? `exit ${e.exit_code}` : ''}{e.signal ? ` signal ${e.signal}` : ''}</span>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory, CPU and restart policy, changed on the running container. Docker does this live,
|
||||||
|
// which is the point — and also the catch, said plainly below.
|
||||||
|
function LimitsDialog({ c, info, onClose, onSaved }: { c: DockerContainer; info: Record<string, unknown> | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const curMem = Number(info?.memory_limit ?? 0)
|
||||||
|
const [mem, setMem] = useState(curMem > 0 ? String(Math.round(curMem / (1 << 20))) + 'm' : '')
|
||||||
|
const [cpus, setCpus] = useState('')
|
||||||
|
const [restart, setRestart] = useState(String(info?.restart ?? ''))
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
useEscape(onClose)
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr(''); setBusy(true)
|
||||||
|
try { await api.dockerLimits(c.name, { memory: mem, cpus, restart }); toast(`${c.name} updated`); onSaved() }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||||
|
<h3>Limits for {c.name}</h3>
|
||||||
|
<label className="muted">Memory — 512m, 2g, or 0 for no limit{curMem > 0 ? ` (now ${fmtBytes(curMem)})` : ' (no limit now)'}
|
||||||
|
<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder="leave blank to keep" spellCheck={false} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">CPUs — 1.5 means one and a half cores, 0 for no limit
|
||||||
|
<input className="mono" value={cpus} onChange={(e) => setCpus(e.target.value)} placeholder="leave blank to keep" spellCheck={false} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">Restart policy
|
||||||
|
<select value={restart} onChange={(e) => setRestart(e.target.value)}>
|
||||||
|
<option value="">leave as it is</option>
|
||||||
|
{['no', 'on-failure', 'unless-stopped', 'always'].map((p) => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="notice attn">
|
||||||
|
This changes the running container now, without recreating it. It does <b>not</b> edit the
|
||||||
|
compose file{c.stack ? <> — so deploying <b>{c.stack}</b> puts back whatever the file says</> : ''}.
|
||||||
|
Edit the stack to make it stick.
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={busy || (!mem && !cpus && !restart)}>Apply</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A plain line diff (longest common subsequence): compose files are short, so the simple way is fine.
|
|
||||||
function lineDiff(a: string, b: string): { s: ' ' | '+' | '-'; t: string }[] {
|
function lineDiff(a: string, b: string): { s: ' ' | '+' | '-'; t: string }[] {
|
||||||
const x = a.split('\n'), y = b.split('\n'); const n = x.length, m = y.length
|
const x = a.split('\n'), y = b.split('\n'); const n = x.length, m = y.length
|
||||||
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
|
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
|
||||||
@@ -120,6 +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
|
// One stack: its compose file to edit, and the four things to do with it. Saving validates
|
||||||
// with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act.
|
// with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act.
|
||||||
|
|
||||||
function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void; onDuplicate: (from: DupFrom) => void }) {
|
function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void; onDuplicate: (from: DupFrom) => void }) {
|
||||||
const [st, setSt] = useState<Stack | null>(null)
|
const [st, setSt] = useState<Stack | null>(null)
|
||||||
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
||||||
@@ -140,7 +348,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
|
|||||||
try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||||
const run = (a: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
const run = (a: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||||
if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
|
if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
|
||||||
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
|
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
|
||||||
return (
|
return (
|
||||||
@@ -150,7 +358,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
|
|||||||
<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.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>
|
||||||
{((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>}
|
{((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> :
|
{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>}
|
{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">
|
<div className="svc-actions">
|
||||||
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
|
<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>
|
<Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn>
|
||||||
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>}
|
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>}
|
||||||
{st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
|
{st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
|
||||||
|
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="build the containers again from scratch: docker compose up -d --force-recreate" confirm={`Recreate every container in ${st.name}?\n\nThis is the way out of a container Docker refuses to start — one pinned to a network that no longer exists, say, which answers "network … not found" to an ordinary Deploy. Nothing in a volume or a bind mount is touched.`} onClick={run('recreate')}>Recreate</Btn>}
|
||||||
{st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>}
|
{st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>}
|
||||||
</div>
|
</div>
|
||||||
<div className="svc-actions">
|
<div className="svc-actions">
|
||||||
@@ -203,6 +412,12 @@ export default function Docker() {
|
|||||||
// what a new stack could join, and what it could collide with
|
// what a new stack could join, and what it could collide with
|
||||||
const nets = useMemo(() => (d?.networks ?? []).map((n) => String(n.Name)).filter((n) => !['bridge', 'host', 'none'].includes(n)).sort(), [d])
|
const nets = useMemo(() => (d?.networks ?? []).map((n) => String(n.Name)).filter((n) => !['bridge', 'host', 'none'].includes(n)).sort(), [d])
|
||||||
const taken = useMemo(() => { const ports: Record<string, string> = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d])
|
const taken = useMemo(() => { const ports: Record<string, string> = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d])
|
||||||
|
// What the registry last said about each image, so a container can be marked without
|
||||||
|
// asking again. Only ever read from the cache here — checking is a deliberate act.
|
||||||
|
const [upd, setUpd] = useState<MachineUpdates | null>(null)
|
||||||
|
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
|
||||||
|
const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
|
||||||
|
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
|
||||||
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
||||||
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
|
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
|
||||||
const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
|
const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
|
||||||
@@ -210,12 +425,12 @@ export default function Docker() {
|
|||||||
const shownTab: Tab = selStack ? 'stacks' : tab
|
const shownTab: Tab = selStack ? 'stacks' : tab
|
||||||
const up = d.containers.filter((c) => c.state === 'running').length
|
const up = d.containers.filter((c) => c.state === 'running').length
|
||||||
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
||||||
const cols: Record<Exclude<Tab, 'containers' | 'stacks'>, [string, string][]> = { images: [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']], volumes: [['Name', 'Name'], ['Driver', 'Driver'], ['Mountpoint', 'Mountpoint']], networks: [['Name', 'Name'], ['Driver', 'Driver'], ['Scope', 'Scope']] }
|
const imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']]
|
||||||
return (
|
return (
|
||||||
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
|
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
|
||||||
<div className="svc-list">
|
<div className="svc-list">
|
||||||
<div className="svc-tools">
|
<div className="svc-tools">
|
||||||
<div className="chips">{(['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : t === 'stacks' ? (stacks?.length ?? '…') : d[t].length}</button>)}</div>
|
<div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
|
||||||
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
|
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
|
||||||
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
|
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
|
||||||
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||||
@@ -224,6 +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>
|
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
|
||||||
</form>}
|
</form>}
|
||||||
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
||||||
|
{shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
|
||||||
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
||||||
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +458,7 @@ export default function Docker() {
|
|||||||
{list.map((c) => (
|
{list.map((c) => (
|
||||||
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
|
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
|
||||||
<span className={'sdot ' + dot(c)} />
|
<span className={'sdot ' + dot(c)} />
|
||||||
<div className="svc-name"><div className="n">{c.name}</div><div className="d">{c.image}</div></div>
|
<div className="svc-name"><div className="n">{c.name}<UpdateDot state={imgState[c.image]} /></div><div className="d">{c.image}</div></div>
|
||||||
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
|
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -260,16 +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> })}
|
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
||||||
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
||||||
)}
|
)}
|
||||||
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && (
|
{shownTab === 'networks' && (
|
||||||
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <th />}</tr></thead>
|
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
|
||||||
<tbody>{d[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map((row, i) => <tr key={i}>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}{shownTab === 'images' && <td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td>}</tr>)}</tbody></table>
|
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
|
||||||
|
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
|
||||||
|
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
|
||||||
|
)}
|
||||||
|
{shownTab === 'images' && (
|
||||||
|
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
|
||||||
|
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr>)}</tbody></table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
|
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
|
||||||
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
||||||
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
||||||
|
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
|
||||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A container's image against the registry, as a single mark: nothing when nobody has
|
||||||
|
// asked, so an uncoloured list means "not checked" rather than "all current".
|
||||||
|
function UpdateDot({ state }: { state?: string }) {
|
||||||
|
if (!state || state === 'current') return null
|
||||||
|
const known = state === 'update'
|
||||||
|
return <span className={'updot' + (known ? ' behind' : ' unknown')} title={known ? 'a newer image has been published' : 'the registry could not say'}>{known ? '↑' : '?'}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is out of date here, and the schedules that keep it from happening again.
|
||||||
|
function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u: MachineUpdates) => void }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [jobs, setJobs] = useState<UpdateJobRow[] | null>(null)
|
||||||
|
const [file, setFile] = useState('')
|
||||||
|
const [editing, setEditing] = useState<Partial<UpdateJob> | null>(null)
|
||||||
|
const [stacks, setStacks] = useState<string[]>([])
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
|
try { const r = await api.updateJobs(); setJobs(r.jobs); setFile(r.file) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setJobs([]) }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { loadJobs(); api.stacks().then((r) => setStacks(r.stacks.map((s) => s.name))).catch(() => {}) }, [loadJobs])
|
||||||
|
const check = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try { onChecked(await api.machineUpdates(true)); toast('checked') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const run = async (j: UpdateJob) => {
|
||||||
|
if (!await askYes(`Run “${j.name}” now? It pulls ${j.stack}'s images${j.apply ? ' and deploys them, which restarts its containers' : ', without deploying them'}.`, 'Run now')) return
|
||||||
|
try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
const remove = async (j: UpdateJob) => {
|
||||||
|
if (!await askYes(`Remove the schedule “${j.name}”? Nothing is updated or rolled back — the schedule simply stops.`, 'Remove')) return
|
||||||
|
try { await api.deleteUpdateJob(j.id); toast('removed'); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
const imgs = upd?.images ?? []
|
||||||
|
const behind = imgs.filter((i) => i.state === 'update')
|
||||||
|
// Updating is per stack — that is what compose can do — so an image row acts on whichever
|
||||||
|
// stacks use it, and a queue runs them one at a time rather than recreating half the
|
||||||
|
// machine at once.
|
||||||
|
const [queue, setQueue] = useState<string[]>([])
|
||||||
|
const [running, setRunning] = useState<{ stack: string; id: string } | null>(null)
|
||||||
|
const [done, setDone] = useState<string[]>([])
|
||||||
|
const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd])
|
||||||
|
const startNext = useCallback(async (rest: string[]) => {
|
||||||
|
const next = rest[0]
|
||||||
|
if (!next) { setRunning(null); setQueue([]); return }
|
||||||
|
setQueue(rest.slice(1))
|
||||||
|
try { setRunning({ stack: next, id: (await api.stackAction(next, 'update')).id }) }
|
||||||
|
catch (e) { toast(`${next}: ${e instanceof Error ? e.message : 'failed'}`, 'err'); setRunning(null); startNext(rest.slice(1)) }
|
||||||
|
}, [])
|
||||||
|
const updateStacks = async (names: string[], what: string) => {
|
||||||
|
const uniq = [...new Set(names)]
|
||||||
|
if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return }
|
||||||
|
if (!await askYes(`Update ${what}?\n\nThis pulls the newer image${uniq.length > 1 ? 's' : ''} and recreates ${uniq.length === 1 ? `the ${uniq[0]} stack` : `these stacks, one at a time: ${uniq.join(', ')}`}. Their containers restart.`, 'Update')) return
|
||||||
|
setDone([]); startNext(uniq)
|
||||||
|
}
|
||||||
|
const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))]
|
||||||
|
return (
|
||||||
|
<div className="svc-detail">
|
||||||
|
<div className="dash-bar"><h2 className="section">What is out of date</h2><span className="spacer" />
|
||||||
|
<span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked yet'}</span>
|
||||||
|
{behindStacks.length > 0 && <Btn className="btn on" disabled={!!running} onClick={() => updateStacks(behindStacks, `everything out of date — ${behind.length} image${behind.length === 1 ? '' : 's'} across ${behindStacks.length} stack${behindStacks.length === 1 ? '' : 's'}`)}>Update all · {behind.length}</Btn>}
|
||||||
|
<Btn className="btn" busy={busy} onClick={check}>Check now</Btn>
|
||||||
|
</div>
|
||||||
|
<div className="muted small">Each check is one request per image, which registries count — so it happens when asked, and the answer is kept for six hours.</div>
|
||||||
|
{imgs.length === 0 && <div className="notice" style={{ marginTop: 8 }}>Nothing checked yet on this machine.</div>}
|
||||||
|
{imgs.length > 0 && (
|
||||||
|
<section className="card" style={{ marginTop: 8 }}>
|
||||||
|
{behind.length === 0 && <div className="muted">Every image that could be checked is current.</div>}
|
||||||
|
<div className="upd-list">
|
||||||
|
{imgs.filter((i) => i.state !== 'current').map((i) => (
|
||||||
|
<div className="row" key={i.image}>
|
||||||
|
<span className="k mono small">{i.image}</span>
|
||||||
|
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{done.some((d) => stacksFor(i.image).includes(d)) ? <span className="good">updated just now</span> : i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
|
||||||
|
<span className="muted small">{stacksFor(i.image).join(' ')}</span>
|
||||||
|
{i.state === 'update' && !done.some((d) => stacksFor(i.image).includes(d)) && (
|
||||||
|
<Btn className="btn" disabled={!!running || stacksFor(i.image).length === 0} title={stacksFor(i.image).length ? `pull it and recreate ${stacksFor(i.image).join(', ')}` : 'no stack here uses this image'}
|
||||||
|
onClick={() => updateStacks(stacksFor(i.image), `${i.image}`)}>Update</Btn>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{behind.length === 0 && imgs.some((i) => i.state === 'current') && <div className="muted small" style={{ marginTop: 6 }}>{imgs.filter((i) => i.state === 'current').length} current.</div>}
|
||||||
|
{running && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div className="sub-head"><span className="dname">Updating {running.stack}</span><span className="spacer" /><span className="muted small">{queue.length > 0 ? `${queue.length} to follow` : 'last one'}</span></div>
|
||||||
|
<TaskOutput id={running.id} onDone={(t) => {
|
||||||
|
if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack])
|
||||||
|
else toast(`${running.stack}: update failed`, 'err')
|
||||||
|
startNext(queue)
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dash-bar" style={{ marginTop: 14 }}><h2 className="section">Schedules</h2><span className="spacer" />
|
||||||
|
<Btn className="btn on" onClick={() => setEditing({ cron: '0 3 * * *', apply: true, enabled: true })}>+ New schedule</Btn>
|
||||||
|
</div>
|
||||||
|
<div className="muted small">Written to <span className="mono">{file || '/etc/cron.d/hamlab-docker'}</span>, which Ham Lab owns — so they keep running whether or not this console is up.</div>
|
||||||
|
{jobs === null && <Loading label="Reading…" />}
|
||||||
|
{jobs?.length === 0 && <div className="notice" style={{ marginTop: 8 }}>No schedule yet. Nothing on this machine updates itself.</div>}
|
||||||
|
<div className="cards" style={{ marginTop: 8 }}>
|
||||||
|
{(jobs ?? []).map(({ job, next, last }) => (
|
||||||
|
<section className="card" key={job.id}>
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className={'dot ' + (last?.error ? 'bad' : job.enabled ? 'ok' : '')} />
|
||||||
|
<span className="dname">{job.name}</span>
|
||||||
|
{!job.enabled && <span className="badge">paused</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small">{job.enabled && next ? new Date(next).toLocaleString() : 'not scheduled'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="props" style={{ marginTop: 6 }}>
|
||||||
|
<div><span className="k">Stack</span><span>{job.stack}{job.services?.length ? ` · ${job.services.join(' ')}` : ''}</span></div>
|
||||||
|
<div><span className="k">Runs</span><span>{describeCron(job.cron) || <span className="mono small">{job.cron}</span>}{describeCron(job.cron) && <span className="muted mono small"> · {job.cron}</span>}</span></div>
|
||||||
|
<div><span className="k">Does</span><span>{job.apply ? 'pulls and deploys' : 'pulls only — you deploy'}</span></div>
|
||||||
|
<div><span className="k">Last run</span><span>{last?.at ? new Date(last.at).toLocaleString() : 'never'}</span></div>
|
||||||
|
</div>
|
||||||
|
{last?.error && <div className="error" style={{ marginTop: 6 }}>{last.error}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<Btn className="btn on" onClick={() => run(job)}>Run now</Btn>
|
||||||
|
<button className="btn" onClick={() => setEditing(job)}>Edit</button>
|
||||||
|
<Btn className="btn" onClick={async () => { await api.saveUpdateJob({ ...job, enabled: !job.enabled }); loadJobs() }}>{job.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||||
|
<Btn className="btn danger" onClick={() => remove(job)}>Remove</Btn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editing && <JobDialog j={editing} stacks={stacks} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); loadJobs() }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobDialog({ j, stacks, onClose, onSaved }: { j: Partial<UpdateJob>; stacks: string[]; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const [f, setF] = useState<Partial<UpdateJob>>(j)
|
||||||
|
const [svc, setSvc] = useState((j.services ?? []).join(' '))
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
useEscape(onClose)
|
||||||
|
const set = (k: keyof UpdateJob, v: unknown) => setF((x) => ({ ...x, [k]: v }))
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr(''); setBusy(true)
|
||||||
|
try { await api.saveUpdateJob({ ...f, services: svc.split(/\s+/).filter(Boolean) }); onSaved() }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||||
|
<h3>{f.id ? 'Edit schedule' : 'Keep a stack up to date'}</h3>
|
||||||
|
<label className="muted">Stack
|
||||||
|
<select value={f.stack ?? ''} onChange={(e) => set('stack', e.target.value)} required>
|
||||||
|
<option value="">choose…</option>
|
||||||
|
{stacks.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="muted">Name — optional
|
||||||
|
<input value={f.name ?? ''} onChange={(e) => set('name', e.target.value)} placeholder={f.stack ?? 'nightly'} maxLength={64} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">Only these services — optional, space separated; blank means the whole stack
|
||||||
|
<input className="mono" value={svc} onChange={(e) => setSvc(e.target.value)} placeholder="all of them" spellCheck={false} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">When — a cron expression
|
||||||
|
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 3 * * *" spellCheck={false} required />
|
||||||
|
</label>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={!!f.apply} onChange={(e) => set('apply', e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Deploy the new image, not just fetch it
|
||||||
|
</label>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div className="notice">
|
||||||
|
{f.apply
|
||||||
|
? 'Deploying restarts the stack’s containers. On something that carries DNS or ingress, pick an hour you are asleep.'
|
||||||
|
: 'Pulling only means the new image is on the machine and the list shows it as available — nothing restarts until you deploy.'}
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={busy || !f.stack || !f.cron}>Save</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -147,13 +147,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ---- actions ----
|
// ---- actions ----
|
||||||
const act = async (label: string, fn: () => Promise<unknown>) => {
|
const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
|
||||||
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
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.)
|
// 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 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 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 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 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)) }
|
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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { describeCron } from './crontext'
|
||||||
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
||||||
import { Logs } from './Services'
|
import { Logs } from './Services'
|
||||||
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
||||||
@@ -13,22 +14,7 @@ function rel(ms: number): string {
|
|||||||
return s >= 0 ? `in ${txt}` : `${txt} ago`
|
return s >= 0 ? `in ${txt}` : `${txt} ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain-English reading of the common five-field shapes; anything else shows the raw expression.
|
const describe = (expr: string) => describeCron(expr) || expr
|
||||||
function describe(expr: string): string {
|
|
||||||
const alias: Record<string, string> = { '@reboot': 'at boot', '@hourly': 'every hour', '@daily': 'every day at 00:00', '@midnight': 'every day at 00:00', '@weekly': 'every Sunday at 00:00', '@monthly': 'on the 1st at 00:00', '@yearly': 'every 1 Jan', '@annually': 'every 1 Jan' }
|
|
||||||
if (alias[expr]) return alias[expr]
|
|
||||||
const f = expr.split(/\s+/)
|
|
||||||
if (f.length !== 5) return expr
|
|
||||||
const [mi, h, dom, mon, dow] = f
|
|
||||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|
||||||
const at = /^\d+$/.test(mi) && /^\d+$/.test(h) ? `at ${h.padStart(2, '0')}:${mi.padStart(2, '0')}` : /^\d+$/.test(mi) && h === '*' ? `every hour at :${mi.padStart(2, '0')}` : mi.startsWith('*/') && h === '*' ? `every ${mi.slice(2)} min` : null
|
|
||||||
if (!at) return expr
|
|
||||||
const dowTxt = dow === '*' ? '' : /^\d$/.test(dow) ? `every ${days[+dow]}` : `on days ${dow}`
|
|
||||||
const domTxt = dom === '*' ? '' : /^\d+$/.test(dom) ? `on the ${dom}${['st', 'nd', 'rd'][+dom - 1] ?? 'th'}` : `on days ${dom}`
|
|
||||||
const monTxt = mon === '*' ? '' : `in month ${mon}`
|
|
||||||
const when = [dowTxt, domTxt, monTxt].filter(Boolean).join(', ')
|
|
||||||
return (when ? `${when} ` : dow === '*' && dom === '*' && !at.startsWith('every') ? 'every day ' : '') + at
|
|
||||||
}
|
|
||||||
|
|
||||||
const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s)
|
const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s)
|
||||||
|
|
||||||
@@ -175,11 +161,11 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- page ----
|
// ---- page ----
|
||||||
export default function Cron() {
|
export default function Jobs() {
|
||||||
const [ov, setOv] = useState<CronOverview | null>(null)
|
const [ov, setOv] = useState<CronOverview | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [hashSel, setHashSel] = useHashSel('cron') // "job:<id>" or "timer:<unit>", in the URL
|
const [hashSel, setHashSel] = useHashSel('jobs') // "job:<id>" or "timer:<unit>", in the URL
|
||||||
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
|
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
|
||||||
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
||||||
const [editing, setEditing] = useState<string | null>(null)
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
@@ -206,7 +192,7 @@ export default function Cron() {
|
|||||||
else pane = <div className="svc-empty muted">Select a job or timer.</div>
|
else pane = <div className="svc-empty muted">Select a job or timer.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'cron' + (sel || editing ? ' has-sel' : '')}>
|
<div className={'cron jobs' + (sel || editing ? ' has-sel' : '')}>
|
||||||
<div className="cron-list">
|
<div className="cron-list">
|
||||||
<div className="svc-tools">
|
<div className="svc-tools">
|
||||||
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
|
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
|
||||||
@@ -218,7 +204,7 @@ export default function Cron() {
|
|||||||
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
|
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
|
||||||
<div className="cron-rows">
|
<div className="cron-rows">
|
||||||
{!ov && !err && <Loading label="Reading crontabs…" />}
|
{!ov && !err && <Loading label="Reading crontabs…" />}
|
||||||
{ov && <h3>Cron jobs · {jobs.length}</h3>}
|
{ov && <h3>Scheduled jobs · {jobs.length}</h3>}
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => (
|
||||||
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
|
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
|
||||||
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
|
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
|
||||||
+187
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+27
-2
@@ -2,6 +2,19 @@ import { FormEvent, useState } from 'react'
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import { Spinner } from './ui'
|
import { Spinner } from './ui'
|
||||||
|
|
||||||
|
// Drawn, not typed: 👁 is an emoji on phones, full colour and its own size. Outline strokes
|
||||||
|
// match the glyphs in the rail and the power button.
|
||||||
|
const EyeIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M1.2 8S3.9 3.6 8 3.6 14.8 8 14.8 8 12.1 12.4 8 12.4 1.2 8 1.2 8z" /><circle cx="8" cy="8" r="2.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const EyeOffIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M1.2 8S3.9 3.6 8 3.6 14.8 8 14.8 8 12.1 12.4 8 12.4 1.2 8 1.2 8z" /><circle cx="8" cy="8" r="2.1" /><path d="M2.4 13.6 13.6 2.4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again
|
const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again
|
||||||
|
|
||||||
export default function Login({ onLogin }: { onLogin: () => void }) {
|
export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||||
@@ -9,6 +22,9 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
|||||||
const [pw, setPw] = useState('')
|
const [pw, setPw] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
// Shown on request: a generated password is typed by hand off a message, and a wrong
|
||||||
|
// character looks exactly like a wrong password from here.
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
async function submit(e: FormEvent) {
|
async function submit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -26,8 +42,17 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
|||||||
<img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} />
|
<img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} />
|
||||||
<input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off"
|
<input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off"
|
||||||
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
|
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
|
||||||
<input type="password" autoFocus={!!user} autoComplete="current-password" placeholder="password"
|
<div className="pwfield">
|
||||||
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
|
{/* 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>
|
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
|||||||
try {
|
try {
|
||||||
let res = await api.updateAllMachines()
|
let res = await api.updateAllMachines()
|
||||||
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
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')
|
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
||||||
setTimeout(load, 4000)
|
setTimeout(load, 4000)
|
||||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
} 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
|
const s = ns / 1e9
|
||||||
return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s`
|
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 {
|
function since(ts?: string): string {
|
||||||
if (!ts) return ''
|
if (!ts) return ''
|
||||||
const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
|
const t = parseSystemdTime(ts)
|
||||||
if (isNaN(t)) return ts
|
if (isNaN(t)) return ts
|
||||||
const s = Math.max(0, (Date.now() - t) / 1000)
|
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)
|
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>
|
||||||
</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">
|
<div className="svc-rows">
|
||||||
{!loaded && <Loading label="Listing services…" />}
|
{!loaded && <Loading label="Listing services…" />}
|
||||||
{shown.map((u) => (
|
{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>
|
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</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>}
|
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
|
||||||
</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>}
|
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
|
||||||
<div className="svc-actions" style={{ marginTop: 10 }}>
|
<div className="svc-actions" style={{ marginTop: 10 }}>
|
||||||
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
|
<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 Docker from './Docker'
|
||||||
import Alerts from './Alerts'
|
import Alerts from './Alerts'
|
||||||
import Services from './Services'
|
import Services from './Services'
|
||||||
import Cron from './Cron'
|
import Jobs from './Jobs'
|
||||||
import Updates from './Updates'
|
import Updates from './Updates'
|
||||||
import Disks from './Disks'
|
import Disks from './Disks'
|
||||||
|
import Hardware from './Hardware'
|
||||||
|
import Network from './Network'
|
||||||
|
import VPN from './VPN'
|
||||||
|
import ZFS from './ZFS'
|
||||||
|
import Virtualization from './Virtualization'
|
||||||
|
import KVM from './KVM'
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
import Top from './Top'
|
import Top from './Top'
|
||||||
import Machines from './Machines'
|
import Machines from './Machines'
|
||||||
import Settings from './Settings'
|
import Settings from './Settings'
|
||||||
import Space from './Space'
|
import Usage from './Usage'
|
||||||
import Users from './Users'
|
import Users from './Users'
|
||||||
import MachineSwitcher from './MachineSwitcher'
|
import MachineSwitcher from './MachineSwitcher'
|
||||||
import { MachineStatus, setMachine } from './api'
|
import { MachineStatus, setMachine } from './api'
|
||||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||||
import Power from './Power'
|
import Power from './Power'
|
||||||
import { applyAccent } from './accents'
|
import { applyAccent } from './accents'
|
||||||
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast, 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).
|
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||||
const FolderIcon = (
|
const FolderIcon = (
|
||||||
@@ -49,33 +56,58 @@ const BellIcon = (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const GlobeIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="8" r="6.2" /><path d="M1.9 8h12.2M8 1.8c1.7 1.8 2.6 3.9 2.6 6.2S9.7 12.4 8 14.2C6.3 12.4 5.4 10.3 5.4 8S6.3 3.6 8 1.8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// A padlock-ish shield: the tab is about who may come in, and a keyhole reads at 14px where
|
||||||
|
// a padlock's shackle does not.
|
||||||
|
const ShieldIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M8 1.6 13.2 3.5v4.2c0 3-2.1 5.4-5.2 6.7-3.1-1.3-5.2-3.7-5.2-6.7V3.5z" /><circle cx="8" cy="7.2" r="1.3" /><path d="M8 8.5v2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'procs', label: 'Processes', icon: '▚' },
|
{ id: 'procs', label: 'Processes', icon: '▚' },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
{ id: 'services', label: 'Services', icon: '◈' },
|
{ id: 'services', label: 'Services', icon: '◈' },
|
||||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
||||||
{ id: 'cron', label: 'Cron', icon: ClockIcon },
|
|
||||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
|
||||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||||
{ id: 'space', label: 'Space', icon: '▧' },
|
|
||||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||||
|
{ id: 'network', label: 'Network', icon: GlobeIcon },
|
||||||
|
{ id: 'vpn', label: 'VPN', icon: ShieldIcon },
|
||||||
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||||
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||||
|
{ id: '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: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||||
{ id: 'users', label: 'Users', icon: '⚇' },
|
|
||||||
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
||||||
|
{ id: 'users', label: 'Users', icon: '⚇' },
|
||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | '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', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', 'files', 'procs', 'machines', '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']
|
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||||
|
|
||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
// forward work, reload restores the tab, and links can point at a page on a machine.
|
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||||
|
// Pages that were renamed, so a link or a bookmark made before it still lands where it meant.
|
||||||
|
const RENAMED_VIEWS: Record<string, View> = { space: 'usage', cron: 'jobs', top: 'procs', dashboard: 'procs' }
|
||||||
|
|
||||||
function parseHash(): { machine: string | null; view: View } | null {
|
function parseHash(): { machine: string | null; view: View } | null {
|
||||||
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
|
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
|
||||||
if (!m || !VIEWS.includes(m[2] as View)) return null
|
if (!m) return null
|
||||||
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
|
const view = VIEWS.includes(m[2] as View) ? (m[2] as View) : RENAMED_VIEWS[m[2]]
|
||||||
|
if (!view) return null
|
||||||
|
return { machine: m[1] === 'hub' ? null : m[1], view }
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtUptime(s: number): string {
|
function fmtUptime(s: number): string {
|
||||||
@@ -93,6 +125,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||||
const [remoteErr, setRemoteErr] = useState('')
|
const [remoteErr, setRemoteErr] = useState('')
|
||||||
|
// A guest login is an account with a clock on it: nothing is hidden from it, but the header
|
||||||
|
// says how long it has, for whoever is holding the phone.
|
||||||
|
const leftMs = useTimeLeft(hubMe.guest_until)
|
||||||
// A machine is titled by its own hostname; the name it was added under stays its identity
|
// A machine is titled by its own hostname; the name it was added under stays its identity
|
||||||
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
||||||
const title = titleOf(machine, machines, hubMe.host)
|
const title = titleOf(machine, machines, hubMe.host)
|
||||||
@@ -102,6 +137,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
|
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
|
||||||
const [drawer, setDrawer] = useState(false)
|
const [drawer, setDrawer] = useState(false)
|
||||||
|
useEscape(() => setDrawer(false), drawer) // the phone's drawer closes like every other overlay
|
||||||
const [online, setOnline] = useState(true)
|
const [online, setOnline] = useState(true)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const handles = useRef<Record<string, TermHandle>>({})
|
const handles = useRef<Record<string, TermHandle>>({})
|
||||||
@@ -116,6 +152,32 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
|
|
||||||
useEffect(() => { reload() }, [reload])
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
// One page sending you to another — the Network tab handing a tunnel over to the VPN tab.
|
||||||
|
// A custom event rather than a hash assignment, because assigning location.hash does not
|
||||||
|
// reliably raise popstate, which is what this shell navigates by.
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: Event) => {
|
||||||
|
const id = (e as CustomEvent<string>).detail
|
||||||
|
if (VIEWS.includes(id as View)) { setView(id as View); setDrawer(false) }
|
||||||
|
}
|
||||||
|
window.addEventListener('hamlab:view', h)
|
||||||
|
return () => window.removeEventListener('hamlab:view', h)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// A shell opened inside a container from the Docker tab is a terminal like any other:
|
||||||
|
// pick it up, show the Terminal tab, and make it the active one.
|
||||||
|
useEffect(() => {
|
||||||
|
const h = async (e: Event) => {
|
||||||
|
const id = (e as CustomEvent<string>).detail
|
||||||
|
if (!id) return
|
||||||
|
await reload()
|
||||||
|
setActive(id)
|
||||||
|
setView('terminal')
|
||||||
|
}
|
||||||
|
window.addEventListener('hamlab:terminal', h)
|
||||||
|
return () => window.removeEventListener('hamlab:terminal', h)
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
// Machine list for the switcher (hub-local), refreshed while the console is open.
|
// Machine list for the switcher (hub-local), refreshed while the console is open.
|
||||||
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
|
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
|
||||||
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
||||||
@@ -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="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
|
||||||
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
||||||
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
|
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
|
||||||
|
{hubMe.guest && <span className="badge guest" title={`this guest login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
|
||||||
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
||||||
<Btn className="link" onClick={logout}>logout</Btn>
|
<Btn className="link" onClick={logout}>logout</Btn>
|
||||||
</header>
|
</header>
|
||||||
@@ -260,19 +323,27 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||||
|
|
||||||
<main className="main" key={machine ?? 'hub'}>
|
<main className="main" key={machine ?? 'hub'}>
|
||||||
|
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
||||||
{view === 'settings' && <Settings me={hubMe} />}
|
{view === 'settings' && <Settings me={hubMe} />}
|
||||||
{view === 'users' && <Users me={hubMe} />}
|
{view === 'users' && <Users me={hubMe} />}
|
||||||
{view === 'space' && <Space key={machine ?? ''} />}
|
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
||||||
|
{view === 'usage' && <Usage key={machine ?? ''} />}
|
||||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||||
{view === 'widgets' && <Gallery />}
|
{view === 'widgets' && <Gallery />}
|
||||||
{view === 'docker' && <Docker />}
|
{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 === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
||||||
{view === 'procs' && <Top />}
|
{view === 'procs' && <Top />}
|
||||||
{view === 'services' && <Services />}
|
{view === 'services' && <Services />}
|
||||||
{view === 'cron' && <Cron />}
|
{view === 'jobs' && <Jobs />}
|
||||||
{view === 'updates' && <Updates />}
|
{view === 'updates' && <Updates />}
|
||||||
{view === 'disks' && <Disks />}
|
{view === 'disks' && <Disks />}
|
||||||
|
{view === 'hardware' && <Hardware />}
|
||||||
|
{view === 'network' && <Network />}
|
||||||
|
{view === 'vpn' && <VPN />}
|
||||||
|
{view === 'zfs' && <ZFS />}
|
||||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||||
|
</PageBoundary>
|
||||||
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||||
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
||||||
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
|
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
|
||||||
@@ -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>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === '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>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { api, StackHome } from './api'
|
import { api, StackHome } from './api'
|
||||||
import { askYes, Btn, Loading, useEscape } from './ui'
|
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 }
|
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: '' }
|
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', labels: '', restart: 'unless-stopped', command: '', network: '' }
|
||||||
@@ -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>
|
<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>
|
<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?.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
|
{!(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} />
|
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
|
||||||
|
|||||||
+160
-27
@@ -4,7 +4,7 @@
|
|||||||
// /api/procs for the table — so this works on any machine the console can see, whether or not
|
// /api/procs for the table — so this works on any machine the console can see, whether or not
|
||||||
// btop is installed on it.
|
// btop is installed on it.
|
||||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
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 { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
|
||||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
||||||
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
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 ----
|
// ---- 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) {
|
function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
|
||||||
const cur = useRef<Map<string, number[]>>(new Map())
|
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
|
const n = binN.current
|
||||||
for (const [id, v] of Object.entries(live)) {
|
for (const [id, v] of Object.entries(live)) {
|
||||||
if (typeof v !== 'number') continue
|
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) ?? []
|
const arr = cur.current.get(id) ?? []
|
||||||
if (!cur.current.has(id)) cur.current.set(id, arr)
|
if (!cur.current.has(id)) cur.current.set(id, arr)
|
||||||
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
|
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
|
||||||
@@ -250,6 +250,20 @@ export default function Top() {
|
|||||||
|
|
||||||
// the process table, and the CPU model, come from the same place the Processes page reads
|
// the process table, and the CPU model, come from the same place the Processes page reads
|
||||||
const [pt, setPt] = useState<ProcTable | null>(null)
|
const [pt, setPt] = useState<ProcTable | null>(null)
|
||||||
|
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
|
||||||
|
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
|
||||||
|
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
|
||||||
|
const [pools, setPools] = useState<Record<string, string>>({})
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
api.storage().then((r) => {
|
||||||
|
if (!live) return
|
||||||
|
const m: Record<string, string> = {}
|
||||||
|
for (const d of r.devices) for (const part of d.parts) if (part.fstype === 'zfs_member' && part.label) m[d.name] = part.label
|
||||||
|
setPools(m)
|
||||||
|
}).catch(() => { /* the rows simply say less */ })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [])
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let stop = false
|
let stop = false
|
||||||
@@ -266,7 +280,7 @@ export default function Top() {
|
|||||||
// how many points the history window holds at this rate: the width of every graph
|
// 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 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 [rev, setRev] = useState(false)
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [sel, setSel] = useState<number | null>(null)
|
const [sel, setSel] = useState<number | null>(null)
|
||||||
@@ -289,8 +303,8 @@ export default function Top() {
|
|||||||
if (!pt) return []
|
if (!pt) return []
|
||||||
const f = filter.trim().toLowerCase()
|
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 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)
|
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)))
|
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a) || b.cpu - a.cpu))
|
||||||
return rev ? list.reverse() : list
|
return rev ? list.reverse() : list
|
||||||
}, [pt, filter, sort, rev])
|
}, [pt, filter, sort, rev])
|
||||||
|
|
||||||
@@ -319,15 +333,28 @@ export default function Top() {
|
|||||||
const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached)
|
const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached)
|
||||||
const swap = s?.swap ?? pt?.swap
|
const swap = s?.swap ?? pt?.swap
|
||||||
const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo')
|
const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo')
|
||||||
const iface = net[0]
|
// the one that has actually carried traffic, not whichever the kernel listed first — wg0
|
||||||
const fs = s?.fs ?? []
|
// and a dormant wlan should not push the real NIC out of the way. Tapping a name switches.
|
||||||
|
const busiest = net.length ? net.reduce((a, b) => (b.rx_total + b.tx_total > a.rx_total + a.tx_total ? b : a)) : undefined
|
||||||
|
const iface = net.find((n) => n.iface === pickedIf) ?? busiest
|
||||||
|
// the disks box is this machine's own storage: a CIFS or NFS share is somebody else's disk
|
||||||
|
const fs = (s?.fs ?? []).filter((f) => !f.remote)
|
||||||
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
|
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
|
||||||
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
|
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
|
||||||
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
|
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
|
||||||
|
// 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 (
|
return (
|
||||||
<Pal.Provider value={pal}>
|
<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={<>
|
<Box n={1} name="cpu" className="top-cpu" right={<>
|
||||||
<span className="t-clock">{clock.toLocaleTimeString([], { hour12: false })}</span>
|
<span className="t-clock">{clock.toLocaleTimeString([], { hour12: false })}</span>
|
||||||
<span className="t-rate">
|
<span className="t-rate">
|
||||||
@@ -365,6 +392,16 @@ export default function Top() {
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</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">
|
<Box n={2} name="mem" className="top-mem">
|
||||||
<div className="t-row"><b>Total:</b><span className="spacer" />{bytes(mem.total)}</div>
|
<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]) => (
|
{([['Used', mem.used, 'used'], ['Available', mem.avail, 'avail'], ['Cached', cached, 'cached'], ['Free', free, 'free']] as const).map(([k, v, g]) => (
|
||||||
@@ -378,18 +415,26 @@ export default function Top() {
|
|||||||
|
|
||||||
<Box n={3} name="disks" className="top-disks" right="io">
|
<Box n={3} name="disks" className="top-disks" right="io">
|
||||||
<div className="t-disks">
|
<div className="t-disks">
|
||||||
{swap && swap.total > 0 && <Disk name="swap" size={swap.total} used={swap.used} />}
|
{(s?.disks ?? []).map((d) => {
|
||||||
{fs.map((f) => {
|
// what is on this drive: the filesystems the sampler could attribute to it. A ZFS
|
||||||
// which drive is behind a mount isn't in the sample, so only the root filesystem
|
// pool or an md array spans several drives, so nothing is attributed and the row
|
||||||
// gets an IO line, against the machine's largest drive — which is the one it is on
|
// shows what is true of the hardware — how busy it is, and how hot.
|
||||||
const d = f.mount === '/' ? [...(s?.disks ?? [])].sort((a, b) => b.size - a.size)[0] : undefined
|
const on = fs.filter((f) => f.disk === d.name)
|
||||||
return <Disk key={f.mount} name={f.mount === '/' ? 'root' : f.mount.split('/').filter(Boolean).pop() ?? f.mount} size={f.size} used={f.used} io={d ? d.util_pct : undefined} />
|
const used = on.reduce((a, f) => a + f.used, 0)
|
||||||
|
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
|
||||||
})}
|
})}
|
||||||
{fs.length === 0 && <div className="t-dim">no filesystems reported</div>}
|
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box n={4} name="net" className="top-net" right={iface ? <span className="t-if">{iface.iface}</span> : undefined}>
|
<Box n={4} name="net" className="top-net" right={net.length ? (
|
||||||
|
<span className="t-ifs">
|
||||||
|
{net.map((n) => (
|
||||||
|
<button key={n.iface} className={'t-if' + (iface && n.iface === iface.iface ? ' on' : '')}
|
||||||
|
title={`${n.iface} — ${bytes(n.rx_total)} in, ${bytes(n.tx_total)} out`} onClick={() => setPickedIf(n.iface)}>{n.iface}</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : undefined}>
|
||||||
{iface ? (
|
{iface ? (
|
||||||
<div className="t-net">
|
<div className="t-net">
|
||||||
<div className="t-net-graphs">
|
<div className="t-net-graphs">
|
||||||
@@ -419,17 +464,17 @@ export default function Top() {
|
|||||||
|
|
||||||
<Box n={5} name="proc" className="top-proc" right={<>
|
<Box n={5} name="proc" className="top-proc" right={<>
|
||||||
<span className="t-sorts">
|
<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>
|
<button className={rev ? 'on' : ''} onClick={() => setRev(!rev)}>reverse</button>
|
||||||
</span>
|
</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" />
|
<input className="t-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||||
<div className="t-thead">
|
<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-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>
|
</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">
|
<div className="t-foot">
|
||||||
{sel !== null && procs.find((p) => p.pid === sel) ? (() => { const p = procs.find((x) => x.pid === sel)!; return <>
|
{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" />
|
<span className="t-dim">{p.pid} {p.name}</span><span className="spacer" />
|
||||||
@@ -447,7 +492,7 @@ export default function Top() {
|
|||||||
|
|
||||||
// The rows are the heaviest thing on the page — hundreds of them — so they are redrawn when the
|
// 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.
|
// 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 (
|
return (
|
||||||
<div className="t-rows">
|
<div className="t-rows">
|
||||||
{procs.map((p) => {
|
{procs.map((p) => {
|
||||||
@@ -461,6 +506,7 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
|
|||||||
<span className="c-user">{p.user}</span>
|
<span className="c-user">{p.user}</span>
|
||||||
<span className="c-mem">{shortBytes(p.rss)}</span>
|
<span className="c-mem">{shortBytes(p.rss)}</span>
|
||||||
<span className="c-g">{braille(c, 7, Math.max(5, ...(c.length ? c : [0])))}</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>
|
<span className="c-cpu">{p.cpu.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -470,14 +516,101 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) {
|
// One GPU, laid out like the cpu box: the busy curve on the left; on the right the device's
|
||||||
const upct = size > 0 ? (used / size) * 100 : 0
|
// 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 }) {
|
||||||
|
const upct = used !== undefined && size > 0 ? (used / size) * 100 : 0
|
||||||
return (
|
return (
|
||||||
<div className="t-disk">
|
<div className="t-disk">
|
||||||
<div className="t-row"><b>{name}</b><span className="spacer" />{bytes(size)}</div>
|
<div className="t-row">
|
||||||
|
<b>{name}</b>
|
||||||
|
{note && <span className="t-dim t-model" title={note}>{note}</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
{temp !== undefined && temp > 0 && <span className="t-temp">{Math.round(temp)}°C</span>}
|
||||||
|
{bytes(size)}
|
||||||
|
</div>
|
||||||
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
|
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
|
||||||
<div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div>
|
{used !== undefined ? (
|
||||||
<div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div>
|
<>
|
||||||
|
<div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div>
|
||||||
|
<div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div>
|
||||||
|
{mounts && mounts.length > 0 && <div className="t-row t-sub t-dim t-mounts" title={mounts.join(' ')}>{mounts.join(' ')}</div>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="t-row t-sub t-dim">{pool ? `member of ZFS pool ${pool}` : 'nothing mounted from it'}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -52,7 +52,11 @@ export default function Updates() {
|
|||||||
setOpen(p.name)
|
setOpen(p.name)
|
||||||
if (!changelog[p.name]) {
|
if (!changelog[p.name]) {
|
||||||
try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
|
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 })
|
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="n">{p.name}{p.security && <span className="badge bad">security</span>}</span>
|
||||||
<span className="d">{p.description}</span>
|
<span className="d">{p.description}</span>
|
||||||
</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>
|
</div>
|
||||||
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
|
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
|
||||||
</div>
|
</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:
|
// filesystem the disk backs, as root when the host allows), then read the result two ways:
|
||||||
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every
|
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every
|
||||||
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
|
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
|
||||||
@@ -45,10 +46,10 @@ const ago = (iso?: string) => {
|
|||||||
const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` }
|
const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` }
|
||||||
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
|
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
|
||||||
|
|
||||||
export default function Space() {
|
export default function Usage() {
|
||||||
const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
|
const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [sel, setSel] = useHashSel('space')
|
const [sel, setSel] = useHashSel('usage')
|
||||||
const [tab, setTab] = useState<'tree' | 'files'>('tree')
|
const [tab, setTab] = useState<'tree' | 'files'>('tree')
|
||||||
const rate = useRefreshMs()
|
const rate = useRefreshMs()
|
||||||
|
|
||||||
+93
-3
@@ -3,19 +3,98 @@
|
|||||||
// a token rather than a login, so a node's accounts are managed on that node's console.
|
// a token rather than a login, so a node's accounts are managed on that node's console.
|
||||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { api, Me, Session, User } from './api'
|
import { api, FreshGuest, Guest, Me, Session, User } from './api'
|
||||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||||
|
import { fmtLeft } from './guest'
|
||||||
|
|
||||||
export default function Users({ me }: { me: Me }) {
|
export default function Users({ me }: { me: Me }) {
|
||||||
return (
|
return (
|
||||||
<div className="dash settings">
|
<div className="dash settings">
|
||||||
<Account me={me} />
|
<Account me={me} />
|
||||||
<Sessions />
|
<Sessions />
|
||||||
|
<Guests />
|
||||||
<Accounts me={me} />
|
<Accounts me={me} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GUEST_MINUTES = [15, 30, 60, 120, 480] as const
|
||||||
|
|
||||||
|
// A login to hand somebody you are showing the console to: an ordinary account with a clock
|
||||||
|
// on it, which expires on its own and can be ended here at any moment. Making one is the only
|
||||||
|
// time its password is readable, so it is shown until dismissed rather than in a toast.
|
||||||
|
function Guests() {
|
||||||
|
const [list, setList] = useState<Guest[] | null>(null)
|
||||||
|
const [minutes, setMinutes] = useState<number>(30)
|
||||||
|
const [fresh, setFresh] = useState<FreshGuest | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const load = useCallback(async () => { try { setList(await api.guests()) } catch { setList([]) } }, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
// The clock is the point of the thing, so it keeps up without a reload.
|
||||||
|
useEffect(() => { const t = setInterval(load, 30000); return () => clearInterval(t) }, [load])
|
||||||
|
const create = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try { setFresh(await api.createGuest(minutes)); load() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const revoke = async (g: Guest) => {
|
||||||
|
if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once, everywhere.`, 'Revoke')) return
|
||||||
|
try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can be revoked</span></div>
|
||||||
|
<section className="card">
|
||||||
|
{list === null && <Loading label="Loading…" />}
|
||||||
|
{list?.length === 0 && <div className="empty">None. One lets somebody in for a while without giving them an account.</div>}
|
||||||
|
{list?.map((g) => {
|
||||||
|
const left = new Date(g.expires).getTime() - Date.now()
|
||||||
|
return (
|
||||||
|
<div className="row" key={g.name}>
|
||||||
|
<span className="k mono">{g.name}</span>
|
||||||
|
<span className="muted small">{left > 0 ? `expires in ${fmtLeft(left)}` : 'expiring'} · {g.sessions > 0 ? `${g.sessions} signed in` : g.used ? 'signed in earlier' : 'not used yet'}{g.issued_by ? ` · by ${g.issued_by}` : ''}</span>
|
||||||
|
<Btn className="btn danger" onClick={() => revoke(g)}>Revoke</Btn>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
{fresh && <FreshCard g={fresh} onClose={() => setFresh(null)} />}
|
||||||
|
<div className="card addform" style={{ marginTop: 8 }}>
|
||||||
|
<label>Lasts
|
||||||
|
<select value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
|
||||||
|
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A guest can do everything you can, on every machine — including the terminal — until the time is up or you revoke it. Give one out accordingly.</span></div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The credentials, once. Copy takes the whole invitation, because that is what gets pasted
|
||||||
|
// into a message.
|
||||||
|
function FreshCard({ g, onClose }: { g: FreshGuest; onClose: () => void }) {
|
||||||
|
const invite = `${location.origin}\nuser: ${g.name}\npassword: ${g.password}\nexpires ${new Date(g.expires).toLocaleTimeString()}`
|
||||||
|
const copy = async (text: string, what: string) => {
|
||||||
|
try { await navigator.clipboard.writeText(text); toast(`${what} copied`) }
|
||||||
|
catch { toast('select and copy it by hand', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="card fresh" style={{ marginTop: 8 }}>
|
||||||
|
<div className="row"><span className="k">User</span><span className="mono">{g.name}</span><Btn className="btn" onClick={() => copy(g.name, 'user')}>Copy</Btn></div>
|
||||||
|
<div className="row"><span className="k">Password</span><span className="mono">{g.password}</span><Btn className="btn" onClick={() => copy(g.password, 'password')}>Copy</Btn></div>
|
||||||
|
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||||
|
<Btn className="btn on" onClick={() => copy(invite, 'invitation')}>Copy invitation</Btn>
|
||||||
|
<span className="muted small">Shown once. Expires {new Date(g.expires).toLocaleTimeString()}.</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn" onClick={onClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Account({ me }: { me: Me }) {
|
function Account({ me }: { me: Me }) {
|
||||||
const [cur, setCur] = useState('')
|
const [cur, setCur] = useState('')
|
||||||
const [pw1, setPw1] = useState('')
|
const [pw1, setPw1] = useState('')
|
||||||
@@ -54,6 +133,13 @@ function Sessions() {
|
|||||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
const others = list?.filter((s) => !s.current).length ?? 0
|
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 () => {
|
const revokeOthers = async () => {
|
||||||
if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return
|
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') }
|
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) => (
|
{list && list.map((s) => (
|
||||||
<div className="row" key={s.id}>
|
<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="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>
|
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
|
||||||
</div>
|
</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>
|
</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,509 @@
|
|||||||
|
// 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, LibraryFile, TaskInfo, VirtImage, VirtInstance, VirtOverview, VirtStatus } from './api'
|
||||||
|
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 [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 lib={ov.library} dir={st.dir} />}
|
||||||
|
</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) }} />}
|
||||||
|
</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' | 'config'>('overview')
|
||||||
|
const running = i.status === 'Running'
|
||||||
|
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 || '—'}{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 ? ` · ${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>
|
||||||
|
{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>
|
||||||
|
{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 === 'config' ? 'on' : ''} onClick={() => setTab('config')}>Config</button>
|
||||||
|
</div>
|
||||||
|
{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 && <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 Library({ lib, dir }: { lib: { images: LibraryFile[]; isos: LibraryFile[] }; dir: string }) {
|
||||||
|
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">Images</span><span className="spacer" /><span className="muted small mono">{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>
|
||||||
|
<section className="card"><div className="sub-head"><span className="dname">ISOs</span><span className="spacer" /><span className="muted small mono">{dir}/isos</span></div>{table(lib.isos)}
|
||||||
|
<div className="muted small">ISO installs for VMs — upload, fetch by URL, attach, and the graphical console they need — are the next phase. Files put here now will be listed when it lands.</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+186
-6
@@ -55,7 +55,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
||||||
export interface Me { addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
|
export interface Me { addr?: string; host: string; user: string; account?: string; guest?: boolean; guest_until?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
|
||||||
export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
|
export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
|
||||||
// What a machine is called on screen: the hostname it reports, which follows a rename within
|
// What a machine is called on screen: the hostname it reports, which follows a rename within
|
||||||
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
|
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
|
||||||
@@ -70,11 +70,22 @@ export interface Temp { chip: string; label: string; c: number; max?: number; cr
|
|||||||
export interface Fan { chip: string; label: string; rpm: number }
|
export interface Fan { chip: string; label: string; rpm: number }
|
||||||
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
|
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
|
||||||
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||||
export interface FS { mount: string; type: string; size: number; used: number }
|
export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string }
|
||||||
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||||
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; 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 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_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 {
|
export interface Sample {
|
||||||
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
|
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 }
|
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
||||||
@@ -109,6 +120,39 @@ export interface StorageDevice { name: string; kind: string; model?: string; ven
|
|||||||
export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean }
|
export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean }
|
||||||
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number }
|
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number }
|
||||||
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
|
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
|
||||||
|
export interface UpdateJob { id: string; name: string; stack: string; machine?: string; services?: string[]; cron: string; apply: boolean; enabled: boolean }
|
||||||
|
export interface UpdateJobRow { job: UpdateJob; next?: string; last?: { at?: string; taken?: string; error?: string } }
|
||||||
|
export interface MachineUpdates { images: ImageCheck[]; checked: number; stacks: Record<string, string[]> }
|
||||||
|
|
||||||
|
export interface DockerHealthProbe { Start: string; End: string; ExitCode: number; Output: string }
|
||||||
|
export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] }
|
||||||
|
export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string }
|
||||||
|
|
||||||
|
// ---- hardware (the Hardware tab: what the machine is made of) ----
|
||||||
|
export interface HWSystem { model?: string; serial?: string; family?: string; kernel?: string; arch?: string; virt?: string }
|
||||||
|
export interface HWSlot { designation: string; type?: string; usage?: string; length?: string; width?: string; bus?: string; device?: string }
|
||||||
|
export interface HWBoard {
|
||||||
|
maker?: string; product?: string; version?: string; serial?: string; asset_tag?: string; type?: string; location?: string
|
||||||
|
chassis?: string; chassis_maker?: string; bios?: string; bios_date?: string; bios_revision?: string; rom_size?: string
|
||||||
|
uefi?: boolean; slots?: HWSlot[]
|
||||||
|
}
|
||||||
|
export interface HWCache { level: string; size: string }
|
||||||
|
export interface HWCPU {
|
||||||
|
model?: string; vendor?: string; arch?: string; sockets?: number; socket_names?: string[]
|
||||||
|
cores?: number; threads?: number; threads_per_core?: number; min_mhz?: number; max_mhz?: number
|
||||||
|
family?: string; model_id?: string; stepping?: string; microcode?: string; virtualization?: string
|
||||||
|
caches?: HWCache[]; flags?: string[]
|
||||||
|
}
|
||||||
|
export interface HWModule {
|
||||||
|
locator: string; bank?: string; empty?: boolean; size?: number; type?: string; form_factor?: string
|
||||||
|
speed_mts?: number; running_mts?: number; maker?: string; part?: string; serial?: string; rank?: number; voltage?: string
|
||||||
|
}
|
||||||
|
export interface HWMemory { total: number; installed?: number; max_capacity?: number; slots?: number; filled?: number; ecc?: string; modules?: HWModule[] }
|
||||||
|
export interface HWDrive { name: string; kind: string; model?: string; vendor?: string; serial?: string; firmware?: string; tran?: string; size: number; rotational?: boolean; removable?: boolean }
|
||||||
|
export interface HWGPU { model?: string; vendor?: string; slot?: string; driver?: string; node?: string; memory?: number; class?: string }
|
||||||
|
export interface Hardware { at: string; system: HWSystem; board: HWBoard; cpu: HWCPU; memory: HWMemory; drives: HWDrive[] | null; gpus: HWGPU[] | null; notes?: string[] }
|
||||||
|
|
||||||
|
export interface DockerLogLine { t: string; m: string; err?: boolean }
|
||||||
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
|
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
|
||||||
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
|
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
|
||||||
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
|
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
|
||||||
@@ -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 User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
|
||||||
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
|
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
|
||||||
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
|
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
|
||||||
|
// A guest login: a full account with a clock on it, gone at `expires` or when revoked.
|
||||||
|
export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean }
|
||||||
|
export interface FreshGuest { name: string; password: string; expires: string; minutes: number }
|
||||||
|
|
||||||
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
|
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
|
||||||
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
|
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
|
||||||
@@ -133,11 +180,93 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
|
|||||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
||||||
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||||
|
|
||||||
|
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
||||||
|
|
||||||
|
// ---- the Network tab ----
|
||||||
|
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
||||||
|
export interface Iface {
|
||||||
|
name: string; kind: string; mac?: string; mtu: number; up: boolean; carrier: boolean
|
||||||
|
speed_mbps?: number; duplex?: string; driver?: string; signal_dbm?: number
|
||||||
|
addrs: IfAddr[]; rx_total: number; tx_total: number; default?: boolean
|
||||||
|
}
|
||||||
|
export interface NetReport { at: string; host?: string; ifaces: Iface[]; gateway?: string; gateway_via?: string; dns?: string[]; search?: string[] }
|
||||||
|
export interface Outside { at: string; ip?: string; colo?: string; country?: string; error?: string }
|
||||||
|
export interface SpeedResult {
|
||||||
|
at: string; down_mbps: number; up_mbps: number; latency_ms: number; jitter_ms: number
|
||||||
|
down_bytes: number; up_bytes: number; took_sec: number; colo?: string; ip?: string; source?: string; note?: string; error?: string
|
||||||
|
}
|
||||||
|
// A test in progress, or the last one to have finished. `done` is 0–1 across the whole test
|
||||||
|
// and `mbps` is the live figure for the phase under way.
|
||||||
|
export interface SpeedRun { started: string; running: boolean; phase: string; done: number; mbps: number; bytes: number; result?: SpeedResult }
|
||||||
|
export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedRun; history: SpeedResult[]; method: string }
|
||||||
|
|
||||||
|
// ---- the VPN tab ----
|
||||||
|
// A peer is a device that dials in (on a server tunnel) or the way out (on a client one).
|
||||||
|
// `online` is a handshake in the last few minutes, not a connection: WireGuard has none.
|
||||||
|
export interface VPNPeer {
|
||||||
|
name?: string; public_key: string; endpoint?: string; allowed_ips: string[]
|
||||||
|
handshake?: string; online: boolean; rx: number; tx: number; keepalive?: number; psk?: boolean
|
||||||
|
// 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 FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
|
||||||
export interface FileInfo extends FileEntry { path: string; text: boolean }
|
export interface FileInfo extends FileEntry { path: string; text: boolean }
|
||||||
|
|
||||||
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
|
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 }
|
||||||
|
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 = {
|
export const api = {
|
||||||
procs: () => req<ProcTable>('GET', '/api/procs'),
|
procs: () => req<ProcTable>('GET', '/api/procs'),
|
||||||
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
|
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
|
||||||
@@ -179,6 +308,16 @@ export const api = {
|
|||||||
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
||||||
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
||||||
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
||||||
|
// Always the hub's: it holds every machine's KVM, so the tab works when the machine does not.
|
||||||
|
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
|
||||||
|
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
|
||||||
|
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
||||||
|
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||||
|
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)}`),
|
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
|
||||||
zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`),
|
zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`),
|
||||||
zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }),
|
zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }),
|
||||||
@@ -212,9 +351,13 @@ export const api = {
|
|||||||
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
||||||
removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
|
removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
|
||||||
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
|
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
|
||||||
|
guests: () => req<Guest[]>('GET', '/api/guests', undefined, true),
|
||||||
|
createGuest: (minutes: number) => req<FreshGuest>('POST', '/api/guests', { minutes }, true),
|
||||||
|
revokeGuest: (name: string) => req<void>('DELETE', `/api/guests/${encodeURIComponent(name)}`, undefined, true),
|
||||||
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
||||||
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
||||||
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
||||||
|
revokeIdleSessions: (hours = 24) => req<{ revoked: number }>('POST', `/api/sessions/revoke-idle?hours=${hours}`, undefined, true),
|
||||||
logout: () => req<void>('POST', '/api/logout', undefined, true),
|
logout: () => req<void>('POST', '/api/logout', undefined, true),
|
||||||
meHere: () => req<Me>('GET', '/api/me', undefined, true),
|
meHere: () => req<Me>('GET', '/api/me', undefined, true),
|
||||||
me: () => req<Me>('GET', '/api/me'),
|
me: () => req<Me>('GET', '/api/me'),
|
||||||
@@ -228,10 +371,30 @@ export const api = {
|
|||||||
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
|
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
|
||||||
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
|
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
|
||||||
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
||||||
|
hardware: (refresh = false) => req<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
|
||||||
|
network: () => req<NetworkInfo>('GET', '/api/network'),
|
||||||
|
vpn: () => req<VPNReport>('GET', '/api/vpn'),
|
||||||
|
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'),
|
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
||||||
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
|
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
|
||||||
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
|
dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
|
||||||
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
|
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
|
||||||
|
machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`),
|
||||||
|
updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'),
|
||||||
|
saveUpdateJob: (j: Partial<UpdateJob>) => req<UpdateJob>('PUT', '/api/docker/schedules', j),
|
||||||
|
deleteUpdateJob: (id: string) => req<void>('DELETE', `/api/docker/schedules/${encodeURIComponent(id)}`),
|
||||||
|
runUpdateJob: (id: string) => req<{ output: string }>('POST', `/api/docker/schedules/${encodeURIComponent(id)}/run`),
|
||||||
|
dockerHealth: (id: string) => req<DockerHealth>('GET', `/api/docker/containers/${encodeURIComponent(id)}/health`),
|
||||||
|
dockerEvents: (id: string, since = '7d') => req<{ events: DockerEvent[]; since: string; kept: number; capacity: number }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/events?since=${since}`),
|
||||||
|
dockerExec: (id: string) => req<{ id: string; name: string }>('POST', `/api/docker/containers/${encodeURIComponent(id)}/exec`, {}),
|
||||||
|
dockerLimits: (id: string, p: { memory?: string; cpus?: string; restart?: string }) => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/limits`, p),
|
||||||
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
|
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
|
||||||
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'),
|
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'),
|
||||||
createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
|
createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
|
||||||
@@ -241,11 +404,13 @@ export const api = {
|
|||||||
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
|
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
|
||||||
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
|
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
|
||||||
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
|
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
|
||||||
|
removeNetwork: (name: string) => req<void>('POST', '/api/docker/networks/remove', { name }),
|
||||||
|
pruneNetworks: () => req<{ result: string }>('POST', '/api/docker/networks/prune'),
|
||||||
stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
|
stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
|
||||||
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
|
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
|
||||||
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
||||||
saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
|
saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
|
||||||
stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
|
stackAction: (name: string, action: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
|
||||||
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
|
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
|
||||||
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
||||||
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
|
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
|
||||||
@@ -257,6 +422,21 @@ export const api = {
|
|||||||
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||||||
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
|
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
|
||||||
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
|
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}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wsURLPath(path: string): string {
|
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'
|
const KEY = 'hamlab.rail'
|
||||||
|
|
||||||
|
// Pages that were renamed, so an order saved before it still puts the page where it was put:
|
||||||
|
// Top became Processes, Space became Usage, Cron became Jobs. A page that was removed
|
||||||
|
// outright (Dashboard) is not here — orderBy drops ids that match no page.
|
||||||
|
const RENAMED: Record<string, string> = { top: 'procs', space: 'usage', cron: 'jobs' }
|
||||||
|
|
||||||
export function getRailOrder(): string[] {
|
export function getRailOrder(): string[] {
|
||||||
try { const v = JSON.parse(localStorage.getItem(KEY) ?? '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [] } catch { return [] }
|
let raw: unknown
|
||||||
|
try { raw = JSON.parse(localStorage.getItem(KEY) ?? '[]') } catch { return [] }
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const x of raw) {
|
||||||
|
if (typeof x !== 'string') continue
|
||||||
|
const id = RENAMED[x] ?? x
|
||||||
|
if (seen.has(id)) continue // a merged page keeps the earlier of the two slots
|
||||||
|
seen.add(id)
|
||||||
|
out.push(id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRailOrder(ids: string[], save = true) {
|
export function setRailOrder(ids: string[], save = true) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type Values = Record<string, number | boolean | string>
|
|||||||
export type Catalog = Map<string, Sensor>
|
export type Catalog = Map<string, Sensor>
|
||||||
|
|
||||||
// Everything the modules draw, as one stream query.
|
// 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.
|
// The stream answers a list with a list; the modules want one flat map.
|
||||||
export function mergeValues(parts: unknown[]): Values {
|
export function mergeValues(parts: unknown[]): Values {
|
||||||
const out: Values = {}
|
const out: Values = {}
|
||||||
@@ -51,7 +51,7 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
|
|||||||
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
|
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
|
||||||
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
|
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
|
||||||
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
|
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
|
||||||
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used') })),
|
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk })),
|
||||||
ups: v['ups.battery'] === undefined ? null : {
|
ups: v['ups.battery'] === undefined ? null : {
|
||||||
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
|
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
|
||||||
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
|
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
|
||||||
|
|||||||
+187
-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 input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||||
.login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; }
|
.login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; }
|
||||||
.login button:disabled { opacity: .5; }
|
.login button:disabled { opacity: .5; }
|
||||||
|
/* the password field and its show/hide eye, which sits inside the field's right edge */
|
||||||
|
.login .pwfield { position: relative; display: flex; }
|
||||||
|
.login .pwfield input { flex: 1; min-width: 0; padding-right: 44px; }
|
||||||
|
.login button.pweye { position: absolute; right: 1px; top: 1px; bottom: 1px; width: 42px; padding: 0; background: none; color: var(--muted); font-weight: 400; border-radius: 0 6px 6px 0; }
|
||||||
|
.login button.pweye:hover, .login button.pweye[aria-pressed="true"] { color: var(--fg); }
|
||||||
|
.login button.pweye:disabled { opacity: .4; }
|
||||||
.error { color: var(--bad); font-size: 13px; }
|
.error { color: var(--bad); font-size: 13px; }
|
||||||
|
.svc-list > .error, .cron-list > .error { margin: 8px 12px; }
|
||||||
|
|
||||||
/* layout */
|
/* layout */
|
||||||
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
|
.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 .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||||
.tile .spark { margin-top: 8px; }
|
.tile .spark { margin-top: 8px; }
|
||||||
.card .empty { color: var(--muted); font-size: 13px; }
|
.card .empty { color: var(--muted); font-size: 13px; }
|
||||||
|
/* the out-of-date list: image, what the registry said, which stacks, and the button */
|
||||||
|
.upd-list .row { grid-template-columns: minmax(130px, 1.3fr) minmax(110px, 1.5fr) auto auto; }
|
||||||
|
.upd-list .row .btn { padding: 2px 10px; }
|
||||||
|
@media (max-width: 560px) { .upd-list .row { grid-template-columns: 1fr auto; } .upd-list .row > :nth-child(2) { grid-column: 1 / -1; } }
|
||||||
.row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; }
|
.row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; }
|
||||||
.row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; }
|
.row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; }
|
||||||
.num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }
|
.num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }
|
||||||
@@ -163,11 +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-list, .cron:not(.has-sel) .cron-list { width: auto; flex: 1 1 auto; border-right: 0; }
|
||||||
.svc:not(.has-sel) .svc-empty, .cron:not(.has-sel) .svc-empty { display: none; }
|
.svc:not(.has-sel) .svc-empty, .cron:not(.has-sel) .svc-empty { display: none; }
|
||||||
}
|
}
|
||||||
|
/* A tab that is a page rather than a list-and-detail — Docker's Updates — stacks: the tab
|
||||||
|
strip on top, the page below at full width. Otherwise the empty list column takes half
|
||||||
|
the window and the page is squeezed into the other half, which looks like a phone. */
|
||||||
|
.svc.wide { flex-direction: column; }
|
||||||
|
/* :not(.has-sel) to outrank the wide-window rule above, which grows the list to fill */
|
||||||
|
.svc.wide:not(.has-sel) .svc-list { width: auto; flex: 0 0 auto; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.svc.wide .svc-rows { display: none; }
|
||||||
|
.svc.wide > .svc-detail { flex: 1 1 auto; min-height: 0; }
|
||||||
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
|
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
|
||||||
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; }
|
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; }
|
||||||
.props > div { display: flex; gap: 8px; min-width: 0; } .props .k { color: var(--muted); width: 80px; flex-shrink: 0; }
|
.props > div { display: flex; gap: 8px; min-width: 0; } .props .k { color: var(--muted); width: 80px; flex-shrink: 0; }
|
||||||
.props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
.props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
/* One property per row, label above its value. For panes like Inspect, whose values are
|
||||||
|
long paths and lists — side by side they were half truncated and read as columns. */
|
||||||
|
.props.stacked { grid-template-columns: 1fr; gap: 10px 0; }
|
||||||
|
.props.stacked > div { flex-direction: column; gap: 2px; }
|
||||||
|
.props.stacked .k { width: auto; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
.props.stacked span:not(.k) { overflow: visible; white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
.props.stacked .k { text-transform: none; } .props.stacked > div > .k { text-transform: uppercase; }
|
||||||
|
.props.stacked .k .muted { text-transform: none; letter-spacing: 0; }
|
||||||
|
.props.stacked span:not(.k) > div + div { margin-top: 3px; }
|
||||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
|
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
|
||||||
.tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
.tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||||
.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
|
.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
|
||||||
@@ -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-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 { 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; }
|
.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); }
|
.log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); }
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
|
.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. */
|
the fallback above stays a fallback. */
|
||||||
.disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); }
|
.disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); }
|
||||||
.datasets { padding: 0; overflow-x: auto; }
|
.datasets { padding: 0; overflow-x: auto; }
|
||||||
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } /* the columns and gaps add up to 692: at 640 the row ended before its own cells did */
|
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 90px 90px 90px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; }
|
||||||
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
.ds-row .num { text-align: right; }
|
.ds-row .num { text-align: right; }
|
||||||
|
@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-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
|
.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
|
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
|
||||||
.snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; }
|
.snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; }
|
||||||
.snap { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }
|
.snap { display: grid; grid-template-columns: minmax(150px, 1fr) 150px 120px 70px auto; gap: 10px; align-items: center; font-size: 12px; padding: 2px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.snap .snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.snap .snap-when, .snap .snap-exp { white-space: nowrap; }
|
||||||
|
.snap .snap-exp.warn { color: var(--s2); }
|
||||||
|
.snap .num { text-align: right; }
|
||||||
|
.snap .snap-del { padding: 0 6px; font-size: 11px; opacity: .55; }
|
||||||
|
.snap:hover .snap-del { opacity: 1; }
|
||||||
|
@media (max-width: 800px) { .snap { grid-template-columns: minmax(120px, 1fr) auto auto; } .snap .snap-when, .snap .snap-exp { display: none; } }
|
||||||
|
|
||||||
/* files */
|
/* files */
|
||||||
.files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
|
.files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
|
||||||
@@ -388,10 +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 { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
|
||||||
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
||||||
|
/* 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 { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||||
.addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
.addform input, .addform select { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
||||||
.header .badge.bad { color: var(--bad); border-color: var(--bad); }
|
.header .badge.bad { color: var(--bad); border-color: var(--bad); }
|
||||||
|
/* a guest login says so, and how long it has, where the power button would be */
|
||||||
|
.badge.guest { color: var(--accent2); border-color: var(--accent2); text-transform: none; margin-left: 0; white-space: nowrap; }
|
||||||
|
.card.fresh { border-color: var(--accent2); }
|
||||||
|
.card.fresh .mono { user-select: all; }
|
||||||
|
|
||||||
/* settings + themes */
|
/* settings + themes */
|
||||||
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||||
@@ -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 .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; }
|
.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; }
|
.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 */
|
/* 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; }
|
.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-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-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; }
|
.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 { 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-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; }
|
.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; }
|
.t-load { display: flex; margin-top: 2px; }
|
||||||
|
|
||||||
/* the dot graph and the dotted meter */
|
/* 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 { 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, .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); }
|
.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; }
|
.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-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; }
|
.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) {
|
@container tproc (max-width: 640px) {
|
||||||
.t-thead, .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 42px; }
|
.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; }
|
.t-thead .c-g, .t-prow .c-g { display: none; }
|
||||||
}
|
}
|
||||||
@container tproc (max-width: 560px) {
|
@container tproc (max-width: 560px) {
|
||||||
.t-thead, .t-prow { grid-template-columns: 58px 110px minmax(0, 1fr) 58px 50px 42px; }
|
.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; }
|
.t-thead .c-thr, .t-prow .c-thr { display: none; }
|
||||||
}
|
}
|
||||||
@container tproc (max-width: 470px) {
|
@container tproc (max-width: 470px) {
|
||||||
.t-thead, .t-prow { grid-template-columns: 52px minmax(0, 1fr) 52px 48px 40px; gap: 6px; }
|
.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-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; }
|
.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-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-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-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) {
|
@media (max-width: 800px) {
|
||||||
.top { display: flex; flex-direction: column; font-size: 12px; }
|
.top { display: flex; flex-direction: column; font-size: 12px; }
|
||||||
.top > .tbox { flex: 0 0 auto; }
|
.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-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; }
|
.t-net { flex-direction: column; } .t-net-nums { width: auto; } .t-net-graphs { flex: 0 0 130px; }
|
||||||
.top-proc { height: 70vh; min-height: 420px; }
|
.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: what is using a disk ---- */
|
||||||
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
.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); }
|
.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 .warn { color: var(--s2); }
|
||||||
.space-tabs { padding: 8px 12px 0; }
|
.space-tabs { padding: 8px 12px 0; }
|
||||||
.space-split { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 8px 12px 12px; gap: 8px; }
|
.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-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-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; }
|
.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; }
|
||||||
@@ -720,3 +858,47 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; }
|
.space-row.file { grid-template-columns: minmax(120px, 1fr) 72px 80px; }
|
||||||
.space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; }
|
.space-split { padding: 8px; } .space-tree { flex: 1 1 45%; } .space-map { flex: 1 1 55%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* the Processes tab's drive rows and NIC switcher */
|
||||||
|
.t-disk .t-model { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 14ch; margin-left: 6px; }
|
||||||
|
.t-disk .t-temp { margin-right: 8px; color: var(--t-blue, var(--accent2)); }
|
||||||
|
.t-disk .t-mounts { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.t-ifs { display: inline-flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.t-ifs .t-if { padding: 0 4px; border-radius: 3px; color: var(--muted); }
|
||||||
|
.t-ifs .t-if:hover { color: var(--fg); background: var(--bg3); }
|
||||||
|
.t-ifs .t-if.on { color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||||
|
|
||||||
|
/* ZFS: the schedule dialog's controls match the rest of the modal forms */
|
||||||
|
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||||
|
.modal .chips { margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---- KVM: a device's own web page, framed ---- */
|
||||||
|
.kvm { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||||
|
.kvm-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-wrap: wrap; }
|
||||||
|
.kvm-bar .kvm-url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 28ch; }
|
||||||
|
.kvm-frame { flex: 1; min-height: 0; background: #000; position: relative; }
|
||||||
|
.kvm-frame iframe { width: 100%; height: 100%; border: 0; display: block; background: #000; }
|
||||||
|
.kvm-frame.full { background: #000; }
|
||||||
|
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
||||||
|
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
||||||
|
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
|
||||||
|
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
|
||||||
|
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; }
|
||||||
|
.kvm-frame.expanded iframe { height: 100%; }
|
||||||
|
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
|
||||||
|
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
||||||
|
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
||||||
|
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
||||||
|
|
||||||
|
/* ---- 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; }
|
||||||
|
.virt .modal { max-width: 640px; }
|
||||||
|
|||||||
+38
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
import { Component, ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
// ---- spinners ----
|
// ---- spinners ----
|
||||||
export function Spinner({ size = 14 }: { size?: number }) {
|
export function Spinner({ size = 14 }: { size?: number }) {
|
||||||
@@ -14,6 +14,12 @@ export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; ti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A filled bar. Red past 95%, amber past 85%, unless the caller says otherwise.
|
||||||
|
export function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
||||||
|
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
|
||||||
|
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
||||||
|
}
|
||||||
|
|
||||||
export function Loading({ label = 'Loading…' }: { label?: string }) {
|
export function Loading({ label = 'Loading…' }: { label?: string }) {
|
||||||
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
|
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
|
||||||
}
|
}
|
||||||
@@ -149,3 +155,34 @@ export async function okToLeave(): Promise<boolean> {
|
|||||||
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
|
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A page that throws takes the whole console down with it: React unmounts the tree, and what
|
||||||
|
// is left is a blank window with no clue in it. The VPN tab did exactly that on a machine
|
||||||
|
// whose tunnel list came back null. So each page is rendered inside one of these, and a fault
|
||||||
|
// costs that page rather than the session — the rail, the switcher and every other tab still
|
||||||
|
// work, and the error says which page and what it said.
|
||||||
|
export class PageBoundary extends Component<{ page: string; children: ReactNode }, { err: Error | null }> {
|
||||||
|
constructor(props: { page: string; children: ReactNode }) {
|
||||||
|
super(props)
|
||||||
|
this.state = { err: null }
|
||||||
|
}
|
||||||
|
static getDerivedStateFromError(err: Error) { return { err } }
|
||||||
|
componentDidCatch(err: Error) { console.error('page failed:', err) }
|
||||||
|
// A different page is a different mount; clear the fault when the page changes.
|
||||||
|
componentDidUpdate(prev: { page: string }) { if (prev.page !== this.props.page && this.state.err) this.setState({ err: null }) }
|
||||||
|
render() {
|
||||||
|
if (!this.state.err) return this.props.children
|
||||||
|
return (
|
||||||
|
<div className="dash">
|
||||||
|
<div className="dash-bar"><h2 className="section">{this.props.page}</h2></div>
|
||||||
|
<section className="card">
|
||||||
|
<div className="error">This page hit a fault and stopped drawing. The rest of the console is unaffected.</div>
|
||||||
|
<div className="muted small mono" style={{ marginTop: 8 }}>{String(this.state.err)}</div>
|
||||||
|
<div className="svc-actions" style={{ marginTop: 10 }}>
|
||||||
|
<button className="btn" onClick={() => this.setState({ err: null })}>Try again</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user