Compare commits
60
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,358 @@
|
|||||||
|
# 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-196** (2026-10-11).
|
||||||
|
Incus runs on `grid` with **plain-files storage on its root disk** (`local`, see decision 1,
|
||||||
|
revised 2026-10-11 — the file-backed ZFS pool and the test VM are gone); the other machines
|
||||||
|
show Install or, on `gate`, unavailable. A **Disk location** picker is on both create forms,
|
||||||
|
with add/remove of locations. Phase 2 verified live on `grid` through the hub on 2026-10-09:
|
||||||
|
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; needed the `binary`
|
||||||
|
websocket subprotocol and one console operation per VM with a data websocket per SPICE
|
||||||
|
channel), a virtiofs mount appeared in a running VM, an export tarball landed and the nightly
|
||||||
|
cron line is written, `instance.*` sensors flow and the "VM or container down" alert default
|
||||||
|
is in. **Left:** the LAN bridge step (his call, needs him near the NanoKVM), cloud-init
|
||||||
|
presets beyond "Use my SSH keys", a backup line for `grid`'s `virt/exports/`.
|
||||||
|
|
||||||
|
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 — revised 2026-10-11.** The create forms carry a **Disk location** picker: a
|
||||||
|
list of the machine's Incus storage pools, each described by where its bytes land, with
|
||||||
|
"+ add a location" (any folder on the machine becomes a plain-files pool). On `grid` the
|
||||||
|
one location is `local`: plain files under `virt/pool/` on the root NVMe, each VM's disk
|
||||||
|
its own sparse file sized on its own, each container a directory, nothing pre-sized.
|
||||||
|
*The first design* (a ZFS pool inside one sparse file, handed to Incus as the pool's
|
||||||
|
source, for instant snapshots and clones) was built, ran for two days, and was taken out
|
||||||
|
at Mitchell's instruction: one pre-sized file holding every disk is an opaque middle
|
||||||
|
layer with a number on it that reads as a reservation, and "zfs · hamlab-virt" on the page
|
||||||
|
read as the NAS pool. The Install dialog still offers zfs/btrfs-in-a-file for a machine
|
||||||
|
that wants instant snapshots; the labels now say exactly what each is. Bulk data is still
|
||||||
|
**bind-mounted in**, not stored in the pool.
|
||||||
|
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 }
|
||||||
+134
-33
@@ -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,59 @@ 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("POST /api/virt/pools", s.auth(s.virtPoolCreate))
|
||||||
|
m.HandleFunc("POST /api/virt/pools/delete", s.auth(s.virtPoolDelete))
|
||||||
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 +301,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 +317,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 +336,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 +369,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 +455,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 +527,11 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ok, trusted := false, false
|
ok, trusted := false, false
|
||||||
var patterns []string
|
var patterns []string
|
||||||
if c, err := r.Cookie(cookieName); err == nil {
|
if a, live, answered := s.cookieActor(w, r); answered {
|
||||||
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
|
return
|
||||||
|
} else if live {
|
||||||
ok, trusted = true, true
|
ok, trusted = true, true
|
||||||
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
|
r = withActor(r, a)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
||||||
@@ -851,11 +950,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 +996,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 +1135,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,
|
||||||
|
|||||||
+258
-295
@@ -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)
|
|
||||||
}
|
}
|
||||||
if st.Via != "" {
|
|
||||||
pre := []string{"exec"}
|
// specOf is a stack as the compose runner needs it — and as a scheduled update stores it,
|
||||||
if st.Remote != "" {
|
// so the cron job and the console invoke compose identically.
|
||||||
pre = append(pre, "-e", "DOCKER_HOST="+st.Remote)
|
func specOf(st stack, files []string) compose.Spec {
|
||||||
}
|
return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Tool: dockerTool()}
|
||||||
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)
|
return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, args)
|
||||||
}
|
|
||||||
env := ""
|
|
||||||
if st.Remote != "" { // through env(1): a quoted NAME=value in front of a command is a command name to sh, not an assignment
|
|
||||||
env = "env " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("cd %s && %s%s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), env, dockerTool(), flags, args)
|
|
||||||
}
|
}
|
||||||
// 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) {
|
|
||||||
node := r.PathValue("node")
|
|
||||||
if node == "" {
|
|
||||||
stacks, err := s.findStacks(ctx)
|
stacks, err := s.findStacks(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.homesOf(stacks, true), nil
|
return s.homesOf(stacks), nil
|
||||||
}
|
|
||||||
stacks, err := s.hubStacks(ctx, node)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var held []stack
|
|
||||||
for _, st := range stacks {
|
|
||||||
if st.HeldBy != "" {
|
|
||||||
held = append(held, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
homes := s.homesOf(held, false)
|
|
||||||
if code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks/homes", nil, nil); err == nil && code == 200 {
|
|
||||||
var res struct{ Homes []stackHome }
|
|
||||||
if json.Unmarshal(b, &res) == nil {
|
|
||||||
for _, h := range res.Homes {
|
|
||||||
if len(homes) > 0 && h.Stacks == 0 {
|
|
||||||
continue // the node's empty ~/stacks is only worth offering when there is nothing else
|
|
||||||
}
|
|
||||||
h.Default = false
|
|
||||||
homes = append(homes, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(homes) > 0 {
|
|
||||||
homes[0].Default = true
|
|
||||||
}
|
|
||||||
return homes, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -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,93 @@
|
|||||||
|
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 local 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 local 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 local 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 a, err := CreateArgs(CreateSpec{Name: "x", Image: "images:alpine/3.22", Pool: "local"}, true); err != nil || strings.Join(a, " ") != "init images:alpine/3.22 x -d root,pool=local" {
|
||||||
|
t.Fatalf("pool override: %v %v", a, err)
|
||||||
|
}
|
||||||
|
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 (
|
||||||
@@ -123,6 +125,7 @@ type Proc struct {
|
|||||||
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
|
||||||
|
GPU float64 `json:"gpu,omitempty"` // its busiest GPU engine, percent; summed over the GPUs it has open
|
||||||
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
|
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
|
||||||
RSS uint64 `json:"rss"` // bytes
|
RSS uint64 `json:"rss"` // bytes
|
||||||
Threads int `json:"threads"`
|
Threads int `json:"threads"`
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+25
-1
@@ -37,9 +37,14 @@ var systemdUnit string
|
|||||||
//go:embed hamlab.openrc
|
//go:embed hamlab.openrc
|
||||||
var openrcScript string
|
var openrcScript string
|
||||||
|
|
||||||
|
//go:embed hamlab.procd
|
||||||
|
var procdScript string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
unitPath = "/etc/systemd/system/hamlab.service"
|
unitPath = "/etc/systemd/system/hamlab.service"
|
||||||
openrcPath = "/etc/init.d/hamlab"
|
openrcPath = "/etc/init.d/hamlab"
|
||||||
|
// procd (OpenWrt) uses the same path as OpenRC; the two are told apart by content.
|
||||||
|
procdPath = "/etc/init.d/hamlab"
|
||||||
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,6 +233,19 @@ func Install(args []string, version string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case exists("/sbin/procd") && exists("/etc/rc.common"):
|
||||||
|
initSys = "procd"
|
||||||
|
if err := os.WriteFile(procdPath, []byte(fill(procdScript, vars)), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := run(procdPath, "enable"); err != nil {
|
||||||
|
say("warning: %s enable: %v", procdPath, err)
|
||||||
|
}
|
||||||
|
if !o.noStart {
|
||||||
|
if err := run(procdPath, "restart"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
||||||
initSys = "OpenRC"
|
initSys = "OpenRC"
|
||||||
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
||||||
@@ -258,7 +276,7 @@ func Install(args []string, version string) error {
|
|||||||
url := scheme + "://" + browseAddr(cfg.Listen)
|
url := scheme + "://" + browseAddr(cfg.Listen)
|
||||||
switch {
|
switch {
|
||||||
case initSys == "":
|
case initSys == "":
|
||||||
say("\nNo systemd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
|
say("\nNo systemd, procd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
|
||||||
case o.noStart:
|
case o.noStart:
|
||||||
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
||||||
say("\nHam Lab is up: %s", url)
|
say("\nHam Lab is up: %s", url)
|
||||||
@@ -294,9 +312,15 @@ func Uninstall(args []string) error {
|
|||||||
_ = run("systemctl", "daemon-reload")
|
_ = run("systemctl", "daemon-reload")
|
||||||
}
|
}
|
||||||
if b, err := os.ReadFile(openrcPath); err == nil {
|
if b, err := os.ReadFile(openrcPath); err == nil {
|
||||||
|
if strings.Contains(string(b), "USE_PROCD=1") {
|
||||||
|
dataDir = configDirFrom(string(b), "HAMLAB_CONFIG=")
|
||||||
|
_ = run(procdPath, "stop")
|
||||||
|
_ = run(procdPath, "disable")
|
||||||
|
} else {
|
||||||
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
|
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
|
||||||
_ = run("rc-service", "hamlab", "stop")
|
_ = run("rc-service", "hamlab", "stop")
|
||||||
_ = run("rc-update", "del", "hamlab", "default")
|
_ = run("rc-update", "del", "hamlab", "default")
|
||||||
|
}
|
||||||
os.Remove(openrcPath)
|
os.Remove(openrcPath)
|
||||||
}
|
}
|
||||||
os.Remove(sudoersPath)
|
os.Remove(sudoersPath)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -66,7 +66,6 @@ type Policy struct {
|
|||||||
KeepMin int `json:"keep_min"`
|
KeepMin int `json:"keep_min"`
|
||||||
Prefix string `json:"prefix"`
|
Prefix string `json:"prefix"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) }
|
func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) }
|
||||||
@@ -452,8 +451,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 {
|
||||||
|
|||||||
+22
-2
@@ -44,6 +44,16 @@ type UserState struct {
|
|||||||
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RememberedMount is one bind mount of a container that was running when Ham Lab last
|
||||||
|
// looked. A compose stack records the path its file had *inside* whatever deployed it, so
|
||||||
|
// when that container is gone the path is the only way back to the file on disk.
|
||||||
|
type RememberedMount struct {
|
||||||
|
Container string `json:"container"`
|
||||||
|
Dest string `json:"dest"` // the path inside the container
|
||||||
|
Src string `json:"src"` // the path on the machine
|
||||||
|
Seen time.Time `json:"seen"`
|
||||||
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Terminals []Terminal `json:"terminals"`
|
Terminals []Terminal `json:"terminals"`
|
||||||
Sessions map[string]Session `json:"sessions"` // token -> session
|
Sessions map[string]Session `json:"sessions"` // token -> session
|
||||||
@@ -51,8 +61,18 @@ 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)
|
||||||
|
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go)
|
||||||
|
Network json.RawMessage `json:"network,omitempty"` // speed test results (internal/api/network.go)
|
||||||
|
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
||||||
|
// orchestrator that has since been removed can still be found on disk. See
|
||||||
|
// internal/api/stacks.go.
|
||||||
|
Mounts []RememberedMount `json:"mounts,omitempty"`
|
||||||
|
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so
|
||||||
|
// the next browser — or this one after it has forgotten them — is signed into the device
|
||||||
|
// already. See internal/api/kvm.go.
|
||||||
|
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
|
||||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
window.setTimeout(tick, 2000)
|
if (pausedRef.current) { pending.current.push(l); return }
|
||||||
|
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), l] : [...prev, l]))
|
||||||
}
|
}
|
||||||
tick()
|
// a container that has stopped — or was never there — ends the stream at once, so
|
||||||
return () => { stop = true }
|
// back off rather than asking Docker the same question twice a second forever
|
||||||
|
s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, Math.min(15000, 1000 * 2 ** attempt++)) }
|
||||||
|
s.onerror = () => { setErr('log stream disconnected'); s.close() }
|
||||||
|
}
|
||||||
|
connect()
|
||||||
|
return () => { closed = true; clearTimeout(retry); sock?.close() }
|
||||||
}, [id])
|
}, [id])
|
||||||
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow])
|
// Resume: flush what arrived while paused.
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || pending.current.length === 0) return
|
||||||
|
const p = pending.current; pending.current = []
|
||||||
|
setLines((prev) => [...prev, ...p].slice(-2000))
|
||||||
|
}, [paused])
|
||||||
|
useEffect(() => { if (follow && !paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow, paused])
|
||||||
return (
|
return (
|
||||||
<div className="logs">
|
<div className="logs">
|
||||||
<div className="logs-bar"><span className="muted">{lines.length} lines</span><span className="spacer" /><button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button><button className="chip" onClick={() => setLines([])}>Clear</button></div>
|
<div className="logs-bar">
|
||||||
{err && <div className="error">{err}</div>}
|
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'} · {lines.length} lines{err && ` · ${err}`}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button>
|
||||||
|
<button className="chip" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
|
||||||
|
<button className="chip" onClick={() => setLines([])}>Clear</button>
|
||||||
|
</div>
|
||||||
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
||||||
{lines.map((l, i) => <div className="log" key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
{lines.map((l, i) => <div className={'log' + (l.err ? ' err' : '')} key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
||||||
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a container is doing, and what can be done to it: its numbers over time, its health
|
||||||
|
// checks, what has happened to it lately, a shell inside it, and its limits.
|
||||||
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
|
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
|
||||||
const [tab, setTab] = useState<'logs' | 'inspect'>('logs')
|
const [tab, setTab] = useState<'logs' | 'stats' | 'health' | 'events' | 'inspect'>('logs')
|
||||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
||||||
useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name])
|
const [limits, setLimits] = useState(false)
|
||||||
|
useEffect(() => { setInfo(null); if (tab === 'inspect' || limits) api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, limits, c.name])
|
||||||
const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
|
const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
|
||||||
const running = c.state === 'running'
|
const running = c.state === 'running'
|
||||||
const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
const seg = c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')
|
||||||
|
const s = (k: string) => live[`container.${seg}.${k}`]
|
||||||
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
|
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
|
||||||
|
const shell = async () => {
|
||||||
|
try { const t = await api.dockerExec(c.name); toast(`shell open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="svc-detail">
|
<div className="svc-detail">
|
||||||
<CloseBtn onClick={onBack} />
|
<CloseBtn onClick={onBack} />
|
||||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div>
|
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span>{c.health && c.health !== 'none' && <span className={'badge' + (c.health === 'healthy' ? '' : ' bad')}>{c.health}</span>}</div>
|
||||||
<div className="props">
|
<div className="props">
|
||||||
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
|
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
|
||||||
<div><span className="k">Status</span><span>{c.status}</span></div>
|
<div><span className="k">Status</span><span>{c.status}</span></div>
|
||||||
@@ -72,27 +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)
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -2,6 +2,19 @@ import { FormEvent, useState } from 'react'
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import { Spinner } from './ui'
|
import { Spinner } from './ui'
|
||||||
|
|
||||||
|
// Drawn, not typed: 👁 is an emoji on phones, full colour and its own size. Outline strokes
|
||||||
|
// match the glyphs in the rail and the power button.
|
||||||
|
const EyeIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M1.2 8S3.9 3.6 8 3.6 14.8 8 14.8 8 12.1 12.4 8 12.4 1.2 8 1.2 8z" /><circle cx="8" cy="8" r="2.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const EyeOffIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M1.2 8S3.9 3.6 8 3.6 14.8 8 14.8 8 12.1 12.4 8 12.4 1.2 8 1.2 8z" /><circle cx="8" cy="8" r="2.1" /><path d="M2.4 13.6 13.6 2.4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again
|
const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again
|
||||||
|
|
||||||
export default function Login({ onLogin }: { onLogin: () => void }) {
|
export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||||
@@ -9,6 +22,9 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
|||||||
const [pw, setPw] = useState('')
|
const [pw, setPw] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
// Shown on request: a generated password is typed by hand off a message, and a wrong
|
||||||
|
// character looks exactly like a wrong password from here.
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
async function submit(e: FormEvent) {
|
async function submit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -26,8 +42,17 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
|||||||
<img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} />
|
<img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} />
|
||||||
<input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off"
|
<input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off"
|
||||||
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
|
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
|
||||||
<input type="password" autoFocus={!!user} autoComplete="current-password" placeholder="password"
|
<div className="pwfield">
|
||||||
|
{/* revealed, the field is a text field, so the keyboard would otherwise offer to
|
||||||
|
capitalise and correct what is typed into it */}
|
||||||
|
<input type={show ? 'text' : 'password'} autoFocus={!!user} autoComplete="current-password" placeholder="password"
|
||||||
|
spellCheck={false} autoCapitalize="off" autoCorrect="off"
|
||||||
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
|
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
|
||||||
|
<button type="button" className="pweye" onClick={() => setShow(!show)} disabled={busy}
|
||||||
|
aria-label={show ? 'hide the password' : 'show the password'} aria-pressed={show} title={show ? 'hide' : 'show'}>
|
||||||
|
{show ? EyeOffIcon : EyeIcon}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
|
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+64
-4
@@ -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,6 +56,20 @@ 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: '>_' },
|
||||||
@@ -58,10 +77,14 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
||||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||||
|
{ id: 'network', label: 'Network', icon: GlobeIcon },
|
||||||
|
{ id: 'vpn', label: 'VPN', icon: ShieldIcon },
|
||||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
{ 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: 'updates', label: 'Updates', icon: '⇪' },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||||
@@ -70,8 +93,8 @@ const GROUPS: Group[] = [
|
|||||||
{ 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,715 @@
|
|||||||
|
// 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} onChanged={load} />)}
|
||||||
|
{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 === 'dir' ? 'plain files on this disk — each VM\u2019s disk is its own file, sized on its own; snapshots are copies' : d === 'zfs' ? 'zfs — one pre-sized file holding every disk; snapshots and clones are instant' : 'btrfs — one pre-sized file holding every disk; snapshots are instant'}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{driver !== 'dir' && (
|
||||||
|
<label className="muted">Ceiling of that file, GiB — sparse, it grows only as disks fill it · {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 ----
|
||||||
|
|
||||||
|
// A location is a plain-files storage pool at a folder of his choosing.
|
||||||
|
async function addLocation(onDone: (name: string) => void) {
|
||||||
|
const path = await askText('Folder on this machine for instance disks', '/', 'Next')
|
||||||
|
if (!path) return
|
||||||
|
const base = path.split('/').filter(Boolean).pop() ?? 'disks'
|
||||||
|
const name = await askText(`A name for ${path}`, base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, ''), 'Add')
|
||||||
|
if (!name) return
|
||||||
|
try { const r = await api.virtPoolCreate(name, path); toast(`${r.name} added at ${r.path}`); onDone(r.name) } catch (e) { toast(errMsg(e), 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Summary({ ov, onChanged }: { ov: VirtOverview; onChanged: () => void }) {
|
||||||
|
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">Disk locations <span className="muted">· where an instance\u2019s disk can go</span></h3>
|
||||||
|
<table className="virt-table"><thead><tr><th>Location</th><th>Where</th><th className="num">Used</th><th className="num">Free</th><th className="num">Disks</th><th /></tr></thead>
|
||||||
|
<tbody>{(ov.pools ?? []).map((p) => <tr key={p.name}><td>{p.name}{p.name === ov.default_pool && <span className="badge">default</span>}</td><td>{p.where}<div className="muted small mono">{p.driver} · {p.source}</div></td><td className="num">{p.total ? fmtBytes(p.used) : '—'}</td><td className="num">{p.total ? fmtBytes(p.total - p.used) : '—'}</td><td className="num">{p.used_by}</td>
|
||||||
|
<td className="num">{p.used_by === 0 && p.name !== ov.default_pool && <Btn className="btn danger" confirm={`Remove the location ${p.name}? Nothing uses it; the folder itself is left alone.`} onClick={async () => { try { await api.virtPoolDelete(p.name); toast('removed'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }}>Remove</Btn>}</td></tr>)}</tbody></table>
|
||||||
|
<div className="svc-actions"><Btn className="btn" onClick={() => addLocation(onChanged)}>+ Add a location…</Btn><span className="muted small">a folder on this machine; VM disks become files in it, containers folders</span></div>
|
||||||
|
<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>
|
||||||
|
<div><span className="k">Disk</span><span>{i.disk_total > 0 ? `${fmtBytes(i.disk_usage)} of ${fmtBytes(i.disk_total)}` : i.disk_usage > 0 ? fmtBytes(i.disk_usage) : 'no limit'}{i.devices.root?.pool ? <span className="muted"> · on {i.devices.root.pool}</span> : ''}</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 ----
|
||||||
|
|
||||||
|
// Where an instance's disk goes. One entry is still a choice shown; a new one can be added here.
|
||||||
|
function PoolPick({ ov, value, onChange }: { ov: VirtOverview; value: string; onChange: (v: string) => void }) {
|
||||||
|
const [extra, setExtra] = useState<{ name: string; where: string }[]>([])
|
||||||
|
const pools = [...(ov.pools ?? []).map((p) => ({ name: p.name, where: p.where + (p.total ? ` · ${fmtBytes(p.total - p.used)} free` : '') })), ...extra.filter((e) => !(ov.pools ?? []).some((p) => p.name === e.name))]
|
||||||
|
return (
|
||||||
|
<label className="muted">Disk location
|
||||||
|
<select value={value} onChange={(e) => { if (e.target.value === '+') addLocation((n) => { setExtra((x) => [...x, { name: n, where: 'just added' }]); onChange(n) }); else onChange(e.target.value) }}>
|
||||||
|
{pools.map((p) => <option key={p.name} value={p.name}>{p.name} — {p.where}</option>)}
|
||||||
|
<option value="+">+ add a location…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [pool, setPool] = useState(ov.default_pool || ov.pools?.[0]?.name || '')
|
||||||
|
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(), pool, 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' : 'no limit'} /></label>
|
||||||
|
</div>
|
||||||
|
<PoolPick ov={ov} value={pool} onChange={setPool} />
|
||||||
|
<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 [pool, setPool] = useState(ov.default_pool || ov.pools?.[0]?.name || '')
|
||||||
|
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(), pool, 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>
|
||||||
|
<PoolPick ov={ov} value={pool} onChange={setPool} />
|
||||||
|
<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>
|
||||||
|
|||||||
+192
-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; where: 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; pool: string; network: string; secureboot: boolean; start: boolean }
|
||||||
|
export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; default_pool?: string; 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; pool: 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,37 @@ 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'),
|
||||||
|
virtPoolCreate: (name: string, path: string) => req<{ name: string; path: string }>('POST', '/api/virt/pools', { name, path }),
|
||||||
|
virtPoolDelete: (name: string) => req<void>('POST', '/api/virt/pools/delete', { name }),
|
||||||
}
|
}
|
||||||
|
|
||||||
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
+384
@@ -0,0 +1,384 @@
|
|||||||
|
"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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/*----------------------------------------------------------------------------
|
||||||
|
** enums.js
|
||||||
|
** 'constants' for Spice
|
||||||
|
**--------------------------------------------------------------------------*/
|
||||||
|
export var Constants = {
|
||||||
|
SPICE_MAGIC : "REDQ",
|
||||||
|
SPICE_VERSION_MAJOR : 2,
|
||||||
|
SPICE_VERSION_MINOR : 2,
|
||||||
|
|
||||||
|
SPICE_CONNECT_TIMEOUT : (30 * 1000),
|
||||||
|
|
||||||
|
SPICE_COMMON_CAP_PROTOCOL_AUTH_SELECTION : 0,
|
||||||
|
SPICE_COMMON_CAP_AUTH_SPICE : 1,
|
||||||
|
SPICE_COMMON_CAP_AUTH_SASL : 2,
|
||||||
|
SPICE_COMMON_CAP_MINI_HEADER : 3,
|
||||||
|
|
||||||
|
SPICE_TICKET_KEY_PAIR_LENGTH : 1024,
|
||||||
|
SPICE_TICKET_PUBKEY_BYTES : (1024 / 8 +34), // (SPICE_TICKET_KEY_PAIR_LENGTH / 8 + 34)
|
||||||
|
|
||||||
|
SPICE_LINK_ERR_OK : 0,
|
||||||
|
SPICE_LINK_ERR_ERROR : 1,
|
||||||
|
SPICE_LINK_ERR_INVALID_MAGIC : 2,
|
||||||
|
SPICE_LINK_ERR_INVALID_DATA : 3,
|
||||||
|
SPICE_LINK_ERR_VERSION_MISMATCH : 4,
|
||||||
|
SPICE_LINK_ERR_NEED_SECURED : 5,
|
||||||
|
SPICE_LINK_ERR_NEED_UNSECURED : 6,
|
||||||
|
SPICE_LINK_ERR_PERMISSION_DENIED : 7,
|
||||||
|
SPICE_LINK_ERR_BAD_CONNECTION_ID : 8,
|
||||||
|
SPICE_LINK_ERR_CHANNEL_NOT_AVAILABLE : 9,
|
||||||
|
|
||||||
|
SPICE_MSG_MIGRATE : 1,
|
||||||
|
SPICE_MSG_MIGRATE_DATA : 2,
|
||||||
|
SPICE_MSG_SET_ACK : 3,
|
||||||
|
SPICE_MSG_PING : 4,
|
||||||
|
SPICE_MSG_WAIT_FOR_CHANNELS : 5,
|
||||||
|
SPICE_MSG_DISCONNECTING : 6,
|
||||||
|
SPICE_MSG_NOTIFY : 7,
|
||||||
|
SPICE_MSG_LIST : 8,
|
||||||
|
|
||||||
|
SPICE_MSG_MAIN_MIGRATE_BEGIN : 101,
|
||||||
|
SPICE_MSG_MAIN_MIGRATE_CANCEL : 102,
|
||||||
|
SPICE_MSG_MAIN_INIT : 103,
|
||||||
|
SPICE_MSG_MAIN_CHANNELS_LIST : 104,
|
||||||
|
SPICE_MSG_MAIN_MOUSE_MODE : 105,
|
||||||
|
SPICE_MSG_MAIN_MULTI_MEDIA_TIME : 106,
|
||||||
|
SPICE_MSG_MAIN_AGENT_CONNECTED : 107,
|
||||||
|
SPICE_MSG_MAIN_AGENT_DISCONNECTED : 108,
|
||||||
|
SPICE_MSG_MAIN_AGENT_DATA : 109,
|
||||||
|
SPICE_MSG_MAIN_AGENT_TOKEN : 110,
|
||||||
|
SPICE_MSG_MAIN_MIGRATE_SWITCH_HOST : 111,
|
||||||
|
SPICE_MSG_MAIN_MIGRATE_END : 112,
|
||||||
|
SPICE_MSG_MAIN_NAME : 113,
|
||||||
|
SPICE_MSG_MAIN_UUID : 114,
|
||||||
|
SPICE_MSG_MAIN_AGENT_CONNECTED_TOKENS : 115,
|
||||||
|
SPICE_MSG_MAIN_MIGRATE_BEGIN_SEAMLESS : 116,
|
||||||
|
SPICE_MSG_MAIN_MIGRATE_DST_SEAMLESS_ACK : 117,
|
||||||
|
SPICE_MSG_MAIN_MIGRATE_DST_SEAMLESS_NACK : 118,
|
||||||
|
SPICE_MSG_END_MAIN : 119,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
SPICE_MSGC_ACK_SYNC : 1,
|
||||||
|
SPICE_MSGC_ACK : 2,
|
||||||
|
SPICE_MSGC_PONG : 3,
|
||||||
|
SPICE_MSGC_MIGRATE_FLUSH_MARK : 4,
|
||||||
|
SPICE_MSGC_MIGRATE_DATA : 5,
|
||||||
|
SPICE_MSGC_DISCONNECTING : 6,
|
||||||
|
|
||||||
|
|
||||||
|
SPICE_MSGC_MAIN_CLIENT_INFO : 101,
|
||||||
|
SPICE_MSGC_MAIN_MIGRATE_CONNECTED : 102,
|
||||||
|
SPICE_MSGC_MAIN_MIGRATE_CONNECT_ERROR : 103,
|
||||||
|
SPICE_MSGC_MAIN_ATTACH_CHANNELS : 104,
|
||||||
|
SPICE_MSGC_MAIN_MOUSE_MODE_REQUEST : 105,
|
||||||
|
SPICE_MSGC_MAIN_AGENT_START : 106,
|
||||||
|
SPICE_MSGC_MAIN_AGENT_DATA : 107,
|
||||||
|
SPICE_MSGC_MAIN_AGENT_TOKEN : 108,
|
||||||
|
SPICE_MSGC_MAIN_MIGRATE_END : 109,
|
||||||
|
SPICE_MSGC_END_MAIN : 110,
|
||||||
|
|
||||||
|
SPICE_MSG_DISPLAY_MODE : 101,
|
||||||
|
SPICE_MSG_DISPLAY_MARK : 102,
|
||||||
|
SPICE_MSG_DISPLAY_RESET : 103,
|
||||||
|
SPICE_MSG_DISPLAY_COPY_BITS : 104,
|
||||||
|
SPICE_MSG_DISPLAY_INVAL_LIST : 105,
|
||||||
|
SPICE_MSG_DISPLAY_INVAL_ALL_PIXMAPS : 106,
|
||||||
|
SPICE_MSG_DISPLAY_INVAL_PALETTE : 107,
|
||||||
|
SPICE_MSG_DISPLAY_INVAL_ALL_PALETTES: 108,
|
||||||
|
|
||||||
|
SPICE_MSG_DISPLAY_STREAM_CREATE : 122,
|
||||||
|
SPICE_MSG_DISPLAY_STREAM_DATA : 123,
|
||||||
|
SPICE_MSG_DISPLAY_STREAM_CLIP : 124,
|
||||||
|
SPICE_MSG_DISPLAY_STREAM_DESTROY : 125,
|
||||||
|
SPICE_MSG_DISPLAY_STREAM_DESTROY_ALL: 126,
|
||||||
|
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_FILL : 302,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_OPAQUE : 303,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_COPY : 304,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_BLEND : 305,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_BLACKNESS : 306,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_WHITENESS : 307,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_INVERS : 308,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_ROP3 : 309,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_STROKE : 310,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_TEXT : 311,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_TRANSPARENT : 312,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_ALPHA_BLEND : 313,
|
||||||
|
SPICE_MSG_DISPLAY_SURFACE_CREATE : 314,
|
||||||
|
SPICE_MSG_DISPLAY_SURFACE_DESTROY : 315,
|
||||||
|
SPICE_MSG_DISPLAY_STREAM_DATA_SIZED : 316,
|
||||||
|
SPICE_MSG_DISPLAY_MONITORS_CONFIG : 317,
|
||||||
|
SPICE_MSG_DISPLAY_DRAW_COMPOSITE : 318,
|
||||||
|
SPICE_MSG_DISPLAY_STREAM_ACTIVATE_REPORT : 319,
|
||||||
|
|
||||||
|
SPICE_MSGC_DISPLAY_INIT : 101,
|
||||||
|
SPICE_MSGC_DISPLAY_STREAM_REPORT : 102,
|
||||||
|
|
||||||
|
SPICE_MSG_INPUTS_INIT : 101,
|
||||||
|
SPICE_MSG_INPUTS_KEY_MODIFIERS : 102,
|
||||||
|
|
||||||
|
SPICE_MSG_INPUTS_MOUSE_MOTION_ACK : 111,
|
||||||
|
|
||||||
|
SPICE_MSGC_INPUTS_KEY_DOWN : 101,
|
||||||
|
SPICE_MSGC_INPUTS_KEY_UP : 102,
|
||||||
|
SPICE_MSGC_INPUTS_KEY_MODIFIERS : 103,
|
||||||
|
|
||||||
|
SPICE_MSGC_INPUTS_MOUSE_MOTION : 111,
|
||||||
|
SPICE_MSGC_INPUTS_MOUSE_POSITION : 112,
|
||||||
|
SPICE_MSGC_INPUTS_MOUSE_PRESS : 113,
|
||||||
|
SPICE_MSGC_INPUTS_MOUSE_RELEASE : 114,
|
||||||
|
|
||||||
|
SPICE_MSG_CURSOR_INIT : 101,
|
||||||
|
SPICE_MSG_CURSOR_RESET : 102,
|
||||||
|
SPICE_MSG_CURSOR_SET : 103,
|
||||||
|
SPICE_MSG_CURSOR_MOVE : 104,
|
||||||
|
SPICE_MSG_CURSOR_HIDE : 105,
|
||||||
|
SPICE_MSG_CURSOR_TRAIL : 106,
|
||||||
|
SPICE_MSG_CURSOR_INVAL_ONE : 107,
|
||||||
|
SPICE_MSG_CURSOR_INVAL_ALL : 108,
|
||||||
|
|
||||||
|
SPICE_MSG_PLAYBACK_DATA : 101,
|
||||||
|
SPICE_MSG_PLAYBACK_MODE : 102,
|
||||||
|
SPICE_MSG_PLAYBACK_START : 103,
|
||||||
|
SPICE_MSG_PLAYBACK_STOP : 104,
|
||||||
|
SPICE_MSG_PLAYBACK_VOLUME : 105,
|
||||||
|
SPICE_MSG_PLAYBACK_MUTE : 106,
|
||||||
|
SPICE_MSG_PLAYBACK_LATENCY : 107,
|
||||||
|
|
||||||
|
SPICE_MSG_SPICEVMC_DATA : 101,
|
||||||
|
SPICE_MSG_PORT_INIT : 201,
|
||||||
|
SPICE_MSG_PORT_EVENT : 202,
|
||||||
|
SPICE_MSG_END_PORT : 203,
|
||||||
|
|
||||||
|
SPICE_MSGC_SPICEVMC_DATA : 101,
|
||||||
|
SPICE_MSGC_PORT_EVENT : 201,
|
||||||
|
SPICE_MSGC_END_PORT : 202,
|
||||||
|
|
||||||
|
SPICE_PLAYBACK_CAP_CELT_0_5_1 : 0,
|
||||||
|
SPICE_PLAYBACK_CAP_VOLUME : 1,
|
||||||
|
SPICE_PLAYBACK_CAP_LATENCY : 2,
|
||||||
|
SPICE_PLAYBACK_CAP_OPUS : 3,
|
||||||
|
|
||||||
|
SPICE_MAIN_CAP_SEMI_SEAMLESS_MIGRATE : 0,
|
||||||
|
SPICE_MAIN_CAP_NAME_AND_UUID : 1,
|
||||||
|
SPICE_MAIN_CAP_AGENT_CONNECTED_TOKENS : 2,
|
||||||
|
SPICE_MAIN_CAP_SEAMLESS_MIGRATE : 3,
|
||||||
|
|
||||||
|
SPICE_DISPLAY_CAP_SIZED_STREAM : 0,
|
||||||
|
SPICE_DISPLAY_CAP_MONITORS_CONFIG : 1,
|
||||||
|
SPICE_DISPLAY_CAP_COMPOSITE : 2,
|
||||||
|
SPICE_DISPLAY_CAP_A8_SURFACE : 3,
|
||||||
|
SPICE_DISPLAY_CAP_STREAM_REPORT : 4,
|
||||||
|
SPICE_DISPLAY_CAP_LZ4_COMPRESSION : 5,
|
||||||
|
SPICE_DISPLAY_CAP_PREF_COMPRESSION : 6,
|
||||||
|
SPICE_DISPLAY_CAP_GL_SCANOUT : 7,
|
||||||
|
SPICE_DISPLAY_CAP_MULTI_CODEC : 8,
|
||||||
|
SPICE_DISPLAY_CAP_CODEC_MJPEG : 9,
|
||||||
|
SPICE_DISPLAY_CAP_CODEC_VP8 : 10,
|
||||||
|
|
||||||
|
SPICE_AUDIO_DATA_MODE_INVALID : 0,
|
||||||
|
SPICE_AUDIO_DATA_MODE_RAW : 1,
|
||||||
|
SPICE_AUDIO_DATA_MODE_CELT_0_5_1 : 2,
|
||||||
|
SPICE_AUDIO_DATA_MODE_OPUS : 3,
|
||||||
|
|
||||||
|
SPICE_AUDIO_FMT_INVALID : 0,
|
||||||
|
SPICE_AUDIO_FMT_S16 : 1,
|
||||||
|
|
||||||
|
SPICE_CHANNEL_MAIN : 1,
|
||||||
|
SPICE_CHANNEL_DISPLAY : 2,
|
||||||
|
SPICE_CHANNEL_INPUTS : 3,
|
||||||
|
SPICE_CHANNEL_CURSOR : 4,
|
||||||
|
SPICE_CHANNEL_PLAYBACK : 5,
|
||||||
|
SPICE_CHANNEL_RECORD : 6,
|
||||||
|
SPICE_CHANNEL_TUNNEL : 7,
|
||||||
|
SPICE_CHANNEL_SMARTCARD : 8,
|
||||||
|
SPICE_CHANNEL_USBREDIR : 9,
|
||||||
|
SPICE_CHANNEL_PORT : 10,
|
||||||
|
SPICE_CHANNEL_WEBDAV : 11,
|
||||||
|
|
||||||
|
SPICE_SURFACE_FLAGS_PRIMARY : (1 << 0),
|
||||||
|
|
||||||
|
SPICE_NOTIFY_SEVERITY_INFO : 0,
|
||||||
|
SPICE_NOTIFY_SEVERITY_WARN : 1,
|
||||||
|
SPICE_NOTIFY_SEVERITY_ERROR : 2,
|
||||||
|
|
||||||
|
SPICE_MOUSE_MODE_SERVER : (1 << 0),
|
||||||
|
SPICE_MOUSE_MODE_CLIENT : (1 << 1),
|
||||||
|
SPICE_MOUSE_MODE_MASK : 0x3,
|
||||||
|
|
||||||
|
SPICE_CLIP_TYPE_NONE : 0,
|
||||||
|
SPICE_CLIP_TYPE_RECTS : 1,
|
||||||
|
|
||||||
|
SPICE_IMAGE_SCALE_MODE_INTERPOLATE : 0,
|
||||||
|
SPICE_IMAGE_SCALE_MODE_NEAREST : 1,
|
||||||
|
|
||||||
|
SPICE_IMAGE_TYPE_BITMAP : 0,
|
||||||
|
SPICE_IMAGE_TYPE_QUIC : 1,
|
||||||
|
SPICE_IMAGE_TYPE_RESERVED : 2,
|
||||||
|
SPICE_IMAGE_TYPE_LZ_PLT : 100,
|
||||||
|
SPICE_IMAGE_TYPE_LZ_RGB : 101,
|
||||||
|
SPICE_IMAGE_TYPE_GLZ_RGB : 102,
|
||||||
|
SPICE_IMAGE_TYPE_FROM_CACHE : 103,
|
||||||
|
SPICE_IMAGE_TYPE_SURFACE : 104,
|
||||||
|
SPICE_IMAGE_TYPE_JPEG : 105,
|
||||||
|
SPICE_IMAGE_TYPE_FROM_CACHE_LOSSLESS : 106,
|
||||||
|
SPICE_IMAGE_TYPE_ZLIB_GLZ_RGB : 107,
|
||||||
|
SPICE_IMAGE_TYPE_JPEG_ALPHA : 108,
|
||||||
|
|
||||||
|
SPICE_IMAGE_FLAGS_CACHE_ME : (1 << 0),
|
||||||
|
SPICE_IMAGE_FLAGS_HIGH_BITS_SET : (1 << 1),
|
||||||
|
SPICE_IMAGE_FLAGS_CACHE_REPLACE_ME : (1 << 2),
|
||||||
|
|
||||||
|
SPICE_BITMAP_FLAGS_PAL_CACHE_ME : (1 << 0),
|
||||||
|
SPICE_BITMAP_FLAGS_PAL_FROM_CACHE : (1 << 1),
|
||||||
|
SPICE_BITMAP_FLAGS_TOP_DOWN : (1 << 2),
|
||||||
|
SPICE_BITMAP_FLAGS_MASK : 0x7,
|
||||||
|
|
||||||
|
SPICE_BITMAP_FMT_INVALID : 0,
|
||||||
|
SPICE_BITMAP_FMT_1BIT_LE : 1,
|
||||||
|
SPICE_BITMAP_FMT_1BIT_BE : 2,
|
||||||
|
SPICE_BITMAP_FMT_4BIT_LE : 3,
|
||||||
|
SPICE_BITMAP_FMT_4BIT_BE : 4,
|
||||||
|
SPICE_BITMAP_FMT_8BIT : 5,
|
||||||
|
SPICE_BITMAP_FMT_16BIT : 6,
|
||||||
|
SPICE_BITMAP_FMT_24BIT : 7,
|
||||||
|
SPICE_BITMAP_FMT_32BIT : 8,
|
||||||
|
SPICE_BITMAP_FMT_RGBA : 9,
|
||||||
|
|
||||||
|
|
||||||
|
SPICE_CURSOR_FLAGS_NONE : (1 << 0),
|
||||||
|
SPICE_CURSOR_FLAGS_CACHE_ME : (1 << 1),
|
||||||
|
SPICE_CURSOR_FLAGS_FROM_CACHE : (1 << 2),
|
||||||
|
SPICE_CURSOR_FLAGS_MASK : 0x7,
|
||||||
|
|
||||||
|
SPICE_MOUSE_BUTTON_MASK_LEFT : (1 << 0),
|
||||||
|
SPICE_MOUSE_BUTTON_MASK_MIDDLE : (1 << 1),
|
||||||
|
SPICE_MOUSE_BUTTON_MASK_RIGHT : (1 << 2),
|
||||||
|
SPICE_MOUSE_BUTTON_MASK_MASK : 0x7,
|
||||||
|
|
||||||
|
SPICE_MOUSE_BUTTON_INVALID : 0,
|
||||||
|
SPICE_MOUSE_BUTTON_LEFT : 1,
|
||||||
|
SPICE_MOUSE_BUTTON_MIDDLE : 2,
|
||||||
|
SPICE_MOUSE_BUTTON_RIGHT : 3,
|
||||||
|
SPICE_MOUSE_BUTTON_UP : 4,
|
||||||
|
SPICE_MOUSE_BUTTON_DOWN : 5,
|
||||||
|
|
||||||
|
SPICE_BRUSH_TYPE_NONE : 0,
|
||||||
|
SPICE_BRUSH_TYPE_SOLID : 1,
|
||||||
|
SPICE_BRUSH_TYPE_PATTERN : 2,
|
||||||
|
|
||||||
|
SPICE_SURFACE_FMT_INVALID : 0,
|
||||||
|
SPICE_SURFACE_FMT_1_A : 1,
|
||||||
|
SPICE_SURFACE_FMT_8_A : 8,
|
||||||
|
SPICE_SURFACE_FMT_16_555 : 16,
|
||||||
|
SPICE_SURFACE_FMT_32_xRGB : 32,
|
||||||
|
SPICE_SURFACE_FMT_16_565 : 80,
|
||||||
|
SPICE_SURFACE_FMT_32_ARGB : 96,
|
||||||
|
|
||||||
|
SPICE_ROPD_INVERS_SRC : (1 << 0),
|
||||||
|
SPICE_ROPD_INVERS_BRUSH : (1 << 1),
|
||||||
|
SPICE_ROPD_INVERS_DEST : (1 << 2),
|
||||||
|
SPICE_ROPD_OP_PUT : (1 << 3),
|
||||||
|
SPICE_ROPD_OP_OR : (1 << 4),
|
||||||
|
SPICE_ROPD_OP_AND : (1 << 5),
|
||||||
|
SPICE_ROPD_OP_XOR : (1 << 6),
|
||||||
|
SPICE_ROPD_OP_BLACKNESS : (1 << 7),
|
||||||
|
SPICE_ROPD_OP_WHITENESS : (1 << 8),
|
||||||
|
SPICE_ROPD_OP_INVERS : (1 << 9),
|
||||||
|
SPICE_ROPD_INVERS_RES : (1 << 10),
|
||||||
|
SPICE_ROPD_MASK : 0x7ff,
|
||||||
|
|
||||||
|
LZ_IMAGE_TYPE_INVALID : 0,
|
||||||
|
LZ_IMAGE_TYPE_PLT1_LE : 1,
|
||||||
|
LZ_IMAGE_TYPE_PLT1_BE : 2, // PLT stands for palette
|
||||||
|
LZ_IMAGE_TYPE_PLT4_LE : 3,
|
||||||
|
LZ_IMAGE_TYPE_PLT4_BE : 4,
|
||||||
|
LZ_IMAGE_TYPE_PLT8 : 5,
|
||||||
|
LZ_IMAGE_TYPE_RGB16 : 6,
|
||||||
|
LZ_IMAGE_TYPE_RGB24 : 7,
|
||||||
|
LZ_IMAGE_TYPE_RGB32 : 8,
|
||||||
|
LZ_IMAGE_TYPE_RGBA : 9,
|
||||||
|
LZ_IMAGE_TYPE_XXXA : 10,
|
||||||
|
|
||||||
|
|
||||||
|
SPICE_INPUT_MOTION_ACK_BUNCH : 4,
|
||||||
|
|
||||||
|
|
||||||
|
SPICE_CURSOR_TYPE_ALPHA : 0,
|
||||||
|
SPICE_CURSOR_TYPE_MONO : 1,
|
||||||
|
SPICE_CURSOR_TYPE_COLOR4 : 2,
|
||||||
|
SPICE_CURSOR_TYPE_COLOR8 : 3,
|
||||||
|
SPICE_CURSOR_TYPE_COLOR16 : 4,
|
||||||
|
SPICE_CURSOR_TYPE_COLOR24 : 5,
|
||||||
|
SPICE_CURSOR_TYPE_COLOR32 : 6,
|
||||||
|
|
||||||
|
SPICE_VIDEO_CODEC_TYPE_MJPEG : 1,
|
||||||
|
SPICE_VIDEO_CODEC_TYPE_VP8 : 2,
|
||||||
|
|
||||||
|
SPICE_STREAM_FLAGS_TOP_DOWN : (1 << 0),
|
||||||
|
|
||||||
|
VD_AGENT_PROTOCOL : 1,
|
||||||
|
VD_AGENT_MAX_DATA_SIZE : 2048,
|
||||||
|
|
||||||
|
VD_AGENT_MOUSE_STATE : 1,
|
||||||
|
VD_AGENT_MONITORS_CONFIG : 2,
|
||||||
|
VD_AGENT_REPLY : 3,
|
||||||
|
VD_AGENT_CLIPBOARD : 4,
|
||||||
|
VD_AGENT_DISPLAY_CONFIG : 5,
|
||||||
|
VD_AGENT_ANNOUNCE_CAPABILITIES : 6,
|
||||||
|
VD_AGENT_CLIPBOARD_GRAB : 7,
|
||||||
|
VD_AGENT_CLIPBOARD_REQUEST : 8,
|
||||||
|
VD_AGENT_CLIPBOARD_RELEASE : 9,
|
||||||
|
VD_AGENT_FILE_XFER_START :10,
|
||||||
|
VD_AGENT_FILE_XFER_STATUS :11,
|
||||||
|
VD_AGENT_FILE_XFER_DATA :12,
|
||||||
|
VD_AGENT_CLIENT_DISCONNECTED :13,
|
||||||
|
VD_AGENT_MAX_CLIPBOARD :14,
|
||||||
|
|
||||||
|
VD_AGENT_CAP_MOUSE_STATE : 0,
|
||||||
|
VD_AGENT_CAP_MONITORS_CONFIG : 1,
|
||||||
|
VD_AGENT_CAP_REPLY : 2,
|
||||||
|
VD_AGENT_CAP_CLIPBOARD : 3,
|
||||||
|
VD_AGENT_CAP_DISPLAY_CONFIG : 4,
|
||||||
|
VD_AGENT_CAP_CLIPBOARD_BY_DEMAND : 5,
|
||||||
|
VD_AGENT_CAP_CLIPBOARD_SELECTION : 6,
|
||||||
|
VD_AGENT_CAP_SPARSE_MONITORS_CONFIG : 7,
|
||||||
|
VD_AGENT_CAP_GUEST_LINEEND_LF : 8,
|
||||||
|
VD_AGENT_CAP_GUEST_LINEEND_CRLF : 9,
|
||||||
|
VD_AGENT_CAP_MAX_CLIPBOARD : 10,
|
||||||
|
VD_AGENT_END_CAP : 11,
|
||||||
|
|
||||||
|
VD_AGENT_FILE_XFER_STATUS_CAN_SEND_DATA : 0,
|
||||||
|
VD_AGENT_FILE_XFER_STATUS_CANCELLED : 1,
|
||||||
|
VD_AGENT_FILE_XFER_STATUS_ERROR : 2,
|
||||||
|
VD_AGENT_FILE_XFER_STATUS_SUCCESS : 3,
|
||||||
|
|
||||||
|
VD_AGENT_CLIPBOARD_NONE : 0,
|
||||||
|
VD_AGENT_CLIPBOARD_UTF8_TEXT : 1,
|
||||||
|
VD_AGENT_CLIPBOARD_IMAGE_PNG : 2,
|
||||||
|
VD_AGENT_CLIPBOARD_IMAGE_BMP : 3,
|
||||||
|
VD_AGENT_CLIPBOARD_IMAGE_TIFF : 4,
|
||||||
|
VD_AGENT_CLIPBOARD_IMAGE_JPG : 5,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user