Compare commits
59
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>/` were the standing exception; since 2026-10-10 the stacks code offers `<data dir>/stacks` as the default home everywhere and such folders only as discovered extras. |
|
||||||
|
|
||||||
## Layout
|
## 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;
|
||||||
@@ -218,12 +260,13 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
filesystem; compose is then run **inside that container**, so `.env`, relative paths
|
filesystem; compose is then run **inside that container**, so `.env`, relative paths
|
||||||
and the compose labels stay exactly as deployed (verified: labels identical after a
|
and the compose labels stay exactly as deployed (verified: labels identical after a
|
||||||
redeploy). Save validates with `compose config` first, keeps the previous file as
|
redeploy). Save validates with `compose config` first, keeps the previous file as
|
||||||
`<file>.hamlab-bak`, and never deploys by itself. A stack deployed from another host
|
`<file>.hamlab-bak`, and never deploys by itself. A stack whose file is not on the
|
||||||
(Dockhand on `core` driving `grid` over tcp) has no file on that machine, so the node
|
machine that runs it is listed read-only, and that is the end of it: **since 2026-10-10
|
||||||
alone lists it read-only — but **the hub fills that in** (`hubStacks`): asked about a
|
the hub holds no files for a node and never points compose at another machine's
|
||||||
node, it resolves the files on itself and runs compose here with `DOCKER_HOST` pointed
|
Docker.** The `hubStacks` path — which resolved a node's files on the hub and deployed
|
||||||
at the node's `tcp://…:2375`, the way it was deployed; the task runs on the hub and the
|
over `tcp://…:2375`, the way Dockhand had — is gone, and every
|
||||||
node's page follows it. Also per stack: every file it was deployed from (chips), its
|
`/api/nodes/<node>/docker/…` request is the node's own Ham Lab's to answer through the
|
||||||
|
generic proxy. Also per stack: every file it was deployed from (chips), its
|
||||||
**`.env`** (values masked until "Reveal and edit"; validated in place, put back if
|
**`.env`** (values masked until "Reveal and edit"; validated in place, put back if
|
||||||
compose objects), **what Deploy would change** (a line diff against the copy kept at the
|
compose objects), **what Deploy would change** (a line diff against the copy kept at the
|
||||||
last deploy from here, `<file>.hamlab-deployed`, else the copy the last save replaced;
|
last deploy from here, `<file>.hamlab-deployed`, else the copy the last save replaced;
|
||||||
@@ -236,9 +279,10 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
form (image, ports, volumes, environment, restart, command, host networking) that writes
|
form (image, ports, volumes, environment, restart, command, host networking) that writes
|
||||||
the compose for you: a container made here is a one-service stack, so it stays editable
|
the compose for you: a container made here is a one-service stack, so it stays editable
|
||||||
and redeployable, where a bare `docker run` could only be thrown away and typed again.
|
and redeployable, where a bare `docker run` could only be thrown away and typed again.
|
||||||
New stacks go in a **home** — a folder where two or more stacks are already deployed from
|
New stacks go in a **home**. Since 2026-10-10 the default is Ham Lab's own
|
||||||
(Dockhand's `stacks/core` here; `~/stacks` on a machine with none) — and are deployed the
|
`<data dir>/stacks`, offered first on every machine; a folder where two or more stacks
|
||||||
way their neighbours were (inside `dockhand`, or over tcp to a node from the hub). Homes
|
are already deployed from is offered as well, and a stack made there is deployed the way
|
||||||
|
its neighbours were (inside an orchestrator's container, when that is how they run). Homes
|
||||||
are also scanned, so a stack with no containers ("not deployed": never deployed, or taken
|
are also scanned, so a stack with no containers ("not deployed": never deployed, or taken
|
||||||
down) is still listed. **Down** removes a stack's containers; **Delete…** (type its name)
|
down) is still listed. **Down** removes a stack's containers; **Delete…** (type its name)
|
||||||
takes it down and moves its own files to `<home>/.hamlab-trash/<name>-<time>/` — moved,
|
takes it down and moves its own files to `<home>/.hamlab-trash/<name>-<time>/` — moved,
|
||||||
@@ -259,6 +303,52 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
|
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).
|
||||||
|
- **CPU power on the Processes tab** — **shipped 2026-10-10** (v0.1.0-194,
|
||||||
|
`internal/metrics/cpupower.go`, the `cpu.power` sensor). Asked for beside the clock speed,
|
||||||
|
and that is where it sits: "3600 MHz · 89 W" in the cpu box's header. Watts are the RAPL
|
||||||
|
package energy counters' delta between samples, summed over packages; `psys`, a package's
|
||||||
|
parts and the MMIO duplicate are left out. The counters are root-only, so a daemon running
|
||||||
|
as `core` keeps a second root child, `hamlab cpu-energy`, beside `drm-clients`. Read on all
|
||||||
|
four Intel machines; the Mac mini has no CPU-only counter (its SMC reports the whole
|
||||||
|
machine's draw instead) and shows nothing.
|
||||||
|
- **The ARC on the ZFS tab** — **shipped 2026-10-02** (`internal/metrics/arc.go`,
|
||||||
|
`internal/storage/arc.go`, `GET /api/zfs/arc`, the Cache section in `ZFS.tsx`, `zfs.arc.*`
|
||||||
|
sensors). Asked for as "current ARC usage, including any types of caching services in the
|
||||||
|
pool". One card for the ARC: size against its ceiling with the target and floor, sparklines
|
||||||
|
of size and hit rate from the sampler's history, what it holds (MRU/MFU/dirty, data vs
|
||||||
|
metadata, compression, the ghost lists), the prefetcher and dbuf cache, memory-pressure
|
||||||
|
counters, and the since-boot breakdown by demand/prefetch × data/metadata. Then a card per
|
||||||
|
pool naming every caching tier — L2ARC, SLOG, special vdev, dedup table, spares — with its
|
||||||
|
devices and fill, **or saying plainly that there is none and what that means** (the ZIL on
|
||||||
|
the pool's own vdevs, with how much it has written), plus the properties that steer caching
|
||||||
|
(`primarycache`, `secondarycache`, `sync`, `logbias`). Read from `/proc/spl/kstat/zfs/*`
|
||||||
|
and `/sys/module/zfs/parameters`, with `zpool list -Hpv` and `zfs get` run without sudo
|
||||||
|
(they need none) every 30 s at most. The day it shipped it showed `tank`'s ARC pinned at
|
||||||
|
its 2 GiB floor under `arc_no_grow`, with a 53 GiB python3 process holding the RAM.
|
||||||
- **Dashboard retired** — **2026-09-23**, at Mitchell's instruction, the same reasoning that
|
- **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
|
||||||
@@ -398,9 +488,16 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
replace, exec in place. Config default moved to `~/.config/hamlab` (existing
|
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
|
||||||
@@ -540,6 +637,86 @@ 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
|
||||||
|
|
||||||
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
|
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
|
||||||
@@ -571,52 +748,64 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
2026-09-24 to take in Docker tab polish and feature work, on the same reasoning as the
|
2026-09-24 to take in Docker tab polish and feature work, on the same reasoning as the
|
||||||
Files item: it is one tab, and the gaps that block retiring the old tool and the things
|
Files item: it is one tab, and the gaps that block retiring the old tool and the things
|
||||||
that would simply make it better are the same body of work. The *homelab* half —
|
that would simply make it better are the same body of work. The *homelab* half —
|
||||||
migrating every stack off Dockhand's data dir, then removing the container — is `hamlet`'s
|
the cutover itself, then moving every stack off Dockhand's data dir — is `hamlet`'s
|
||||||
`docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", agreed
|
`docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", agreed
|
||||||
2026-09-20. This entry is the *app* half.
|
2026-09-20. This entry is the *app* half.
|
||||||
Where it stands: Stacks already creates, edits, deploys, updates, restarts, stops, downs
|
|
||||||
and deletes stacks on every machine, and Containers does start/stop/restart, logs that
|
|
||||||
follow, inspect, and the images/volumes/networks tables. So this is not a rewrite.
|
|
||||||
- **What Dockhand still does that Ham Lab does not.**
|
|
||||||
- *Git-sourced stacks.* Dockhand's `stack_sources` table points a stack at a repo and
|
|
||||||
pulls it. Ham Lab has no equivalent. Decide whether to build it or drop it — most
|
|
||||||
stacks here have no row in that table anyway, so dropping it may be the honest answer.
|
|
||||||
- *Scheduled image updates.* Ham Lab checks for new images only when asked, since each
|
|
||||||
check is a registry request Docker Hub counts, and never updates by itself. If
|
|
||||||
anything relies on Dockhand updating on a schedule, that must move here or be dropped
|
|
||||||
on purpose. Note the Jobs tab and the ZFS schedules already show the shape this would
|
|
||||||
take: a cron line Ham Lab owns, not a ticker in the daemon.
|
|
||||||
- *Webhooks and per-host environments*, if either is in use. Inventory before deciding.
|
|
||||||
- **Known gaps in the tab itself**, worth closing whether or not Dockhand goes:
|
|
||||||
- *Removing networks* — volumes and images can be pruned and removed, networks cannot.
|
|
||||||
- *Container detail is thin next to the stack view.* No per-container resource limits,
|
|
||||||
restart-policy editing, or exec-into-container from the tab (the Terminal tab is the
|
|
||||||
workaround). Decide which of these belong here at all.
|
|
||||||
- *Logs are polled with `--since` rather than followed over a held connection.* That was
|
|
||||||
deliberate and works, but a long tail is choppier than the Services tab's live tail,
|
|
||||||
which does hold a connection. Worth reconciling the two.
|
|
||||||
- *Image update checks are remembered six hours and asked for per stack*, so there is no
|
|
||||||
one view of "what is out of date across the fleet".
|
|
||||||
- **The `*:2375` question, which this is the moment to settle.** Ham Lab runs compose
|
|
||||||
inside `dockhand` because that is how the stacks were deployed; once they move to a
|
|
||||||
neutral home that path is unused for them, but a stack deployed to `grid` or the Mac
|
|
||||||
still goes over `tcp://<node>:2375` from the hub. Decide whether the hub should instead
|
|
||||||
ask the node's own Ham Lab to deploy locally, which would end the unauthenticated
|
|
||||||
Docker socket exposure — its own item in `docs/pending.md`, currently filed under
|
|
||||||
"decided, no action", and this is the change that would make closing it free.
|
|
||||||
- *Not app work, but the trigger:* nothing above blocks starting the migration except the
|
|
||||||
inventory. Do that first.
|
|
||||||
|
|
||||||
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
|
**Done 2026-09-24 — the eight Mitchell picked from the inventory**, all in the Docker tab:
|
||||||
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
|
per-container **Stats**, **Health** (with the last few probe outputs), **Events** (Ham Lab
|
||||||
which is the comparison Ham Lab has invited since its first line. Nothing exists yet.
|
keeps its own 4000-entry ring, since `docker events` forgets after about a minute),
|
||||||
What a future session has to settle before writing code: which stack (libvirt/KVM +
|
**Shell** (`docker exec`, handed to the Terminal tab) and **Limits** (`docker update` for
|
||||||
`virsh` is the obvious one for VMs; LXC or Incus for containers, and Incus covers both);
|
memory, CPU and restart policy); an **updates** tab giving the whole machine at once with
|
||||||
whether it manages what is already installed or installs it; where disk images live
|
a per-container indicator in the list; and **schedules**, which write
|
||||||
(`tank` on `grid`, local NVMe elsewhere); console access (SPICE/VNC proxied through the
|
`/etc/cron.d/hamlab-docker` and call
|
||||||
daemon, or a serial console over the existing terminal machinery, which would reuse
|
`hamlab docker-update <id>` — the shape the ZFS schedules already use.
|
||||||
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
|
Also done: Ham Lab keeps working when `dockhand` is removed. It remembers where a
|
||||||
at all, which is every host here today. Worth scoping as its own plan before starting.
|
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.
|
||||||
|
|
||||||
|
- **Left from the Dockhand inventory, both to decide rather than build.**
|
||||||
|
- *Git-sourced stacks.* Dockhand's `stack_sources` table points a stack at a repo and
|
||||||
|
pulls it. Its `git_repositories` and `git_stacks` tables are **empty**, so nothing
|
||||||
|
here uses it — dropping it is the honest answer unless Mitchell says otherwise.
|
||||||
|
- *Webhooks, OIDC, LDAP, vulnerability scans.* All four tables empty. Nothing to move.
|
||||||
|
|
||||||
|
**Done 2026-09-25 — the two gaps in the tab itself.**
|
||||||
|
- *Logs are followed over a held connection*, the way the Services tab follows a journal
|
||||||
|
(`internal/api/docker_logs.go`, `/api/docker/containers/{id}/logs/tail`): one
|
||||||
|
`docker logs -f` per viewer, stdout and stderr both, stderr coloured as an error. A
|
||||||
|
reconnect resumes from the last line's timestamp, so a container that restarts or a
|
||||||
|
phone that slept doesn't replay what is already on screen; the client dedupes by line
|
||||||
|
rather than by "later than", because `--since` is inclusive and two lines can share a
|
||||||
|
timestamp. Docker replays the opening backlog down two pipes in no particular order, so
|
||||||
|
the first 300 ms is held and sorted before it is sent. The polled `?since=` endpoint
|
||||||
|
stays — it is what the resume is built on. Reconnects back off to 15 s, since a
|
||||||
|
container that has stopped ends the stream immediately.
|
||||||
|
- *Networks can be removed and pruned* — `POST /api/docker/networks/{remove,prune}`, the
|
||||||
|
shape images and volumes already had. The overview says who is attached to each one, so
|
||||||
|
the ✕ is only offered on a network nothing uses and never on Docker's own three. Prune
|
||||||
|
is deliberately untested against this host: `core` has several networks belonging to
|
||||||
|
stacks that are merely down (`pihole_default`, `transmission_default`, `gluetun_default`
|
||||||
|
among them), and pruning would take them and hand them new subnets on the next deploy.
|
||||||
|
|
||||||
|
- **The `*:2375` question — settled, and the code removed 2026-10-10.** The hub asks the
|
||||||
|
node's own Ham Lab; nothing probes or uses a node's Docker port. `hubStacks`,
|
||||||
|
`nodeDocker`, `taskOnHub`, the hub-side `/api/nodes/{node}/docker/…` routes and the
|
||||||
|
`remote` / `held_by` fields are gone. A schedule saved before this that still carries a
|
||||||
|
`remote` is refused with a message, never run against the wrong Docker. `PORTABILITY.md`
|
||||||
|
§3 has the audit it came from. Deployed as v0.1.0-191 and checked on all five machines
|
||||||
|
through the hub.
|
||||||
|
|
||||||
|
- **A Virtualization tab** — asked for 2026-09-23; **scoped, built and deployed 2026-10-09 — see
|
||||||
|
`VIRTUALIZATION.md`** for what shipped (phases 1 and 2) and what is left. Incus 6.0 LTS as the engine; the tab detects per machine whether Incus is
|
||||||
|
running, installable, or unavailable, and whether KVM is there, and acts on the machine in
|
||||||
|
the switcher — never a default host. Containers and VMs in one list, console/shell through
|
||||||
|
the terminal machinery, snapshots, exports, and the old Proxmox estate on `tank` as a later
|
||||||
|
import. That file has the probe of every host, the per-machine layout Ham Lab computes
|
||||||
|
(pool driver, a bridge subnet that avoids the LAN's /8 and Docker's pools, the Docker
|
||||||
|
`FORWARD` fix) and the phased build.
|
||||||
|
|
||||||
|
|
||||||
## 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 | **done** 2026-10-10 | `internal/api/stacks.go` `nodeDocker()` (removed) | For every node whose stacks aren't editable locally, the hub **probes `tcp://<node-host>:2375`** with `docker -H … version` and, if it answers, deploys to the node over an unauthenticated Docker socket. This is the Dockhand-era layout; on any other network an open 2375 is either absent or a security problem the console should not quietly use. | Remove the probe. The node's own Ham Lab deploys its stacks (the files live on the node, under its data dir). `PLAN.md` "To do" already names this decision. |
|
||||||
|
| 3.2 | **done** 2026-10-10 | `hubStacks()`, `taskOnHub()`, the hub-side `/api/nodes/{node}/docker/…` routes, `Stack.remote`/`held_by` (all removed; the generic node proxy carries those requests) | A hub can hold compose files for a node and run compose remotely; new stacks for a node may be created **on the hub**. Only true here because Dockhand on `core` deployed `grid` and the Mac. | Same as 3.1: one machine, one set of files, one Docker. Hub-held stacks become an import path (copy to the node) rather than a steady state. |
|
||||||
|
| 3.3 | **done** 2026-10-10 | `internal/api/stacks.go` `homesOf()` — `<DataDir>/stacks` is always offered first and default; the `~/stacks` fallback is gone; `TestHomesDefaultToDataDir` | Where new stacks go is **discovered** from where ≥2 stacks already are (Dockhand's `stacks/<machine>` habit), else `~/stacks`. Neither is Ham Lab's data directory, which the project's own rule says everything managed must live under. | Default home = `<DataDir>/stacks`. Discovered homes stay as *additional* places, never the default. |
|
||||||
|
| 3.4 | **B** | `internal/api/stacks.go:100-203` remembered mounts, `internal/api/dockersched.go:772-795` `repoint()` | Machinery exists to find compose files **inside a removed orchestrator container's filesystem**. Generic in form, but it exists because of Dockhand and is exercised only here. | Keep (it is general), but it is not a reason to keep 3.1/3.2. |
|
||||||
|
| 3.5 | — | `internal/api/stacks.go` `stackAction`, `internal/compose/compose.go` | Inside a `Via` container the command is literally `docker compose`. | **Checked 2026-10-10, no change:** the CLI inside an orchestrator's container is that container's own `docker`, independent of what the host runs; the host's tool only chooses the `exec`. Both places now say so in a comment. |
|
||||||
|
| 3.6 | **B** | `internal/api/stacks.go:908` `docker buildx imagetools inspect` | Update checks need the **buildx** plugin. Docker CE on Debian has it; Podman and minimal Docker installs do not → every image reports "the registry didn't answer". | Fall back to `skopeo inspect` or a direct registry `HEAD` for the digest. |
|
||||||
|
| 3.7 | **B** | `Makefile:445-448` `deploy` | The machine you build on **is** the hub: installs locally, then `push-nodes`. | Fine for a developer; document that `deploy` means "I am the hub". A plain `make install` target for a single box. |
|
||||||
|
| 3.8 | **B** | `internal/nodes/nodes.go:316-323` | A hub pushes other architectures only if `<prefix>/hamlab-<arch>` siblings exist, which only `make deploy`/`install.sh`-from-dist put there. | A hub without siblings should fetch the right build from `update.url` instead of erroring. |
|
||||||
|
|
||||||
|
## 4. Init system: systemd assumed where the product promises procd and OpenRC too
|
||||||
|
|
||||||
|
The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-262`). These paths do not.
|
||||||
|
|
||||||
|
| # | Sev | Where | Assumes | General form |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 4.1 | **A** | `internal/updates/updates.go:537-543` `Power()` | `systemctl reboot` / `systemctl poweroff`. On OpenWrt (`gate`) and OpenRC the Power dialog fails. | Try `systemctl`, else `reboot` / `poweroff` (busybox, OpenRC, sysvinit all have them). |
|
||||||
|
| 4.2 | **A** | `internal/incus/incus.go:1017, 1044-1058, 1069, 1075` | The Incus install script writes `/etc/systemd/system/*` drop-ins and units, adds `SupplementaryGroups` to `hamlab.service`, restarts with `systemd-run`. On OpenRC/procd the daemon never joins `incus-admin` → the tab stays in "repair" forever; the Docker FORWARD fix is never persisted. `HamlabUnit = "hamlab.service"` (`:35`) is surfaced in the API (`:280`). | Branch on the detected init: OpenRC `rc-update`/`conf.d`, procd init script edits, or fall back to `usermod` + a daemon restart via the installer's own supervisor knowledge. Docker rules via a boot hook that is not a unit. |
|
||||||
|
| 4.3 | **A** | `internal/services/services.go:163-170` | Services tab is systemd-only ("says so cleanly"). `gate` (procd) and any OpenRC/runit/s6 box have no Services tab. | `rc-service`/`rc-status` (OpenRC), `/etc/init.d/<x> status` + `ubus call service list` (procd), `sv` (runit) backends. Noted as "not done" in `PLAN.md:487-489`. |
|
||||||
|
| 4.4 | **A** | `internal/cron/cron.go:353-373, 401, 442, 532-534, 669-670` | Jobs tab reads `/etc/crontab`, `/etc/cron.d`, `/var/spool/cron{,/crontabs}` (Debian/cronie), `/usr/sbin/anacron`, last-runs from **journald**, timers via `systemctl`; writes only `/etc/crontab` and `/etc/cron.d`. Busybox crond (OpenWrt, Alpine) keeps crontabs in `/etc/crontabs/<user>` and has no `cron.d` → the Jobs tab is empty on `gate` and schedules written by 4.5 never run. | Add `/etc/crontabs/*` as a source; last-runs from syslog/logread when there is no journal. |
|
||||||
|
| 4.5 | **A** | `internal/storage/snapshots.go:402` `/etc/cron.d/hamlab-zfs`; `internal/api/dockersched.go:65` `/etc/cron.d/hamlab-docker` | ZFS snapshot and image-update schedules are cron.d files. No cron.d on busybox crond; no cron at all on a systemd-timer-only host (Fedora Server, CoreOS). | Detect: cron.d → cron.d; busybox → a line in `/etc/crontabs/<user>`; none but systemd → a timer unit. The daemon-side runner already exists (`hamlab zfs-snapshot`, `docker-update`). |
|
||||||
|
| 4.6 | **B** | `internal/hardware/hardware.go:333` `systemd-detect-virt` | Virtualisation detection is systemd-only. | Fall back to `/sys/class/dmi/id/sys_vendor`, `/proc/cpuinfo` `hypervisor` flag, `/sys/devices/virtual/dmi`. |
|
||||||
|
| 4.7 | **B** | `internal/api/server.go:1129`, `internal/updates/updates.go:187` | `/run/reboot-required` is Debian/Ubuntu-only. `updates.Status` falls back to a kernel check for other distros; `/api/me.reboot_needed` (the Machines badge, the header's amber power button) does not. | Share one function. |
|
||||||
|
| 4.8 | **B** | `install.sh:154-157, 164, 375-378`; `internal/setup/setup.go:284` | The wizard's init detection knows systemd and OpenRC only: on OpenWrt it warns "no systemd or OpenRC: nothing starts it at boot" although `hamlab install` then installs a procd script fine; the "logs" hint and the "isn't answering yet" hint have no procd case. | Add procd to both. |
|
||||||
|
| 4.9 | **B** | `internal/setup/setup.go:609-641` `firewall()` | Only firewalld and ufw are detected. OpenWrt fw4/nftables, plain nftables, iptables, Alpine awall are not. | Detect `fw4`/`nft`/`iptables`; on OpenWrt add a LAN-zone rule or say what to open. |
|
||||||
|
| 4.10 | **B** | `internal/setup/setup.go:568-605` `quietSudoers()`; `internal/services/services.go:176-185` | The sudoers quieting list is tuned to what the lab polls (`smartctl`, `pwrstat`, `journalctl`, `zpool`, `zfs`). A host with NUT or apcupsd gets `upsc`/`apcaccess` lines in its auth log at 10 s intervals; a host without `/etc/sudoers.d` (OpenWrt) gets nothing, which is correct. | Build the list from what the collector actually probes. |
|
||||||
|
|
||||||
|
## 5. Distro and package assumptions beyond init
|
||||||
|
|
||||||
|
| # | Sev | Where | Assumes | General form |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 5.1 | **B** | `internal/incus/incus.go:213-250, 351-352` | Incus can be installed with apt, dnf, pacman, apk. **zypper is missing** (the Updates tab supports it), so an openSUSE host reads "No package manager Ham Lab knows how to drive". `opkg` is known only to say "not packaged for OpenWrt". | Add zypper; phrase the opkg case as "not packaged for this distribution". |
|
||||||
|
| 5.2 | **B** | `internal/incus/incus.go:973-978` | dnf branch adds `qemu-system-x86-core`/`edk2-ovmf` or `qemu-system-aarch64-core`/`edk2-aarch64`; other arches (riscv64, ppc64le, which `IncusArch()` maps) get no QEMU. pacman and apk branches assume their package names never change. | Table per PM × arch, or let `incus` pull its own deps where the distro does that (Debian does). |
|
||||||
|
| 5.3 | **B** | `internal/incus/incus.go:958, 970` `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold`; `internal/updates/updates.go:78, 432` | Debian-isms exported unconditionally. Harmless elsewhere. | Fine; note only. |
|
||||||
|
| 5.4 | **B** | `scripts/pci-ids-gpu.sh:7, 41` default `/usr/share/misc/pci.ids` | Debian's path for `pci.ids`; Fedora/Arch keep it in `/usr/share/hwdata/`. Build-time only. | Try both. |
|
||||||
|
| 5.5 | **B** | `internal/api/status_sensors.go:283`, `internal/api/docker.go:133` | Container memory/CPU read from three cgroup paths: cgroup v2 + systemd driver, cgroupfs, libpod. cgroup v1 hosts (older Debian/Ubuntu, some Alpine, OpenWrt) get no `container.*.mem/cpu`. | Add the v1 `memory/docker/<id>` and `cpu,cpuacct` paths, or fall back to `docker stats --no-stream` once in a while. |
|
||||||
|
| 5.6 | **B** | `internal/metrics/metrics.go:1064` `clkTck = 100` | USER_HZ assumed 100. True on every mainstream build; wrong on a kernel built otherwise. | `sysconf` via `getconf CLK_TCK` once at start. |
|
||||||
|
| 5.7 | **B** | `internal/incus/subnet.go:150-179` `ip -j`; `internal/space/manager.go:578-585` `timeout -s KILL` | iproute2 JSON output (busybox `ip` has no `-j`); GNU `timeout`. | Parse plain `ip route`/`ip addr`; busybox has `timeout` too but flag spelling differs — use the context deadline. |
|
||||||
|
|
||||||
|
## 6. Hardware and vendor assumptions
|
||||||
|
|
||||||
|
| # | Sev | Where | Assumes | General form |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 6.1 | **B** | `internal/metrics/gpu.go:343-383` `read()` | Busy/clock/VRAM are read for **i915, xe, amdgpu** only — the three GPUs in this lab. Nouveau, nvidia-drm, panfrost, v3d, Asahi are listed but never measured. `PLAN.md:317`: "No NVIDIA path: nothing here has one to test against." | `nvidia-smi --query-gpu` where present; `/sys/class/drm/*/device/gpu_busy_percent` for nouveau; `devfreq` for the ARM drivers. |
|
||||||
|
| 6.2 | **B** | `internal/metrics/pciids_gpu.txt`, `scripts/pci-ids-gpu.sh:12-15` | Embedded PCI ID table trimmed to Intel, AMD, NVIDIA and three virtual vendors. Matrox, ASPEED (every server BMC), Moore Threads, etc. fall back to `lspci` or "Vendor GPU <id>". | Include the full display-class device list, not three vendors. |
|
||||||
|
| 6.3 | **B** | `internal/metrics/gpu.go:237-241` | "Integrated" = PCI bus `0000:00:`. Wrong on multi-domain or APU boards where the iGPU hangs off a bridge; cosmetic. | Use `boot_vga` + `PCI_SUBSYS` heuristics, or drop the flag. |
|
||||||
|
| 6.4 | **B** | `internal/metrics/metrics.go:189-196, 917-968` | UPS probe order **pwrstat (CyberPower) first**, then NUT, then apcupsd; pwrstat's `....`-separated output is parsed bespoke. A host with both pwrstat and NUT shows pwrstat only. | Prefer NUT (the vendor-neutral one), then the vendor tools; or list every UPS found. |
|
||||||
|
| 6.5 | **B** | `internal/metrics/metrics.go:1179-1182` `cpuTemp()` | CPU temperature from `coretemp`, `k10temp`, `thermal`/`cpu`, `cpu_thermal`, `soc_thermal`. Misses `zenpower`, `acpitz` as the only sensor, Apple `macsmc` names, many ARM SoC zone names. | Broaden; fall back to the hottest zone when none matches. |
|
||||||
|
| 6.6 | **B** | `internal/hardware/hardware.go:617-627` `jedec` | JEDEC IDs for "the memory makers who turn up in a homelab" — 14 entries. | Embed the JEDEC JEP106 bank list (public, small). |
|
||||||
|
| 6.7 | **B** | `internal/storage/storage.go`, `internal/api/zfs.go`, `internal/storage/arc.go`, `web/src/ZFS.tsx` | A whole **ZFS** tab and ZFS-aware Space/Disks logic; **no btrfs or LVM equivalent** (snapshots, subvolumes, scrub, balance). Not wrong, but the tab list reflects a ZFS NAS. | Later: a Btrfs tab with the same shape, or a generic "Filesystems" tab that shows what the host has. |
|
||||||
|
| 6.8 | **B** | `internal/storage/snapshots.go:1-16, 966-968` | Snapshot naming is **zfSnap's** (`2026-09-24_02.30.01--6w`) because `grid` ran zfSnap. A sanoid/zfs-auto-snapshot user's existing snapshots (`autosnap_…`, `zfs-auto-snap_daily-…`) are detected (`internal/api/zfs.go:1764-1798`) but can never be expired by Ham Lab, and new schedules write a third convention beside them. | Make the naming a per-schedule choice (zfSnap / sanoid / zfs-auto-snapshot), or at least document the convention as the product's rather than inherited. |
|
||||||
|
| 6.9 | **B** | `internal/network/speedtest.go:64-67` `downSources` | Fallback download source is **`speedtest.seattle.linode.com`** — chosen because the lab is in the Pacific Northwest. Everywhere else it reads low. | Pick the nearest of several mirrors, or drop the fallback and report the refusal. |
|
||||||
|
| 6.10 | **B** | `internal/network/speedtest.go:70-74, 604`, `internal/network/network.go:323-334` | Public IP, edge and speed come from **Cloudflare** endpoints, polled every 15 min with no off switch. Air-gapped or privacy-minded installs have no way to disable the outbound probe. | A `network.outside: off` setting; honour it in the watcher. |
|
||||||
|
| 6.11 | **B** | `internal/vpn/edit.go:5-7, 419-421` | Peers can be **created/edited only through OpenWrt UCI** "because that is where the server here is configured". A wg-quick server (the common case everywhere else) is read-only. | Write wg-quick `.conf` too (append a `[Peer]` block + `wg set`), guarded the same way. |
|
||||||
|
| 6.12 | **B** | `internal/vpn/vpn.go:251-253` | Peer names come from wg-quick comments or UCI descriptions — "which between them cover every machine here". NetworkManager and systemd-networkd WireGuard configs are not read. | Add `.nmconnection` and `.netdev` readers. |
|
||||||
|
| 6.13 | **B** | `internal/vpn/edit.go:515` `profile()` | Split-tunnel routes hard-code `10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12` on top of the tunnel's own prefix. | Offer the host's actual LAN prefixes (from `TakenPrefixes`-style route reading), with RFC 1918 as the fallback. |
|
||||||
|
| 6.14 | **B** | `internal/incus/subnet.go:1-9, 23-34, 55`; `web/src/Virtualization.tsx:133` | Bridge subnet candidates skip **all of 10/8** because "this LAN is 10.0.0.0/8"; order is 172.16.100–254, then 192.168.254–200, then 172.16.1–99; last-resort `172.16.100.0/24`. Works anywhere, but a lab on 172.16/12 gets 192.168.x first and a lab on 192.168/16 never gets its own range. | Read the host's routes (already done) and pick from all three RFC 1918 blocks in a neutral order. |
|
||||||
|
| 6.15 | **B** | `internal/network/network.go:170-199, 303-314`; `internal/incus/subnet.go`; `internal/vpn/edit.go:558-595`; `internal/tlsauto/tlsauto.go:533-537`; `install.sh:114-120`; `internal/setup/setup.go:648-666` | **IPv4 everywhere**: default route from `/proc/net/route` only, outside view v4 only, bridge/peer allocation v4 only, cert SANs v4 only, installer lists v4 addresses only. | `/proc/net/ipv6_route`, v6 SANs, v6 peer addresses. An IPv6-only host today has no gateway, no cert SAN and no bridge. |
|
||||||
|
|
||||||
|
## 7. Privilege and deployment posture (the lab's NOPASSWD-everywhere model)
|
||||||
|
|
||||||
|
| # | Sev | Where | Assumes | General form |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 7.1 | **A** | `internal/priv/priv.go`; every tab that calls `priv.Available()`; `web/src/Updates.tsx:2144` "give its user passwordless sudo or doas"; `web/src/Jobs.tsx:1883` | Full function needs the service user to have **unrestricted passwordless sudo**, which `hamlet/CLAUDE.md` grants on every host here. The installer neither creates nor offers a sudoers rule, so on a fresh box most tabs are read-only and the UI tells the user to go fix sudoers by hand. | `hamlab install --sudo` writes a **scoped** `/etc/sudoers.d/hamlab` naming the commands Ham Lab runs (smartctl, zpool, zfs, systemctl, docker, journalctl, tee for its cron files, `hamlab fs|space-walk|drm-clients|cpu-energy|replace`), or installs as root. Either way the console works out of the box. |
|
||||||
|
| 7.2 | **A** | `internal/api/server.go:1069-1080` `clientKey()`, `:1083-1092`, `:431-433` `secure()` | The **first `X-Forwarded-For` hop is trusted unconditionally**, because here Ham Lab always sits behind Nginx Proxy Manager. On a host exposed directly, a client sets the header and defeats the per-client login rate limiter and the session IP check. | Trust `X-Forwarded-*` only from configured proxy addresses (`trusted_proxies` in config), else use the socket peer. |
|
||||||
|
| 7.3 | **B** | `internal/api/server.go:1324` `X-Accel-Buffering: no` "nginx (NPM)" | Harmless header; comment names the lab's proxy. | Keep header; generalise comment. |
|
||||||
|
| 7.4 | **B** | `internal/api/guests.go:1-9`, `PLAN.md:21` | Guest logins are full-power ("he gives these to people he trusts"). A product default. | Keep, but it is a posture decision taken for this household; a read-only guest is the generic expectation and was cut. Document as a deliberate choice in README. |
|
||||||
|
| 7.5 | **B** | `internal/api/users.go:1-6` | "a household console, not a tenancy": any signed-in user can add users, make tokens, open a root shell. | Same as 7.4 — fine, but say so where a stranger installs it. |
|
||||||
|
|
||||||
|
## 8. Resource tuning from the lab's smallest host
|
||||||
|
|
||||||
|
| # | Sev | Where | Assumes | General form |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 8.1 | **B** | `cmd/hamlab/main.go:451` `debug.SetMemoryLimit(160 << 20)`; `PLAN.md:11` "`core` has ~370 MiB free RAM" | A fixed 160 MiB soft heap limit sized for `core`. On a 64 GiB NAS it forces needless GC under a big Space scan; on a 512 MiB Pi it is still half the box. | Scale from `MemTotal` (e.g. 2–5 %, floor 64 MiB, ceiling 1 GiB), or make it a config key. |
|
||||||
|
| 8.2 | **B** | `internal/space/walk.go:893` `workers := 32`; `internal/space/manager.go:578` 3-minute `zfs diff` patience | Tuned on `grid` (16 cores) and `core`. | Workers from `GOMAXPROCS`; patience configurable. |
|
||||||
|
| 8.3 | **C** | `internal/metrics/metrics.go:32-43` intervals, `History = 300` | Reasonable generic defaults; listed only because 8.1 made them look lab-tuned. | None. |
|
||||||
|
|
||||||
|
## 9. Data directory and legacy paths
|
||||||
|
|
||||||
|
| # | Sev | Where | Assumes | General form |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 9.1 | **B** | `internal/config/config.go:115-117` `Candidates()`; `internal/setup/setup.go:413-422`; `README.md:38` | `~/apps/hamlab/config.json` is searched as a legacy location — the lab's pre-v0.2 path, kept so `core`, `grid` and the Mac keep working. | Keep for one more release with a one-time migration log line, then drop. A stranger never had it. |
|
||||||
|
| 9.2 | **done** 2026-10-10 | 3.3 above; `PLAN.md:24` "`grid`'s and the Mac's stacks at `~/stacks/<machine>/` are the standing exception" | The "everything under DataDir" rule has a lab-shaped exception baked into the stacks code. | Default to DataDir; treat `~/stacks` as a discovered home like any other. |
|
||||||
|
| 9.3 | **C** | `internal/setup/setup.go:351` `ownFiles` | Correctly scoped; note only. | None. |
|
||||||
|
|
||||||
|
## 10. Build and release
|
||||||
|
|
||||||
|
| # | Sev | Where | Assumes | General form |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 10.1 | **B** | `Makefile:60-61, 442-443`; `scripts/gitea-release.sh` | Publishing is Gitea/Forgejo-only (release API shape). `selfupdate` also understands a static directory, so publishing could too. | `make release` + any static host is already enough; add a GitHub/Forgejo-agnostic `release-dir` note. |
|
||||||
|
| 10.2 | **C** | `scripts/gitea-release.sh:500-515` | Needs `python3` on the publishing host. | `jq`, or do it in Go (`hamlab publish`). |
|
||||||
|
| 10.3 | **C** | `Makefile:392` `ARCHES ?= amd64 arm64 arm 386 riscv64` | Fine. `ppc64le`/`s390x` are mapped by `IncusArch()` but never built. | Add or drop them from `IncusArch`. |
|
||||||
|
|
||||||
|
## 11. Things that are already general (checked, no change needed)
|
||||||
|
|
||||||
|
Listed so the sweep is known to be complete, not because they need work.
|
||||||
|
|
||||||
|
- `internal/priv` (root / sudo / doas), `internal/setup` service templates, `IP_FREEBIND` listen, self-update atomic replace and re-exec.
|
||||||
|
- `internal/updates`: apt, dnf, zypper, pacman, apk with refresh/upgrade/install.
|
||||||
|
- `internal/storage` block devices: `lsblk -J` with a `/sys/block` fallback, eMMC health, NVMe namespace folding, SMART only when `smartctl` exists.
|
||||||
|
- `internal/metrics` CPU/mem/hwmon/thermal/net/disk/fs from `/proc` and `/sys`; remote-FS and pseudo-FS lists; filesystem statfs with a timeout.
|
||||||
|
- `internal/hardware`: every reader may come back empty with a note; device-tree fallbacks for non-SMBIOS boards.
|
||||||
|
- `internal/incus` detection and the per-machine "ready / repair / installable / unavailable" model (modulo 4.2, 5.1, 5.2).
|
||||||
|
- `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
|
||||||
|
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
|
||||||
|
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`.
|
||||||
|
- `internal/api/kvm.go`: "nothing in it knows anything about any particular KVM" — verified.
|
||||||
|
- `internal/compose` (modulo 3.5).
|
||||||
|
- The React UI: machine-switcher model, hash routing, per-account prefs, themes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order of work
|
||||||
|
|
||||||
|
1. **Posture and safety first (A):** 7.2 trusted proxies; 7.1 installer-written scoped sudoers; ~~3.1 drop the `:2375` probe (with 3.2 as its consequence)~~ done 2026-10-10.
|
||||||
|
2. **"Any Linux" promises the README already makes (A):** 4.1 power, 4.4/4.5 cron on busybox and timer-only hosts, 4.2 Incus install on non-systemd, 4.3 a Services backend for OpenRC/procd, 2.10 the `tank` regex.
|
||||||
|
3. **Lab defaults that phone home or mislead (B):** 1.1–1.3 update/install URLs, 2.9 schedule defaults, 6.9/6.10 speed-test sources and an off switch, 8.1 memory limit, ~~3.3/9.2 stack home = DataDir~~ done 2026-10-10.
|
||||||
|
4. **Breadth (B):** 5.1 zypper for Incus, 5.5 cgroup v1, 6.1/6.2 GPUs beyond Intel/AMD, 6.4 UPS order, 6.11/6.12 wg-quick/NetworkManager, 6.15 IPv6.
|
||||||
|
5. **Cosmetics (C):** every placeholder and comment in §1.10 and §2, then split lab facts out of `PLAN.md`, `VIRTUALIZATION.md`, `SENSORS.md`, `README.md`.
|
||||||
@@ -54,6 +54,14 @@ reset a password; your own password confirms the last two). From the shell:
|
|||||||
users` lists them, `hamlab user-rm <user>` removes one. Machine settings — refresh rate,
|
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 |
|
||||||
|
|||||||
+27
-1
@@ -12,13 +12,16 @@ added to the registry in `api.New`.
|
|||||||
## IDs
|
## IDs
|
||||||
|
|
||||||
```
|
```
|
||||||
cpu.usage cpu.core.<n>.usage cpu.temp load.1|5|15 uptime
|
cpu.usage cpu.core.<n>.usage cpu.temp cpu.power load.1|5|15 uptime
|
||||||
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
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,29 @@ ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
|||||||
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between
|
- 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.
|
||||||
|
- `cpu.power` is the CPU packages' own power in watts, from the kernel's RAPL energy
|
||||||
|
counters (Intel, and AMD Zen), summed over every package: cores, built-in graphics and
|
||||||
|
memory controller, not the whole machine. The counters are root-only, so a daemon that is
|
||||||
|
not root reads them through a helper started once with sudo or doas. It is absent on a
|
||||||
|
machine without the counters (ARM, the Mac mini) or without a way to root.
|
||||||
|
- `gpu.<name>.*` is each graphics device, named as lspci names it (`gpu.intel_arc_a380`;
|
||||||
|
labels carry `driver` and the PCI `bus`). `usage` is how busy the device was — from the
|
||||||
|
GT's idle residency on Intel, `gpu_busy_percent` on amdgpu — and is absent on a driver
|
||||||
|
with no such counter. The per-engine percentages (`render`, `video`…) are the sum over the
|
||||||
|
processes holding the device open, read from DRM fdinfo, and exist only while something
|
||||||
|
has it open. `temp` is the card's own sensor, or on an integrated GPU the CPU package it
|
||||||
|
shares a die with (label `source` says which); an Arc on i915 has none and reports none.
|
||||||
|
`freq` is the actual clock, 0 when power-gated. `clients` counts the processes using it.
|
||||||
|
- `zfs.arc.*` is the ZFS read cache in RAM, on a machine with the module loaded. `hit_pct`
|
||||||
|
is over the last sampling interval and appears once an interval has had any reads (an idle
|
||||||
|
interval repeats the last value); `lifetime_hit_pct` is since boot. `no_grow` is true
|
||||||
|
while memory pressure stops the cache growing. `l2_*` appear only with cache devices.
|
||||||
|
|
||||||
## A sensor
|
## A sensor
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# Virtualization tab — plan (2026-10-09; phase 1 built the same day)
|
||||||
|
|
||||||
|
**Status:** phases 1 and 2 are **deployed to all five machines as v0.1.0-188** (2026-10-09).
|
||||||
|
Incus runs on `grid` (installed from the tab); the other machines show Install or, on `gate`,
|
||||||
|
unavailable. Phase 2 verified live on `grid` through the hub: ISO fetched by URL with its
|
||||||
|
sha256 checked, **New VM from ISO** booted Alpine with Secure Boot off, **the VGA console
|
||||||
|
drew it in a real browser** (headless Chromium, 1280×800 canvas, keyboard and mouse live),
|
||||||
|
a virtiofs mount appeared in a running VM, an export tarball landed in `virt/exports/` and the
|
||||||
|
nightly cron line is written, `instance.*` sensors flow and the "VM or container down" alert
|
||||||
|
default is in. Two things found and fixed on the way: spice-html5 wants the `binary`
|
||||||
|
websocket subprotocol agreed; and it opens one websocket per SPICE channel, so the bridge
|
||||||
|
holds **one console operation per VM** and gives each channel its own data websocket on it
|
||||||
|
(Incus refuses a second operation as "already connected"). **Left from phase 2:** the LAN
|
||||||
|
bridge step (`br0` on the uplink — needs a live test on a machine that can afford to drop
|
||||||
|
off the network for a moment, which is a decision, not a build), cloud-init presets beyond
|
||||||
|
"Use my SSH keys", and a backup line for `grid`'s `virt/exports/` (see `hamlet/docs/backups.md`).
|
||||||
|
`t-alpine` on `grid` is the live example; delete it from the tab when done.
|
||||||
|
|
||||||
|
A tab for creating, watching, consoling into and deleting **LXC containers and KVM virtual
|
||||||
|
machines**, the way Proxmox does with `pct` and `qm` — **per machine, like every other
|
||||||
|
page.** This is the scoping PLAN.md asked for. Nothing here is built. Live state of the
|
||||||
|
hosts stays in `hamlet/docs/hosts.md`.
|
||||||
|
|
||||||
|
## The governing rule: capability, not policy
|
||||||
|
|
||||||
|
Stated by Mitchell 2026-10-09, after the first draft of this plan excluded hosts on the
|
||||||
|
strength of the homelab's deployment rules:
|
||||||
|
|
||||||
|
> Anything that's done when a different machine is selected SHOULD GO ON THAT machine. I
|
||||||
|
> want the option to do it anywhere I want, unless the hardware / software literally
|
||||||
|
> doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on
|
||||||
|
> any Linux system. If certain parts / libraries aren't available on that machine, those
|
||||||
|
> tabs should just report that.
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
- **Every action lands on the machine in the switcher.** There is no "VM host", no default
|
||||||
|
machine, no cross-machine placement. `grid` is where he will most likely click first;
|
||||||
|
the tab does not know or care.
|
||||||
|
- **The tab is gated by what the machine can do, detected live** — never by a rule about
|
||||||
|
what *should* run there. The `hamlet/CLAUDE.md` do-not-break rules (Mac mini for
|
||||||
|
single-core workloads, `hamtv` not spare hardware) bind the *agent's* unasked
|
||||||
|
deployments. They are not feature gates for the product, and the first draft was wrong
|
||||||
|
to treat them as such.
|
||||||
|
- **Ham Lab owns its data, and its data lives in its own folder.** Everything this tab
|
||||||
|
manages — instance storage, ISOs, image tarballs, exports — sits under Ham Lab's data
|
||||||
|
directory on *that* machine (`config.DataDir()`, `~/apps/hamlab` on `core`, `grid` and
|
||||||
|
the Mac), in one `virt/` tree. Never a path elsewhere on the machine, however convenient
|
||||||
|
the existing folder. Stated 2026-10-09 after the first Library draft pointed the ISO shelf
|
||||||
|
at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it
|
||||||
|
manages its own data."* This is the rule that moved `core`'s compose files under
|
||||||
|
`apps/hamlab/stacks/` on 2026-10-05; `grid`'s and the Mac's stacks, still at
|
||||||
|
`~/stacks/<machine>/`, are the outstanding exception and are not touched by this plan.
|
||||||
|
The engine's own runtime state (`/var/lib/incus`, like `/var/lib/docker`) is the engine's,
|
||||||
|
not Ham Lab's data: Ham Lab keeps what it would need to rebuild it.
|
||||||
|
- **Ham Lab installs or reports.** It detects what is missing, offers to install it where it
|
||||||
|
knows how, and where it cannot or the hardware cannot do the job, says so in one sentence
|
||||||
|
and offers what *does* work (containers without KVM, for instance).
|
||||||
|
|
||||||
|
## Decision: Incus 6.0 LTS as the engine
|
||||||
|
|
||||||
|
| Option | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| **Incus** (LXD fork, linuxcontainers.org) | **Chosen.** One daemon, one CLI and one JSON API for both containers and VMs. Packaged by Debian 13 (`incus` 6.0.4 LTS — `apt install incus` pulls `qemu-system-x86`, `swtpm`, `virtiofsd`, `incus-agent`, so VMs work out of the box), Fedora (6.23), Arch, Alpine, Ubuntu (Zabbly repo); not by OpenWrt. Storage drivers for ZFS, btrfs, LVM and plain directories, so it fits a machine with or without ZFS. Built-in image server (`images:debian/13`, `images:ubuntu/24.04`, Alpine, Fedora, Arch… as container *or* VM, per architecture), snapshot schedules with expiry, `incus export` backups, per-instance CPU/memory limits, cloud-init, text and VGA console over websocket. Everything is reachable as `incus … --format json` or `incus query`, which is exactly how Ham Lab already drives Docker: `exec.Command`, no new Go dependency, no cgo. |
|
||||||
|
| libvirt/KVM + `virsh`, plus LXC separately | Two daemons, two models, XML. libvirt's official Go binding needs cgo, which the static binary rules out (the pure-Go `go-libvirt` exists but then containers are a second project). Rejected. |
|
||||||
|
| Proxmox | An OS, not a library. Rejected. |
|
||||||
|
|
||||||
|
## What each machine can do today
|
||||||
|
|
||||||
|
Probed 2026-10-09 (`/dev/kvm`, cpu flags, kvm modules, RAM, disk, package availability).
|
||||||
|
These are **facts the tab will detect at runtime**, not a list of where instances are
|
||||||
|
allowed. The last column is what the tab would show the day it ships.
|
||||||
|
|
||||||
|
| Machine | Arch | KVM | RAM (free) | Incus from the package manager | Pool driver available | Tab state on first open |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `grid` | x86-64 | yes — VT-x, `kvm_intel` loaded, nested on | 62 GiB (~25; ARC holds 23 of the rest, max 40) | apt: `incus` 6.0.4 | **zfs** (2.3.9, `tank`) | **Install** — containers and VMs |
|
||||||
|
| `core` | x86-64 | yes — N100, `kvm_intel` loaded | 7.5 GiB (~4) | apt: `incus` 6.0.4 | btrfs or dir (ZFS was purged 2026-09-23) | **Install** — containers and VMs; the create form shows free memory so a VM's size is an informed choice |
|
||||||
|
| Mac mini | aarch64 | yes — Asahi KVM, 16K pages | 15 GiB (~8) | dnf: `incus` 6.23, `qemu-system-aarch64-core`, `edk2-aarch64` | btrfs or dir | **Install** — containers and VMs, **arm64 images only** (the picker filters by host arch). Whether a stock 4K-page guest kernel boots under the 16K host is untested; `muvm` does exactly that, so it is expected to. |
|
||||||
|
| `hamtv` | x86-64 | yes | 3.7 GiB (~2.9) | apt: `incus` 6.0.4 | btrfs or dir | **Install** — containers and small VMs |
|
||||||
|
| `gate` | x86-64 | **no** `/dev/kvm` | 8 GiB (~6) | **none** — OpenWrt's opkg has no Incus | — | **Unavailable**: "Incus isn't packaged for OpenWrt, and this kernel has no KVM." Nothing to install. |
|
||||||
|
|
||||||
|
Nothing is installed anywhere today: no qemu, libvirt, lxc or incus on any host.
|
||||||
|
|
||||||
|
### Detection the tab runs, in order
|
||||||
|
|
||||||
|
1. `incus` on `PATH` and its socket answering → **ready**; read pools, networks, instances.
|
||||||
|
2. Else: a package manager Ham Lab knows (apt, dnf, pacman, apk) that has an `incus`
|
||||||
|
candidate → **installable**, with the Install dialog pre-filled (below).
|
||||||
|
3. Else → **unavailable**, with the reason in one sentence (no package; no root; OpenWrt).
|
||||||
|
4. Independently: `/dev/kvm` present and openable → VMs offered. Absent → **containers
|
||||||
|
only**, said once on the page, and the create form has no VM option.
|
||||||
|
5. Architecture from `uname -m`, so the image picker only shows images that can run here.
|
||||||
|
6. Pool driver: `zfs` if the module and tools are present, else `btrfs` (installing
|
||||||
|
`btrfs-progs` as part of Install), else `dir`. The dialog says which it picked and why.
|
||||||
|
|
||||||
|
## What the tab does (v1)
|
||||||
|
|
||||||
|
Mirrors the Docker tab's shape: a list, a detail page, actions that are followed tasks,
|
||||||
|
destructive ones confirmed by typing the name.
|
||||||
|
|
||||||
|
- **Install** — one task that runs *on the selected machine*: install the package(s),
|
||||||
|
`incus admin init --preseed` with the layout from the dialog, create the pool and bridge,
|
||||||
|
add Ham Lab's user to `incus-admin` (socket access, the `docker`-group shape) and add it
|
||||||
|
to the unit's `SupplementaryGroups`, apply the Docker fix where Docker is present (below),
|
||||||
|
then restart Ham Lab. Idempotent, so it doubles as "repair". The dialog shows and lets him
|
||||||
|
edit: pool driver (detected), pool size (proposed from free space), bridge subnet
|
||||||
|
(computed), whether to apply the Docker rules. Where it all goes is not a choice: Ham
|
||||||
|
Lab's folder.
|
||||||
|
- **Instances** — containers and VMs in one list: type, state, IPv4, CPU and memory live,
|
||||||
|
disk, snapshot count, uptime. Click for detail.
|
||||||
|
- **Create** — container or VM (VM only where KVM is); image from the `images:` remote,
|
||||||
|
filtered to this architecture, searchable, with the common ones pinned (Debian 13, Ubuntu
|
||||||
|
24.04, Alpine, Fedora); name, CPUs, memory (with this machine's free memory beside it),
|
||||||
|
root disk size; network (the NAT bridge, or a LAN bridge if this machine has one);
|
||||||
|
optional cloud-init user-data (SSH key, packages, a first command); profile. Create, or
|
||||||
|
create and start. Collisions (name taken) named before Create.
|
||||||
|
- **Lifecycle** — start, stop, restart, force stop, rename, delete (type the name; the
|
||||||
|
instance's snapshots go with it, and the dialog says so).
|
||||||
|
- **Console** — two buttons, both open as tabs in the Terminal page through the existing PTY
|
||||||
|
manager (`term.CreateCmd`, the `docker exec` path): **Console** (`incus console`, the
|
||||||
|
serial console — works before the network is up, shows boot output) and **Shell**
|
||||||
|
(`incus exec <n> -- login shell`). Containers and, via `incus-agent`, VMs.
|
||||||
|
- **Snapshots** — take, restore (confirmed), delete; a **schedule** per instance with an
|
||||||
|
expiry, which is Incus's own `snapshots.schedule`/`snapshots.expiry`, so no cron file of
|
||||||
|
ours this time.
|
||||||
|
- **Resources** — edit CPU and memory limits (live for containers; VMs take CPU hotplug and
|
||||||
|
memory balloon live with the agent, else on restart), grow the root disk, add a bind mount
|
||||||
|
(a host path into a container; virtiofs into a VM).
|
||||||
|
- **Library** — ISOs for VMs and images (container templates and VM images). Its own
|
||||||
|
section below.
|
||||||
|
- **Storage and networks** — the pools with their fill, the bridges with their subnets.
|
||||||
|
Read-only in v1 beyond what Create and Install need.
|
||||||
|
- **Sensors** — `instance.<name>.cpu`, `.memory`, `.running` from Incus's state, like the
|
||||||
|
`container.*` sensors, so Alerts gets an "instance down" default and widgets can show one.
|
||||||
|
|
||||||
|
## Where the data lives
|
||||||
|
|
||||||
|
One tree per machine, under Ham Lab's data directory, created by Install:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/apps/hamlab/virt/
|
||||||
|
pool.img the instance pool's backing file (zfs or btrfs loop; a plain directory
|
||||||
|
pool/ when the driver is dir) — every instance's disks live in here
|
||||||
|
isos/ the ISO library: the files themselves
|
||||||
|
images/ the image library: template tarballs and VM disk images as files, the
|
||||||
|
way Proxmox keeps CT templates — Incus's image store is loaded from here
|
||||||
|
exports/ nightly `incus export` tarballs, with retention
|
||||||
|
virt.json what Ham Lab chose and when: driver, pool size, bridge subnet, ISO and
|
||||||
|
image inventory, export schedule
|
||||||
|
```
|
||||||
|
|
||||||
|
Consequences, stated once:
|
||||||
|
|
||||||
|
- The tree is on whatever disk holds Ham Lab's data directory — the boot NVMe on all three.
|
||||||
|
Install proposes the pool size from that disk's free space. On `grid` that disk has
|
||||||
|
790 GiB free; on `core`, 175 GiB.
|
||||||
|
- Bulk data a guest needs is **bind-mounted in from where it already is** (`/tank/…` into an
|
||||||
|
instance on `grid`), the same split Docker has: the instance is Ham Lab's, the media is
|
||||||
|
not. That is a device on the instance, recorded in its config, not a second home.
|
||||||
|
- On `core` the whole tree rides the existing nightly rsync to `grid` for free. On `grid`
|
||||||
|
and the Mac it is as backed up as the rest of their Ham Lab folders — which is to say the
|
||||||
|
exports are the backup, and the backup job will need to be told about them in phase 2.
|
||||||
|
- Existing material elsewhere — the 105 GiB of ISOs at `/tank/software/isos/`, the Proxmox
|
||||||
|
archives — is **imported into the tree by copying**, file by file, on request. The
|
||||||
|
library never indexes a folder it does not own.
|
||||||
|
|
||||||
|
## Library: ISOs and images (asked for 2026-10-09)
|
||||||
|
|
||||||
|
Proxmox has a storage view with **ISO Images** and **CT Templates**. Incus has the same two
|
||||||
|
things under other names, and the tab gives them one home, a **Library** sub-tab, per machine
|
||||||
|
like everything else.
|
||||||
|
|
||||||
|
| Proxmox calls it | Incus calls it | What it is here |
|
||||||
|
|---|---|---|
|
||||||
|
| ISO Images | an ISO file attached to a VM as a `disk` device (added as a ROM device; VMs only), or a custom storage volume of type `iso` | **`virt/isos/` in Ham Lab's folder**, attached by path — no copy into the pool |
|
||||||
|
| CT Templates | **images** — the `images:` remote (linuxcontainers.org), the local image store, anything imported or published | **`virt/images/` in Ham Lab's folder** — tarballs and disk images as files, loaded into Incus from there; the remote is the catalogue to pull from |
|
||||||
|
|
||||||
|
### ISOs
|
||||||
|
|
||||||
|
- **The ISO library is `virt/isos/` in Ham Lab's folder**, on every machine, full stop. The
|
||||||
|
105 GiB at `/tank/software/isos/` on `grid` (`linux/`, `windows/`, `truenas/`,
|
||||||
|
`virtio_drivers/`…) is a *source* to import from — pick the ones wanted, Ham Lab copies them
|
||||||
|
in — not the library itself.
|
||||||
|
- **Getting an ISO in:** upload from the browser (the Files tab's `PUT /api/files/upload`
|
||||||
|
with its progress bar, reused), **download from a URL** as a followed task with an optional
|
||||||
|
checksum (the usual way — an ISO starts as a link), or **import from a path** already on the
|
||||||
|
machine — which **copies** the file into `virt/isos/` and says so. Rename, delete (refused while a VM has it attached, naming the VM).
|
||||||
|
- **Using one:** attach to / detach from a VM as a ROM device by path, with `boot.priority`
|
||||||
|
set so it boots first; **New VM from ISO** makes an empty VM (`incus init --empty --vm`)
|
||||||
|
with the ISO attached, starts it and opens the console. After install, Detach is one click
|
||||||
|
and boot order falls back to the disk.
|
||||||
|
- **Honest limit:** an ISO install is an interactive graphical installer, so it needs the
|
||||||
|
**VGA console in the browser** (`spice-html5` over Incus's VGA console websocket).
|
||||||
|
Installers that run on a serial console (Debian with `console=ttyS0`, Alpine, Arch) work
|
||||||
|
through the text console before that exists. Windows needs the virtio drivers ISO attached
|
||||||
|
as a second ROM device as well — Incus's documented path is `distrobuilder repack-windows`
|
||||||
|
to bake them in; the drivers are already in `virtio_drivers/` on `tank`. So **the ISO shelf
|
||||||
|
and the VGA console ship together, in phase 2**, moved up from phase 3.
|
||||||
|
- ISO *volumes* (`incus storage volume import <pool> <iso> <name> --type=iso`) are the
|
||||||
|
alternative: read-only, attachable to several VMs at once, but they copy the file into the
|
||||||
|
pool. Not used here — a file in `virt/isos/` is already Ham Lab's, and a volume would copy it
|
||||||
|
a second time into the pool. Offered as an option only if a need shows up.
|
||||||
|
|
||||||
|
### Images (container templates and VM images)
|
||||||
|
|
||||||
|
- **The catalogue:** browse `images:` filtered to this machine's architecture, by type
|
||||||
|
(container / VM), with the common ones pinned. **Download** copies one into the local
|
||||||
|
store (`incus image copy images:debian/13 local: --alias debian-13`) so a later Create is
|
||||||
|
instant and works without the internet. Images used by Create are cached by Incus anyway;
|
||||||
|
this is just the explicit version, Proxmox's "Templates → Download".
|
||||||
|
- **The local store:** every image here with alias, type, arch, size, origin (remote,
|
||||||
|
published, imported), when it was last used, and **which instances were made from it**
|
||||||
|
(Incus records the base image on each instance). Alias, delete (refused while an
|
||||||
|
instance's base — Incus knows), "New instance from this image".
|
||||||
|
- **Import:** a unified tarball or metadata + rootfs pair (a container template from
|
||||||
|
anywhere, including the phase-3 Proxmox conversions), or a **qcow2 / raw disk with
|
||||||
|
metadata Ham Lab writes** for a VM image — Home Assistant OS, OPNsense, a vendor cloud
|
||||||
|
image — by upload, URL, or a path on the machine. Same three ways in as ISOs.
|
||||||
|
- **Publish:** a stopped instance becomes an image with an alias — Proxmox's "convert to
|
||||||
|
template", the one thing the Images section of v1 always had.
|
||||||
|
- **Where they live:** as files in `virt/images/` — the downloaded or imported tarball, the
|
||||||
|
published image exported to one (`incus image export`) — so the library is complete without
|
||||||
|
Incus and can be reloaded into a fresh install. Incus's own image store under
|
||||||
|
`/var/lib/incus/images/` is a cache derived from it, and the page says so.
|
||||||
|
|
||||||
|
## Not in v1, with reasons
|
||||||
|
|
||||||
|
| Thing | Why not yet |
|
||||||
|
|---|---|
|
||||||
|
| **VGA console in the browser** | Incus hands the VM's SPICE socket over a websocket; drawing it needs `spice-html5` in the UI. Doable (the Incus UI does it); text console + shell cover every Linux guest until then. **Phase 2, with the ISO shelf** — an ISO install is useless without it. |
|
||||||
|
| **Windows VMs** | Incus runs them (ISO repacked with virtio drivers by `distrobuilder`), but without a VGA console in the browser they are unusable from the tab. After the above. |
|
||||||
|
| **GPU passthrough to a VM** | Removes the card from the host and from whatever uses it there (Jellyfin on `grid`). Offered only with that warning, later. Sharing a GPU into a *container* (`gpu` device) leaves the host its card and is how `r720-jellyfin` used to run. Phase 3. |
|
||||||
|
| **Clustering / live migration** | Incus clusters, but these machines differ in size and one is arm64, with no shared storage. Not planned. |
|
||||||
|
| **A fleet-wide instance list** | One machine per view. The switcher is the machine. |
|
||||||
|
| **A second engine (bare LXC on OpenWrt, libvirt-only hosts)** | Would double the backend for one router that has no KVM. The tab reports Incus unavailable there and stops. |
|
||||||
|
|
||||||
|
## Layout decisions Ham Lab makes per machine (overridable in the Install dialog)
|
||||||
|
|
||||||
|
1. **Storage.** The instance pool's backing lives in Ham Lab's folder: Install creates a
|
||||||
|
sparse `virt/pool.img` and hands it to Incus as the pool's `source` (zfs or btrfs by the
|
||||||
|
detection above), or `virt/pool/` as a `dir` pool where neither is available. Sized from
|
||||||
|
the disk's free space, shown and editable. Bulk data is **bind-mounted in**, not stored in
|
||||||
|
the pool. The driver, path and size are recorded in `virt.json` so the page can say where
|
||||||
|
the data is and Install can rebuild the pool from the file after a reinstall.
|
||||||
|
2. **Bridge subnet, computed.** Incus's `auto` picks a random `10.x.y.0/24`, and **this
|
||||||
|
LAN is `10.0.0.0/8`** — every host has a /8 on its NIC, so any 10.x bridge collides.
|
||||||
|
Ham Lab reads the machine's routes and picks the first free /24 from `192.168.0.0/16`,
|
||||||
|
then `172.16.0.0/12`, that is neither on-link nor routed nor inside Docker's configured
|
||||||
|
pool (`172.28.0.0/14` on `grid`; `172.19`–`172.29` and `192.168.80`–`143` live on
|
||||||
|
`core`). Instances get `name.incus` DNS from Incus's dnsmasq.
|
||||||
|
3. **LAN addresses are a later, explicit step.** A `br0` on the machine's uplink lets an
|
||||||
|
instance take a lease from `gate` and a Pi-hole name, but it rewrites the host's network
|
||||||
|
config and drops it off the network for seconds — never part of Install, always its own
|
||||||
|
confirmed action (with the NanoKVM standing by on `grid`). macvlan was considered and
|
||||||
|
rejected: no host change, but host and guests cannot talk, so nothing on the host could
|
||||||
|
reach an instance.
|
||||||
|
4. **Docker and Incus on one host.** Docker sets the `FORWARD` policy to `DROP`, which
|
||||||
|
silently kills the Incus bridge's traffic. Fix is two `DOCKER-USER` accept rules for the
|
||||||
|
bridge, persisted — applied by Install wherever Docker is detected (`grid`, `core`, the
|
||||||
|
Mac, `hamtv` all run it).
|
||||||
|
5. **Memory.** A VM's memory is a hard reservation; the create form shows the machine's
|
||||||
|
free memory and warns past it, and never refuses. On a ZFS host Install offers to cap
|
||||||
|
the ARC (on `grid`, 40 GiB → 16 GiB) so guest RAM is deterministic; his choice.
|
||||||
|
6. **Privilege.** Ham Lab's user joins `incus-admin`; no sudo in the hot path. Install
|
||||||
|
itself goes through `priv` like the other installers.
|
||||||
|
7. **Backups.** Nightly `incus export --instance-only` of every instance to `virt/exports/`
|
||||||
|
with a retention, as a cron line like the ZFS schedules (`hamlab incus-export`), from
|
||||||
|
phase 2. On `core` that folder is inside the nightly rsync already; on `grid` and the Mac
|
||||||
|
the backup job gets a line for it in phase 2.
|
||||||
|
|
||||||
|
## The old Proxmox estate (importable, phase 3)
|
||||||
|
|
||||||
|
`/tank/backups/proxmox/` holds the retired `r720` node: `/etc/pve` (12 LXC confs, 2 VM
|
||||||
|
confs), 118 GiB of `vzdump` archives, and three raw VM disks. Latest of each:
|
||||||
|
|
||||||
|
| ID | Name | Latest archive | Size |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 100 | r720-docker | 2026-01-11 | 20.7 GB |
|
||||||
|
| 101 | r720-cockpit | 2026-01-11 | 439 MB |
|
||||||
|
| 102 | r720-wordpress-alexblaskovich | 2026-01-11 | 1.2 GB |
|
||||||
|
| 103 | r720-spoonterm | 2025-06-14 | 494 MB |
|
||||||
|
| 104 | r720-ollama | 2026-01-11 | 11.3 GB |
|
||||||
|
| 105 | r720-transmission | 2026-01-11 | 322 MB |
|
||||||
|
| 106 | r720-apache | 2026-01-11 | 347 MB |
|
||||||
|
| 107 | r720-nut | 2025-03-29 | 205 MB |
|
||||||
|
| 108 | r720-filebrowser | 2026-01-11 | 262 MB |
|
||||||
|
| 109 | r720-bookstack | 2025-04-27 | 516 MB |
|
||||||
|
| 110 | r720-jellyfin | 2026-01-11 | 2.2 GB |
|
||||||
|
| 111 | r720-wireguard | 2025-04-13 | 510 MB |
|
||||||
|
| 112 | r720-eggwiki | 2026-01-11 | 634 MB |
|
||||||
|
| VMs | steam (102), powerpanel (105/107/112) | `.vma.zst` 2024–2025; raw disks `vm-105`, `vm-112`, `base-104` | 19 GB, 1.9 GB ×6, 97 GiB |
|
||||||
|
|
||||||
|
- **LXC:** feasible and scriptable. Unpack the rootfs (`tar --zstd -xpf … --numeric-owner`),
|
||||||
|
read `etc/vzdump/pct.conf` for arch/ostype, drop it, write `metadata.yaml`, pack, `incus
|
||||||
|
image import`, launch. Unprivileged uid shifting is Incus's job after that. The result is
|
||||||
|
an image in the Library, so a restored container is launched from there like any other
|
||||||
|
template. An import action in the tab (pick an archive on this machine) is plausible once
|
||||||
|
the script works by hand. x86-64 archives only run on x86-64 machines; the picker says so.
|
||||||
|
- **VMs:** the raw disks import directly (`incus-migrate`, or an empty VM with the disk
|
||||||
|
imported into its root volume). The `.vma.zst` archives need Proxmox's `vma` extractor (or
|
||||||
|
the Python port) to become raw disks first. Most of these are the same `powerpanel` box
|
||||||
|
backed up six times, and `tank-space-audit.md` already has them down for deletion — decide
|
||||||
|
which, if any, are wanted before anything is restored.
|
||||||
|
|
||||||
|
## Build plan
|
||||||
|
|
||||||
|
| Phase | What | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| **1 — detection, Install, the tab** | `internal/incus` (detection, CLI wrapper, JSON types, `incus monitor` for live state, the preseed builder and subnet picker), `internal/api/virt.go` (routes below), `Virtualization.tsx` (the three page states; list, detail, create dialog, snapshot panel), rail entry `virt`, terminal integration, sensors, and the Library's **Images** shelf (browse and download from `images:`, local store, import, publish). **Tested by clicking Install on `grid` first, then `core`** — two different pool drivers, both with Docker present. Then the Mac for the arm64 path. `gate` for the unavailable state. | 2–3 sessions |
|
||||||
|
| **2 — operations (done 2026-10-09, except the LAN bridge)** | The Library's **ISO** shelf (directory, upload/URL/path, attach, New VM from ISO) **with the VGA console** (`spice-html5`), snapshot schedules UI, nightly exports with retention and the per-machine export directory, limits editor, bind mounts, the LAN-bridge step, instance alert default, cloud-init presets. | 1–2 sessions |
|
||||||
|
| **3 — the rest** | Proxmox import (archives become Library images), GPU device for containers, Windows via `distrobuilder repack-windows`, GPU passthrough to a VM with its warning. | as wanted |
|
||||||
|
|
||||||
|
### API sketch
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/virt {state: ready|installable|unavailable, reason, kvm, arch,
|
||||||
|
pool_drivers[], pools[], networks[], instances[], free_memory}
|
||||||
|
POST /api/virt/install {driver, size, path, subnet, docker_rules, arc_max?} task
|
||||||
|
GET /api/virt/images?remote=images&q= local cache + remote search, filtered to this arch
|
||||||
|
DELETE /api/virt/images/{fingerprint}
|
||||||
|
POST /api/virt/instances {name, type, image, cpu, memory, disk, network, cloudinit, profile, start} task
|
||||||
|
GET /api/virt/instances/{name} config, state, devices, snapshots
|
||||||
|
PATCH /api/virt/instances/{name} limits, description, devices
|
||||||
|
POST /api/virt/instances/{name}/{start|stop|restart|kill|delete|rename|publish|export}
|
||||||
|
POST /api/virt/instances/{name}/console → terminal {id} (incus console)
|
||||||
|
POST /api/virt/instances/{name}/exec → terminal {id} (incus exec -- sh -lc …)
|
||||||
|
POST /api/virt/instances/{name}/snapshots {name}; POST …/{snap}/restore; DELETE …/{snap}
|
||||||
|
PUT /api/virt/instances/{name}/schedule {cron, expiry}
|
||||||
|
POST /api/virt/import {path} task (export tarball, or phase-3 Proxmox archive)
|
||||||
|
|
||||||
|
GET /api/virt/isos virt/isos/, scanned: name, size, attached-to
|
||||||
|
POST /api/virt/isos/import {path} task: copy a file on this machine into virt/isos/
|
||||||
|
POST /api/virt/isos/fetch {url, sha256?} task; uploads reuse PUT /api/files/upload
|
||||||
|
DELETE /api/virt/isos/{name} refused while attached
|
||||||
|
POST /api/virt/instances/{name}/iso {iso, boot_first} attach; DELETE …/iso detach
|
||||||
|
POST /api/virt/instances/from-iso {name, iso, cpu, memory, disk, network} task → console
|
||||||
|
GET /api/virt/images/remote?q=&type= the `images:` catalogue for this arch
|
||||||
|
POST /api/virt/images/download {alias} task (incus image copy images:… local:)
|
||||||
|
POST /api/virt/images/import {path | url, alias, type} task: copy into virt/images/, then load into Incus
|
||||||
|
GET /api/virt/instances/{name}/vga websocket: Incus VGA console → spice-html5 (phase 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything is per machine and reaches a node through the existing `/api/nodes/{name}/…`
|
||||||
|
proxy with no hub-side code; consoles attach over the existing `/api/terminals/{id}/attach`.
|
||||||
|
Tasks stream through `/api/tasks/{id}/stream` as Docker's do.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Incus on Debian: https://wiki.debian.org/Incus
|
||||||
|
- Incus and Docker on one host (the `FORWARD` drop): https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/
|
||||||
|
- Bridge options and `ipv4.address: auto`: https://linuxcontainers.org/incus/docs/main/reference/network_bridge/
|
||||||
|
- Consoles (text and VGA): https://linuxcontainers.org/incus/docs/main/howto/instances_console/
|
||||||
|
- ZFS storage driver: https://linuxcontainers.org/incus/docs/main/reference/storage_zfs/
|
||||||
|
- macvlan's host-isolation: https://linuxcontainers.org/incus/docs/main/reference/network_macvlan/
|
||||||
|
- Image format for the Proxmox import: https://linuxcontainers.org/incus/docs/main/reference/image_format/
|
||||||
+81
-2
@@ -30,10 +30,11 @@ import (
|
|||||||
"hamlab/internal/config"
|
"hamlab/internal/config"
|
||||||
"hamlab/internal/cron"
|
"hamlab/internal/cron"
|
||||||
"hamlab/internal/files"
|
"hamlab/internal/files"
|
||||||
|
"hamlab/internal/incus"
|
||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
"hamlab/internal/nodes"
|
"hamlab/internal/nodes"
|
||||||
"hamlab/internal/services"
|
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
|
"hamlab/internal/services"
|
||||||
"hamlab/internal/setup"
|
"hamlab/internal/setup"
|
||||||
"hamlab/internal/space"
|
"hamlab/internal/space"
|
||||||
"hamlab/internal/storage"
|
"hamlab/internal/storage"
|
||||||
@@ -62,6 +63,8 @@ func main() {
|
|||||||
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)
|
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
|
||||||
|
virt-export [name…] export Incus instances to the library (what its cron line calls)
|
||||||
|
docker-update <id> run one image-update schedule (what its cron line calls)
|
||||||
user-rm <user> remove an account and sign it out everywhere
|
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
|
||||||
@@ -81,6 +84,10 @@ 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 "cpu-energy": // privileged helper for the CPU power reading: the packages' energy counters (run as root by the daemon)
|
||||||
|
os.Exit(metrics.CPUEnergyHelper(os.Stdin, os.Stdout))
|
||||||
case "install", "uninstall":
|
case "install", "uninstall":
|
||||||
var err error
|
var err error
|
||||||
if cmd == "install" {
|
if cmd == "install" {
|
||||||
@@ -132,10 +139,18 @@ 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":
|
case "zfs-snapshot":
|
||||||
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
|
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
case "virt-export": // the nightly cron line from the Virtualization tab; optional instance names
|
||||||
|
if err := virtExport(cfg, flag.Args()[1:]); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
case "users":
|
case "users":
|
||||||
migrateUsers(cfg)
|
migrateUsers(cfg)
|
||||||
if len(cfg.Users) == 0 {
|
if len(cfg.Users) == 0 {
|
||||||
@@ -146,7 +161,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 {
|
||||||
@@ -185,6 +207,48 @@ 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
|
// 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
|
// 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.
|
// not the console is up — and keeps running if Ham Lab is never opened again.
|
||||||
@@ -237,6 +301,21 @@ func zfsSnapshot(cfg *config.Config, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// virtExport runs the instance exports the Virtualization tab scheduled, as the account Ham
|
||||||
|
// Lab runs as (which is in incus-admin), and records the run beside the data.
|
||||||
|
func virtExport(cfg *config.Config, names []string) error {
|
||||||
|
who := os.Getenv("USER")
|
||||||
|
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||||
|
who = u.Username
|
||||||
|
}
|
||||||
|
run := incus.New(cfg.DataDir(), who, version).RunExports(context.Background(), names, func(l string) { fmt.Println(l) })
|
||||||
|
fmt.Printf("exported %d, pruned %d, %.0f s\n", len(run.Exported), len(run.Pruned), run.Seconds)
|
||||||
|
if len(run.Errors) > 0 {
|
||||||
|
return errors.New(strings.Join(run.Errors, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func migrateUsers(cfg *config.Config) {
|
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 != "" {
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ type alertSaved struct {
|
|||||||
Channels []AlertChannel `json:"channels"`
|
Channels []AlertChannel `json:"channels"`
|
||||||
Events []AlertEvent `json:"events"`
|
Events []AlertEvent `json:"events"`
|
||||||
Seeded bool `json:"seeded"`
|
Seeded bool `json:"seeded"`
|
||||||
|
// SawInstanceRule marks that the instance-down default has been offered once; set so that
|
||||||
|
// removing it stays removed.
|
||||||
|
SawInstanceRule bool `json:"saw_instance_rule,omitempty"`
|
||||||
// Firing is what was firing when the hub last looked, so a restart (every deploy is one)
|
// Firing is what was firing when the hub last looked, so a restart (every deploy is one)
|
||||||
// carries on where it was and doesn't announce the same alerts all over again.
|
// carries on where it was and doesn't announce the same alerts all over again.
|
||||||
Firing map[string]alertKept `json:"firing,omitempty"`
|
Firing map[string]alertKept `json:"firing,omitempty"`
|
||||||
@@ -102,6 +105,7 @@ var defaultRules = []AlertRule{
|
|||||||
{ID: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true},
|
{ID: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true},
|
||||||
{ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
{ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||||
{ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true},
|
{ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true},
|
||||||
|
{ID: "instance-down", Name: "VM or container down", Sensor: "instance.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||||
{ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true},
|
{ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||||
{ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
|
{ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||||
{ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true},
|
{ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true},
|
||||||
@@ -122,6 +126,23 @@ func newAlertEngine(s *Server) *alertEngine {
|
|||||||
for key, k := range e.saved.Firing {
|
for key, k := range e.saved.Firing {
|
||||||
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
|
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
|
||||||
}
|
}
|
||||||
|
// a default added after an install was seeded joins its rules once; removing it afterwards sticks
|
||||||
|
if e.saved.Seeded && !e.saved.SawInstanceRule {
|
||||||
|
has := false
|
||||||
|
for _, r := range e.saved.Rules {
|
||||||
|
if r.ID == "instance-down" {
|
||||||
|
has = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
for _, d := range defaultRules {
|
||||||
|
if d.ID == "instance-down" {
|
||||||
|
e.saved.Rules = append(e.saved.Rules, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.saved.SawInstanceRule = true
|
||||||
|
}
|
||||||
for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
|
for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
|
||||||
if r.ID == "container-down" && r.Sensor == "container.*.running" {
|
if r.ID == "container-down" && r.Sensor == "container.*.running" {
|
||||||
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
|
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
|
||||||
|
|||||||
+245
-10
@@ -3,8 +3,8 @@ package api
|
|||||||
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
|
// 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,357 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Scheduled image updates.
|
||||||
|
//
|
||||||
|
// Dockhand kept three containers up to date nightly — unbound, nginx and pihole, which is
|
||||||
|
// DNS and ingress — and that is the one thing it does that Ham Lab did not. This replaces
|
||||||
|
// it, and deliberately in the same shape as the ZFS snapshot schedules: a real line in a
|
||||||
|
// cron file Ham Lab owns, calling the binary, rather than a ticker inside the daemon.
|
||||||
|
// Updating an image is not something that should stop happening because a web console is
|
||||||
|
// down, and a cron line is visible to anyone looking at the machine the normal way.
|
||||||
|
//
|
||||||
|
// A schedule stores everything the run needs — project, compose files, how compose has to
|
||||||
|
// be invoked for that stack — so the cron job is self-contained and does not have to
|
||||||
|
// rediscover the world before it can act.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
|
"hamlab/internal/cron"
|
||||||
|
"hamlab/internal/storage"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateJob is a standing instruction to pull a stack's images, and optionally deploy them.
|
||||||
|
type UpdateJob struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Stack string `json:"stack"`
|
||||||
|
Machine string `json:"machine,omitempty"` // for display; the job runs where it was made
|
||||||
|
// Services narrows it to some of the stack's services; empty is all of them.
|
||||||
|
Services []string `json:"services,omitempty"`
|
||||||
|
Cron string `json:"cron"`
|
||||||
|
// Apply says whether a newer image is deployed or only fetched. Check-only still pulls,
|
||||||
|
// so the Docker tab shows what is behind without a registry round trip.
|
||||||
|
Apply bool `json:"apply"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// How to run compose for this stack, captured when the schedule was made.
|
||||||
|
Spec composeSpec `json:"spec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type composeSpec struct {
|
||||||
|
Project string `json:"project"`
|
||||||
|
Files []string `json:"files"`
|
||||||
|
Via string `json:"via,omitempty"`
|
||||||
|
// Remote is read but never written: schedules made before v0.2 could point compose at
|
||||||
|
// another machine's Docker over tcp. Such a job is refused rather than run against this
|
||||||
|
// machine's Docker, which is where it would land without the variable.
|
||||||
|
Remote string `json:"remote,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c composeSpec) toSpec(tool string) compose.Spec {
|
||||||
|
return compose.Spec{Project: c.Project, Files: c.Files, Via: c.Via, Tool: tool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dockerCronFile = "/etc/cron.d/hamlab-docker"
|
||||||
|
|
||||||
|
type dockerSchedState struct {
|
||||||
|
Jobs []UpdateJob `json:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsLoad() dockerSchedState {
|
||||||
|
var raw json.RawMessage
|
||||||
|
s.store.View(func(st *store.State) { raw = st.DockerJobs })
|
||||||
|
var d dockerSchedState
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &d)
|
||||||
|
}
|
||||||
|
if d.Jobs == nil {
|
||||||
|
d.Jobs = []UpdateJob{}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsSave(d dockerSchedState) error {
|
||||||
|
b, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.Update(func(st *store.State) { st.DockerJobs = b })
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsCronText renders the schedules as cron lines. Same shape as the ZFS file: Ham Lab owns
|
||||||
|
// it whole and rewrites it on every change.
|
||||||
|
func dsCronText(jobs []UpdateJob, user, exe, config string) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# Ham Lab image-update schedules — written by the Docker tab, rewritten whole on every change.\n")
|
||||||
|
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
|
||||||
|
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||||
|
for i := range jobs {
|
||||||
|
j := &jobs[i]
|
||||||
|
if !j.Enabled {
|
||||||
|
b.WriteString("# " + j.Name + " — paused\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expr := strings.Join(strings.Fields(j.Cron), " ")
|
||||||
|
if err := cron.ValidSchedule(expr); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
what := "pull only"
|
||||||
|
if j.Apply {
|
||||||
|
what = "pull and deploy"
|
||||||
|
}
|
||||||
|
b.WriteString("# " + j.Name + " — " + j.Stack + ", " + what + "\n")
|
||||||
|
b.WriteString(expr + " " + user + " " + exe + " -config " + config + " docker-update " + j.ID + "\n\n")
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsSyncCron(ctx context.Context, d dockerSchedState) error {
|
||||||
|
live := false
|
||||||
|
for i := range d.Jobs {
|
||||||
|
if d.Jobs[i].Enabled {
|
||||||
|
live = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !live {
|
||||||
|
return s.storage.RemoveRootFile(ctx, dockerCronFile)
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exe, _ = filepath.EvalSymlinks(exe)
|
||||||
|
text, err := dsCronText(d.Jobs, s.user, exe, s.cfg.Path())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.storage.WriteRootFile(ctx, dockerCronFile, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoint keeps a schedule working when what deployed its stack has been removed.
|
||||||
|
//
|
||||||
|
// A schedule captures how to invoke compose at the moment it is made, so the cron job never
|
||||||
|
// has to rediscover the world at 3am. But "run compose inside this container" stops being
|
||||||
|
// true the day that container is deleted — which is exactly what happens when an
|
||||||
|
// orchestrator is retired, and would otherwise break every schedule made before it went.
|
||||||
|
// So: if the container named is not running, the files are looked up again, through live
|
||||||
|
// mounts and remembered ones both, and compose runs on the host instead.
|
||||||
|
func repoint(ctx context.Context, db *store.Store, spec compose.Spec) compose.Spec {
|
||||||
|
if spec.Via == "" || db == nil {
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
if out, err := dockerRun(ctx, "inspect", "-f", "{{.State.Running}}", spec.Via); err == nil && strings.TrimSpace(string(out)) == "true" {
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
st := stack{Name: spec.Project, File: spec.Files[0], Files: spec.Files}
|
||||||
|
st.resolve(mountsSeen(ctx, db)) // read-only: this also runs from cron, where the daemon owns state.json
|
||||||
|
if !st.Editable || st.Via != "" {
|
||||||
|
return spec // still only reachable through a container, or not reachable at all
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %s: %s is gone, running compose here against %s", spec.Project, spec.Via, st.HostFiles[0])
|
||||||
|
spec.Files, spec.Via = st.HostFiles, ""
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUpdate pulls, and deploys when the schedule says to. Shared with the CLI so the button
|
||||||
|
// and the cron line cannot drift apart.
|
||||||
|
func RunUpdate(ctx context.Context, db *store.Store, j UpdateJob, tool string) (string, error) {
|
||||||
|
if len(j.Spec.Files) == 0 {
|
||||||
|
return "", errors.New("this schedule has no compose file recorded — open it in the Docker tab and save it again")
|
||||||
|
}
|
||||||
|
if j.Spec.Remote != "" {
|
||||||
|
return "", errors.New("this schedule was made to deploy to another machine over tcp, which this console no longer does — make it again on that machine")
|
||||||
|
}
|
||||||
|
spec := repoint(ctx, db, j.Spec.toSpec(tool))
|
||||||
|
var log strings.Builder
|
||||||
|
run := func(args ...string) error {
|
||||||
|
cmd := compose.Cmd(ctx, spec, args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
log.Write(out)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
args := append([]string{"pull"}, j.Services...)
|
||||||
|
if err := run(args...); err != nil {
|
||||||
|
return log.String(), err
|
||||||
|
}
|
||||||
|
if !j.Apply {
|
||||||
|
return log.String(), nil
|
||||||
|
}
|
||||||
|
up := append([]string{"up", "-d"}, j.Services...)
|
||||||
|
if err := run(up...); err != nil {
|
||||||
|
return log.String(), err
|
||||||
|
}
|
||||||
|
return log.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- endpoints ----
|
||||||
|
|
||||||
|
func (s *Server) dsList(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
d := s.dsLoad()
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]map[string]any, 0, len(d.Jobs))
|
||||||
|
for _, j := range d.Jobs {
|
||||||
|
m := map[string]any{"job": j, "last": storage.ReadRun(s.cfg.DataDir(), "docker-"+j.ID)}
|
||||||
|
if j.Enabled {
|
||||||
|
if n, ok := cron.NextRun(strings.Join(strings.Fields(j.Cron), " "), now); ok {
|
||||||
|
m["next"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{"jobs": out, "file": dockerCronFile})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var j UpdateJob
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&j); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.Cron = strings.Join(strings.Fields(j.Cron), " ")
|
||||||
|
if err := cron.ValidSchedule(j.Cron); err != nil {
|
||||||
|
jsonErr(w, 400, "schedule: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(j.Stack) == "" {
|
||||||
|
jsonErr(w, 400, "which stack?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// resolve the stack now, so the cron job never has to
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
stacks, err := s.findStacks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, st := range stacks {
|
||||||
|
if st.Name == j.Stack {
|
||||||
|
// seenFiles is the list as compose itself sees it: host paths normally, the
|
||||||
|
// orchestrator container's own paths when compose runs inside one. Getting this
|
||||||
|
// backwards gives a cron line that works from the console and fails at 3am.
|
||||||
|
j.Spec = composeSpec{Project: st.Name, Files: seenFiles(st, -1, ""), Via: st.Via}
|
||||||
|
if len(j.Spec.Files) == 0 {
|
||||||
|
jsonErr(w, 409, "that stack's compose file isn't anywhere this console can reach")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no stack called "+j.Stack+" on this machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(j.Name) == "" {
|
||||||
|
j.Name = j.Stack
|
||||||
|
}
|
||||||
|
d := s.dsLoad()
|
||||||
|
replaced := false
|
||||||
|
for i := range d.Jobs {
|
||||||
|
if d.Jobs[i].ID == j.ID && j.ID != "" {
|
||||||
|
d.Jobs[i] = j
|
||||||
|
replaced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.ID = hex.EncodeToString(b)
|
||||||
|
d.Jobs = append(d.Jobs, j)
|
||||||
|
}
|
||||||
|
if err := s.dsSave(d); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.dsSyncCron(r.Context(), d); err != nil {
|
||||||
|
jsonErr(w, 500, "saved, but writing "+dockerCronFile+" failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %q saved by %s (%s, %q, %s)", j.Name, clientIP(r), j.Stack, j.Cron, map[bool]string{true: "pull and deploy", false: "pull only"}[j.Apply])
|
||||||
|
jsonOK(w, 200, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
d := s.dsLoad()
|
||||||
|
kept := d.Jobs[:0]
|
||||||
|
found := false
|
||||||
|
for _, j := range d.Jobs {
|
||||||
|
if j.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, j)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Jobs = kept
|
||||||
|
if err := s.dsSave(d); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.dsSyncCron(r.Context(), d); err != nil {
|
||||||
|
jsonErr(w, 500, "removed, but writing "+dockerCronFile+" failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storage.ForgetRun(s.cfg.DataDir(), "docker-"+id)
|
||||||
|
log.Printf("docker schedule %s removed by %s", id, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
var job UpdateJob
|
||||||
|
found := false
|
||||||
|
for _, j := range s.dsLoad().Jobs {
|
||||||
|
if j.ID == id {
|
||||||
|
job, found = j, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Outliving the request: a pull the browser gave up on still finishes, but not forever.
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
out, err := RunUpdate(ctx, s.store, job, dockerTool())
|
||||||
|
rec := storage.RunRecord{At: time.Now(), Taken: job.Stack}
|
||||||
|
if err != nil {
|
||||||
|
rec.Error = err.Error()
|
||||||
|
}
|
||||||
|
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
|
||||||
|
stackUpdatesForget("/" + job.Stack)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %q run by %s", job.Name, clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"output": tail(out, 4000)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Guest logins: a temporary account for handing to somebody you want to show around. It is
|
||||||
|
// an ordinary account with a clock on it — every tab, every action, on this machine and on
|
||||||
|
// every machine this hub manages — that stops working at its expiry (half an hour by
|
||||||
|
// default) or the moment it is revoked.
|
||||||
|
//
|
||||||
|
// Deliberately not a reduced account: Mitchell, 2026-09-27, replacing a read-only version
|
||||||
|
// built the same day. He gives these to people he trusts and revokes them otherwise, and a
|
||||||
|
// console with half its buttons missing is not the console he wanted to show off. What that
|
||||||
|
// means in practice: a guest has the Terminal tab, which is a shell as the user the daemon
|
||||||
|
// runs as — root on every host here — and can make an account or a token of its own that
|
||||||
|
// outlives the guest login.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/auth"
|
||||||
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
guestPrefix = "guest-"
|
||||||
|
guestTTLDefault = 30 * time.Minute
|
||||||
|
guestTTLMax = 24 * time.Hour
|
||||||
|
guestReapEvery = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- the accounts themselves ----
|
||||||
|
|
||||||
|
func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
|
||||||
|
now := time.Now()
|
||||||
|
counts := s.sessions.Count()
|
||||||
|
out := []map[string]any{}
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if !u.Guest || u.Expired(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"name": u.Name, "created": u.Created, "expires": u.Expires,
|
||||||
|
"issued_by": u.IssuedBy, "sessions": counts[u.Name], "used": !u.LastSeen.IsZero(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jsonOK(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addGuest makes a guest login and hands back its password — the only time it is ever
|
||||||
|
// readable, here as everywhere else a password is generated.
|
||||||
|
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := actorOf(r)
|
||||||
|
var body struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||||
|
ttl := guestTTLDefault
|
||||||
|
if body.Minutes > 0 {
|
||||||
|
ttl = time.Duration(body.Minutes) * time.Minute
|
||||||
|
}
|
||||||
|
if ttl > guestTTLMax {
|
||||||
|
jsonErr(w, 400, "a guest login lasts at most 24 hours")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pw, err := auth.GuestPassword()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(pw)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
for try := 0; try < 8 && name == ""; try++ {
|
||||||
|
suffix, err := auth.GuestSuffix()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidate := guestPrefix + suffix
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
if _, taken := c.User(candidate); !taken {
|
||||||
|
name = candidate
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
jsonErr(w, 500, "could not find a free guest name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expires := now.Add(ttl)
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
c.Users = append(c.Users, config.User{Name: name, Hash: hash, Created: now, Guest: true, Expires: expires, IssuedBy: me.User})
|
||||||
|
}); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("guest login %q issued until %s (by %s)", name, expires.Format(time.RFC3339), clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]any{"name": name, "password": pw, "expires": expires, "minutes": int(ttl.Minutes())})
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeGuest ends a guest login now. No password: it only ever takes access away, and the
|
||||||
|
// whole point of the thing is that ending it is one tap.
|
||||||
|
func (s *Server) revokeGuest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
found := false
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
kept := c.Users[:0]
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if u.Name == name && u.Guest {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, u)
|
||||||
|
}
|
||||||
|
c.Users = kept
|
||||||
|
}); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such guest login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.forgetGuest(name)
|
||||||
|
log.Printf("guest login %q revoked (by %s)", name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetGuest signs a guest out everywhere and drops the look they left behind.
|
||||||
|
func (s *Server) forgetGuest(name string) {
|
||||||
|
s.sessions.RevokeUser(name)
|
||||||
|
_ = s.store.Update(func(st *store.State) { delete(st.Users, name) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchGuests clears out guest logins whose time is up. Expiry is enforced on every request
|
||||||
|
// as well — this is what keeps the config file from collecting them, and what signs a guest
|
||||||
|
// out of a console they left open rather than waiting for their next click.
|
||||||
|
func (s *Server) watchGuests() {
|
||||||
|
for {
|
||||||
|
s.reapGuests()
|
||||||
|
time.Sleep(guestReapEvery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) reapGuests() {
|
||||||
|
now := time.Now()
|
||||||
|
var gone []string
|
||||||
|
var have bool
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if u.Guest {
|
||||||
|
have = true
|
||||||
|
if u.Expired(now) {
|
||||||
|
gone = append(gone, u.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !have || len(gone) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expired := map[string]bool{}
|
||||||
|
for _, n := range gone {
|
||||||
|
expired[n] = true
|
||||||
|
}
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
kept := c.Users[:0]
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if expired[u.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, u)
|
||||||
|
}
|
||||||
|
c.Users = kept
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("guests: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, n := range gone {
|
||||||
|
s.forgetGuest(n)
|
||||||
|
}
|
||||||
|
log.Printf("guest login(s) expired: %s", strings.Join(gone, ", "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/auth"
|
||||||
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A guest login is an ordinary account with a clock on it, so the clock is the thing worth
|
||||||
|
// testing: one whose time is up is stopped at the door, whether or not the minute-by-minute
|
||||||
|
// sweep has reached their account yet, and their session is dropped on the way — so the
|
||||||
|
// console they left open goes back to the login form rather than carrying on.
|
||||||
|
func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
expires time.Time
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"still has time", time.Now().Add(10 * time.Minute), true},
|
||||||
|
{"time is up", time.Now().Add(-time.Second), false},
|
||||||
|
{"no expiry at all", time.Time{}, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Default(filepath.Join(dir, "config.json"))
|
||||||
|
if err := cfg.Update(func(c *config.Config) {
|
||||||
|
c.Users = []config.User{{Name: "guest-abcd", Hash: "x", Guest: true, Expires: tc.expires}}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(dir, "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)}
|
||||||
|
tok, err := s.sessions.Create("guest-abcd", "10.0.0.5", "curl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("GET", "/api/me", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a, ok, answered := s.cookieActor(w, r)
|
||||||
|
if ok != tc.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||||
|
}
|
||||||
|
if tc.wantOK {
|
||||||
|
if !a.Guest || a.User != "guest-abcd" {
|
||||||
|
t.Errorf("actor = %+v, want the guest account marked as one", a)
|
||||||
|
}
|
||||||
|
if answered {
|
||||||
|
t.Error("answered the caller for a guest that still has time")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !answered || w.Code != 401 {
|
||||||
|
t.Errorf("answered = %v, code = %d, want true and 401", answered, w.Code)
|
||||||
|
}
|
||||||
|
if _, live := s.sessions.Lookup(tok, "10.0.0.5"); live {
|
||||||
|
t.Error("the session of an expired guest is still live")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// The Hardware tab: what the machine is made of. Read once and remembered — none of it
|
||||||
|
// changes while the kernel runs — with ?refresh=1 for the one thing that can, a drive
|
||||||
|
// plugged in or pulled out.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) hardwareReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jsonOK(w, 200, s.hardware.Report(r.Context(), r.URL.Query().Get("refresh") == "1"))
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlab/internal/config"
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const kvmPrefix = "/kvm/"
|
const kvmPrefix = "/kvm/"
|
||||||
@@ -82,9 +83,25 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
|||||||
}
|
}
|
||||||
r.URL.RawPath = ""
|
r.URL.RawPath = ""
|
||||||
r.Host = target.Host
|
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.
|
// The device gets its own host and none of Ham Lab's session.
|
||||||
r.Header.Del("Origin")
|
r.Header.Del("Origin")
|
||||||
stripCookie(r, cookieName, kvmCookie)
|
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
|
FlushInterval: -1, // a video stream must not be buffered
|
||||||
ModifyResponse: func(resp *http.Response) error {
|
ModifyResponse: func(resp *http.Response) error {
|
||||||
@@ -94,6 +111,24 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
|||||||
for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} {
|
for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} {
|
||||||
resp.Header.Del(h)
|
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.
|
// A redirect to the device's own root has to come back through the prefix.
|
||||||
if loc := resp.Header.Get("Location"); loc != "" {
|
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) {
|
if u, err := url.Parse(loc); err == nil && (u.Host == "" || u.Host == target.Host) && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, prefix) {
|
||||||
@@ -114,6 +149,93 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
|||||||
return p, nil
|
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
|
// 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.
|
// a device and the device never sees which KVM the browser is looking at.
|
||||||
func stripCookie(r *http.Request, names ...string) {
|
func stripCookie(r *http.Request, names ...string) {
|
||||||
@@ -157,6 +279,7 @@ func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode})
|
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)
|
p.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +428,7 @@ func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.forgetKVMProxy(id)
|
s.forgetKVMProxy(id)
|
||||||
|
s.forgetKVMCookies(id)
|
||||||
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
||||||
w.WriteHeader(204)
|
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 }
|
||||||
+135
-36
@@ -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,9 +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
|
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
|
||||||
@@ -86,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"
|
||||||
@@ -99,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.
|
||||||
@@ -137,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)
|
||||||
@@ -185,27 +208,57 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
|
m.HandleFunc("POST /api/docker/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))
|
||||||
// The same, asked of a node through this hub: the hub answers for the stacks whose files
|
m.HandleFunc("GET /api/docker/updates", s.auth(s.machineUpdates))
|
||||||
// it holds (deployed to the node over tcp) and passes everything else to the node.
|
m.HandleFunc("GET /api/docker/schedules", s.auth(s.dsList))
|
||||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
m.HandleFunc("PUT /api/docker/schedules", s.auth(s.dsPut))
|
||||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks", s.auth(s.stackCreate))
|
m.HandleFunc("DELETE /api/docker/schedules/{id}", s.auth(s.dsDelete))
|
||||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/homes", s.auth(s.stackHomes))
|
m.HandleFunc("POST /api/docker/schedules/{id}/run", s.auth(s.dsRun))
|
||||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/delete", s.auth(s.stackDelete))
|
// Asked of a node through this hub, every one of these is the node's to answer: the
|
||||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackFile))
|
// generic /api/nodes/{name}/{rest...} proxy above carries them there unchanged.
|
||||||
m.HandleFunc("PUT /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackSave))
|
|
||||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
|
||||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
|
||||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
|
||||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}", s.auth(s.taskOnHub(s.getTask)))
|
|
||||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
|
||||||
m.HandleFunc("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/from-iso", s.auth(s.virtFromISO))
|
||||||
|
m.HandleFunc("GET /api/virt/instances/{name}/vga", s.auth(s.virtVGA))
|
||||||
|
m.HandleFunc("POST /api/virt/instances/{name}/iso", s.auth(s.virtISOAttach))
|
||||||
|
m.HandleFunc("POST /api/virt/instances/{name}/iso/detach", s.auth(s.virtISODetach))
|
||||||
|
m.HandleFunc("POST /api/virt/instances/{name}/mounts", s.auth(s.virtMountAdd))
|
||||||
|
m.HandleFunc("POST /api/virt/instances/{name}/mounts/remove", s.auth(s.virtMountRemove))
|
||||||
|
m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction))
|
||||||
|
m.HandleFunc("POST /api/virt/isos/fetch", s.auth(s.virtISOFetch))
|
||||||
|
m.HandleFunc("POST /api/virt/isos/import", s.auth(s.virtISOImport))
|
||||||
|
m.HandleFunc("POST /api/virt/isos/delete", s.auth(s.virtISODelete))
|
||||||
|
m.HandleFunc("POST /api/virt/isos/rename", s.auth(s.virtISORename))
|
||||||
|
m.HandleFunc("GET /api/virt/exports", s.auth(s.virtExports))
|
||||||
|
m.HandleFunc("PUT /api/virt/exports", s.auth(s.virtExportsPut))
|
||||||
|
m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun))
|
||||||
|
m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete))
|
||||||
|
m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys))
|
||||||
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
|
m.HandleFunc("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))
|
||||||
@@ -246,6 +299,15 @@ 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))
|
||||||
@@ -253,6 +315,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
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", 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("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut))
|
||||||
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete))
|
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/schedules/{id}/run", s.auth(s.zfsRunNow))
|
||||||
@@ -271,9 +334,13 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/users", s.auth(s.addUser))
|
m.HandleFunc("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).
|
||||||
@@ -300,8 +367,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
jsonErr(w, 404, "not found")
|
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.
|
||||||
@@ -387,14 +453,45 @@ func (s *Server) signedIn(r *http.Request) bool {
|
|||||||
return ok
|
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
|
||||||
@@ -428,11 +525,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
|
||||||
@@ -851,11 +948,7 @@ func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
|
||||||
node := r.PathValue("node") // routes that also carry a {name} of their own call the machine {node}
|
p, ok := s.nodes.Proxy(r.PathValue("name"))
|
||||||
if node == "" {
|
|
||||||
node = r.PathValue("name")
|
|
||||||
}
|
|
||||||
p, ok := s.nodes.Proxy(node)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
jsonErr(w, 404, "no such machine")
|
jsonErr(w, 404, "no such machine")
|
||||||
return
|
return
|
||||||
@@ -901,11 +994,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
// name is allowed only while there is exactly one account — what a client from before
|
// 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
|
||||||
@@ -1040,8 +1133,14 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
addr = h // the address this console listens on, for the machine menu
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+263
-300
@@ -12,11 +12,16 @@ 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.
|
||||||
//
|
//
|
||||||
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
|
// Those mounts are also written down. An orchestrator can be removed, and when it is, the
|
||||||
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
|
// containers it left behind still carry labels naming paths inside a filesystem that no
|
||||||
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
// longer exists; without a record of where that filesystem was mounted, every stack on the
|
||||||
// in what the node couldn't (hubStacks): it resolves the files on itself and runs compose
|
// machine would become unfindable at once. A remembered mount resolves with no container to
|
||||||
// here with DOCKER_HOST pointed at the node.
|
// run in, which is right: compose runs on the host against the host's copy of the file.
|
||||||
|
//
|
||||||
|
// A stack is one machine's affair: its files and the Docker that runs it are on the same
|
||||||
|
// box, and that box's Ham Lab is the only thing that touches either. Asked about a node's
|
||||||
|
// stacks, a hub passes the request to the node's own Ham Lab and relays the answer; it holds
|
||||||
|
// no files for anyone else and never points compose at another machine's Docker.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -29,7 +34,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -39,7 +43,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
|
"hamlab/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stack struct {
|
type stack struct {
|
||||||
@@ -52,8 +58,6 @@ type stack struct {
|
|||||||
Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b
|
Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b
|
||||||
HostFiles []string `json:"host_files"` // the same, on the machine that holds them
|
HostFiles []string `json:"host_files"` // the same, on the machine that holds them
|
||||||
Via string `json:"via"` // container compose runs inside; "" = on the host
|
Via string `json:"via"` // container compose runs inside; "" = on the host
|
||||||
Remote string `json:"remote"` // DOCKER_HOST compose is pointed at; "" = this machine's Docker
|
|
||||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
|
||||||
Editable bool `json:"editable"`
|
Editable bool `json:"editable"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
HasEnv bool `json:"has_env"`
|
HasEnv bool `json:"has_env"`
|
||||||
@@ -89,6 +93,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 +270,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)
|
||||||
@@ -180,7 +278,7 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
|||||||
st.resolve(maps)
|
st.resolve(maps)
|
||||||
stacks = append(stacks, *st)
|
stacks = append(stacks, *st)
|
||||||
}
|
}
|
||||||
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks, true), stacks)...)
|
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks), stacks)...)
|
||||||
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
|
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
|
||||||
return stacks, nil
|
return stacks, nil
|
||||||
}
|
}
|
||||||
@@ -188,19 +286,23 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
|||||||
// ---- where stacks live ----
|
// ---- where stacks live ----
|
||||||
|
|
||||||
// A home is a directory of stacks, one folder each: where new ones are made, and where ones
|
// A home is a directory of stacks, one folder each: where new ones are made, and where ones
|
||||||
// that aren't deployed (never were, or were taken down) are still found. Homes aren't
|
// that aren't deployed (never were, or were taken down) are still found. The default is
|
||||||
// configured; they are wherever this machine's stacks already are. With none at all, ~/stacks.
|
// Ham Lab's own: <data dir>/stacks, which is where everything this console manages lives.
|
||||||
|
// Any folder this machine already keeps stacks in — two or more deployed from it — is
|
||||||
|
// offered as well, so what was deployed before Ham Lab arrived stays editable and gains
|
||||||
|
// neighbours where it is, but it is never the default.
|
||||||
type stackHome struct {
|
type stackHome struct {
|
||||||
Dir string `json:"dir"` // on the machine that holds the files
|
Dir string `json:"dir"` // on this machine
|
||||||
Inner string `json:"inner"` // the same directory as compose sees it (inside Via); Dir otherwise
|
Inner string `json:"inner"` // the same directory as compose sees it (inside Via); Dir otherwise
|
||||||
Via string `json:"via"` // container compose runs inside
|
Via string `json:"via"` // container compose runs inside
|
||||||
Remote string `json:"remote"` // DOCKER_HOST, when the containers run on another machine
|
|
||||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
|
||||||
Stacks int `json:"stacks"`
|
Stacks int `json:"stacks"`
|
||||||
Default bool `json:"default"`
|
Default bool `json:"default"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
// stacksDir is the home Ham Lab owns.
|
||||||
|
func (s *Server) stacksDir() string { return filepath.Join(s.cfg.DataDir(), "stacks") }
|
||||||
|
|
||||||
|
func (s *Server) homesOf(stacks []stack) []stackHome {
|
||||||
type tally struct {
|
type tally struct {
|
||||||
home stackHome
|
home stackHome
|
||||||
deployed int
|
deployed int
|
||||||
@@ -216,11 +318,10 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
|||||||
if strings.Count(dir, "/") < 2 { // "/" or "/tmp": not somewhere to make folders in
|
if strings.Count(dir, "/") < 2 { // "/" or "/tmp": not somewhere to make folders in
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := dir + "|" + st.Remote
|
t := by[dir]
|
||||||
t := by[key]
|
|
||||||
if t == nil {
|
if t == nil {
|
||||||
t = &tally{home: stackHome{Dir: dir, Remote: st.Remote, HeldBy: st.HeldBy}, byVia: map[string]int{}, inner: map[string]string{}}
|
t = &tally{home: stackHome{Dir: dir}, byVia: map[string]int{}, inner: map[string]string{}}
|
||||||
by[key] = t
|
by[dir] = t
|
||||||
}
|
}
|
||||||
t.home.Stacks++
|
t.home.Stacks++
|
||||||
if len(st.Containers) > 0 {
|
if len(st.Containers) > 0 {
|
||||||
@@ -233,11 +334,13 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A folder is a home when stacks are kept there as a habit: two or more deployed from it.
|
// A folder is a home when stacks are kept there as a habit: two or more deployed from it.
|
||||||
// One stack on its own (Dockhand's own compose file in ~/apps/dockhand) says nothing about
|
// One stack on its own (an orchestrator's own compose file in its app folder) says nothing
|
||||||
// where the next should go, and would have every compose file near it listed as a stack.
|
// about where the next should go, and would have every compose file near it listed as a
|
||||||
// New stacks are deployed the way most of their neighbours were.
|
// stack — unless it is already in Ham Lab's folder. New stacks are deployed the way most
|
||||||
var homes, lone []stackHome
|
// of their neighbours were.
|
||||||
for _, t := range by {
|
own := s.stacksDir()
|
||||||
|
var homes []stackHome
|
||||||
|
for dir, t := range by {
|
||||||
best, n := "", -1
|
best, n := "", -1
|
||||||
for via, c := range t.byVia {
|
for via, c := range t.byVia {
|
||||||
if c > n || (c == n && via > best) {
|
if c > n || (c == n && via > best) {
|
||||||
@@ -253,29 +356,23 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
|||||||
if t.home.Inner == "" {
|
if t.home.Inner == "" {
|
||||||
t.home.Inner = t.home.Dir
|
t.home.Inner = t.home.Dir
|
||||||
}
|
}
|
||||||
if t.deployed >= 2 {
|
if t.deployed >= 2 || dir == own {
|
||||||
homes = append(homes, t.home)
|
homes = append(homes, t.home)
|
||||||
} else {
|
|
||||||
lone = append(lone, t.home)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(homes) == 0 {
|
|
||||||
homes = lone
|
|
||||||
}
|
|
||||||
sort.Slice(homes, func(i, j int) bool {
|
sort.Slice(homes, func(i, j int) bool {
|
||||||
|
if (homes[i].Dir == own) != (homes[j].Dir == own) {
|
||||||
|
return homes[i].Dir == own
|
||||||
|
}
|
||||||
if homes[i].Stacks != homes[j].Stacks {
|
if homes[i].Stacks != homes[j].Stacks {
|
||||||
return homes[i].Stacks > homes[j].Stacks
|
return homes[i].Stacks > homes[j].Stacks
|
||||||
}
|
}
|
||||||
return homes[i].Dir < homes[j].Dir
|
return homes[i].Dir < homes[j].Dir
|
||||||
})
|
})
|
||||||
if len(homes) == 0 && withFallback {
|
if len(homes) == 0 || homes[0].Dir != own {
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
homes = append([]stackHome{{Dir: own, Inner: own}}, homes...)
|
||||||
homes = append(homes, stackHome{Dir: filepath.Join(home, "stacks"), Inner: filepath.Join(home, "stacks")})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(homes) > 0 {
|
|
||||||
homes[0].Default = true
|
|
||||||
}
|
}
|
||||||
|
homes[0].Default = true
|
||||||
return homes
|
return homes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +389,9 @@ func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
|||||||
}
|
}
|
||||||
var out []stack
|
var out []stack
|
||||||
for _, h := range homes {
|
for _, h := range homes {
|
||||||
|
if _, err := os.Stat(h.Dir); errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue // Ham Lab's own home before its first stack: nothing to find, and no sudo to spend finding it
|
||||||
|
}
|
||||||
args := []string{h.Dir, "-mindepth", "2", "-maxdepth", "2", "-type", "f", "("}
|
args := []string{h.Dir, "-mindepth", "2", "-maxdepth", "2", "-type", "f", "("}
|
||||||
for i, n := range composeNames {
|
for i, n := range composeNames {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -315,7 +415,7 @@ func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
have[name], have[dir] = true, true
|
have[name], have[dir] = true, true
|
||||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, Remote: h.Remote, HeldBy: h.HeldBy, Editable: true, Note: "not deployed"}
|
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, Editable: true, Note: "not deployed"}
|
||||||
st.Files = []string{filepath.Join(h.Inner, filepath.Base(dir), filepath.Base(f))}
|
st.Files = []string{filepath.Join(h.Inner, filepath.Base(dir), filepath.Base(f))}
|
||||||
st.File = st.Files[0]
|
st.File = st.Files[0]
|
||||||
st.HasEnv = readable(st.envPath())
|
st.HasEnv = readable(st.envPath())
|
||||||
@@ -334,114 +434,23 @@ func contains(list []string, s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// hubStacks is a node's stack list with the hub's help: what the node could not find is
|
// stackFor finds the stack a request means.
|
||||||
// looked for here, and if it is here — and the node's Docker answers over tcp, which is how
|
func (s *Server) stackFor(ctx context.Context, r *http.Request) (stack, error) {
|
||||||
// it was deployed in the first place — the stack becomes editable from this hub.
|
all, err := s.findStacks(ctx)
|
||||||
func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
|
|
||||||
code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks", nil, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return stack{}, err
|
||||||
}
|
|
||||||
var res struct{ Stacks []stack }
|
|
||||||
if code != 200 || json.Unmarshal(b, &res) != nil {
|
|
||||||
return nil, fmt.Errorf("%s answered %d", node, code)
|
|
||||||
}
|
|
||||||
remote, maps := "", []mountMap(nil)
|
|
||||||
for i := range res.Stacks {
|
|
||||||
st := &res.Stacks[i]
|
|
||||||
if st.Editable || len(st.Files) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if remote == "" {
|
|
||||||
remote, maps = s.nodeDocker(ctx, node), stackMounts(ctx)
|
|
||||||
}
|
|
||||||
if remote == "-" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
note := st.Note
|
|
||||||
if st.resolve(maps); !st.Editable {
|
|
||||||
st.Note = note
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
st.Remote, st.HeldBy = remote, s.hostName()
|
|
||||||
}
|
|
||||||
var held []stack
|
|
||||||
for _, st := range res.Stacks {
|
|
||||||
if st.HeldBy != "" {
|
|
||||||
held = append(held, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.Stacks = append(res.Stacks, scanHomes(ctx, s.homesOf(held, false), res.Stacks)...)
|
|
||||||
sort.Slice(res.Stacks, func(i, j int) bool { return res.Stacks[i].Name < res.Stacks[j].Name })
|
|
||||||
return res.Stacks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
nodeDockerMu sync.Mutex
|
|
||||||
nodeDockerOK = map[string]string{} // node → "tcp://host:2375", or "-" when it doesn't answer
|
|
||||||
)
|
|
||||||
|
|
||||||
// nodeDocker is the node's Docker endpoint as Dockhand-style deployments use it, if it answers.
|
|
||||||
func (s *Server) nodeDocker(ctx context.Context, node string) string {
|
|
||||||
nodeDockerMu.Lock()
|
|
||||||
defer nodeDockerMu.Unlock()
|
|
||||||
if v, ok := nodeDockerOK[node]; ok && v != "-" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
ep := "-"
|
|
||||||
for _, n := range s.nodes.List() {
|
|
||||||
if n.Name != node {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if u, err := url.Parse(n.URL); err == nil && u.Hostname() != "" {
|
|
||||||
cand := "tcp://" + u.Hostname() + ":2375"
|
|
||||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
if exec.CommandContext(c, dockerTool(), "-H", cand, "version", "--format", "{{.Server.Version}}").Run() == nil {
|
|
||||||
ep = cand
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nodeDockerOK[node] = ep
|
|
||||||
return ep
|
|
||||||
}
|
|
||||||
|
|
||||||
// stackFor finds the stack a request means: on this machine, or (on a hub route) one of a
|
|
||||||
// node's stacks that this hub holds the files for. handled is false when the request should
|
|
||||||
// simply be passed on to the node.
|
|
||||||
func (s *Server) stackFor(ctx context.Context, r *http.Request) (st stack, handled bool, err error) {
|
|
||||||
var all []stack
|
|
||||||
if node := r.PathValue("node"); node != "" {
|
|
||||||
all, err = s.hubStacks(ctx, node)
|
|
||||||
} else {
|
|
||||||
all, err = s.findStacks(ctx)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return stack{}, true, err
|
|
||||||
}
|
}
|
||||||
for _, x := range all {
|
for _, x := range all {
|
||||||
if x.Name == r.PathValue("name") {
|
if x.Name == r.PathValue("name") {
|
||||||
if r.PathValue("node") != "" && x.Remote == "" {
|
return x, nil
|
||||||
return x, false, nil // the node can serve this one itself
|
|
||||||
}
|
|
||||||
return x, true, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stack{}, true, errors.New("no such stack")
|
return stack{}, errors.New("no such stack")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if node := r.PathValue("node"); node != "" {
|
|
||||||
stacks, err := s.hubStacks(ctx, node)
|
|
||||||
if err != nil {
|
|
||||||
s.proxyNode(w, r) // let the node's own answer (or failure) through
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonOK(w, 200, map[string]any{"stacks": stacks})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if dockerTool() == "" { // no Docker is an empty answer, not a failure
|
if dockerTool() == "" { // no Docker is an empty answer, not a failure
|
||||||
jsonOK(w, 200, map[string]any{"stacks": []stack{}})
|
jsonOK(w, 200, map[string]any{"stacks": []stack{}})
|
||||||
return
|
return
|
||||||
@@ -485,11 +494,7 @@ func target(r *http.Request, st stack) (host string, index int, ok bool) {
|
|||||||
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
st, handled, err := s.stackFor(ctx, r)
|
st, err := s.stackFor(ctx, r)
|
||||||
if !handled {
|
|
||||||
s.proxyNode(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil || !st.Editable {
|
if err != nil || !st.Editable {
|
||||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||||
return
|
return
|
||||||
@@ -508,27 +513,16 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, 200, map[string]any{"stack": st, "text": string(b), "sum": sumOf(b), "index": i})
|
jsonOK(w, 200, map[string]any{"stack": st, "text": string(b), "sum": sumOf(b), "index": i})
|
||||||
}
|
}
|
||||||
|
|
||||||
// composeCmd is `docker compose …` for this stack, run where its files make sense and
|
// composeCmd is `docker compose …` for this stack, run where its files make sense. files are
|
||||||
// pointed at the Docker that runs its containers. files are given as compose will see them.
|
// 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, 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.
|
||||||
@@ -556,11 +550,7 @@ func (s *Server) writeAsOwner(ctx context.Context, path, text string) error {
|
|||||||
func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
st, handled, err := s.stackFor(ctx, r)
|
st, err := s.stackFor(ctx, r)
|
||||||
if !handled {
|
|
||||||
s.proxyNode(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil || !st.Editable {
|
if err != nil || !st.Editable {
|
||||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||||
return
|
return
|
||||||
@@ -632,11 +622,7 @@ func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) stackDiff(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stackDiff(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
st, handled, err := s.stackFor(ctx, r)
|
st, err := s.stackFor(ctx, r)
|
||||||
if !handled {
|
|
||||||
s.proxyNode(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil || !st.Editable {
|
if err != nil || !st.Editable {
|
||||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||||
return
|
return
|
||||||
@@ -686,11 +672,7 @@ func maskEnv(text string) string {
|
|||||||
func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
st, handled, err := s.stackFor(ctx, r)
|
st, err := s.stackFor(ctx, r)
|
||||||
if !handled {
|
|
||||||
s.proxyNode(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil || !st.Editable {
|
if err != nil || !st.Editable {
|
||||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||||
return
|
return
|
||||||
@@ -703,17 +685,10 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
flags := priv.Quote(append([]string{"-p", st.Name}, fl...))
|
flags := priv.Quote(append([]string{"-p", st.Name}, fl...))
|
||||||
base := func(args string) string {
|
base := func(args string) string {
|
||||||
if st.Via != "" {
|
if st.Via != "" {
|
||||||
env := ""
|
// The CLI inside the orchestrator's container is its own `docker`, whatever this host runs.
|
||||||
if st.Remote != "" {
|
return fmt.Sprintf("%s exec %s docker compose %s %s", dockerTool(), priv.Quote([]string{st.Via}), flags, args)
|
||||||
env = "-e " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s exec %s%s docker compose %s %s", dockerTool(), env, priv.Quote([]string{st.Via}), flags, args)
|
|
||||||
}
|
}
|
||||||
env := ""
|
return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, args)
|
||||||
if st.Remote != "" { // through env(1): a quoted NAME=value in front of a command is a command name to sh, not an assignment
|
|
||||||
env = "env " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("cd %s && %s%s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), env, dockerTool(), flags, args)
|
|
||||||
}
|
}
|
||||||
// After a deploy that worked, keep a copy of exactly what was deployed: the next diff is against it.
|
// After a deploy that worked, keep a copy of exactly what was deployed: the next diff is against it.
|
||||||
remember := ""
|
remember := ""
|
||||||
@@ -734,6 +709,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 +722,7 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
|
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 {
|
||||||
@@ -763,7 +744,7 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run()
|
_ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
stackUpdatesForget(st.Name)
|
||||||
log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r))
|
log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r))
|
||||||
jsonOK(w, 202, t.Snapshot(false))
|
jsonOK(w, 202, t.Snapshot(false))
|
||||||
}
|
}
|
||||||
@@ -788,52 +769,21 @@ var (
|
|||||||
|
|
||||||
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
||||||
|
|
||||||
// stackUpdates asks each image's registry for the digest its tag points at now and compares
|
// checkImages asks the registry whether each image is still the one we hold. Each image is
|
||||||
// it with the digest that was pulled. It is asked for, never polled: every answer is a
|
// one registry request, which Docker Hub counts — so this is only ever called when asked for.
|
||||||
// registry request, and Docker Hub counts those. Answers are kept for six hours.
|
func checkImages(ctx context.Context, images []string) []imageCheck {
|
||||||
func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
st, handled, err := s.stackFor(ctx, r)
|
|
||||||
if !handled {
|
|
||||||
s.proxyNode(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 404, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := r.PathValue("node") + "/" + st.Name
|
|
||||||
updMu.Lock()
|
|
||||||
c, ok := updCache[key]
|
|
||||||
updMu.Unlock()
|
|
||||||
refresh := r.URL.Query().Get("refresh") == "1"
|
|
||||||
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
|
||||||
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !refresh {
|
|
||||||
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
|
||||||
if st.Remote != "" {
|
|
||||||
args = append([]string{"-H", st.Remote}, args...)
|
|
||||||
}
|
|
||||||
return dockerRun(ctx, args...)
|
|
||||||
}
|
|
||||||
res := []imageCheck{}
|
res := []imageCheck{}
|
||||||
for _, img := range st.Images {
|
for _, img := range images {
|
||||||
ic := imageCheck{Image: img, State: "unknown"}
|
ic := imageCheck{Image: img, State: "unknown"}
|
||||||
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||||
var have []string
|
var have []string
|
||||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
||||||
ic.Detail = "built or loaded here, not pulled from a registry"
|
ic.Detail = "built or loaded here, not pulled from a registry"
|
||||||
res = append(res, ic)
|
res = append(res, ic)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
remote, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
|
rem, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
|
||||||
digest := strings.TrimSpace(string(remote))
|
digest := strings.TrimSpace(string(rem))
|
||||||
if i := strings.LastIndex(digest, "\n"); i >= 0 {
|
if i := strings.LastIndex(digest, "\n"); i >= 0 {
|
||||||
digest = digest[i+1:]
|
digest = digest[i+1:]
|
||||||
}
|
}
|
||||||
@@ -853,83 +803,111 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
res = append(res, ic)
|
res = append(res, ic)
|
||||||
}
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// machineUpdates checks every image on this machine in one pass, so the container list can
|
||||||
|
// be coloured and "what is behind?" answered without walking stack by stack.
|
||||||
|
func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if dockerTool() == "" { // no Docker is an empty answer, not a failure
|
||||||
|
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0, "stacks": map[string][]string{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
stacks, err := s.findStacks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// one entry per image, however many stacks use it
|
||||||
|
byImage := map[string][]string{} // image -> stacks
|
||||||
|
for _, st := range stacks {
|
||||||
|
for _, img := range st.Images {
|
||||||
|
byImage[img] = append(byImage[img], st.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh := r.URL.Query().Get("refresh") == "1"
|
||||||
|
key := "machine"
|
||||||
|
updMu.Lock()
|
||||||
|
c, ok := updCache[key]
|
||||||
|
updMu.Unlock()
|
||||||
|
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||||
|
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli(), "stacks": byImage})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !refresh {
|
||||||
|
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0, "stacks": byImage})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
for img := range byImage {
|
||||||
|
list = append(list, img)
|
||||||
|
}
|
||||||
|
sort.Strings(list)
|
||||||
|
res := checkImages(ctx, list)
|
||||||
|
updMu.Lock()
|
||||||
|
updCache[key] = updAnswer{time.Now(), res}
|
||||||
|
updMu.Unlock()
|
||||||
|
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli(), "stacks": byImage})
|
||||||
|
}
|
||||||
|
|
||||||
|
// stackUpdates asks each image's registry for the digest its tag points at now and compares
|
||||||
|
// it with the digest that was pulled. It is asked for, never polled: every answer is a
|
||||||
|
// registry request, and Docker Hub counts those. Answers are kept for six hours.
|
||||||
|
func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
st, err := s.stackFor(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 404, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := st.Name
|
||||||
|
updMu.Lock()
|
||||||
|
c, ok := updCache[key]
|
||||||
|
updMu.Unlock()
|
||||||
|
refresh := r.URL.Query().Get("refresh") == "1"
|
||||||
|
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||||
|
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !refresh {
|
||||||
|
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := checkImages(ctx, st.Images)
|
||||||
updMu.Lock()
|
updMu.Lock()
|
||||||
updCache[key] = updAnswer{time.Now(), res}
|
updCache[key] = updAnswer{time.Now(), res}
|
||||||
updMu.Unlock()
|
updMu.Unlock()
|
||||||
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli()})
|
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskOnHub serves a task this hub is running for one of its nodes' stacks; anything else
|
|
||||||
// under /api/nodes/<node>/tasks belongs to the node.
|
|
||||||
func (s *Server) taskOnHub(local http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if _, ok := s.tasks.Get(r.PathValue("id")); ok {
|
|
||||||
local(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.proxyNode(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- making and unmaking stacks ----
|
// ---- making and unmaking stacks ----
|
||||||
|
|
||||||
var stackNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
var stackNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||||
|
|
||||||
// homesFor is where a new stack could go, seen from this request: this machine's homes, or —
|
// homesFor is where a new stack could go on this machine.
|
||||||
// on a hub route — the node's own homes plus the ones this hub holds for it.
|
func (s *Server) homesFor(ctx context.Context) ([]stackHome, error) {
|
||||||
func (s *Server) homesFor(ctx context.Context, r *http.Request) ([]stackHome, error) {
|
stacks, err := s.findStacks(ctx)
|
||||||
node := r.PathValue("node")
|
|
||||||
if node == "" {
|
|
||||||
stacks, err := s.findStacks(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.homesOf(stacks, true), nil
|
|
||||||
}
|
|
||||||
stacks, err := s.hubStacks(ctx, node)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var held []stack
|
return s.homesOf(stacks), nil
|
||||||
for _, st := range stacks {
|
|
||||||
if st.HeldBy != "" {
|
|
||||||
held = append(held, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
homes := s.homesOf(held, false)
|
|
||||||
if code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks/homes", nil, nil); err == nil && code == 200 {
|
|
||||||
var res struct{ Homes []stackHome }
|
|
||||||
if json.Unmarshal(b, &res) == nil {
|
|
||||||
for _, h := range res.Homes {
|
|
||||||
if len(homes) > 0 && h.Stacks == 0 {
|
|
||||||
continue // the node's empty ~/stacks is only worth offering when there is nothing else
|
|
||||||
}
|
|
||||||
h.Default = false
|
|
||||||
homes = append(homes, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(homes) > 0 {
|
|
||||||
homes[0].Default = true
|
|
||||||
}
|
|
||||||
return homes, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) stackHomes(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) stackHomes(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if dockerTool() == "" && r.PathValue("node") == "" {
|
if dockerTool() == "" {
|
||||||
jsonOK(w, 200, map[string]any{"homes": []stackHome{}})
|
jsonOK(w, 200, map[string]any{"homes": []stackHome{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
homes, err := s.homesFor(ctx, r)
|
homes, err := s.homesFor(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
jsonErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if homes == nil { // a machine with no Docker and nothing held for it: an empty list, never null
|
|
||||||
homes = []stackHome{}
|
|
||||||
}
|
|
||||||
jsonOK(w, 200, map[string]any{"homes": homes})
|
jsonOK(w, 200, map[string]any{"homes": homes})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,7 +934,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, 400, "the compose file can't be empty")
|
jsonErr(w, 400, "the compose file can't be empty")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
homes, err := s.homesFor(ctx, r)
|
homes, err := s.homesFor(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(w, 500, err.Error())
|
jsonErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
@@ -971,17 +949,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, 400, "that isn't one of the places this machine keeps stacks")
|
jsonErr(w, 400, "that isn't one of the places this machine keeps stacks")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.PathValue("node") != "" && home.HeldBy == "" { // the node's own folder: the node makes it
|
all, _ := s.findStacks(ctx)
|
||||||
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
|
|
||||||
s.proxyNode(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var all []stack
|
|
||||||
if node := r.PathValue("node"); node != "" {
|
|
||||||
all, _ = s.hubStacks(ctx, node)
|
|
||||||
} else {
|
|
||||||
all, _ = s.findStacks(ctx)
|
|
||||||
}
|
|
||||||
for _, st := range all {
|
for _, st := range all {
|
||||||
if st.Name == name {
|
if st.Name == name {
|
||||||
jsonErr(w, 409, "there is already a stack called "+name)
|
jsonErr(w, 409, "there is already a stack called "+name)
|
||||||
@@ -1043,7 +1011,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = sudo("chmod", "600", filepath.Join(dir, ".env")) // it is where the secrets go
|
_ = sudo("chmod", "600", filepath.Join(dir, ".env")) // it is where the secrets go
|
||||||
}
|
}
|
||||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{host}, HostFile: host, Files: []string{filepath.Join(home.Inner, name, "compose.yaml")},
|
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{host}, HostFile: host, Files: []string{filepath.Join(home.Inner, name, "compose.yaml")},
|
||||||
Via: home.Via, Remote: home.Remote, HeldBy: home.HeldBy, Editable: true, Note: "not deployed", HasEnv: strings.TrimSpace(body.Env) != ""}
|
Via: home.Via, Editable: true, Note: "not deployed", HasEnv: strings.TrimSpace(body.Env) != ""}
|
||||||
st.File = st.Files[0]
|
st.File = st.Files[0]
|
||||||
if msg, err := composeCmd(ctx, st, seenFiles(st, -1, ""), "config", "-q").CombinedOutput(); err != nil {
|
if msg, err := composeCmd(ctx, st, seenFiles(st, -1, ""), "config", "-q").CombinedOutput(); err != nil {
|
||||||
undo()
|
undo()
|
||||||
@@ -1062,12 +1030,7 @@ func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
|
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
raw, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
|
raw, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||||
st, handled, err := s.stackFor(ctx, r)
|
st, err := s.stackFor(ctx, r)
|
||||||
if !handled {
|
|
||||||
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
|
|
||||||
s.proxyNode(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil || !st.Editable {
|
if err != nil || !st.Editable {
|
||||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||||
return
|
return
|
||||||
@@ -1105,7 +1068,7 @@ func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = run("rmdir", dir) // only if nothing else was in it
|
_ = run("rmdir", dir) // only if nothing else was in it
|
||||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
stackUpdatesForget(st.Name)
|
||||||
log.Printf("stack %s: taken down and %d file(s) moved to %s (from %s)", st.Name, moved, trash, clientIP(r))
|
log.Printf("stack %s: taken down and %d file(s) moved to %s (from %s)", st.Name, moved, trash, clientIP(r))
|
||||||
jsonOK(w, 200, map[string]any{"trash": trash, "moved": moved})
|
jsonOK(w, 200, map[string]any{"trash": trash, "moved": moved})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A stack deployed by an orchestrator running in a container records the path its compose
|
||||||
|
// file had inside that container. Resolving it has to work both while the orchestrator is
|
||||||
|
// running — compose is then run inside it — and after it has been removed, where the same
|
||||||
|
// mount is only remembered and compose has to run on the host instead.
|
||||||
|
func TestResolveThroughMounts(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(dir, "compose.yaml")
|
||||||
|
if err := os.WriteFile(file, []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inner := "/app/data/stacks/app/compose.yaml"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, container, wantVia string
|
||||||
|
}{
|
||||||
|
{"orchestrator running", "orch", "orch"},
|
||||||
|
{"orchestrator removed", "", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
st := stack{Name: "app", File: inner, Files: []string{inner}}
|
||||||
|
st.resolve([]mountMap{{tc.container, "/app/data", filepath.Join(root, "orch")}})
|
||||||
|
if !st.Editable {
|
||||||
|
t.Fatalf("not editable: %s", st.Note)
|
||||||
|
}
|
||||||
|
if st.HostFile != file {
|
||||||
|
t.Errorf("host file = %q, want %q", st.HostFile, file)
|
||||||
|
}
|
||||||
|
if st.Via != tc.wantVia {
|
||||||
|
t.Errorf("via = %q, want %q", st.Via, tc.wantVia)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing explains the path: the stack is listed, but read-only and says why.
|
||||||
|
st := stack{Name: "app", File: inner, Files: []string{inner}}
|
||||||
|
st.resolve(nil)
|
||||||
|
if st.Editable || st.Note == "" {
|
||||||
|
t.Errorf("unresolvable stack should be read-only with a note, got editable=%v note=%q", st.Editable, st.Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every file of a multi-file stack must resolve through the same place. Redeploying from
|
||||||
|
// the first file alone would silently drop whatever the override set.
|
||||||
|
func TestResolveRefusesSplitStack(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st := stack{Name: "app", File: "/app/data/stacks/app/compose.yaml", Files: []string{
|
||||||
|
"/app/data/stacks/app/compose.yaml",
|
||||||
|
"/app/data/stacks/app/override.yaml", // never written: not on disk
|
||||||
|
}}
|
||||||
|
st.resolve([]mountMap{{"orch", "/app/data", filepath.Join(root, "orch")}})
|
||||||
|
if st.Editable {
|
||||||
|
t.Error("a stack with a file that cannot be found should not be editable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A schedule made while an orchestrator was running says "run compose inside that
|
||||||
|
// container". When the container is gone the schedule must still work, against the same
|
||||||
|
// files on the host — otherwise retiring the orchestrator silently breaks every scheduled
|
||||||
|
// update made before it went.
|
||||||
|
func TestRepointSurvivesAMissingContainer(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
host := filepath.Join(dir, "compose.yaml")
|
||||||
|
if err := os.WriteFile(host, []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := store.Open(filepath.Join(root, "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Update(func(st *store.State) {
|
||||||
|
st.Mounts = []store.RememberedMount{{Container: "gone-orchestrator", Dest: "/app/data", Src: filepath.Join(root, "orch")}}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
in := compose.Spec{Project: "app", Files: []string{"/app/data/stacks/app/compose.yaml"}, Via: "gone-orchestrator", Tool: "docker"}
|
||||||
|
got := repoint(context.Background(), db, in)
|
||||||
|
if got.Via != "" {
|
||||||
|
t.Errorf("via = %q, want it dropped", got.Via)
|
||||||
|
}
|
||||||
|
if len(got.Files) != 1 || got.Files[0] != host {
|
||||||
|
t.Errorf("files = %v, want [%s]", got.Files, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing remembered: the schedule is left exactly as it was rather than pointed at a
|
||||||
|
// guess. It will fail loudly, which is better than deploying the wrong file.
|
||||||
|
empty, err := store.Open(filepath.Join(root, "empty.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := repoint(context.Background(), empty, in); got.Via != in.Via || len(got.Files) != 1 || got.Files[0] != in.Files[0] {
|
||||||
|
t.Errorf("unresolvable spec was changed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where new stacks go: Ham Lab's own <data dir>/stacks is always offered, first and as the
|
||||||
|
// default, whether or not anything is in it yet. A folder this machine already keeps stacks
|
||||||
|
// in — two or more deployed from it — is offered too, but never ahead of Ham Lab's own.
|
||||||
|
func TestHomesDefaultToDataDir(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg := config.Default(filepath.Join(root, "data", "config.json"))
|
||||||
|
s := &Server{cfg: cfg}
|
||||||
|
own := filepath.Join(root, "data", "stacks")
|
||||||
|
legacy := filepath.Join(root, "home", "stacks", "box")
|
||||||
|
deployed := func(dir, name string) stack {
|
||||||
|
f := filepath.Join(dir, name, "compose.yaml")
|
||||||
|
return stack{Name: name, Containers: []string{name + "-1"}, Files: []string{f}, File: f, HostFiles: []string{f}, HostFile: f, Editable: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing deployed anywhere: one home, Ham Lab's own.
|
||||||
|
homes := s.homesOf(nil)
|
||||||
|
if len(homes) != 1 || homes[0].Dir != own || !homes[0].Default {
|
||||||
|
t.Fatalf("empty machine: homes = %+v, want just %s as default", homes, own)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two stacks deployed from a pre-existing folder: it is offered, after Ham Lab's own.
|
||||||
|
homes = s.homesOf([]stack{deployed(legacy, "a"), deployed(legacy, "b")})
|
||||||
|
if len(homes) != 2 || homes[0].Dir != own || !homes[0].Default || homes[1].Dir != legacy || homes[1].Default || homes[1].Stacks != 2 {
|
||||||
|
t.Fatalf("legacy folder: homes = %+v, want [%s (default), %s]", homes, own, legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One lone stack elsewhere says nothing about where the next should go; one in Ham Lab's
|
||||||
|
// own folder is already home.
|
||||||
|
homes = s.homesOf([]stack{deployed(filepath.Join(root, "home", "apps"), "orchestrator"), deployed(own, "c")})
|
||||||
|
if len(homes) != 1 || homes[0].Dir != own || homes[0].Stacks != 1 || !homes[0].Default {
|
||||||
|
t.Fatalf("lone stacks: homes = %+v, want just %s", homes, own)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/incus"
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
"hamlab/internal/sensors"
|
"hamlab/internal/sensors"
|
||||||
"hamlab/internal/storage"
|
"hamlab/internal/storage"
|
||||||
@@ -54,6 +55,7 @@ func newStatusSensors(s *Server) *statusSensors {
|
|||||||
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}, acc: map[string]*bucket{}}
|
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}, acc: map[string]*bucket{}}
|
||||||
go p.loop("services", servicesEvery, p.services)
|
go p.loop("services", servicesEvery, p.services)
|
||||||
go p.loop("containers", containersEvery, p.containers)
|
go p.loop("containers", containersEvery, p.containers)
|
||||||
|
go p.loop("instances", containersEvery, p.instances)
|
||||||
go p.loop("updates", updatesEvery, p.updates)
|
go p.loop("updates", updatesEvery, p.updates)
|
||||||
go p.loop("zfs", zfsEvery, p.zfs)
|
go p.loop("zfs", zfsEvery, p.zfs)
|
||||||
go p.loop("smart", smartEvery, p.smart)
|
go p.loop("smart", smartEvery, p.smart)
|
||||||
@@ -320,6 +322,43 @@ type cpuMark struct {
|
|||||||
t int64
|
t int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// instances are Incus's containers and VMs. "Down" means it should be up and is not: an
|
||||||
|
// instance marked to start with the machine that is stopped, or one in Error — a stopped
|
||||||
|
// instance with no such mark was stopped on purpose, as with Docker containers.
|
||||||
|
func (p *statusSensors) instances(ctx context.Context, t int64) []sensors.Sensor {
|
||||||
|
if !incus.Installed() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
list, err := p.s.virt.InstancesFor(ctx, "sensors")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var res []sensors.Sensor
|
||||||
|
var running, total float64
|
||||||
|
for _, in := range list {
|
||||||
|
total++
|
||||||
|
id := "instance." + sensors.Seg(in.Name)
|
||||||
|
l := map[string]string{"instance": in.Name, "type": in.Type, "image": in.Image}
|
||||||
|
up := in.Status == "Running"
|
||||||
|
if up {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
down := in.Status == "Error" || (!up && in.Config["boot.autostart"] == "true")
|
||||||
|
res = append(res,
|
||||||
|
sensors.Sensor{ID: id + ".running", Name: in.Name, Kind: "state", Value: up, T: t, Labels: l},
|
||||||
|
sensors.Sensor{ID: id + ".down", Name: in.Name + " down", Kind: "state", Value: down, T: t, Labels: l},
|
||||||
|
sensors.Sensor{ID: id + ".state", Name: in.Name + " state", Kind: "state", Value: strings.ToLower(in.Status), T: t, Labels: l})
|
||||||
|
if up {
|
||||||
|
res = append(res,
|
||||||
|
sensors.Sensor{ID: id + ".cpu", Name: in.Name + " CPU", Kind: "usage", Unit: "%", Value: float64(int(in.CPUPct*10+0.5)) / 10, T: t, Min: sensors.F(0), Labels: l},
|
||||||
|
sensors.Sensor{ID: id + ".mem", Name: in.Name + " memory", Kind: "bytes", Unit: "B", Value: float64(in.MemUsage), T: t, Labels: l})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(res,
|
||||||
|
sensors.Sensor{ID: "instances.total", Name: "Instances", Kind: "count", Value: total, T: t},
|
||||||
|
sensors.Sensor{ID: "instances.running", Name: "Running instances", Kind: "count", Value: running, T: t})
|
||||||
|
}
|
||||||
|
|
||||||
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
|
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
|
||||||
st := p.s.updates.Status(ctx)
|
st := p.s.updates.Status(ctx)
|
||||||
if st.Manager == "" {
|
if st.Manager == "" {
|
||||||
|
|||||||
+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 == "" {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// The VPN tab: this machine's WireGuard tunnels, who is on them, and — where the tunnel is
|
||||||
|
// one this machine configures — adding, renaming and removing the devices that dial in.
|
||||||
|
//
|
||||||
|
// Per machine like every other tab: the tunnels shown and edited are the ones on the machine
|
||||||
|
// in the switcher, which is the only honest way to show them, since the inbound server and an
|
||||||
|
// outbound tunnel are different machines here.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlab/internal/vpn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) vpnReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jsonOK(w, 200, vpn.Look(r.Context()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tunnelOf finds the tunnel a request names, so every editing handler starts from the live
|
||||||
|
// state rather than from whatever the page last saw.
|
||||||
|
func (s *Server) tunnelOf(ctx context.Context, w http.ResponseWriter, name string) (vpn.Tunnel, bool) {
|
||||||
|
for _, t := range vpn.Look(ctx).Tunnels {
|
||||||
|
if t.Name == name {
|
||||||
|
if !t.Managed {
|
||||||
|
jsonErr(w, 400, vpn.ErrUnmanaged.Error())
|
||||||
|
return t, false
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonErr(w, 404, "no such tunnel on this machine")
|
||||||
|
return vpn.Tunnel{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// addVPNPeer makes a device and hands back its profile. The profile holds a private key that
|
||||||
|
// is generated for this call and stored nowhere, so it is the only time it can be read — the
|
||||||
|
// same bargain as any password this console generates.
|
||||||
|
func (s *Server) addVPNPeer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body vpn.NewPeer
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
made, err := vpn.AddPeer(r.Context(), t, body)
|
||||||
|
if err != nil {
|
||||||
|
code := 400
|
||||||
|
if errors.Is(err, vpn.ErrUnmanaged) {
|
||||||
|
code = 409
|
||||||
|
}
|
||||||
|
jsonErr(w, code, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The name and address are worth a log line; the profile is emphatically not.
|
||||||
|
log.Printf("vpn: device %q added to %s as %s (by %s)", made.Name, t.Name, made.Address, clientIP(r))
|
||||||
|
jsonOK(w, 200, made)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) editVPNPeer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct{ Name, Address string }
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
section := r.PathValue("section")
|
||||||
|
if err := vpn.EditPeer(r.Context(), t, section, body.Name, body.Address); err != nil {
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("vpn: device %s on %s changed (by %s)", section, t.Name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) removeVPNPeer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t, ok := s.tunnelOf(r.Context(), w, r.PathValue("iface"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
section := r.PathValue("section")
|
||||||
|
var body struct{ Confirm string }
|
||||||
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&body)
|
||||||
|
// The name has to be typed back. Removing a device is the one action here that cannot be
|
||||||
|
// undone — the key is gone and the profile on the device stops working — and it is one tap
|
||||||
|
// away from a list of devices that all look alike.
|
||||||
|
var want string
|
||||||
|
for _, p := range t.Peers {
|
||||||
|
if p.Section == section {
|
||||||
|
want = p.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want != "" && !strings.EqualFold(strings.TrimSpace(body.Confirm), want) {
|
||||||
|
jsonErr(w, 400, "type the device's name to remove it")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := vpn.DeletePeer(r.Context(), t, section); err != nil {
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("vpn: device %s (%s) removed from %s (by %s)", section, want, t.Name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
@@ -136,6 +136,17 @@ func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||||
var p storage.Policy
|
var p storage.Policy
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
|
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
|
||||||
|
|||||||
+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,42 @@
|
|||||||
|
// Package compose runs docker compose the way a stack was deployed — which is not always
|
||||||
|
// simply "on this host". A stack deployed by an orchestrator running inside its own
|
||||||
|
// container records paths in that container's filesystem, so compose has to run in there.
|
||||||
|
// Both the console and the scheduled updates it writes must invoke it identically, or a
|
||||||
|
// redeploy from one would not match the other's labels. Compose always drives this
|
||||||
|
// machine's own Docker: a stack's files and its containers are on the same box.
|
||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spec is everything needed to run compose for one stack.
|
||||||
|
type Spec struct {
|
||||||
|
Project string // -p
|
||||||
|
Files []string // -f, in order
|
||||||
|
Via string // a container to run compose inside; "" is this host
|
||||||
|
Tool string // "docker" or "podman"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd builds the command. args are compose's own, e.g. "pull" or "up", "-d".
|
||||||
|
func Cmd(ctx context.Context, s Spec, args ...string) *exec.Cmd {
|
||||||
|
tool := s.Tool
|
||||||
|
if tool == "" {
|
||||||
|
tool = "docker"
|
||||||
|
}
|
||||||
|
base := []string{"compose", "-p", s.Project}
|
||||||
|
for _, f := range s.Files {
|
||||||
|
base = append(base, "-f", f)
|
||||||
|
}
|
||||||
|
if s.Via != "" {
|
||||||
|
// The CLI inside the orchestrator's container is its own `docker`, whatever this host runs.
|
||||||
|
return exec.CommandContext(ctx, tool, append(append([]string{"exec", s.Via, "docker"}, base...), args...)...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
|
||||||
|
if len(s.Files) > 0 {
|
||||||
|
cmd.Dir = filepath.Dir(s.Files[0])
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -46,6 +46,22 @@ type User struct {
|
|||||||
Hash string `json:"hash"`
|
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
|
||||||
@@ -82,8 +98,8 @@ 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"`
|
||||||
@@ -145,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 {
|
||||||
|
|||||||
@@ -0,0 +1,843 @@
|
|||||||
|
// Package hardware is what is physically in the machine: the board it is, its processor,
|
||||||
|
// the memory modules in its slots, the drives attached to it and the graphics in it.
|
||||||
|
//
|
||||||
|
// None of this changes while the machine is running, so it is read once and remembered.
|
||||||
|
// Everything here is read from what Debian and its kin already have — dmidecode for the
|
||||||
|
// parts only the firmware knows, lscpu, lspci, sysfs — and every reader is allowed to
|
||||||
|
// come back empty: a machine with no dmidecode, no PCI bus or no root still gets a page,
|
||||||
|
// with a note saying what could not be read and why.
|
||||||
|
package hardware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/priv"
|
||||||
|
"hamlab/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type System struct {
|
||||||
|
Model string `json:"model,omitempty"` // "GMKtec NucBox G3"
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
Family string `json:"family,omitempty"`
|
||||||
|
Kernel string `json:"kernel,omitempty"`
|
||||||
|
Arch string `json:"arch,omitempty"`
|
||||||
|
Virt string `json:"virt,omitempty"` // "kvm", "lxc", … when this is not real hardware
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot is an expansion slot as the firmware describes it. Worth treating with suspicion on
|
||||||
|
// a mini PC, where the table is often copied from a reference design and lists slots the
|
||||||
|
// case has no room for.
|
||||||
|
type Slot struct {
|
||||||
|
Designation string `json:"designation"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Usage string `json:"usage,omitempty"` // Available | In Use | Unknown
|
||||||
|
Length string `json:"length,omitempty"`
|
||||||
|
Width string `json:"width,omitempty"`
|
||||||
|
Bus string `json:"bus,omitempty"` // PCI address of what is in it
|
||||||
|
Device string `json:"device,omitempty"` // and what that turns out to be
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board is the motherboard and the firmware on it.
|
||||||
|
type Board struct {
|
||||||
|
Maker string `json:"maker,omitempty"`
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
AssetTag string `json:"asset_tag,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"` // "Motherboard", "Processor Module", …
|
||||||
|
Location string `json:"location,omitempty"` // where it sits in the chassis
|
||||||
|
Chassis string `json:"chassis,omitempty"`
|
||||||
|
ChassisMaker string `json:"chassis_maker,omitempty"`
|
||||||
|
BIOS string `json:"bios,omitempty"` // vendor and version
|
||||||
|
BIOSDate string `json:"bios_date,omitempty"`
|
||||||
|
BIOSRevision string `json:"bios_revision,omitempty"`
|
||||||
|
ROMSize string `json:"rom_size,omitempty"`
|
||||||
|
UEFI bool `json:"uefi,omitempty"`
|
||||||
|
Slots []Slot `json:"slots,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
Level string `json:"level"` // L1d, L1i, L2, L3
|
||||||
|
Size string `json:"size"` // as lscpu puts it: "6 MiB (1 instance)"
|
||||||
|
}
|
||||||
|
|
||||||
|
type CPU struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Arch string `json:"arch,omitempty"`
|
||||||
|
Sockets int `json:"sockets,omitempty"`
|
||||||
|
SocketNames []string `json:"socket_names,omitempty"` // what the firmware calls each socket
|
||||||
|
Cores int `json:"cores,omitempty"`
|
||||||
|
Threads int `json:"threads,omitempty"`
|
||||||
|
ThreadsPerCore int `json:"threads_per_core,omitempty"`
|
||||||
|
MinMHz float64 `json:"min_mhz,omitempty"`
|
||||||
|
MaxMHz float64 `json:"max_mhz,omitempty"`
|
||||||
|
Family string `json:"family,omitempty"`
|
||||||
|
ModelID string `json:"model_id,omitempty"`
|
||||||
|
Stepping string `json:"stepping,omitempty"`
|
||||||
|
Microcode string `json:"microcode,omitempty"`
|
||||||
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
|
Caches []Cache `json:"caches,omitempty"`
|
||||||
|
Flags []string `json:"flags,omitempty"` // the handful anyone looks for
|
||||||
|
}
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
Locator string `json:"locator"` // the slot, as the board labels it
|
||||||
|
Bank string `json:"bank,omitempty"`
|
||||||
|
Empty bool `json:"empty,omitempty"`
|
||||||
|
Size uint64 `json:"size,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"` // DDR4
|
||||||
|
FormFactor string `json:"form_factor,omitempty"` // SODIMM
|
||||||
|
SpeedMTs int `json:"speed_mts,omitempty"` // what the module can do
|
||||||
|
RunningMTs int `json:"running_mts,omitempty"` // what the board is running it at
|
||||||
|
Maker string `json:"maker,omitempty"`
|
||||||
|
Part string `json:"part,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
Rank int `json:"rank,omitempty"`
|
||||||
|
Voltage string `json:"voltage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Memory struct {
|
||||||
|
Total uint64 `json:"total"` // what the kernel can use
|
||||||
|
Installed uint64 `json:"installed,omitempty"` // what the modules add up to
|
||||||
|
MaxCapacity uint64 `json:"max_capacity,omitempty"` // what the board will take
|
||||||
|
Slots int `json:"slots,omitempty"`
|
||||||
|
Filled int `json:"filled,omitempty"`
|
||||||
|
ECC string `json:"ecc,omitempty"`
|
||||||
|
Modules []Module `json:"modules,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Drive struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // nvme | ssd | hdd | usb | emmc | sd | other
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
Tran string `json:"tran,omitempty"`
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
Rotational bool `json:"rotational,omitempty"`
|
||||||
|
Removable bool `json:"removable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GPU struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Slot string `json:"slot,omitempty"` // PCI address, when it is on a bus
|
||||||
|
Driver string `json:"driver,omitempty"`
|
||||||
|
Node string `json:"node,omitempty"` // card0
|
||||||
|
Memory uint64 `json:"memory,omitempty"`
|
||||||
|
Class string `json:"class,omitempty"` // VGA | 3D | Display
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
System System `json:"system"`
|
||||||
|
Board Board `json:"board"`
|
||||||
|
CPU CPU `json:"cpu"`
|
||||||
|
Memory Memory `json:"memory"`
|
||||||
|
Drives []Drive `json:"drives"`
|
||||||
|
GPUs []GPU `json:"gpus"`
|
||||||
|
Notes []string `json:"notes,omitempty"` // what could not be read, and why
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager remembers the last report. Hardware does not change under a running kernel —
|
||||||
|
// the one thing that would, a drive plugged in, is what Refresh is for.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
last *Report
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Manager { return &Manager{} }
|
||||||
|
|
||||||
|
const keepFor = 10 * time.Minute
|
||||||
|
|
||||||
|
func (m *Manager) Report(ctx context.Context, refresh bool) *Report {
|
||||||
|
m.mu.Lock()
|
||||||
|
if !refresh && m.last != nil && time.Since(m.at) < keepFor {
|
||||||
|
r := m.last
|
||||||
|
m.mu.Unlock()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
r := collect(ctx)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.last, m.at = r, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func collect(ctx context.Context) *Report {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r := &Report{At: time.Now()}
|
||||||
|
dmi, note := readDMI(ctx)
|
||||||
|
if note != "" {
|
||||||
|
r.Notes = append(r.Notes, note)
|
||||||
|
}
|
||||||
|
r.System = system(ctx, dmi)
|
||||||
|
r.Board = board(dmi, pciNames(ctx))
|
||||||
|
r.CPU = cpu(ctx, dmi)
|
||||||
|
r.Memory = memory(dmi)
|
||||||
|
if len(r.Memory.Modules) == 0 && note == "" {
|
||||||
|
r.Notes = append(r.Notes, "memory modules: the firmware's table says nothing about this machine's slots")
|
||||||
|
}
|
||||||
|
r.Drives = drives(ctx)
|
||||||
|
r.GPUs = gpus(ctx)
|
||||||
|
if len(r.GPUs) == 0 {
|
||||||
|
r.Notes = append(r.Notes, "graphics: nothing on the PCI bus and no kernel display device")
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dmidecode ----
|
||||||
|
|
||||||
|
// record is one entry of the firmware's table: its DMI type and its key/value lines.
|
||||||
|
type record struct {
|
||||||
|
Type int
|
||||||
|
V map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// listKey holds every doubly-indented line of a record — dmidecode's lists, which have no
|
||||||
|
// key of their own: BIOS characteristics, board features, slot characteristics.
|
||||||
|
const listKey = "\x00list"
|
||||||
|
|
||||||
|
var handleRe = regexp.MustCompile(`^Handle 0x[0-9A-Fa-f]+, DMI type (\d+)`)
|
||||||
|
|
||||||
|
// readDMI runs dmidecode once for every type the page wants and parses the lot. It needs
|
||||||
|
// root; without it the page loses the board, the memory modules and the socket names, and
|
||||||
|
// says so rather than pretending the machine has none.
|
||||||
|
func readDMI(ctx context.Context) ([]record, string) {
|
||||||
|
tool, err := exec.LookPath("dmidecode")
|
||||||
|
if err != nil {
|
||||||
|
if _, e := os.Stat("/usr/sbin/dmidecode"); e != nil {
|
||||||
|
return nil, "the board and the memory modules need dmidecode, which isn't installed here"
|
||||||
|
}
|
||||||
|
tool = "/usr/sbin/dmidecode"
|
||||||
|
}
|
||||||
|
args := []string{tool, "-t", "bios", "-t", "system", "-t", "baseboard", "-t", "chassis", "-t", "slot", "-t", "processor", "-t", "memory"}
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
if !priv.Available() {
|
||||||
|
return nil, "the board and the memory modules come from the firmware's table, which only root may read"
|
||||||
|
}
|
||||||
|
args = priv.Wrap(args...)
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "the firmware's table could not be read: " + strings.TrimSpace(err.Error())
|
||||||
|
}
|
||||||
|
return parse(string(out)), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse turns dmidecode's output into records: a handle line starts one, and the single
|
||||||
|
// indented "Key: Value" lines under it are its fields. Doubly-indented lines are the
|
||||||
|
// continuation of a list (a processor's flags), which nothing here reads.
|
||||||
|
func parse(text string) []record {
|
||||||
|
var recs []record
|
||||||
|
var cur *record
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(text))
|
||||||
|
sc.Buffer(make([]byte, 0, 64<<10), 2<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if m := handleRe.FindStringSubmatch(line); m != nil {
|
||||||
|
t, _ := strconv.Atoi(m[1])
|
||||||
|
recs = append(recs, record{Type: t, V: map[string]string{}})
|
||||||
|
cur = &recs[len(recs)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == nil || !strings.HasPrefix(line, "\t") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
cur = nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "\t\t") { // a list under the field above it, e.g. BIOS characteristics
|
||||||
|
cur.V[listKey] += strings.TrimSpace(line) + "\n"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(strings.TrimSpace(line), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur.V[k] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return recs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r record) get(keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := r.V[k]; v != "" && !unset(v) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// unset is every way the firmware has of saying "nobody filled this in".
|
||||||
|
func unset(v string) bool {
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "", "unknown", "not specified", "not provided", "none", "default string", "to be filled by o.e.m.",
|
||||||
|
"system manufacturer", "system product name", "to be filled by oem", "n/a", "empty", "no module installed",
|
||||||
|
"<out of spec>", "other", "not applicable":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func first(recs []record, t int) record {
|
||||||
|
for _, r := range recs {
|
||||||
|
if r.Type == t {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record{Type: t, V: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func all(recs []record, t int) []record {
|
||||||
|
var out []record
|
||||||
|
for _, r := range recs {
|
||||||
|
if r.Type == t {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- system ----
|
||||||
|
|
||||||
|
func system(ctx context.Context, dmi []record) System {
|
||||||
|
sys := first(dmi, 1)
|
||||||
|
s := System{
|
||||||
|
Model: join(sys.get("Manufacturer"), sys.get("Product Name")),
|
||||||
|
Serial: sys.get("Serial Number"),
|
||||||
|
Family: sys.get("Family"),
|
||||||
|
Arch: runtimeArch(),
|
||||||
|
}
|
||||||
|
s.Kernel = readFile("/proc/sys/kernel/osrelease")
|
||||||
|
if s.Model == "" { // no SMBIOS: a Pi, a Mac, anything with a device tree
|
||||||
|
s.Model = strings.TrimRight(readFile("/sys/firmware/devicetree/base/model"), "\x00")
|
||||||
|
}
|
||||||
|
if out, err := exec.CommandContext(ctx, "systemd-detect-virt").Output(); err == nil {
|
||||||
|
if v := strings.TrimSpace(string(out)); v != "" && v != "none" {
|
||||||
|
s.Virt = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// board reads the motherboard, the chassis it sits in, the firmware on it and the
|
||||||
|
// expansion slots the firmware claims. All of it is SMBIOS, so a machine without one — a
|
||||||
|
// Mac, a Pi — gets an empty board, and the page says so rather than inventing one.
|
||||||
|
func board(dmi []record, pci map[string]string) Board {
|
||||||
|
b, ch, bios := first(dmi, 2), first(dmi, 3), first(dmi, 0)
|
||||||
|
out := Board{
|
||||||
|
Maker: b.get("Manufacturer"),
|
||||||
|
Product: b.get("Product Name"),
|
||||||
|
Version: b.get("Version"),
|
||||||
|
Serial: b.get("Serial Number"),
|
||||||
|
AssetTag: b.get("Asset Tag"),
|
||||||
|
Type: b.get("Type"),
|
||||||
|
Location: b.get("Location In Chassis"),
|
||||||
|
Chassis: ch.get("Type"),
|
||||||
|
ChassisMaker: ch.get("Manufacturer"),
|
||||||
|
BIOS: join(bios.get("Vendor"), bios.get("Version")),
|
||||||
|
BIOSDate: bios.get("Release Date"),
|
||||||
|
BIOSRevision: bios.get("BIOS Revision"),
|
||||||
|
}
|
||||||
|
// "ROM Size: 0 MB" is a board that never filled the field in, not a board with no ROM
|
||||||
|
if rom := bios.get("ROM Size"); rom != "" && !strings.HasPrefix(rom, "0 ") {
|
||||||
|
out.ROMSize = rom
|
||||||
|
}
|
||||||
|
if out.Product == out.Maker {
|
||||||
|
out.Product = "" // a board named after its maker says nothing twice
|
||||||
|
}
|
||||||
|
out.UEFI = strings.Contains(bios.V[listKey], "UEFI") // a characteristics line, not a field
|
||||||
|
for _, sl := range all(dmi, 9) {
|
||||||
|
s := Slot{
|
||||||
|
Designation: sl.get("Designation"),
|
||||||
|
Type: sl.get("Type"),
|
||||||
|
Usage: sl.get("Current Usage"),
|
||||||
|
Length: sl.get("Length"),
|
||||||
|
Width: sl.get("Data Bus Width"),
|
||||||
|
}
|
||||||
|
// every slot on some boards claims 0000:00:00.0, which is the host bridge and
|
||||||
|
// certainly not what is plugged into that slot
|
||||||
|
if bus := sl.get("Bus Address"); bus != "" && bus != "0000:00:00.0" {
|
||||||
|
s.Bus, s.Device = bus, pci[bus]
|
||||||
|
}
|
||||||
|
if s.Designation != "" {
|
||||||
|
out.Slots = append(out.Slots, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func join(parts ...string) string {
|
||||||
|
var keep []string
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" && !unset(p) {
|
||||||
|
keep = append(keep, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "GMKtec GMKtec" reads badly: drop a maker the product already names
|
||||||
|
if len(keep) == 2 && strings.HasPrefix(strings.ToLower(keep[1]), strings.ToLower(keep[0])) {
|
||||||
|
return keep[1]
|
||||||
|
}
|
||||||
|
return strings.Join(keep, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(p string) string {
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeArch() string {
|
||||||
|
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- processor ----
|
||||||
|
|
||||||
|
// worthShowing is the short list of processor features anyone actually asks about; the
|
||||||
|
// full flag list runs to hundreds and belongs in a terminal.
|
||||||
|
var worthShowing = []string{"aes", "avx", "avx2", "avx512f", "sha_ni", "vmx", "svm", "sse4_2", "rdrand", "hypervisor"}
|
||||||
|
|
||||||
|
func cpu(ctx context.Context, dmi []record) CPU {
|
||||||
|
c := CPU{}
|
||||||
|
f := lscpu(ctx)
|
||||||
|
c.Model = f["Model name"]
|
||||||
|
c.Vendor = f["Vendor ID"]
|
||||||
|
c.Arch = f["Architecture"]
|
||||||
|
c.Sockets = atoi(f["Socket(s)"])
|
||||||
|
c.ThreadsPerCore = atoi(f["Thread(s) per core"])
|
||||||
|
c.Threads = atoi(f["CPU(s)"])
|
||||||
|
c.Cores = atoi(f["Core(s) per socket"]) * maxInt(1, c.Sockets)
|
||||||
|
c.MinMHz = atof(f["CPU min MHz"])
|
||||||
|
c.MaxMHz = atof(f["CPU max MHz"])
|
||||||
|
c.Family, c.ModelID, c.Stepping = f["CPU family"], f["Model"], f["Stepping"]
|
||||||
|
c.Virtualization = f["Virtualization"]
|
||||||
|
for _, l := range []string{"L1d", "L1i", "L2", "L3"} {
|
||||||
|
if v := f[l+" cache"]; v != "" {
|
||||||
|
c.Caches = append(c.Caches, Cache{Level: l, Size: v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, fl := range strings.Fields(f["Flags"]) {
|
||||||
|
have[fl] = true
|
||||||
|
}
|
||||||
|
for _, fl := range worthShowing {
|
||||||
|
if have[fl] {
|
||||||
|
c.Flags = append(c.Flags, fl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range all(dmi, 4) {
|
||||||
|
if s := p.get("Socket Designation"); s != "" {
|
||||||
|
c.SocketNames = append(c.SocketNames, s)
|
||||||
|
}
|
||||||
|
if c.Model == "" {
|
||||||
|
c.Model = p.get("Version", "Family")
|
||||||
|
}
|
||||||
|
if c.MaxMHz == 0 {
|
||||||
|
if mhz := atof(strings.TrimSuffix(p.get("Max Speed"), " MHz")); mhz > 0 {
|
||||||
|
c.MaxMHz = mhz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Sockets == 0 {
|
||||||
|
c.Sockets = len(c.SocketNames)
|
||||||
|
}
|
||||||
|
// A processor whose cores are not all alike — Apple's, Intel's P and E cores — is
|
||||||
|
// reported by lscpu as several groups, and "core(s) per socket" then counts one group
|
||||||
|
// rather than the chip. Without SMT every thread is a core, so the thread count is the
|
||||||
|
// honest answer.
|
||||||
|
if c.ThreadsPerCore == 1 && c.Threads > c.Cores {
|
||||||
|
c.Cores = c.Threads
|
||||||
|
}
|
||||||
|
// /proc/cpuinfo is the only place the running microcode revision is written down
|
||||||
|
if b, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == "microcode" {
|
||||||
|
c.Microcode = strings.TrimSpace(v)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Model == "" {
|
||||||
|
c.Model = "unknown processor"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func lscpu(ctx context.Context) map[string]string {
|
||||||
|
f := map[string]string{}
|
||||||
|
out, err := exec.CommandContext(ctx, "lscpu").Output()
|
||||||
|
if err != nil {
|
||||||
|
// no util-linux: /proc/cpuinfo still names the thing and counts it
|
||||||
|
if b, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
|
n := 0
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, ":")
|
||||||
|
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||||
|
switch {
|
||||||
|
case ok && strings.EqualFold(k, "model name"):
|
||||||
|
f["Model name"] = v
|
||||||
|
case ok && k == "processor":
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f["CPU(s)"] = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, ":")
|
||||||
|
if !ok || strings.HasPrefix(line, " ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi(s string) int { n, _ := strconv.Atoi(strings.TrimSpace(s)); return n }
|
||||||
|
|
||||||
|
func atof(s string) float64 {
|
||||||
|
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// leadingInt reads the number off the front of "3200 MT/s".
|
||||||
|
func leadingInt(s string) int {
|
||||||
|
f := strings.Fields(s)
|
||||||
|
if len(f) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return atoi(f[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- memory ----
|
||||||
|
|
||||||
|
var sizeRe = regexp.MustCompile(`^(\d+)\s*(kB|KB|MB|GB|TB)$`)
|
||||||
|
|
||||||
|
// dmiSize turns dmidecode's "8 GB" / "16384 MB" into bytes.
|
||||||
|
func dmiSize(s string) uint64 {
|
||||||
|
m := sizeRe.FindStringSubmatch(strings.TrimSpace(s))
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.ParseUint(m[1], 10, 64)
|
||||||
|
switch strings.ToUpper(m[2]) {
|
||||||
|
case "KB":
|
||||||
|
return n << 10
|
||||||
|
case "MB":
|
||||||
|
return n << 20
|
||||||
|
case "GB":
|
||||||
|
return n << 30
|
||||||
|
case "TB":
|
||||||
|
return n << 40
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func memory(dmi []record) Memory {
|
||||||
|
m := Memory{}
|
||||||
|
if b, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, ":"); ok && k == "MemTotal" {
|
||||||
|
m.Total = uint64(atoi(strings.TrimSuffix(strings.TrimSpace(v), " kB"))) << 10
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range all(dmi, 16) {
|
||||||
|
m.MaxCapacity += dmiSize(a.get("Maximum Capacity"))
|
||||||
|
switch e := a.V["Error Correction Type"]; {
|
||||||
|
case e == "None":
|
||||||
|
m.ECC = "non-ECC" // the array says so outright; worth knowing, not worth hiding
|
||||||
|
case e != "" && !unset(e):
|
||||||
|
m.ECC = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range all(dmi, 17) {
|
||||||
|
mod := Module{
|
||||||
|
Locator: d.get("Locator"),
|
||||||
|
Bank: d.get("Bank Locator"),
|
||||||
|
Size: dmiSize(d.get("Size")),
|
||||||
|
}
|
||||||
|
m.Slots++
|
||||||
|
if mod.Locator == "" {
|
||||||
|
mod.Locator = "slot " + strconv.Itoa(m.Slots)
|
||||||
|
}
|
||||||
|
if mod.Size == 0 {
|
||||||
|
mod.Empty = true
|
||||||
|
m.Modules = append(m.Modules, mod)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Filled++
|
||||||
|
mod.Type = d.get("Type")
|
||||||
|
mod.FormFactor = d.get("Form Factor")
|
||||||
|
mod.SpeedMTs = leadingInt(d.get("Speed"))
|
||||||
|
mod.RunningMTs = leadingInt(d.get("Configured Memory Speed", "Configured Clock Speed"))
|
||||||
|
mod.Maker = makerName(d.get("Manufacturer"), d.V["Module Manufacturer ID"])
|
||||||
|
mod.Part = d.get("Part Number")
|
||||||
|
mod.Serial = d.get("Serial Number")
|
||||||
|
mod.Rank = atoi(d.get("Rank"))
|
||||||
|
mod.Voltage = d.get("Configured Voltage")
|
||||||
|
m.Installed += mod.Size
|
||||||
|
m.Modules = append(m.Modules, mod)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// jedec maps the memory makers who turn up in a homelab to their names: SPD carries a
|
||||||
|
// JEDEC id, and dmidecode prints the raw code when the module's string field is empty.
|
||||||
|
var jedec = map[string]string{
|
||||||
|
"0X2C00": "Micron", "0X802C": "Micron",
|
||||||
|
"0XCE00": "Samsung", "0X80CE": "Samsung", "0X00CE": "Samsung",
|
||||||
|
"0XAD00": "SK hynix", "0X80AD": "SK hynix",
|
||||||
|
"0X9801": "Kingston", "0X7F98": "Kingston", "0X0198": "Kingston",
|
||||||
|
"0X859B": "Crucial", "0X04CB": "A-DATA", "0X04CD": "G.Skill",
|
||||||
|
"0X029E": "Corsair", "0X0B45": "Wilk Elektronik", "0X830B": "Wilk Elektronik",
|
||||||
|
"0X0443": "Ramaxel", "0X0194": "Smart Modular",
|
||||||
|
}
|
||||||
|
|
||||||
|
// makerName prefers what the module says it is; a bare JEDEC id becomes a name when it is
|
||||||
|
// one we know, and stays the id when it is not — a wrong name is worse than a number.
|
||||||
|
func makerName(v, id string) string {
|
||||||
|
if v != "" && !unset(v) && !strings.HasPrefix(v, "0x") {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
code := v
|
||||||
|
if code == "" {
|
||||||
|
code = id
|
||||||
|
}
|
||||||
|
if n, ok := jedec[strings.ToUpper(code)]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if v != "" && !unset(v) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- drives ----
|
||||||
|
|
||||||
|
func drives(ctx context.Context) []Drive {
|
||||||
|
devs, err := storage.Devices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]Drive, 0, len(devs))
|
||||||
|
for _, d := range devs {
|
||||||
|
out = append(out, Drive{
|
||||||
|
Name: d.Name, Kind: d.Kind, Model: d.Model, Vendor: d.Vendor, Serial: d.Serial,
|
||||||
|
Tran: d.Tran, Size: d.Size, Rotational: d.Rotational, Removable: d.Removable,
|
||||||
|
Firmware: firmwareOf(d.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// firmwareOf reads a drive's firmware revision. An NVMe device is folded to its
|
||||||
|
// controller ("nvme0"), which has no /sys/block entry of its own — the revision is on the
|
||||||
|
// controller in /sys/class/nvme.
|
||||||
|
func firmwareOf(name string) string {
|
||||||
|
return firstNonEmpty(
|
||||||
|
readFile("/sys/block/"+name+"/device/firmware_rev"),
|
||||||
|
readFile("/sys/block/"+name+"/device/rev"),
|
||||||
|
readFile("/sys/class/nvme/"+name+"/firmware_rev"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(v ...string) string {
|
||||||
|
for _, s := range v {
|
||||||
|
if s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- graphics ----
|
||||||
|
|
||||||
|
// lspci -mm quotes every field, so a device name can hold the spaces and brackets it
|
||||||
|
// likes: `"VGA compatible controller [0300]" "Intel Corporation [8086]" "Alder Lake-N …"`.
|
||||||
|
var quoted = regexp.MustCompile(`"([^"]*)"`)
|
||||||
|
|
||||||
|
// stripID drops the trailing PCI id from `Alder Lake-N [UHD Graphics] [46d1]`.
|
||||||
|
func stripID(s string) string {
|
||||||
|
if i := strings.LastIndex(s, " ["); i > 0 && strings.HasSuffix(s, "]") {
|
||||||
|
return strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func gpus(ctx context.Context) []GPU {
|
||||||
|
var out []GPU
|
||||||
|
seen := map[string]bool{} // PCI addresses already accounted for
|
||||||
|
if _, err := exec.LookPath("lspci"); err == nil {
|
||||||
|
drivers := pciDrivers(ctx)
|
||||||
|
if b, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output(); err == nil {
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
slot, _, _ := strings.Cut(line, " ")
|
||||||
|
fields := quoted.FindAllStringSubmatch(line, -1)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind := displayClass(fields[0][1])
|
||||||
|
if kind == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[slot] = true
|
||||||
|
g := GPU{Slot: slot, Class: kind, Vendor: stripID(fields[1][1]), Model: stripID(fields[2][1]), Driver: drivers[slot]}
|
||||||
|
g.Node, g.Memory = drmFor(slot)
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A GPU that is not on a PCI bus at all — an Apple Silicon Mac, a Pi, anything with a
|
||||||
|
// device tree — is only visible as a kernel display device.
|
||||||
|
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
|
||||||
|
for _, c := range cards {
|
||||||
|
dev, err := filepath.EvalSymlinks(c + "/device")
|
||||||
|
if err != nil || seen[filepath.Base(dev)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g := GPU{Node: filepath.Base(c), Class: "Display"}
|
||||||
|
if drv, err := filepath.EvalSymlinks(dev + "/driver"); err == nil {
|
||||||
|
g.Driver = filepath.Base(drv)
|
||||||
|
}
|
||||||
|
g.Model = deviceTreeName(dev)
|
||||||
|
if g.Model == "" && g.Driver != "" {
|
||||||
|
g.Model = g.Driver + " display"
|
||||||
|
}
|
||||||
|
if g.Model == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g.Memory = vram(dev)
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pciNames maps a PCI address to a readable name, so a slot the firmware says is in use
|
||||||
|
// can say what is in it.
|
||||||
|
func pciNames(ctx context.Context) map[string]string {
|
||||||
|
m := map[string]string{}
|
||||||
|
if _, err := exec.LookPath("lspci"); err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output()
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
slot, _, _ := strings.Cut(line, " ")
|
||||||
|
f := quoted.FindAllStringSubmatch(line, -1)
|
||||||
|
if slot == "" || len(f) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[slot] = strings.TrimSpace(stripID(f[1][1]) + " " + stripID(f[2][1]))
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayClass(c string) string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(c, "[0300]"):
|
||||||
|
return "VGA"
|
||||||
|
case strings.Contains(c, "[0302]"):
|
||||||
|
return "3D"
|
||||||
|
case strings.Contains(c, "[0301]"), strings.Contains(c, "[0380]"):
|
||||||
|
return "Display"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// pciDrivers maps a PCI address to the kernel module driving it.
|
||||||
|
func pciDrivers(ctx context.Context) map[string]string {
|
||||||
|
m := map[string]string{}
|
||||||
|
out, err := exec.CommandContext(ctx, "lspci", "-D", "-k").Output()
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
slot := ""
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") {
|
||||||
|
slot, _, _ = strings.Cut(line, " ")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "Kernel driver in use:"); ok && slot != "" {
|
||||||
|
m[slot] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// drmFor finds the kernel display device for a PCI address, and its video memory when the
|
||||||
|
// driver publishes it (amdgpu does; Intel's integrated graphics have none of their own).
|
||||||
|
func drmFor(slot string) (string, uint64) {
|
||||||
|
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
|
||||||
|
for _, c := range cards {
|
||||||
|
dev, err := filepath.EvalSymlinks(c + "/device")
|
||||||
|
if err != nil || filepath.Base(dev) != slot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return filepath.Base(c), vram(dev)
|
||||||
|
}
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func vram(dev string) uint64 {
|
||||||
|
for _, f := range []string{"mem_info_vram_total", "mem_info_vis_vram_total"} {
|
||||||
|
if v := readFile(dev + "/" + f); v != "" {
|
||||||
|
n, _ := strconv.ParseUint(v, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceTreeName reads what a device-tree machine calls its graphics: the compatible
|
||||||
|
// string, tidied — "apple,agx-g13g" becomes "Apple agx-g13g".
|
||||||
|
func deviceTreeName(dev string) string {
|
||||||
|
c := readFile(dev + "/of_node/compatible")
|
||||||
|
if c == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
c = strings.TrimRight(strings.Split(c, "\x00")[0], "\x00")
|
||||||
|
maker, rest, ok := strings.Cut(c, ",")
|
||||||
|
if !ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if maker != "" {
|
||||||
|
maker = strings.ToUpper(maker[:1]) + maker[1:]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(maker + " " + rest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package hardware
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDMISize(t *testing.T) {
|
||||||
|
for in, want := range map[string]uint64{
|
||||||
|
"8 GB": 8 << 30, "16384 MB": 16384 << 20, "512 kB": 512 << 10, "1 TB": 1 << 40,
|
||||||
|
"No Module Installed": 0, "": 0, "8 GiB": 0,
|
||||||
|
} {
|
||||||
|
if got := dmiSize(in); got != want {
|
||||||
|
t.Errorf("dmiSize(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakerName(t *testing.T) {
|
||||||
|
cases := []struct{ v, id, want string }{
|
||||||
|
{"Samsung", "", "Samsung"}, // the module says who it is
|
||||||
|
{"0x0B45", "Bank 12, Hex 0x45", "Wilk Elektronik"}, // only a JEDEC id, and one we know
|
||||||
|
{"0xFFFF", "", "0xFFFF"}, // one we don't: the number, not a guess
|
||||||
|
{"Unknown", "", ""},
|
||||||
|
{"", "0xCE00", "Samsung"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := makerName(c.v, c.id); got != c.want {
|
||||||
|
t.Errorf("makerName(%q, %q) = %q, want %q", c.v, c.id, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripIDAndDisplayClass(t *testing.T) {
|
||||||
|
if got := stripID("Alder Lake-N [UHD Graphics] [46d1]"); got != "Alder Lake-N [UHD Graphics]" {
|
||||||
|
t.Errorf("stripID = %q", got)
|
||||||
|
}
|
||||||
|
if got := stripID("no brackets here"); got != "no brackets here" {
|
||||||
|
t.Errorf("stripID left a plain name alone wrong: %q", got)
|
||||||
|
}
|
||||||
|
if displayClass(`VGA compatible controller [0300]`) != "VGA" {
|
||||||
|
t.Error("a VGA controller should be graphics")
|
||||||
|
}
|
||||||
|
if displayClass(`Ethernet controller [0200]`) != "" {
|
||||||
|
t.Error("a network card is not graphics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeadingIntAndJoin(t *testing.T) {
|
||||||
|
if leadingInt("3200 MT/s") != 3200 || leadingInt("") != 0 || leadingInt("Unknown") != 0 {
|
||||||
|
t.Error("leadingInt")
|
||||||
|
}
|
||||||
|
if got := join("GMKtec", "GMKtec NucBox G3"); got != "GMKtec NucBox G3" {
|
||||||
|
t.Errorf("a maker the product already names should not be said twice: %q", got)
|
||||||
|
}
|
||||||
|
if got := join("Dell Inc.", "OptiPlex 7040"); got != "Dell Inc. OptiPlex 7040" {
|
||||||
|
t.Errorf("join = %q", got)
|
||||||
|
}
|
||||||
|
if got := join("To Be Filled By O.E.M.", "System Product Name"); got != "" {
|
||||||
|
t.Errorf("firmware boilerplate is not a model: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = `# dmidecode 3.6
|
||||||
|
Handle 0x0028, DMI type 17, 92 bytes
|
||||||
|
Memory Device
|
||||||
|
Array Handle: 0x0027
|
||||||
|
Size: 8 GB
|
||||||
|
Form Factor: SODIMM
|
||||||
|
Locator: Controller0-ChannelA-DIMM0
|
||||||
|
Type: DDR4
|
||||||
|
Speed: 3200 MT/s
|
||||||
|
Manufacturer: 0x0B45
|
||||||
|
Part Number: WPBH32D408SWD-8G
|
||||||
|
Configured Memory Speed: 2933 MT/s
|
||||||
|
Rank: 1
|
||||||
|
|
||||||
|
Handle 0x0029, DMI type 17, 92 bytes
|
||||||
|
Memory Device
|
||||||
|
Size: No Module Installed
|
||||||
|
Locator: Controller1-ChannelA-DIMM0
|
||||||
|
Type: Unknown
|
||||||
|
`
|
||||||
|
|
||||||
|
// parseRecords is what readDMI does once dmidecode has answered; this exercises it
|
||||||
|
// without needing root or the tool.
|
||||||
|
func TestMemoryFromRecords(t *testing.T) {
|
||||||
|
recs := parse(sample)
|
||||||
|
m := memory(recs)
|
||||||
|
if m.Slots != 2 || m.Filled != 1 {
|
||||||
|
t.Fatalf("slots %d filled %d, want 2 and 1", m.Slots, m.Filled)
|
||||||
|
}
|
||||||
|
mod := m.Modules[0]
|
||||||
|
if mod.Size != 8<<30 || mod.Type != "DDR4" || mod.Maker != "Wilk Elektronik" || mod.Part != "WPBH32D408SWD-8G" {
|
||||||
|
t.Errorf("first module read wrong: %+v", mod)
|
||||||
|
}
|
||||||
|
if mod.SpeedMTs != 3200 || mod.RunningMTs != 2933 {
|
||||||
|
t.Errorf("a module running below its rating should say both: %d / %d", mod.RunningMTs, mod.SpeedMTs)
|
||||||
|
}
|
||||||
|
if !m.Modules[1].Empty || m.Modules[1].Locator != "Controller1-ChannelA-DIMM0" {
|
||||||
|
t.Errorf("empty slot read wrong: %+v", m.Modules[1])
|
||||||
|
}
|
||||||
|
if m.Installed != 8<<30 {
|
||||||
|
t.Errorf("installed = %d", m.Installed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package incus
|
||||||
|
|
||||||
|
// Nightly exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a
|
||||||
|
// cron line like the ZFS schedules — `hamlab virt-export` in /etc/cron.d/hamlab-virt — so the
|
||||||
|
// backup runs whether or not the console is up. The settings and the last run's record are
|
||||||
|
// files beside the data, not state.json, because the cron job is a separate process.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CronFile = "/etc/cron.d/hamlab-virt"
|
||||||
|
|
||||||
|
type ExportConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Hour int `json:"hour"`
|
||||||
|
Minute int `json:"minute"`
|
||||||
|
Keep int `json:"keep"` // newest exports kept per instance
|
||||||
|
InstanceOnly bool `json:"instance_only"` // leave the snapshots out of the tarball
|
||||||
|
Instances []string `json:"instances,omitempty"` // empty: every instance
|
||||||
|
Updated time.Time `json:"updated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultExportConfig() ExportConfig {
|
||||||
|
return ExportConfig{Hour: 2, Minute: 15, Keep: 3, InstanceOnly: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ExportConfig) Validate() error {
|
||||||
|
if c.Hour < 0 || c.Hour > 23 || c.Minute < 0 || c.Minute > 59 {
|
||||||
|
return errors.New("time: hour 0–23, minute 0–59")
|
||||||
|
}
|
||||||
|
if c.Keep < 1 || c.Keep > 100 {
|
||||||
|
return errors.New("keep: between 1 and 100")
|
||||||
|
}
|
||||||
|
for _, n := range c.Instances {
|
||||||
|
if !ValidName(n) {
|
||||||
|
return errors.New("instance: " + n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ExportConfig) CronExpr() string { return fmt.Sprintf("%d %d * * *", c.Minute, c.Hour) }
|
||||||
|
|
||||||
|
// NextRun is the next time the line fires, for the page.
|
||||||
|
func (c ExportConfig) NextRun(now time.Time) time.Time {
|
||||||
|
n := time.Date(now.Year(), now.Month(), now.Day(), c.Hour, c.Minute, 0, 0, now.Location())
|
||||||
|
if !n.After(now) {
|
||||||
|
n = n.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// CronText is the whole file; rewritten on every change, never edited.
|
||||||
|
func (c ExportConfig) CronText(user, exe, config string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# Ham Lab instance exports — written by the Virtualization tab, rewritten whole on every change.\n")
|
||||||
|
b.WriteString("# Edit it there, not here.\n")
|
||||||
|
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||||
|
fmt.Fprintf(&b, "# every instance to virt/exports, keep %d each\n", c.Keep)
|
||||||
|
fmt.Fprintf(&b, "%s %s %s -config %s virt-export\n", c.CronExpr(), user, exe, config)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) exportConfigPath() string { return filepath.Join(m.dir, "exports.json") }
|
||||||
|
func (m *Manager) exportRunPath() string { return filepath.Join(m.dir, "export-runs.json") }
|
||||||
|
|
||||||
|
func (m *Manager) ExportConfig() ExportConfig {
|
||||||
|
c := DefaultExportConfig()
|
||||||
|
if b, err := os.ReadFile(m.exportConfigPath()); err == nil {
|
||||||
|
_ = json.Unmarshal(b, &c)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SaveExportConfig(c ExportConfig) error {
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.EnsureDirs(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Updated = time.Now()
|
||||||
|
b, _ := json.MarshalIndent(c, "", " ")
|
||||||
|
return os.WriteFile(m.exportConfigPath(), b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportRun struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Exported []string `json:"exported"`
|
||||||
|
Pruned []string `json:"pruned"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
Seconds float64 `json:"seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) LastExportRun() *ExportRun {
|
||||||
|
b, err := os.ReadFile(m.exportRunPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var r ExportRun
|
||||||
|
if json.Unmarshal(b, &r) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportFile is one tarball in the exports tree.
|
||||||
|
type ExportFile struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ExportFiles() []ExportFile {
|
||||||
|
out := []ExportFile{}
|
||||||
|
dirs, _ := os.ReadDir(m.ExportsDir())
|
||||||
|
for _, d := range dirs {
|
||||||
|
if !d.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files, _ := os.ReadDir(filepath.Join(m.ExportsDir(), d.Name()))
|
||||||
|
for _, f := range files {
|
||||||
|
if f.IsDir() || strings.HasSuffix(f.Name(), ".part") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info, err := f.Info(); err == nil {
|
||||||
|
out = append(out, ExportFile{Instance: d.Name(), Name: f.Name(), Size: info.Size(), Modified: info.ModTime()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Instance != out[j].Instance {
|
||||||
|
return out[i].Instance < out[j].Instance
|
||||||
|
}
|
||||||
|
return out[i].Modified.After(out[j].Modified)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunExports exports the named instances (or every one when names is empty) and prunes each
|
||||||
|
// instance's folder to the configured count. Output goes to w line by line for a task to show.
|
||||||
|
func (m *Manager) RunExports(ctx context.Context, names []string, w func(string)) ExportRun {
|
||||||
|
start := time.Now()
|
||||||
|
c := m.ExportConfig()
|
||||||
|
run := ExportRun{At: start, Exported: []string{}, Pruned: []string{}}
|
||||||
|
if err := m.EnsureDirs(); err != nil {
|
||||||
|
run.Errors = append(run.Errors, err.Error())
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
all, err := m.Instances(ctx)
|
||||||
|
if err != nil {
|
||||||
|
run.Errors = append(run.Errors, "listing instances: "+err.Error())
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, n := range names {
|
||||||
|
want[n] = true
|
||||||
|
}
|
||||||
|
if len(names) == 0 && len(c.Instances) > 0 {
|
||||||
|
for _, n := range c.Instances {
|
||||||
|
want[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, in := range all {
|
||||||
|
if len(want) > 0 && !want[in.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Join(m.ExportsDir(), in.Name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
run.Errors = append(run.Errors, in.Name+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stamp := start.Format("2006-01-02_15.04")
|
||||||
|
final := filepath.Join(dir, fmt.Sprintf("%s-%s.tar.gz", in.Name, stamp))
|
||||||
|
part := final + ".part"
|
||||||
|
args := []string{"export", in.Name, part}
|
||||||
|
if c.InstanceOnly {
|
||||||
|
args = append(args, "--instance-only")
|
||||||
|
}
|
||||||
|
w(fmt.Sprintf("exporting %s …", in.Name))
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 2*time.Hour)
|
||||||
|
out, err := exec.CommandContext(cctx, "incus", args...).CombinedOutput()
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(part)
|
||||||
|
msg := strings.TrimSpace(string(out))
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
run.Errors = append(run.Errors, in.Name+": "+msg)
|
||||||
|
w(" failed: " + msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Rename(part, final); err != nil {
|
||||||
|
run.Errors = append(run.Errors, in.Name+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(final); err == nil {
|
||||||
|
w(fmt.Sprintf(" %s (%.1f MiB)", filepath.Base(final), float64(st.Size())/(1<<20)))
|
||||||
|
}
|
||||||
|
run.Exported = append(run.Exported, filepath.Base(final))
|
||||||
|
// prune: newest first, keep c.Keep
|
||||||
|
var files []ExportFile
|
||||||
|
for _, f := range m.ExportFiles() {
|
||||||
|
if f.Instance == in.Name {
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := c.Keep; i < len(files); i++ {
|
||||||
|
if err := os.Remove(filepath.Join(dir, files[i].Name)); err == nil {
|
||||||
|
run.Pruned = append(run.Pruned, files[i].Name)
|
||||||
|
w(" pruned " + files[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run.Seconds = time.Since(start).Seconds()
|
||||||
|
b, _ := json.MarshalIndent(run, "", " ")
|
||||||
|
_ = os.WriteFile(m.exportRunPath(), b, 0o644)
|
||||||
|
return run
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
package incus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The install script is generated, so a quoting slip would only show on a live machine.
|
||||||
|
// Build it for the shapes of machine we have and let sh check the syntax.
|
||||||
|
func TestInstallScriptShapes(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("sh"); err != nil {
|
||||||
|
t.Skip("no sh")
|
||||||
|
}
|
||||||
|
m := New("/home/core/apps/hamlab", "core", "test")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
st Status
|
||||||
|
o InstallOpts
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"grid: zfs, apt, docker", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"zfs", "btrfs", "dir"}, Docker: true, ZFSHost: true, DiskFree: 700 << 30, MemTotal: 62 << 30},
|
||||||
|
InstallOpts{Driver: "zfs", PoolSizeGiB: 300, Subnet: "172.16.100.1/24", DockerRules: true, ARCMaxGiB: 16},
|
||||||
|
[]string{"apt-get", "zpool create", "incus storage create default zfs source=hamlab-virt", "ipv4.address='172.16.100.1/24'", "DOCKER-USER", "zfs_arc_max", "usermod -aG incus-admin 'core'", "SupplementaryGroups=incus-admin", "systemd-run"}},
|
||||||
|
{"core: btrfs, apt, docker, no tools yet", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"btrfs", "dir"}, Docker: true, DiskFree: 170 << 30, MemTotal: 8 << 30},
|
||||||
|
InstallOpts{Driver: "btrfs", PoolSizeGiB: 40, Subnet: "172.16.100.1/24", DockerRules: true},
|
||||||
|
[]string{"btrfs-progs", "mkfs.btrfs", "/etc/fstab", "incus storage create default btrfs source="}},
|
||||||
|
{"mac: dir, dnf, arm64", Status{State: "installable", PM: "dnf", Arch: "aarch64", Drivers: []string{"dir"}, DiskFree: 200 << 30, MemTotal: 15 << 30},
|
||||||
|
InstallOpts{Driver: "dir", Subnet: "172.16.101.1/24"},
|
||||||
|
[]string{"dnf -y install incus qemu-system-aarch64-core edk2-aarch64", "incus storage create default dir source="}},
|
||||||
|
{"repair: already installed", Status{State: "repair", PM: "apt", Arch: "x86_64", Drivers: []string{"dir"}, DiskFree: 10 << 30, MemTotal: 4 << 30},
|
||||||
|
InstallOpts{Driver: "dir", Subnet: "172.16.100.1/24"},
|
||||||
|
[]string{"already installed"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
script, lay, err := m.InstallScript(c.st, c.o)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", c.name, err)
|
||||||
|
}
|
||||||
|
for _, w := range c.want {
|
||||||
|
if !strings.Contains(script, w) {
|
||||||
|
t.Errorf("%s: script lacks %q", c.name, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lay.Driver != c.o.Driver || lay.Subnet != c.o.Subnet {
|
||||||
|
t.Errorf("%s: layout %+v", c.name, lay)
|
||||||
|
}
|
||||||
|
cmd := exec.Command("sh", "-n")
|
||||||
|
cmd.Stdin = strings.NewReader(script)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Errorf("%s: sh -n: %v\n%s", c.name, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallScriptRefusals(t *testing.T) {
|
||||||
|
m := New("/x", "core", "test")
|
||||||
|
st := Status{State: "installable", PM: "apt", Drivers: []string{"btrfs", "dir"}, DiskFree: 20 << 30, MemTotal: 8 << 30}
|
||||||
|
for _, o := range []InstallOpts{
|
||||||
|
{Driver: "zfs", PoolSizeGiB: 10, Subnet: "172.16.100.1/24"}, // not offered here
|
||||||
|
{Driver: "btrfs", PoolSizeGiB: 50, Subnet: "172.16.100.1/24"}, // more than is free
|
||||||
|
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "172.16.100.0/24"}, // the network address, not the bridge's
|
||||||
|
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "10.0.0.1/8"}, // too wide
|
||||||
|
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "172.16.100.1/24", ARCMaxGiB: 64}, // more RAM than there is
|
||||||
|
} {
|
||||||
|
if _, _, err := m.InstallScript(st, o); err == nil {
|
||||||
|
t.Errorf("accepted %+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateArgs(t *testing.T) {
|
||||||
|
args, err := CreateArgs(CreateSpec{Name: "web-1", Image: "images:debian/13", VM: true, CPU: "2", Memory: "2GiB", Disk: "20GiB", Network: "incusbr0", Start: true}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "launch images:debian/13 web-1 --vm -c limits.cpu=2 -c limits.memory=2GiB -d root,size=20GiB -n incusbr0"
|
||||||
|
if got := strings.Join(args, " "); got != want {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
if _, err := CreateArgs(CreateSpec{Name: "web-1", Image: "images:debian/13", VM: true}, false); err == nil {
|
||||||
|
t.Fatal("a VM without KVM must be refused")
|
||||||
|
}
|
||||||
|
if _, err := CreateArgs(CreateSpec{Name: "1bad", Image: "images:debian/13"}, true); err == nil {
|
||||||
|
t.Fatal("a name starting with a digit must be refused")
|
||||||
|
}
|
||||||
|
if _, err := CreateArgs(CreateSpec{Name: "ok", Image: "images:debian/13", Memory: "2 gigs"}, true); err == nil {
|
||||||
|
t.Fatal("a malformed size must be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package incus
|
||||||
|
|
||||||
|
// The bridge subnet. Incus's own `auto` picks a random 10.x.y.0/24, and on a LAN that is
|
||||||
|
// itself 10.0.0.0/8 (this one) every such pick is on-link and collides. So Ham Lab chooses:
|
||||||
|
// the first /24 that nothing on the machine already routes, is not an address it holds, and
|
||||||
|
// is outside Docker's configured pools — tried in an order that also stays clear of Docker's
|
||||||
|
// *defaults* (172.17–172.31 and 192.168.0.0/16 carved into /20s), which a Docker that has no
|
||||||
|
// daemon.json will hand out later without asking anyone.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// candidates in order of preference: 172.16.0.0/16 is the one RFC 1918 block Docker never
|
||||||
|
// takes by default; then the top of 192.168/16, which Docker reaches last; then the rest.
|
||||||
|
func candidates() []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for i := 100; i <= 254; i++ {
|
||||||
|
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
|
||||||
|
}
|
||||||
|
for i := 254; i >= 200; i-- {
|
||||||
|
out = append(out, netip.MustParsePrefix(fmt.Sprintf("192.168.%d.0/24", i)))
|
||||||
|
}
|
||||||
|
for i := 1; i < 100; i++ {
|
||||||
|
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func overlaps(a, b netip.Prefix) bool {
|
||||||
|
return a.Contains(b.Addr()) || b.Contains(a.Addr())
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickSubnet returns the first candidate /24 clear of every prefix in taken.
|
||||||
|
func PickSubnet(taken []netip.Prefix) netip.Prefix {
|
||||||
|
for _, c := range candidates() {
|
||||||
|
clear := true
|
||||||
|
for _, t := range taken {
|
||||||
|
if overlaps(c, t) {
|
||||||
|
clear = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clear {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return netip.MustParsePrefix("172.16.100.0/24") // every candidate taken: not a machine this will run on
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway is the bridge's own address in a subnet: its first host, as CIDR, the way
|
||||||
|
// Incus's ipv4.address wants it.
|
||||||
|
func Gateway(p netip.Prefix) string {
|
||||||
|
return fmt.Sprintf("%s/%d", p.Masked().Addr().Next(), p.Bits())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakenPrefixes reads what the machine already uses: its routes, its addresses, and the
|
||||||
|
// pools Docker is configured to carve networks from.
|
||||||
|
func TakenPrefixes(ctx context.Context) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
add := func(s string) {
|
||||||
|
if p, err := netip.ParsePrefix(s); err == nil && p.Addr().Is4() {
|
||||||
|
out = append(out, p.Masked())
|
||||||
|
} else if a, err := netip.ParseAddr(s); err == nil && a.Is4() {
|
||||||
|
out = append(out, netip.PrefixFrom(a, 32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "route", "show").Output(); err == nil {
|
||||||
|
var routes []struct{ Dst string }
|
||||||
|
if json.Unmarshal(b, &routes) == nil {
|
||||||
|
for _, r := range routes {
|
||||||
|
if r.Dst != "" && r.Dst != "default" {
|
||||||
|
add(r.Dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "addr", "show").Output(); err == nil {
|
||||||
|
var links []struct {
|
||||||
|
AddrInfo []struct {
|
||||||
|
Local string `json:"local"`
|
||||||
|
Prefixlen int `json:"prefixlen"`
|
||||||
|
} `json:"addr_info"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &links) == nil {
|
||||||
|
for _, l := range links {
|
||||||
|
for _, a := range l.AddrInfo {
|
||||||
|
add(fmt.Sprintf("%s/%d", a.Local, a.Prefixlen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range dockerPools() {
|
||||||
|
add(p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerPools reads default-address-pools from Docker's daemon.json, and falls back to
|
||||||
|
// Docker's built-in defaults when the file names none.
|
||||||
|
func dockerPools() []string {
|
||||||
|
var cfg struct {
|
||||||
|
Pools []struct {
|
||||||
|
Base string `json:"base"`
|
||||||
|
} `json:"default-address-pools"`
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile("/etc/docker/daemon.json"); err == nil && json.Unmarshal(b, &cfg) == nil && len(cfg.Pools) > 0 {
|
||||||
|
var out []string
|
||||||
|
for _, p := range cfg.Pools {
|
||||||
|
out = append(out, p.Base)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"172.17.0.0/12", "192.168.0.0/16"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String form used in the layout file and on the page.
|
||||||
|
func prefixString(p netip.Prefix) string { return strings.TrimSpace(p.Masked().String()) }
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package incus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPickSubnetAvoidsTaken(t *testing.T) {
|
||||||
|
taken := []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"), // the LAN, a /8
|
||||||
|
netip.MustParsePrefix("172.16.100.0/24"), // first choice gone
|
||||||
|
netip.MustParsePrefix("172.16.101.0/24"), // and the second
|
||||||
|
netip.MustParsePrefix("172.28.0.0/14"), // Docker's configured pool
|
||||||
|
netip.MustParsePrefix("192.168.80.0/20"), // a Docker network
|
||||||
|
}
|
||||||
|
got := PickSubnet(taken)
|
||||||
|
if got.String() != "172.16.102.0/24" {
|
||||||
|
t.Fatalf("got %s", got)
|
||||||
|
}
|
||||||
|
if Gateway(got) != "172.16.102.1/24" {
|
||||||
|
t.Fatalf("gateway %s", Gateway(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickSubnetWholeBlockTaken(t *testing.T) {
|
||||||
|
// 172.16.0.0/12 covers every 172.16.x candidate; the picker must move to 192.168
|
||||||
|
got := PickSubnet([]netip.Prefix{netip.MustParsePrefix("172.16.0.0/12")})
|
||||||
|
if got.String() != "192.168.254.0/24" {
|
||||||
|
t.Fatalf("got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlapsEitherWay(t *testing.T) {
|
||||||
|
a := netip.MustParsePrefix("172.16.5.0/24")
|
||||||
|
if !overlaps(a, netip.MustParsePrefix("172.16.0.0/12")) || !overlaps(netip.MustParsePrefix("172.16.0.0/12"), a) {
|
||||||
|
t.Fatal("a /24 inside a /12 overlaps both ways")
|
||||||
|
}
|
||||||
|
if overlaps(a, netip.MustParsePrefix("172.16.6.0/24")) {
|
||||||
|
t.Fatal("neighbours do not overlap")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package incus
|
||||||
|
|
||||||
|
// The graphical console. Incus exposes a VM's SPICE display as a websocket: POST to the
|
||||||
|
// instance's console endpoint with type vga and it answers an operation with two secrets,
|
||||||
|
// one for the display stream and one for a control channel. Ham Lab dials both over Incus's
|
||||||
|
// unix socket and hands the display stream to the browser, where spice-html5 draws it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SocketPath is where Incus's API listens; the daemon's account reaches it by being in
|
||||||
|
// incus-admin, which Install arranges.
|
||||||
|
func SocketPath() string {
|
||||||
|
if p := os.Getenv("INCUS_SOCKET"); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/var/lib/incus/unix.socket", "/run/incus/unix.socket"} {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/var/lib/incus/unix.socket"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) apiClient() *http.Client {
|
||||||
|
sock := SocketPath()
|
||||||
|
return &http.Client{Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, "unix", sock)
|
||||||
|
},
|
||||||
|
ForceAttemptHTTP2: false,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A SPICE client opens one connection per channel — main, display, inputs, cursor, playback
|
||||||
|
// — and spice-html5 does the same with websockets, all to one URL. Incus allows exactly one
|
||||||
|
// console operation per VM at a time, but takes any number of data websockets on it. So a VGA
|
||||||
|
// session is one operation, held for as long as any channel is open, and every browser
|
||||||
|
// websocket becomes one more data websocket on it.
|
||||||
|
type vgaSession struct {
|
||||||
|
op string
|
||||||
|
secret string
|
||||||
|
control *websocket.Conn
|
||||||
|
cancel context.CancelFunc
|
||||||
|
refs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// VGAChannel returns a fresh data websocket on the VM's console operation, opening the
|
||||||
|
// operation first when none is held. release must be called when the channel is done.
|
||||||
|
func (m *Manager) VGAChannel(ctx context.Context, name string) (data *websocket.Conn, release func(), err error) {
|
||||||
|
if !ValidName(name) {
|
||||||
|
return nil, nil, errors.New("bad name")
|
||||||
|
}
|
||||||
|
client := m.apiClient()
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.vga == nil {
|
||||||
|
m.vga = map[string]*vgaSession{}
|
||||||
|
}
|
||||||
|
dial := func(s *vgaSession) (*websocket.Conn, error) {
|
||||||
|
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", s.op, s.secret)
|
||||||
|
ws, _, err := websocket.Dial(c, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
|
||||||
|
return ws, err
|
||||||
|
}
|
||||||
|
s := m.vga[name]
|
||||||
|
if s != nil {
|
||||||
|
if ws, err := dial(s); err == nil {
|
||||||
|
s.refs++
|
||||||
|
ws.SetReadLimit(64 << 20)
|
||||||
|
return ws, m.releaser(name, s), nil
|
||||||
|
}
|
||||||
|
// the operation is gone (the VM restarted, or Incus did): start over
|
||||||
|
s.cancel()
|
||||||
|
delete(m.vga, name)
|
||||||
|
}
|
||||||
|
s, err = m.openVGA(ctx, client, name, false)
|
||||||
|
if err != nil && strings.Contains(err.Error(), "already connected") {
|
||||||
|
// a session a previous Ham Lab process held and never let go of; take it over
|
||||||
|
s, err = m.openVGA(ctx, client, name, true)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ws, err := dial(s)
|
||||||
|
if err != nil {
|
||||||
|
s.cancel()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ws.SetReadLimit(64 << 20)
|
||||||
|
s.refs = 1
|
||||||
|
m.vga[name] = s
|
||||||
|
return ws, m.releaser(name, s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) releaser(name string, s *vgaSession) func() {
|
||||||
|
return func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
s.refs--
|
||||||
|
if s.refs <= 0 && m.vga[name] == s {
|
||||||
|
s.cancel()
|
||||||
|
delete(m.vga, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openVGA asks Incus for the console operation and holds its control websocket, which is
|
||||||
|
// what keeps the operation alive.
|
||||||
|
func (m *Manager) openVGA(ctx context.Context, client *http.Client, name string, force bool) (*vgaSession, error) {
|
||||||
|
body, _ := json.Marshal(map[string]any{"type": "vga", "width": 0, "height": 0, "force": force})
|
||||||
|
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, _ := http.NewRequestWithContext(c, "POST", "http://incus/1.0/instances/"+name+"/console", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if isPermission(err) {
|
||||||
|
return nil, errors.New("Ham Lab's account can't reach Incus's socket; Finish setup on the Virtualization tab puts it in incus-admin")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var op struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Metadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Metadata struct {
|
||||||
|
FDs map[string]string `json:"fds"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&op); err != nil {
|
||||||
|
return nil, errors.New("unreadable answer from incus")
|
||||||
|
}
|
||||||
|
if op.Error != "" {
|
||||||
|
return nil, errors.New(op.Error)
|
||||||
|
}
|
||||||
|
secret, controlSecret := op.Metadata.Metadata.FDs["0"], op.Metadata.Metadata.FDs["control"]
|
||||||
|
if op.Metadata.ID == "" || secret == "" {
|
||||||
|
return nil, errors.New("incus gave no console; is the VM running?")
|
||||||
|
}
|
||||||
|
s := &vgaSession{op: op.Metadata.ID, secret: secret}
|
||||||
|
// the control websocket lives outside any one request: it is the session
|
||||||
|
sctx, scancel := context.WithCancel(context.Background())
|
||||||
|
s.cancel = scancel
|
||||||
|
if controlSecret != "" {
|
||||||
|
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", s.op, controlSecret)
|
||||||
|
control, _, err := websocket.Dial(sctx, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
|
||||||
|
if err != nil {
|
||||||
|
scancel()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.control = control
|
||||||
|
go func() { // drain it; when it ends, so has the operation
|
||||||
|
for {
|
||||||
|
if _, _, err := control.Read(sctx); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
control.Close(websocket.StatusNormalClosure, "")
|
||||||
|
}()
|
||||||
|
go func() { <-sctx.Done(); control.Close(websocket.StatusNormalClosure, "") }()
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPermission(err error) bool {
|
||||||
|
var pe *os.PathError
|
||||||
|
if errors.As(err, &pe) {
|
||||||
|
return os.IsPermission(pe)
|
||||||
|
}
|
||||||
|
var oe *net.OpError
|
||||||
|
if errors.As(err, &oe) {
|
||||||
|
return os.IsPermission(oe.Err) || isPermission(oe.Err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
// The ZFS ARC — the read cache in RAM — read live from /proc/spl/kstat/zfs/arcstats on a
|
||||||
|
// machine that has the module loaded, and left out of the sample everywhere else. The kstat
|
||||||
|
// file is counters since boot; the hit rate here is over the sampling interval, which is the
|
||||||
|
// number that says whether the cache is doing its job *now*. The lifetime figures and the
|
||||||
|
// pool's caching tiers (L2ARC, SLOG, special vdevs) are the storage package's business.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const arcstatsPath = "/proc/spl/kstat/zfs/arcstats"
|
||||||
|
|
||||||
|
// ARC is the read cache as one sample sees it. Sizes are bytes.
|
||||||
|
type ARC struct {
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
Target uint64 `json:"target"` // c: what the ARC is currently allowed to hold
|
||||||
|
Min uint64 `json:"min"` // c_min: the floor it will not shrink below
|
||||||
|
Max uint64 `json:"max"` // c_max: the ceiling (zfs_arc_max, or half of RAM)
|
||||||
|
// what is in it
|
||||||
|
MRU uint64 `json:"mru"` // recently used once
|
||||||
|
MFU uint64 `json:"mfu"` // used more than once
|
||||||
|
Anon uint64 `json:"anon"` // in flight: dirty, not yet on disk
|
||||||
|
Data uint64 `json:"data"` // file contents
|
||||||
|
Metadata uint64 `json:"metadata"` // dnodes, indirect blocks, dbufs, bonus buffers
|
||||||
|
MetaUsed uint64 `json:"meta_used"`
|
||||||
|
Headers uint64 `json:"headers"`
|
||||||
|
Compressed uint64 `json:"compressed"`
|
||||||
|
Uncompressed uint64 `json:"uncompressed"`
|
||||||
|
MRUGhost uint64 `json:"mru_ghost"` // what it recently evicted and still remembers
|
||||||
|
MFUGhost uint64 `json:"mfu_ghost"`
|
||||||
|
// L2ARC, the cache devices (0 when there are none)
|
||||||
|
L2Size uint64 `json:"l2_size"`
|
||||||
|
L2ASize uint64 `json:"l2_asize"` // as stored on the device, after compression
|
||||||
|
// over this interval
|
||||||
|
ReadsPerSec float64 `json:"reads_per_sec"`
|
||||||
|
MissesPerSec float64 `json:"misses_per_sec"`
|
||||||
|
HitPct float64 `json:"hit_pct"` // of the interval's reads; an idle interval keeps the last one
|
||||||
|
HitPctKnown bool `json:"hit_pct_known"` // false until an interval has had any reads
|
||||||
|
L2HitPct float64 `json:"l2_hit_pct"`
|
||||||
|
// since boot
|
||||||
|
Hits uint64 `json:"hits"`
|
||||||
|
Misses uint64 `json:"misses"`
|
||||||
|
LifetimeHitPct float64 `json:"lifetime_hit_pct"`
|
||||||
|
// pressure
|
||||||
|
NoGrow bool `json:"no_grow"` // arc_no_grow: memory pressure is stopping it growing
|
||||||
|
Throttles uint64 `json:"throttles"` // memory_throttle_count since boot
|
||||||
|
Prunes uint64 `json:"prunes"` // arc_prune: metadata evictions asked of the filesystems
|
||||||
|
EvictSkip uint64 `json:"evict_skip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type arcPrev struct {
|
||||||
|
hits, misses, l2hits, l2misses uint64
|
||||||
|
hitPct float64
|
||||||
|
known bool
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// readKstat parses a kstat table ("name type data" rows after two header lines).
|
||||||
|
func readKstat(p string) map[string]uint64 {
|
||||||
|
s := readStr(p)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]uint64{}
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) != 3 || f[1] != "4" { // type 4 is KSTAT_DATA_UINT64
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(f[2], 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
out[f[0]] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// arc reads the ARC; nil when this machine has no ZFS module loaded.
|
||||||
|
func (c *Collector) arc(now time.Time) *ARC {
|
||||||
|
k := readKstat(arcstatsPath)
|
||||||
|
if k == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a := &ARC{Size: k["size"], Target: k["c"], Min: k["c_min"], Max: k["c_max"],
|
||||||
|
MRU: k["mru_size"], MFU: k["mfu_size"], Anon: k["anon_size"], Data: k["data_size"], Metadata: k["metadata_size"],
|
||||||
|
MetaUsed: k["arc_meta_used"], Headers: k["hdr_size"], Compressed: k["compressed_size"], Uncompressed: k["uncompressed_size"],
|
||||||
|
MRUGhost: k["mru_ghost_size"], MFUGhost: k["mfu_ghost_size"], L2Size: k["l2_size"], L2ASize: k["l2_asize"],
|
||||||
|
Hits: k["hits"], Misses: k["misses"], NoGrow: k["arc_no_grow"] != 0, Throttles: k["memory_throttle_count"], Prunes: k["arc_prune"], EvictSkip: k["evict_skip"]}
|
||||||
|
if n := a.Hits + a.Misses; n > 0 {
|
||||||
|
a.LifetimeHitPct = round1(float64(a.Hits) / float64(n) * 100)
|
||||||
|
}
|
||||||
|
p := c.prevARC
|
||||||
|
if !p.at.IsZero() && a.Hits >= p.hits && a.Misses >= p.misses {
|
||||||
|
if dt := now.Sub(p.at).Seconds(); dt > 0 {
|
||||||
|
hits, misses := a.Hits-p.hits, a.Misses-p.misses
|
||||||
|
a.ReadsPerSec = round1(float64(hits+misses) / dt)
|
||||||
|
a.MissesPerSec = round1(float64(misses) / dt)
|
||||||
|
if hits+misses > 0 {
|
||||||
|
p.hitPct, p.known = round1(float64(hits)/float64(hits+misses)*100), true
|
||||||
|
}
|
||||||
|
if l2 := (k["l2_hits"] - p.l2hits) + (k["l2_misses"] - p.l2misses); l2 > 0 && k["l2_hits"] >= p.l2hits {
|
||||||
|
a.L2HitPct = round1(float64(k["l2_hits"]-p.l2hits) / float64(l2) * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.HitPct, a.HitPctKnown = p.hitPct, p.known
|
||||||
|
c.prevARC = arcPrev{hits: a.Hits, misses: a.Misses, l2hits: k["l2_hits"], l2misses: k["l2_misses"], hitPct: p.hitPct, known: p.known, at: now}
|
||||||
|
return a
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
// CPU power, from the RAPL energy counters the kernel keeps under /sys/class/powercap: Intel
|
||||||
|
// since Sandy Bridge, and AMD Zen through the same driver. Each counter is a running total in
|
||||||
|
// µJ, so power is the difference between two readings over the time between them. Every
|
||||||
|
// package is summed (`package-0`, and `package-1` on a two-socket board). The other top-level
|
||||||
|
// domains are left out. `psys` claims the whole platform but is only as good as the firmware's
|
||||||
|
// calibration, and on one machine here it reads lower than the package inside it. The MMIO
|
||||||
|
// copy of a package (`intel-rapl-mmio:0`) measures the same thing again. This is the CPU's own
|
||||||
|
// figure, shown beside its clock: cores, built-in graphics and memory controller, not disks,
|
||||||
|
// fans or the power supply.
|
||||||
|
//
|
||||||
|
// The counters are root-only since the PLATYPUS side channel (2020). So, like the GPU client
|
||||||
|
// walk, they are read in-process when the daemon is root, and otherwise by a root helper,
|
||||||
|
// `hamlab cpu-energy`, started once through sudo or doas and asked over a pipe at every
|
||||||
|
// sample: one privilege escalation per daemon, not one a second in the auth log. The helper
|
||||||
|
// takes nothing from the daemon but the newline that asks; it finds and reads the counters
|
||||||
|
// itself. Without root there is no reading, and nothing is shown.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/priv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const raplRoot = "/sys/class/powercap"
|
||||||
|
|
||||||
|
var raplPkgRe = regexp.MustCompile(`^package-\d+$`)
|
||||||
|
|
||||||
|
// raplCount is one package's counter at one moment.
|
||||||
|
type raplCount struct {
|
||||||
|
Dir string `json:"d"`
|
||||||
|
UJ uint64 `json:"uj"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cpuPowerState struct {
|
||||||
|
probed bool
|
||||||
|
dirs []string // the package domains, found at the first sample
|
||||||
|
max map[string]uint64 // each counter's range: where it wraps to zero
|
||||||
|
useHelper bool // the counters are not readable in-process
|
||||||
|
helper *energyHelper
|
||||||
|
prev []raplCount
|
||||||
|
prevAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// raplPackages lists the package domains under root, sorted. intel-rapl:0 is a package;
|
||||||
|
// intel-rapl:0:0 is a part of one (core, uncore, dram).
|
||||||
|
func raplPackages(root string) []string {
|
||||||
|
ents, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range ents {
|
||||||
|
n := e.Name()
|
||||||
|
if !strings.HasPrefix(n, "intel-rapl:") || strings.Count(n, ":") != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if raplPkgRe.MatchString(strings.TrimSpace(readStr(filepath.Join(root, n, "name")))) {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRAPL(root string, dirs []string) ([]raplCount, bool) {
|
||||||
|
out := make([]raplCount, 0, len(dirs))
|
||||||
|
for _, d := range dirs {
|
||||||
|
b, err := os.ReadFile(filepath.Join(root, d, "energy_uj"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out = append(out, raplCount{d, v})
|
||||||
|
}
|
||||||
|
return out, len(out) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// raplWatts is the power between two readings of the same counters. A counter that wrapped
|
||||||
|
// is carried across its range. One that went backwards with no known range, or a set of
|
||||||
|
// counters that changed between readings, gives no answer rather than a wrong one.
|
||||||
|
func raplWatts(prev, cur []raplCount, max map[string]uint64, dt float64) (float64, bool) {
|
||||||
|
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var uj float64
|
||||||
|
for i := range cur {
|
||||||
|
if cur[i].Dir != prev[i].Dir {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if cur[i].UJ >= prev[i].UJ {
|
||||||
|
uj += float64(cur[i].UJ - prev[i].UJ)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := max[cur[i].Dir]
|
||||||
|
if m == 0 || prev[i].UJ > m {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
uj += float64(m - prev[i].UJ + cur[i].UJ)
|
||||||
|
}
|
||||||
|
return uj / 1e6 / dt, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cpuPower is the CPU packages' power since the previous sample, in watts; 0 when this
|
||||||
|
// machine has no counters or no way to read them.
|
||||||
|
func (c *Collector) cpuPower(now time.Time) float64 {
|
||||||
|
p := &c.power
|
||||||
|
if !p.probed {
|
||||||
|
p.probed = true
|
||||||
|
p.dirs = raplPackages(raplRoot)
|
||||||
|
p.max = map[string]uint64{}
|
||||||
|
for _, d := range p.dirs {
|
||||||
|
if v, ok := readInt(filepath.Join(raplRoot, d, "max_energy_range_uj")); ok && v > 0 {
|
||||||
|
p.max[d] = uint64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.dirs) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var cur []raplCount
|
||||||
|
ok := false
|
||||||
|
if !p.useHelper {
|
||||||
|
if cur, ok = readRAPL(raplRoot, p.dirs); !ok && priv.Mode() != "root" && priv.Available() {
|
||||||
|
p.useHelper = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.useHelper {
|
||||||
|
if p.helper == nil {
|
||||||
|
p.helper = &energyHelper{}
|
||||||
|
}
|
||||||
|
cur, ok = p.helper.read()
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
p.prev = nil
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
w, good := raplWatts(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
|
||||||
|
p.prev, p.prevAt = cur, now
|
||||||
|
if !good {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return round1(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// energyHelper is the root child that answers each newline with the package counters, one
|
||||||
|
// JSON line. The same shape as the GPU client walk's helper, kept separate from it.
|
||||||
|
type energyHelper struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cmd *exec.Cmd
|
||||||
|
in io.WriteCloser
|
||||||
|
out *bufio.Reader
|
||||||
|
failedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *energyHelper) read() ([]raplCount, bool) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.cmd == nil {
|
||||||
|
if !h.failedAt.IsZero() && time.Since(h.failedAt) < helperRetry {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !h.start() {
|
||||||
|
h.failedAt = time.Now()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// a stuck child must not stall the sampler: it is killed, and tried again later
|
||||||
|
cmd := h.cmd
|
||||||
|
stop := time.AfterFunc(5*time.Second, func() { cmd.Process.Kill() })
|
||||||
|
defer stop.Stop()
|
||||||
|
if _, err := io.WriteString(h.in, "\n"); err != nil {
|
||||||
|
h.fail()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
line, err := h.out.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
h.fail()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var cs []raplCount
|
||||||
|
if json.Unmarshal(line, &cs) != nil {
|
||||||
|
h.fail()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return cs, len(cs) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *energyHelper) start() bool {
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
full := priv.Wrap(self, "cpu-energy")
|
||||||
|
cmd := exec.Command(full[0], full[1:]...)
|
||||||
|
in, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h.cmd, h.in, h.out = cmd, in, bufio.NewReader(out)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *energyHelper) fail() {
|
||||||
|
if h.cmd != nil {
|
||||||
|
h.in.Close()
|
||||||
|
h.cmd.Process.Kill()
|
||||||
|
go h.cmd.Wait()
|
||||||
|
}
|
||||||
|
h.cmd, h.in, h.out = nil, nil, nil
|
||||||
|
h.failedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPUEnergyHelper is `hamlab cpu-energy`: for every line read on stdin, one JSON line of the
|
||||||
|
// package counters on stdout. Ends when stdin does — when the daemon that started it has gone.
|
||||||
|
func CPUEnergyHelper(stdin io.Reader, stdout io.Writer) int {
|
||||||
|
return cpuEnergyHelper(raplRoot, stdin, stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cpuEnergyHelper(root string, stdin io.Reader, stdout io.Writer) int {
|
||||||
|
dirs := raplPackages(root)
|
||||||
|
sc := bufio.NewScanner(stdin)
|
||||||
|
w := bufio.NewWriter(stdout)
|
||||||
|
for sc.Scan() {
|
||||||
|
cs, ok := readRAPL(root, dirs)
|
||||||
|
if !ok {
|
||||||
|
cs = []raplCount{}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(cs)
|
||||||
|
w.Write(b)
|
||||||
|
w.WriteByte('\n')
|
||||||
|
if w.Flush() != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A fake /sys/class/powercap: a package with its parts, the platform domain, and the MMIO
|
||||||
|
// copy of the same package. Only the package counts.
|
||||||
|
func fakeRAPL(t *testing.T, domains map[string][2]string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
for dir, v := range domains {
|
||||||
|
d := filepath.Join(root, dir)
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.WriteFile(filepath.Join(d, "name"), []byte(v[0]+"\n"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(d, "energy_uj"), []byte(v[1]+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRAPLPackagesOnlyCountsPackages(t *testing.T) {
|
||||||
|
root := fakeRAPL(t, map[string][2]string{
|
||||||
|
"intel-rapl:0": {"package-0", "1000"},
|
||||||
|
"intel-rapl:0:0": {"core", "600"},
|
||||||
|
"intel-rapl:0:1": {"uncore", "10"},
|
||||||
|
"intel-rapl:1": {"psys", "300"},
|
||||||
|
"intel-rapl-mmio:0": {"package-0", "1000"},
|
||||||
|
})
|
||||||
|
if got := raplPackages(root); len(got) != 1 || got[0] != "intel-rapl:0" {
|
||||||
|
t.Fatalf("packages = %v, want [intel-rapl:0]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
two := fakeRAPL(t, map[string][2]string{"intel-rapl:0": {"package-0", "5"}, "intel-rapl:1": {"package-1", "7"}})
|
||||||
|
got := raplPackages(two)
|
||||||
|
if strings.Join(got, ",") != "intel-rapl:0,intel-rapl:1" {
|
||||||
|
t.Fatalf("two sockets: packages = %v", got)
|
||||||
|
}
|
||||||
|
cs, ok := readRAPL(two, got)
|
||||||
|
if !ok || len(cs) != 2 || cs[0].UJ != 5 || cs[1].UJ != 7 {
|
||||||
|
t.Fatalf("readRAPL = %+v, %v", cs, ok)
|
||||||
|
}
|
||||||
|
if got := raplPackages(filepath.Join(two, "nope")); len(got) != 0 {
|
||||||
|
t.Fatalf("no powercap: packages = %v, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRAPLWatts(t *testing.T) {
|
||||||
|
c := func(d string, uj uint64) raplCount { return raplCount{d, uj} }
|
||||||
|
max := map[string]uint64{"intel-rapl:0": 1_000_000_000}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
prev, cur []raplCount
|
||||||
|
dt float64
|
||||||
|
want float64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"steady", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:0", 10_000_000)}, 2, 5, true},
|
||||||
|
{"two packages add", []raplCount{c("intel-rapl:0", 0), c("intel-rapl:1", 0)}, []raplCount{c("intel-rapl:0", 4_000_000), c("intel-rapl:1", 6_000_000)}, 1, 10, true},
|
||||||
|
{"wrapped", []raplCount{c("intel-rapl:0", 999_000_000)}, []raplCount{c("intel-rapl:0", 1_000_000)}, 1, 2, true},
|
||||||
|
{"backwards, range unknown", []raplCount{c("intel-rapl:1", 5_000_000)}, []raplCount{c("intel-rapl:1", 1)}, 1, 0, false},
|
||||||
|
{"counters changed", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:1", 9)}, 1, 0, false},
|
||||||
|
{"first reading", nil, []raplCount{c("intel-rapl:0", 9)}, 1, 0, false},
|
||||||
|
{"no time passed", []raplCount{c("intel-rapl:0", 0)}, []raplCount{c("intel-rapl:0", 9)}, 0, 0, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, ok := raplWatts(tc.prev, tc.cur, max, tc.dt)
|
||||||
|
if ok != tc.ok || (ok && (got < tc.want-1e-9 || got > tc.want+1e-9)) {
|
||||||
|
t.Errorf("%s: got %v, %v; want %v, %v", tc.name, got, ok, tc.want, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The helper answers every newline with the counters it found itself, and stops at EOF.
|
||||||
|
func TestCPUEnergyHelper(t *testing.T) {
|
||||||
|
root := fakeRAPL(t, map[string][2]string{"intel-rapl:0": {"package-0", "42"}, "intel-rapl:0:0": {"core", "7"}})
|
||||||
|
var out bytes.Buffer
|
||||||
|
if code := cpuEnergyHelper(root, strings.NewReader("\n\n"), &out); code != 0 {
|
||||||
|
t.Fatalf("exit %d", code)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||||
|
if len(lines) != 2 {
|
||||||
|
t.Fatalf("got %d lines, want 2: %q", len(lines), out.String())
|
||||||
|
}
|
||||||
|
var cs []raplCount
|
||||||
|
if err := json.Unmarshal([]byte(lines[1]), &cs); err != nil || len(cs) != 1 || cs[0] != (raplCount{"intel-rapl:0", 42}) {
|
||||||
|
t.Fatalf("line = %s (%v)", lines[1], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History keeps the average of the samples that had a reading.
|
||||||
|
func TestFoldCPUPower(t *testing.T) {
|
||||||
|
b := []Sample{{T: 1, CPUPower: 4}, {T: 2}, {T: 3, CPUPower: 8}}
|
||||||
|
if got := fold(b).CPUPower; got != 6 {
|
||||||
|
t.Fatalf("folded CPU power = %v, want 6", got)
|
||||||
|
}
|
||||||
|
if got := fold([]Sample{{T: 1}, {T: 2}}).CPUPower; got != 0 {
|
||||||
|
t.Fatalf("no readings folded to %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,17 @@ func fold(b []Sample) Sample {
|
|||||||
return t / uint64(len(b))
|
return t / uint64(len(b))
|
||||||
}
|
}
|
||||||
out.CPU = sum(func(s Sample) float64 { return s.CPU })
|
out.CPU = sum(func(s Sample) float64 { return s.CPU })
|
||||||
|
// CPU power: over the samples that have a reading, so a helper restart is not a dip to zero
|
||||||
|
var pw, pn float64
|
||||||
|
for _, s := range b {
|
||||||
|
if s.CPUPower > 0 {
|
||||||
|
pw, pn = pw+s.CPUPower, pn+1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.CPUPower = 0
|
||||||
|
if pn > 0 {
|
||||||
|
out.CPUPower = round1(pw / pn)
|
||||||
|
}
|
||||||
out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] })
|
out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] })
|
||||||
out.Load[1] = sum(func(s Sample) float64 { return s.Load[1] })
|
out.Load[1] = sum(func(s Sample) float64 { return s.Load[1] })
|
||||||
out.Load[2] = sum(func(s Sample) float64 { return s.Load[2] })
|
out.Load[2] = sum(func(s Sample) float64 { return s.Load[2] })
|
||||||
|
|||||||
@@ -0,0 +1,680 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
// GPU telemetry, the way btop and intel_gpu_top get it without a vendor library: the
|
||||||
|
// device from sysfs, and who is using it from DRM fdinfo.
|
||||||
|
//
|
||||||
|
// - The device: /sys/class/drm/card<n> with a driver we know to be a GPU. Clock from the
|
||||||
|
// driver's frequency files, temperature/power/fan from the hwmon under the device, and
|
||||||
|
// how busy it is from the one counter every Intel GT exports without root — RC6 idle
|
||||||
|
// residency, so busy = 100 − idle. amdgpu says it outright (gpu_busy_percent).
|
||||||
|
// - The clients: every process holding /dev/dri/* open has a drm-engine-<name> running
|
||||||
|
// total (nanoseconds) in /proc/<pid>/fdinfo/<fd>; the delta between two reads is that
|
||||||
|
// process's share of each engine, and the sum across clients is the engine's load. Other
|
||||||
|
// users' fdinfo is unreadable, so when the daemon isn't root it keeps one root child
|
||||||
|
// (`hamlab drm-clients`) that does the walk, asked over a pipe — one sudo per daemon
|
||||||
|
// start, not one per second.
|
||||||
|
//
|
||||||
|
// A GPU with no counter of either kind (Asahi, today) is still listed: a name is better
|
||||||
|
// than silence about hardware the machine has.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/priv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GPU is one graphics device as a Sample reports it.
|
||||||
|
type GPU struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Bus string `json:"bus,omitempty"` // PCI address; empty for a platform device
|
||||||
|
Integrated bool `json:"integrated,omitempty"`
|
||||||
|
Usage float64 `json:"usage"` // % busy
|
||||||
|
UsageSrc string `json:"usage_src,omitempty"` // "idle" (100 − idle residency), "busy" (the driver's own %), "clients" (the busiest engine); "" when nothing measures it
|
||||||
|
Engines map[string]float64 `json:"engines,omitempty"` // render/video/copy/enhance… % from the clients, normalised by engine count
|
||||||
|
Temp float64 `json:"temp,omitempty"`
|
||||||
|
TempSrc string `json:"temp_src,omitempty"` // "gpu" from the device's own sensor, "package" the CPU die an integrated GPU shares
|
||||||
|
PowerW float64 `json:"power_w,omitempty"`
|
||||||
|
PowerCapW float64 `json:"power_cap_w,omitempty"`
|
||||||
|
FanRPM int `json:"fan_rpm,omitempty"`
|
||||||
|
MHz float64 `json:"mhz,omitempty"` // actual clock (0 when power-gated)
|
||||||
|
ReqMHz float64 `json:"req_mhz,omitempty"` // what the driver asked for
|
||||||
|
MaxMHz float64 `json:"max_mhz,omitempty"`
|
||||||
|
MemUsed uint64 `json:"mem_used,omitempty"` // VRAM used, or the clients' resident bytes where the driver has no VRAM counter
|
||||||
|
MemTotal uint64 `json:"mem_total,omitempty"`
|
||||||
|
Clients []GPUClient `json:"clients"`
|
||||||
|
ClientsOK bool `json:"clients_ok"` // false when fdinfo of other users' processes could not be read
|
||||||
|
Stalled bool `json:"stalled,omitempty"` // a sysfs read of this card never returned; its readings are paused until restart
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPUClient is one process with the device open.
|
||||||
|
type GPUClient struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Usage float64 `json:"usage"` // its busiest engine, %
|
||||||
|
Engines map[string]float64 `json:"engines,omitempty"`
|
||||||
|
Mem uint64 `json:"mem,omitempty"` // resident bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpuDrivers are DRM drivers that drive a GPU, not just a scanout (simpledrm, ast, the
|
||||||
|
// Apple display coprocessor) — those have no engines and nothing to say.
|
||||||
|
var gpuDrivers = map[string]bool{"i915": true, "xe": true, "amdgpu": true, "radeon": true, "nouveau": true, "nvidia-drm": true,
|
||||||
|
"asahi": true, "panfrost": true, "panthor": true, "v3d": true, "msm": true, "etnaviv": true, "lima": true, "powervr": true, "virtio_gpu": true}
|
||||||
|
|
||||||
|
type gpuDev struct {
|
||||||
|
card, dev, key, bus, driver, name, hwmon string
|
||||||
|
integrated, stalled bool
|
||||||
|
prevIdle, prevEnergy float64
|
||||||
|
prevAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type gpuState struct {
|
||||||
|
devs []*gpuDev
|
||||||
|
listedAt time.Time
|
||||||
|
clients map[string]clientPrev // device key + client id → engine totals at the previous scan
|
||||||
|
last []drmFD // the newest client walk, reused between walks
|
||||||
|
scanAt time.Time
|
||||||
|
helper *drmHelper
|
||||||
|
ok bool // the last walk saw every process
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientPrev struct {
|
||||||
|
ns map[string]uint64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpuRelist = time.Minute // how often the card list is re-read: hotplug is rare, the readdir is cheap
|
||||||
|
const gpuScanEvery = time.Second // engine shares over less than this are noise
|
||||||
|
|
||||||
|
// gpus is the GPU section of a sample. cpuTemp stands in for an integrated GPU's
|
||||||
|
// temperature: it is the same die.
|
||||||
|
func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
|
||||||
|
g := &c.gpu
|
||||||
|
if g.devs == nil || now.Sub(g.listedAt) >= gpuRelist {
|
||||||
|
g.devs, g.listedAt = listGPUs(g.devs), now
|
||||||
|
}
|
||||||
|
if len(g.devs) == 0 {
|
||||||
|
return []GPU{}
|
||||||
|
}
|
||||||
|
if g.clients == nil {
|
||||||
|
g.clients = map[string]clientPrev{}
|
||||||
|
}
|
||||||
|
if g.scanAt.IsZero() || now.Sub(g.scanAt) >= gpuScanEvery {
|
||||||
|
g.last, g.ok = c.scanClients()
|
||||||
|
g.scanAt = now
|
||||||
|
}
|
||||||
|
out := make([]GPU, 0, len(g.devs))
|
||||||
|
live := map[string]bool{}
|
||||||
|
for _, d := range g.devs {
|
||||||
|
x := GPU{Name: d.name, Driver: d.driver, Bus: d.bus, Integrated: d.integrated, Clients: []GPUClient{}, ClientsOK: g.ok}
|
||||||
|
d.readWithin(&x, now)
|
||||||
|
if x.Temp == 0 && d.integrated && cpuTemp > 0 {
|
||||||
|
x.Temp, x.TempSrc = cpuTemp, "package"
|
||||||
|
}
|
||||||
|
// the clients: one entry per client id, under the first pid that holds it (a child
|
||||||
|
// that inherited the fd shares the client)
|
||||||
|
byID := map[string]*GPUClient{}
|
||||||
|
var order []string
|
||||||
|
engines := map[string]float64{}
|
||||||
|
var resident uint64
|
||||||
|
for _, f := range g.last {
|
||||||
|
fi := parseFdinfo(f.Info)
|
||||||
|
if fi.pdev != "" && fi.pdev != d.bus || fi.pdev == "" && fi.driver != d.driver {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := d.key + "/" + fi.client
|
||||||
|
live[key] = true
|
||||||
|
if byID[fi.client] != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cl := &GPUClient{PID: f.PID, Name: f.Comm, Mem: fi.mem}
|
||||||
|
byID[fi.client] = cl
|
||||||
|
order = append(order, fi.client)
|
||||||
|
resident += fi.mem
|
||||||
|
if prev, ok := g.clients[key]; ok {
|
||||||
|
if dt := now.Sub(prev.at).Nanoseconds(); dt > 0 {
|
||||||
|
for e, ns := range fi.engines {
|
||||||
|
p, ok := prev.ns[e]
|
||||||
|
if !ok || ns < p {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pct := float64(ns-p) / float64(dt) * 100
|
||||||
|
if n := fi.capacity[e]; n > 1 {
|
||||||
|
pct /= float64(n)
|
||||||
|
}
|
||||||
|
pct = round1(pct)
|
||||||
|
if cl.Engines == nil {
|
||||||
|
cl.Engines = map[string]float64{}
|
||||||
|
}
|
||||||
|
cl.Engines[e] = pct
|
||||||
|
engines[e] += pct
|
||||||
|
if pct > cl.Usage {
|
||||||
|
cl.Usage = pct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.clients[key] = clientPrev{ns: fi.engines, at: now}
|
||||||
|
}
|
||||||
|
for _, id := range order {
|
||||||
|
x.Clients = append(x.Clients, *byID[id])
|
||||||
|
}
|
||||||
|
sort.SliceStable(x.Clients, func(i, j int) bool { return x.Clients[i].Usage > x.Clients[j].Usage })
|
||||||
|
if len(engines) > 0 {
|
||||||
|
x.Engines = map[string]float64{}
|
||||||
|
for e, v := range engines {
|
||||||
|
if v > 100 {
|
||||||
|
v = 100
|
||||||
|
}
|
||||||
|
x.Engines[e] = round1(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if x.UsageSrc == "" && len(x.Engines) > 0 {
|
||||||
|
for _, v := range x.Engines {
|
||||||
|
if v > x.Usage {
|
||||||
|
x.Usage = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x.UsageSrc = "clients"
|
||||||
|
}
|
||||||
|
if x.MemTotal == 0 && x.MemUsed == 0 {
|
||||||
|
x.MemUsed = resident
|
||||||
|
}
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
for k := range g.clients { // a client that closed the device
|
||||||
|
if !live[k] {
|
||||||
|
delete(g.clients, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// listGPUs enumerates the cards, keeping the counters of those already known.
|
||||||
|
func listGPUs(known []*gpuDev) []*gpuDev {
|
||||||
|
ents, _ := os.ReadDir("/sys/class/drm")
|
||||||
|
old := map[string]*gpuDev{}
|
||||||
|
for _, d := range known {
|
||||||
|
old[d.card] = d
|
||||||
|
}
|
||||||
|
out := []*gpuDev{}
|
||||||
|
for _, e := range ents {
|
||||||
|
n := e.Name()
|
||||||
|
if !strings.HasPrefix(n, "card") || strings.Contains(n, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
card := "/sys/class/drm/" + n
|
||||||
|
dev, err := filepath.EvalSymlinks(card + "/device")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
driver := ueventValue(dev+"/uevent", "DRIVER")
|
||||||
|
if driver == "" { // some drivers hang the DRIVER key off the parent
|
||||||
|
driver = filepath.Base(readLink(dev + "/driver"))
|
||||||
|
}
|
||||||
|
if !gpuDrivers[driver] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d, ok := old[card]; ok && d.dev == dev {
|
||||||
|
out = append(out, d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := &gpuDev{card: card, dev: dev, driver: driver}
|
||||||
|
if pci := ueventValue(dev+"/uevent", "PCI_SLOT_NAME"); pci != "" {
|
||||||
|
d.bus, d.key = pci, pci
|
||||||
|
// an on-die GPU hangs off root bus 0 (Intel's 00:02.0, an AMD APU's 00:08.0 bridge)
|
||||||
|
d.integrated = strings.HasPrefix(pci, "0000:00:")
|
||||||
|
} else {
|
||||||
|
d.key = "driver:" + driver
|
||||||
|
d.integrated = true
|
||||||
|
}
|
||||||
|
if hw, _ := filepath.Glob(dev + "/hwmon/hwmon*"); len(hw) > 0 {
|
||||||
|
d.hwmon = hw[0]
|
||||||
|
}
|
||||||
|
d.name = gpuName(dev, d.bus, driver)
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].card < out[j].card })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var gpuVendors = map[string]string{"0x8086": "Intel", "0x1002": "AMD", "0x10de": "NVIDIA", "0x1af4": "Virtio", "0x106b": "Apple"}
|
||||||
|
|
||||||
|
var (
|
||||||
|
revRe = regexp.MustCompile(`\s*\(rev [^)]*\)`)
|
||||||
|
bracketRe = regexp.MustCompile(`\[([^\]]+)\]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// gpuName is "Intel Arc A380": from the embedded PCI ID table first, so the name is the
|
||||||
|
// same on every machine; from lspci for a card newer than the table; and failing both,
|
||||||
|
// the vendor and the raw device ID.
|
||||||
|
func gpuName(dev, bus, driver string) string {
|
||||||
|
if bus != "" {
|
||||||
|
vid := strings.TrimPrefix(strings.TrimSpace(readStr(dev+"/vendor")), "0x")
|
||||||
|
did := strings.TrimPrefix(strings.TrimSpace(readStr(dev+"/device")), "0x")
|
||||||
|
if vn, dn := pciName(vid, did); dn != "" {
|
||||||
|
return lspciName(vn + " " + dn)
|
||||||
|
}
|
||||||
|
if out, ok := runOut("lspci", "-s", bus); ok {
|
||||||
|
if _, desc, found := strings.Cut(out, ": "); found {
|
||||||
|
return lspciName(desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vendor := gpuVendors[strings.TrimSpace(readStr(dev+"/vendor"))]
|
||||||
|
if compat := readStr(dev + "/of_node/compatible"); compat != "" { // "apple,agx-t8103\0apple,agx-g13g"
|
||||||
|
first := strings.SplitN(compat, "\x00", 2)[0]
|
||||||
|
if v, model, ok := strings.Cut(first, ","); ok {
|
||||||
|
if vendor == "" && v != "" {
|
||||||
|
vendor = strings.ToUpper(v[:1]) + v[1:]
|
||||||
|
}
|
||||||
|
return vendor + " GPU (" + model + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vendor == "" && driver != "" {
|
||||||
|
vendor = strings.ToUpper(driver[:1]) + driver[1:]
|
||||||
|
}
|
||||||
|
if id := strings.TrimPrefix(strings.TrimSpace(readStr(dev+"/device")), "0x"); id != "" {
|
||||||
|
return strings.TrimSpace(vendor + " GPU " + id)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(vendor + " GPU")
|
||||||
|
}
|
||||||
|
|
||||||
|
// lspciName turns "Intel Corporation DG2 [Arc A380] (rev 05)" into "Intel Arc A380".
|
||||||
|
func lspciName(desc string) string {
|
||||||
|
desc = revRe.ReplaceAllString(strings.TrimSpace(desc), "")
|
||||||
|
vendor := ""
|
||||||
|
for _, v := range []struct{ long, short string }{{"Intel Corporation", "Intel"}, {"Advanced Micro Devices, Inc. [AMD/ATI]", "AMD"}, {"Advanced Micro Devices, Inc. [AMD]", "AMD"}, {"NVIDIA Corporation", "NVIDIA"}, {"Red Hat, Inc.", "Virtio"}} {
|
||||||
|
if strings.HasPrefix(desc, v.long) {
|
||||||
|
vendor = v.short
|
||||||
|
desc = strings.TrimSpace(desc[len(v.long):])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vendor == "" { // vendor not recognised: its first word
|
||||||
|
if i := strings.IndexByte(desc, ' '); i > 0 {
|
||||||
|
vendor, desc = desc[:i], strings.TrimSpace(desc[i+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m := bracketRe.FindStringSubmatch(desc); m != nil {
|
||||||
|
desc = m[1]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(vendor + " " + desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readWithin is read with a deadline. A sysfs read that does not come back must not take
|
||||||
|
// the whole sampler with it — every graph on every page of this machine stops — so the
|
||||||
|
// device is marked stalled, its goroutine abandoned, and the rest of the sample goes on.
|
||||||
|
const gpuReadWithin = 3 * time.Second
|
||||||
|
|
||||||
|
func (d *gpuDev) readWithin(x *GPU, now time.Time) {
|
||||||
|
if d.stalled {
|
||||||
|
x.Stalled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done := make(chan GPU, 1)
|
||||||
|
go func() {
|
||||||
|
y := *x
|
||||||
|
d.read(&y, now)
|
||||||
|
done <- y
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case y := <-done:
|
||||||
|
*x = y
|
||||||
|
case <-time.After(gpuReadWithin):
|
||||||
|
d.stalled, x.Stalled = true, true
|
||||||
|
log.Printf("gpu: %s (%s): a sysfs read did not return within %s; its readings are paused until restart", d.name, d.card, gpuReadWithin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// read fills the device-level numbers: clock, idle/busy, hwmon.
|
||||||
|
func (d *gpuDev) read(x *GPU, now time.Time) {
|
||||||
|
dt := now.Sub(d.prevAt).Seconds()
|
||||||
|
switch d.driver {
|
||||||
|
case "i915":
|
||||||
|
x.MHz = readF(d.card + "/gt_act_freq_mhz")
|
||||||
|
x.ReqMHz = readF(d.card + "/gt_cur_freq_mhz")
|
||||||
|
x.MaxMHz = readF(d.card + "/gt_max_freq_mhz")
|
||||||
|
idle, ok := readInt(d.card + "/gt/gt0/rc6_residency_ms")
|
||||||
|
if !ok {
|
||||||
|
idle, ok = readInt(d.card + "/power/rc6_residency_ms")
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
d.busyFromIdle(x, float64(idle), dt)
|
||||||
|
}
|
||||||
|
case "xe":
|
||||||
|
gt := d.dev + "/tile0/gt0"
|
||||||
|
x.MHz = readF(gt + "/freq0/act_freq")
|
||||||
|
x.ReqMHz = readF(gt + "/freq0/cur_freq")
|
||||||
|
x.MaxMHz = readF(gt + "/freq0/max_freq")
|
||||||
|
if idle, ok := readInt(gt + "/gtidle/idle_residency_ms"); ok {
|
||||||
|
d.busyFromIdle(x, float64(idle), dt)
|
||||||
|
}
|
||||||
|
case "amdgpu":
|
||||||
|
if v, ok := readInt(d.dev + "/gpu_busy_percent"); ok {
|
||||||
|
x.Usage, x.UsageSrc = float64(v), "busy"
|
||||||
|
}
|
||||||
|
x.MemUsed = uint64(readF(d.dev + "/mem_info_vram_used"))
|
||||||
|
x.MemTotal = uint64(readF(d.dev + "/mem_info_vram_total"))
|
||||||
|
// "1: 1800Mhz *" — the starred level is the current one, the last the highest
|
||||||
|
for _, line := range strings.Split(readStr(d.dev+"/pp_dpm_sclk"), "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
if len(f) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mhz := leadingNum(f[1])
|
||||||
|
x.MaxMHz = mhz
|
||||||
|
if strings.HasSuffix(line, "*") {
|
||||||
|
x.MHz = mhz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.hwmon != "" {
|
||||||
|
if t := readF(d.hwmon + "/temp1_input"); t > 0 {
|
||||||
|
x.Temp, x.TempSrc = t/1000, "gpu"
|
||||||
|
}
|
||||||
|
if p := readF(d.hwmon + "/power1_input"); p > 0 {
|
||||||
|
x.PowerW = round1(p / 1e6)
|
||||||
|
} else if p := readF(d.hwmon + "/power1_average"); p > 0 {
|
||||||
|
x.PowerW = round1(p / 1e6)
|
||||||
|
} else if e := readF(d.hwmon + "/energy1_input"); e > 0 { // µJ, cumulative
|
||||||
|
if d.prevEnergy > 0 && e >= d.prevEnergy && dt > 0 && !d.prevAt.IsZero() {
|
||||||
|
x.PowerW = round1((e - d.prevEnergy) / 1e6 / dt)
|
||||||
|
}
|
||||||
|
d.prevEnergy = e
|
||||||
|
}
|
||||||
|
if p := readF(d.hwmon + "/power1_cap"); p > 0 {
|
||||||
|
x.PowerCapW = p / 1e6
|
||||||
|
} else if p := readF(d.hwmon + "/power1_max"); p > 0 {
|
||||||
|
x.PowerCapW = p / 1e6
|
||||||
|
}
|
||||||
|
x.FanRPM = int(readF(d.hwmon + "/fan1_input"))
|
||||||
|
}
|
||||||
|
d.prevAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// busyFromIdle: the GT's idle residency is a running ms total; the share of the interval
|
||||||
|
// it did not cover is how busy the device was.
|
||||||
|
func (d *gpuDev) busyFromIdle(x *GPU, idle, dt float64) {
|
||||||
|
x.UsageSrc = "idle"
|
||||||
|
if d.prevAt.IsZero() || dt <= 0 || idle < d.prevIdle {
|
||||||
|
d.prevIdle = idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy := 100 - (idle-d.prevIdle)/(dt*1000)*100
|
||||||
|
if busy < 0 {
|
||||||
|
busy = 0
|
||||||
|
}
|
||||||
|
if busy > 100 {
|
||||||
|
busy = 100
|
||||||
|
}
|
||||||
|
x.Usage = round1(busy)
|
||||||
|
d.prevIdle = idle
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DRM fdinfo: who has the device open ----
|
||||||
|
|
||||||
|
// drmFD is one client of a DRM device: the fdinfo text of one open fd, with its owner.
|
||||||
|
type drmFD struct {
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Comm string `json:"comm"`
|
||||||
|
Info string `json:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fdinfo struct {
|
||||||
|
driver, pdev, client string
|
||||||
|
engines map[string]uint64 // ns, cumulative
|
||||||
|
capacity map[string]int
|
||||||
|
mem uint64 // bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFdinfo(s string) fdinfo {
|
||||||
|
fi := fdinfo{engines: map[string]uint64{}, capacity: map[string]int{}}
|
||||||
|
var resident, memory uint64
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, ":")
|
||||||
|
if !ok || !strings.HasPrefix(k, "drm-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
switch {
|
||||||
|
case k == "drm-driver":
|
||||||
|
fi.driver = v
|
||||||
|
case k == "drm-pdev":
|
||||||
|
fi.pdev = v
|
||||||
|
case k == "drm-client-id":
|
||||||
|
fi.client = v
|
||||||
|
case strings.HasPrefix(k, "drm-engine-capacity-"):
|
||||||
|
fi.capacity[k[len("drm-engine-capacity-"):]], _ = strconv.Atoi(v)
|
||||||
|
case strings.HasPrefix(k, "drm-engine-"):
|
||||||
|
n, _ := strconv.ParseUint(strings.TrimSuffix(v, " ns"), 10, 64)
|
||||||
|
fi.engines[k[len("drm-engine-"):]] = n
|
||||||
|
case strings.HasPrefix(k, "drm-resident-"):
|
||||||
|
resident += kib(v)
|
||||||
|
case strings.HasPrefix(k, "drm-memory-"):
|
||||||
|
memory += kib(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fi.mem = resident
|
||||||
|
if fi.mem == 0 {
|
||||||
|
fi.mem = memory
|
||||||
|
}
|
||||||
|
return fi
|
||||||
|
}
|
||||||
|
|
||||||
|
// kib parses "52624 KiB" (or MiB, or a bare count of bytes) to bytes.
|
||||||
|
func kib(v string) uint64 {
|
||||||
|
f := strings.Fields(v)
|
||||||
|
if len(f) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.ParseUint(f[0], 10, 64)
|
||||||
|
if len(f) > 1 {
|
||||||
|
switch f[1] {
|
||||||
|
case "KiB":
|
||||||
|
n <<= 10
|
||||||
|
case "MiB":
|
||||||
|
n <<= 20
|
||||||
|
case "GiB":
|
||||||
|
n <<= 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// drmScan walks /proc for every fd open on /dev/dri/*, one entry per client per process.
|
||||||
|
// Run as root it sees everyone; as a user, only that user's processes.
|
||||||
|
func drmScan() (out []drmFD, complete bool) {
|
||||||
|
ents, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return []drmFD{}, false
|
||||||
|
}
|
||||||
|
complete = true
|
||||||
|
out = []drmFD{}
|
||||||
|
for _, e := range ents {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fds, err := os.ReadDir("/proc/" + e.Name() + "/fd")
|
||||||
|
if err != nil {
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
complete = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
comm := ""
|
||||||
|
for _, fd := range fds {
|
||||||
|
target, err := os.Readlink("/proc/" + e.Name() + "/fd/" + fd.Name())
|
||||||
|
if err != nil || !strings.HasPrefix(target, "/dev/dri/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info := readStr("/proc/" + e.Name() + "/fdinfo/" + fd.Name())
|
||||||
|
_, rest, ok := strings.Cut(info, "drm-client-id:")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(strings.SplitN(rest, "\n", 2)[0])
|
||||||
|
if seen[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
if comm == "" {
|
||||||
|
comm = strings.TrimSpace(readStr("/proc/" + e.Name() + "/comm"))
|
||||||
|
}
|
||||||
|
out = append(out, drmFD{PID: pid, Comm: comm, Info: info})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||||
|
return out, complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanClients walks in-process when that sees everything (root), else through the helper.
|
||||||
|
func (c *Collector) scanClients() ([]drmFD, bool) {
|
||||||
|
if priv.Mode() == "root" || !priv.Available() {
|
||||||
|
return drmScan()
|
||||||
|
}
|
||||||
|
if c.gpu.helper == nil {
|
||||||
|
c.gpu.helper = &drmHelper{}
|
||||||
|
}
|
||||||
|
if fds, ok := c.gpu.helper.scan(); ok {
|
||||||
|
return fds, true
|
||||||
|
}
|
||||||
|
return drmScan() // the helper is down: our own processes are better than nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
// drmHelper is the root child that answers "scan" requests. One line in, one JSON line out.
|
||||||
|
type drmHelper struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cmd *exec.Cmd
|
||||||
|
in io.WriteCloser
|
||||||
|
out *bufio.Reader
|
||||||
|
failedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const helperRetry = 30 * time.Second
|
||||||
|
|
||||||
|
func (h *drmHelper) scan() ([]drmFD, bool) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if h.cmd == nil {
|
||||||
|
if !h.failedAt.IsZero() && time.Since(h.failedAt) < helperRetry {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !h.start() {
|
||||||
|
h.failedAt = time.Now()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// a stuck child must not stall the sampler: it is killed, and tried again later
|
||||||
|
cmd := h.cmd
|
||||||
|
stop := time.AfterFunc(5*time.Second, func() { cmd.Process.Kill() })
|
||||||
|
defer stop.Stop()
|
||||||
|
if _, err := io.WriteString(h.in, "\n"); err != nil {
|
||||||
|
h.fail()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
line, err := h.out.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
h.fail()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var fds []drmFD
|
||||||
|
if json.Unmarshal(line, &fds) != nil {
|
||||||
|
h.fail()
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if fds == nil {
|
||||||
|
fds = []drmFD{}
|
||||||
|
}
|
||||||
|
return fds, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *drmHelper) start() bool {
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
full := priv.Wrap(self, "drm-clients")
|
||||||
|
cmd := exec.Command(full[0], full[1:]...)
|
||||||
|
in, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
out, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
h.cmd, h.in, h.out = cmd, in, bufio.NewReaderSize(out, 1<<20)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *drmHelper) fail() {
|
||||||
|
if h.cmd != nil {
|
||||||
|
h.in.Close()
|
||||||
|
h.cmd.Process.Kill()
|
||||||
|
go h.cmd.Wait()
|
||||||
|
}
|
||||||
|
h.cmd, h.in, h.out = nil, nil, nil
|
||||||
|
h.failedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DRMClientsHelper is `hamlab drm-clients`: for every line read on stdin, one JSON line of
|
||||||
|
// drmFD on stdout. Ends when stdin does — when the daemon that started it has gone.
|
||||||
|
func DRMClientsHelper(stdin io.Reader, stdout io.Writer) int {
|
||||||
|
sc := bufio.NewScanner(stdin)
|
||||||
|
w := bufio.NewWriter(stdout)
|
||||||
|
for sc.Scan() {
|
||||||
|
fds, _ := drmScan()
|
||||||
|
b, _ := json.Marshal(fds)
|
||||||
|
w.Write(b)
|
||||||
|
w.WriteByte('\n')
|
||||||
|
if w.Flush() != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- small helpers ----
|
||||||
|
|
||||||
|
func readF(p string) float64 {
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSpace(readStr(p)), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLink(p string) string {
|
||||||
|
t, err := os.Readlink(p)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func ueventValue(p, key string) string {
|
||||||
|
for _, line := range strings.Split(readStr(p), "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, "="); ok && k == key {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fdinfoA = `pos: 0
|
||||||
|
flags: 02100002
|
||||||
|
drm-driver: i915
|
||||||
|
drm-client-id: 544
|
||||||
|
drm-pdev: 0000:00:02.0
|
||||||
|
drm-total-system0: 52624 KiB
|
||||||
|
drm-resident-system0: 50140 KiB
|
||||||
|
drm-resident-stolen-system0: 32 KiB
|
||||||
|
drm-engine-render: 1000000000 ns
|
||||||
|
drm-engine-copy: 0 ns
|
||||||
|
drm-engine-video: 2000000000 ns
|
||||||
|
drm-engine-capacity-video: 2
|
||||||
|
drm-engine-video-enhance: 0 ns
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseFdinfo(t *testing.T) {
|
||||||
|
fi := parseFdinfo(fdinfoA)
|
||||||
|
if fi.driver != "i915" || fi.pdev != "0000:00:02.0" || fi.client != "544" {
|
||||||
|
t.Fatalf("identity: %+v", fi)
|
||||||
|
}
|
||||||
|
if fi.engines["video"] != 2000000000 || fi.capacity["video"] != 2 {
|
||||||
|
t.Errorf("video engine: %+v", fi)
|
||||||
|
}
|
||||||
|
if fi.mem != (50140+32)<<10 {
|
||||||
|
t.Errorf("resident = %d", fi.mem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLspciName(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"Intel Corporation DG2 [Arc A380] (rev 05)": "Intel Arc A380",
|
||||||
|
"Intel Corporation RocketLake-S GT1 [UHD Graphics 750] (rev 04)": "Intel UHD Graphics 750",
|
||||||
|
"Intel Corporation Alder Lake-N [UHD Graphics]": "Intel UHD Graphics",
|
||||||
|
"Advanced Micro Devices, Inc. [AMD/ATI] Navi 23 [Radeon RX 6600/6600 XT/6600M] (rev c7)": "AMD Radeon RX 6600/6600 XT/6600M",
|
||||||
|
"NVIDIA Corporation GA106 [GeForce RTX 3060 Lite Hash Rate] (rev a1)": "NVIDIA GeForce RTX 3060 Lite Hash Rate",
|
||||||
|
"Moore Threads Technology Co.,Ltd MTT S80": "Moore Threads Technology Co.,Ltd MTT S80",
|
||||||
|
} {
|
||||||
|
if got := lspciName(in); got != want {
|
||||||
|
t.Errorf("%q → %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two scans a second apart: a client that ran its render engine for half of it and both
|
||||||
|
// video engines flat out reads 50% render, 100% video, and the device's engines follow.
|
||||||
|
func TestGPUClientsShare(t *testing.T) {
|
||||||
|
c := NewCollector()
|
||||||
|
d := &gpuDev{card: "/nonexistent/card9", dev: "/nonexistent", key: "0000:00:02.0", bus: "0000:00:02.0", driver: "i915", name: "Test GPU", integrated: true}
|
||||||
|
c.gpu.devs = []*gpuDev{d}
|
||||||
|
c.gpu.listedAt = time.Now().Add(time.Hour) // keep the fake list
|
||||||
|
c.gpu.clients = map[string]clientPrev{}
|
||||||
|
t0 := time.Now()
|
||||||
|
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: fdinfoA}}
|
||||||
|
c.gpu.scanAt = t0 // no walk: the fake list stands
|
||||||
|
first := c.gpus(t0, 40)
|
||||||
|
if len(first) != 1 || len(first[0].Clients) != 1 || first[0].Clients[0].Usage != 0 {
|
||||||
|
t.Fatalf("first pass: %+v", first)
|
||||||
|
}
|
||||||
|
if first[0].Temp != 40 || first[0].TempSrc != "package" {
|
||||||
|
t.Errorf("an integrated GPU wears the package temperature: %+v", first[0])
|
||||||
|
}
|
||||||
|
later := fdinfoA
|
||||||
|
later = replace(later, "drm-engine-render: 1000000000 ns", "drm-engine-render: 1500000000 ns")
|
||||||
|
later = replace(later, "drm-engine-video: 2000000000 ns", "drm-engine-video: 4000000000 ns")
|
||||||
|
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: later}}
|
||||||
|
c.gpu.scanAt = t0.Add(time.Second)
|
||||||
|
got := c.gpus(t0.Add(time.Second), 40)
|
||||||
|
cl := got[0].Clients[0]
|
||||||
|
if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 {
|
||||||
|
t.Errorf("client share: %+v", cl)
|
||||||
|
}
|
||||||
|
if got[0].Engines["video"] != 100 || got[0].Usage != 100 || got[0].UsageSrc != "clients" {
|
||||||
|
t.Errorf("device from clients: %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[0].MemUsed != cl.Mem {
|
||||||
|
t.Errorf("resident memory should be the clients' sum: %d vs %d", got[0].MemUsed, cl.Mem)
|
||||||
|
}
|
||||||
|
// the Processes table joins the client to its row
|
||||||
|
c.ring = []Sample{{T: 1, GPUs: got}}
|
||||||
|
c.prevProc = nil
|
||||||
|
table := c.Processes()
|
||||||
|
if len(table.GPUs) != 1 {
|
||||||
|
t.Errorf("table carries the GPUs: %+v", table.GPUs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlattenGPU(t *testing.T) {
|
||||||
|
s := Sample{T: 5, GPUs: []GPU{{Name: "Intel Arc A380", Driver: "i915", Bus: "0000:03:00.0", Usage: 12, UsageSrc: "idle", Engines: map[string]float64{"video": 30}, PowerW: 9.5, PowerCapW: 55, MHz: 2450, MaxMHz: 2450, Clients: []GPUClient{{PID: 1}}}}}
|
||||||
|
got := map[string]any{}
|
||||||
|
for _, x := range flatten(s, nil) {
|
||||||
|
got[x.ID] = x.Value
|
||||||
|
}
|
||||||
|
for id, want := range map[string]any{"gpu.intel_arc_a380.usage": 12.0, "gpu.intel_arc_a380.video": 30.0, "gpu.intel_arc_a380.power": 9.5, "gpu.intel_arc_a380.freq": 2450.0, "gpu.intel_arc_a380.clients": 1.0} {
|
||||||
|
if got[id] != want {
|
||||||
|
t.Errorf("%s = %v, want %v", id, got[id], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := got["gpu.intel_arc_a380.temp"]; ok {
|
||||||
|
t.Error("no temperature sensor, no temperature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replace(s, old, new string) string {
|
||||||
|
for i := 0; i+len(old) <= len(s); i++ {
|
||||||
|
if s[i:i+len(old)] == old {
|
||||||
|
return s[:i] + new + s[i+len(old):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLiveGPU reads this machine's GPUs for real: HAMLAB_LIVE=1 go test -run TestLiveGPU -v ./internal/metrics
|
||||||
|
func TestLiveGPU(t *testing.T) {
|
||||||
|
if os.Getenv("HAMLAB_LIVE") == "" {
|
||||||
|
t.Skip("set HAMLAB_LIVE=1 to read the real hardware")
|
||||||
|
}
|
||||||
|
c := NewCollector()
|
||||||
|
c.sample()
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
c.sample()
|
||||||
|
s, _ := c.Latest()
|
||||||
|
b, _ := json.MarshalIndent(s.GPUs, "", " ")
|
||||||
|
t.Logf("%s", b)
|
||||||
|
for _, x := range flatten(s, func(id string) bool { return len(id) > 4 && id[:4] == "gpu." }) {
|
||||||
|
t.Logf("%s = %v %s", x.ID, x.Value, x.Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLiveARC: HAMLAB_LIVE=1 go test -run TestLiveARC -v ./internal/metrics
|
||||||
|
func TestLiveARC(t *testing.T) {
|
||||||
|
if os.Getenv("HAMLAB_LIVE") == "" {
|
||||||
|
t.Skip("set HAMLAB_LIVE=1 to read the real machine")
|
||||||
|
}
|
||||||
|
c := NewCollector()
|
||||||
|
c.sample()
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
c.sample()
|
||||||
|
s, _ := c.Latest()
|
||||||
|
b, _ := json.MarshalIndent(s.ARC, "", " ")
|
||||||
|
t.Logf("%s", b)
|
||||||
|
for _, x := range flatten(s, func(id string) bool { return strings.HasPrefix(id, "zfs.arc.") }) {
|
||||||
|
t.Logf("%s = %v %s", x.ID, x.Value, x.Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The embedded table names the lab's four GPUs the way lspci would, so a machine without
|
||||||
|
// pciutils (the router) agrees with one that has it.
|
||||||
|
func TestPCIName(t *testing.T) {
|
||||||
|
for id, want := range map[string]string{"8086:3185": "Intel UHD Graphics 600", "8086:56a5": "Intel Arc A380", "8086:46d1": "Intel UHD Graphics", "8086:4c8a": "Intel UHD Graphics 750", "10de:2684": "NVIDIA GeForce RTX 4090"} {
|
||||||
|
v, d := pciName(id[:4], id[5:])
|
||||||
|
if d == "" {
|
||||||
|
t.Errorf("%s: not in the table", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got := lspciName(v + " " + d); got != want {
|
||||||
|
t.Errorf("%s → %q, want %q", id, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, d := pciName("8086", "ffff"); d != "" {
|
||||||
|
t.Errorf("an unknown device should be empty, got %q", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
// lm-sensors, smartmontools or root. Sections whose source is absent (no fans, no
|
// lm-sensors, smartmontools or root. Sections whose source is absent (no fans, no
|
||||||
// UPS daemon, no hwmon at all on a VM) simply come back empty rather than failing.
|
// UPS daemon, no hwmon at all on a VM) simply come back empty rather than failing.
|
||||||
// The only external commands are optional UPS daemons: pwrstat (CyberPower), upsc
|
// The only external commands are optional UPS daemons: pwrstat (CyberPower), upsc
|
||||||
// (NUT) and apcaccess (apcupsd), tried in that order.
|
// (NUT) and apcaccess (apcupsd), tried in that order. Two readings are root-only, the GPU
|
||||||
|
// client walk and the CPU energy counters; without root they come from small helpers of
|
||||||
|
// this binary started once through sudo or doas (gpu.go, cpupower.go), or not at all.
|
||||||
package metrics
|
package metrics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -122,9 +124,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 +139,7 @@ type Sample struct {
|
|||||||
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Cores []float64 `json:"cores"`
|
Cores []float64 `json:"cores"`
|
||||||
|
CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading
|
||||||
Load [3]float64 `json:"load"`
|
Load [3]float64 `json:"load"`
|
||||||
Mem Mem `json:"mem"`
|
Mem Mem `json:"mem"`
|
||||||
Swap Mem `json:"swap"`
|
Swap Mem `json:"swap"`
|
||||||
@@ -145,6 +149,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
|
||||||
@@ -173,6 +179,9 @@ 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
|
||||||
|
power cpuPowerState
|
||||||
|
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
|
||||||
@@ -338,6 +347,7 @@ func (c *Collector) sample() {
|
|||||||
|
|
||||||
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
|
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
|
||||||
s.CPU, s.Cores = c.cpu()
|
s.CPU, s.Cores = c.cpu()
|
||||||
|
s.CPUPower = c.cpuPower(now)
|
||||||
s.Load = loadavg()
|
s.Load = loadavg()
|
||||||
s.Mem, s.Swap = meminfo()
|
s.Mem, s.Swap = meminfo()
|
||||||
s.Uptime = uptime()
|
s.Uptime = uptime()
|
||||||
@@ -356,6 +366,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
|
||||||
@@ -1106,12 +1118,14 @@ type ProcTable struct {
|
|||||||
CPUModel string `json:"cpu_model,omitempty"`
|
CPUModel string `json:"cpu_model,omitempty"`
|
||||||
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
||||||
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
||||||
|
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
|
||||||
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
||||||
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
||||||
Load [3]float64 `json:"load"`
|
Load [3]float64 `json:"load"`
|
||||||
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"`
|
||||||
@@ -1274,6 +1288,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
|
||||||
@@ -1281,7 +1309,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++
|
||||||
@@ -1290,7 +1318,7 @@ func (c *Collector) Processes() ProcTable {
|
|||||||
t.Mem, t.Swap = meminfo()
|
t.Mem, t.Swap = meminfo()
|
||||||
if n := len(c.ring); n > 0 {
|
if n := len(c.ring); n > 0 {
|
||||||
last := c.ring[n-1]
|
last := c.ring[n-1]
|
||||||
t.CPU, t.Cores, t.Net, t.CPUTemp = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps)
|
t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower
|
||||||
t.CoreIDs = coreIDs(len(last.Cores))
|
t.CoreIDs = coreIDs(len(last.Cores))
|
||||||
from := n - 90
|
from := n - 90
|
||||||
if from < 0 {
|
if from < 0 {
|
||||||
@@ -1330,12 +1358,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
@@ -62,6 +62,9 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
|||||||
if c := cpuTemp(s.Temps); c > 0 {
|
if c := cpuTemp(s.Temps); c > 0 {
|
||||||
f.add("cpu.temp", "CPU temperature", "temperature", "°C", c, t, none)
|
f.add("cpu.temp", "CPU temperature", "temperature", "°C", c, t, none)
|
||||||
}
|
}
|
||||||
|
if s.CPUPower > 0 {
|
||||||
|
f.add("cpu.power", "CPU power", "power", "W", s.CPUPower, t, none)
|
||||||
|
}
|
||||||
for i, w := range []string{"1", "5", "15"} {
|
for i, w := range []string{"1", "5", "15"} {
|
||||||
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
|
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
|
||||||
}
|
}
|
||||||
@@ -131,6 +134,75 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
|||||||
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
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 {
|
||||||
ut := s.upsT
|
ut := s.upsT
|
||||||
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
|
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Package network is what the Network tab shows: the machine's interfaces as the kernel has
|
||||||
|
// them, how the internet looks from out there, and a speed test that measures the line.
|
||||||
|
//
|
||||||
|
// Everything here is read from /proc and /sys, or measured against Cloudflare's public speed
|
||||||
|
// endpoints — no tool to install, on any of the machines. That matters for the router, where
|
||||||
|
// flash is small and `opkg install` is a decision.
|
||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addr is one address on an interface.
|
||||||
|
type Addr struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Prefix int `json:"prefix"` // network bits, as in /24
|
||||||
|
V6 bool `json:"v6,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iface is one network interface: what it is, whether it is up, and what it has moved.
|
||||||
|
type Iface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // ethernet | wifi | bridge | tunnel | loopback | virtual
|
||||||
|
MAC string `json:"mac,omitempty"`
|
||||||
|
MTU int `json:"mtu"`
|
||||||
|
// Up is the administrative state, Carrier whether a cable (or an association) is actually
|
||||||
|
// there. Up without Carrier is the usual "it's plugged into nothing".
|
||||||
|
Up bool `json:"up"`
|
||||||
|
Carrier bool `json:"carrier"`
|
||||||
|
// SpeedMbps is what the link negotiated, where the driver says: 0 for wireless, virtual
|
||||||
|
// and tunnel devices, which have no such number.
|
||||||
|
SpeedMbps int `json:"speed_mbps,omitempty"`
|
||||||
|
Duplex string `json:"duplex,omitempty"`
|
||||||
|
Driver string `json:"driver,omitempty"`
|
||||||
|
SignalDbm *float64 `json:"signal_dbm,omitempty"` // wireless only, from /proc/net/wireless
|
||||||
|
Addrs []Addr `json:"addrs"`
|
||||||
|
RxTotal uint64 `json:"rx_total"`
|
||||||
|
TxTotal uint64 `json:"tx_total"`
|
||||||
|
// Default marks the interface the default route leaves by — the one that matters when
|
||||||
|
// the question is about the internet.
|
||||||
|
Default bool `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is the machine's network as it stands.
|
||||||
|
type Report struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Ifaces []Iface `json:"ifaces"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
GatewayVia string `json:"gateway_via,omitempty"` // the interface it is reached through
|
||||||
|
DNS []string `json:"dns,omitempty"`
|
||||||
|
Search []string `json:"search,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStr(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInt(path string) int {
|
||||||
|
n, err := strconv.Atoi(readStr(path))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindOf names what sort of device this is, from what the kernel exposes about it rather
|
||||||
|
// than from its name — a bridge is a bridge whatever it is called.
|
||||||
|
func kindOf(name string, flags net.Flags) string {
|
||||||
|
sys := "/sys/class/net/" + name + "/"
|
||||||
|
switch {
|
||||||
|
case flags&net.FlagLoopback != 0:
|
||||||
|
return "loopback"
|
||||||
|
case dirExists(sys + "wireless"), dirExists(sys + "phy80211"):
|
||||||
|
return "wifi"
|
||||||
|
case dirExists(sys + "bridge"):
|
||||||
|
return "bridge"
|
||||||
|
case dirExists(sys + "bonding"):
|
||||||
|
return "bond"
|
||||||
|
}
|
||||||
|
// DEVTYPE says vlan, bridge, wlan… for the devices that have one
|
||||||
|
for _, line := range strings.Split(readStr(sys+"uevent"), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "DEVTYPE="); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dirExists(sys + "device") { // no bus device behind it: veth, tun, wireguard, docker's own
|
||||||
|
return "virtual"
|
||||||
|
}
|
||||||
|
return "ethernet"
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirExists(p string) bool {
|
||||||
|
st, err := os.Stat(p)
|
||||||
|
return err == nil && st.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverOf is the module behind an interface, which is what names the hardware in one word.
|
||||||
|
func driverOf(name string) string {
|
||||||
|
l, err := os.Readlink("/sys/class/net/" + name + "/device/driver")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l[strings.LastIndexByte(l, '/')+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// wirelessSignal is the signal level of each wireless interface, in dBm.
|
||||||
|
func wirelessSignal() map[string]float64 {
|
||||||
|
out := map[string]float64{}
|
||||||
|
f, err := os.Open("/proc/net/wireless")
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
name, rest, ok := strings.Cut(sc.Text(), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs := strings.Fields(rest)
|
||||||
|
if len(fs) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSuffix(fs[2], "."), 64); err == nil {
|
||||||
|
out[strings.TrimSpace(name)] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// counters is every interface's byte totals, from the same file the sampler reads.
|
||||||
|
func counters() map[string][2]uint64 {
|
||||||
|
out := map[string][2]uint64{}
|
||||||
|
f, err := os.Open("/proc/net/dev")
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
name, rest, ok := strings.Cut(sc.Text(), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs := strings.Fields(rest)
|
||||||
|
if len(fs) < 16 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rx, _ := strconv.ParseUint(fs[0], 10, 64)
|
||||||
|
tx, _ := strconv.ParseUint(fs[8], 10, 64)
|
||||||
|
out[strings.TrimSpace(name)] = [2]uint64{rx, tx}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultRoute is the gateway the machine sends the internet to, and the interface it
|
||||||
|
// leaves by. Read from /proc/net/route, whose addresses are little-endian hex.
|
||||||
|
func defaultRoute() (gw, via string) {
|
||||||
|
f, err := os.Open("/proc/net/route")
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
best := -1
|
||||||
|
for sc.Scan() {
|
||||||
|
fs := strings.Fields(sc.Text())
|
||||||
|
if len(fs) < 8 || fs[0] == "Iface" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fs[1] != "00000000" { // not a default route
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metric, _ := strconv.Atoi(fs[6])
|
||||||
|
if best >= 0 && metric >= best {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip, err := hexIP(fs[2])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best, gw, via = metric, ip, fs[0]
|
||||||
|
}
|
||||||
|
return gw, via
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexIP reads one address out of /proc/net/route, where each is printed as a 32-bit word in
|
||||||
|
// the host's byte order: 10.0.0.1 appears as "0100000A" on a little-endian machine. So the
|
||||||
|
// text is parsed as a plain number, then written back out in native order to recover the four
|
||||||
|
// bytes — which is right on either sort of machine, without asking which this is.
|
||||||
|
func hexIP(h string) (string, error) {
|
||||||
|
v, err := strconv.ParseUint(h, 16, 32)
|
||||||
|
if err != nil || len(h) != 8 {
|
||||||
|
return "", fmt.Errorf("not an address: %q", h)
|
||||||
|
}
|
||||||
|
var b [4]byte
|
||||||
|
binary.NativeEndian.PutUint32(b[:], uint32(v))
|
||||||
|
return net.IPv4(b[0], b[1], b[2], b[3]).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvers is what /etc/resolv.conf points at.
|
||||||
|
func resolvers() (servers, search []string) {
|
||||||
|
f, err := os.Open("/etc/resolv.conf")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
fs := strings.Fields(sc.Text())
|
||||||
|
if len(fs) < 2 || strings.HasPrefix(fs[0], "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch fs[0] {
|
||||||
|
case "nameserver":
|
||||||
|
servers = append(servers, fs[1])
|
||||||
|
case "search", "domain":
|
||||||
|
search = append(search, fs[1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return servers, search
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces is every interface the kernel has, with its addresses and totals.
|
||||||
|
func Interfaces() []Iface {
|
||||||
|
list, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, via := defaultRoute()
|
||||||
|
sig, cnt := wirelessSignal(), counters()
|
||||||
|
out := make([]Iface, 0, len(list))
|
||||||
|
for _, in := range list {
|
||||||
|
sys := "/sys/class/net/" + in.Name + "/"
|
||||||
|
f := Iface{
|
||||||
|
Name: in.Name, Kind: kindOf(in.Name, in.Flags), MTU: in.MTU,
|
||||||
|
MAC: in.HardwareAddr.String(), Up: in.Flags&net.FlagUp != 0,
|
||||||
|
Driver: driverOf(in.Name), Default: in.Name == via, Addrs: []Addr{},
|
||||||
|
}
|
||||||
|
f.Carrier = readStr(sys+"carrier") == "1"
|
||||||
|
if f.Kind == "loopback" {
|
||||||
|
f.Carrier = f.Up
|
||||||
|
}
|
||||||
|
if sp := readInt(sys + "speed"); sp > 0 { // -1 when the driver doesn't know
|
||||||
|
f.SpeedMbps = sp
|
||||||
|
}
|
||||||
|
if d := readStr(sys + "duplex"); d != "" && d != "unknown" {
|
||||||
|
f.Duplex = d
|
||||||
|
}
|
||||||
|
if v, ok := sig[in.Name]; ok {
|
||||||
|
f.SignalDbm = &v
|
||||||
|
}
|
||||||
|
if c, ok := cnt[in.Name]; ok {
|
||||||
|
f.RxTotal, f.TxTotal = c[0], c[1]
|
||||||
|
}
|
||||||
|
addrs, _ := in.Addrs()
|
||||||
|
for _, a := range addrs {
|
||||||
|
n, ok := a.(*net.IPNet)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ones, _ := n.Mask.Size()
|
||||||
|
f.Addrs = append(f.Addrs, Addr{IP: n.IP.String(), Prefix: ones, V6: n.IP.To4() == nil})
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
// the interface the internet leaves by first, then the ones carrying addresses, then the rest
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
a, b := out[i], out[j]
|
||||||
|
if a.Default != b.Default {
|
||||||
|
return a.Default
|
||||||
|
}
|
||||||
|
ai, bi := len(a.Addrs) > 0 && a.Kind != "loopback", len(b.Addrs) > 0 && b.Kind != "loopback"
|
||||||
|
if ai != bi {
|
||||||
|
return ai
|
||||||
|
}
|
||||||
|
return a.Name < b.Name
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look is the machine's network now. Cheap: a handful of small files.
|
||||||
|
func Look(host string) Report {
|
||||||
|
gw, via := defaultRoute()
|
||||||
|
dns, search := resolvers()
|
||||||
|
return Report{At: time.Now(), Host: host, Ifaces: Interfaces(), Gateway: gw, GatewayVia: via, DNS: dns, Search: search}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- how the internet sees this machine ----
|
||||||
|
|
||||||
|
// Outside is what the far end of the line says about this connection: the address the
|
||||||
|
// internet sees, and where it came ashore. Refreshed on its own clock, because the public
|
||||||
|
// address here is a DHCP lease and moves without warning.
|
||||||
|
type Outside struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
Colo string `json:"colo,omitempty"` // the Cloudflare edge that answered — "SEA"
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher keeps the outside view fresh and holds the speed tester.
|
||||||
|
type Watcher struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
out Outside
|
||||||
|
Test *Tester
|
||||||
|
}
|
||||||
|
|
||||||
|
const outsideEvery = 15 * time.Minute
|
||||||
|
|
||||||
|
func NewWatcher(onResult func(Result)) *Watcher {
|
||||||
|
w := &Watcher{Test: NewTester(onResult)}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
w.refresh()
|
||||||
|
time.Sleep(outsideEvery)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) refresh() {
|
||||||
|
o := probeOutside()
|
||||||
|
w.mu.Lock()
|
||||||
|
// a failed probe does not throw away a good answer; it is noted beside it
|
||||||
|
if o.IP == "" && w.out.IP != "" {
|
||||||
|
w.out.Error, w.out.At = o.Error, o.At
|
||||||
|
} else {
|
||||||
|
w.out = o
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside is the last known view from outside, asking again if it has gone stale.
|
||||||
|
func (w *Watcher) Outside() Outside {
|
||||||
|
w.mu.RLock()
|
||||||
|
o := w.out
|
||||||
|
w.mu.RUnlock()
|
||||||
|
if time.Since(o.At) > outsideEvery {
|
||||||
|
w.refresh()
|
||||||
|
w.mu.RLock()
|
||||||
|
o = w.out
|
||||||
|
w.mu.RUnlock()
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHexIP(t *testing.T) {
|
||||||
|
// /proc/net/route prints each address as a host-order word: on a little-endian machine
|
||||||
|
// 10.0.0.1 is written "0100000A". These cases are that machine, which is all of ours.
|
||||||
|
for hex, want := range map[string]string{
|
||||||
|
"0100000A": "10.0.0.1",
|
||||||
|
"00000000": "0.0.0.0",
|
||||||
|
"0101A8C0": "192.168.1.1",
|
||||||
|
} {
|
||||||
|
got, err := hexIP(hex)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("hexIP(%q) = %q, %v; want %q", hex, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := hexIP("nonsense"); err == nil {
|
||||||
|
t.Error("hexIP accepted nonsense")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerTimeIsNotLatency(t *testing.T) {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Add("Server-Timing", "cfSpeedEdge;dur=7, cfSpeedWorker;dur=36")
|
||||||
|
h.Add("Server-Timing", `cfL4;desc="?proto=TCP&rtt=3505&min_rtt=2856"`)
|
||||||
|
if got := serverMs(h); got != 43 {
|
||||||
|
t.Errorf("serverMs = %v, want 43 (7+36, and nothing from the cfL4 description)", got)
|
||||||
|
}
|
||||||
|
if got := serverMs(http.Header{}); got != 0 {
|
||||||
|
t.Errorf("serverMs with no header = %v, want 0", got)
|
||||||
|
}
|
||||||
|
h2 := http.Header{"Cf-Ray": {"a41f6720f966a36c-SEA"}, "Cf-Meta-Ip": {"50.47.252.221"}}
|
||||||
|
if m := readMeta(h2); m.Colo != "SEA" || m.IP != "50.47.252.221" {
|
||||||
|
t.Errorf("readMeta = %+v, want the edge SEA and the address", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRates(t *testing.T) {
|
||||||
|
// a gibibyte in eight seconds is 1073.7 Mbps
|
||||||
|
if got := mbps(1<<30, 8); math.Abs(got-1073.7) > 0.1 {
|
||||||
|
t.Errorf("mbps(1GiB, 8s) = %v, want ~1073.7", got)
|
||||||
|
}
|
||||||
|
if got := best([]float64{12.5, 9.2, 40, 9.9}); got != 9.2 {
|
||||||
|
t.Errorf("best = %v, want 9.2 (the least of them)", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{10, 10, 10}); got != 0 {
|
||||||
|
t.Errorf("jitter of a steady line = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{10}); got != 0 {
|
||||||
|
t.Errorf("jitter of one sample = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{5, 50, 5, 50}); got != 45 {
|
||||||
|
t.Errorf("jitter of a line alternating by 45 = %v, want 45", got)
|
||||||
|
}
|
||||||
|
// one slow probe among steady ones moves it a little, not enormously
|
||||||
|
if got := jitter([]float64{10, 10, 10, 60, 10, 10}); got > 25 {
|
||||||
|
t.Errorf("jitter with a single outlier = %v, want it not to run away", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body an upload sends is a fixed size, counts itself, and costs nothing to make.
|
||||||
|
func TestZerosBodyCountsItself(t *testing.T) {
|
||||||
|
var n atomic.Int64
|
||||||
|
z := &zeros{left: 100, n: &n}
|
||||||
|
buf := make([]byte, 30)
|
||||||
|
total := 0
|
||||||
|
for {
|
||||||
|
got, err := z.Read(buf)
|
||||||
|
total += got
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total != 100 || n.Load() != 100 {
|
||||||
|
t.Errorf("read %d bytes and counted %d, want 100 and 100", total, n.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces has to work on whatever machine this runs on, and always name a loopback.
|
||||||
|
func TestInterfacesHasLoopback(t *testing.T) {
|
||||||
|
ifs := Interfaces()
|
||||||
|
if len(ifs) == 0 {
|
||||||
|
t.Fatal("no interfaces at all")
|
||||||
|
}
|
||||||
|
lo := false
|
||||||
|
for _, f := range ifs {
|
||||||
|
if f.Kind == "loopback" {
|
||||||
|
lo = true
|
||||||
|
if !f.Up || len(f.Addrs) == 0 {
|
||||||
|
t.Errorf("loopback %s looks wrong: up=%v addrs=%v", f.Name, f.Up, f.Addrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Name == "" {
|
||||||
|
t.Error("an interface with no name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !lo {
|
||||||
|
t.Error("no loopback among the interfaces")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real thing, against the real endpoint: skipped by default, because `make test` must
|
||||||
|
// not need the internet. HAMLAB_NET_TEST=1 go test ./internal/network/ -run Live -v
|
||||||
|
func TestLiveSpeedTest(t *testing.T) {
|
||||||
|
if os.Getenv("HAMLAB_NET_TEST") == "" {
|
||||||
|
t.Skip("set HAMLAB_NET_TEST=1 to measure the actual line")
|
||||||
|
}
|
||||||
|
tr := NewTester(nil)
|
||||||
|
if _, err := tr.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tr.Start(); err == nil {
|
||||||
|
t.Error("a second test started while the first was running")
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(90 * time.Second)
|
||||||
|
for tr.Running() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
st := tr.State()
|
||||||
|
if st.Result == nil {
|
||||||
|
t.Fatal("no result")
|
||||||
|
}
|
||||||
|
r := *st.Result
|
||||||
|
t.Logf("down %s up %s latency %.1f ms jitter %.1f ms via %s ip %s %.0f MB moved in %.1fs",
|
||||||
|
Mbits(r.DownMbps), Mbits(r.UpMbps), r.LatencyMs, r.JitterMs, r.Colo, r.IP,
|
||||||
|
float64(r.DownBytes+r.UpBytes)/1e6, r.TookSec)
|
||||||
|
if r.Error != "" {
|
||||||
|
t.Fatalf("test failed: %s", r.Error)
|
||||||
|
}
|
||||||
|
if r.DownMbps <= 0 || r.UpMbps <= 0 || r.LatencyMs <= 0 {
|
||||||
|
t.Errorf("a figure came back zero: %+v", r)
|
||||||
|
}
|
||||||
|
if r.IP == "" {
|
||||||
|
t.Error("no public address reported")
|
||||||
|
}
|
||||||
|
if len(tr.History()) != 1 {
|
||||||
|
t.Errorf("history has %d results, want 1", len(tr.History()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phase measured against a local server, at a rate the test controls. This is the part of
|
||||||
|
// the speed test with arithmetic in it — the window after the ramp, the byte cap that ends it
|
||||||
|
// early, and the rate that comes out — and none of it needs the internet to check.
|
||||||
|
func TestPhaseMeasuresTheWindow(t *testing.T) {
|
||||||
|
const perTick = 1 << 20 // a mebibyte every 5 ms ≈ 1678 Mbps, whatever the machine is doing
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/up" {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
w.WriteHeader(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, perTick)
|
||||||
|
fl, _ := w.(http.Flusher)
|
||||||
|
for {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fl != nil {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(5 * time.Millisecond):
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
defer func(up string, w, d, mw time.Duration, m int64) {
|
||||||
|
upURL, warmUp, downFor, minWindow, measureBytes = up, w, d, mw, m
|
||||||
|
}(upURL, warmUp, downFor, minWindow, measureBytes)
|
||||||
|
upURL = srv.URL + "/up"
|
||||||
|
warmUp, downFor, minWindow, measureBytes = 100*time.Millisecond, 3*time.Second, 200*time.Millisecond, 8<<20
|
||||||
|
here := Source{"local", func(int) string { return srv.URL + "/down" }}
|
||||||
|
|
||||||
|
tr := NewTester(nil)
|
||||||
|
began := time.Now()
|
||||||
|
mbps, moved, note, err := tr.phase(context.Background(), srv.Client(), "download", 2, downFor, 0, 1, here)
|
||||||
|
took := time.Since(began)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("phase: %v", err)
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
t.Errorf("note = %q, want none — nothing went wrong", note)
|
||||||
|
}
|
||||||
|
// The cap must have ended it early rather than the clock: the phase was allowed three
|
||||||
|
// seconds and should be done in well under one, having moved at least the cap.
|
||||||
|
if moved < measureBytes {
|
||||||
|
t.Errorf("moved %d bytes, want at least the %d-byte cap", moved, measureBytes)
|
||||||
|
}
|
||||||
|
if took > time.Second {
|
||||||
|
t.Errorf("phase took %v of the %v it was allowed; the byte cap should have ended it", took.Round(time.Millisecond), downFor)
|
||||||
|
}
|
||||||
|
// The server hands out a mebibyte per 5 ms per stream, so the rate is in the hundreds of
|
||||||
|
// Mbps. The point is that it is a real quotient of what moved over how long, not that it
|
||||||
|
// hits a particular figure on a loaded test machine.
|
||||||
|
if mbps < 50 || mbps > 20000 {
|
||||||
|
t.Errorf("mbps = %v, which is not a believable quotient", mbps)
|
||||||
|
}
|
||||||
|
t.Logf("measured %.0f Mbps over %.1f MiB", mbps, float64(moved)/(1<<20))
|
||||||
|
|
||||||
|
// A phase whose server refuses outright fails, and says what it was told.
|
||||||
|
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
|
||||||
|
defer bad.Close()
|
||||||
|
refuser := Source{"refuser", func(int) string { return bad.URL + "/down" }}
|
||||||
|
_, _, _, err = tr.phase(context.Background(), bad.Client(), "download", 2, 300*time.Millisecond, 0, 1, refuser)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("a phase against a server that refuses everything should fail")
|
||||||
|
} else if !refused(err) {
|
||||||
|
t.Errorf("error = %q, want the refusal that makes the fallback kick in", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping a test mid-flight is reported as cancelled, not as whatever the streams said on
|
||||||
|
// their way out.
|
||||||
|
func TestCancelIsReportedAsCancelled(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
buf := make([]byte, 1<<16)
|
||||||
|
for {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
defer func(w, d time.Duration) { warmUp, downFor = w, d }(warmUp, downFor)
|
||||||
|
warmUp, downFor = 10*time.Millisecond, 10*time.Second
|
||||||
|
here := Source{"local", func(int) string { return srv.URL + "/down" }}
|
||||||
|
|
||||||
|
tr := NewTester(nil)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() { time.Sleep(200 * time.Millisecond); cancel() }()
|
||||||
|
_, _, _, err := tr.phase(ctx, srv.Client(), "download", 1, downFor, 0, 1, here)
|
||||||
|
// Cancelled after bytes moved: the figure stands, and the phase does not claim an error.
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("err = %v, want nil or a cancellation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the first source refuses, the download is taken from the next one and the result says
|
||||||
|
// so. This is the whole reason there is more than one.
|
||||||
|
func TestDownloadFallsBackWhenRefused(t *testing.T) {
|
||||||
|
serving := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/up" {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
w.WriteHeader(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// paced, so the phase is governed by its window and not by a loopback moving a
|
||||||
|
// gibibyte before the first tick
|
||||||
|
buf := make([]byte, 1<<20)
|
||||||
|
fl, _ := w.(http.Flusher)
|
||||||
|
for i := 0; i < 64; i++ {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fl != nil {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(2 * time.Millisecond):
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer serving.Close()
|
||||||
|
refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
|
||||||
|
defer refusing.Close()
|
||||||
|
|
||||||
|
defer func(srcs []Source, up string, w, d, mw time.Duration, m int64) {
|
||||||
|
downSources, upURL, warmUp, downFor, minWindow, measureBytes = srcs, up, w, d, mw, m
|
||||||
|
}(downSources, upURL, warmUp, downFor, minWindow, measureBytes)
|
||||||
|
downSources = []Source{
|
||||||
|
{"refuser", func(int) string { return refusing.URL + "/down" }},
|
||||||
|
{"the-other-one", func(int) string { return serving.URL + "/down" }},
|
||||||
|
}
|
||||||
|
upURL = serving.URL + "/up"
|
||||||
|
warmUp, downFor, upFor, minWindow, measureBytes = 50*time.Millisecond, time.Second, 300*time.Millisecond, 100*time.Millisecond, 4<<20
|
||||||
|
|
||||||
|
tr := NewTester(nil)
|
||||||
|
if _, err := tr.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for tr.Running() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
res := tr.State().Result
|
||||||
|
if res == nil {
|
||||||
|
t.Fatal("no result")
|
||||||
|
}
|
||||||
|
if res.Source != "the-other-one" {
|
||||||
|
t.Errorf("source = %q, want the second one — the first refused", res.Source)
|
||||||
|
}
|
||||||
|
if res.DownMbps <= 0 {
|
||||||
|
t.Errorf("download = %v, want a figure from the source that did serve: %+v", res.DownMbps, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
// The speed test. It measures against Cloudflare's public speed endpoints, the same ones
|
||||||
|
// speed.cloudflare.com uses: a plain GET of N bytes down, a plain POST of N bytes up, and a
|
||||||
|
// zero-byte GET for the round trip. No account, no API key, nothing to install, and an edge
|
||||||
|
// close to every one of these machines.
|
||||||
|
//
|
||||||
|
// The method, since a number without one means little — it is stated in the UI too:
|
||||||
|
//
|
||||||
|
// - Latency is the round trip of a zero-byte response on a connection that is already open,
|
||||||
|
// less the time the far end says it spent on the request (its Server-Timing header). That
|
||||||
|
// subtraction is the difference between measuring the line and measuring Cloudflare's
|
||||||
|
// thinking time: a zero-byte response is almost entirely the latter — tens of
|
||||||
|
// milliseconds of worker, against a few of actual wire. The figure reported is the best of
|
||||||
|
// several probes; jitter is the average change from one to the next.
|
||||||
|
// - Download and upload each run several streams at once for a fixed few seconds. One
|
||||||
|
// stream measures one TCP connection's congestion window as much as the line, which is
|
||||||
|
// why every speed test ever made uses more than one.
|
||||||
|
// - The first second of each phase is thrown away. That is the ramp: counting it reports a
|
||||||
|
// number nobody's connection ever actually did.
|
||||||
|
// - Each phase is capped in bytes as well as seconds, so a very fast line cannot run away
|
||||||
|
// with the meter.
|
||||||
|
//
|
||||||
|
// The one thing to know about the far end: it rate-limits by volume, and a test moves a few
|
||||||
|
// hundred megabytes. Several in a row will start earning 429s, which is why there is a
|
||||||
|
// cooldown between tests and why a phase that gets refused says so instead of reporting the
|
||||||
|
// decayed figure it would otherwise compute.
|
||||||
|
//
|
||||||
|
// HTTP/2 is deliberately off: over one HTTP/2 connection the streams would share a single
|
||||||
|
// congestion window, which measures the window, not the line.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The endpoints, and the shape of a phase. Variables rather than constants only so the tests
|
||||||
|
// can point them at a local server and shrink the windows; nothing else rebinds them.
|
||||||
|
// Source is somewhere to pull bytes from. Cloudflare's endpoint is the first choice because
|
||||||
|
// it is anycast — near wherever Ham Lab is running — and because the same host answers for
|
||||||
|
// upload and for the trace. The fallback exists because that endpoint rate-limits by volume
|
||||||
|
// and will refuse a connection that has been tested hard: a plain 100 MB file on a public
|
||||||
|
// mirror has no such opinion. It is in Seattle, so on a machine far from there it will read
|
||||||
|
// low, which is exactly why every result names the source it used.
|
||||||
|
type Source struct {
|
||||||
|
Name string
|
||||||
|
// URL takes the number of bytes wanted, where the source can be asked; a fixed file
|
||||||
|
// ignores it and is fetched again each time round.
|
||||||
|
URL func(bytes int) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var downSources = []Source{
|
||||||
|
{"cloudflare", func(b int) string { return fmt.Sprintf(cfDownURL, b) }},
|
||||||
|
{"linode-seattle", func(int) string { return "https://speedtest.seattle.linode.com/100MB-seattle.bin" }},
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfDownURL = "https://speed.cloudflare.com/__down?bytes=%d"
|
||||||
|
upURL = "https://speed.cloudflare.com/__up"
|
||||||
|
// traceURL is Cloudflare's standard trace: a few lines of key=value naming the address it
|
||||||
|
// sees, the edge that answered and the country. A few hundred bytes.
|
||||||
|
traceURL = "https://speed.cloudflare.com/cdn-cgi/trace"
|
||||||
|
|
||||||
|
warmUp = time.Second
|
||||||
|
downFor = 4 * time.Second
|
||||||
|
upFor = 4 * time.Second
|
||||||
|
// measureBytes ends a phase early once this much has moved inside the window. A fast line
|
||||||
|
// says what it is in well under the time allowed, and stopping there is what keeps a test
|
||||||
|
// down to a couple of hundred megabytes — which is the difference between the far end
|
||||||
|
// serving this happily and rate-limiting it. A slow line never reaches it and is timed out
|
||||||
|
// by downFor/upFor instead, which is the honest way round.
|
||||||
|
measureBytes = int64(64) << 20
|
||||||
|
// minWindow is how long the window must last whatever the byte cap says. Without it a very
|
||||||
|
// fast line fills the cap in a few hundredths of a second and the phase would be over
|
||||||
|
// before there was anything to divide by.
|
||||||
|
minWindow = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
latencyProbes = 8
|
||||||
|
downStreams = 4
|
||||||
|
upStreams = 3
|
||||||
|
downChunk = 25 << 20 // bytes per download request; the far end refuses much more
|
||||||
|
upChunk = 25 << 20 // and per upload request
|
||||||
|
// phaseBudget is the runaway guard, counting the ramp too: nothing measures forever.
|
||||||
|
phaseBudget = int64(512) << 20
|
||||||
|
httpTimeout = 90 * time.Second
|
||||||
|
// Cooldown is how long the line is left alone between tests. The far end rate-limits by
|
||||||
|
// volume, and two back-to-back tests are the surest way to be refused by it.
|
||||||
|
Cooldown = 20 * time.Second
|
||||||
|
|
||||||
|
// Method is what the UI tells the reader about the numbers it is showing.
|
||||||
|
Method = "Cloudflare's speed endpoints · 4 streams down, 3 up · a 1 s ramp, then up to 4 s or 64 MiB each way"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is one finished speed test.
|
||||||
|
type Result struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
DownMbps float64 `json:"down_mbps"`
|
||||||
|
UpMbps float64 `json:"up_mbps"`
|
||||||
|
LatencyMs float64 `json:"latency_ms"`
|
||||||
|
JitterMs float64 `json:"jitter_ms"`
|
||||||
|
DownBytes int64 `json:"down_bytes"`
|
||||||
|
UpBytes int64 `json:"up_bytes"`
|
||||||
|
TookSec float64 `json:"took_sec"`
|
||||||
|
// Colo is the Cloudflare edge that answered — "SEA" — and IP is this connection's public
|
||||||
|
// address as that edge saw it.
|
||||||
|
Colo string `json:"colo,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
// Source is where the download came from, which matters because the fallback is not in the
|
||||||
|
// same place as the anycast one. Empty means the usual.
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
// Note is set when the figures are real but something went wrong along the way — a stream
|
||||||
|
// that was refused part-way, most often. The number is what was actually seen; the note is
|
||||||
|
// why it may be low.
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run is a test in progress, or the last one to have finished.
|
||||||
|
type Run struct {
|
||||||
|
Started time.Time `json:"started"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
// Phase is where it has got to: latency, download, upload, done.
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
// Done is how far through the whole test, 0–1, so one bar can show all of it.
|
||||||
|
Done float64 `json:"done"`
|
||||||
|
// Mbps is the live figure for the phase under way — what the needle shows.
|
||||||
|
Mbps float64 `json:"mbps"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Result *Result `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester runs one speed test at a time and remembers what the last ones said.
|
||||||
|
type Tester struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
run Run
|
||||||
|
hist []Result
|
||||||
|
// lastTry is when a test was last *started*, which is what the cooldown counts from — a
|
||||||
|
// test that was refused still used the line, and hammering retries at an endpoint that has
|
||||||
|
// just rate-limited us is the one thing guaranteed not to help.
|
||||||
|
lastTry time.Time
|
||||||
|
cancel context.CancelFunc
|
||||||
|
onDone func(Result)
|
||||||
|
maxHist int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTester(onDone func(Result)) *Tester {
|
||||||
|
return &Tester{onDone: onDone, maxHist: 50}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load takes the results kept from before a restart, oldest first.
|
||||||
|
func (t *Tester) Load(rs []Result) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.hist = append(t.hist[:0], rs...)
|
||||||
|
if n := len(t.hist); n > 0 {
|
||||||
|
last := t.hist[n-1]
|
||||||
|
t.run = Run{Started: last.At, Phase: "done", Done: 1, Result: &last}
|
||||||
|
t.lastTry = last.At
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) State() Run {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return t.run
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) History() []Result {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return append([]Result{}, t.hist...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) Running() bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return t.run.Running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel stops a test in progress. Reports whether there was one.
|
||||||
|
func (t *Tester) Cancel() bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
c := t.cancel
|
||||||
|
t.mu.Unlock()
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrBusy = errors.New("a speed test is already running")
|
||||||
|
|
||||||
|
// Start begins a test in the background and returns its opening state.
|
||||||
|
func (t *Tester) Start() (Run, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.run.Running {
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
return r, ErrBusy
|
||||||
|
}
|
||||||
|
if since := time.Since(t.lastTry); since < Cooldown && !t.lastTry.IsZero() {
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
return r, fmt.Errorf("give the line %d more seconds — testing back to back is what gets this connection rate-limited", int((Cooldown-since).Seconds())+1)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
t.cancel = cancel
|
||||||
|
t.lastTry = time.Now()
|
||||||
|
t.run = Run{Started: t.lastTry, Running: true, Phase: "latency"}
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
go t.measure(ctx, cancel)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) set(fn func(*Run)) {
|
||||||
|
t.mu.Lock()
|
||||||
|
fn(&t.run)
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) measure(ctx context.Context, cancel context.CancelFunc) {
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
res := Result{At: started}
|
||||||
|
|
||||||
|
tr := newTransport(downStreams)
|
||||||
|
defer tr.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: tr, Timeout: httpTimeout}
|
||||||
|
|
||||||
|
// latency, and the meta the same responses carry
|
||||||
|
lat, meta, err := t.latency(ctx, client)
|
||||||
|
if err == nil {
|
||||||
|
res.LatencyMs, res.JitterMs = best(lat), jitter(lat)
|
||||||
|
res.Colo, res.IP = meta.Colo, meta.IP
|
||||||
|
}
|
||||||
|
var note string
|
||||||
|
if err == nil {
|
||||||
|
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "download", 0.1, 0 })
|
||||||
|
// Each source in turn: a refusal from the first is exactly what the second is for, and
|
||||||
|
// anything else (no route, no DNS) would fail the same way twice, so it stops there.
|
||||||
|
for i, src := range downSources {
|
||||||
|
res.Source = src.Name
|
||||||
|
res.DownMbps, res.DownBytes, note, err = t.phase(ctx, client, "download", downStreams, downFor, 0.1, 0.6, src)
|
||||||
|
if err == nil || !refused(err) || i == len(downSources)-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf("speed test: %s refused the download, trying %s", src.Name, downSources[i+1].Name)
|
||||||
|
}
|
||||||
|
res.Note = join(res.Note, note)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "upload", 0.7, 0 })
|
||||||
|
res.UpMbps, res.UpBytes, note, err = t.phase(ctx, client, "upload", upStreams, upFor, 0.7, 1.0, Source{Name: "cloudflare"})
|
||||||
|
res.Note = join(res.Note, note)
|
||||||
|
}
|
||||||
|
res.TookSec = round(time.Since(started).Seconds(), 1)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
res.Error = "cancelled"
|
||||||
|
} else {
|
||||||
|
res.Error = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.cancel = nil
|
||||||
|
t.run = Run{Started: started, Phase: "done", Done: 1, Result: &res, Bytes: res.DownBytes + res.UpBytes}
|
||||||
|
if res.Error == "" || res.DownMbps > 0 { // a cancelled test that got a number is still a reading
|
||||||
|
t.hist = append(t.hist, res)
|
||||||
|
if len(t.hist) > t.maxHist {
|
||||||
|
t.hist = t.hist[len(t.hist)-t.maxHist:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done, out := t.onDone, res
|
||||||
|
t.mu.Unlock()
|
||||||
|
if done != nil {
|
||||||
|
done(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta is what the edge says about the connection. The address comes from a header of the
|
||||||
|
// speed endpoint's own; the edge's name is the tail of the ray ID, which every Cloudflare
|
||||||
|
// response carries ("a41f6720f966a36c-SEA").
|
||||||
|
type meta struct{ IP, Colo string }
|
||||||
|
|
||||||
|
func readMeta(h http.Header) meta {
|
||||||
|
m := meta{IP: h.Get("cf-meta-ip")}
|
||||||
|
if ray := h.Get("cf-ray"); ray != "" {
|
||||||
|
if i := strings.LastIndexByte(ray, '-'); i >= 0 {
|
||||||
|
m.Colo = ray[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverMs is how long the far end says it spent on this request, from Server-Timing. The
|
||||||
|
// cfL4 entry carries no dur= and contributes nothing, which is what we want — it is a
|
||||||
|
// description of the connection, not time spent.
|
||||||
|
func serverMs(h http.Header) float64 {
|
||||||
|
var total float64
|
||||||
|
for _, v := range h.Values("Server-Timing") {
|
||||||
|
for _, part := range strings.Split(v, ",") {
|
||||||
|
_, after, ok := strings.Cut(part, "dur=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := 0
|
||||||
|
for end < len(after) && (after[end] == '.' || (after[end] >= '0' && after[end] <= '9')) {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(after[:end], 64); err == nil {
|
||||||
|
total += f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// latency times several zero-byte responses on an already-open connection.
|
||||||
|
func (t *Tester) latency(ctx context.Context, client *http.Client) ([]float64, meta, error) {
|
||||||
|
var m meta
|
||||||
|
var out []float64
|
||||||
|
for i := 0; i <= latencyProbes; i++ { // the first is the handshake, and is not counted
|
||||||
|
start := time.Now()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(cfDownURL, 0), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, m, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, m, err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, m, fmt.Errorf("speed endpoint answered %s", resp.Status)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
m = readMeta(resp.Header)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rtt := float64(time.Since(start).Microseconds())/1000 - serverMs(resp.Header)
|
||||||
|
if rtt < 0.01 { // the far end claimed more time than the whole request took
|
||||||
|
rtt = 0.01
|
||||||
|
}
|
||||||
|
out = append(out, rtt)
|
||||||
|
t.set(func(r *Run) { r.Done = 0.02 + 0.08*float64(i)/latencyProbes })
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, m, errors.New("no reply from the speed endpoint")
|
||||||
|
}
|
||||||
|
return out, m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase pushes traffic with several streams for a fixed spell and reports the rate over the
|
||||||
|
// window after the ramp. from and to are where this phase sits in the whole test's progress.
|
||||||
|
//
|
||||||
|
// The window ends when the traffic does, not when the clock runs out: if the far end starts
|
||||||
|
// refusing part-way, the figure is what was actually moved while it was moving, and the note
|
||||||
|
// says what happened. Coasting to the end of the clock with no bytes arriving would report a
|
||||||
|
// number a third of the truth.
|
||||||
|
func (t *Tester) phase(parent context.Context, client *http.Client, dir string, streams int, forHow time.Duration, from, to float64, src Source) (float64, int64, string, error) {
|
||||||
|
// parent is the test's own context, which is cancelled when somebody presses Stop. ctx is
|
||||||
|
// this phase's, which *this function* cancels to shut the streams down — so the two are
|
||||||
|
// kept apart: asking ctx whether the test was cancelled would always say yes by then, and
|
||||||
|
// every failure would be reported as "context canceled" whatever really happened.
|
||||||
|
ctx, stop := context.WithCancel(parent)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
var moved atomic.Int64
|
||||||
|
var firstErr error
|
||||||
|
var errOnce sync.Once
|
||||||
|
var alive atomic.Int32
|
||||||
|
alive.Store(int32(streams))
|
||||||
|
dead := make(chan struct{})
|
||||||
|
var deadOnce sync.Once
|
||||||
|
|
||||||
|
for i := 0; i < streams; i++ {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if alive.Add(-1) == 0 {
|
||||||
|
deadOnce.Do(func() { close(dead) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
tries := 0
|
||||||
|
for ctx.Err() == nil && moved.Load() < phaseBudget {
|
||||||
|
var err error
|
||||||
|
if dir == "download" {
|
||||||
|
err = stream(ctx, client, src, &moved)
|
||||||
|
} else {
|
||||||
|
err = upstream(ctx, client, &moved)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
tries = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errOnce.Do(func() { firstErr = err })
|
||||||
|
if tries++; tries > 2 { // refused three times: this stream is finished
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(400 * time.Millisecond):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ramp, measured and thrown away
|
||||||
|
select {
|
||||||
|
case <-time.After(warmUp):
|
||||||
|
case <-dead:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
mark, at := moved.Load(), time.Now()
|
||||||
|
last, lastAt := mark, at // the last time bytes were still arriving
|
||||||
|
deadline := at.Add(forHow)
|
||||||
|
tick := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case <-tick.C:
|
||||||
|
case <-dead:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
if parent.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
now := moved.Load()
|
||||||
|
if now > last {
|
||||||
|
last, lastAt = now, time.Now()
|
||||||
|
}
|
||||||
|
// enough has moved to know, and it has been long enough to mean it
|
||||||
|
if (now-mark >= measureBytes && time.Since(at) >= minWindow) || now >= phaseBudget {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if secs := time.Since(at).Seconds(); secs > 0 {
|
||||||
|
live := mbps(now-mark, secs)
|
||||||
|
part := math.Min(1, secs/forHow.Seconds())
|
||||||
|
t.set(func(r *Run) { r.Mbps, r.Bytes, r.Done = live, now, from+(to-from)*part })
|
||||||
|
}
|
||||||
|
// every stream has given up and nothing more is coming
|
||||||
|
if alive.Load() == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total, secs := last-mark, lastAt.Sub(at).Seconds()
|
||||||
|
stop()
|
||||||
|
|
||||||
|
if total <= 0 {
|
||||||
|
switch {
|
||||||
|
case firstErr != nil: // what the far end said explains it better than anything else
|
||||||
|
return 0, moved.Load(), "", firstErr
|
||||||
|
case parent.Err() != nil:
|
||||||
|
return 0, moved.Load(), "", parent.Err()
|
||||||
|
}
|
||||||
|
return 0, moved.Load(), "", fmt.Errorf("nothing moved in the %s phase", dir)
|
||||||
|
}
|
||||||
|
if secs < 0.02 { // too short a window to divide by; the guard is against dividing by zero,
|
||||||
|
// not a judgement about how long a good measurement takes
|
||||||
|
return 0, moved.Load(), "", fmt.Errorf("the %s phase was over before it could be measured", dir)
|
||||||
|
}
|
||||||
|
note := ""
|
||||||
|
if firstErr != nil {
|
||||||
|
note = fmt.Sprintf("the %s phase was cut short: %s", dir, firstErr)
|
||||||
|
}
|
||||||
|
return mbps(total, secs), moved.Load(), note, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// join puts two notes together without leaving an empty one dangling.
|
||||||
|
func join(a, b string) string {
|
||||||
|
switch {
|
||||||
|
case a == "":
|
||||||
|
return b
|
||||||
|
case b == "":
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return a + "; " + b
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream fetches one chunk from src, counting bytes as they arrive.
|
||||||
|
func stream(ctx context.Context, client *http.Client, src Source, n *atomic.Int64) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", src.URL(downChunk), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return answered(resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 64<<10)
|
||||||
|
for {
|
||||||
|
got, err := resp.Body.Read(buf)
|
||||||
|
if got > 0 {
|
||||||
|
n.Add(int64(got))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstream posts one chunk of nothing in particular, counting bytes as they go out.
|
||||||
|
func upstream(ctx context.Context, client *http.Client, n *atomic.Int64) error {
|
||||||
|
body := &zeros{left: upChunk, n: n}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", upURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.ContentLength = upChunk
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 && resp.StatusCode != 204 {
|
||||||
|
return answered(resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// answered turns the far end's refusal into something worth reading. It rate-limits by
|
||||||
|
// volume: enough testing in a short while and it stops serving the big transfers, which is
|
||||||
|
// the only failure of this test anyone here is likely to meet.
|
||||||
|
func answered(code int, status string) error {
|
||||||
|
if code == 429 || code == 403 {
|
||||||
|
return errRefused
|
||||||
|
}
|
||||||
|
return fmt.Errorf("the speed endpoint answered %s", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errRefused is the far end rate-limiting this connection, which is the only failure of this
|
||||||
|
// test anyone here is likely to meet: it limits by volume, and a test moves a few hundred
|
||||||
|
// megabytes. It is recognisable so the download phase can try its other source.
|
||||||
|
var errRefused = errors.New("the speed endpoint is rate-limiting this connection — it does that after a lot of testing; leave it a few minutes")
|
||||||
|
|
||||||
|
func refused(err error) bool { return errors.Is(err, errRefused) }
|
||||||
|
|
||||||
|
// zeros is a request body of a fixed size that costs nothing to produce, counting itself as
|
||||||
|
// it is read out onto the wire.
|
||||||
|
type zeros struct {
|
||||||
|
left int
|
||||||
|
n *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *zeros) Read(p []byte) (int, error) {
|
||||||
|
if z.left <= 0 {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
if len(p) > z.left {
|
||||||
|
p = p[:z.left]
|
||||||
|
}
|
||||||
|
for i := range p {
|
||||||
|
p[i] = 0
|
||||||
|
}
|
||||||
|
z.left -= len(p)
|
||||||
|
z.n.Add(int64(len(p)))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTransport(streams int) *http.Transport {
|
||||||
|
return &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
MaxIdleConns: streams * 2,
|
||||||
|
MaxIdleConnsPerHost: streams * 2,
|
||||||
|
// An empty map turns HTTP/2 off: see the note at the top of this file.
|
||||||
|
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeOutside asks the far end who it thinks we are. One small request.
|
||||||
|
func probeOutside() Outside {
|
||||||
|
tr := newTransport(1)
|
||||||
|
defer tr.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", traceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Outside{At: time.Now(), Error: err.Error()}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Outside{At: time.Now(), Error: shortErr(err)}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return Outside{At: time.Now(), Error: "the trace endpoint answered " + resp.Status}
|
||||||
|
}
|
||||||
|
o := Outside{At: time.Now()}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "ip":
|
||||||
|
o.IP = v
|
||||||
|
case "colo":
|
||||||
|
o.Colo = v
|
||||||
|
case "loc":
|
||||||
|
o.Country = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.IP == "" {
|
||||||
|
o.Error = "the trace endpoint said nothing about this connection"
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortErr keeps the part of a network error a person can act on.
|
||||||
|
func shortErr(err error) string {
|
||||||
|
s := err.Error()
|
||||||
|
if i := strings.LastIndex(s, ": "); i > 0 && len(s)-i < 60 {
|
||||||
|
return s[i+2:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func mbps(bytes int64, secs float64) float64 {
|
||||||
|
return round(float64(bytes)*8/secs/1e6, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func best(v []float64) float64 {
|
||||||
|
m := v[0]
|
||||||
|
for _, x := range v {
|
||||||
|
if x < m {
|
||||||
|
m = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return round(m, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jitter is how much the line wobbles: the average change from one round trip to the next,
|
||||||
|
// which is the usual definition and, unlike a standard deviation, is not run away with by a
|
||||||
|
// single slow probe.
|
||||||
|
func jitter(v []float64) float64 {
|
||||||
|
if len(v) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var sum float64
|
||||||
|
for i := 1; i < len(v); i++ {
|
||||||
|
sum += math.Abs(v[i] - v[i-1])
|
||||||
|
}
|
||||||
|
return round(sum/float64(len(v)-1), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(v float64, places int) float64 {
|
||||||
|
p := math.Pow(10, float64(places))
|
||||||
|
return math.Round(v*p) / p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mbits renders a rate the way the line is sold, for a log line.
|
||||||
|
func Mbits(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) + " Mbps" }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh /etc/rc.common
|
||||||
|
# Ham Lab web console (written by hamlab install)
|
||||||
|
|
||||||
|
USE_PROCD=1
|
||||||
|
# after network and firewall, before the tail of the boot
|
||||||
|
START=95
|
||||||
|
STOP=10
|
||||||
|
|
||||||
|
start_service() {
|
||||||
|
procd_open_instance hamlab
|
||||||
|
procd_set_param command "@BIN@" serve
|
||||||
|
procd_set_param env HAMLAB_CONFIG="@CONFIG@" HOME="@HOME@" USER="@USER@"
|
||||||
|
procd_set_param user "@USER@"
|
||||||
|
procd_set_param group "@GROUP@"
|
||||||
|
procd_set_param pidfile /var/run/hamlab.pid
|
||||||
|
# respawn: settle 3s, 5 retries inside that window, then give up counting
|
||||||
|
procd_set_param respawn 3600 3 0
|
||||||
|
procd_set_param stdout 1
|
||||||
|
procd_set_param stderr 1
|
||||||
|
procd_close_instance
|
||||||
|
}
|
||||||
|
|
||||||
|
service_triggers() {
|
||||||
|
procd_add_reload_trigger network
|
||||||
|
}
|
||||||
+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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -452,8 +452,25 @@ func (m *Manager) WriteCronFile(ctx context.Context, policies []Policy, user, ex
|
|||||||
return m.writeRoot(ctx, CronFile, text)
|
return m.writeRoot(ctx, CronFile, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRoot puts text at a root-owned path, through the same privilege escalation every
|
// WriteRootFile puts text at a root-owned path, through the same privilege escalation every
|
||||||
// other write here uses.
|
// 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 {
|
func (m *Manager) writeRoot(ctx context.Context, path, text string) error {
|
||||||
args := []string{"tee", path}
|
args := []string{"tee", path}
|
||||||
if m.sudo {
|
if m.sudo {
|
||||||
|
|||||||
+24
-4
@@ -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
|
||||||
@@ -51,10 +61,20 @@ type State struct {
|
|||||||
// UI is the pre-users machine-wide look; on first boot with users it moves to the first
|
// UI is the pre-users machine-wide look; on first boot with users it moves to the first
|
||||||
// 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"`
|
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
||||||
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go) // rules, channels and recent events (internal/api/alerts.go)
|
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
|
||||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go)
|
||||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
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",
|
||||||
|
|||||||
+2
-1
@@ -7,12 +7,13 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"icons": "node scripts/icons.mjs",
|
"icons": "node scripts/icons.mjs",
|
||||||
"test": "node scripts/test-graphcols.mjs"
|
"test": "node scripts/test-graphcols.mjs && node scripts/test-css-imports.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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,20 @@
|
|||||||
|
// node scripts/test-css-imports.mjs — every stylesheet the UI imports is one somebody has read.
|
||||||
|
// A third-party stylesheet usually carries its demo page's global rules (body, *, button); on
|
||||||
|
// 2026-10-09 one restyled every button in the app. Add a file here only once its rules are
|
||||||
|
// known to touch nothing but its own component.
|
||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import { join, relative } from 'node:path'
|
||||||
|
|
||||||
|
const allowed = new Set(['./styles.css', '@xterm/xterm/css/xterm.css'])
|
||||||
|
const src = new URL('../src', import.meta.url).pathname
|
||||||
|
const files = []
|
||||||
|
const walk = (d) => { for (const n of readdirSync(d)) { const p = join(d, n); if (statSync(p).isDirectory()) { if (n !== 'vendor') walk(p) } else if (/\.(ts|tsx)$/.test(n)) files.push(p) } }
|
||||||
|
walk(src)
|
||||||
|
const fails = []
|
||||||
|
for (const f of files) {
|
||||||
|
for (const m of readFileSync(f, 'utf8').matchAll(/import\s+(?:[^'"]*from\s+)?['"]([^'"]+\.css)['"]/g)) {
|
||||||
|
if (!allowed.has(m[1])) fails.push(`${relative(src, f)} imports ${m[1]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fails.length) { console.error('css imports: not allowed:\n ' + fails.join('\n ')); process.exit(1) }
|
||||||
|
console.log(`css imports: ok (${files.length} files)`)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// A textarea that knows what it is holding: line numbers in a gutter, YAML (or a .env)
|
||||||
|
// coloured underneath the text, and the keys a compose file needs — Tab indents by two,
|
||||||
|
// Shift+Tab outdents, Enter keeps the indentation of the line above (one step deeper after
|
||||||
|
// a key or a list dash), and a phone, whose keyboard has none of those, gets a small bar of
|
||||||
|
// them. The text itself stays in a real textarea, so selection, undo and the keyboard are
|
||||||
|
// the browser's own; the colour is a <pre> drawn behind it, kept in step line for line.
|
||||||
|
import { useEffect, useMemo, useRef, useState, ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type Language = 'yaml' | 'env' | 'plain'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string; onChange: (v: string) => void; language?: Language; className?: string
|
||||||
|
readOnly?: boolean; placeholder?: string; spellCheck?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const INDENT = ' '
|
||||||
|
const coarse = () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches
|
||||||
|
|
||||||
|
// One line of YAML, split into spans by what each part is. Deliberately shallow: a compose
|
||||||
|
// file is keys, scalars, lists and comments; anything cleverer is a parser, which this is not.
|
||||||
|
function yamlLine(line: string): ReactNode[] {
|
||||||
|
const out: ReactNode[] = []
|
||||||
|
let i = 0
|
||||||
|
const push = (cls: string, text: string) => { if (text) out.push(cls ? <span key={out.length} className={cls}>{text}</span> : text) }
|
||||||
|
const m = /^(\s*)(-\s+)?/.exec(line)!
|
||||||
|
push('', m[1]); push('y-dash', m[2] ?? ''); i = m[0].length
|
||||||
|
const rest = line.slice(i)
|
||||||
|
if (rest.startsWith('#')) { push('y-comment', rest); return out }
|
||||||
|
// a key: up to the first colon that is followed by a space or the end
|
||||||
|
const key = /^("[^"]*"|'[^']*'|[^\s:#][^:#]*?)(:)(?=\s|$)/.exec(rest)
|
||||||
|
let tail = rest
|
||||||
|
if (key) { push('y-key', key[1]); push('y-colon', ':'); tail = rest.slice(key[0].length) }
|
||||||
|
// the value, with a trailing comment cut off
|
||||||
|
let value = tail, comment = ''
|
||||||
|
const c = / #.*$/.exec(tail) ?? (tail.startsWith('#') ? /#.*$/.exec(tail) : null)
|
||||||
|
if (c) { value = tail.slice(0, c.index); comment = tail.slice(c.index) }
|
||||||
|
const v = value.trim()
|
||||||
|
const lead = value.slice(0, value.length - value.trimStart().length)
|
||||||
|
push('', lead)
|
||||||
|
if (!v) { /* nothing */ }
|
||||||
|
else if (/^(["']).*\1$/.test(v)) push('y-string', v)
|
||||||
|
else if (/^(true|false|yes|no|on|off|null|~)$/i.test(v)) push('y-bool', v)
|
||||||
|
else if (/^[-+]?(\d[\d_]*(\.\d+)?|\.\d+)([eE][-+]?\d+)?$/.test(v) || /^0x[0-9a-fA-F]+$/.test(v)) push('y-number', v)
|
||||||
|
else if (/^[&*!]/.test(v)) push('y-anchor', v)
|
||||||
|
else if (/^[|>][-+]?\d*$/.test(v)) push('y-block', v)
|
||||||
|
else push(key ? 'y-value' : (m[2] ? 'y-value' : ''), v)
|
||||||
|
push('', value.slice(lead.length + v.length))
|
||||||
|
push('y-comment', comment)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function envLine(line: string): ReactNode[] {
|
||||||
|
if (/^\s*#/.test(line)) return [<span key="c" className="y-comment">{line}</span>]
|
||||||
|
const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_.-]*)(=)(.*)$/.exec(line)
|
||||||
|
if (!m) return [line]
|
||||||
|
return [m[1], <span key="k" className="y-key">{m[2]}</span>, <span key="e" className="y-colon">=</span>, <span key="v" className="y-string">{m[4]}</span>]
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight(text: string, lang: Language): ReactNode[] {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
const f = lang === 'yaml' ? yamlLine : lang === 'env' ? envLine : (l: string) => [l] as ReactNode[]
|
||||||
|
return lines.map((l, i) => <div key={i} className="code-line">{f(l)}{l === '' ? '' : ''}</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setText replaces [start, end) in a textarea with s, through the browser's own edit so Undo
|
||||||
|
// still works, and leaves the caret where asked.
|
||||||
|
function setText(ta: HTMLTextAreaElement, start: number, end: number, s: string, caret: number | [number, number]) {
|
||||||
|
ta.focus()
|
||||||
|
ta.setSelectionRange(start, end)
|
||||||
|
let done = false
|
||||||
|
try { done = document.execCommand('insertText', false, s) } catch { /* not every browser */ }
|
||||||
|
if (!done || ta.value.slice(start, start + s.length) !== s) { ta.setRangeText(s, start, end, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })) }
|
||||||
|
if (Array.isArray(caret)) ta.setSelectionRange(caret[0], caret[1]); else ta.setSelectionRange(caret, caret)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeEditor({ value, onChange, language = 'yaml', className = '', readOnly, placeholder, spellCheck = false }: Props) {
|
||||||
|
const ta = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const pre = useRef<HTMLPreElement>(null)
|
||||||
|
const gutter = useRef<HTMLDivElement>(null)
|
||||||
|
const [touch] = useState(coarse)
|
||||||
|
const lines = useMemo(() => value.split('\n').length, [value])
|
||||||
|
const painted = useMemo(() => highlight(value, language), [value, language])
|
||||||
|
|
||||||
|
// the colour and the numbers follow the text's own scrolling
|
||||||
|
const sync = () => { const t = ta.current; if (!t) return; if (pre.current) { pre.current.scrollTop = t.scrollTop; pre.current.scrollLeft = t.scrollLeft } if (gutter.current) gutter.current.scrollTop = t.scrollTop }
|
||||||
|
useEffect(sync, [value])
|
||||||
|
|
||||||
|
// lines touched by the selection, for indent and outdent
|
||||||
|
const lineRange = (t: HTMLTextAreaElement) => {
|
||||||
|
const s = t.value.lastIndexOf('\n', t.selectionStart - 1) + 1
|
||||||
|
let e = t.value.indexOf('\n', Math.max(t.selectionEnd - (t.selectionEnd > t.selectionStart && t.value[t.selectionEnd - 1] === '\n' ? 1 : 0), t.selectionStart))
|
||||||
|
if (e < 0) e = t.value.length
|
||||||
|
return [s, e] as const
|
||||||
|
}
|
||||||
|
const indent = (out: boolean) => {
|
||||||
|
const t = ta.current; if (!t || readOnly) return
|
||||||
|
const [s, e] = lineRange(t)
|
||||||
|
const block = t.value.slice(s, e)
|
||||||
|
const sel = t.selectionStart !== t.selectionEnd || block.includes('\n')
|
||||||
|
if (!sel && !out) { const at = t.selectionStart; setText(t, at, at, INDENT, at + INDENT.length); return }
|
||||||
|
const next = block.split('\n').map((l) => (out ? l.replace(/^ {1,2}/, '') : INDENT + l)).join('\n')
|
||||||
|
if (next === block) return
|
||||||
|
const d0 = out ? -(block.length - next.length) : INDENT.length
|
||||||
|
setText(t, s, e, next, sel ? [s, s + next.length] : [Math.max(s, t.selectionStart + d0), Math.max(s, t.selectionStart + d0)])
|
||||||
|
}
|
||||||
|
const comment = () => {
|
||||||
|
const t = ta.current; if (!t || readOnly) return
|
||||||
|
const [s, e] = lineRange(t)
|
||||||
|
const block = t.value.slice(s, e), ls = block.split('\n')
|
||||||
|
const all = ls.every((l) => /^\s*#/.test(l) || !l.trim())
|
||||||
|
const next = ls.map((l) => (!l.trim() ? l : all ? l.replace(/^(\s*)# ?/, '$1') : l.replace(/^(\s*)/, '$1# '))).join('\n')
|
||||||
|
setText(t, s, e, next, [s, s + next.length])
|
||||||
|
}
|
||||||
|
const newline = () => {
|
||||||
|
const t = ta.current; if (!t || readOnly) return
|
||||||
|
const at = t.selectionStart
|
||||||
|
const lineStart = t.value.lastIndexOf('\n', at - 1) + 1
|
||||||
|
const line = t.value.slice(lineStart, at)
|
||||||
|
let ind = /^\s*/.exec(line)![0]
|
||||||
|
// one step deeper after a key or a list item that opens a block
|
||||||
|
if (language === 'yaml' && /(:|^\s*-)\s*(#.*)?$/.test(line) && line.trim() && !/^\s*#/.test(line)) ind += INDENT
|
||||||
|
setText(t, at, t.selectionEnd, '\n' + ind, at + 1 + ind.length)
|
||||||
|
}
|
||||||
|
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); indent(e.shiftKey) }
|
||||||
|
else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); newline() }
|
||||||
|
else if (e.key === '/' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); comment() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'code ' + className + (readOnly ? ' ro' : '')}>
|
||||||
|
<div className="code-gutter" ref={gutter} aria-hidden="true">{Array.from({ length: lines }, (_, i) => <div key={i}>{i + 1}</div>)}</div>
|
||||||
|
<div className="code-body">
|
||||||
|
<pre className="code-paint" ref={pre} aria-hidden="true">{painted}</pre>
|
||||||
|
<textarea ref={ta} className="code-text" value={value} onChange={(e) => onChange(e.target.value)} onScroll={sync} onKeyDown={onKey}
|
||||||
|
readOnly={readOnly} placeholder={placeholder} spellCheck={spellCheck} autoCapitalize="off" autoCorrect="off" autoComplete="off" wrap="off" />
|
||||||
|
</div>
|
||||||
|
{touch && !readOnly && (
|
||||||
|
<div className="code-keys" onMouseDown={(e) => e.preventDefault()}>
|
||||||
|
<button type="button" title="outdent" onClick={() => indent(true)}>⇤</button>
|
||||||
|
<button type="button" title="indent" onClick={() => indent(false)}>⇥</button>
|
||||||
|
<button type="button" title="comment" onClick={comment}>#</button>
|
||||||
|
<button type="button" title="list item" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '- ', t.selectionStart + 2) }}>-</button>
|
||||||
|
<button type="button" title="colon" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, ': ', t.selectionStart + 2) }}>:</button>
|
||||||
|
<button type="button" title="quotes" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '"' + t.value.slice(t.selectionStart, t.selectionEnd) + '"', t.selectionEnd + 1) }}>"</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+452
-44
@@ -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,17 +348,17 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
|
|||||||
try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
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 (
|
||||||
<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 ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
|
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
|
||||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||||
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
|
{((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>
|
||||||
@@ -231,7 +447,7 @@ export default function Docker() {
|
|||||||
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
|
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
|
||||||
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
|
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
|
||||||
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
|
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
|
||||||
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
|
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? st.host_file : st.note}</div></div>
|
||||||
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
|
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-16
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+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>
|
||||||
|
|||||||
+70
-10
@@ -11,7 +11,11 @@ import Services from './Services'
|
|||||||
import Jobs from './Jobs'
|
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 ZFS from './ZFS'
|
||||||
|
import Virtualization from './Virtualization'
|
||||||
import KVM from './KVM'
|
import KVM from './KVM'
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
import Top from './Top'
|
import Top from './Top'
|
||||||
@@ -24,7 +28,8 @@ 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 = (
|
||||||
@@ -51,27 +56,45 @@ 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: 'jobs', label: 'Jobs', icon: ClockIcon },
|
||||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
|
||||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
|
||||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
|
||||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ 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: 'kvm', label: 'KVM', icon: '⌨' },
|
||||||
|
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
||||||
|
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||||
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
{ id: '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' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | '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', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
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
|
||||||
@@ -102,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)
|
||||||
@@ -111,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>>({})
|
||||||
@@ -125,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])
|
||||||
@@ -258,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>
|
||||||
@@ -269,6 +323,7 @@ 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 === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
||||||
@@ -276,14 +331,19 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
{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 === 'jobs' && <Jobs />}
|
{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 === '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)} />}
|
||||||
@@ -309,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 === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
<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: '' }
|
||||||
@@ -100,7 +101,7 @@ export default function StackNew({ mode, from, networks = [], taken, onClose, on
|
|||||||
{homes.length > 1 && <label className="muted">Where
|
{homes.length > 1 && <label className="muted">Where
|
||||||
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{sameTail(h) ? h.dir : tail(h.dir)} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
|
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{sameTail(h) ? h.dir : tail(h.dir)} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
|
||||||
</label>}
|
</label>}
|
||||||
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
|
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
|
||||||
{!from && <div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={toCompose}>Compose</button></div>}
|
{!from && <div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={toCompose}>Compose</button></div>}
|
||||||
{how === 'form' ? <>
|
{how === 'form' ? <>
|
||||||
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
|
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
|
||||||
@@ -119,7 +120,7 @@ export default function StackNew({ mode, from, networks = [], taken, onClose, on
|
|||||||
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
|
<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} />
|
||||||
|
|||||||
+106
-13
@@ -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() }
|
||||||
@@ -280,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)
|
||||||
@@ -303,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])
|
||||||
|
|
||||||
@@ -342,10 +342,19 @@ export default function Top() {
|
|||||||
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">
|
||||||
@@ -360,7 +369,8 @@ export default function Top() {
|
|||||||
<div className="t-up">up {upfor(uptime)}</div>
|
<div className="t-up">up {upfor(uptime)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="t-cores">
|
<div className="t-cores">
|
||||||
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}</div>
|
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
|
||||||
|
{pt?.cpu_power_w ? <span title="the CPU's own power reading: cores, built-in graphics and memory controller, not the whole machine">{pt.cpu_mhz ? ' · ' : ''}{pt.cpu_power_w < 10 ? pt.cpu_power_w.toFixed(1) : Math.round(pt.cpu_power_w)} W</span> : null}</div>
|
||||||
<div className="t-core">
|
<div className="t-core">
|
||||||
<span className="t-core-n">CPU</span>
|
<span className="t-core-n">CPU</span>
|
||||||
<Meter pct={cpu} grad="cpu" />
|
<Meter pct={cpu} grad="cpu" />
|
||||||
@@ -383,6 +393,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]) => (
|
||||||
@@ -445,17 +465,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" />
|
||||||
@@ -473,7 +493,7 @@ export default function Top() {
|
|||||||
|
|
||||||
// The rows are the heaviest thing on the page — hundreds of them — so they are redrawn when the
|
// 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) => {
|
||||||
@@ -487,6 +507,7 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
|
|||||||
<span className="c-user">{p.user}</span>
|
<span className="c-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>
|
||||||
)
|
)
|
||||||
@@ -496,6 +517,78 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// One GPU, laid out like the cpu box: the busy curve on the left; on the right the device's
|
||||||
|
// meters — busy, each engine, temperature — then clock, power and memory, then the processes
|
||||||
|
// that have it open with their share. What cannot be measured on this card is left out rather
|
||||||
|
// than shown as zero: an Arc on i915 has no temperature sensor, Asahi has no busy counter.
|
||||||
|
const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'], ['video', 'video'], ['video-enhance', 'enhance'], ['copy', 'copy']]
|
||||||
|
function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; tick: number; advance: number; cap: number }) {
|
||||||
|
const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
|
||||||
|
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined
|
||||||
|
const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts'
|
||||||
|
: g.usage_src === 'idle' ? 'busy, from idle residency' : g.usage_src === 'busy' ? 'busy, as the driver reports it' : g.usage_src === 'clients' ? 'the busiest engine, summed over its clients' : 'this driver exposes no activity counter'
|
||||||
|
return (
|
||||||
|
<div className="t-gpu">
|
||||||
|
<div className="t-gpu-graph">
|
||||||
|
<Graph values={values} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
|
||||||
|
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
|
||||||
|
</div>
|
||||||
|
<div className="t-gpu-stats">
|
||||||
|
<div className="t-cores-head"><b>{g.name}</b><span className="spacer" />{g.driver}{g.integrated ? ' · integrated' : ''}</div>
|
||||||
|
<div className="t-core">
|
||||||
|
<span className="t-core-n">GPU</span>
|
||||||
|
<Meter pct={g.usage_src ? g.usage : 0} grad="cpu" />
|
||||||
|
<span className="t-core-p">{g.usage_src ? `${Math.round(g.usage)}%` : '—'}</span>
|
||||||
|
<span className="t-core-t">{temp !== undefined && <Meter pct={Math.min(100, temp)} grad="temp" />}</span>
|
||||||
|
<span className="t-core-c" title={temp !== undefined ? (g.temp_src === 'package' ? 'the CPU package: an integrated GPU shares the die, and this is its only sensor' : "the card's own sensor") : 'no temperature sensor on this device'}>{temp !== undefined ? `${Math.round(temp)}°C${g.temp_src === 'package' ? '*' : ''}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
{engines.map(([k, label]) => (
|
||||||
|
<div className="t-core" key={k}>
|
||||||
|
<span className="t-core-n t-eng">{label}</span>
|
||||||
|
<Meter pct={g.engines![k]} grad="cpu" />
|
||||||
|
<span className="t-core-p">{Math.round(g.engines![k])}%</span>
|
||||||
|
<span className="t-core-t" /><span className="t-core-c" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="t-gpu-facts">
|
||||||
|
{(g.mhz !== undefined || g.max_mhz) ? <div className="t-row t-sub"><span>Clock</span><span className="spacer" />
|
||||||
|
<span className="t-fact" title={g.req_mhz ? `${Math.round(g.req_mhz)} MHz requested${g.max_mhz ? `, ${Math.round(g.max_mhz)} MHz max` : ''}` : undefined}>{Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz</span></div> : null}
|
||||||
|
{(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ? <div className="t-row t-sub"><span>Power</span><span className="spacer" />
|
||||||
|
<span className="t-fact">{(g.power_w ?? 0).toFixed(1)}{g.power_cap_w ? ` / ${Math.round(g.power_cap_w)}` : ''} W</span></div> : null}
|
||||||
|
{g.fan_rpm ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{g.fan_rpm} rpm</span></div> : null}
|
||||||
|
{g.mem_total ? <div className="t-row t-sub"><span>Mem</span><span className="t-pct">{Math.round(((g.mem_used ?? 0) / g.mem_total) * 100)}%</span><Meter pct={((g.mem_used ?? 0) / g.mem_total) * 100} grad="used" /><span className="t-amt">{bytes(g.mem_used ?? 0, 1)}</span></div>
|
||||||
|
: g.mem_used ? <div className="t-row t-sub"><span>Mem</span><span className="spacer" /><span className="t-fact" title="resident memory of the processes that have it open">{bytes(g.mem_used, 1)}</span></div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="t-gpu-clients">
|
||||||
|
<div className="t-thead"><span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-mem">MemB</span><span className="c-cpu">Gpu%</span></div>
|
||||||
|
{g.clients.map((c) => (
|
||||||
|
<div className="t-crow" key={c.pid} title={c.engines ? Object.entries(c.engines).map(([k, v]) => `${k} ${v.toFixed(1)}%`).join(' ') : undefined}>
|
||||||
|
<span className="c-pid">{c.pid}</span><span className="c-name">{c.name}</span><span className="c-mem">{c.mem ? shortBytes(c.mem) : ''}</span><span className="c-cpu">{c.usage.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{g.clients.length === 0 && <div className="t-dim t-crow-none">{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden: the console has no root here'}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing on this GPU can be read — no busy counter, engine, temperature, clock, power, memory,
|
||||||
|
// nor a process with it open: the Mac's Asahi, today. Its box would be a graph that can never
|
||||||
|
// draw beside a column of dashes, so it is one line instead. A client opening it brings the box back.
|
||||||
|
const unmeasured = (g: GPU) => !g.usage_src && !g.engines && !(g.temp && g.temp > 0) && !g.mhz && !g.max_mhz && !g.power_w && !g.power_cap_w && !g.fan_rpm && !g.mem_total && !g.mem_used && g.clients.length === 0
|
||||||
|
function GPULine({ g }: { g: GPU }) {
|
||||||
|
return (
|
||||||
|
<div className="t-row t-gpu-line">
|
||||||
|
<span>{g.driver}{g.integrated ? ' · integrated' : ''}</span>
|
||||||
|
{g.stalled
|
||||||
|
? <span className="t-dim" title="a sysfs read of this card stopped answering">readings paused until the console restarts</span>
|
||||||
|
: <span className="t-dim" title="the driver exposes no busy counter, clock, temperature, power or memory reading">nothing measurable</span>}
|
||||||
|
<span className="t-dim t-gpu-who" title={g.clients_ok ? undefined : 'the console has no root here'}>{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// One physical drive. `used` is the bytes of the filesystems on it, left out when none can
|
// 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.
|
// 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 }) {
|
function Disk({ name, note, size, used, io, temp, mounts, pool }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[]; pool?: string }) {
|
||||||
|
|||||||
+6
-2
@@ -52,7 +52,11 @@ export default function Updates() {
|
|||||||
setOpen(p.name)
|
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>
|
||||||
|
|||||||
+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,86 @@
|
|||||||
|
// The graphical console of a VM: spice-html5 (vendored, loaded on demand) drawing the SPICE
|
||||||
|
// stream that Ham Lab bridges from Incus into a canvas. Used for installers from an ISO, and
|
||||||
|
// anything else with a screen rather than a serial line.
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { wsURLPath } from './api'
|
||||||
|
import { Btn, useEscape } from './ui'
|
||||||
|
// No spice.css: upstream's stylesheet is for its own demo page and sets body, *, button and
|
||||||
|
// canvas for the whole document. Imported here it restyled every button in Ham Lab. The
|
||||||
|
// console is styled by the .vga rules in styles.css.
|
||||||
|
|
||||||
|
type Spice = typeof import('./vendor/spice-html5/src/main.js')
|
||||||
|
|
||||||
|
export default function VgaConsole({ name, onClose }: { name: string; onClose: () => void }) {
|
||||||
|
const [state, setState] = useState<'loading' | 'connecting' | 'connected' | 'error'>('loading')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [fit, setFit] = useState(true)
|
||||||
|
const [size, setSize] = useState('')
|
||||||
|
const conn = useRef<InstanceType<Spice['SpiceMainConn']> | null>(null)
|
||||||
|
const mod = useRef<Spice | null>(null)
|
||||||
|
const area = useRef<HTMLDivElement>(null)
|
||||||
|
const screen = useRef<HTMLDivElement>(null)
|
||||||
|
useEscape(onClose)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let dead = false
|
||||||
|
import('./vendor/spice-html5/src/main.js').then((m) => {
|
||||||
|
if (dead) return
|
||||||
|
mod.current = m
|
||||||
|
setState('connecting')
|
||||||
|
try {
|
||||||
|
conn.current = new m.SpiceMainConn({
|
||||||
|
uri: wsURLPath(`/api/virt/instances/${encodeURIComponent(name)}/vga`),
|
||||||
|
screen_id: 'spice-screen', message_id: 'spice-message',
|
||||||
|
onerror: (e) => { setState('error'); setErr(e instanceof Error ? e.message : (e as Event & { reason?: string })?.reason || 'the console closed') },
|
||||||
|
onsuccess: () => setState('connected'),
|
||||||
|
onagent: () => { try { m.handle_resize() } catch { /* no agent in the guest yet */ } },
|
||||||
|
})
|
||||||
|
} catch (e) { setState('error'); setErr(String(e)) }
|
||||||
|
}).catch((e) => { setState('error'); setErr('could not load the SPICE viewer: ' + String(e)) })
|
||||||
|
return () => { dead = true; try { conn.current?.stop() } catch { /* already gone */ } conn.current = null }
|
||||||
|
}, [name])
|
||||||
|
|
||||||
|
// The guest draws at its own resolution; scale the canvas to the pane unless asked not to.
|
||||||
|
useEffect(() => {
|
||||||
|
const a = area.current, s = screen.current
|
||||||
|
if (!a || !s) return
|
||||||
|
const apply = () => {
|
||||||
|
const c = s.querySelector('canvas')
|
||||||
|
if (!c) return
|
||||||
|
setSize(`${c.width}×${c.height}`)
|
||||||
|
c.tabIndex = 1
|
||||||
|
if (!fit) { s.style.transform = ''; s.style.width = ''; s.style.height = ''; return }
|
||||||
|
const k = Math.min(1, (a.clientWidth - 8) / c.width, (a.clientHeight - 8) / c.height)
|
||||||
|
s.style.transformOrigin = 'top left'
|
||||||
|
s.style.transform = `scale(${k})`
|
||||||
|
s.style.width = `${c.width}px`; s.style.height = `${c.height}px`
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
const ro = new ResizeObserver(apply); ro.observe(a)
|
||||||
|
const mo = new MutationObserver(apply); mo.observe(s, { childList: true, attributes: true, subtree: true, attributeFilter: ['width', 'height'] })
|
||||||
|
return () => { ro.disconnect(); mo.disconnect() }
|
||||||
|
}, [fit, state])
|
||||||
|
|
||||||
|
const cad = () => { if (conn.current && mod.current) mod.current.sendCtrlAltDel(conn.current) }
|
||||||
|
const focus = () => screen.current?.querySelector('canvas')?.focus()
|
||||||
|
return (
|
||||||
|
<div className="modal-back vga-back" onClick={onClose}>
|
||||||
|
<div className="vga" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="logs-bar vga-bar">
|
||||||
|
<span className={'dot ' + (state === 'connected' ? 'ok' : state === 'error' ? 'bad' : 'warn')} />
|
||||||
|
<span className="dname">{name}</span>
|
||||||
|
<span className="muted small">{state === 'connected' ? `VGA · ${size || 'waiting for the first frame'}` : state === 'error' ? err : state === 'loading' ? 'loading the viewer…' : 'connecting…'}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<Btn className="btn" disabled={state !== 'connected'} onClick={cad} title="send Ctrl+Alt+Del to the guest">Ctrl+Alt+Del</Btn>
|
||||||
|
<button className={'btn' + (fit ? ' on' : '')} onClick={() => setFit(!fit)} title="scale the screen to this window, or show it pixel for pixel">Fit</button>
|
||||||
|
<button className="btn" onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
<div className="vga-area" ref={area} onClick={focus}>
|
||||||
|
<div id="spice-area"><div id="spice-screen" ref={screen} /></div>
|
||||||
|
<div id="spice-message" className="spice-message vga-msg" />
|
||||||
|
</div>
|
||||||
|
<div className="muted small vga-hint">Click the screen to type into it. Keys go to the guest while it has focus; Escape with focus elsewhere closes this.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
// Virtualization: LXC containers and KVM virtual machines through Incus, per machine. The
|
||||||
|
// page has four states — ready, repair, installable, unavailable — decided by what the
|
||||||
|
// selected machine can do, never by a rule about what should run where. Everything the
|
||||||
|
// tab makes lives under Ham Lab's own folder on that machine.
|
||||||
|
import { FormEvent, Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { api, ExportsView, LibraryFile, TaskInfo, VirtImage, VirtInstance, VirtOverview, VirtStatus } from './api'
|
||||||
|
import VgaConsole from './VgaConsole'
|
||||||
|
import TaskOutput from './TaskOutput'
|
||||||
|
import { useHashSel } from './hashsel'
|
||||||
|
import { useRefreshMs } from './prefs'
|
||||||
|
import { fmtBytes } from './fmt'
|
||||||
|
import { askText, askYes, Btn, CloseBtn, Loading, toast, useEscape } from './ui'
|
||||||
|
|
||||||
|
type Tab = 'instances' | 'images' | 'library'
|
||||||
|
const GiB = 1 << 30
|
||||||
|
const dot = (i: VirtInstance) => (i.status === 'Running' ? 'ok' : i.status === 'Stopped' ? '' : i.status === 'Error' ? 'bad' : 'warn')
|
||||||
|
const kind = (i: VirtInstance) => (i.type === 'virtual-machine' ? 'VM' : 'LXC')
|
||||||
|
const when = (iso?: string) => { if (!iso) return '—'; const d = new Date(iso); return isNaN(d.getTime()) || d.getFullYear() < 1980 ? '—' : d.toLocaleString() }
|
||||||
|
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'failed')
|
||||||
|
const imgLabel = (im: VirtImage) => im.aliases[0] ?? im.fingerprint.slice(0, 12)
|
||||||
|
|
||||||
|
export default function Virtualization() {
|
||||||
|
const [ov, setOv] = useState<VirtOverview | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [tab, setTab] = useState<Tab>('instances')
|
||||||
|
const [sel, setSel] = useHashSel('virt')
|
||||||
|
const [making, setMaking] = useState<null | { image?: string; vm?: boolean }>(null)
|
||||||
|
const [fromIso, setFromIso] = useState<string | null>(null)
|
||||||
|
const [task, setTask] = useState<{ id: string; title: string } | null>(null)
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
const rate = useRefreshMs()
|
||||||
|
const load = useCallback(async () => { try { setOv(await api.virt()); setErr('') } catch (e) { setErr(errMsg(e)) } }, [])
|
||||||
|
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
|
||||||
|
const startTask = (t: TaskInfo) => setTask({ id: t.id, title: t.title })
|
||||||
|
|
||||||
|
if (!ov) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Virtualization tab. Update it from Machines." : err}</div> : <Loading label="Asking Incus…" />}</div>
|
||||||
|
const st = ov.status
|
||||||
|
if (st.state !== 'ready') return <Gate st={st} onChanged={load} />
|
||||||
|
const instances = ov.instances ?? []
|
||||||
|
const images = ov.images ?? []
|
||||||
|
const cur = instances.find((i) => i.name === sel)
|
||||||
|
const running = instances.filter((i) => i.status === 'Running').length
|
||||||
|
const f = filter.trim().toLowerCase()
|
||||||
|
const shown = instances.filter((i) => !f || `${i.name} ${i.image} ${i.type} ${i.status}`.toLowerCase().includes(f))
|
||||||
|
const libCount = ov.library.images.length + ov.library.isos.length
|
||||||
|
return (
|
||||||
|
<div className={'svc virt' + (cur && tab === 'instances' ? ' has-sel' : '') + (tab !== 'instances' ? ' wide' : '')}>
|
||||||
|
<div className="svc-list">
|
||||||
|
<div className="svc-tools">
|
||||||
|
<div className="chips">
|
||||||
|
{(['instances', 'images', 'library'] as Tab[]).map((t) => (
|
||||||
|
<button key={t} className={'chip' + (tab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>
|
||||||
|
{t}{t === 'instances' ? ` · ${running}/${instances.length}` : t === 'images' ? ` · ${images.length}` : ` · ${libCount}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'instances' && (
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button className="btn on" onClick={() => setMaking({})}>+ New instance</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small">{st.kvm ? 'containers and VMs' : 'containers only — no KVM on this machine'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'instances' && instances.length > 3 && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter instances" />}
|
||||||
|
{ov.error && <div className="error">{ov.error}</div>}
|
||||||
|
{task && <TaskOutput id={task.id} onDone={(t) => { load(); if (t.exit_code === 0 && !t.error) toast(`${task.title}: done`); else toast(`${task.title}: failed`, 'err') }} />}
|
||||||
|
{task && <div className="svc-actions"><button className="btn" onClick={() => setTask(null)}>Hide output</button></div>}
|
||||||
|
</div>
|
||||||
|
{tab === 'instances' && (
|
||||||
|
<div className="svc-rows">
|
||||||
|
{shown.map((i) => (
|
||||||
|
<div key={i.name} className={'svc-row' + (i.name === sel ? ' active' : '')} onClick={() => setSel(i.name)}>
|
||||||
|
<span className={'sdot ' + dot(i)} />
|
||||||
|
<div className="svc-name"><div className="n">{i.name} <span className="badge">{kind(i)}</span></div><div className="d">{i.image || '—'}{i.ipv4.length ? ` · ${i.ipv4.join(' ')}` : ''}</div></div>
|
||||||
|
<div className="svc-state">{i.status === 'Running' ? <>{fmtBytes(i.mem_usage)}<br />{i.cpu_pct.toFixed(0)}%</> : i.status.toLowerCase()}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{instances.length === 0 && <div className="muted small" style={{ padding: 12 }}>Nothing here yet. “+ New instance” makes a container{st.kvm ? ' or a VM' : ''} from the catalogue.</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tab === 'images' && <Images st={st} images={images} instances={instances} onTask={startTask} onNew={(image, vm) => { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />}
|
||||||
|
{tab === 'library' && <Library st={st} ov={ov} onTask={startTask} onChanged={load} onNewVM={(iso) => setFromIso(iso)} />}
|
||||||
|
</div>
|
||||||
|
{tab === 'instances' && (cur ? <Detail i={cur} st={st} onBack={() => setSel('')} onChanged={load} onTask={startTask} /> : <Summary ov={ov} />)}
|
||||||
|
{making && <NewInstance st={st} ov={ov} preset={making} onClose={() => setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />}
|
||||||
|
{fromIso !== null && <NewVMFromISO st={st} ov={ov} iso={fromIso} onClose={() => setFromIso(null)} onStarted={(t) => { setFromIso(null); startTask(t); setTab('instances') }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the three states before "ready" ----
|
||||||
|
|
||||||
|
function Gate({ st, onChanged }: { st: VirtStatus; onChanged: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [task, setTask] = useState('')
|
||||||
|
const [waiting, setWaiting] = useState(false)
|
||||||
|
// the install ends by restarting Ham Lab, so poll until it answers again
|
||||||
|
useEffect(() => {
|
||||||
|
if (!waiting) return
|
||||||
|
const t = setInterval(async () => { try { const o = await api.virt(); if (o.status.state === 'ready') { setWaiting(false); onChanged() } } catch { /* restarting */ } }, 3000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [waiting, onChanged])
|
||||||
|
const headline = st.state === 'installable'
|
||||||
|
? `Incus isn't installed on this machine. Ham Lab can install it with ${st.pm}: ${st.kvm ? 'containers and virtual machines' : 'containers only, since this kernel has no KVM'}.`
|
||||||
|
: st.reason
|
||||||
|
return (
|
||||||
|
<div className="dash">
|
||||||
|
<div className="dash-bar"><h2 className="section">Virtualization</h2></div>
|
||||||
|
<section className="card">
|
||||||
|
<div className="notice">{headline}</div>
|
||||||
|
<div className="props" style={{ marginTop: 8 }}>
|
||||||
|
<div><span className="k">KVM</span><span>{st.kvm ? 'yes — virtual machines possible' : 'no — containers only'}</span></div>
|
||||||
|
<div><span className="k">Architecture</span><span className="mono">{st.arch}</span></div>
|
||||||
|
{st.pm && <div><span className="k">Packages</span><span>{st.pm}</span></div>}
|
||||||
|
<div><span className="k">Pool drivers</span><span>{st.drivers.join(', ')}</span></div>
|
||||||
|
<div><span className="k">Memory</span><span>{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}</span></div>
|
||||||
|
<div><span className="k">Disk</span><span>{fmtBytes(st.disk_free)} free where Ham Lab's data lives</span></div>
|
||||||
|
<div><span className="k">Docker</span><span>{st.docker ? 'present — its FORWARD policy needs two rules, Install adds them' : 'not here'}</span></div>
|
||||||
|
<div><span className="k">Data</span><span className="mono small">{st.dir}</span></div>
|
||||||
|
{st.layout && <div><span className="k">Last attempt</span><span>{when(st.layout.attempted)} · {st.layout.driver} pool · {st.layout.subnet}</span></div>}
|
||||||
|
</div>
|
||||||
|
{(st.state === 'installable' || st.state === 'repair') && !task && (
|
||||||
|
<div className="svc-actions"><Btn className="btn on" onClick={() => setOpen(true)}>{st.state === 'repair' ? 'Finish setup' : 'Install Incus'}</Btn></div>
|
||||||
|
)}
|
||||||
|
{task && <TaskOutput id={task} onDone={(t) => { if (t.exit_code === 0 && !t.error) setWaiting(true); else { toast('install failed — the output says where', 'err'); setTask('') } }} />}
|
||||||
|
{waiting && <div className="muted small" style={{ marginTop: 8 }}>Ham Lab is restarting to pick up its new group. The page reconnects by itself.</div>}
|
||||||
|
</section>
|
||||||
|
{open && <InstallDialog st={st} onClose={() => setOpen(false)} onStarted={(id) => { setOpen(false); setTask(id) }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: () => void; onStarted: (id: string) => void }) {
|
||||||
|
const [driver, setDriver] = useState(st.layout?.driver ?? st.drivers[0] ?? 'dir')
|
||||||
|
const [size, setSize] = useState(String(st.layout?.pool_size_gib || st.pool_size_gib || 20))
|
||||||
|
const [subnet, setSubnet] = useState(st.layout?.subnet || st.subnet || '172.16.100.1/24')
|
||||||
|
const [docker, setDocker] = useState(st.docker)
|
||||||
|
const [arc, setArc] = useState(st.layout?.arc_max_gib ? String(st.layout.arc_max_gib) : '')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
useEscape(onClose)
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr(''); setBusy(true)
|
||||||
|
try { const t = await api.virtInstall({ driver, pool_size_gib: Number(size) || 0, subnet: subnet.trim(), docker_rules: docker, arc_max_gib: Number(arc) || 0 }); onStarted(t.id) }
|
||||||
|
catch (e) { setErr(errMsg(e)); setBusy(false) }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||||
|
<h3>{st.state === 'repair' ? 'Finish setting up Incus' : 'Install Incus'}</h3>
|
||||||
|
<div className="notice">Everything goes under <span className="mono">{st.dir}</span>: the pool's backing file, ISOs, images, exports. Where is not a choice; how big is.</div>
|
||||||
|
<label className="muted">Pool driver
|
||||||
|
<select value={driver} onChange={(e) => setDriver(e.target.value)}>
|
||||||
|
{st.drivers.map((d) => <option key={d} value={d}>{d}{d === 'zfs' ? ' — snapshots and clones are instant' : d === 'btrfs' ? ' — snapshots, on a loop file' : ' — plain files, no snapshots to speak of'}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{driver !== 'dir' && (
|
||||||
|
<label className="muted">Pool size, GiB — a sparse file, grows as used · {fmtBytes(st.disk_free)} free
|
||||||
|
<input type="number" min={5} max={Math.floor(st.disk_free / GiB)} value={size} onChange={(e) => setSize(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="muted">Bridge address — picked clear of this machine's routes and Docker's pools
|
||||||
|
<input className="mono" value={subnet} onChange={(e) => setSubnet(e.target.value)} spellCheck={false} required />
|
||||||
|
</label>
|
||||||
|
{st.docker && (
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={docker} onChange={(e) => setDocker(e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Add the two DOCKER-USER rules that let the bridge's traffic through Docker's FORWARD drop
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{st.zfs_host && (
|
||||||
|
<label className="muted">Cap the ZFS ARC, GiB — blank leaves it at {st.arc_max_gib} GiB; a cap makes guest memory deterministic
|
||||||
|
<input type="number" min={1} max={Math.floor(st.mem_total / GiB)} value={arc} onChange={(e) => setArc(e.target.value)} placeholder="leave as is" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="muted small">Ham Lab's account ({st.user}) joins the incus-admin group and Ham Lab restarts at the end.</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={busy}>{busy ? 'Starting…' : 'Install'}</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the right-hand pane when nothing is selected: what this machine's Incus is ----
|
||||||
|
|
||||||
|
function Summary({ ov }: { ov: VirtOverview }) {
|
||||||
|
const st = ov.status
|
||||||
|
return (
|
||||||
|
<div className="svc-detail">
|
||||||
|
<div className="svc-head"><span className="dname">Incus {st.version}</span><span className="badge">{st.arch}</span>{!st.kvm && <span className="badge warn">no KVM</span>}</div>
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">Data</span><span className="mono small">{st.dir}</span></div>
|
||||||
|
<div><span className="k">Memory</span><span>{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}</span></div>
|
||||||
|
{st.layout && <div><span className="k">Installed</span><span>{when(st.layout.attempted)} by Ham Lab {st.layout.hamlab_version ?? ''}</span></div>}
|
||||||
|
{!st.layout && <div><span className="k">Installed</span><span>outside Ham Lab — pools and networks below are as found</span></div>}
|
||||||
|
</div>
|
||||||
|
<h3 className="stack-head">Storage pools</h3>
|
||||||
|
<table className="virt-table"><thead><tr><th>Pool</th><th>Driver</th><th>Source</th><th className="num">Used</th><th className="num">Of</th><th className="num">Users</th></tr></thead>
|
||||||
|
<tbody>{(ov.pools ?? []).map((p) => <tr key={p.name}><td>{p.name}</td><td>{p.driver}</td><td className="mono small">{p.source}</td><td className="num">{p.total ? fmtBytes(p.used) : '—'}</td><td className="num">{p.total ? fmtBytes(p.total) : '—'}</td><td className="num">{p.used_by}</td></tr>)}</tbody></table>
|
||||||
|
<h3 className="stack-head">Networks</h3>
|
||||||
|
<table className="virt-table"><thead><tr><th>Network</th><th>Type</th><th>Address</th><th>NAT</th><th className="num">Users</th></tr></thead>
|
||||||
|
<tbody>{(ov.networks ?? []).filter((n) => n.managed).map((n) => <tr key={n.name}><td>{n.name}</td><td>{n.type}</td><td className="mono small">{n.address ?? '—'}</td><td>{n.nat ? 'yes' : 'no'}</td><td className="num">{n.used_by}</td></tr>)}</tbody></table>
|
||||||
|
<div className="muted small">Pick an instance on the left, or make one. Consoles open as tabs on the Terminal page.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- one instance ----
|
||||||
|
|
||||||
|
function Detail({ i, st, onBack, onChanged, onTask }: { i: VirtInstance; st: VirtStatus; onBack: () => void; onChanged: () => void; onTask: (t: TaskInfo) => void }) {
|
||||||
|
const [tab, setTab] = useState<'overview' | 'snapshots' | 'mounts' | 'config'>('overview')
|
||||||
|
const [vga, setVga] = useState(false)
|
||||||
|
const running = i.status === 'Running'
|
||||||
|
const isVM = i.type === 'virtual-machine'
|
||||||
|
const stopped = i.status === 'Stopped'
|
||||||
|
const act = (a: 'start' | 'stop' | 'restart' | 'kill') => async () => { try { await api.virtAction(i.name, a); toast(`${i.name}: ${a} ok`); onChanged() } catch (e) { toast(`${i.name}: ${errMsg(e)}`, 'err') } }
|
||||||
|
const term = (k: 'console' | 'exec') => async () => {
|
||||||
|
try { const t = await api.virtTerminal(i.name, k); toast(`${k === 'exec' ? 'shell' : 'console'} open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) }
|
||||||
|
catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
const rename = async () => { const to = await askText(`Rename ${i.name} to`, i.name, 'Rename'); if (!to || to === i.name) return; try { await api.virtRename(i.name, to); toast('renamed'); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||||
|
const del = async () => {
|
||||||
|
const typed = await askText(`Delete ${i.name}${running ? ', which is running' : ''}? Its disk and ${i.snapshots.length} snapshot${i.snapshots.length === 1 ? '' : 's'} go with it. Type its name to confirm.`, '', 'Delete')
|
||||||
|
if (typed === null) return
|
||||||
|
if (typed !== i.name) { toast('name did not match', 'err'); return }
|
||||||
|
try { await api.virtDelete(i.name, running); toast(`${i.name} deleted`); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
const publish = async () => {
|
||||||
|
const alias = await askText(`Publish ${i.name} as an image — an alias for it`, `${i.name}-image`, 'Publish')
|
||||||
|
if (!alias) return
|
||||||
|
try { onTask(await api.virtImagePublish(i.name, alias)) } catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
const snap = async () => {
|
||||||
|
const name = await askText(`Snapshot ${i.name} — a name, or blank for the date`, '', 'Snapshot')
|
||||||
|
if (name === null) return
|
||||||
|
try { const r = await api.virtSnapshot(i.name, name); toast(`snapshot ${r.name} taken`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="svc-detail">
|
||||||
|
<CloseBtn onClick={onBack} />
|
||||||
|
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(i)} /><span className="dname">{i.name}</span><span className="badge">{kind(i)}</span><span className={'badge' + (i.status === 'Error' ? ' bad' : '')}>{i.status.toLowerCase()}</span></div>
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">Image</span><span>{i.image || (Object.keys(i.devices).some((d) => d.startsWith('iso')) ? 'none — installed from an ISO' : 'none')}{i.arch ? <span className="muted"> · {i.arch}</span> : ''}</span></div>
|
||||||
|
{i.description && <div><span className="k">About</span><span>{i.description}</span></div>}
|
||||||
|
{running && <div><span className="k">Using</span><span>{i.cpu_pct.toFixed(0)}% of a core · {fmtBytes(i.mem_usage)}{i.mem_peak ? ` (peak ${fmtBytes(i.mem_peak)})` : ''}{i.processes > 0 ? ` · ${i.processes} processes` : ''}</span></div>}
|
||||||
|
<div><span className="k">Limits</span><span>{i.cpu ? `${i.cpu} CPU` : 'all CPUs'} · {i.memory || 'unlimited memory'}</span></div>
|
||||||
|
{i.disk_total > 0 && <div><span className="k">Disk</span><span>{fmtBytes(i.disk_usage)} of {fmtBytes(i.disk_total)}</span></div>}
|
||||||
|
<div><span className="k">Network</span><span className="mono small">{i.ipv4.length ? i.ipv4.join(' ') : running ? 'no address yet' : '—'}</span></div>
|
||||||
|
<div><span className="k">Created</span><span>{when(i.created)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="svc-actions">
|
||||||
|
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
|
||||||
|
{running && <Btn className="btn" onClick={term('exec')}>Shell</Btn>}
|
||||||
|
<Btn className="btn" onClick={term('console')} title="the serial console: boot output, and a login prompt where the guest has one on it">Console</Btn>
|
||||||
|
{isVM && running && <Btn className="btn" onClick={() => setVga(true)} title="the VM's screen, keyboard and mouse — what an installer needs">VGA</Btn>}
|
||||||
|
{running && <Btn className="btn" confirm={`Restart ${i.name}?`} onClick={act('restart')}>Restart</Btn>}
|
||||||
|
{running && <Btn className="btn danger" confirm={`Stop ${i.name}? It is asked to shut down cleanly.`} onClick={act('stop')}>Stop</Btn>}
|
||||||
|
{running && <Btn className="btn danger" confirm={`Force stop ${i.name}? Like pulling the plug — anything unsaved inside is lost.`} onClick={act('kill')}>Force stop</Btn>}
|
||||||
|
<Btn className="btn" onClick={snap}>Snapshot</Btn>
|
||||||
|
<Btn className="btn" onClick={async () => { try { onTask(await api.virtExportsRun(i.name)) } catch (e) { toast(errMsg(e), 'err') } }} title="a tarball of it in the library's exports folder">Export</Btn>
|
||||||
|
{stopped && <Btn className="btn" onClick={rename}>Rename</Btn>}
|
||||||
|
{stopped && <Btn className="btn" onClick={publish} title="make an image of it, to launch more like it">Publish</Btn>}
|
||||||
|
<Btn className="btn danger" onClick={del}>Delete…</Btn>
|
||||||
|
</div>
|
||||||
|
<div className="tabs">
|
||||||
|
<button className={tab === 'overview' ? 'on' : ''} onClick={() => setTab('overview')}>Limits</button>
|
||||||
|
<button className={tab === 'snapshots' ? 'on' : ''} onClick={() => setTab('snapshots')}>Snapshots · {i.snapshots.length}</button>
|
||||||
|
<button className={tab === 'mounts' ? 'on' : ''} onClick={() => setTab('mounts')}>Disks · {Object.values(i.devices).filter((d) => d.type === 'disk').length}</button>
|
||||||
|
<button className={tab === 'config' ? 'on' : ''} onClick={() => setTab('config')}>Config</button>
|
||||||
|
</div>
|
||||||
|
{tab === 'mounts' && <Mounts i={i} st={st} onChanged={onChanged} />}
|
||||||
|
{vga && <VgaConsole name={i.name} onClose={() => setVga(false)} />}
|
||||||
|
{tab === 'overview' && <Limits i={i} st={st} onChanged={onChanged} />}
|
||||||
|
{tab === 'snapshots' && <Snapshots i={i} onChanged={onChanged} />}
|
||||||
|
{tab === 'config' && (
|
||||||
|
<>
|
||||||
|
<div className="virt-kv">{Object.entries(i.config).sort().map(([k, v]) => <Fragment key={k}><span className="muted mono">{k}</span><span className="mono">{v}</span></Fragment>)}</div>
|
||||||
|
<h3 className="stack-head">Devices</h3>
|
||||||
|
<div className="virt-kv">{Object.entries(i.devices).sort().map(([d, cfg]) => <Fragment key={d}><span className="muted mono">{d}</span><span className="mono">{Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join(' ')}</span></Fragment>)}</div>
|
||||||
|
<div className="muted small">Profiles: {i.profiles.join(', ') || '—'}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Limits({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) {
|
||||||
|
const [cpu, setCpu] = useState(i.cpu ?? '')
|
||||||
|
const [mem, setMem] = useState(i.memory ?? '')
|
||||||
|
const [auto, setAuto] = useState(i.config['boot.autostart'] ?? '')
|
||||||
|
const [desc, setDesc] = useState(i.description ?? '')
|
||||||
|
useEffect(() => { setCpu(i.cpu ?? ''); setMem(i.memory ?? ''); setAuto(i.config['boot.autostart'] ?? ''); setDesc(i.description ?? '') }, [i.name, i.cpu, i.memory, i.description, i.config])
|
||||||
|
const changed = cpu !== (i.cpu ?? '') || mem !== (i.memory ?? '') || auto !== (i.config['boot.autostart'] ?? '') || desc !== (i.description ?? '')
|
||||||
|
const save = async () => { try { await api.virtConfig(i.name, { 'limits.cpu': cpu, 'limits.memory': mem, 'boot.autostart': auto, description: desc }); toast('saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="virt-form-row">
|
||||||
|
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder="all" /></label>
|
||||||
|
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder="unlimited · e.g. 2GiB" /></label>
|
||||||
|
</div>
|
||||||
|
<div className="virt-form-row">
|
||||||
|
<label className="muted">Start with the machine
|
||||||
|
<select value={auto} onChange={(e) => setAuto(e.target.value)}><option value="">default (as it was when the host went down)</option><option value="true">yes</option><option value="false">no</option></select>
|
||||||
|
</label>
|
||||||
|
<label className="muted">Description<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label>
|
||||||
|
</div>
|
||||||
|
<div className="muted small">{i.type === 'container' ? 'Containers take new limits live.' : 'A VM takes CPU and memory changes live where its agent is running, otherwise on its next start.'} {fmtBytes(st.mem_avail)} is free on this machine right now.</div>
|
||||||
|
<div className="svc-actions"><Btn className="btn on" disabled={!changed} onClick={save}>Save</Btn></div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Snapshots({ i, onChanged }: { i: VirtInstance; onChanged: () => void }) {
|
||||||
|
const [sched, setSched] = useState(i.schedule ?? '')
|
||||||
|
const [exp, setExp] = useState(i.expiry ?? '')
|
||||||
|
useEffect(() => { setSched(i.schedule ?? ''); setExp(i.expiry ?? '') }, [i.name, i.schedule, i.expiry])
|
||||||
|
const saveSched = async () => { try { await api.virtConfig(i.name, { 'snapshots.schedule': sched.trim(), 'snapshots.expiry': exp.trim() }); toast('schedule saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||||
|
const on = async (s: string, a: 'restore' | 'delete') => {
|
||||||
|
const ok = await askYes(a === 'restore' ? `Restore ${i.name} to snapshot ${s}? Everything since it is lost${i.status === 'Running' ? '; it is stopped first' : ''}.` : `Delete snapshot ${s} of ${i.name}?`, a === 'restore' ? 'Restore' : 'Delete')
|
||||||
|
if (!ok) return
|
||||||
|
try { await api.virtSnapshotAction(i.name, s, a); toast(`${a} ${s}: ok`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{i.snapshots.length === 0 && <div className="muted small">No snapshots. “Snapshot” above takes one now; a schedule below keeps taking them.</div>}
|
||||||
|
{i.snapshots.length > 0 && (
|
||||||
|
<table className="virt-table"><thead><tr><th>Snapshot</th><th>Taken</th><th>Expires</th><th className="num">Size</th><th /></tr></thead>
|
||||||
|
<tbody>{[...i.snapshots].sort((a, b) => b.created.localeCompare(a.created)).map((s) => (
|
||||||
|
<tr key={s.name}><td className="mono">{s.name}</td><td>{when(s.created)}</td><td>{s.expires ? when(s.expires) : 'never'}</td><td className="num">{s.size ? fmtBytes(s.size) : '—'}</td>
|
||||||
|
<td className="num"><Btn className="btn" onClick={() => on(s.name, 'restore')}>Restore</Btn> <Btn className="btn danger" onClick={() => on(s.name, 'delete')}>Delete</Btn></td></tr>
|
||||||
|
))}</tbody></table>
|
||||||
|
)}
|
||||||
|
<section className="card">
|
||||||
|
<div className="virt-form-row">
|
||||||
|
<label className="muted">Schedule — @hourly, @daily, @weekly, or a cron line<input className="mono" value={sched} onChange={(e) => setSched(e.target.value)} placeholder="none" spellCheck={false} /></label>
|
||||||
|
<label className="muted">Keep for — 1d, 2w, 3M…<input className="mono" value={exp} onChange={(e) => setExp(e.target.value)} placeholder="forever" spellCheck={false} /></label>
|
||||||
|
</div>
|
||||||
|
<div className="muted small">Incus takes and expires these itself, so they carry on whether or not this console is up.</div>
|
||||||
|
<div className="svc-actions"><Btn className="btn on" disabled={sched === (i.schedule ?? '') && exp === (i.expiry ?? '')} onClick={saveSched}>Save schedule</Btn></div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- making one ----
|
||||||
|
|
||||||
|
function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; preset: { image?: string; vm?: boolean }; onClose: () => void; onStarted: (t: TaskInfo) => void }) {
|
||||||
|
const [vm, setVm] = useState(!!preset.vm && st.kvm)
|
||||||
|
const [image, setImage] = useState(preset.image ?? '')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [cpu, setCpu] = useState('')
|
||||||
|
const [mem, setMem] = useState('')
|
||||||
|
const [disk, setDisk] = useState('')
|
||||||
|
const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge')
|
||||||
|
const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '')
|
||||||
|
const [cloud, setCloud] = useState('')
|
||||||
|
const [showCloud, setShowCloud] = useState(false)
|
||||||
|
const [desc, setDesc] = useState('')
|
||||||
|
const [start, setStart] = useState(true)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [remote, setRemote] = useState<VirtImage[] | null>(null)
|
||||||
|
const [remoteErr, setRemoteErr] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const type = vm ? 'virtual-machine' : 'container'
|
||||||
|
useEscape(onClose)
|
||||||
|
useEffect(() => { setRemote(null); setRemoteErr(''); api.virtRemote(type).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type])
|
||||||
|
const local = (ov.images ?? []).filter((im) => im.type === type)
|
||||||
|
const picks = useMemo(() => {
|
||||||
|
const f = q.trim().toLowerCase()
|
||||||
|
const match = (im: VirtImage) => !f || `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release}`.toLowerCase().includes(f)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: { ref: string; label: string; sub: string; here: boolean }[] = []
|
||||||
|
for (const im of local) { if (!match(im)) continue; const ref = imgLabel(im); if (seen.has(ref)) continue; seen.add(ref); out.push({ ref, label: ref, sub: `${im.description} · on this machine · ${fmtBytes(im.size)}`, here: true }) }
|
||||||
|
for (const im of remote ?? []) {
|
||||||
|
const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0]
|
||||||
|
if (!a || !match(im)) continue
|
||||||
|
const ref = `images:${a}`
|
||||||
|
if (seen.has(ref)) continue
|
||||||
|
seen.add(ref); out.push({ ref, label: a, sub: `${im.description}${im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · ${fmtBytes(im.size)}`, here: false })
|
||||||
|
}
|
||||||
|
return out.slice(0, 60)
|
||||||
|
}, [q, local, remote])
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr(''); setBusy(true)
|
||||||
|
try { onStarted(await api.virtCreate({ name: name.trim(), image: image.trim(), vm, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), network: net, cloud_init: showCloud ? cloud : '', profiles: [], description: desc.trim(), start })) }
|
||||||
|
catch (e) { setErr(errMsg(e)); setBusy(false) }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||||
|
<h3>New instance</h3>
|
||||||
|
<div className="chips">
|
||||||
|
<button type="button" className={'chip' + (!vm ? ' on' : '')} onClick={() => setVm(false)}>container</button>
|
||||||
|
<button type="button" className={'chip' + (vm ? ' on' : '')} disabled={!st.kvm} title={st.kvm ? '' : 'no KVM on this machine'} onClick={() => setVm(true)}>virtual machine</button>
|
||||||
|
</div>
|
||||||
|
<label className="muted">Image — from this machine, or the public catalogue for {st.arch}
|
||||||
|
<input className="mono" value={image} onChange={(e) => { setImage(e.target.value); setQ(e.target.value.replace(/^images:/, '')) }} placeholder="images:debian/13" spellCheck={false} autoCapitalize="off" autoCorrect="off" required />
|
||||||
|
</label>
|
||||||
|
<div className="virt-pick">
|
||||||
|
{remote === null && !remoteErr && <div className="muted small" style={{ padding: 8 }}>Reading the catalogue…</div>}
|
||||||
|
{remoteErr && <div className="error" style={{ margin: 8 }}>catalogue: {remoteErr}</div>}
|
||||||
|
{picks.map((p) => (
|
||||||
|
<div key={p.ref} className={'svc-row' + (image === p.ref ? ' active' : '')} onClick={() => setImage(p.ref)}>
|
||||||
|
<div className="svc-name"><div className="n">{p.label}{p.here && <span className="badge">here</span>}</div><div className="d">{p.sub}</div></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{remote && picks.length === 0 && <div className="muted small" style={{ padding: 8 }}>Nothing in the catalogue matches.</div>}
|
||||||
|
</div>
|
||||||
|
<div className="virt-form-row">
|
||||||
|
<label className="muted">Name<input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-zA-Z][a-zA-Z0-9\-]{0,62}" placeholder="web-1" spellCheck={false} autoCapitalize="off" autoCorrect="off" required /></label>
|
||||||
|
<label className="muted">Network<select value={net} onChange={(e) => setNet(e.target.value)}>{nets.map((n) => <option key={n.name} value={n.name}>{n.name}{n.address ? ` · ${n.address}` : ''}</option>)}</select></label>
|
||||||
|
</div>
|
||||||
|
<div className="virt-form-row">
|
||||||
|
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder={vm ? '2' : 'all'} /></label>
|
||||||
|
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder={vm ? '2GiB' : 'unlimited'} /></label>
|
||||||
|
<label className="muted">Root disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} placeholder={vm ? '20GiB' : 'pool default'} /></label>
|
||||||
|
</div>
|
||||||
|
<div className="muted small">{fmtBytes(st.mem_avail)} free on this machine.{vm ? ' A VM’s memory is a reservation.' : ''}</div>
|
||||||
|
<label className="muted">Description — optional<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={showCloud} onChange={(e) => setShowCloud(e.target.checked)} style={{ width: 'auto' }} />cloud-init user-data (an SSH key, packages, a first command — needs a /cloud image)
|
||||||
|
</label>
|
||||||
|
{showCloud && <div className="svc-actions"><Btn className="btn" onClick={async () => { try { const k = await api.virtSSHKeys(); if (!k.keys.length) { toast('no public keys found for ' + k.user, 'info'); return } setCloud(`#cloud-config\nusers:\n - name: ${k.user}\n groups: [sudo]\n shell: /bin/bash\n sudo: ALL=(ALL) NOPASSWD:ALL\n ssh_authorized_keys:\n${k.keys.map((x) => ' - ' + x).join('\n')}\npackage_update: true\npackages: [curl, vim]\n`) } catch (e) { toast(errMsg(e), 'err') } }} title="a cloud-config that makes your account inside, with the public keys this machine's Ham Lab account holds">Use my SSH keys</Btn></div>}
|
||||||
|
{showCloud && <textarea className="mono" rows={6} value={cloud} onChange={(e) => setCloud(e.target.value)} placeholder={'#cloud-config\nusers:\n - name: core\n ssh_authorized_keys:\n - ssh-ed25519 AAAA…\n sudo: ALL=(ALL) NOPASSWD:ALL\npackages: [curl]'} spellCheck={false} />}
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={start} onChange={(e) => setStart(e.target.checked)} style={{ width: 'auto' }} />Start it once made
|
||||||
|
</label>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={busy || !image.trim() || !name.trim()}>{busy ? 'Starting…' : start ? 'Create and start' : 'Create'}</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- images: the local store, the catalogue, and files brought in ----
|
||||||
|
|
||||||
|
function Images({ st, images, instances, onTask, onNew, onChanged }: { st: VirtStatus; images: VirtImage[]; instances: VirtInstance[]; onTask: (t: TaskInfo) => void; onNew: (image: string, vm: boolean) => void; onChanged: () => void }) {
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [type, setType] = useState<'container' | 'virtual-machine'>('container')
|
||||||
|
const [remote, setRemote] = useState<VirtImage[] | null>(null)
|
||||||
|
const [remoteErr, setRemoteErr] = useState('')
|
||||||
|
const [path, setPath] = useState('')
|
||||||
|
const [alias, setAlias] = useState('')
|
||||||
|
const [itype, setItype] = useState<'container' | 'virtual-machine'>('container')
|
||||||
|
const loadRemote = useCallback((refresh = false) => { setRemote(null); setRemoteErr(''); api.virtRemote(type, refresh).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type])
|
||||||
|
useEffect(() => { loadRemote() }, [loadRemote])
|
||||||
|
const usedBy = (im: VirtImage) => instances.filter((i) => i.config['volatile.base_image'] === im.fingerprint || im.aliases.includes(i.image)).map((i) => i.name)
|
||||||
|
const f = q.trim().toLowerCase()
|
||||||
|
const found = (remote ?? []).filter((im) => f && `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release} ${im.variant}`.toLowerCase().includes(f)).slice(0, 40)
|
||||||
|
const del = async (im: VirtImage) => {
|
||||||
|
if (!(await askYes(`Delete the image ${imgLabel(im)} from this machine's store? Instances made from it are not affected; the file in the library, if any, stays.`, 'Delete'))) return
|
||||||
|
try { await api.virtImageDelete(im.fingerprint); toast('image deleted'); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
const imp = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtImageImport(path.trim(), alias.trim(), itype)); setPath(''); setAlias('') } catch (er) { toast(errMsg(er), 'err') } }
|
||||||
|
return (
|
||||||
|
<div className="svc-rows" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">On this machine</span><span className="spacer" /><span className="muted small">{images.length} image{images.length === 1 ? '' : 's'} · {fmtBytes(images.reduce((a, im) => a + im.size, 0))}</span></div>
|
||||||
|
{images.length === 0 && <div className="muted small">No images yet. Make an instance and its image is kept here; or download one below so the next one is instant.</div>}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<table className="virt-table"><thead><tr><th>Image</th><th>Type</th><th>What</th><th className="num">Size</th><th>Used by</th><th /></tr></thead>
|
||||||
|
<tbody>{images.map((im) => (
|
||||||
|
<tr key={im.fingerprint}><td className="mono">{im.aliases.join(', ') || im.fingerprint.slice(0, 12)}</td><td>{im.type === 'virtual-machine' ? 'VM' : 'LXC'}</td><td>{im.description}{im.cached ? <span className="muted small"> · cached from {im.source}</span> : ''}</td><td className="num">{fmtBytes(im.size)}</td><td className="small">{usedBy(im).join(' ') || '—'}</td>
|
||||||
|
<td className="num"><Btn className="btn" onClick={() => onNew(imgLabel(im), im.type === 'virtual-machine')}>New instance</Btn> <Btn className="btn danger" onClick={() => del(im)}>Delete</Btn></td></tr>
|
||||||
|
))}</tbody></table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">Download from the catalogue</span><span className="spacer" />
|
||||||
|
<div className="chips"><button className={'chip' + (type === 'container' ? ' on' : '')} onClick={() => setType('container')}>containers</button><button className={'chip' + (type === 'virtual-machine' ? ' on' : '')} disabled={!st.kvm} onClick={() => setType('virtual-machine')}>VMs</button></div>
|
||||||
|
<Btn className="btn" onClick={() => loadRemote(true)} title="the catalogue is kept for an hour">Refresh</Btn></div>
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`search images.linuxcontainers.org for ${st.arch} — debian, ubuntu 24.04, alpine…`} />
|
||||||
|
{remote === null && !remoteErr && <div className="muted small">Reading the catalogue…</div>}
|
||||||
|
{remoteErr && <div className="error">{remoteErr}</div>}
|
||||||
|
{remote && !f && <div className="muted small">{remote.length} {type === 'virtual-machine' ? 'VM' : 'container'} images for {st.arch}. Type to search. A download keeps the image in the store and a copy of it in the library.</div>}
|
||||||
|
{found.length > 0 && (
|
||||||
|
<div className="virt-pick">{found.map((im) => { const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0]; return (
|
||||||
|
<div key={im.fingerprint} className="svc-row" style={{ cursor: 'default' }}>
|
||||||
|
<div className="svc-name"><div className="n">{a}</div><div className="d">{im.description}{im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · {fmtBytes(im.size)}</div></div>
|
||||||
|
<Btn className="btn" onClick={async () => { try { onTask(await api.virtImageDownload(a)) } catch (e) { toast(errMsg(e), 'err') } }}>Download</Btn>
|
||||||
|
</div>) })}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">Import a file from this machine</span></div>
|
||||||
|
<form onSubmit={imp} className="virt-form-row" style={{ flexWrap: 'wrap' }}>
|
||||||
|
<label className="muted" style={{ flex: '2 1 240px' }}>Path — a template tarball, or a qcow2 / raw disk for a VM<input className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder="/path/to/image.tar.xz" spellCheck={false} required /></label>
|
||||||
|
<label className="muted">Alias<input className="mono" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="my-template" spellCheck={false} required /></label>
|
||||||
|
<label className="muted">Type<select value={itype} onChange={(e) => setItype(e.target.value as 'container' | 'virtual-machine')}><option value="container">container template</option><option value="virtual-machine" disabled={!st.kvm}>VM disk image</option></select></label>
|
||||||
|
<div className="svc-actions" style={{ alignSelf: 'flex-end' }}><button className="btn on" disabled={!path.trim() || !alias.trim()}>Import</button></div>
|
||||||
|
</form>
|
||||||
|
<div className="muted small">The file is copied into <span className="mono">{st.dir}/images/</span> first — the library is Ham Lab's — then loaded into Incus.</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mounts({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) {
|
||||||
|
const [src, setSrc] = useState('')
|
||||||
|
const [dst, setDst] = useState('')
|
||||||
|
const [dev, setDev] = useState('')
|
||||||
|
const [ro, setRo] = useState(false)
|
||||||
|
const [shift, setShift] = useState(true)
|
||||||
|
const isos = st.dir + '/isos/'
|
||||||
|
const disks = Object.entries(i.devices).filter(([, d]) => d.type === 'disk').sort()
|
||||||
|
const add = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try { const r = await api.virtMountAdd(i.name, { device: dev.trim(), source: src.trim(), path: dst.trim(), readonly: ro, shift }); toast(`mounted as ${r.device}`); setSrc(''); setDst(''); setDev(''); onChanged() } catch (er) { toast(errMsg(er), 'err') }
|
||||||
|
}
|
||||||
|
const remove = async (d: string, iso: boolean) => {
|
||||||
|
if (!(await askYes(iso ? `Eject ${d} from ${i.name}?` : `Remove the mount ${d} from ${i.name}? Nothing on the host is touched.`, iso ? 'Eject' : 'Remove'))) return
|
||||||
|
try { if (iso) await api.virtISODetach(i.name, d); else await api.virtMountRemove(i.name, d); toast('done'); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<table className="virt-table"><thead><tr><th>Device</th><th>Source</th><th>Inside</th><th /></tr></thead>
|
||||||
|
<tbody>{disks.map(([name, d]) => { const iso = (d.source ?? '').startsWith(isos); return (
|
||||||
|
<tr key={name}><td className="mono">{name}{iso && <span className="badge">ISO</span>}{d['boot.priority'] && <span className="badge">boots first</span>}</td>
|
||||||
|
<td className="mono small">{name === 'root' ? `pool ${d.pool}${d.size ? ` · ${d.size}` : ''}` : iso ? (d.source ?? '').slice(isos.length) : d.source}</td>
|
||||||
|
<td className="mono small">{iso ? 'CD-ROM' : d.path}{d.readonly === 'true' ? ' · read only' : ''}{d.shift === 'true' ? ' · shifted' : ''}</td>
|
||||||
|
<td className="num">{name !== 'root' && <Btn className="btn danger" onClick={() => remove(name, iso)}>{iso ? 'Eject' : 'Remove'}</Btn>}</td></tr>) })}</tbody></table>
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">Mount a folder from this machine</span></div>
|
||||||
|
<form onSubmit={add}>
|
||||||
|
<div className="virt-form-row">
|
||||||
|
<label className="muted">Host path<input className="mono" value={src} onChange={(e) => setSrc(e.target.value)} placeholder="/tank/media" spellCheck={false} required /></label>
|
||||||
|
<label className="muted">Path inside<input className="mono" value={dst} onChange={(e) => setDst(e.target.value)} placeholder="/mnt/media" spellCheck={false} required /></label>
|
||||||
|
<label className="muted">Device name — optional<input className="mono" value={dev} onChange={(e) => setDev(e.target.value)} placeholder="mnt-media" spellCheck={false} /></label>
|
||||||
|
</div>
|
||||||
|
<div className="virt-form-row" style={{ alignItems: 'center' }}>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={ro} onChange={(e) => setRo(e.target.checked)} style={{ width: 'auto' }} />read only</label>
|
||||||
|
{i.type === 'container' && <label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }} title="map the host's file ownership into the container's user range, so files look owned by the same names inside"><input type="checkbox" checked={shift} onChange={(e) => setShift(e.target.checked)} style={{ width: 'auto' }} />shift ownership</label>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn on" disabled={!src.trim() || !dst.trim()}>Mount</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="muted small">{i.type === 'container' ? 'A bind mount; it appears at once, running or not.' : 'Shared into the VM over virtiofs; its agent mounts it. Appears at once on a running VM with the agent, otherwise at the next start.'} The data stays where it is — this is the instance’s device, not a copy.</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewVMFromISO({ st, ov, iso, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; iso: string; onClose: () => void; onStarted: (t: TaskInfo) => void }) {
|
||||||
|
const isos = ov.library.isos
|
||||||
|
const [pick, setPick] = useState(iso || isos[0]?.name || '')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [cpu, setCpu] = useState('2')
|
||||||
|
const [mem, setMem] = useState('2GiB')
|
||||||
|
const [disk, setDisk] = useState('20GiB')
|
||||||
|
const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge')
|
||||||
|
const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '')
|
||||||
|
const [sb, setSb] = useState(true)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
useEscape(onClose)
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr(''); setBusy(true)
|
||||||
|
try { onStarted(await api.virtFromISO({ name: name.trim(), iso: pick, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), network: net, secureboot: sb, start: true })) }
|
||||||
|
catch (e) { setErr(errMsg(e)); setBusy(false) }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||||
|
<h3>New VM from an ISO</h3>
|
||||||
|
<label className="muted">ISO<select value={pick} onChange={(e) => setPick(e.target.value)}>{isos.map((f) => <option key={f.name} value={f.name}>{f.name} · {fmtBytes(f.size)}</option>)}</select></label>
|
||||||
|
<div className="virt-form-row">
|
||||||
|
<label className="muted">Name<input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-zA-Z][a-zA-Z0-9\-]{0,62}" placeholder="win11" spellCheck={false} autoCapitalize="off" autoCorrect="off" required /></label>
|
||||||
|
<label className="muted">Network<select value={net} onChange={(e) => setNet(e.target.value)}>{nets.map((n) => <option key={n.name} value={n.name}>{n.name}{n.address ? ` · ${n.address}` : ''}</option>)}</select></label>
|
||||||
|
</div>
|
||||||
|
<div className="virt-form-row">
|
||||||
|
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} /></label>
|
||||||
|
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} /></label>
|
||||||
|
<label className="muted">Disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={sb} onChange={(e) => setSb(e.target.checked)} style={{ width: 'auto' }} />Secure Boot — Windows, Debian, Ubuntu and Fedora boot with it; Alpine, Arch, TrueNAS and most others need it off
|
||||||
|
</label>
|
||||||
|
<div className="muted small">An empty VM with the ISO as its first boot device, started, so the installer is on the VGA console. {fmtBytes(st.mem_avail)} free here; a VM’s memory is a reservation. Windows also wants the virtio drivers ISO attached as a second drive — add it from the VM’s Disks tab.</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={busy || !pick || !name.trim()}>{busy ? 'Starting…' : 'Create and boot the ISO'}</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Library({ st, ov, onTask, onChanged, onNewVM }: { st: VirtStatus; ov: VirtOverview; onTask: (t: TaskInfo) => void; onChanged: () => void; onNewVM: (iso: string) => void }) {
|
||||||
|
const lib = ov.library
|
||||||
|
const vms = (ov.instances ?? []).filter((i) => i.type === 'virtual-machine')
|
||||||
|
const [url, setUrl] = useState(''); const [sha, setSha] = useState(''); const [path, setPath] = useState('')
|
||||||
|
const [upload, setUpload] = useState<{ name: string; pct: number } | null>(null)
|
||||||
|
const [exp, setExp] = useState<ExportsView | null>(null)
|
||||||
|
const [cfg, setCfg] = useState<ExportsView['config'] | null>(null)
|
||||||
|
const loadExports = useCallback(() => api.virtExports().then((v) => { setExp(v); setCfg(v.config) }).catch(() => setExp(null)), [])
|
||||||
|
useEffect(() => { loadExports() }, [loadExports])
|
||||||
|
const uploadFiles = async (files: FileList | null) => {
|
||||||
|
if (!files?.length) return
|
||||||
|
for (const f of Array.from(files)) {
|
||||||
|
if (!/\.(iso|img)$/i.test(f.name)) { toast(`${f.name}: only .iso or .img files`, 'err'); continue }
|
||||||
|
setUpload({ name: f.name, pct: 0 })
|
||||||
|
try { await api.fileUpload(st.dir + '/isos', f, false, (pct) => setUpload({ name: f.name, pct })); toast(`uploaded ${f.name}`) } catch (e) { toast(`${f.name}: ${errMsg(e)}`, 'err') }
|
||||||
|
}
|
||||||
|
setUpload(null); onChanged()
|
||||||
|
}
|
||||||
|
const fetchISO = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtISOFetch(url.trim(), sha.trim(), '')); setUrl(''); setSha('') } catch (er) { toast(errMsg(er), 'err') } }
|
||||||
|
const importISO = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtISOImport(path.trim())); setPath('') } catch (er) { toast(errMsg(er), 'err') } }
|
||||||
|
const attach = async (f: LibraryFile) => {
|
||||||
|
if (vms.length === 0) { toast('no VM to attach it to — make one first, or use New VM from ISO', 'info'); return }
|
||||||
|
const vm = vms.length === 1 ? vms[0].name : await askText(`Attach ${f.name} to which VM? (${vms.map((v) => v.name).join(', ')})`, vms[0].name, 'Attach')
|
||||||
|
if (!vm) return
|
||||||
|
const first = await askYes(`Boot ${vm} from ${f.name} first, ahead of its disk? Say no to just make it available as a CD-ROM.`, 'Boot first')
|
||||||
|
try { const r = await api.virtISOAttach(vm, f.name, first); toast(`${f.name} in ${vm} as ${r.device}`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
const del = async (f: LibraryFile) => { if (!(await askYes(`Delete ${f.name} from the library?`, 'Delete'))) return; try { await api.virtISODelete(f.name); toast('deleted'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||||
|
const rename = async (f: LibraryFile) => { const to = await askText(`Rename ${f.name} to`, f.name, 'Rename'); if (!to || to === f.name) return; try { await api.virtISORename(f.name, to); toast('renamed'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
|
||||||
|
const saveExports = async () => { if (!cfg) return; try { await api.virtExportsPut(cfg); toast(cfg.enabled ? 'exports scheduled' : 'exports paused'); loadExports() } catch (e) { toast(errMsg(e), 'err') } }
|
||||||
|
const delExport = async (inst: string, name: string) => { if (!(await askYes(`Delete the export ${name}?`, 'Delete'))) return; try { await api.virtExportDelete(inst, name); loadExports() } catch (e) { toast(errMsg(e), 'err') } }
|
||||||
|
const table = (files: LibraryFile[]) => files.length === 0 ? <div className="muted small">empty</div> : (
|
||||||
|
<table className="virt-table"><thead><tr><th>File</th><th className="num">Size</th><th>Changed</th></tr></thead>
|
||||||
|
<tbody>{files.map((f) => <tr key={f.name}><td className="mono">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td>{when(f.modified)}</td></tr>)}</tbody></table>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className="svc-rows" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">ISOs</span><span className="spacer" /><span className="muted small mono">{st.dir}/isos</span></div>
|
||||||
|
{lib.isos.length === 0 && <div className="muted small">No ISOs yet. Upload one, fetch one by URL, or import a file already on this machine.</div>}
|
||||||
|
{lib.isos.length > 0 && (
|
||||||
|
<table className="virt-table"><thead><tr><th>File</th><th className="num">Size</th><th>In</th><th /></tr></thead>
|
||||||
|
<tbody>{lib.isos.map((f) => (
|
||||||
|
<tr key={f.name}><td className="mono">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td className="small">{f.attached_to?.join(' ') || '—'}</td>
|
||||||
|
<td className="num" style={{ whiteSpace: 'normal' }}>
|
||||||
|
{st.kvm && <Btn className="btn on" onClick={() => onNewVM(f.name)}>New VM</Btn>} {st.kvm && <Btn className="btn" onClick={() => attach(f)}>Attach…</Btn>} <Btn className="btn" onClick={() => rename(f)}>Rename</Btn> <Btn className="btn danger" disabled={!!f.attached_to?.length} title={f.attached_to?.length ? 'attached — eject it first' : ''} onClick={() => del(f)}>Delete</Btn>
|
||||||
|
</td></tr>
|
||||||
|
))}</tbody></table>
|
||||||
|
)}
|
||||||
|
<div className="virt-form-row" style={{ flexWrap: 'wrap', marginTop: 8 }}>
|
||||||
|
<label className="btn" style={{ alignSelf: 'flex-end' }}>{upload ? `${upload.name} · ${upload.pct}%` : 'Upload…'}<input type="file" accept=".iso,.img" multiple style={{ display: 'none' }} onChange={(e) => uploadFiles(e.target.files)} /></label>
|
||||||
|
<form onSubmit={fetchISO} className="virt-form-row" style={{ flex: '2 1 320px' }}>
|
||||||
|
<label className="muted" style={{ flex: 3 }}>Fetch from a URL<input className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…/something.iso" spellCheck={false} /></label>
|
||||||
|
<label className="muted" style={{ flex: 2 }}>sha256 — optional<input className="mono" value={sha} onChange={(e) => setSha(e.target.value)} spellCheck={false} /></label>
|
||||||
|
<button className="btn" style={{ alignSelf: 'flex-end' }} disabled={!url.trim()}>Fetch</button>
|
||||||
|
</form>
|
||||||
|
<form onSubmit={importISO} className="virt-form-row" style={{ flex: '1 1 260px' }}>
|
||||||
|
<label className="muted" style={{ flex: 1 }}>Import a file on this machine — copied in<input className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder="/tank/software/isos/…/x.iso" spellCheck={false} /></label>
|
||||||
|
<button className="btn" style={{ alignSelf: 'flex-end' }} disabled={!path.trim()}>Import</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="muted small">{st.kvm ? 'An ISO attaches to a VM as a CD-ROM, live or stopped; “New VM” makes an empty VM that boots it, for an installer on the VGA console.' : 'This machine has no KVM, so there is nothing here to boot an ISO.'}</div>
|
||||||
|
</section>
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">Exports</span><span className="spacer" /><span className="muted small mono">{st.dir}/exports</span></div>
|
||||||
|
{cfg && (
|
||||||
|
<div className="virt-form-row" style={{ flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: '0 0 auto' }}><input type="checkbox" checked={cfg.enabled} onChange={(e) => setCfg({ ...cfg, enabled: e.target.checked })} style={{ width: 'auto' }} />nightly</label>
|
||||||
|
<label className="muted" style={{ flex: '0 0 90px' }}>at<input className="mono" type="time" value={`${String(cfg.hour).padStart(2, '0')}:${String(cfg.minute).padStart(2, '0')}`} onChange={(e) => { const [h, m] = e.target.value.split(':').map(Number); if (!isNaN(h) && !isNaN(m)) setCfg({ ...cfg, hour: h, minute: m }) }} /></label>
|
||||||
|
<label className="muted" style={{ flex: '0 0 110px' }}>keep per instance<input className="mono" type="number" min={1} max={100} value={cfg.keep} onChange={(e) => setCfg({ ...cfg, keep: Number(e.target.value) || 1 })} /></label>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: '0 0 auto' }}><input type="checkbox" checked={!cfg.instance_only} onChange={(e) => setCfg({ ...cfg, instance_only: !e.target.checked })} style={{ width: 'auto' }} />include snapshots</label>
|
||||||
|
<span className="spacer" />
|
||||||
|
<Btn className="btn on" disabled={JSON.stringify(cfg) === JSON.stringify(exp?.config)} onClick={saveExports}>Save</Btn>
|
||||||
|
<Btn className="btn" onClick={async () => { try { onTask(await api.virtExportsRun()) } catch (e) { toast(errMsg(e), 'err') } }}>Export all now</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exp && <div className="muted small" style={{ marginTop: 6 }}>{exp.config.enabled ? <>Cron line in <span className="mono">{exp.cron_file}</span>, next {exp.next ? new Date(exp.next).toLocaleString() : '—'}.</> : 'Not scheduled.'} {exp.last ? <>Last run {when(exp.last.at)}: {exp.last.exported.length} exported, {exp.last.pruned.length} pruned{exp.last.errors?.length ? <span className="warn-text">, {exp.last.errors.length} failed: {exp.last.errors.join('; ')}</span> : ''}.</> : 'Never run.'} Every instance as a tarball Incus can import again; the folder is backed up with the rest of Ham Lab’s data.</div>}
|
||||||
|
{exp && exp.files.length > 0 && (
|
||||||
|
<table className="virt-table" style={{ marginTop: 8 }}><thead><tr><th>Instance</th><th>Export</th><th className="num">Size</th><th>Taken</th><th /></tr></thead>
|
||||||
|
<tbody>{exp.files.map((f) => <tr key={f.instance + f.name}><td>{f.instance}</td><td className="mono small">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td>{when(f.modified)}</td><td className="num"><Btn className="btn danger" onClick={() => delExport(f.instance, f.name)}>Delete</Btn></td></tr>)}</tbody></table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="card"><div className="sub-head"><span className="dname">Images</span><span className="spacer" /><span className="muted small mono">{st.dir}/images</span></div>{table(lib.images)}
|
||||||
|
<div className="muted small">Every image downloaded, imported or published lands here as a file, so the library is whole without Incus and can be reloaded into a fresh install.</div></section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+153
-4
@@ -8,10 +8,12 @@
|
|||||||
// the retention in the name — rather than inventing a second convention for the two tools to
|
// 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
|
// 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.
|
// about the machine whether Ham Lab or something else is doing the work.
|
||||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
import { FormEvent, Fragment, useCallback, useEffect, useState } from 'react'
|
||||||
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
import { api, ARCView, CacheDev, PoolCaching, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
||||||
import { fmtBytes } from './fmt'
|
import { fmtBytes } from './fmt'
|
||||||
import { useRefreshMs } from './prefs'
|
import { useRefreshMs } from './prefs'
|
||||||
|
import { fetchHistories } from './sensors'
|
||||||
|
import Sparkline from './Sparkline'
|
||||||
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
|
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
|
||||||
|
|
||||||
const ago = (t?: string | number) => {
|
const ago = (t?: string | number) => {
|
||||||
@@ -95,7 +97,10 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
|
|||||||
if (!open && !snaps) await reload()
|
if (!open && !snaps) await reload()
|
||||||
}
|
}
|
||||||
async function snapshot() {
|
async function snapshot() {
|
||||||
const def = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '.')
|
// 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)
|
const name = await askText(`Snapshot name for ${d.name}@`, def)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() }
|
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() }
|
||||||
@@ -297,7 +302,7 @@ function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
|
|||||||
const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset
|
const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset
|
||||||
return {
|
return {
|
||||||
...BLANK, name: `${x.tool} (adopted)`, dataset: ds,
|
...BLANK, name: `${x.tool} (adopted)`, dataset: ds,
|
||||||
recursive: /\s-\w*r/.test(cmd) || true,
|
recursive: /\s-[a-zA-Z]*r\b/.test(cmd), // -r, or -r folded into another flag group
|
||||||
every: simple ? 'daily' : 'custom',
|
every: simple ? 'daily' : 'custom',
|
||||||
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
|
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
|
||||||
cron: simple ? '' : x.schedule.trim(),
|
cron: simple ? '' : x.schedule.trim(),
|
||||||
@@ -305,6 +310,145 @@ function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 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 ----
|
// ---- page ----
|
||||||
|
|
||||||
export default function ZFS() {
|
export default function ZFS() {
|
||||||
@@ -333,6 +477,11 @@ export default function ZFS() {
|
|||||||
{zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</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="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" />
|
<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>
|
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+190
-7
@@ -55,7 +55,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
export interface 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
|
||||||
@@ -72,9 +72,20 @@ export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: num
|
|||||||
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||||
export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string }
|
export interface 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_power_w?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number }
|
||||||
export interface Sample {
|
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,9 +120,42 @@ export interface StorageDevice { name: string; kind: string; model?: string; ven
|
|||||||
export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean }
|
export interface 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; 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; stacks: number; default: boolean }
|
||||||
export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string }
|
export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string }
|
||||||
export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: string }
|
export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: string }
|
||||||
export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string }
|
export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string }
|
||||||
@@ -125,6 +169,9 @@ export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record<
|
|||||||
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
|
export interface 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> }
|
||||||
@@ -135,16 +182,96 @@ export interface SpaceFile { path: string; size: number; apparent: number; mtime
|
|||||||
|
|
||||||
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
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 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 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 ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||||
export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; cron: string; next?: string; last: ZFSRun }[]; external?: ZFSExternal | null }
|
export interface 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; attached_to?: string[] }
|
||||||
|
export interface ExportConfig { enabled: boolean; hour: number; minute: number; keep: number; instance_only: boolean; instances?: string[]; updated?: string }
|
||||||
|
export interface ExportRun { at: string; exported: string[]; pruned: string[]; errors?: string[]; seconds: number }
|
||||||
|
export interface ExportFile { instance: string; name: string; size: number; modified: string }
|
||||||
|
export interface ExportsView { config: ExportConfig; cron: string; next?: string; last: ExportRun | null; files: ExportFile[]; dir: string; cron_file: string }
|
||||||
|
export interface FromISO { name: string; iso: string; cpu: string; memory: string; disk: string; network: string; secureboot: boolean; start: boolean }
|
||||||
|
export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; networks?: VirtNetwork[]; library: { images: LibraryFile[]; isos: LibraryFile[] } }
|
||||||
|
export interface VirtInstallOpts { driver: string; pool_size_gib: number; subnet: string; docker_rules: boolean; arc_max_gib: number }
|
||||||
|
export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean }
|
||||||
|
|
||||||
export const api = {
|
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'),
|
||||||
@@ -191,6 +318,7 @@ export const api = {
|
|||||||
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, 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),
|
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
||||||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||||
|
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||||||
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||||||
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
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`),
|
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
|
||||||
@@ -228,9 +356,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'),
|
||||||
@@ -244,10 +376,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),
|
||||||
@@ -257,11 +409,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),
|
||||||
@@ -273,6 +427,35 @@ 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}`),
|
||||||
|
virtISOFetch: (url: string, sha256: string, name: string) => req<TaskInfo>('POST', '/api/virt/isos/fetch', { url, sha256, name }),
|
||||||
|
virtISOImport: (path: string) => req<TaskInfo>('POST', '/api/virt/isos/import', { path }),
|
||||||
|
virtISODelete: (name: string) => req<void>('POST', '/api/virt/isos/delete', { name }),
|
||||||
|
virtISORename: (name: string, to: string) => req<void>('POST', '/api/virt/isos/rename', { name, to }),
|
||||||
|
virtISOAttach: (inst: string, iso: string, bootFirst: boolean) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso`, { iso, boot_first: bootFirst }),
|
||||||
|
virtISODetach: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso/detach`, { device }),
|
||||||
|
virtFromISO: (f: FromISO) => req<TaskInfo>('POST', '/api/virt/instances/from-iso', f),
|
||||||
|
virtMountAdd: (inst: string, m: { device: string; source: string; path: string; readonly: boolean; shift: boolean }) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts`, m),
|
||||||
|
virtMountRemove: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts/remove`, { device }),
|
||||||
|
virtExports: () => req<ExportsView>('GET', '/api/virt/exports'),
|
||||||
|
virtExportsPut: (c: ExportConfig) => req<void>('PUT', '/api/virt/exports', c),
|
||||||
|
virtExportsRun: (instance = '') => req<TaskInfo>('POST', '/api/virt/exports/run', { instance }),
|
||||||
|
virtExportDelete: (instance: string, name: string) => req<void>('POST', '/api/virt/exports/delete', { instance, name }),
|
||||||
|
virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wsURLPath(path: string): string {
|
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
|
||||||
|
}
|
||||||
@@ -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 = {}
|
||||||
|
|||||||
+159
-3
@@ -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; }
|
||||||
@@ -288,6 +318,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.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 { 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; }
|
||||||
@@ -395,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; }
|
||||||
@@ -549,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; }
|
||||||
@@ -582,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; }
|
||||||
@@ -606,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); }
|
||||||
@@ -645,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; }
|
||||||
@@ -667,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); }
|
||||||
@@ -697,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; }
|
||||||
@@ -756,3 +887,28 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.kvm-frame.expanded iframe { height: 100%; }
|
.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 { 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; }
|
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
||||||
|
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
||||||
|
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
||||||
|
|
||||||
|
/* ---- Virtualization ---- */
|
||||||
|
.virt .svc-tools .task { max-height: 45vh; }
|
||||||
|
.virt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.virt-table th, .virt-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
.virt-table th { font-weight: 500; opacity: .7; }
|
||||||
|
.virt-table td.num, .virt-table th.num { text-align: right; white-space: nowrap; }
|
||||||
|
.virt-pick { max-height: 240px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
|
||||||
|
.virt-pick .svc-row { padding: 6px 10px; }
|
||||||
|
.virt-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; font-size: 12px; word-break: break-all; }
|
||||||
|
.virt-form-row { display: flex; gap: 8px; }
|
||||||
|
.virt-form-row > label { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.virt-form-row > label > input, .virt-form-row > label > select { width: 100%; box-sizing: border-box; }
|
||||||
|
.virt .modal { max-width: 640px; }
|
||||||
|
.vga-back { align-items: stretch; padding: 10px; }
|
||||||
|
.vga { display: flex; flex-direction: column; gap: 6px; width: 100%; height: 100%; max-width: none; max-height: none; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
|
||||||
|
.vga-bar { flex-shrink: 0; }
|
||||||
|
.vga-area { flex: 1; min-height: 0; overflow: auto; background: #000; border-radius: 6px; display: flex; align-items: flex-start; justify-content: flex-start; padding: 4px; }
|
||||||
|
.vga-area #spice-area { display: inline-block; }
|
||||||
|
.vga-area #spice-screen { display: inline-block; line-height: 0; }
|
||||||
|
.vga-area canvas { outline: none; }
|
||||||
|
.vga-msg { display: none; }
|
||||||
|
.vga-hint { flex-shrink: 0; }
|
||||||
|
|||||||
+32
-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 }) {
|
||||||
@@ -155,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+674
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
GNU LESSER GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
|
||||||
|
This version of the GNU Lesser General Public License incorporates
|
||||||
|
the terms and conditions of version 3 of the GNU General Public
|
||||||
|
License, supplemented by the additional permissions listed below.
|
||||||
|
|
||||||
|
0. Additional Definitions.
|
||||||
|
|
||||||
|
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||||
|
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||||
|
General Public License.
|
||||||
|
|
||||||
|
"The Library" refers to a covered work governed by this License,
|
||||||
|
other than an Application or a Combined Work as defined below.
|
||||||
|
|
||||||
|
An "Application" is any work that makes use of an interface provided
|
||||||
|
by the Library, but which is not otherwise based on the Library.
|
||||||
|
Defining a subclass of a class defined by the Library is deemed a mode
|
||||||
|
of using an interface provided by the Library.
|
||||||
|
|
||||||
|
A "Combined Work" is a work produced by combining or linking an
|
||||||
|
Application with the Library. The particular version of the Library
|
||||||
|
with which the Combined Work was made is also called the "Linked
|
||||||
|
Version".
|
||||||
|
|
||||||
|
The "Minimal Corresponding Source" for a Combined Work means the
|
||||||
|
Corresponding Source for the Combined Work, excluding any source code
|
||||||
|
for portions of the Combined Work that, considered in isolation, are
|
||||||
|
based on the Application, and not on the Linked Version.
|
||||||
|
|
||||||
|
The "Corresponding Application Code" for a Combined Work means the
|
||||||
|
object code and/or source code for the Application, including any data
|
||||||
|
and utility programs needed for reproducing the Combined Work from the
|
||||||
|
Application, but excluding the System Libraries of the Combined Work.
|
||||||
|
|
||||||
|
1. Exception to Section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
You may convey a covered work under sections 3 and 4 of this License
|
||||||
|
without being bound by section 3 of the GNU GPL.
|
||||||
|
|
||||||
|
2. Conveying Modified Versions.
|
||||||
|
|
||||||
|
If you modify a copy of the Library, and, in your modifications, a
|
||||||
|
facility refers to a function or data to be supplied by an Application
|
||||||
|
that uses the facility (other than as an argument passed when the
|
||||||
|
facility is invoked), then you may convey a copy of the modified
|
||||||
|
version:
|
||||||
|
|
||||||
|
a) under this License, provided that you make a good faith effort to
|
||||||
|
ensure that, in the event an Application does not supply the
|
||||||
|
function or data, the facility still operates, and performs
|
||||||
|
whatever part of its purpose remains meaningful, or
|
||||||
|
|
||||||
|
b) under the GNU GPL, with none of the additional permissions of
|
||||||
|
this License applicable to that copy.
|
||||||
|
|
||||||
|
3. Object Code Incorporating Material from Library Header Files.
|
||||||
|
|
||||||
|
The object code form of an Application may incorporate material from
|
||||||
|
a header file that is part of the Library. You may convey such object
|
||||||
|
code under terms of your choice, provided that, if the incorporated
|
||||||
|
material is not limited to numerical parameters, data structure
|
||||||
|
layouts and accessors, or small macros, inline functions and templates
|
||||||
|
(ten or fewer lines in length), you do both of the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the object code that the
|
||||||
|
Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
4. Combined Works.
|
||||||
|
|
||||||
|
You may convey a Combined Work under terms of your choice that,
|
||||||
|
taken together, effectively do not restrict modification of the
|
||||||
|
portions of the Library contained in the Combined Work and reverse
|
||||||
|
engineering for debugging such modifications, if you also do each of
|
||||||
|
the following:
|
||||||
|
|
||||||
|
a) Give prominent notice with each copy of the Combined Work that
|
||||||
|
the Library is used in it and that the Library and its use are
|
||||||
|
covered by this License.
|
||||||
|
|
||||||
|
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||||
|
document.
|
||||||
|
|
||||||
|
c) For a Combined Work that displays copyright notices during
|
||||||
|
execution, include the copyright notice for the Library among
|
||||||
|
these notices, as well as a reference directing the user to the
|
||||||
|
copies of the GNU GPL and this license document.
|
||||||
|
|
||||||
|
d) Do one of the following:
|
||||||
|
|
||||||
|
0) Convey the Minimal Corresponding Source under the terms of this
|
||||||
|
License, and the Corresponding Application Code in a form
|
||||||
|
suitable for, and under terms that permit, the user to
|
||||||
|
recombine or relink the Application with a modified version of
|
||||||
|
the Linked Version to produce a modified Combined Work, in the
|
||||||
|
manner specified by section 6 of the GNU GPL for conveying
|
||||||
|
Corresponding Source.
|
||||||
|
|
||||||
|
1) Use a suitable shared library mechanism for linking with the
|
||||||
|
Library. A suitable mechanism is one that (a) uses at run time
|
||||||
|
a copy of the Library already present on the user's computer
|
||||||
|
system, and (b) will operate properly with a modified version
|
||||||
|
of the Library that is interface-compatible with the Linked
|
||||||
|
Version.
|
||||||
|
|
||||||
|
e) Provide Installation Information, but only if you would otherwise
|
||||||
|
be required to provide such information under section 6 of the
|
||||||
|
GNU GPL, and only to the extent that such information is
|
||||||
|
necessary to install and execute a modified version of the
|
||||||
|
Combined Work produced by recombining or relinking the
|
||||||
|
Application with a modified version of the Linked Version. (If
|
||||||
|
you use option 4d0, the Installation Information must accompany
|
||||||
|
the Minimal Corresponding Source and Corresponding Application
|
||||||
|
Code. If you use option 4d1, you must provide the Installation
|
||||||
|
Information in the manner specified by section 6 of the GNU GPL
|
||||||
|
for conveying Corresponding Source.)
|
||||||
|
|
||||||
|
5. Combined Libraries.
|
||||||
|
|
||||||
|
You may place library facilities that are a work based on the
|
||||||
|
Library side by side in a single library together with other library
|
||||||
|
facilities that are not Applications and are not covered by this
|
||||||
|
License, and convey such a combined library under terms of your
|
||||||
|
choice, if you do both of the following:
|
||||||
|
|
||||||
|
a) Accompany the combined library with a copy of the same work based
|
||||||
|
on the Library, uncombined with any other library facilities,
|
||||||
|
conveyed under the terms of this License.
|
||||||
|
|
||||||
|
b) Give prominent notice with the combined library that part of it
|
||||||
|
is a work based on the Library, and explaining where to find the
|
||||||
|
accompanying uncombined form of the same work.
|
||||||
|
|
||||||
|
6. Revised Versions of the GNU Lesser General Public License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the GNU Lesser General Public License from time to time. Such new
|
||||||
|
versions will be similar in spirit to the present version, but may
|
||||||
|
differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Library as you received it specifies that a certain numbered version
|
||||||
|
of the GNU Lesser General Public License "or any later version"
|
||||||
|
applies to it, you have the option of following the terms and
|
||||||
|
conditions either of that published version or of any later version
|
||||||
|
published by the Free Software Foundation. If the Library as you
|
||||||
|
received it does not specify a version number of the GNU Lesser
|
||||||
|
General Public License, you may choose any version of the GNU Lesser
|
||||||
|
General Public License ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Library as you received it specifies that a proxy can decide
|
||||||
|
whether future versions of the GNU Lesser General Public License shall
|
||||||
|
apply, that proxy's public statement of acceptance of any version is
|
||||||
|
permanent authorization for you to choose that version for the
|
||||||
|
Library.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# spice-html5, vendored
|
||||||
|
|
||||||
|
Upstream: https://gitlab.freedesktop.org/spice/spice-html5 — commit b372aab (2026-09-21),
|
||||||
|
copied unchanged on 2026-10-09 (`src/`, `thirdparty/`). LGPL-3.0-or-later; the
|
||||||
|
licence text is beside this file. It draws a VM's SPICE display into a canvas from a websocket
|
||||||
|
carrying the raw SPICE stream — which is what Incus's VGA console websocket is — so Ham Lab's
|
||||||
|
daemon bridges the two and this does the rest. Loaded lazily by `VgaConsole.tsx`, so the main
|
||||||
|
bundle does not carry it. Not on npm in a current form (the `@spice-project/spice-html5`
|
||||||
|
package stopped at 0.2.1 in 2019), hence the copy.
|
||||||
|
|
||||||
|
Upstream's `spice.css` is deliberately **not** here. It is the stylesheet of spice-html5's
|
||||||
|
demo page, with rules for `body`, `*`, `button` and `canvas` that apply to the whole
|
||||||
|
document. It was copied and imported on 2026-10-09 and turned every button in Ham Lab into
|
||||||
|
a gradient pill shifted off its place (v0.1.0-188 to -191). The console is styled by the
|
||||||
|
`.vga` rules in `src/styles.css`; `scripts/test-css-imports.mjs` fails on any new
|
||||||
|
stylesheet import that has not been looked at.
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
"use strict";
|
||||||
|
/*
|
||||||
|
Copyright (C) 2012 by Aric Stewart <aric@codeweavers.com>
|
||||||
|
|
||||||
|
This file is part of spice-html5.
|
||||||
|
|
||||||
|
spice-html5 is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
spice-html5 is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public License
|
||||||
|
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Copyright 1990,91 by Thomas Roell, Dinkelscherben, Germany.
|
||||||
|
*
|
||||||
|
* Permission to use, copy, modify, distribute, and sell this software and its
|
||||||
|
* documentation for any purpose is hereby granted without fee, provided that
|
||||||
|
* the above copyright notice appear in all copies and that both that
|
||||||
|
* copyright notice and this permission notice appear in supporting
|
||||||
|
* documentation, and that the name of Thomas Roell not be used in
|
||||||
|
* advertising or publicity pertaining to distribution of the software without
|
||||||
|
* specific, written prior permission. Thomas Roell makes no representations
|
||||||
|
* about the suitability of this software for any purpose. It is provided
|
||||||
|
* "as is" without express or implied warranty.
|
||||||
|
*
|
||||||
|
* THOMAS ROELL DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||||
|
* INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO
|
||||||
|
* EVENT SHALL THOMAS ROELL BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||||
|
* CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
|
||||||
|
* DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||||
|
* TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
* PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Copyright (c) 1994-2003 by The XFree86 Project, Inc.
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
* copy of this software and associated documentation files (the "Software"),
|
||||||
|
* to deal in the Software without restriction, including without limitation
|
||||||
|
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||||
|
* and/or sell copies of the Software, and to permit persons to whom the
|
||||||
|
* Software is furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||||
|
* THE COPYRIGHT HOLDER(S) OR AUTHOR(S) BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
* OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
* OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Except as contained in this notice, the name of the copyright holder(s)
|
||||||
|
* and author(s) shall not be used in advertising or otherwise to promote
|
||||||
|
* the sale, use or other dealings in this Software without prior written
|
||||||
|
* authorization from the copyright holder(s) and author(s).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* NOTE: The AT/MF keyboards can generate (via the 8042) two (MF: three)
|
||||||
|
* sets of scancodes. Set3 can only be generated by a MF keyboard.
|
||||||
|
* Set2 sends a makecode for keypress, and the same code prefixed by a
|
||||||
|
* F0 for keyrelease. This is a little bit ugly to handle. Thus we use
|
||||||
|
* here for X386 the PC/XT compatible Set1. This set uses 8bit scancodes.
|
||||||
|
* Bit 7 ist set if the key is released. The code E0 switches to a
|
||||||
|
* different meaning to add the new MF cursorkeys, while not breaking old
|
||||||
|
* applications. E1 is another special prefix. Since I assume that there
|
||||||
|
* will be further versions of PC/XT scancode compatible keyboards, we
|
||||||
|
* may be in trouble one day.
|
||||||
|
*
|
||||||
|
* IDEA: 1) Use Set2 on AT84 keyboards and translate it to MF Set3.
|
||||||
|
* 2) Use the keyboards native set and translate it to common keysyms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
var KeyNames = {
|
||||||
|
/*
|
||||||
|
* definition of the AT84/MF101/MF102 Keyboard:
|
||||||
|
* ============================================================
|
||||||
|
* Defined Key Cap Glyphs Pressed value
|
||||||
|
* Key Name Main Also (hex) (dec)
|
||||||
|
* ---------------- ---------- ------- ------ ------
|
||||||
|
*/
|
||||||
|
KEY_Escape :/* Escape 0x01 */ 1,
|
||||||
|
KEY_1 :/* 1 ! 0x02 */ 2,
|
||||||
|
KEY_2 :/* 2 @ 0x03 */ 3,
|
||||||
|
KEY_3 :/* 3 # 0x04 */ 4,
|
||||||
|
KEY_4 :/* 4 $ 0x05 */ 5,
|
||||||
|
KEY_5 :/* 5 % 0x06 */ 6,
|
||||||
|
KEY_6 :/* 6 ^ 0x07 */ 7,
|
||||||
|
KEY_7 :/* 7 & 0x08 */ 8,
|
||||||
|
KEY_8 :/* 8 * 0x09 */ 9,
|
||||||
|
KEY_9 :/* 9 ( 0x0a */ 10,
|
||||||
|
KEY_0 :/* 0 ) 0x0b */ 11,
|
||||||
|
KEY_Minus :/* - (Minus) _ (Under) 0x0c */ 12,
|
||||||
|
KEY_Equal :/* = (Equal) + 0x0d */ 13,
|
||||||
|
KEY_BackSpace :/* Back Space 0x0e */ 14,
|
||||||
|
KEY_Tab :/* Tab 0x0f */ 15,
|
||||||
|
KEY_Q :/* Q 0x10 */ 16,
|
||||||
|
KEY_W :/* W 0x11 */ 17,
|
||||||
|
KEY_E :/* E 0x12 */ 18,
|
||||||
|
KEY_R :/* R 0x13 */ 19,
|
||||||
|
KEY_T :/* T 0x14 */ 20,
|
||||||
|
KEY_Y :/* Y 0x15 */ 21,
|
||||||
|
KEY_U :/* U 0x16 */ 22,
|
||||||
|
KEY_I :/* I 0x17 */ 23,
|
||||||
|
KEY_O :/* O 0x18 */ 24,
|
||||||
|
KEY_P :/* P 0x19 */ 25,
|
||||||
|
KEY_LBrace :/* [ { 0x1a */ 26,
|
||||||
|
KEY_RBrace :/* ] } 0x1b */ 27,
|
||||||
|
KEY_Enter :/* Enter 0x1c */ 28,
|
||||||
|
KEY_LCtrl :/* Ctrl(left) 0x1d */ 29,
|
||||||
|
KEY_A :/* A 0x1e */ 30,
|
||||||
|
KEY_S :/* S 0x1f */ 31,
|
||||||
|
KEY_D :/* D 0x20 */ 32,
|
||||||
|
KEY_F :/* F 0x21 */ 33,
|
||||||
|
KEY_G :/* G 0x22 */ 34,
|
||||||
|
KEY_H :/* H 0x23 */ 35,
|
||||||
|
KEY_J :/* J 0x24 */ 36,
|
||||||
|
KEY_K :/* K 0x25 */ 37,
|
||||||
|
KEY_L :/* L 0x26 */ 38,
|
||||||
|
KEY_SemiColon :/* ;(SemiColon) :(Colon) 0x27 */ 39,
|
||||||
|
KEY_Quote :/* ' (Apostr) " (Quote) 0x28 */ 40,
|
||||||
|
KEY_Tilde :/* ` (Accent) ~ (Tilde) 0x29 */ 41,
|
||||||
|
KEY_ShiftL :/* Shift(left) 0x2a */ 42,
|
||||||
|
KEY_BSlash :/* \(BckSlash) |(VertBar)0x2b */ 43,
|
||||||
|
KEY_Z :/* Z 0x2c */ 44,
|
||||||
|
KEY_X :/* X 0x2d */ 45,
|
||||||
|
KEY_C :/* C 0x2e */ 46,
|
||||||
|
KEY_V :/* V 0x2f */ 47,
|
||||||
|
KEY_B :/* B 0x30 */ 48,
|
||||||
|
KEY_N :/* N 0x31 */ 49,
|
||||||
|
KEY_M :/* M 0x32 */ 50,
|
||||||
|
KEY_Comma :/* , (Comma) < (Less) 0x33 */ 51,
|
||||||
|
KEY_Period :/* . (Period) >(Greater)0x34 */ 52,
|
||||||
|
KEY_Slash :/* / (Slash) ? 0x35 */ 53,
|
||||||
|
KEY_ShiftR :/* Shift(right) 0x36 */ 54,
|
||||||
|
KEY_KP_Multiply :/* * 0x37 */ 55,
|
||||||
|
KEY_Alt :/* Alt(left) 0x38 */ 56,
|
||||||
|
KEY_Space :/* (SpaceBar) 0x39 */ 57,
|
||||||
|
KEY_CapsLock :/* CapsLock 0x3a */ 58,
|
||||||
|
KEY_F1 :/* F1 0x3b */ 59,
|
||||||
|
KEY_F2 :/* F2 0x3c */ 60,
|
||||||
|
KEY_F3 :/* F3 0x3d */ 61,
|
||||||
|
KEY_F4 :/* F4 0x3e */ 62,
|
||||||
|
KEY_F5 :/* F5 0x3f */ 63,
|
||||||
|
KEY_F6 :/* F6 0x40 */ 64,
|
||||||
|
KEY_F7 :/* F7 0x41 */ 65,
|
||||||
|
KEY_F8 :/* F8 0x42 */ 66,
|
||||||
|
KEY_F9 :/* F9 0x43 */ 67,
|
||||||
|
KEY_F10 :/* F10 0x44 */ 68,
|
||||||
|
KEY_NumLock :/* NumLock 0x45 */ 69,
|
||||||
|
KEY_ScrollLock :/* ScrollLock 0x46 */ 70,
|
||||||
|
KEY_KP_7 :/* 7 Home 0x47 */ 71,
|
||||||
|
KEY_KP_8 :/* 8 Up 0x48 */ 72,
|
||||||
|
KEY_KP_9 :/* 9 PgUp 0x49 */ 73,
|
||||||
|
KEY_KP_Minus :/* - (Minus) 0x4a */ 74,
|
||||||
|
KEY_KP_4 :/* 4 Left 0x4b */ 75,
|
||||||
|
KEY_KP_5 :/* 5 0x4c */ 76,
|
||||||
|
KEY_KP_6 :/* 6 Right 0x4d */ 77,
|
||||||
|
KEY_KP_Plus :/* + (Plus) 0x4e */ 78,
|
||||||
|
KEY_KP_1 :/* 1 End 0x4f */ 79,
|
||||||
|
KEY_KP_2 :/* 2 Down 0x50 */ 80,
|
||||||
|
KEY_KP_3 :/* 3 PgDown 0x51 */ 81,
|
||||||
|
KEY_KP_0 :/* 0 Insert 0x52 */ 82,
|
||||||
|
KEY_KP_Decimal :/* . (Decimal) Delete 0x53 */ 83,
|
||||||
|
KEY_SysRequest :/* SysRequest 0x54 */ 84,
|
||||||
|
/* NOTUSED 0x55 */
|
||||||
|
KEY_Less :/* < (Less) >(Greater) 0x56 */ 86,
|
||||||
|
KEY_F11 :/* F11 0x57 */ 87,
|
||||||
|
KEY_F12 :/* F12 0x58 */ 88,
|
||||||
|
|
||||||
|
KEY_Prefix0 :/* special 0x60 */ 96,
|
||||||
|
KEY_Prefix1 :/* specail 0x61 */ 97,
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
KeyNames,
|
||||||
|
};
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
"use strict";
|
||||||
|
/*
|
||||||
|
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
|
||||||
|
|
||||||
|
This file is part of spice-html5.
|
||||||
|
|
||||||
|
spice-html5 is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
spice-html5 is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public License
|
||||||
|
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/*----------------------------------------------------------------------------
|
||||||
|
** bitmap.js
|
||||||
|
** Handle SPICE_IMAGE_TYPE_BITMAP
|
||||||
|
**--------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
import { Constants } from './enums.js';
|
||||||
|
|
||||||
|
/* A 32-bit source pixel is B, G, R, A in memory. Read it as a little-endian
|
||||||
|
word, A R G B, rotate it left by eight to R G B A and store that word
|
||||||
|
big-endian, so the bytes land in the R, G, B, A order ImageData wants: one
|
||||||
|
load, one store and two shifts per pixel. A DataView takes either host
|
||||||
|
byte order and any alignment. Only 32BIT and RGBA are handled; 32BIT
|
||||||
|
ignores the source's high byte and is fully opaque. */
|
||||||
|
function convert_spice_bitmap_to_web(context, spice_bitmap)
|
||||||
|
{
|
||||||
|
if (spice_bitmap.format != Constants.SPICE_BITMAP_FMT_32BIT &&
|
||||||
|
spice_bitmap.format != Constants.SPICE_BITMAP_FMT_RGBA)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const w = spice_bitmap.x;
|
||||||
|
const h = spice_bitmap.y;
|
||||||
|
const stride = spice_bitmap.stride;
|
||||||
|
const ret = context.createImageData(w, h);
|
||||||
|
const opaque = spice_bitmap.format == Constants.SPICE_BITMAP_FMT_32BIT;
|
||||||
|
const top_down = spice_bitmap.flags & Constants.SPICE_BITMAP_FLAGS_TOP_DOWN;
|
||||||
|
const src = new DataView(spice_bitmap.data);
|
||||||
|
const dest = new DataView(ret.data.buffer);
|
||||||
|
let d = 0;
|
||||||
|
for (let y = 0; y < h; y++)
|
||||||
|
{
|
||||||
|
let s = (top_down ? y : h - 1 - y) * stride;
|
||||||
|
for (let x = 0; x < w; x++, d += 4, s += 4)
|
||||||
|
{
|
||||||
|
const v = src.getUint32(s, true);
|
||||||
|
dest.setUint32(d, (v << 8) | (opaque ? 0xff : v >>> 24), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
convert_spice_bitmap_to_web,
|
||||||
|
};
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
|
||||||
|
*/
|
||||||
|
export const code_to_scancode = [];
|
||||||
|
code_to_scancode["Escape"] = 0x01;
|
||||||
|
code_to_scancode["Digit1"] = 0x02;
|
||||||
|
code_to_scancode["Digit2"] = 0x03;
|
||||||
|
code_to_scancode["Digit3"] = 0x04;
|
||||||
|
code_to_scancode["Digit4"] = 0x05;
|
||||||
|
code_to_scancode["Digit5"] = 0x06;
|
||||||
|
code_to_scancode["Digit6"] = 0x07;
|
||||||
|
code_to_scancode["Digit7"] = 0x08;
|
||||||
|
code_to_scancode["Digit8"] = 0x09;
|
||||||
|
code_to_scancode["Digit9"] = 0x0A;
|
||||||
|
code_to_scancode["Digit0"] = 0x0B;
|
||||||
|
code_to_scancode["Minus"] = 0x0C;
|
||||||
|
code_to_scancode["Equal"] = 0x0D;
|
||||||
|
code_to_scancode["Backspace"] = 0x0E;
|
||||||
|
code_to_scancode["Tab"] = 0x0F;
|
||||||
|
code_to_scancode["KeyQ"] = 0x10;
|
||||||
|
code_to_scancode["KeyW"] = 0x11;
|
||||||
|
code_to_scancode["KeyE"] = 0x12;
|
||||||
|
code_to_scancode["KeyR"] = 0x13;
|
||||||
|
code_to_scancode["KeyT"] = 0x14;
|
||||||
|
code_to_scancode["KeyY"] = 0x15;
|
||||||
|
code_to_scancode["KeyU"] = 0x16;
|
||||||
|
code_to_scancode["KeyI"] = 0x17;
|
||||||
|
code_to_scancode["KeyO"] = 0x18;
|
||||||
|
code_to_scancode["KeyP"] = 0x19;
|
||||||
|
code_to_scancode["BracketLeft"] = 0x1A;
|
||||||
|
code_to_scancode["BracketRight"] = 0x1B;
|
||||||
|
code_to_scancode["Enter"] = 0x1C;
|
||||||
|
code_to_scancode["ControlLeft"] = 0x1D;
|
||||||
|
code_to_scancode["KeyA"] = 0x1E;
|
||||||
|
code_to_scancode["KeyS"] = 0x1F;
|
||||||
|
code_to_scancode["KeyD"] = 0x20;
|
||||||
|
code_to_scancode["KeyF"] = 0x21;
|
||||||
|
code_to_scancode["KeyG"] = 0x22;
|
||||||
|
code_to_scancode["KeyH"] = 0x23;
|
||||||
|
code_to_scancode["KeyJ"] = 0x24;
|
||||||
|
code_to_scancode["KeyK"] = 0x25;
|
||||||
|
code_to_scancode["KeyL"] = 0x26;
|
||||||
|
code_to_scancode["Semicolon"] = 0x27;
|
||||||
|
code_to_scancode["Quote"] = 0x28;
|
||||||
|
code_to_scancode["Backquote"] = 0x29;
|
||||||
|
code_to_scancode["ShiftLeft"] = 0x2A;
|
||||||
|
code_to_scancode["Backslash"] = 0x2B;
|
||||||
|
code_to_scancode["KeyZ"] = 0x2C;
|
||||||
|
code_to_scancode["KeyX"] = 0x2D;
|
||||||
|
code_to_scancode["KeyC"] = 0x2E;
|
||||||
|
code_to_scancode["KeyV"] = 0x2F;
|
||||||
|
code_to_scancode["KeyB"] = 0x30;
|
||||||
|
code_to_scancode["KeyN"] = 0x31;
|
||||||
|
code_to_scancode["KeyM"] = 0x32;
|
||||||
|
code_to_scancode["Comma"] = 0x33;
|
||||||
|
code_to_scancode["Period"] = 0x34;
|
||||||
|
code_to_scancode["Slash"] = 0x35;
|
||||||
|
code_to_scancode["ShiftRight"] = 0x36;
|
||||||
|
code_to_scancode["NumpadMultiply"] = 0x37;
|
||||||
|
code_to_scancode["AltLeft"] = 0x38;
|
||||||
|
code_to_scancode["Space"] = 0x39;
|
||||||
|
code_to_scancode["CapsLock"] = 0x3A;
|
||||||
|
code_to_scancode["F1"] = 0x3B;
|
||||||
|
code_to_scancode["F2"] = 0x3C;
|
||||||
|
code_to_scancode["F3"] = 0x3D;
|
||||||
|
code_to_scancode["F4"] = 0x3E;
|
||||||
|
code_to_scancode["F5"] = 0x3F;
|
||||||
|
code_to_scancode["F6"] = 0x40;
|
||||||
|
code_to_scancode["F7"] = 0x41;
|
||||||
|
code_to_scancode["F8"] = 0x42;
|
||||||
|
code_to_scancode["F9"] = 0x43;
|
||||||
|
code_to_scancode["F10"] = 0x44;
|
||||||
|
code_to_scancode["Pause"] = 0x45;
|
||||||
|
code_to_scancode["ScrollLock"] = 0x46;
|
||||||
|
code_to_scancode["Numpad7"] = 0x47;
|
||||||
|
code_to_scancode["Numpad8"] = 0x48;
|
||||||
|
code_to_scancode["Numpad9"] = 0x49;
|
||||||
|
code_to_scancode["NumpadSubtract"] = 0x4A;
|
||||||
|
code_to_scancode["Numpad4"] = 0x4B;
|
||||||
|
code_to_scancode["Numpad5"] = 0x4C;
|
||||||
|
code_to_scancode["Numpad6"] = 0x4D;
|
||||||
|
code_to_scancode["NumpadAdd"] = 0x4E;
|
||||||
|
code_to_scancode["Numpad1"] = 0x4F;
|
||||||
|
code_to_scancode["Numpad2"] = 0x50;
|
||||||
|
code_to_scancode["Numpad3"] = 0x51;
|
||||||
|
code_to_scancode["Numpad0"] = 0x52;
|
||||||
|
code_to_scancode["NumpadDecimal"] = 0x53;
|
||||||
|
code_to_scancode["PrintScreen"] = 0x54;
|
||||||
|
code_to_scancode["IntlBackslash"] = 0x56;
|
||||||
|
code_to_scancode["F11"] = 0x57;
|
||||||
|
code_to_scancode["F12"] = 0x58;
|
||||||
|
code_to_scancode["NumpadEqual"] = 0x59;
|
||||||
|
code_to_scancode["F13"] = 0x64;
|
||||||
|
code_to_scancode["F14"] = 0x65;
|
||||||
|
code_to_scancode["F15"] = 0x66;
|
||||||
|
code_to_scancode["F16"] = 0x67;
|
||||||
|
code_to_scancode["F17"] = 0x68;
|
||||||
|
code_to_scancode["F18"] = 0x69;
|
||||||
|
code_to_scancode["F19"] = 0x6A;
|
||||||
|
code_to_scancode["F20"] = 0x6B;
|
||||||
|
code_to_scancode["F21"] = 0x6C;
|
||||||
|
code_to_scancode["F22"] = 0x6D;
|
||||||
|
code_to_scancode["F23"] = 0x6E;
|
||||||
|
code_to_scancode["KanaMode"] = 0x70;
|
||||||
|
code_to_scancode["IntlRo"] = 0x73;
|
||||||
|
code_to_scancode["F24"] = 0x76;
|
||||||
|
code_to_scancode["Convert"] = 0x79;
|
||||||
|
code_to_scancode["NonConvert"] = 0x7B;
|
||||||
|
code_to_scancode["IntlYen"] = 0x7D;
|
||||||
|
code_to_scancode["NumpadComma"] = 0x7E;
|
||||||
|
code_to_scancode["MediaTrackPrevious"] = 0xE0 | (0x10 << 8);
|
||||||
|
code_to_scancode["MediaTrackNext"] = 0xE0 | (0x19 << 8);
|
||||||
|
code_to_scancode["NumpadEnter"] = 0xE0 | (0x1C << 8);
|
||||||
|
code_to_scancode["ControlRight"] = 0xE0 | (0x1D << 8);
|
||||||
|
code_to_scancode["AudioVolumeMute"] = 0xE0 | (0x20 << 8);
|
||||||
|
code_to_scancode["LaunchApp2"] = 0xE0 | (0x21 << 8);
|
||||||
|
code_to_scancode["MediaPlayPause"] = 0xE0 | (0x22 << 8);
|
||||||
|
code_to_scancode["MediaStop"] = 0xE0 | (0x24 << 8);
|
||||||
|
code_to_scancode["VolumeDown"] = 0xE0 | (0x2E << 8);
|
||||||
|
code_to_scancode["VolumeUp"] = 0xE0 | (0x30 << 8);
|
||||||
|
code_to_scancode["BrowserHome"] = 0xE0 | (0x32 << 8);
|
||||||
|
code_to_scancode["NumpadDivide"] = 0xE0 | (0x35 << 8);
|
||||||
|
code_to_scancode["PrintScreen"] = 0xE0 | (0x37 << 8);
|
||||||
|
code_to_scancode["AltRight"] = 0xE0 | (0x38 << 8);
|
||||||
|
code_to_scancode["NumLock"] = 0xE0 | (0x45 << 8);
|
||||||
|
code_to_scancode["Pause"] = 0xE0 | (0x46 << 8);
|
||||||
|
code_to_scancode["Home"] = 0xE0 | (0x47 << 8);
|
||||||
|
code_to_scancode["ArrowUp"] = 0xE0 | (0x48 << 8);
|
||||||
|
code_to_scancode["PageUp"] = 0xE0 | (0x49 << 8);
|
||||||
|
code_to_scancode["ArrowLeft"] = 0xE0 | (0x4B << 8);
|
||||||
|
code_to_scancode["ArrowRight"] = 0xE0 | (0x4D << 8);
|
||||||
|
code_to_scancode["End"] = 0xE0 | (0x4F << 8);
|
||||||
|
code_to_scancode["ArrowDown"] = 0xE0 | (0x50 << 8);
|
||||||
|
code_to_scancode["PageDown"] = 0xE0 | (0x51 << 8);
|
||||||
|
code_to_scancode["Insert"] = 0xE0 | (0x52 << 8);
|
||||||
|
code_to_scancode["Delete"] = 0xE0 | (0x53 << 8);
|
||||||
|
code_to_scancode["MetaLeft"] = 0xE0 | (0x5B << 8);
|
||||||
|
code_to_scancode["MetaRight"] = 0xE0 | (0x5C << 8);
|
||||||
|
code_to_scancode["ContextMenu"] = 0xE0 | (0x5D << 8);
|
||||||
|
code_to_scancode["Power"] = 0xE0 | (0x5E << 8);
|
||||||
|
code_to_scancode["BrowserSearch"] = 0xE0 | (0x65 << 8);
|
||||||
|
code_to_scancode["BrowserFavorites"] = 0xE0 | (0x66 << 8);
|
||||||
|
code_to_scancode["BrowserRefresh"] = 0xE0 | (0x67 << 8);
|
||||||
|
code_to_scancode["BrowserStop"] = 0xE0 | (0x68 << 8);
|
||||||
|
code_to_scancode["BrowserForward"] = 0xE0 | (0x69 << 8);
|
||||||
|
code_to_scancode["BrowserBack"] = 0xE0 | (0x6A << 8);
|
||||||
|
code_to_scancode["LaunchApp1"] = 0xE0 | (0x6B << 8);
|
||||||
|
code_to_scancode["LaunchMail"] = 0xE0 | (0x6C << 8);
|
||||||
|
code_to_scancode["MediaSelect"] = 0xE0 | (0x6D << 8);
|
||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
"use strict";
|
||||||
|
/*
|
||||||
|
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
|
||||||
|
|
||||||
|
This file is part of spice-html5.
|
||||||
|
|
||||||
|
spice-html5 is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Lesser General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
spice-html5 is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public License
|
||||||
|
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create_rgba_png } from './png.js';
|
||||||
|
import { Constants } from './enums.js';
|
||||||
|
import { DEBUG } from './utils.js';
|
||||||
|
import {
|
||||||
|
SpiceMsgCursorInit,
|
||||||
|
SpiceMsgCursorMove,
|
||||||
|
SpiceMsgCursorSet,
|
||||||
|
} from './spicemsg.js';
|
||||||
|
import { SpiceSimulateCursor } from './simulatecursor.js';
|
||||||
|
import { SpiceConn } from './spiceconn.js';
|
||||||
|
|
||||||
|
/*----------------------------------------------------------------------------
|
||||||
|
** SpiceCursorConn
|
||||||
|
** Drive the Spice Cursor Channel
|
||||||
|
**--------------------------------------------------------------------------*/
|
||||||
|
function SpiceCursorConn()
|
||||||
|
{
|
||||||
|
SpiceConn.apply(this, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The largest cursor image Chromium and Firefox will show. */
|
||||||
|
var MAX_CSS_CURSOR = 128;
|
||||||
|
|
||||||
|
SpiceCursorConn.prototype = Object.create(SpiceConn.prototype);
|
||||||
|
SpiceCursorConn.prototype.process_channel_message = function(msg)
|
||||||
|
{
|
||||||
|
if (msg.type == Constants.SPICE_MSG_CURSOR_INIT)
|
||||||
|
{
|
||||||
|
var cursor_init = new SpiceMsgCursorInit(msg.data);
|
||||||
|
DEBUG > 1 && console.log("SpiceMsgCursorInit");
|
||||||
|
if (this.parent && this.parent.inputs &&
|
||||||
|
this.parent.inputs.mouse_mode == Constants.SPICE_MOUSE_MODE_SERVER)
|
||||||
|
{
|
||||||
|
// FIXME - this imagines that the server actually
|
||||||
|
// provides the current cursor position,
|
||||||
|
// instead of 0,0. As of May 11, 2012,
|
||||||
|
// that assumption was false :-(.
|
||||||
|
this.parent.inputs.mousex = cursor_init.position.x;
|
||||||
|
this.parent.inputs.mousey = cursor_init.position.y;
|
||||||
|
}
|
||||||
|
// FIXME - We don't handle most of the parameters here...
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type == Constants.SPICE_MSG_CURSOR_SET)
|
||||||
|
{
|
||||||
|
var cursor_set = new SpiceMsgCursorSet(msg.data);
|
||||||
|
DEBUG > 1 && console.log("SpiceMsgCursorSet");
|
||||||
|
if (cursor_set.cursor.flags & Constants.SPICE_CURSOR_FLAGS_NONE)
|
||||||
|
{
|
||||||
|
this.hide_cursor();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor_set.cursor.flags > 0)
|
||||||
|
this.log_warn("FIXME: No support for cursor flags " + cursor_set.cursor.flags);
|
||||||
|
|
||||||
|
/* An unconvertible shape is not an unhandled message: returning
|
||||||
|
false here made the base class log a second, misleading
|
||||||
|
"Unknown message type 103" for every one of these. */
|
||||||
|
if (! this.set_cursor(cursor_set.cursor))
|
||||||
|
this.warn_once_per_cursor_type(cursor_set.cursor.header.type);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type == Constants.SPICE_MSG_CURSOR_MOVE)
|
||||||
|
{
|
||||||
|
/* Only meaningful under server mouse mode, where the server owns
|
||||||
|
the pointer position and we track it for inputs. Under client
|
||||||
|
mode the browser draws the pointer and already knows where it
|
||||||
|
is, so there is nothing to do -- but it is handled either way,
|
||||||
|
rather than reported as unimplemented on every move. */
|
||||||
|
var cursor_move = new SpiceMsgCursorMove(msg.data);
|
||||||
|
if (this.parent && this.parent.inputs &&
|
||||||
|
this.parent.inputs.mouse_mode == Constants.SPICE_MOUSE_MODE_SERVER)
|
||||||
|
{
|
||||||
|
this.parent.inputs.mousex = cursor_move.position.x;
|
||||||
|
this.parent.inputs.mousey = cursor_move.position.y;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type == Constants.SPICE_MSG_CURSOR_HIDE)
|
||||||
|
{
|
||||||
|
DEBUG > 1 && console.log("SpiceMsgCursorHide");
|
||||||
|
this.hide_cursor();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type == Constants.SPICE_MSG_CURSOR_TRAIL)
|
||||||
|
{
|
||||||
|
this.known_unimplemented(msg.type, "Cursor Trail");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type == Constants.SPICE_MSG_CURSOR_RESET)
|
||||||
|
{
|
||||||
|
DEBUG > 1 && console.log("SpiceMsgCursorReset");
|
||||||
|
document.getElementById(this.parent.screen_id).style.cursor = "auto";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type == Constants.SPICE_MSG_CURSOR_INVAL_ONE)
|
||||||
|
{
|
||||||
|
this.known_unimplemented(msg.type, "Cursor Inval One");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type == Constants.SPICE_MSG_CURSOR_INVAL_ALL)
|
||||||
|
{
|
||||||
|
DEBUG > 1 && console.log("SpiceMsgCursorInvalAll");
|
||||||
|
// FIXME - There may be something useful to do here...
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One warning per shape type per connection. Windows sends a cursor on
|
||||||
|
nearly every hover, so an unconvertible type used to produce an
|
||||||
|
unbounded stream of identical warnings. */
|
||||||
|
SpiceCursorConn.prototype.warn_once_per_cursor_type = function(type)
|
||||||
|
{
|
||||||
|
if (! this.warned_cursor_types)
|
||||||
|
this.warned_cursor_types = {};
|
||||||
|
if (this.warned_cursor_types[type])
|
||||||
|
return;
|
||||||
|
this.warned_cursor_types[type] = true;
|
||||||
|
this.log_warn("No support for cursor type " + type + "; leaving the cursor as it was.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SPICE cursor shapes to the RGBA the PNG writer expects.
|
||||||
|
Returns null for a type we cannot convert.
|
||||||
|
Layouts follow spice-gtk's set_cursor() (src/channel-cursor.c). */
|
||||||
|
function cursor_to_rgba(header, data)
|
||||||
|
{
|
||||||
|
var width = header.width;
|
||||||
|
var height = header.height;
|
||||||
|
var pixels = width * height;
|
||||||
|
var u8 = new Uint8Array(data);
|
||||||
|
var rgba = new Uint8Array(pixels * 4);
|
||||||
|
var i, at;
|
||||||
|
|
||||||
|
/* SPICE ships 32bpp shapes as native-endian ARGB, which on every
|
||||||
|
platform we run on is B,G,R,A in memory; PNG wants R,G,B,A. The
|
||||||
|
swap is invisible on the black/white/transparent cursors that
|
||||||
|
dominate, which is why it went unnoticed, but colour cursors came
|
||||||
|
out with red and blue exchanged. */
|
||||||
|
if (header.type == Constants.SPICE_CURSOR_TYPE_ALPHA)
|
||||||
|
{
|
||||||
|
if (u8.length < pixels * 4)
|
||||||
|
return null;
|
||||||
|
for (i = 0, at = 0; i < pixels; i++, at += 4)
|
||||||
|
{
|
||||||
|
rgba[at] = u8[at + 2];
|
||||||
|
rgba[at + 1] = u8[at + 1];
|
||||||
|
rgba[at + 2] = u8[at];
|
||||||
|
rgba[at + 3] = u8[at + 3];
|
||||||
|
}
|
||||||
|
return rgba;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two 1bpp masks, AND then XOR, each row padded to a byte. The four
|
||||||
|
combinations are the classic ones:
|
||||||
|
AND=1 XOR=0 transparent
|
||||||
|
AND=1 XOR=1 invert the screen
|
||||||
|
AND=0 XOR=0 black
|
||||||
|
AND=0 XOR=1 white
|
||||||
|
A CSS cursor cannot invert what is under it, so those pixels are
|
||||||
|
drawn black -- the same choice every other HTML SPICE/VNC client
|
||||||
|
makes. It is what Windows' I-beam and resize cursors use, and
|
||||||
|
black-on-transparent reads correctly against the light backgrounds
|
||||||
|
those appear over. */
|
||||||
|
if (header.type == Constants.SPICE_CURSOR_TYPE_MONO)
|
||||||
|
{
|
||||||
|
var bpl = (width + 7) >> 3;
|
||||||
|
if (u8.length < bpl * height * 2)
|
||||||
|
return null;
|
||||||
|
for (var y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
for (var x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
var byte_at = y * bpl + (x >> 3);
|
||||||
|
var bit = 0x80 >> (x & 7);
|
||||||
|
var and_bit = (u8[byte_at] & bit) != 0;
|
||||||
|
var xor_bit = (u8[bpl * height + byte_at] & bit) != 0;
|
||||||
|
|
||||||
|
at = (y * width + x) * 4;
|
||||||
|
if (and_bit && ! xor_bit)
|
||||||
|
continue; /* transparent; rgba is zeroed */
|
||||||
|
|
||||||
|
var lum = xor_bit && ! and_bit ? 255 : 0;
|
||||||
|
rgba[at] = rgba[at + 1] = rgba[at + 2] = lum;
|
||||||
|
rgba[at + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rgba;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 32bpp BGRA followed by a 1bpp AND mask: mask set means the pixel
|
||||||
|
is transparent. spice-gtk renders the "white pixel that is also
|
||||||
|
masked" case as a dim checkerboard rather than a hole, because
|
||||||
|
that combination is how shapes encode invert; do the same. */
|
||||||
|
if (header.type == Constants.SPICE_CURSOR_TYPE_COLOR32)
|
||||||
|
{
|
||||||
|
var mask_at = pixels * 4;
|
||||||
|
if (u8.length < mask_at + ((width + 7) >> 3) * height)
|
||||||
|
return null;
|
||||||
|
for (i = 0, at = 0; i < pixels; i++, at += 4)
|
||||||
|
{
|
||||||
|
var masked = (u8[mask_at + (i >> 3)] & (0x80 >> (i & 7))) != 0;
|
||||||
|
var white = u8[at] == 0xff && u8[at + 1] == 0xff && u8[at + 2] == 0xff;
|
||||||
|
if (masked && white)
|
||||||
|
{
|
||||||
|
var dark = (((i % width) ^ ((i / width) | 0)) & 1) != 0;
|
||||||
|
rgba[at] = rgba[at + 1] = rgba[at + 2] = dark ? 0x30 : 0x50;
|
||||||
|
rgba[at + 3] = dark ? 0xc0 : 0x30;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rgba[at] = u8[at + 2];
|
||||||
|
rgba[at + 1] = u8[at + 1];
|
||||||
|
rgba[at + 2] = u8[at];
|
||||||
|
rgba[at + 3] = masked ? 0 : 255;
|
||||||
|
}
|
||||||
|
return rgba;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* True if the shape was converted and applied. */
|
||||||
|
SpiceCursorConn.prototype.set_cursor = function(cursor)
|
||||||
|
{
|
||||||
|
var rgba = cursor_to_rgba(cursor.header, cursor.data);
|
||||||
|
if (! rgba)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var pngstr = create_rgba_png(cursor.header.width, cursor.header.height, rgba);
|
||||||
|
var curstr = 'url(data:image/png,' + pngstr + ') ' +
|
||||||
|
cursor.header.hot_spot_x + ' ' + cursor.header.hot_spot_y + ", default";
|
||||||
|
var screen = document.getElementById(this.parent.screen_id);
|
||||||
|
screen.style.cursor = 'auto';
|
||||||
|
screen.style.cursor = curstr;
|
||||||
|
/* Chromium and Firefox drop a cursor image over 128 px on a side
|
||||||
|
without saying so: the computed style still reads as the url,
|
||||||
|
and the pointer is the plain arrow, with the arrow's hot spot,
|
||||||
|
so it points somewhere other than the guest's cursor does. A
|
||||||
|
shape that size is drawn under the pointer instead. */
|
||||||
|
var too_large = cursor.header.width > MAX_CSS_CURSOR || cursor.header.height > MAX_CSS_CURSOR;
|
||||||
|
if (too_large || window.getComputedStyle(screen, null).cursor == 'auto')
|
||||||
|
SpiceSimulateCursor.simulate_cursor(this, cursor, screen, pngstr, too_large);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
/* This cursor took effect natively, so drop the simulated one
|
||||||
|
left by an earlier browser-rejected cursor; the mousemove
|
||||||
|
handler would otherwise keep painting it alongside the real
|
||||||
|
cursor forever. */
|
||||||
|
this.remove_simulated_cursor();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A hidden pointer is hidden whichever way the last shape was shown:
|
||||||
|
the CSS cursor goes to none, and a simulated cursor image is removed,
|
||||||
|
or the mousemove handler would keep painting it. */
|
||||||
|
SpiceCursorConn.prototype.hide_cursor = function()
|
||||||
|
{
|
||||||
|
document.getElementById(this.parent.screen_id).style.cursor = "none";
|
||||||
|
this.remove_simulated_cursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
SpiceCursorConn.prototype.remove_simulated_cursor = function()
|
||||||
|
{
|
||||||
|
if (! this.spice_simulated_cursor)
|
||||||
|
return;
|
||||||
|
if (this.spice_simulated_cursor.parentNode)
|
||||||
|
this.spice_simulated_cursor.parentNode.removeChild(this.spice_simulated_cursor);
|
||||||
|
delete this.spice_simulated_cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpiceCursorConn.prototype.cleanup = function()
|
||||||
|
{
|
||||||
|
/* The screen div outlives the connection: a simulated cursor left in
|
||||||
|
it would sit frozen over the next session, and a guest that ended
|
||||||
|
with its pointer hidden (or a custom shape) would leave the next
|
||||||
|
one without a visible pointer until it sends a shape of its own. */
|
||||||
|
this.remove_simulated_cursor();
|
||||||
|
var screen = document.getElementById(this.parent.screen_id);
|
||||||
|
if (screen)
|
||||||
|
screen.style.cursor = "auto";
|
||||||
|
SpiceConn.prototype.cleanup.call(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
SpiceCursorConn,
|
||||||
|
};
|
||||||
+1691
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user