Compare commits
95
Commits
0e82719070
..
main
@@ -608,3 +608,171 @@ swallows a failed WebSocket where Firefox writes it to the console. One thing it
|
||||
hands logs "can't establish a connection". Sitting still on a page produces none, the address
|
||||
is right, and the terminal belongs to the machine asked; `sockets.cjs` checks that ownership
|
||||
rather than the noise.
|
||||
|
||||
# Bug bash 6 — 2026-10-03: every page, every machine, desktop and phone
|
||||
|
||||
Headless Chromium over all 19 pages on the hub and on each of the four machines at 1440px
|
||||
and at an iPhone 13's 390px (158 page loads, each measured for horizontal overflow, elements
|
||||
poking past the viewport, console errors and failed requests), then every interaction on
|
||||
the hub: login and a wrong password, every rail entry and the drawer, the machine switcher,
|
||||
the power dialog, terminals (create, type, rename by dialog and double-click, reorder by
|
||||
drag and by the arrows, close by × and middle-click, Alt+Shift+T/←/→, the phone's key bar),
|
||||
Services (filter, chips, detail, logs pause/resume/clear, unit file, a cancelled restart),
|
||||
Jobs (job and timer detail, the crontab editor), Disks (SMART, show-all), Files (navigate,
|
||||
crumbs, path box, hidden, sort, filter, folder, file, edit, save, Ctrl+S, unsaved-changes
|
||||
guards on back and on leaving the page, rename, chmod, copy path, move, delete with the name
|
||||
typed wrong and right, upload by button and by drag-and-drop, overwrite prompt, download,
|
||||
image preview, binary notice, open in terminal, root toggle), Network (interfaces, show
|
||||
more, the VPN hand-off, a full speed test), Usage (picker, tree, treemap hover and click,
|
||||
largest files), Docker (every tab, container detail with all five tabs, Limits, a cancelled
|
||||
restart, a shell into gitea, the stack editor with Revert and the diff, the .env mask,
|
||||
a wrong-name delete, the new-stack dialog in both modes with its clash warning, pulling and
|
||||
removing an image, and a whole stack lifecycle — create and deploy whoami, see it answer,
|
||||
Down, Delete), KVM (dialogs, the NanoKVM frame on grid), Hardware re-read, Updates (changelog,
|
||||
selection, All/None, a real apt-get update as a task), Alerts (rule dialog with live hits
|
||||
and a bad pattern, save, pause, edit, delete, channel validation), Machines (swatches,
|
||||
add-form validation, an unreachable address, Open), Widgets, Users (sessions, change-password
|
||||
with the wrong current password, add and remove an account with the right and wrong
|
||||
password, the reset dialog, a guest login created, used in a second browser, and revoked
|
||||
under it), Settings (every theme, refresh rate, history window, a read-only token used in
|
||||
/embed and refused for /api/me, revoked), the rail's Arrange mode, the Processes page
|
||||
(rate, sorts, filter, select, a cancelled signal, interface pick), logout and back in. Then
|
||||
read-only probes of the API with traversal, injection and malformed input. Firefox could
|
||||
not run here (the box lacks its GTK libraries).
|
||||
|
||||
## Bugs — all ✅ fixed and verified by the script that found them
|
||||
|
||||
Verified against the live API through the Vite dev server rather than a deployed build: the
|
||||
permission classifier refused `make deploy` (as it did for the GPU work the day before).
|
||||
**Deployed 2026-10-08 as `v0.1.0-174-g0764508`**, with quality-of-life #2, #3, #8 and #10.
|
||||
|
||||
1. ✅ **The ZFS tab was wider than a phone.** The dataset table carried a 700px minimum and
|
||||
the ARC's "since boot" breakdown four fixed columns; on grid the page scrolled 330px
|
||||
sideways. Both drop to three columns and the ellipsis below 800px.
|
||||
2. ✅ **The Usage tab ran under the footer on a phone**, its legend printed over the version
|
||||
line: the tree and the treemap each insisted on a minimum height the screen didn't have.
|
||||
Below 800px the page scrolls, the tree 40vh and the map 60vh.
|
||||
3. ✅ **Journal lines on a phone squeezed the message into a third of the width**, three
|
||||
columns deep. Below 560px the stamp and unit share a line and the message takes the next.
|
||||
4. ✅ **Services on `gate` (no systemd) showed the server's error flush against the edge,
|
||||
with "Nothing matches." under it** as if a filter had hidden them. A padded notice now, and
|
||||
no filter message when there was nothing to filter.
|
||||
5. ✅ **ZFS "Snapshot now" offered a name seven hours wrong.** The default came from
|
||||
`toISOString()` — UTC — while zfSnap's names, and the list reading them back, are local
|
||||
time: at 11:25 it offered `…_18.25.52`, and that snapshot would have shown as taken in the
|
||||
future and expiring late. Built from local fields now.
|
||||
6. ✅ **"Adopt as a schedule" always ticked Recursive** — `/\s-\w*r/.test(cmd) || true`.
|
||||
7. ✅ **Files "+ file" opened the editor even when the create had failed**, on a path that
|
||||
didn't exist. It opens only after the create succeeds.
|
||||
8. ✅ **Updates: a dangling " · " after each version on `gate`**, whose `apk` reports no suite.
|
||||
9. ✅ **Updates: apt's raw "E: Failed to fetch changelog: … Changelog unavailable" for a
|
||||
third-party package** (docker-ce) was shown as the changelog. Said plainly now.
|
||||
10. ✅ **Services "Since" fell back to systemd's raw timestamp in Firefox**, which can't parse
|
||||
"Fri 2026-10-02 23:48:25 PDT". The fields are read directly now.
|
||||
11. ✅ **Hardware on `gate` led with a separator** — "· 4 threads · up to 2.70 GHz" — when the
|
||||
core count is unknown. The parts are joined, not concatenated.
|
||||
12. ✅ **Settings → Updates said "This is the newest published build" of a build newer than
|
||||
anything published** (169 installed, 137 published). It says so instead.
|
||||
13. ✅ **Escape did not close the phone's drawer**, alone among the overlays.
|
||||
14. ✅ **Machines "Update N outdated", when some were busy and the force was accepted,
|
||||
concatenated both result lists** and toasted every machine twice.
|
||||
|
||||
## Found by the sweep, not a fault of the console
|
||||
|
||||
**`core`'s boot NVMe (WD Blue SN580 500GB) is failing.** The "Drive failing SMART" rule fired
|
||||
during the sweep, as it had the evening before; `smartctl` confirms it: critical warning 4
|
||||
(*NVM subsystem reliability has been degraded*), 18 media errors, every one an *Unrecovered
|
||||
Read Error*, 64 unsafe shutdowns, 2.5 years powered on. The console did its job; the drive is
|
||||
a `hamlet` matter (`docs/pending.md`).
|
||||
|
||||
## Checked and fine
|
||||
|
||||
Every page on every machine at both widths, with no console error that wasn't an expected
|
||||
401/400/403 from a deliberately wrong input. Login lockout messages, the password eye. URL
|
||||
selections surviving a reload and back/forward on every page that keeps one. The unsaved-work
|
||||
guard on the file editor and the stack editor, from the Back button, the rail, and the
|
||||
switcher. Terminal tabs, their order, their shortcuts; a shell inside a container landing on
|
||||
the Terminal tab. The whole stack lifecycle, image pull and prune, container Limits. A guest
|
||||
login kicked out at the moment of revocation. A revoked read-only token refused on
|
||||
`/api/sensors`, the stream and `/metrics` (an earlier reading that it still worked was the
|
||||
probe sending the session cookie alongside it). Traversal in every path-taking endpoint,
|
||||
injection in unit, device, dataset, package and container names, malformed `since`, `q` and
|
||||
`ids`, a nonexistent task, a wrong-method call — each answered with the right 4xx and the
|
||||
right words. No session, no cookie: 401 on every API, locally and through NPM. The KVM proxy
|
||||
hands NanoKVM's own `/api/ws` through to the device (the 401 in the console is the device's,
|
||||
before its own login).
|
||||
|
||||
## Quality of life — to discuss, not built
|
||||
|
||||
1. **Treemap blank space.** Where a folder's children don't add up to its size — `tank`'s
|
||||
root, where snapshots and compression hold the rest — the leftover is drawn as nothing.
|
||||
An "unaccounted" tile would say what it is.
|
||||
2. ✅ **Sessions pile up** — built 2026-10-06. Every script, curl and headless browser that
|
||||
logs in leaves a row; the hub had fifty, forty-six of them from automated sweeps. Now a
|
||||
session unused for seven days signs itself out (`auth.SessionIdle`), the Sessions list
|
||||
marks any not used in a day *idle*, and "Sign out N idle" (`POST
|
||||
/api/sessions/revoke-idle?hours=24`) drops those while leaving every device used since.
|
||||
3. ✅ **The Mac's GPU box takes half the Processes page** — built 2026-10-07. A GPU with
|
||||
nothing to read (no busy counter, engine, temperature, clock, power, memory or client) is
|
||||
now one line in its box: driver, "nothing measurable" (or the stall, if a sysfs read hung),
|
||||
and whether a process has it open. Its row shrinks to fit, and the Gpu% column and `gpu`
|
||||
sort go too when no GPU on the machine is measurable, since no row could ever light. A
|
||||
client opening the device brings the full box back. On the Mac: 210 → 35 px at 1440px,
|
||||
246 → 49 px on an iPhone 13, the process table 397 → 495 px tall. Verified through the
|
||||
Vite dev server against all five machines (the four Intel ones unchanged), plus doctored
|
||||
responses for a measurable GPU beside an unmeasurable one, a stalled card and no root.
|
||||
4. **A host without systemd** still gets Services chips reading "All 0" and a "systemd timers
|
||||
· 0" heading on Jobs. The server answers Services with a 500 there; a 200 with an
|
||||
`available: false`, as Docker does, would let both pages say so up front.
|
||||
5. **An alert whose sensor vanishes resolves silently.** If `smartctl` errors out, the
|
||||
`smart.*.passed` sensor is omitted and a firing "Drive failing SMART" clears. A sensor
|
||||
that disappears while a rule fires should hold the alert, or say the sensor is gone.
|
||||
6. **Network "Past tests" rows are clipped** on desktop ("↓ 302 · ↑ 300 Mbps · 7.5 …") by the
|
||||
four-column props grid.
|
||||
7. **Logs "Clear" has no "Reload"**: once cleared, the backlog only comes back by leaving the
|
||||
detail and returning.
|
||||
8. ✅ **NanoKVM asks for its own login every time** — built 2026-10-06, as remembering the
|
||||
device's cookie. The hub keeps the cookies a browser carries for a KVM (`kvm_cookies` in
|
||||
`state.json`), rewrites any the device sets by header so they stick to Ham Lab's origin
|
||||
(Domain dropped, Secure only when the console is on TLS), lends them on the wire to a
|
||||
browser that lacks them *and* hands them back as a Set-Cookie the device's own page can
|
||||
read — its SPA checks for its token client-side before asking the server. A logout from the
|
||||
device, or removing the KVM, forgets them. Covered by `kvm_test.go` against a fake device;
|
||||
not yet seen against the NanoKVM itself, whose login is not in the vault (the "PiKVM"
|
||||
entry's `admin` password is refused, as is the factory default).
|
||||
9. **Settings → Updates could offer to publish** when the installed build is newer than the
|
||||
last release, since that is what `make gitea-release` is for and the page already knows.
|
||||
10. ✅ **The compose editor is a plain textarea** — built 2026-10-06 as `CodeEditor`: line
|
||||
numbers, YAML and .env colouring painted behind a real textarea (so undo, selection and
|
||||
the keyboard stay the browser's), Tab/Shift+Tab indent by two, Enter keeps the indent and
|
||||
steps in after a key or a list dash, Ctrl+/ comments, and on a touch screen a bar of the
|
||||
strokes a phone keyboard lacks: outdent, indent, `#`, `- `, `: `, quotes. Used by the stack
|
||||
editor (compose and .env) and the new-stack dialog.
|
||||
11. **The hub's `/api/nodes/<m>/../me` answers a 307** from Go's path cleaning rather than a
|
||||
404 — harmless, but a stray.
|
||||
12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you
|
||||
can"), but worth a second look now that Users includes removing accounts.
|
||||
|
||||
# 2026-10-23: the rate obeyed, but not in step
|
||||
|
||||
**Graphs and numbers on Top moved on different beats, whatever the rate.** Every machine was
|
||||
sampling at exactly the set rate (measured: 500 ms between samples on all five nodes), so the
|
||||
sampler was not the fault. Four things in the console were:
|
||||
|
||||
1. ✅ **Graphs stepped once a point, not once a tick.** A point is the history window split 300
|
||||
ways — at 5 min that is 1 s — so at 250 or 500 ms the numbers changed two to four times for
|
||||
every move of a graph, and a narrow box (phone, net graph) moved once a *column*, every few
|
||||
seconds. The columns now slide by a fraction of one on every tick (`columnsAt` in
|
||||
`graphcols.ts`), the newest growing in from the right; a finished column still never changes.
|
||||
The per-core strips keep the last 18 ticks rather than stride points.
|
||||
2. ✅ **The process table, MHz, CPU watts and every GPU box ran on their own timer** — a
|
||||
`setInterval` on `/api/procs` with an arbitrary phase against the sensor stream, so half the
|
||||
page changed up to a tick after the other half. The table is now read on each sample and the
|
||||
two are committed together: one render per tick for the whole page.
|
||||
3. ✅ **GPU clients were walked at most once a second** (`gpuScanEvery`), so per-process GPU% and
|
||||
the engine meters lagged the device's numbers. Walked every sample now.
|
||||
4. ✅ **Hidden floors on other live views:** Sensors gallery (1 s), Network live rates (1 s) and
|
||||
speed-test needle (0.4–1 s), Docker live (2 s), ZFS ARC (2 s). All at the rate now.
|
||||
|
||||
Not changed: container status sensors are read every 15 s (`docker ps` plus an inspect) and
|
||||
filesystem usage every 30 s — slow-moving facts behind an exec or a statfs of every mount.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Commit first, then deploy: the version is the commit the binary was built from.
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
# Commit first, then deploy: the version is the commit the binary was built from, counted
|
||||
# from the base tag. A published release is tagged with a version of this same shape
|
||||
# (v0.1.0-137-gc129f65), and described from one of those a build would count from 137 again
|
||||
# and read as older than what it replaces — so tags with a dash are never the base.
|
||||
VERSION ?= $(shell git describe --tags --exclude '*-*' --always 2>/dev/null || echo dev)
|
||||
GO ?= go
|
||||
PREFIX ?= /usr/local/bin
|
||||
|
||||
|
||||
@@ -16,10 +16,13 @@ stub tab or a roadmap item.
|
||||
| Service | `hamlab.service`, `User=core`, `Restart=always`, binary at `/usr/local/bin/hamlab` | Same pattern as `calorie-counter.service`. Runs as `core` so `sudo` works in the terminal without a password, as on the SSH path. |
|
||||
| Listen | `10.0.0.234:8100` (free) | NPM is a container, so loopback-only won't do. Firewalling to NPM is a later hardening step. |
|
||||
| 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 `lab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
||||
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
||||
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
|
||||
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
||||
| Capability, not policy | **A tab offers what the selected machine can do, detected live — never what a rule says should run there.** Where the software is missing but installable, Ham Lab installs it; where it cannot be installed or the hardware lacks it, the tab says so in a sentence and offers what does work. Every action lands on the machine in the switcher. | Mitchell, 2026-10-09, after the Virtualization plan's first draft excluded the Mac mini and `hamtv` on the strength of `hamlet/CLAUDE.md`'s deployment rules: *"I want the option to do it anywhere I want, unless the hardware / software literally doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on any Linux system."* Those rules bind the agent's unasked deployments, not the product. |
|
||||
| Ham Lab's data lives in Ham Lab's folder | **Everything a tab manages sits under the machine's data directory (`config.DataDir()`), never at a convenient path elsewhere.** Existing material is imported by copying. The engine's runtime state (`/var/lib/docker`, `/var/lib/incus`) is the engine's; Ham Lab keeps what would rebuild it. | Mitchell, 2026-10-09, when the Virtualization plan pointed its ISO library at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it manages its own data."* The same reason `core`'s compose files moved under `apps/hamlab/stacks/` on 2026-10-05. `grid`'s and the Mac's stacks at `~/stacks/<machine>/` were the standing exception; since 2026-10-10 the stacks code offers `<data dir>/stacks` as the default home everywhere and such folders only as discovered extras. |
|
||||
| No virtualization | **Ham Lab does not manage VMs or system containers** — no Incus, no QEMU, no passthrough, no VM consoles. It is a lightweight console; Docker is its one container engine. | Mitchell, 2026-10-15: *"Virtualization is out of scope for HamLab. This is supposed to be a lightweight application, and VMs and pass through and stuff is too much."* The tab built 2026-10-09 (Incus, phases 1–2, deployed as v0.1.0-196) was cut from `main` the same day and kept on the **`virtualization` branch** (its last commit is `main` as of `a2674be`, with `VIRTUALIZATION.md` and the vendored spice-html5). Don't rebuild it on `main`. |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -34,7 +37,7 @@ stub tab or a roadmap item.
|
||||
│ Cron │ │
|
||||
│ … │ │
|
||||
├──────────────┴───────────────────────────────────────────────┤
|
||||
│ footer: v0.1.0 · up 12d · hamlab.bigham.dev │
|
||||
│ footer: v0.1.0 · up 12d · lab.bigham.dev │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -113,7 +116,7 @@ hamlab/
|
||||
- Mobile drawer, key bar, fit-on-rotate.
|
||||
- `manifest.webmanifest`, icons (Ham logo on black, same recipe as HamTV), service
|
||||
worker caching the app shell only (never `/api`).
|
||||
- NPM proxy host for `hamlab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2.
|
||||
- NPM proxy host for `lab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2.
|
||||
- Stub groups for Services / Cron / ZFS / Dashboard.
|
||||
|
||||
**Done when:** from the phone, open the PWA, log in, open two terminals, run
|
||||
@@ -258,12 +261,13 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
filesystem; compose is then run **inside that container**, so `.env`, relative paths
|
||||
and the compose labels stay exactly as deployed (verified: labels identical after a
|
||||
redeploy). Save validates with `compose config` first, keeps the previous file as
|
||||
`<file>.hamlab-bak`, and never deploys by itself. A stack deployed from another host
|
||||
(Dockhand on `core` driving `grid` over tcp) has no file on that machine, so the node
|
||||
alone lists it read-only — but **the hub fills that in** (`hubStacks`): asked about a
|
||||
node, it resolves the files on itself and runs compose here with `DOCKER_HOST` pointed
|
||||
at the node's `tcp://…:2375`, the way it was deployed; the task runs on the hub and the
|
||||
node's page follows it. Also per stack: every file it was deployed from (chips), its
|
||||
`<file>.hamlab-bak`, and never deploys by itself. A stack whose file is not on the
|
||||
machine that runs it is listed read-only, and that is the end of it: **since 2026-10-10
|
||||
the hub holds no files for a node and never points compose at another machine's
|
||||
Docker.** The `hubStacks` path — which resolved a node's files on the hub and deployed
|
||||
over `tcp://…:2375`, the way Dockhand had — is gone, and every
|
||||
`/api/nodes/<node>/docker/…` request is the node's own Ham Lab's to answer through the
|
||||
generic proxy. Also per stack: every file it was deployed from (chips), its
|
||||
**`.env`** (values masked until "Reveal and edit"; validated in place, put back if
|
||||
compose objects), **what Deploy would change** (a line diff against the copy kept at the
|
||||
last deploy from here, `<file>.hamlab-deployed`, else the copy the last save replaced;
|
||||
@@ -276,9 +280,10 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
form (image, ports, volumes, environment, restart, command, host networking) that writes
|
||||
the compose for you: a container made here is a one-service stack, so it stays editable
|
||||
and redeployable, where a bare `docker run` could only be thrown away and typed again.
|
||||
New stacks go in a **home** — a folder where two or more stacks are already deployed from
|
||||
(Dockhand's `stacks/core` here; `~/stacks` on a machine with none) — and are deployed the
|
||||
way their neighbours were (inside `dockhand`, or over tcp to a node from the hub). Homes
|
||||
New stacks go in a **home**. Since 2026-10-10 the default is Ham Lab's own
|
||||
`<data dir>/stacks`, offered first on every machine; a folder where two or more stacks
|
||||
are already deployed from is offered as well, and a stack made there is deployed the way
|
||||
its neighbours were (inside an orchestrator's container, when that is how they run). Homes
|
||||
are also scanned, so a stack with no containers ("not deployed": never deployed, or taken
|
||||
down) is still listed. **Down** removes a stack's containers; **Delete…** (type its name)
|
||||
takes it down and moves its own files to `<home>/.hamlab-trash/<name>-<time>/` — moved,
|
||||
@@ -299,6 +304,58 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
|
||||
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
|
||||
Top sat rather than falling to the bottom.
|
||||
- **GPUs on the Processes tab** — **shipped 2026-10-02** (`internal/metrics/gpu.go`, the
|
||||
`GPUBox` in `Top.tsx`, `gpu.*` sensors). Asked for as "GPU activity and temps, like btop
|
||||
has it, always on". Each DRM device with a real GPU driver gets a box under the cpu box:
|
||||
busy curve, busy meter, a meter per engine (render/video/copy/enhance), temperature, clock,
|
||||
power and fan where the card has them, and the processes that have it open with their
|
||||
share. The process table gains a `Gpu%` column and sort. **How it is measured, and the
|
||||
honest gaps:** device busy is Intel's RC6 idle residency inverted (the counter every GT
|
||||
exposes without root; `intel_gpu_top` shows the same number) or amdgpu's own percent;
|
||||
per-process and per-engine shares are DRM fdinfo deltas, the way nvtop reads them. Other
|
||||
users' fdinfo needs root, so a daemon running as `core` keeps **one** root child
|
||||
(`hamlab drm-clients`, over a pipe) rather than a sudo a second. **NVIDIA's proprietary
|
||||
driver (2026-10-23)** has neither a sysfs busy counter nor fdinfo engine totals, and its DRM
|
||||
card reports `DRIVER=nvidia` (not `nvidia-drm`), so until then such a card was not even
|
||||
listed; now it is read through `nvidia-smi` — `--query-gpu` for busy/encoder/decoder, VRAM,
|
||||
SM clock, temperature and power, and `pmon -c 1 -s um` for the per-process table (sm/enc/dec
|
||||
shares, framebuffer MiB) — two ~20–75 ms calls per sample, selected by PCI address, no root.
|
||||
First machine: `link`, the Surface Studio's GTX 965M. The Arc A380 on i915 6.12
|
||||
exposes power, energy and fan but **no temperature**, so its box shows none; an integrated
|
||||
GPU shows the CPU package temperature marked `*`. Asahi (the Mac mini) has neither a busy
|
||||
counter nor fdinfo in 6.19, so it lists the device and says so. No NVIDIA path: nothing here
|
||||
has one to test against. **Names come from an embedded PCI ID table** (2026-10-02,
|
||||
`internal/metrics/pciids_gpu.txt`, regenerated by `scripts/pci-ids-gpu.sh` from the hub's
|
||||
`pci.ids`): `gate` has no `lspci` and showed "Intel GPU 3185" beside `hamtv`'s "Intel UHD
|
||||
Graphics 600" for the identical chip. The table is tried first everywhere, so the name — and
|
||||
the sensor ID built from it — does not depend on which distro's pciutils a machine has;
|
||||
`lspci` is the fallback for a card newer than the table. Two fixes the same evening:
|
||||
the gpu box's layout rules had been lost from the stylesheet (v0.1.0-166/167), and on
|
||||
`grid` the sampler hung for 42 minutes in Go's poller on the Arc's `fan1_input` —
|
||||
`readStr` now uses plain blocking syscalls and each GPU's sysfs reads run under a
|
||||
3-second deadline (v0.1.0-168; `hamlet/docs/gotchas.md` has the mechanism).
|
||||
- **CPU power on the Processes tab** — **shipped 2026-10-10** (v0.1.0-194,
|
||||
`internal/metrics/cpupower.go`, the `cpu.power` sensor). Asked for beside the clock speed,
|
||||
and that is where it sits: "3600 MHz · 89 W" in the cpu box's header. Watts are the RAPL
|
||||
package energy counters' delta between samples, summed over packages; `psys`, a package's
|
||||
parts and the MMIO duplicate are left out. The counters are root-only, so a daemon running
|
||||
as `core` keeps a second root child, `hamlab cpu-energy`, beside `drm-clients`. Read on all
|
||||
four Intel machines; the Mac mini has no CPU-only counter (its SMC reports the whole
|
||||
machine's draw instead) and shows nothing.
|
||||
- **The ARC on the ZFS tab** — **shipped 2026-10-02** (`internal/metrics/arc.go`,
|
||||
`internal/storage/arc.go`, `GET /api/zfs/arc`, the Cache section in `ZFS.tsx`, `zfs.arc.*`
|
||||
sensors). Asked for as "current ARC usage, including any types of caching services in the
|
||||
pool". One card for the ARC: size against its ceiling with the target and floor, sparklines
|
||||
of size and hit rate from the sampler's history, what it holds (MRU/MFU/dirty, data vs
|
||||
metadata, compression, the ghost lists), the prefetcher and dbuf cache, memory-pressure
|
||||
counters, and the since-boot breakdown by demand/prefetch × data/metadata. Then a card per
|
||||
pool naming every caching tier — L2ARC, SLOG, special vdev, dedup table, spares — with its
|
||||
devices and fill, **or saying plainly that there is none and what that means** (the ZIL on
|
||||
the pool's own vdevs, with how much it has written), plus the properties that steer caching
|
||||
(`primarycache`, `secondarycache`, `sync`, `logbias`). Read from `/proc/spl/kstat/zfs/*`
|
||||
and `/sys/module/zfs/parameters`, with `zpool list -Hpv` and `zfs get` run without sudo
|
||||
(they need none) every 30 s at most. The day it shipped it showed `tank`'s ARC pinned at
|
||||
its 2 GiB floor under `arc_no_grow`, with a 53 GiB python3 process holding the RAM.
|
||||
- **Dashboard retired** — **2026-09-23**, at Mitchell's instruction, the same reasoning that
|
||||
merged Top into Processes: the app repeated itself. The customisable module list is gone
|
||||
(`Dashboard.tsx` deleted), and with it `/api/prefs/dashboard`, `applyPrefs`, and the
|
||||
@@ -360,39 +417,9 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
||||
had taken left untouched and fully readable here — which is what adopting its naming
|
||||
bought. See `hamlet/docs/hosts.md`.
|
||||
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`).
|
||||
Paste a device's address; its own web page appears in the tab. **Nothing in it knows
|
||||
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid`
|
||||
at `10.0.0.217`, but none of that is in the code.
|
||||
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
|
||||
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
|
||||
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
|
||||
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
|
||||
**The general problem, and the answer.** A device UI asks for its assets by absolute path
|
||||
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
|
||||
bodies per device would not be general. So the prefix is only the entry point: any request
|
||||
Ham Lab does not serve itself is routed to whichever KVM the asking page belongs to, read
|
||||
from the **Referer**, falling back to a **cookie** when there is none (a WebSocket upgrade).
|
||||
Ham Lab's own routes always match first, so a device can never shadow the console, and the
|
||||
fallback is gated on a live session. The device never receives Ham Lab's session cookie,
|
||||
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
|
||||
now. Redirects to the device's root are rewritten back through the prefix.
|
||||
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
|
||||
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
|
||||
refused before it loaded.
|
||||
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
|
||||
does not need to, since one with focus receives them directly. Fullscreen plus
|
||||
`navigator.keyboard.lock()` is what takes Escape, Tab and Ctrl-W back from the browser;
|
||||
that is Chromium-only, and the tab says which version you are getting rather than
|
||||
pretending.
|
||||
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
|
||||
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
|
||||
to `grid` and you get grid's; adding a second for a machine replaces the first. The
|
||||
addresses are still kept **on the hub** rather than on each machine, because a KVM earns
|
||||
its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub
|
||||
proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at
|
||||
all. Not done: holding the device's own login on the hub, so its page does not ask for a
|
||||
second password.
|
||||
- **KVM tab** — shipped 2026-09-24, **removed 2026-11-09** at Mitchell's call: not needed —
|
||||
the KVMs are reached over WireGuard (see `hamlet/docs/hosts.md`). It proxied a device's web
|
||||
page through Ham Lab's origin; the code is in git history before that date.
|
||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||
@@ -575,6 +602,281 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
so both modes share one implementation and one set of rules (absolute paths only;
|
||||
`/`, `/etc`, `/usr`, … are never deleted or moved whole). Every write is logged with
|
||||
the caller's IP.
|
||||
- **Previews on the Files tab** — **shipped 2026-10-11** (v0.1.0-200, `internal/thumbs`,
|
||||
`internal/api/thumbs.go`, `Thumb`/`ThumbNote` in `Files.tsx`). Asked for as a button that
|
||||
toggles a file preview, "obviously not for everything", cached, "use whatever GPU is on the
|
||||
machine we're looking at, and fall back to CPU", "a single small frame preview so it's
|
||||
fast". A **previews** button beside `.hidden` turns the icon column into a 256 px
|
||||
thumbnail for every image and video row (the listing says which: `preview: image|video`
|
||||
per entry, decided by extension in one place, `thumbs.Kind`), loaded as rows scroll within
|
||||
400 px of view — the list scrolls in a box of its own, so it has its own
|
||||
IntersectionObserver rather than the browser's window-based lazy loading. A video thumb
|
||||
carries a ▶ badge. The setting is in localStorage and follows the browser.
|
||||
- **ffmpeg is the one engine**, for images and video alike; `GET /api/files/thumb` is one
|
||||
ffprobe and one ffmpeg: `-ss` a tenth of the way in (30 s at most, `-noaccurate_seek`
|
||||
so one keyframe is decoded), the first real video stream (cover art is skipped), one
|
||||
frame, `scale` to fit 256, MJPEG q4 to a pipe. JPEGs decode at a reduced DCT size
|
||||
(`-lowres`, enough halvings to stay at twice the preview) — a 24 MP photo is read as
|
||||
1.5 MP. Thumbnails are the only thing Ham Lab asks a GPU to do, and it asks the way the
|
||||
Processes tab reads one: **probed, never assumed.** The probe looks ffmpeg and ffprobe
|
||||
up, opens every `/dev/dri/renderD*`, pushes a synthetic frame through each with VAAPI,
|
||||
and reads `vainfo`'s decode profiles per node where it exists; CUDA is tried the same
|
||||
way when `/dev/nvidiactl` is there (untested: nothing here has one). A video goes to the
|
||||
first GPU listing its codec; ffmpeg's own software fallback sits behind that for a
|
||||
profile the list did not distinguish, and a CPU retry behind a hardware run that fails
|
||||
outright. Images are CPU only — one frame through libjpeg at a quarter size beats a
|
||||
round trip through a GPU. Measured on `grid`: 1080p h264 0.4 s on the UHD 750 against
|
||||
0.7 s on the CPU; the 8K Osmo HEVC 1.0 s against 2.9 s. `grid`'s two GPUs are listed in
|
||||
node order and both decode the same set (h264, hevc, vp9, mjpeg, mpeg2 — **no AV1 on
|
||||
either**, per vainfo, so AV1 is dav1d on the CPU).
|
||||
- **Cached in one database file under the machine's data dir**, `<data dir>/thumbs.db`
|
||||
(bbolt — pure Go, so the binary stays static; v0.1.0-202, the first build kept them as
|
||||
a tree of hash-named JPEGs under `thumbs/`, replaced the same day when Mitchell asked
|
||||
where they were kept: "stored in hamlab, maybe under a DB, up to you"). An entry is
|
||||
keyed by path, size and mtime — a changed file gets a new preview, an unchanged one is
|
||||
never decoded twice — and carries the engine, codec, source size and time beside the
|
||||
picture; a count rides in the file so the previews button's tooltip can say "N kept
|
||||
(M MB)" without walking anything. The URL carries `v=<mtime>-<size>` so the browser's
|
||||
own cache is right too (7 days, ETag, 304). A file ffmpeg could not decode is kept with
|
||||
ffmpeg's reason and no picture, answered as 422, retried only when the file changes; a
|
||||
timeout is not kept. A miss never creates the file, so a machine nobody browses with
|
||||
previews on never has one; a cache that cannot be opened is logged and previews are
|
||||
still made and served, just not kept. Nothing expires: a preview is a few KB, and only
|
||||
files somebody scrolled past are ever made. At most 2–4 ffmpegs at once per machine
|
||||
(half the cores, clamped), one per file however many rows ask, and a preview's work
|
||||
runs on a clock of its own so the row that asked first leaving does not cancel it for
|
||||
the rest. Each ffmpeg runs under `nice -n 10`. `hamlet`'s backup job skips the file.
|
||||
- **Root mode makes them as root** (`hamlab thumb`, the sibling of `hamlab fs`, caps on
|
||||
stdin, JSON out), so a file only root can read gets a preview, and the probe runs as
|
||||
root too — root opens every render node whatever groups the daemon has. Which matters:
|
||||
a render node is `root:render 0660`, and on `core` and `hamtv` the `core` user is not in
|
||||
`render`, so there the daemon's own previews are CPU and the tab says so, naming the GPU
|
||||
and the owner, with an **Allow GPU access** button (confirms, then a task writes
|
||||
`hamlab.service.d/gpu.conf` adding `render`/`video` as supplementary groups and
|
||||
restarts Ham Lab three seconds later, the shape the Incus install used for
|
||||
`incus-admin`). Not pressed anywhere yet. Where ffmpeg is missing (the Mac mini, `gate`)
|
||||
the note offers **Install ffmpeg with dnf/apk** through the Updates machinery
|
||||
(`ffmpeg-free` on Fedora, `ffmpeg` elsewhere); the status re-probes when the task ends.
|
||||
- *Not done, on purpose:* no cap on the cache and no clear button (not asked; it only
|
||||
holds what was looked at); no tonemapping for HDR sources (a 10-bit HEVC frame comes out
|
||||
flat); no preview in the viewer for a video (it still says "binary, download it").
|
||||
- **Move… is a folder picker** — **shipped 2026-10-11** (v0.1.0-204, `MovePicker` in
|
||||
`Files.tsx`, `internal/files/move.go`, `POST /api/files/move`). Asked for as: instead of
|
||||
a path prompt, "an interactive file explorer, where I can go back/forward, etc and then
|
||||
click some button, and it will move the file/folder to where I was". The picker opens in
|
||||
the item's own folder and walks the machine the way the tab does — back and forward
|
||||
through where it has been, up, breadcrumbs, a path box, folders only, `.hidden`
|
||||
following the tab's toggle, **+ folder** to make the destination on the spot — and
|
||||
**Move here** puts the item in the folder being looked at. It says why it can't when it
|
||||
can't, with the button greyed: already here, a folder into itself, a name clash in that
|
||||
folder (read from the listing before anything is tried). The row being moved shows in
|
||||
its own folder, greyed. Rename stays a name prompt: it is a different question.
|
||||
- **Across filesystems it copies, then removes.** `os.Rename` is EXDEV between
|
||||
filesystems, and on `grid` every dataset is one — `/tank/media` to `/tank/files` was a
|
||||
"invalid cross-device link" error before this, and the picker would have made that
|
||||
move easy to attempt. `/api/files/move` renames when it can (204) and otherwise runs
|
||||
`hamlab fs move` as a task (202 with the task; the tab shows its output and reloads
|
||||
when it ends). The copy is built under a hidden name beside its destination
|
||||
(`.<name>.hamlab-moving`), renamed into place once complete, and only then is the
|
||||
original removed — a failure part-way removes the partial copy and leaves the original
|
||||
as it was, and a cancel (SIGTERM to the helper) does the same. Modes, mtimes and
|
||||
symlinks are kept; owners too when the backend is root. Devices, pipes and sockets are
|
||||
refused outright rather than half-copied. Progress every 500 files or 512 MiB.
|
||||
Verified on `grid` ext4 → ZFS with a tree holding a 0600 file, a symlink and an old
|
||||
mtime, all intact on the other side.
|
||||
- **Video and audio play in the viewer** — **shipped 2026-10-11** (v0.1.0-208). Asked for
|
||||
as "keep it simple for now, and just send whatever codec the file is in to the client,
|
||||
and let the client try to decode and watch it" — and that is the whole design. Opening a
|
||||
video or audio file shows a `<video>`/`<audio>` with the browser's own controls, fed the
|
||||
file as it is by `/api/files/read`; nothing is transcoded or remuxed, so what plays is
|
||||
what the browser decodes (Safari: h264/hevc/aac in mp4/mov; Chrome: those plus vp9/av1 in
|
||||
mkv/webm, hevc only where the machine has a hardware decoder; nothing plays MPEG-TS or a
|
||||
GoPro `.360`'s raw EAC strip). When the element errors the viewer says so and leaves the
|
||||
download. What the server gained is the part a player cannot do without: reads of a
|
||||
real file with a known size go through `http.ServeContent`, so **Range requests** (every
|
||||
seek, and Safari's first request) and HEAD work, and a download can resume. The root
|
||||
backend's helper takes `--offset` for that, and `files.Seeker` opens the backend once, at
|
||||
the range asked for, rather than streaming from the top and skipping; seeking costs
|
||||
nothing until a read wants somewhere else. `/proc` files, which stat at size 0, keep the
|
||||
plain streaming path, as does the editor's capped read. Audio became a preview kind at
|
||||
the same time (`thumbs.Kind` → `audio`): its cover art, where it has any, is the row's
|
||||
thumbnail, with a ♪ badge.
|
||||
- *Not done, on purpose:* no playlist or next/previous, no subtitles. (Transcoding came
|
||||
the same evening after all — the next entry — because Safari turned out not to play
|
||||
the gaming captures.)
|
||||
- **Re-encoding for the browser, on the machine's GPU** — **shipped 2026-10-11**
|
||||
(v0.1.0-212, `internal/media/{encode,hls,ffrun,inspect}.go`, `internal/api/play.go`,
|
||||
`Player.tsx`, `hls.js`). Asked for when Safari would not play the gaming captures:
|
||||
"add GPU transcoding to a supported codec when the client browser can't play it. Each
|
||||
machine should use its own GPU (if available) and transcode to probably H.264. Maybe we
|
||||
need a quality selector, and a codec selector, up to you." What Safari actually refuses
|
||||
is the **MKV container** — the captures checked are H.264 High 4:2:0, 18–36 Mbps — so
|
||||
the picker has a "repackaged" choice beside the codecs, and a 36 Mbps capture over
|
||||
WireGuard is the other reason to re-encode at all.
|
||||
- **The viewer tries the file as it is first**, as before. When the browser gives up, or
|
||||
on **Re-encode…** in the bar, a picker: **codec** — H.264, HEVC, AV1, or the original
|
||||
codec repackaged — each labelled with the engine this machine would use (or greyed as
|
||||
"not on this machine"), and **size** — original, 1080p, 720p, 480p; the choice is
|
||||
remembered. Play starts the re-encode and the player follows it: "h264 High 2292×960
|
||||
60 fps 36 Mbps in matroska → H.264 2292×960 5.0 Mbps on Intel Arc A380 (vaapi) · 7.3× ·
|
||||
34% through — seek within what's done", then "all of it is ready". **Change…** drops
|
||||
the stream and goes back to the picker.
|
||||
- **HLS, made on the machine in the switcher.** `POST /api/files/play` inspects the file
|
||||
(ffprobe, as root in root mode) and runs ffmpeg into an *event* playlist of 4 s
|
||||
fragmented-MP4 segments under `<data dir>/transcode/<id>/`, answering once the first
|
||||
segment exists (0.5–1 s on `grid`); the player reads the playlist and segments from
|
||||
`/api/files/play/<id>/…` (only those three names are served), Safari natively and
|
||||
everything else through hls.js, loaded only then. ffmpeg runs ahead at the engine's
|
||||
pace, so the seekable range grows and the whole file is through long before it is
|
||||
watched. Keyframes are forced every 4 s so segments cut cleanly; sound is AAC stereo
|
||||
unless it already is AAC; HDR is tone-mapped (`tonemap_vaapi`, or zscale/hable on the
|
||||
CPU). Nothing is kept: the same request within ten minutes reuses the stream, the
|
||||
player closes it when it goes (and the "Change…" path does), the sweeper closes what
|
||||
nobody touched for ten minutes, and the folder is emptied when the daemon starts. At
|
||||
most three at once per machine (429 with the reason). Root mode runs ffmpeg as root
|
||||
through `hamlab run-ffmpeg`, which ends when the daemon lets go of its stdin — so a
|
||||
stream cannot outlive the console.
|
||||
- **Engines are probed, never assumed**, the same probe the previews use, now also
|
||||
reading each node's encode profiles from vainfo and ffmpeg's own encoder list. The
|
||||
recipes for a codec are tried best first until one produces a first segment: a card on
|
||||
the bus before the integrated GPU (the A380 before the UHD 750), decoding on the same
|
||||
card so frames never leave it, then decoding on the CPU and uploading (a source the
|
||||
card cannot decode — AV1 on `grid`), then libx264 / libx265 / SVT-AV1. The size is
|
||||
never larger than the source, and the bit rate follows the output size (2/4/8/12/20
|
||||
Mbps for 480p/720p/1080p/1440p/2160p, +25 % at 50 fps and up, ×0.6 for HEVC and AV1).
|
||||
Measured on `grid` with a 2292×960 60 fps capture: H.264 7.3× realtime, HEVC 720p
|
||||
11.7×, AV1 720p 13×, all on the Arc. `core` and `hamtv` re-encode on the CPU until
|
||||
*Allow GPU access* is pressed (libx264 at 9.4× for a 720p→480p clip on `core`); the Mac
|
||||
and `gate` need ffmpeg first, which the previews note offers to install.
|
||||
- *Not done, on purpose:* no bit-rate ladder or adaptive switching, no subtitles, no
|
||||
keeping a finished re-encode for next time. (Restart-from-a-seek was left out here at
|
||||
first; it came 2026-10-20, the next entry.)
|
||||
- **Seek anywhere in a re-encode** — **2026-10-20**. Asked for as "there's no way to seek in
|
||||
the video and have the service just start transcoding from that point onward".
|
||||
- **The playlist is the whole film, up front:** a VOD playlist on a fixed 4 s grid
|
||||
(`segCount`, the last 0.3 s left out so sound running past the picture never lists a
|
||||
segment no frame starts), so the seek bar is the film's from the first frame.
|
||||
- **A segment not on disk** is waited for when the run going now will write it within 3
|
||||
segments; otherwise it is a seek: that run is ended and one starts at the segment asked
|
||||
for. A run stops half a frame short of the next segment already on disk, never over it.
|
||||
When a run ends by itself the next gap is filled, wrapping round, until every segment is
|
||||
there. The newest request moves the run; an older one still waiting never moves it back.
|
||||
A run we end closes its half-done segment short on the way out, so whatever it finished
|
||||
after being asked to stop is deleted.
|
||||
- **Runs are interchangeable** because every one, the first included, is made the same way
|
||||
(`segArgs`): accurate `-ss` to the segment, timestamps put back with
|
||||
`-output_ts_offset start+10` (the +10 keeps every run clear of the muxer's shift for the
|
||||
B-frame delay, which a run from the top would otherwise get and a seek wouldn't), kept
|
||||
absolute in each fragment (`movflags=+frag_discont`), `-start_number`. Tested on MKV
|
||||
and on MPEG-TS with a 2.8 s start time (where `-copyts` was a second out): the init
|
||||
segments come out byte-identical, and a seeked run's segments join a from-the-top run's
|
||||
within a frame. Only the first run's init is served; a later run writes `init-run.mp4`
|
||||
and the log notes if it ever differs.
|
||||
- Repackaged ("copy") streams keep the growing event playlist: the source's keyframes
|
||||
don't sit on a grid, and repackaging is near-instant anyway.
|
||||
- A segment waits at most 45 s, under the hub proxy's 60 s for a node to answer; hls.js
|
||||
is told a segment's first byte may take 50 s. The bar reads "7.3× from 12:00 · 43%
|
||||
ready — seek anywhere".
|
||||
- Tested (`TestSeekRestartsTheEncode`): a 64 s 1080p clip, a request for segment 12 right
|
||||
after the start runs from 48 s, the gap is filled, all 16 segments joined behind the one
|
||||
init decode with no hole.
|
||||
- **The player is Ham Share's; a Transcode device setting** — **2026-10-23** (v0.1.0-226,
|
||||
`internal/media/{encode,play}.go`, `internal/api/play.go`, `Player.tsx`, `vr.ts`,
|
||||
`Settings.tsx`). Asked for as "redo the HamLab one with the one you sent … a 'Transcode
|
||||
Device' setting … the A380 on Grid selected … other options being the other transcode
|
||||
devices like the iGPUs on the various CPUs that have HamLab installed". **Replaces** the
|
||||
previous two entries' design: no codec/size picker, no single-quality re-encode, no
|
||||
repackaging; the old streamer (`hls.go`) is gone.
|
||||
- **YouTube's way:** Auto (hls.js reads the connection), any quality by hand, or Original
|
||||
(the file as it is, `/api/files/read`, falling back to Auto when the browser can't). A
|
||||
ladder by size (`Ladder`): flat 2160p/1440p/1080p/720p/480p by the short side, plus the
|
||||
file's own size on top when it sits between steps (a 2292×960 capture gets 960p); 360
|
||||
files by width, named as YouTube does (2160s 3840 … 720s). 720 and down at ≤30 fps.
|
||||
H.264 High + AAC only.
|
||||
- **Pieces made when asked for**, 3 s MPEG-TS, every quality's playlist written whole up
|
||||
front. One ffmpeg per play: it starts at the piece asked, runs ahead, and is paused
|
||||
(SIGSTOP; a root run through `run-ffmpeg`, which now reads `stop`/`cont` on stdin) 10
|
||||
pieces ahead; a piece more than 3 past what's made, or at another quality, starts a new
|
||||
run there. Keyframes only on the 3 s marks and every run's times offset alike
|
||||
(`-output_ts_offset start+10`), so pieces from any run and quality join (`TestPieces`
|
||||
checks it; `SRC=… DEVICE=… go test ./internal/media -run Pieces` on a machine with a GPU).
|
||||
At most 3 runs a machine, the one asked of least lately ended for a fourth; 2 GiB of
|
||||
pieces a play; forgotten after 30 min.
|
||||
- **hls.js is patched** (`PatientAbr`): the wait for a piece being made isn't counted
|
||||
against the connection, and a piece isn't abandoned before its first byte. Without it
|
||||
Auto dropped to the bottom after every seek.
|
||||
- **Transcode device** (Settings, hub-side, `store.Transcode`): every machine's devices
|
||||
asked live (`GET /api/files/play-devices` on each), unusable ones greyed with the reason
|
||||
(hamtv's UHD 600: the render permission; mac and gate: no ffmpeg). Set to **grid's Arc
|
||||
A380**. "The machine the file is on" is the other choice. `POST /api/play` on the hub
|
||||
opens the play where the setting says; **a machine transcoding another's file reads it
|
||||
through the hub** at `/api/play-src/<token>` — a random token good for that one file, 12
|
||||
h from its last use, ranges passed through to the file's machine (ffmpeg reconnects).
|
||||
If that machine fails, the file's own makes it and the player says so.
|
||||
- Recipes per device: same-card decode first, then CPU decode + upload, then libx264 as
|
||||
the fallback whatever was asked. A card without `vainfo` (core) is tried anyway: core's
|
||||
UHD encodes. **Rotation:** the card path turns a phone's picture itself
|
||||
(`transpose_vaapi`, `-noautorotate`; ffmpeg's autorotate never applied to VAAPI frames,
|
||||
so iPhone clips came out sideways). **HLG** is shown as it is (tonemap_vaapi refuses it);
|
||||
HDR10 is tone-mapped on the card, else zscale/hable before the upload.
|
||||
- 360 files: the look-around view, zoom 8°–300° (ordinary lens into stereographic, the
|
||||
"little planet"), drag/pinch/wheel, phone motion. Full screen is the element's, or the
|
||||
whole window on an iPhone.
|
||||
- **Video previews follow the setting too** (same day, asked for as "it should use the
|
||||
same video generation device"). On the device's own machine the hub says which device
|
||||
by header (`X-Hamlab-Device`); for a file elsewhere the hub asks the file's machine's
|
||||
cache first (`?cached=1`), else has the device's machine make it from the hub's one-file
|
||||
URL (`POST /api/files/thumb-from`) and keeps it in the file's machine's cache (`PUT
|
||||
/api/files/thumb`, refused if the file changed since its listing), so each is made once.
|
||||
A failure is kept as a failure. Unreachable → made where the file is, as before. Images
|
||||
and audio covers stay on the file's machine's CPU (a JPEG is cheaper there than a GPU
|
||||
round trip). Measured: a `hamtv` video previewed on grid's A380 in 0.22 s, then 2 ms
|
||||
from hamtv's cache. The previews button's tooltip names the device.
|
||||
- Measured (`web/scripts/e2e-player.mjs`, Chromium): an iPhone HLG clip on `core` made on
|
||||
grid's A380 — 3.2 s to play, a seek 1.0 s, a quality change 1.5 s; the 8K 360 Disney day on
|
||||
grid — 3.1 s, 2160s on Auto, a seek 4.9 s (10 s source keyframes).
|
||||
- **Files edge sweep** — **2026-10-11** (v0.1.0-214), asked for as "a full bug sweep of
|
||||
the Files section, every example and edge case". Every handler and both UI files read
|
||||
through with odd inputs in mind, then a battery of ~60 requests against the live hub
|
||||
(`sweep.py`, not kept: listings of files, missing and forbidden paths, NUL bytes, names
|
||||
with spaces/#/%/?/umlauts, links to files, folders and nothing, a pipe, over-size saves,
|
||||
uploads with bad names and clashes, renames and moves onto things, protected paths,
|
||||
thumbnails of empty and mislabelled images, playback of an image and an audio-only mp4,
|
||||
root-mode variants). One real data bug: **a save over the 8 MiB cap truncated the file
|
||||
and kept the first 8 MiB, answering 204** — the body is now read whole first and refused
|
||||
with 413, the file untouched. The rest: a link to a file showed the link's own time; a
|
||||
link to nothing read as an empty 200; a bad codec or size was answered by inspecting
|
||||
the file; a failed stream was handed back to the next identical request; a player
|
||||
leaving mid-start made the recipe loop try the next engine; a root-mode ffmpeg's end
|
||||
could wait forever; a page closing kept its stream for ten minutes (a beacon now
|
||||
releases it at once); the path box took only absolute paths (it resolves like a shell
|
||||
now, in the tab and the Move picker, with a test); a dropped folder became a nameless
|
||||
empty file; the upload toast counted intentions; the viewer called a pipe a binary
|
||||
file. Not bugs, left alone: `rm -rf` of a mount point empties it (the console is
|
||||
root-capable by design); a folder of ten thousand rows is slow (no virtual list).
|
||||
- **Whole-console sweep** — **2026-10-13** (v0.1.0-216), asked for as "a full sweep of
|
||||
Ham Lab again, look for any bugs and fix them". Four nets, then reading: the Go race
|
||||
detector over the whole suite (clean), staticcheck's development build (the release does
|
||||
not know Go 1.26; it found only dead code and one capitalised error string), a
|
||||
garbage-in battery over every route safe to poke — 202 routes, bad identifiers, empty
|
||||
and malformed bodies, with the destructive ones held back — and a headless Chromium pass
|
||||
through all twenty tabs on the hub and on `grid`, collecting exceptions, console errors
|
||||
and failed requests (none). By hand: the auth middleware and sessions, token issue, the
|
||||
hub→node and KVM proxies, every validation regex before a shell, the cron and cron.d
|
||||
writers, config and state atomicity, every WebSocket and SSE loop's exit, the terminal
|
||||
manager, the task manager, and every destructive confirmation in the UI. Fixed, all
|
||||
small: docker inspect/health of a missing container was a 500 (404 now, a daemon that
|
||||
is down 503), `/api/virt/instances/{name}` without Incus was a 502 (409 like its
|
||||
siblings), a ZFS snapshot delete with a bad name was a 500 (400; 404 for one that does
|
||||
not exist), the task list read a task's Running flag outside its lock (a real data race,
|
||||
never seen), the Virtualization export config was written in place rather than
|
||||
atomically, and the dead code. Nothing in auth, the proxies or the shell boundaries.
|
||||
One self-inflicted finding: the battery's skip list left out `POST
|
||||
/api/files/thumbs/gpu-access`, so the sweep pressed *Allow GPU access* on `core` three
|
||||
times over — the drop-in landed and the hub restarted (that is what the headless pass
|
||||
saw as a refused connection). Kept, since it is the fix the tab offers; `core` makes
|
||||
previews and re-encodes on its UHD iGPU now. The handler refuses a second run while one
|
||||
is in flight (409), since the second `systemd-run` only collided with the first's timer.
|
||||
- **Docker read-only view** — `docker ps`/logs; dockhand stays the source of truth.
|
||||
- **Notifications** — push on service failure / SMART warning / UPS event.
|
||||
- **Terminal extras** — search, copy-mode, font/theme, split panes, upload via
|
||||
@@ -669,12 +971,17 @@ what it has moved.
|
||||
|
||||
## To do
|
||||
|
||||
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
|
||||
polish, and deprecating copyparty), merged 2026-09-24: they are the same tab and the same
|
||||
work. `copyparty` on `grid` (`copyparty.service`, `:3923`, `/etc/copyparty.conf`, serving
|
||||
all of `/tank` as user `core` with one account behind NPM) overlaps **Files** almost
|
||||
entirely. The aim is to stop the service and keep only Ham Lab, with general ease-of-use
|
||||
polish along the way. What Files must gain first, in the order that matters:
|
||||
- **Handle `fuse.mergerfs` in the Storage view** (2026-10-25). `grid` has a mergerfs pool at
|
||||
`/srv/tank` (the NVMe landing area + `/tank`; `hamlet`'s `docs/tank-landing-plan.md`).
|
||||
`fuse.mergerfs` isn't in `remoteFS` or `pseudoFS` (`internal/metrics/metrics.go`), so it shows
|
||||
as a local filesystem whose size is `tank` and the NVMe added together. That counts both twice
|
||||
in any storage total. Show it, labelled as a pool of its branches, and leave it out of totals.
|
||||
- **Grow the Files tab** — asked for 2026-09-23 as two items (Files polish, and deprecating
|
||||
copyparty), merged 2026-09-24. **copyparty was retired 2026-10-11** at Mitchell's call,
|
||||
ahead of these gaps rather than after them: service, files and NPM host all gone, with a
|
||||
rollback archive kept on `grid` (`hamlet`'s `docs/hosts.md` has the detail). Files is now
|
||||
the only file browser on `grid`, so the list below is no longer parity — it is the polish
|
||||
still owed, in the order that matters:
|
||||
- **Multi-select and bulk actions.** copyparty selects many rows and moves, deletes or
|
||||
downloads them together; Files acts on one row at a time.
|
||||
- **Download a folder**, and a multi-file download, as a streamed zip or tar — the thing
|
||||
@@ -682,17 +989,14 @@ what it has moved.
|
||||
- **Resumable uploads.** copyparty's up2k chunks a file, survives a dropped connection
|
||||
and skips what is already there; Files streams one PUT, so a phone upload that drops at
|
||||
90% starts over.
|
||||
- **A gallery.** Thumbnails for a folder of images and video, and play audio/video in
|
||||
place — `/tank/media` and `/tank/immich` are most of what is browsed.
|
||||
- **A gallery.** ~~Thumbnails for a folder of images and video~~ (the **previews** toggle,
|
||||
2026-10-11 — see the shipped entry above; a grid view is not built), and play
|
||||
audio/video in place — `/tank/media` and `/tank/immich` are most of what is browsed.
|
||||
- **Search** below the current folder by name, since `find` in a terminal is the fallback
|
||||
today.
|
||||
- *Decide when built:* whether share links (copyparty's public per-path links) are wanted
|
||||
at all — they are the one feature that hands out access without a login, and Ham Lab has
|
||||
no notion of that; if not, say so in the docs rather than leaving a gap unexplained.
|
||||
- *After:* `systemctl disable --now copyparty`, remove its NPM host, keep
|
||||
`/etc/copyparty.conf` until a rollback is clearly not needed, then update
|
||||
`hamlet`'s `docs/hosts.md` bare-metal list, `docs/gotchas.md` (the "`docker ps` is not
|
||||
the inventory" note names it) and the `grid` row in `CLAUDE.md`.
|
||||
|
||||
- **Grow the Docker tab until Dockhand can go** — asked for 2026-09-23 and widened
|
||||
2026-09-24 to take in Docker tab polish and feature work, on the same reasoning as the
|
||||
@@ -740,23 +1044,15 @@ what it has moved.
|
||||
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, which this is the moment to settle.** 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.
|
||||
- **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, not started, and **large**. Run and
|
||||
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
|
||||
which is the comparison Ham Lab has invited since its first line. Nothing exists yet.
|
||||
What a future session has to settle before writing code: which stack (libvirt/KVM +
|
||||
`virsh` is the obvious one for VMs; LXC or Incus for containers, and Incus covers both);
|
||||
whether it manages what is already installed or installs it; where disk images live
|
||||
(`tank` on `grid`, local NVMe elsewhere); console access (SPICE/VNC proxied through the
|
||||
daemon, or a serial console over the existing terminal machinery, which would reuse
|
||||
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
|
||||
at all, which is every host here today. Worth scoping as its own plan before starting.
|
||||
- ~~**A Virtualization tab**~~ — built 2026-10-09, **cut 2026-10-15**: out of scope (see Decisions). The code, its plan and its tests are on the `virtualization` branch.
|
||||
|
||||
|
||||
## Open questions
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
# 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.
|
||||
|
||||
Virtualization was cut from the app 2026-10-15 (kept on the `virtualization` branch); its
|
||||
items — 1.8, 4.2, 5.1, 5.2, 6.14 and the Incus halves of 5.3, 5.7, 6.15, 10.3 — went with it.
|
||||
|
||||
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://lab.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 `lab.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/`lab.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.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.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.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.3 | **B** | `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold` in `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/space/manager.go:578-585` `timeout -s KILL` | GNU `timeout`. | 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.15 | **B** | `internal/network/network.go:170-199, 303-314`; `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, 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, and no cert SAN. |
|
||||
|
||||
## 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. | — |
|
||||
|
||||
## 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/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/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.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.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`, `SENSORS.md`, `README.md`.
|
||||
@@ -97,5 +97,5 @@ page flags machines running an older version and offers the same push per machin
|
||||
Mixed architectures are fine: `make deploy` also cross-compiles every other architecture,
|
||||
and the hub sends each machine the build for its own.
|
||||
|
||||
- On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager.
|
||||
- On `core` it is public at `https://lab.bigham.dev` behind Nginx Proxy Manager.
|
||||
- Installable as a PWA.
|
||||
|
||||
+25
-3
@@ -12,7 +12,7 @@ added to the registry in `api.New`.
|
||||
## IDs
|
||||
|
||||
```
|
||||
cpu.usage cpu.core.<n>.usage cpu.temp load.1|5|15 uptime
|
||||
cpu.usage cpu.core.<n>.usage cpu.temp cpu.power load.1|5|15 uptime
|
||||
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
||||
temp.<chip>.<label> fan.<chip>.<label>
|
||||
net.<iface>.rx|tx|rx_total|tx_total|up
|
||||
@@ -20,6 +20,8 @@ net.internet.download|upload|latency|jitter|tested|ip|edge
|
||||
disk.<dev>.read|write|util|size|temp
|
||||
fs.<mount>.size|used|used_pct
|
||||
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
||||
gpu.<name>.usage|render|video|video-enhance|copy|compute|temp|power|fan|freq|mem_used|mem_total|clients
|
||||
zfs.arc.size|target|min|max|mru|mfu|data|metadata|hit_pct|reads|misses|lifetime_hit_pct|no_grow|compress_ratio|l2_size|l2_hit_pct
|
||||
```
|
||||
|
||||
- Segments are lower case `[a-z0-9_-]`: `Package id 0` → `package_id_0`, `/mnt/tank` →
|
||||
@@ -33,6 +35,23 @@ ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
||||
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
|
||||
|
||||
@@ -150,6 +169,9 @@ container.<name>.down should be up and isn't: Docker c
|
||||
restart it — *not* a container someone stopped
|
||||
updates.pending|security|reboot_required every 5 min
|
||||
zfs.<pool>.health|healthy|size|used|used_pct|frag every 1 min
|
||||
zfs.write.dirty|limit|delayed the RAM write buffer (all pools) and writes the throttle is slowing
|
||||
zfs.<pool>.write_bps|logical_bps bytes/s synced to disk, and written by programs
|
||||
zfs.<pool>.landing.pending_bytes|pending_files|used_pct|in_bps|drain_bps|mover|queue a landing tier, where one fronts the pool
|
||||
smart.<dev>.passed|warnings|power_on|wear every 10 min, never waking a sleeping disk
|
||||
```
|
||||
|
||||
@@ -181,13 +203,13 @@ facts, never live. An `iframe` of the embed is live.
|
||||
```yaml
|
||||
- type: iframe
|
||||
title: core CPU
|
||||
source: https://hamlab.bigham.dev/embed/widget?type=gauge&sensor=cpu.usage&bare=1&token=<read-only token>
|
||||
source: https://lab.bigham.dev/embed/widget?type=gauge&sensor=cpu.usage&bare=1&token=<read-only token>
|
||||
height: 130
|
||||
|
||||
- type: custom-api
|
||||
title: grid
|
||||
cache: 1m
|
||||
url: https://hamlab.bigham.dev/api/nodes/grid/sensors/query?pool=zfs.tank.used_pct&failed=services.failed&updates=updates.pending
|
||||
url: https://lab.bigham.dev/api/nodes/grid/sensors/query?pool=zfs.tank.used_pct&failed=services.failed&updates=updates.pending
|
||||
headers: { Authorization: Bearer <read-only token> }
|
||||
template: |
|
||||
<div>tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/nodes"
|
||||
"hamlab/internal/priv"
|
||||
@@ -62,6 +63,7 @@ func main() {
|
||||
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
|
||||
users list the accounts
|
||||
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
|
||||
landing-verify <new|all|id> verify a landing tier against its mover's ledger (a schedule's id is what its cron line calls)
|
||||
docker-update <id> run one image-update schedule (what its cron line calls)
|
||||
user-rm <user> remove an account and sign it out everywhere
|
||||
check verify the config and state load, then exit
|
||||
@@ -80,8 +82,16 @@ func main() {
|
||||
switch cmd { // commands that don't read the config
|
||||
case "fs": // privileged helper for the Files tab (run as root by the daemon)
|
||||
os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
case "thumb": // privileged helper for the Files tab's previews in root mode (run as root by the daemon)
|
||||
os.Exit(media.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
case "run-ffmpeg": // privileged helper for playback in root mode: ffmpeg as root, ending when stdin closes
|
||||
os.Exit(media.RunFFmpegHelper(flag.Args()[1:], os.Stdin, os.Stderr))
|
||||
case "space-walk": // helper for the Space tab: measure mountpoints, stream the tree back
|
||||
os.Exit(space.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
case "drm-clients": // privileged helper for the GPU readings: who has the GPU open (run as root by the daemon)
|
||||
os.Exit(metrics.DRMClientsHelper(os.Stdin, os.Stdout))
|
||||
case "cpu-energy": // privileged helper for the CPU power reading: the packages' energy counters (run as root by the daemon)
|
||||
os.Exit(metrics.CPUEnergyHelper(os.Stdin, os.Stdout))
|
||||
case "install", "uninstall":
|
||||
var err error
|
||||
if cmd == "install" {
|
||||
@@ -141,6 +151,10 @@ func main() {
|
||||
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case "landing-verify":
|
||||
if err := landingVerify(cfg, flag.Arg(1)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case "users":
|
||||
migrateUsers(cfg)
|
||||
if len(cfg.Users) == 0 {
|
||||
@@ -245,6 +259,81 @@ func dockerUpdateJob(cfg *config.Config, id string) error {
|
||||
//
|
||||
// It deliberately does not write state.json. The daemon owns that file and would clobber
|
||||
// anything written from out here; the outcome goes beside it instead, in zfs-runs/.
|
||||
// landingVerify runs one verify pass over every landing tier on this machine: by hand with a
|
||||
// scope ("new" or "all"), or as a schedule by its id, which is what the cron line passes.
|
||||
func landingVerify(cfg *config.Config, arg string) error {
|
||||
scope, id := arg, "manual"
|
||||
if arg != "new" && arg != "all" {
|
||||
if strings.TrimSpace(arg) == "" {
|
||||
return errors.New("usage: hamlab landing-verify <new|all|schedule-id>")
|
||||
}
|
||||
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
st.View(func(s *store.State) { raw = s.ZFS })
|
||||
var z struct {
|
||||
Verify []storage.VerifyPolicy `json:"verify"`
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &z); err != nil {
|
||||
return fmt.Errorf("reading the schedules: %w", err)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, p := range z.Verify {
|
||||
if p.ID == arg {
|
||||
scope, id, found = p.Scope, p.ID, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("no verify schedule %q — it may have been removed; this cron line is stale", arg)
|
||||
}
|
||||
}
|
||||
dir := cfg.DataDir()
|
||||
unlock, err := storage.LockVerify(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
defer storage.ClearVerifyProgress(dir)
|
||||
targets := storage.LandingTargets()
|
||||
if len(targets) == 0 {
|
||||
r := storage.VerifyResult{ID: id, Scope: scope, Started: time.Now(), Finished: time.Now(), Issues: []storage.VerifyIssue{},
|
||||
Error: "no landing mover is reporting (nothing in " + storage.MoverStatusDir + "), so there is nothing to verify"}
|
||||
_ = storage.RecordVerify(dir, storage.LandingTarget{}, r)
|
||||
return errors.New(r.Error)
|
||||
}
|
||||
var firstErr error
|
||||
for _, t := range targets {
|
||||
r := storage.Verify(t, id, scope, storage.VerifyCursor(dir, t), func(p storage.VerifyProgress) { storage.WriteVerifyProgress(dir, p) })
|
||||
if err := storage.RecordVerify(dir, t, r); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "recording the pass:", err)
|
||||
}
|
||||
fmt.Printf("%s -> %s (%s): %d checked, %d match, %d mismatch, %d gone, %d unreadable, %d changed since, %d waiting, %d unhashed, %s read in %s\n",
|
||||
t.Src, t.Dst, scope, r.Checked, r.Matched, r.Mismatched, r.Gone, r.Unreadable, r.Changed, r.Waiting, r.Unhashed,
|
||||
humanBytes(r.BytesRead), r.Finished.Sub(r.Started).Round(time.Second))
|
||||
if r.Error != "" && firstErr == nil {
|
||||
firstErr = errors.New(r.Error)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const u = 1024
|
||||
if n < u {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(u), 0
|
||||
for v := n / u; v >= u; v /= u {
|
||||
div *= u
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func zfsSnapshot(cfg *config.Config, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return errors.New("usage: hamlab zfs-snapshot <schedule-id>")
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.26.0
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/creack/pty v1.1.24
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/term v0.46.0
|
||||
)
|
||||
|
||||
@@ -2,9 +2,21 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -122,6 +122,14 @@ func newAlertEngine(s *Server) *alertEngine {
|
||||
for key, k := range e.saved.Firing {
|
||||
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
|
||||
}
|
||||
// the Virtualization tab's default went with the tab (2026-10-15): nothing publishes instance.* now
|
||||
kept := e.saved.Rules[:0]
|
||||
for _, r := range e.saved.Rules {
|
||||
if r.ID != "instance-down" {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
e.saved.Rules = kept
|
||||
for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
|
||||
if r.ID == "container-down" && r.Sensor == "container.*.running" {
|
||||
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
|
||||
|
||||
+22
-3
@@ -343,6 +343,21 @@ func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// dockerInspect is the parts of `docker inspect` worth reading on a phone.
|
||||
// dockerErr answers for a docker command that failed: a thing that isn't there is a 404,
|
||||
// the daemon being away a 503, anything else a 500.
|
||||
func dockerErr(w http.ResponseWriter, err error) {
|
||||
msg := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(err.Error()), "error: "))
|
||||
low := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(low, "no such object") || strings.Contains(low, "no such container") || strings.Contains(low, "not found"):
|
||||
jsonErr(w, 404, msg)
|
||||
case strings.Contains(low, "cannot connect to the docker daemon") || strings.Contains(low, "is the docker daemon running"):
|
||||
jsonErr(w, 503, msg)
|
||||
default:
|
||||
jsonErr(w, 500, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !dockerRef.MatchString(id) {
|
||||
@@ -352,9 +367,13 @@ func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
out, err := dockerRun(ctx, "inspect", id)
|
||||
if err != nil {
|
||||
dockerErr(w, err)
|
||||
return
|
||||
}
|
||||
var all []map[string]any
|
||||
if err != nil || json.Unmarshal(out, &all) != nil || len(all) == 0 {
|
||||
jsonErr(w, 500, fmt.Sprint("inspect failed: ", err))
|
||||
if json.Unmarshal(out, &all) != nil || len(all) == 0 {
|
||||
jsonErr(w, 500, "inspect: unreadable answer")
|
||||
return
|
||||
}
|
||||
c := all[0]
|
||||
@@ -486,7 +505,7 @@ func (s *Server) dockerHealth(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
out, err := dockerRun(ctx, "inspect", "--format", "{{json .State}}", id)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
dockerErr(w, err)
|
||||
return
|
||||
}
|
||||
var st struct {
|
||||
|
||||
@@ -55,11 +55,14 @@ type composeSpec struct {
|
||||
Project string `json:"project"`
|
||||
Files []string `json:"files"`
|
||||
Via string `json:"via,omitempty"`
|
||||
Remote string `json:"remote,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, Remote: c.Remote, Tool: tool}
|
||||
return compose.Spec{Project: c.Project, Files: c.Files, Via: c.Via, Tool: tool}
|
||||
}
|
||||
|
||||
const dockerCronFile = "/etc/cron.d/hamlab-docker"
|
||||
@@ -169,6 +172,9 @@ func RunUpdate(ctx context.Context, db *store.Store, j UpdateJob, tool string) (
|
||||
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 {
|
||||
@@ -238,7 +244,7 @@ func (s *Server) dsPut(w http.ResponseWriter, r *http.Request) {
|
||||
// seenFiles is the list as compose itself sees it: host paths normally, the
|
||||
// orchestrator container's own paths when compose runs inside one. Getting this
|
||||
// backwards gives a cron line that works from the console and fails at 3am.
|
||||
j.Spec = composeSpec{Project: st.Name, Files: seenFiles(st, -1, ""), Via: st.Via, Remote: st.Remote}
|
||||
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
|
||||
@@ -334,7 +340,6 @@ func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"hamlab/internal/files"
|
||||
)
|
||||
|
||||
// A file read answers Range requests — what a <video> needs to seek — and HEAD, through
|
||||
// the same handler that streams everything else.
|
||||
func TestFilesReadRanges(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "clip.mp4")
|
||||
data := make([]byte, 10000)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 251)
|
||||
}
|
||||
if err := os.WriteFile(p, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{fsLocal: files.Local{}}
|
||||
get := func(method, extra string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, "/api/files/read?path="+url.QueryEscape(p)+extra, nil)
|
||||
for k, v := range hdr {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.filesRead(w, r)
|
||||
return w
|
||||
}
|
||||
w := get("GET", "", nil)
|
||||
if w.Code != 200 || w.Body.Len() != 10000 || w.Header().Get("Content-Type") != "video/mp4" || w.Header().Get("Accept-Ranges") != "bytes" {
|
||||
t.Fatalf("whole: %d %d %q %q", w.Code, w.Body.Len(), w.Header().Get("Content-Type"), w.Header().Get("Accept-Ranges"))
|
||||
}
|
||||
w = get("GET", "", map[string]string{"Range": "bytes=100-199"})
|
||||
if w.Code != 206 || w.Header().Get("Content-Range") != "bytes 100-199/10000" || w.Body.Len() != 100 || w.Body.Bytes()[0] != data[100] {
|
||||
t.Fatalf("range: %d %q %d", w.Code, w.Header().Get("Content-Range"), w.Body.Len())
|
||||
}
|
||||
w = get("GET", "", map[string]string{"Range": "bytes=9990-"})
|
||||
if w.Code != 206 || w.Body.Len() != 10 || w.Body.Bytes()[9] != data[9999] {
|
||||
t.Fatalf("tail range: %d %d", w.Code, w.Body.Len())
|
||||
}
|
||||
w = get("HEAD", "", nil)
|
||||
if w.Code != 200 || w.Body.Len() != 0 || w.Header().Get("Content-Length") != "10000" {
|
||||
t.Fatalf("head: %d %d %q", w.Code, w.Body.Len(), w.Header().Get("Content-Length"))
|
||||
}
|
||||
if w := get("GET", "&max=50", nil); w.Code != 413 {
|
||||
t.Fatalf("the editor's cap: %d", w.Code)
|
||||
}
|
||||
if w := get("GET", "", map[string]string{"Range": "bytes=20000-"}); w.Code != 416 {
|
||||
t.Fatalf("a range past the end: %d", w.Code)
|
||||
}
|
||||
if err := os.Chmod(p, 0); err == nil && os.Geteuid() != 0 {
|
||||
if w := get("GET", "", nil); w.Code != 403 {
|
||||
t.Fatalf("unreadable: %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A write over the cap is refused whole: the file is not truncated first.
|
||||
func TestFilesWriteCap(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "f.txt")
|
||||
if err := os.WriteFile(p, []byte("keep me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{fsLocal: files.Local{}}
|
||||
body := make([]byte, 8<<20+1)
|
||||
r := httptest.NewRequest("PUT", "/api/files/write?path="+url.QueryEscape(p), bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
s.filesWrite(w, r)
|
||||
if w.Code != 413 {
|
||||
t.Fatalf("got %d", w.Code)
|
||||
}
|
||||
if b, _ := os.ReadFile(p); string(b) != "keep me" {
|
||||
t.Fatalf("the file was touched: %q", b)
|
||||
}
|
||||
r = httptest.NewRequest("PUT", "/api/files/write?path="+url.QueryEscape(p), bytes.NewReader([]byte("new")))
|
||||
w = httptest.NewRecorder()
|
||||
s.filesWrite(w, r)
|
||||
if b, _ := os.ReadFile(p); w.Code != 204 || string(b) != "new" {
|
||||
t.Fatalf("a write within the cap: %d %q", w.Code, b)
|
||||
}
|
||||
}
|
||||
|
||||
// A link to nothing is a 404, not an empty 200.
|
||||
func TestFilesReadDanglingLink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
l := filepath.Join(dir, "broken")
|
||||
if err := os.Symlink("nowhere", l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{fsLocal: files.Local{}}
|
||||
r := httptest.NewRequest("GET", "/api/files/read?path="+url.QueryEscape(l), nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.filesRead(w, r)
|
||||
if w.Code != 404 {
|
||||
t.Fatalf("got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// dockerErr tells a thing that isn't there from a daemon that isn't up from anything else.
|
||||
func TestDockerErr(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
msg string
|
||||
want int
|
||||
}{
|
||||
{"error: No such object: zzz", 404}, {"Error response from daemon: No such container: x", 404},
|
||||
{"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", 503},
|
||||
{"something else entirely", 500},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
dockerErr(w, errors.New(c.msg))
|
||||
if w.Code != c.want {
|
||||
t.Errorf("%q → %d, want %d", c.msg, w.Code, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
package api
|
||||
|
||||
// IP-KVMs: a device's own web page, shown inside Ham Lab.
|
||||
//
|
||||
// The whole difficulty is that Ham Lab is served over TLS and a KVM usually is not, so a
|
||||
// browser refuses to put the device's page in an iframe — mixed content, blocked before
|
||||
// anything loads. The fix is to serve the device from Ham Lab's own origin, which also
|
||||
// means it inherits Ham Lab's login instead of needing a second one, and makes the iframe
|
||||
// same-origin.
|
||||
//
|
||||
// Proxying an arbitrary web app under a path prefix is the hard part, and nothing here
|
||||
// knows anything about any particular device. A device UI asks for its assets by absolute
|
||||
// path — "/assets/app.js", "/api/whatever" — which a prefix cannot rewrite away. So the
|
||||
// prefix is only the entry point: any request Ham Lab does not serve itself is routed to
|
||||
// whichever KVM the asking page belongs to, worked out from the Referer, and from a cookie
|
||||
// when there is no Referer (a WebSocket upgrade, say). Ham Lab's own routes always win, so
|
||||
// a device can never shadow the console.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/config"
|
||||
)
|
||||
|
||||
const kvmPrefix = "/kvm/"
|
||||
const kvmCookie = "hamlab_kvm"
|
||||
|
||||
type kvmProxies struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func (s *Server) kvm(id string) (config.KVM, bool) {
|
||||
var found config.KVM
|
||||
ok := false
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, k := range c.KVMs {
|
||||
if k.ID == id {
|
||||
found, ok = k, true
|
||||
}
|
||||
}
|
||||
})
|
||||
return found, ok
|
||||
}
|
||||
|
||||
// kvmProxy is the reverse proxy for one device, made once and kept.
|
||||
func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
||||
s.kvms.mu.Lock()
|
||||
defer s.kvms.mu.Unlock()
|
||||
if s.kvms.m == nil {
|
||||
s.kvms.m = map[string]*httputil.ReverseProxy{}
|
||||
}
|
||||
if p, ok := s.kvms.m[k.ID]; ok {
|
||||
return p, nil
|
||||
}
|
||||
target, err := url.Parse(k.URL)
|
||||
if err != nil || target.Host == "" {
|
||||
return nil, errors.New("this KVM's address is not a URL")
|
||||
}
|
||||
prefix := kvmPrefix + k.ID
|
||||
p := &httputil.ReverseProxy{
|
||||
Director: func(r *http.Request) {
|
||||
r.URL.Scheme, r.URL.Host = target.Scheme, target.Host
|
||||
// Entry through the prefix keeps the path after it; anything else arrived by its
|
||||
// own absolute path and is passed through untouched.
|
||||
if rest, ok := strings.CutPrefix(r.URL.Path, prefix); ok {
|
||||
if rest == "" {
|
||||
rest = "/"
|
||||
}
|
||||
r.URL.Path = rest
|
||||
}
|
||||
r.URL.RawPath = ""
|
||||
r.Host = target.Host
|
||||
// The device gets its own host and none of Ham Lab's session.
|
||||
r.Header.Del("Origin")
|
||||
stripCookie(r, cookieName, kvmCookie)
|
||||
},
|
||||
FlushInterval: -1, // a video stream must not be buffered
|
||||
ModifyResponse: func(resp *http.Response) error {
|
||||
// The device's own framing and transport policy would stop it being embedded, or
|
||||
// send a browser off to plain HTTP. Ham Lab is the origin now, and says what it
|
||||
// serves; its own headers are added on the way out.
|
||||
for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} {
|
||||
resp.Header.Del(h)
|
||||
}
|
||||
// A redirect to the device's own root has to come back through the prefix.
|
||||
if loc := resp.Header.Get("Location"); loc != "" {
|
||||
if u, err := url.Parse(loc); err == nil && (u.Host == "" || u.Host == target.Host) && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, prefix) {
|
||||
u.Scheme, u.Host = "", ""
|
||||
u.Path = prefix + u.Path
|
||||
resp.Header.Set("Location", u.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": k.Name + " unreachable: " + err.Error()})
|
||||
},
|
||||
}
|
||||
s.kvms.m[k.ID] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// stripCookie removes named cookies from a request, so Ham Lab's session is never handed to
|
||||
// a device and the device never sees which KVM the browser is looking at.
|
||||
func stripCookie(r *http.Request, names ...string) {
|
||||
cs := r.Cookies()
|
||||
kept := cs[:0]
|
||||
for _, c := range cs {
|
||||
drop := false
|
||||
for _, n := range names {
|
||||
if c.Name == n {
|
||||
drop = true
|
||||
}
|
||||
}
|
||||
if !drop {
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
r.Header.Del("Cookie")
|
||||
for _, c := range kept {
|
||||
r.AddCookie(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) forgetKVMProxy(id string) {
|
||||
s.kvms.mu.Lock()
|
||||
delete(s.kvms.m, id)
|
||||
s.kvms.mu.Unlock()
|
||||
}
|
||||
|
||||
// serveKVM is the entry point: /kvm/<id>/… . It also drops a cookie naming the device, so
|
||||
// that requests the page makes later by absolute path can be traced back to it.
|
||||
func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
k, ok := s.kvm(id)
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such KVM")
|
||||
return
|
||||
}
|
||||
p, err := s.kvmProxy(k)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode})
|
||||
p.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// kvmFor works out which device a request belongs to when it arrived by absolute path
|
||||
// rather than through the prefix: the Referer names the page that asked, and a cookie
|
||||
// covers what has no Referer at all. Nothing device-specific: it never has to know which
|
||||
// paths a given KVM uses.
|
||||
func (s *Server) kvmFor(r *http.Request) (config.KVM, bool) {
|
||||
if ref := r.Header.Get("Referer"); ref != "" {
|
||||
if u, err := url.Parse(ref); err == nil {
|
||||
if rest, ok := strings.CutPrefix(u.Path, kvmPrefix); ok {
|
||||
if id, _, _ := strings.Cut(rest, "/"); id != "" {
|
||||
if k, ok := s.kvm(id); ok {
|
||||
return k, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// asked for by a page that is not a KVM: not ours to answer
|
||||
return config.KVM{}, false
|
||||
}
|
||||
if c, err := r.Cookie(kvmCookie); err == nil && c.Value != "" {
|
||||
return s.kvm(c.Value)
|
||||
}
|
||||
return config.KVM{}, false
|
||||
}
|
||||
|
||||
// kvmFallback answers a request Ham Lab does not serve itself, when it came from inside a
|
||||
// KVM's page. It returns false when the request has nothing to do with a KVM, and the
|
||||
// caller carries on with its own 404.
|
||||
func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool {
|
||||
k, ok := s.kvmFor(r)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
p, err := s.kvmProxy(k)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
p.ServeHTTP(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
func machineLabel(m string) string {
|
||||
if m == "" {
|
||||
return "this host"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---- the list ----
|
||||
|
||||
func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) {
|
||||
out := []config.KVM{}
|
||||
s.cfg.View(func(c *config.Config) { out = append(out, c.KVMs...) })
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
// kvmForMachine is the KVM attached to a machine, "" being the hub itself.
|
||||
func (s *Server) kvmForMachine(machine string) (config.KVM, bool) {
|
||||
var found config.KVM
|
||||
ok := false
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, k := range c.KVMs {
|
||||
if k.Machine == machine {
|
||||
found, ok = k, true
|
||||
}
|
||||
}
|
||||
})
|
||||
return found, ok
|
||||
}
|
||||
|
||||
func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name, URL, Machine string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(body.URL)
|
||||
if raw != "" && !strings.Contains(raw, "://") {
|
||||
raw = "http://" + raw // what you would type: an address, not a scheme
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
jsonErr(w, 400, "address: http(s)://host, or just the host")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
name = u.Host
|
||||
}
|
||||
if len(name) > maxNameLen {
|
||||
jsonErr(w, 400, "that name is too long")
|
||||
return
|
||||
}
|
||||
b := make([]byte, 6)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
machine := strings.TrimSpace(body.Machine)
|
||||
k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: machine, Added: time.Now()}
|
||||
// One per machine: a second one for the same host replaces the first rather than sitting
|
||||
// beside it, since the tab shows the machine you are looking at.
|
||||
var replaced string
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
kept := c.KVMs[:0]
|
||||
for _, old := range c.KVMs {
|
||||
if old.Machine == machine {
|
||||
replaced = old.ID
|
||||
continue
|
||||
}
|
||||
kept = append(kept, old)
|
||||
}
|
||||
c.KVMs = append(kept, k)
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if replaced != "" {
|
||||
s.forgetKVMProxy(replaced)
|
||||
}
|
||||
log.Printf("kvm %q added for %s (%s) by %s", k.Name, machineLabel(machine), k.URL, clientIP(r))
|
||||
jsonOK(w, 201, k)
|
||||
}
|
||||
|
||||
func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
found := false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
kept := c.KVMs[:0]
|
||||
for _, k := range c.KVMs {
|
||||
if k.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, k)
|
||||
}
|
||||
c.KVMs = kept
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
jsonErr(w, 404, "no such KVM")
|
||||
return
|
||||
}
|
||||
s.forgetKVMProxy(id)
|
||||
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package api
|
||||
|
||||
// The ZFS tab's landing-tier checks: what has waited too long on a landing branch, and verify
|
||||
// passes that read the pool's copy of every moved file back against the mover's ledger —
|
||||
// run by hand or on a schedule written to /etc/cron.d/hamlab-landing, just as snapshot
|
||||
// schedules are. A pass runs as its own process (`hamlab landing-verify`), the same one cron
|
||||
// starts, so it survives the console being restarted or upgraded mid-pass.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/storage"
|
||||
)
|
||||
|
||||
func (s *Server) syncVerifyCron(ctx context.Context, z zfsState) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exe, _ = filepath.EvalSymlinks(exe)
|
||||
return s.storage.WriteVerifyCron(ctx, z.Verify, s.user, exe, s.cfg.Path())
|
||||
}
|
||||
|
||||
// startVerify launches a pass in its own session: arg is "new", "all" or a schedule id.
|
||||
func (s *Server) startVerify(arg string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exe, _ = filepath.EvalSymlinks(exe)
|
||||
dir := filepath.Join(s.cfg.DataDir(), "landing-verify")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(filepath.Join(dir, "last.log"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(exe, "-config", s.cfg.Path(), "landing-verify", arg)
|
||||
cmd.Stdout, cmd.Stderr = out, out
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
go func() { _ = cmd.Wait(); out.Close() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) landingOverview(w http.ResponseWriter, r *http.Request) {
|
||||
dir := s.cfg.DataDir()
|
||||
now := time.Now()
|
||||
type tier struct {
|
||||
storage.LandingTarget
|
||||
Cursor int64 `json:"cursor"`
|
||||
StaleCount int `json:"stale_count"`
|
||||
StaleBytes int64 `json:"stale_bytes"`
|
||||
Stale []storage.StaleFile `json:"stale"`
|
||||
}
|
||||
tiers := []tier{}
|
||||
for _, t := range storage.LandingTargets() {
|
||||
files, n, b := storage.StaleFiles(t.Src, 100, now)
|
||||
if files == nil {
|
||||
files = []storage.StaleFile{}
|
||||
}
|
||||
tiers = append(tiers, tier{LandingTarget: t, Cursor: storage.VerifyCursor(dir, t), StaleCount: n, StaleBytes: b, Stale: files})
|
||||
}
|
||||
z := s.zfsLoad()
|
||||
pols := make([]map[string]any, 0, len(z.Verify))
|
||||
for i := range z.Verify {
|
||||
p := z.Verify[i]
|
||||
expr, _ := p.CronExpr()
|
||||
m := map[string]any{"policy": p, "cron": expr, "last": storage.LastVerify(dir, p.ID)}
|
||||
if p.Enabled {
|
||||
if n := p.NextRun(now); !n.IsZero() {
|
||||
m["next"] = n
|
||||
}
|
||||
}
|
||||
pols = append(pols, m)
|
||||
}
|
||||
hist := storage.VerifyHistory(dir)
|
||||
if len(hist) > 10 {
|
||||
hist = hist[:10]
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{
|
||||
"tiers": tiers, "stale_after_sec": int(storage.StaleAfter.Seconds()),
|
||||
"running": storage.RunningVerify(dir), "history": hist, "policies": pols,
|
||||
"cron_file": storage.VerifyCronFile,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) landingVerifyNow(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil || (body.Scope != "new" && body.Scope != "all") {
|
||||
jsonErr(w, 400, "scope: new or all")
|
||||
return
|
||||
}
|
||||
if storage.RunningVerify(s.cfg.DataDir()) != nil {
|
||||
jsonErr(w, 409, "a verify pass is already running")
|
||||
return
|
||||
}
|
||||
if len(storage.LandingTargets()) == 0 {
|
||||
jsonErr(w, 409, "no landing mover is reporting on this machine, so there is nothing to verify")
|
||||
return
|
||||
}
|
||||
if err := s.startVerify(body.Scope); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("landing verify (%s) started by %s", body.Scope, clientIP(r))
|
||||
jsonOK(w, 202, map[string]string{"started": body.Scope})
|
||||
}
|
||||
|
||||
func (s *Server) landingPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
var p storage.VerifyPolicy
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
z := s.zfsLoad()
|
||||
found := false
|
||||
for i := range z.Verify {
|
||||
if p.ID != "" && z.Verify[i].ID == p.ID {
|
||||
z.Verify[i], found = p, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if p.ID == "" {
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
p.ID = id
|
||||
}
|
||||
z.Verify = append(z.Verify, p)
|
||||
}
|
||||
if err := s.zfsSave(z); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.syncVerifyCron(r.Context(), z); err != nil {
|
||||
jsonErr(w, 500, "saved, but writing "+storage.VerifyCronFile+" failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
expr, _ := p.CronExpr()
|
||||
log.Printf("landing verify schedule %q saved by %s (%q, %s)", p.Name, clientIP(r), expr, p.Scope)
|
||||
jsonOK(w, 200, p)
|
||||
}
|
||||
|
||||
func (s *Server) landingPolicyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
z := s.zfsLoad()
|
||||
kept := z.Verify[:0]
|
||||
found := false
|
||||
for _, p := range z.Verify {
|
||||
if p.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if !found {
|
||||
jsonErr(w, 404, "no such schedule")
|
||||
return
|
||||
}
|
||||
z.Verify = kept
|
||||
if err := s.zfsSave(z); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.syncVerifyCron(r.Context(), z); err != nil {
|
||||
jsonErr(w, 500, "removed, but writing "+storage.VerifyCronFile+" failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
storage.ForgetVerify(s.cfg.DataDir(), id)
|
||||
log.Printf("landing verify schedule %s removed by %s", id, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) landingPolicyRun(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
found := false
|
||||
for _, p := range s.zfsLoad().Verify {
|
||||
found = found || p.ID == id
|
||||
}
|
||||
if !found {
|
||||
jsonErr(w, 404, "no such schedule")
|
||||
return
|
||||
}
|
||||
if storage.RunningVerify(s.cfg.DataDir()) != nil {
|
||||
jsonErr(w, 409, "a verify pass is already running")
|
||||
return
|
||||
}
|
||||
if err := s.startVerify(id); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("landing verify schedule %s run by %s", id, clientIP(r))
|
||||
jsonOK(w, 202, map[string]string{"started": id})
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package api
|
||||
|
||||
// Playing a video in the Files viewer, YouTube's way: Auto (the player reads the connection),
|
||||
// any quality by hand, or the file itself. The qualities are made on the fly as HLS by
|
||||
// internal/media, on the device the Transcode device setting names — which may be on another
|
||||
// machine than the file.
|
||||
//
|
||||
// On every machine:
|
||||
// POST /api/files/play[?sudo=1] {path | url, name, device} open a play
|
||||
// GET /api/files/play/{id}/master.m3u8, /{rung}.m3u8, /{rung}/{n}.ts
|
||||
// GET /api/files/play/{id} what it is doing
|
||||
// DELETE /api/files/play/{id} (or POST …/close, for a beacon) end it
|
||||
// GET /api/files/play-devices what it can encode on
|
||||
// On the hub:
|
||||
// POST /api/play {node, path, sudo} open a play of a file on node, wherever the setting
|
||||
// says, answering which machine the play is on
|
||||
// GET|PUT /api/transcode the setting, and every machine's devices
|
||||
// GET /api/play-src/{token} a file, for a machine transcoding it, by a token
|
||||
// that names that one file; ranges pass through
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
// playRuns is how many plays may be encoding at once on one machine; a fourth ends the one
|
||||
// asked of least lately. The A380 makes about two 8K-sourced streams in real time.
|
||||
const playRuns = 3
|
||||
|
||||
type playReq struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
URL string `json:"url,omitempty"` // a file on another machine, as the hub serves it
|
||||
Name string `json:"name,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type playOut struct {
|
||||
Node string `json:"node"` // the machine the play is on: "" for the hub; filled in by the hub
|
||||
Play *media.Play `json:"play"`
|
||||
Master string `json:"master"` // the playlist, under that machine's /api
|
||||
Engine string `json:"engine"`
|
||||
Host string `json:"host"` // that machine's name, for the menu
|
||||
Note string `json:"note,omitempty"`
|
||||
Status media.PlayStatus `json:"status"`
|
||||
}
|
||||
|
||||
// playDevices: GET /api/files/play-devices — what this machine could make the player's H.264
|
||||
// on, probed as the daemon's own user (a play from another machine never runs as root).
|
||||
func (s *Server) playDevices(w http.ResponseWriter, r *http.Request) {
|
||||
caps, err := s.thumbs.capsFor(r.Context(), false, false)
|
||||
if err != nil {
|
||||
caps.Error = err.Error()
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"host": s.hostName(), "devices": media.Devices(caps)})
|
||||
}
|
||||
|
||||
// playStart: POST /api/files/play[?sudo=1] — open a play of a file here, or of a URL the hub
|
||||
// serves a file on another machine at.
|
||||
func (s *Server) playStart(w http.ResponseWriter, r *http.Request) {
|
||||
var body playReq
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
root := r.URL.Query().Get("sudo") == "1"
|
||||
if root && s.fsSudo == nil {
|
||||
jsonErr(w, 403, "root access needs passwordless sudo on the host")
|
||||
return
|
||||
}
|
||||
out, code, err := s.openPlay(r.Context(), body, root)
|
||||
if err != nil {
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) openPlay(ctx context.Context, body playReq, root bool) (playOut, int, error) {
|
||||
if root && s.fsSudo == nil && body.URL == "" {
|
||||
return playOut{}, 403, errors.New("root access needs passwordless sudo on the host")
|
||||
}
|
||||
in, name := body.Path, body.Name
|
||||
if body.URL != "" {
|
||||
u, err := url.Parse(body.URL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || body.Path != "" {
|
||||
return playOut{}, 400, errors.New("a play is of a path or of an http URL")
|
||||
}
|
||||
in, root = body.URL, false
|
||||
} else {
|
||||
p, err := files.Clean(body.Path)
|
||||
if err != nil {
|
||||
return playOut{}, 400, err
|
||||
}
|
||||
if media.Kind(p) != "video" {
|
||||
return playOut{}, 415, errors.New("not a video")
|
||||
}
|
||||
fsys := s.fsLocal
|
||||
if root {
|
||||
fsys = s.fsSudo
|
||||
}
|
||||
if _, err := fsys.Stat(ctx, p); err != nil {
|
||||
return playOut{}, 404, err
|
||||
}
|
||||
in = p
|
||||
if name == "" {
|
||||
name = filepath.Base(p)
|
||||
}
|
||||
}
|
||||
caps, err := s.thumbs.capsFor(ctx, root, false)
|
||||
if err == nil && caps.Error != "" {
|
||||
err = errors.New(caps.Error)
|
||||
}
|
||||
if err != nil {
|
||||
return playOut{}, 503, err
|
||||
}
|
||||
ictx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
src, err := media.Inspect(ictx, caps, in, root)
|
||||
cancel()
|
||||
if err != nil {
|
||||
var de *media.DecodeError
|
||||
if errors.As(err, &de) {
|
||||
return playOut{}, 422, errors.New(de.Msg)
|
||||
}
|
||||
return playOut{}, 504, err
|
||||
}
|
||||
p, err := s.plays.Open(caps, root, in, name, src, body.Device)
|
||||
if err != nil {
|
||||
return playOut{}, 422, err
|
||||
}
|
||||
log.Printf("files: play %s on %s (%d qualities, %s)", name, p.Engine(), len(p.Rungs), map[bool]string{true: "from another machine", false: "here"}[p.Remote])
|
||||
return playOut{Play: p, Master: "/api/files/play/" + p.ID + "/master.m3u8", Engine: p.Engine(), Host: s.hostName(), Status: p.Status()}, 200, nil
|
||||
}
|
||||
|
||||
// playStatus: GET /api/files/play/{id}
|
||||
func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.plays.Find(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such play")
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, p.Status())
|
||||
}
|
||||
|
||||
// playStop: DELETE /api/files/play/{id}, or POST …/close — the form a page can send on its
|
||||
// way out (navigator.sendBeacon), so a phone closing the tab releases the machine at once.
|
||||
func (s *Server) playStop(w http.ResponseWriter, r *http.Request) {
|
||||
s.plays.Close(r.PathValue("id"))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// playPlaylist: GET /api/files/play/{id}/{file} — master.m3u8, or one quality's <name>.m3u8.
|
||||
func (s *Server) playPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.plays.Find(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such play")
|
||||
return
|
||||
}
|
||||
f := r.PathValue("file")
|
||||
var b []byte
|
||||
if f == "master.m3u8" {
|
||||
b = p.Master()
|
||||
} else if name, isList := strings.CutSuffix(f, ".m3u8"); isList {
|
||||
if b, ok = p.Media(name); !ok {
|
||||
jsonErr(w, 404, "no such quality")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
jsonErr(w, 404, "not a play file")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// playPiece: GET /api/files/play/{id}/{rung}/{seg} — a piece, made first if need be.
|
||||
func (s *Server) playPiece(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.plays.Find(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such play")
|
||||
return
|
||||
}
|
||||
ns, isTS := strings.CutSuffix(r.PathValue("seg"), ".ts")
|
||||
n, err := strconv.Atoi(ns)
|
||||
if !isTS || err != nil || strconv.Itoa(n) != ns {
|
||||
jsonErr(w, 404, "not a piece")
|
||||
return
|
||||
}
|
||||
file, err := p.Segment(r.Context().Done(), r.PathValue("rung"), n)
|
||||
switch {
|
||||
case errors.Is(err, media.ErrNotFound):
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
case r.Context().Err() != nil:
|
||||
return // the player moved on
|
||||
case err != nil:
|
||||
jsonErr(w, 503, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
http.ServeFile(w, r, file)
|
||||
}
|
||||
|
||||
// ---- on the hub: where plays are made ----
|
||||
|
||||
// transcodeChoice is the setting; nil is the machine the file is on.
|
||||
func (s *Server) transcodeChoice() *store.Transcode {
|
||||
var t *store.Transcode
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
s.store.View(func(st *store.State) {
|
||||
if st.Transcode != nil {
|
||||
c := *st.Transcode
|
||||
t = &c
|
||||
}
|
||||
})
|
||||
return t
|
||||
}
|
||||
|
||||
type transcodeMachine struct {
|
||||
Node string `json:"node"` // "" is the hub
|
||||
Host string `json:"host"`
|
||||
Devices []media.Device `json:"devices"`
|
||||
Error string `json:"error,omitempty"` // the machine didn't answer
|
||||
}
|
||||
|
||||
// getTranscode: GET /api/transcode — the setting, and what every machine can encode on,
|
||||
// asked of each now. ?only=selected asks only the chosen machine, for the device's name.
|
||||
func (s *Server) getTranscode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("only") == "selected" {
|
||||
c := s.transcodeChoice()
|
||||
out := map[string]any{"selected": c}
|
||||
if c != nil {
|
||||
name := c.Device
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
var devs []media.Device
|
||||
if c.Node == "" {
|
||||
caps, _ := s.thumbs.capsFor(ctx, false, false)
|
||||
devs = media.Devices(caps)
|
||||
} else if code, b, err := s.nodes.Do(ctx, c.Node, "GET", "/api/files/play-devices", nil, nil); err == nil && code == 200 {
|
||||
var d struct{ Devices []media.Device }
|
||||
_ = json.Unmarshal(b, &d)
|
||||
devs = d.Devices
|
||||
}
|
||||
for _, d := range devs {
|
||||
if d.ID == c.Device {
|
||||
name = d.Name
|
||||
}
|
||||
}
|
||||
host := c.Node
|
||||
if host == "" {
|
||||
host = s.hostName()
|
||||
}
|
||||
out["host"], out["name"] = host, name
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
return
|
||||
}
|
||||
names := []string{""}
|
||||
if s.nodes != nil {
|
||||
names = append(names, s.nodes.Names()...)
|
||||
}
|
||||
out := make([]transcodeMachine, len(names))
|
||||
var wg sync.WaitGroup
|
||||
for i, n := range names {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
m := transcodeMachine{Node: n, Host: n}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
if n == "" {
|
||||
caps, err := s.thumbs.capsFor(ctx, false, false)
|
||||
if err != nil {
|
||||
caps.Error = err.Error()
|
||||
}
|
||||
m.Host, m.Devices = s.hostName(), media.Devices(caps)
|
||||
} else {
|
||||
code, b, err := s.nodes.Do(ctx, n, "GET", "/api/files/play-devices", nil, nil)
|
||||
var d struct {
|
||||
Host string `json:"host"`
|
||||
Devices []media.Device `json:"devices"`
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
m.Error = err.Error()
|
||||
case code == 404:
|
||||
m.Error = "runs an older Ham Lab"
|
||||
case code != 200 || json.Unmarshal(b, &d) != nil:
|
||||
m.Error = fmt.Sprintf("answered %d", code)
|
||||
default:
|
||||
m.Devices = d.Devices
|
||||
}
|
||||
}
|
||||
out[i] = m
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
jsonOK(w, 200, map[string]any{"selected": s.transcodeChoice(), "machines": out})
|
||||
}
|
||||
|
||||
// putTranscode: PUT /api/transcode {node, device}, or {} for the machine the file is on.
|
||||
func (s *Server) putTranscode(w http.ResponseWriter, r *http.Request) {
|
||||
var body *store.Transcode
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "expected {\"node\": …, \"device\": …}")
|
||||
return
|
||||
}
|
||||
if body != nil && body.Device == "" {
|
||||
body = nil
|
||||
}
|
||||
if body != nil {
|
||||
known := body.Node == ""
|
||||
if s.nodes != nil {
|
||||
for _, n := range s.nodes.Names() {
|
||||
known = known || n == body.Node
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
jsonErr(w, 400, "no such machine")
|
||||
return
|
||||
}
|
||||
if body.Device != media.CPU && !strings.HasPrefix(body.Device, "/dev/dri/") {
|
||||
jsonErr(w, 400, "a device is a render node or \"cpu\"")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.Update(func(st *store.State) { st.Transcode = body }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
s.getTranscode(w, r)
|
||||
}
|
||||
|
||||
// hubPlay: POST /api/play {node, path, sudo} — open a play of a file on node ("" the hub) on
|
||||
// the device the setting names. When that is on another machine, that machine reads the file
|
||||
// through this hub, by a token good for that one file. If it can't be reached, the file's own
|
||||
// machine makes it instead, and the answer says so.
|
||||
func (s *Server) hubPlay(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Node string `json:"node"`
|
||||
Path string `json:"path"`
|
||||
Sudo bool `json:"sudo"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
p, err := files.Clean(body.Path)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if body.Node != "" && (s.nodes == nil || !has(s.nodes.Names(), body.Node)) {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
}
|
||||
choice := s.transcodeChoice()
|
||||
var note string
|
||||
if choice != nil && choice.Node != body.Node {
|
||||
if s.nodes != nil && choice.Node != "" && !has(s.nodes.Names(), choice.Node) {
|
||||
note = "The transcode machine " + choice.Node + " is no longer managed here; made where the file is."
|
||||
} else {
|
||||
src, err := s.srcURL(choice.Node, body.Node, p, body.Sudo)
|
||||
if err == nil {
|
||||
req := playReq{URL: src, Name: filepath.Base(p), Device: choice.Device}
|
||||
out, code, err := s.playOn(r.Context(), choice.Node, req, false)
|
||||
if err == nil {
|
||||
jsonOK(w, 200, out)
|
||||
return
|
||||
}
|
||||
if code == 422 || code == 415 {
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Printf("files: play on %s failed (%v); making it where the file is", nodeLabel(choice.Node), err)
|
||||
note = "Couldn't make it on " + nodeLabel(choice.Node) + " (" + err.Error() + "), so it is made where the file is."
|
||||
}
|
||||
}
|
||||
device := ""
|
||||
if choice != nil && choice.Node == body.Node {
|
||||
device = choice.Device
|
||||
}
|
||||
out, code, err := s.playOn(r.Context(), body.Node, playReq{Path: p, Device: device}, body.Sudo)
|
||||
if err != nil {
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
out.Note = note
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func nodeLabel(n string) string {
|
||||
if n == "" {
|
||||
return "the hub"
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func has(list []string, x string) bool {
|
||||
for _, v := range list {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// playOn opens a play on a machine: here, or through that node's API.
|
||||
func (s *Server) playOn(ctx context.Context, node string, req playReq, sudo bool) (playOut, int, error) {
|
||||
if node == "" {
|
||||
out, code, err := s.openPlay(ctx, req, sudo)
|
||||
return out, code, err
|
||||
}
|
||||
b, _ := json.Marshal(req)
|
||||
path := "/api/files/play"
|
||||
if sudo {
|
||||
path += "?sudo=1"
|
||||
}
|
||||
code, resp, err := s.nodes.Do(ctx, node, "POST", path, b, http.Header{"Content-Type": []string{"application/json"}})
|
||||
if err != nil {
|
||||
return playOut{}, 502, err
|
||||
}
|
||||
if code != 200 {
|
||||
var e struct{ Error string }
|
||||
_ = json.Unmarshal(resp, &e)
|
||||
if e.Error == "" {
|
||||
e.Error = fmt.Sprintf("%s answered %d", node, code)
|
||||
}
|
||||
return playOut{}, code, errors.New(e.Error)
|
||||
}
|
||||
var out playOut
|
||||
if err := json.Unmarshal(resp, &out); err != nil {
|
||||
return playOut{}, 502, err
|
||||
}
|
||||
out.Node = node
|
||||
return out, 200, nil
|
||||
}
|
||||
|
||||
// ---- the file, for the machine transcoding it ----
|
||||
|
||||
type srcGrant struct {
|
||||
node, path string
|
||||
sudo bool
|
||||
until time.Time
|
||||
}
|
||||
|
||||
type srcGrants struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*srcGrant
|
||||
}
|
||||
|
||||
const srcLife = 12 * time.Hour // from its last use: a film paused overnight still plays
|
||||
|
||||
var grants = &srcGrants{m: map[string]*srcGrant{}}
|
||||
|
||||
func (g *srcGrants) mint(node, path string, sudo bool) string {
|
||||
b := make([]byte, 24)
|
||||
_, _ = rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
now := time.Now()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for k, v := range g.m {
|
||||
if now.After(v.until) {
|
||||
delete(g.m, k)
|
||||
}
|
||||
}
|
||||
g.m[tok] = &srcGrant{node: node, path: path, sudo: sudo, until: now.Add(srcLife)}
|
||||
return tok
|
||||
}
|
||||
|
||||
func (g *srcGrants) use(tok string) (srcGrant, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
v, ok := g.m[tok]
|
||||
if !ok || time.Now().After(v.until) {
|
||||
return srcGrant{}, false
|
||||
}
|
||||
v.until = time.Now().Add(srcLife)
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// srcURL is where the machine `to` can read a file on machine `from` through this hub: this
|
||||
// hub's own address as `to` reaches it, and a token for that one file.
|
||||
func (s *Server) srcURL(to, from, path string, sudo bool) (string, error) {
|
||||
host, port, err := net.SplitHostPort(s.cfg.Listen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if host == "" || ip == nil || ip.IsUnspecified() || ip.IsLoopback() {
|
||||
// Listening everywhere: the address this machine reaches `to` from is one it answers on.
|
||||
target := "127.0.0.1:1"
|
||||
if to != "" {
|
||||
if u, err := url.Parse(s.nodes.URL(to)); err == nil && u.Host != "" {
|
||||
target = u.Host
|
||||
}
|
||||
}
|
||||
c, err := net.Dial("udp", target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host = c.LocalAddr().(*net.UDPAddr).IP.String()
|
||||
c.Close()
|
||||
}
|
||||
scheme := "http"
|
||||
if s.fingerprint != "" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + net.JoinHostPort(host, port) + "/api/play-src/" + grants.mint(from, path, sudo), nil
|
||||
}
|
||||
|
||||
// playSrc: GET /api/play-src/{token} — the one file a token names, ranges and all, read from
|
||||
// its machine. The token is the only key: it is minted for a machine transcoding that file.
|
||||
func (s *Server) playSrc(w http.ResponseWriter, r *http.Request) {
|
||||
g, ok := grants.use(r.PathValue("token"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such file")
|
||||
return
|
||||
}
|
||||
q := url.Values{"path": {g.path}}
|
||||
if g.sudo {
|
||||
q.Set("sudo", "1")
|
||||
}
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.RawQuery = q.Encode()
|
||||
r2.Header.Del("Cookie")
|
||||
if g.node == "" {
|
||||
r2.URL.Path = "/api/files/read"
|
||||
s.filesRead(w, r2)
|
||||
return
|
||||
}
|
||||
p, ok := s.nodes.Proxy(g.node)
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
}
|
||||
r2.URL.Path = "/api/nodes/" + g.node + "/files/read"
|
||||
p.ServeHTTP(w, r2)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/files"
|
||||
)
|
||||
|
||||
// The file a machine transcodes from another is read through the hub by a token naming that
|
||||
// one file: ranges pass through, the query can't point it anywhere else, and an unknown token
|
||||
// is nothing.
|
||||
func TestPlaySrc(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "film.mp4")
|
||||
if err := os.WriteFile(p, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "other.mp4"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{fsLocal: files.Local{}, cfg: &config.Config{Listen: "10.0.0.234:8100"}}
|
||||
u, err := s.srcURL("", "", p, false)
|
||||
if err != nil || !strings.HasPrefix(u, "http://10.0.0.234:8100/api/play-src/") {
|
||||
t.Fatalf("url %q %v", u, err)
|
||||
}
|
||||
tok := strings.TrimPrefix(u, "http://10.0.0.234:8100/api/play-src/")
|
||||
get := func(tok, query, rng string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", "/api/play-src/"+tok+query, nil)
|
||||
r.SetPathValue("token", tok)
|
||||
if rng != "" {
|
||||
r.Header.Set("Range", rng)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.playSrc(w, r)
|
||||
return w
|
||||
}
|
||||
if w := get(tok, "", "bytes=2-4"); w.Code != 206 || w.Body.String() != "234" {
|
||||
t.Fatalf("range: %d %q", w.Code, w.Body)
|
||||
}
|
||||
if w := get(tok, "?path="+filepath.Join(dir, "other.mp4"), ""); w.Body.String() != "0123456789" {
|
||||
t.Fatalf("the query moved it: %q", w.Body)
|
||||
}
|
||||
if w := get("nope", "", ""); w.Code != 404 {
|
||||
t.Fatalf("unknown token: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A preview made on another machine is kept by the file's own: only while the file is still
|
||||
// the one it was made of, and a failure is kept as a failure.
|
||||
func TestThumbPut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "film.mp4")
|
||||
if err := os.WriteFile(p, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, _ := os.Stat(p)
|
||||
s := &Server{fsLocal: files.Local{}, thumbs: newThumbSvc(dir, "")}
|
||||
v := fmt.Sprintf("%d-%d", fi.ModTime().UnixMilli(), fi.Size())
|
||||
put := func(v string, body string, hdr map[string]string) int {
|
||||
r := httptest.NewRequest("PUT", "/api/files/thumb?path="+url.QueryEscape(p)+"&v="+v, strings.NewReader(body))
|
||||
for k, x := range hdr {
|
||||
r.Header.Set(k, x)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.thumbPut(w, r)
|
||||
return w.Code
|
||||
}
|
||||
get := func() *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", "/api/files/thumb?cached=1&path="+url.QueryEscape(p)+"&v="+v, nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.filesThumb(w, r)
|
||||
return w
|
||||
}
|
||||
if w := get(); w.Code != 404 {
|
||||
t.Fatalf("cached=1 before anything: %d", w.Code)
|
||||
}
|
||||
if c := put("1-10", "jpeg", nil); c != 409 {
|
||||
t.Fatalf("a changed file: %d", c)
|
||||
}
|
||||
if c := put(v, "jpeg-bytes", map[string]string{"X-Hamlab-Thumb": "grid vaapi:/dev/dri/renderD129"}); c != 204 {
|
||||
t.Fatalf("put: %d", c)
|
||||
}
|
||||
if w := get(); w.Code != 200 || w.Body.String() != "jpeg-bytes" || !strings.Contains(w.Header().Get("X-Hamlab-Thumb"), "grid") {
|
||||
t.Fatalf("kept: %d %q %q", w.Code, w.Body, w.Header().Get("X-Hamlab-Thumb"))
|
||||
}
|
||||
}
|
||||
+131
-59
@@ -34,6 +34,7 @@ import (
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/hardware"
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/network"
|
||||
"hamlab/internal/nodes"
|
||||
@@ -74,9 +75,11 @@ type Server struct {
|
||||
net *network.Watcher
|
||||
update updateState
|
||||
events eventLog
|
||||
kvms kvmProxies
|
||||
fsLocal files.FS
|
||||
fsSudo files.FS // nil without passwordless sudo
|
||||
thumbs *thumbSvc
|
||||
plays *media.Plays
|
||||
self string // this binary, for helpers run as tasks
|
||||
nodes *nodes.Manager
|
||||
fingerprint string
|
||||
version string
|
||||
@@ -96,7 +99,10 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
if err != nil {
|
||||
self = "hamlab"
|
||||
}
|
||||
s.self = self
|
||||
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
|
||||
s.thumbs = newThumbSvc(cfg.DataDir(), self)
|
||||
s.plays = media.NewPlays(cfg.DataDir(), self, playRuns)
|
||||
s.hostName() // read it once now so the first request doesn't have to
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
s.user = u.Username
|
||||
@@ -217,20 +223,8 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("PUT /api/docker/schedules", s.auth(s.dsPut))
|
||||
m.HandleFunc("DELETE /api/docker/schedules/{id}", s.auth(s.dsDelete))
|
||||
m.HandleFunc("POST /api/docker/schedules/{id}/run", s.auth(s.dsRun))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/updates", s.auth(s.machineUpdates))
|
||||
// The same, asked of a node through this hub: the hub answers for the stacks whose files
|
||||
// it holds (deployed to the node over tcp) and passes everything else to the node.
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks", s.auth(s.stackCreate))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/homes", s.auth(s.stackHomes))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/delete", s.auth(s.stackDelete))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackFile))
|
||||
m.HandleFunc("PUT /api/nodes/{node}/docker/stacks/{name}", s.auth(s.stackSave))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
||||
m.HandleFunc("POST /api/nodes/{node}/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}", s.auth(s.taskOnHub(s.getTask)))
|
||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
||||
// Asked of a node through this hub, every one of these is the node's to answer: the
|
||||
// generic /api/nodes/{name}/{rest...} proxy above carries them there unchanged.
|
||||
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
|
||||
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
|
||||
m.HandleFunc("GET /api/docker/containers/{id}/logs/tail", s.auth(s.dockerTail))
|
||||
@@ -277,8 +271,27 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("POST /api/files/mkdir", s.auth(s.filesMkdir))
|
||||
m.HandleFunc("POST /api/files/touch", s.auth(s.filesTouch))
|
||||
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
|
||||
m.HandleFunc("POST /api/files/move", s.auth(s.filesMove))
|
||||
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
||||
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
||||
m.HandleFunc("GET /api/files/thumb", s.auth(s.filesThumb))
|
||||
m.HandleFunc("PUT /api/files/thumb", s.auth(s.thumbPut))
|
||||
m.HandleFunc("POST /api/files/thumb-from", s.auth(s.thumbFrom))
|
||||
m.HandleFunc("GET /api/nodes/{name}/files/thumb", s.auth(s.hubThumb))
|
||||
m.HandleFunc("GET /api/files/thumbs", s.auth(s.thumbsStatus))
|
||||
m.HandleFunc("POST /api/files/thumbs/install", s.auth(s.thumbsInstall))
|
||||
m.HandleFunc("POST /api/files/thumbs/gpu-access", s.auth(s.thumbsGPUAccess))
|
||||
m.HandleFunc("GET /api/files/play-devices", s.auth(s.playDevices))
|
||||
m.HandleFunc("POST /api/files/play", s.auth(s.playStart))
|
||||
m.HandleFunc("GET /api/files/play/{id}", s.auth(s.playStatus))
|
||||
m.HandleFunc("DELETE /api/files/play/{id}", s.auth(s.playStop))
|
||||
m.HandleFunc("POST /api/files/play/{id}/close", s.auth(s.playStop))
|
||||
m.HandleFunc("GET /api/files/play/{id}/{file}", s.auth(s.playPlaylist))
|
||||
m.HandleFunc("GET /api/files/play/{id}/{rung}/{seg}", s.auth(s.playPiece))
|
||||
m.HandleFunc("POST /api/play", s.auth(s.hubPlay))
|
||||
m.HandleFunc("GET /api/transcode", s.auth(s.getTranscode))
|
||||
m.HandleFunc("PUT /api/transcode", s.auth(s.putTranscode))
|
||||
m.HandleFunc("GET /api/play-src/{token}", s.playSrc) // the token is the key
|
||||
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))
|
||||
@@ -295,10 +308,16 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
||||
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
||||
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
|
||||
m.HandleFunc("GET /api/zfs/arc", s.auth(s.zfsARC))
|
||||
m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut))
|
||||
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete))
|
||||
m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow))
|
||||
m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete))
|
||||
m.HandleFunc("GET /api/zfs/landing", s.auth(s.landingOverview))
|
||||
m.HandleFunc("POST /api/zfs/landing/verify", s.auth(s.landingVerifyNow))
|
||||
m.HandleFunc("PUT /api/zfs/landing/schedules", s.auth(s.landingPolicyPut))
|
||||
m.HandleFunc("DELETE /api/zfs/landing/schedules/{id}", s.auth(s.landingPolicyDelete))
|
||||
m.HandleFunc("POST /api/zfs/landing/schedules/{id}/run", s.auth(s.landingPolicyRun))
|
||||
m.HandleFunc("GET /api/space", s.auth(s.spaceOverview))
|
||||
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
|
||||
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
|
||||
@@ -319,6 +338,7 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
||||
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
||||
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
||||
m.HandleFunc("POST /api/sessions/revoke-idle", s.auth(s.revokeIdleSessions))
|
||||
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
|
||||
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
|
||||
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
|
||||
@@ -330,18 +350,7 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
||||
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
||||
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
||||
m.HandleFunc("GET /api/kvms", s.auth(s.listKVMs))
|
||||
m.HandleFunc("POST /api/kvms", s.auth(s.addKVM))
|
||||
m.HandleFunc("DELETE /api/kvms/{id}", s.auth(s.removeKVM))
|
||||
m.HandleFunc("/kvm/{id}/{rest...}", s.auth(s.serveKVM))
|
||||
m.HandleFunc("/kvm/{id}", s.auth(s.serveKVM))
|
||||
// A path Ham Lab does not serve may still belong to a KVM's page, which asks for its
|
||||
// own assets by absolute path. Ham Lab's own routes matched first, so this can only
|
||||
// catch what would otherwise have been a 404.
|
||||
m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.kvmFallback(w, r) {
|
||||
return
|
||||
}
|
||||
jsonErr(w, 404, "not found")
|
||||
}))
|
||||
m.HandleFunc("/", s.static)
|
||||
@@ -365,16 +374,10 @@ func sensorPath(p string) bool {
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
// The console must never be framed. Two exceptions: an embedded widget exists to be
|
||||
// (a page that can only read sensors, and only with a read-only token in its URL),
|
||||
// and a proxied KVM is framed by the console itself — 'none' forbids that even from
|
||||
// the same origin, so those paths say 'self' instead of opening up.
|
||||
switch {
|
||||
case r.URL.Path == "/embed/widget":
|
||||
case strings.HasPrefix(r.URL.Path, kvmPrefix):
|
||||
h.Set("X-Frame-Options", "SAMEORIGIN")
|
||||
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
|
||||
default:
|
||||
// The console must never be framed. The one exception is an embedded widget, which
|
||||
// exists to be (a page that can only read sensors, and only with a read-only token in
|
||||
// its URL).
|
||||
if r.URL.Path != "/embed/widget" {
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
}
|
||||
@@ -926,11 +929,7 @@ func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
|
||||
node := r.PathValue("node") // routes that also carry a {name} of their own call the machine {node}
|
||||
if node == "" {
|
||||
node = r.PathValue("name")
|
||||
}
|
||||
p, ok := s.nodes.Proxy(node)
|
||||
p, ok := s.nodes.Proxy(r.PathValue("name"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
@@ -1894,21 +1893,15 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "not a regular file (device, pipe or socket)")
|
||||
return
|
||||
}
|
||||
if in.Broken {
|
||||
jsonErr(w, 404, "a link to nothing: "+in.Target)
|
||||
return
|
||||
}
|
||||
mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64)
|
||||
if mx > 0 && in.Size > mx {
|
||||
jsonErr(w, 413, "file too large")
|
||||
return
|
||||
}
|
||||
f, err := fsys.Open(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
var body io.Reader = f
|
||||
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
|
||||
body = io.LimitReader(f, mx+1)
|
||||
}
|
||||
name := filepath.Base(p)
|
||||
ct := mime.TypeByExtension(filepath.Ext(name))
|
||||
if ct == "" {
|
||||
@@ -1927,7 +1920,28 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
|
||||
}
|
||||
if in.Size > 0 && mx == 0 {
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10))
|
||||
// A real file of known size goes through net/http, which speaks Range: a video player
|
||||
// can seek and a download can resume. /proc files say size 0 and take the plain path.
|
||||
rs := files.NewSeeker(r.Context(), fsys, p, in.Size)
|
||||
defer rs.Close()
|
||||
if _, local := fsys.(files.Local); local { // cheap here, and turns "can't open" into a status rather than a cut-off body
|
||||
if err := rs.Check(); err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.ServeContent(w, r, name, time.UnixMilli(in.Mtime), rs)
|
||||
return
|
||||
}
|
||||
f, err := fsys.Open(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
var body io.Reader = f
|
||||
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
|
||||
body = io.LimitReader(f, mx+1)
|
||||
}
|
||||
_, _ = io.Copy(w, body)
|
||||
}
|
||||
@@ -1941,7 +1955,14 @@ func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := fsys.Write(r.Context(), p, io.LimitReader(r.Body, 8<<20)); err != nil {
|
||||
// Whole in memory first: the write truncates the file, so a body over the cap must be
|
||||
// refused before that, not cut short after it.
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 8<<20))
|
||||
if err != nil {
|
||||
jsonErr(w, 413, "too large to save here (8 MiB at most)")
|
||||
return
|
||||
}
|
||||
if err := fsys.Write(r.Context(), p, bytes.NewReader(body)); err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -2028,6 +2049,63 @@ func (s *Server) filesRename(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// filesMove: POST {path, to} — to is the full destination path. A rename when the two
|
||||
// ends share a filesystem (204); otherwise the copy-and-remove runs as a task (202 with the
|
||||
// task), since a folder between datasets can take as long as it takes.
|
||||
func (s *Server) filesMove(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct{ Path, To string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
from, err := files.Clean(body.Path)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
to, err := files.Clean(body.To)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if from == to {
|
||||
jsonErr(w, 400, "it is already there")
|
||||
return
|
||||
}
|
||||
if files.Inside(from, to) {
|
||||
jsonErr(w, 400, "a folder can't be moved into itself")
|
||||
return
|
||||
}
|
||||
// The kernel answers EXDEV for two paths on different filesystems before it looks for
|
||||
// the source, so a missing file would otherwise become a task that fails on arrival.
|
||||
if _, err := fsys.Stat(r.Context(), from); err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
err = fsys.Rename(r.Context(), from, to)
|
||||
if err == nil {
|
||||
log.Printf("files: move %s -> %s (from %s)", from, to, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
if !files.IsCrossDevice(err) {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
cmd := files.MoveCmd(context.Background(), fsys, s.self, from, to)
|
||||
t, err := s.tasks.Start("files:move", "move "+filepath.Base(from)+" to "+filepath.Dir(to), cmd)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("files: move %s -> %s across filesystems as task %s (from %s)", from, to, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
func (s *Server) filesDelete(w http.ResponseWriter, r *http.Request) {
|
||||
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
|
||||
rec, _ := body["recursive"].(bool)
|
||||
@@ -2558,12 +2636,6 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// A path with no file of ours behind it may belong to a KVM's page, which asks for its
|
||||
// own assets by absolute path. Checked here rather than in the router because this
|
||||
// handler is the catch-all — and gated on a session, since this handler is public.
|
||||
if s.signedIn(r) && s.kvmFallback(w, r) {
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFileFS(w, r, s.ui, "index.html")
|
||||
|
||||
+121
-285
@@ -18,11 +18,10 @@ package api
|
||||
// machine would become unfindable at once. A remembered mount resolves with no container to
|
||||
// run in, which is right: compose runs on the host against the host's copy of the file.
|
||||
//
|
||||
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
|
||||
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
|
||||
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
||||
// in what the node couldn't (hubStacks): it resolves the files on itself and runs compose
|
||||
// here with DOCKER_HOST pointed at the node.
|
||||
// A stack is one machine's affair: its files and the Docker that runs it are on the same
|
||||
// box, and that box's Ham Lab is the only thing that touches either. Asked about a node's
|
||||
// stacks, a hub passes the request to the node's own Ham Lab and relays the answer; it holds
|
||||
// no files for anyone else and never points compose at another machine's Docker.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -35,7 +34,6 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -60,8 +58,6 @@ type stack struct {
|
||||
Files []string `json:"files"` // every file it was deployed from, in order: -f a -f b
|
||||
HostFiles []string `json:"host_files"` // the same, on the machine that holds them
|
||||
Via string `json:"via"` // container compose runs inside; "" = on the host
|
||||
Remote string `json:"remote"` // DOCKER_HOST compose is pointed at; "" = this machine's Docker
|
||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
||||
Editable bool `json:"editable"`
|
||||
Note string `json:"note,omitempty"`
|
||||
HasEnv bool `json:"has_env"`
|
||||
@@ -282,7 +278,7 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
st.resolve(maps)
|
||||
stacks = append(stacks, *st)
|
||||
}
|
||||
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks, true), stacks)...)
|
||||
stacks = append(stacks, scanHomes(ctx, s.homesOf(stacks), stacks)...)
|
||||
sort.Slice(stacks, func(i, j int) bool { return stacks[i].Name < stacks[j].Name })
|
||||
return stacks, nil
|
||||
}
|
||||
@@ -290,19 +286,23 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
||||
// ---- where stacks live ----
|
||||
|
||||
// A home is a directory of stacks, one folder each: where new ones are made, and where ones
|
||||
// that aren't deployed (never were, or were taken down) are still found. Homes aren't
|
||||
// configured; they are wherever this machine's stacks already are. With none at all, ~/stacks.
|
||||
// that aren't deployed (never were, or were taken down) are still found. The default is
|
||||
// Ham Lab's own: <data dir>/stacks, which is where everything this console manages lives.
|
||||
// Any folder this machine already keeps stacks in — two or more deployed from it — is
|
||||
// offered as well, so what was deployed before Ham Lab arrived stays editable and gains
|
||||
// neighbours where it is, but it is never the default.
|
||||
type stackHome struct {
|
||||
Dir string `json:"dir"` // on the machine that holds the files
|
||||
Inner string `json:"inner"` // the same directory as compose sees it (inside Via); Dir otherwise
|
||||
Via string `json:"via"` // container compose runs inside
|
||||
Remote string `json:"remote"` // DOCKER_HOST, when the containers run on another machine
|
||||
HeldBy string `json:"held_by"` // the machine holding the files, when it isn't the one running them
|
||||
Dir string `json:"dir"` // on this machine
|
||||
Inner string `json:"inner"` // the same directory as compose sees it (inside Via); Dir otherwise
|
||||
Via string `json:"via"` // container compose runs inside
|
||||
Stacks int `json:"stacks"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
// stacksDir is the home Ham Lab owns.
|
||||
func (s *Server) stacksDir() string { return filepath.Join(s.cfg.DataDir(), "stacks") }
|
||||
|
||||
func (s *Server) homesOf(stacks []stack) []stackHome {
|
||||
type tally struct {
|
||||
home stackHome
|
||||
deployed int
|
||||
@@ -318,11 +318,10 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
if strings.Count(dir, "/") < 2 { // "/" or "/tmp": not somewhere to make folders in
|
||||
continue
|
||||
}
|
||||
key := dir + "|" + st.Remote
|
||||
t := by[key]
|
||||
t := by[dir]
|
||||
if t == nil {
|
||||
t = &tally{home: stackHome{Dir: dir, Remote: st.Remote, HeldBy: st.HeldBy}, byVia: map[string]int{}, inner: map[string]string{}}
|
||||
by[key] = t
|
||||
t = &tally{home: stackHome{Dir: dir}, byVia: map[string]int{}, inner: map[string]string{}}
|
||||
by[dir] = t
|
||||
}
|
||||
t.home.Stacks++
|
||||
if len(st.Containers) > 0 {
|
||||
@@ -335,11 +334,13 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
}
|
||||
}
|
||||
// A folder is a home when stacks are kept there as a habit: two or more deployed from it.
|
||||
// One stack on its own (Dockhand's own compose file in ~/apps/dockhand) says nothing about
|
||||
// where the next should go, and would have every compose file near it listed as a stack.
|
||||
// New stacks are deployed the way most of their neighbours were.
|
||||
var homes, lone []stackHome
|
||||
for _, t := range by {
|
||||
// One stack on its own (an orchestrator's own compose file in its app folder) says nothing
|
||||
// about where the next should go, and would have every compose file near it listed as a
|
||||
// stack — unless it is already in Ham Lab's folder. New stacks are deployed the way most
|
||||
// of their neighbours were.
|
||||
own := s.stacksDir()
|
||||
var homes []stackHome
|
||||
for dir, t := range by {
|
||||
best, n := "", -1
|
||||
for via, c := range t.byVia {
|
||||
if c > n || (c == n && via > best) {
|
||||
@@ -355,29 +356,23 @@ func (s *Server) homesOf(stacks []stack, withFallback bool) []stackHome {
|
||||
if t.home.Inner == "" {
|
||||
t.home.Inner = t.home.Dir
|
||||
}
|
||||
if t.deployed >= 2 {
|
||||
if t.deployed >= 2 || dir == own {
|
||||
homes = append(homes, t.home)
|
||||
} else {
|
||||
lone = append(lone, t.home)
|
||||
}
|
||||
}
|
||||
if len(homes) == 0 {
|
||||
homes = lone
|
||||
}
|
||||
sort.Slice(homes, func(i, j int) bool {
|
||||
if (homes[i].Dir == own) != (homes[j].Dir == own) {
|
||||
return homes[i].Dir == own
|
||||
}
|
||||
if homes[i].Stacks != homes[j].Stacks {
|
||||
return homes[i].Stacks > homes[j].Stacks
|
||||
}
|
||||
return homes[i].Dir < homes[j].Dir
|
||||
})
|
||||
if len(homes) == 0 && withFallback {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
homes = append(homes, stackHome{Dir: filepath.Join(home, "stacks"), Inner: filepath.Join(home, "stacks")})
|
||||
}
|
||||
}
|
||||
if len(homes) > 0 {
|
||||
homes[0].Default = true
|
||||
if len(homes) == 0 || homes[0].Dir != own {
|
||||
homes = append([]stackHome{{Dir: own, Inner: own}}, homes...)
|
||||
}
|
||||
homes[0].Default = true
|
||||
return homes
|
||||
}
|
||||
|
||||
@@ -394,6 +389,9 @@ func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
||||
}
|
||||
var out []stack
|
||||
for _, h := range homes {
|
||||
if _, err := os.Stat(h.Dir); errors.Is(err, os.ErrNotExist) {
|
||||
continue // Ham Lab's own home before its first stack: nothing to find, and no sudo to spend finding it
|
||||
}
|
||||
args := []string{h.Dir, "-mindepth", "2", "-maxdepth", "2", "-type", "f", "("}
|
||||
for i, n := range composeNames {
|
||||
if i > 0 {
|
||||
@@ -417,7 +415,7 @@ func scanHomes(ctx context.Context, homes []stackHome, known []stack) []stack {
|
||||
continue
|
||||
}
|
||||
have[name], have[dir] = true, true
|
||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, Remote: h.Remote, HeldBy: h.HeldBy, Editable: true, Note: "not deployed"}
|
||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{f}, HostFile: f, Via: h.Via, Editable: true, Note: "not deployed"}
|
||||
st.Files = []string{filepath.Join(h.Inner, filepath.Base(dir), filepath.Base(f))}
|
||||
st.File = st.Files[0]
|
||||
st.HasEnv = readable(st.envPath())
|
||||
@@ -436,114 +434,23 @@ func contains(list []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// hubStacks is a node's stack list with the hub's help: what the node could not find is
|
||||
// looked for here, and if it is here — and the node's Docker answers over tcp, which is how
|
||||
// it was deployed in the first place — the stack becomes editable from this hub.
|
||||
func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
|
||||
code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks", nil, nil)
|
||||
// stackFor finds the stack a request means.
|
||||
func (s *Server) stackFor(ctx context.Context, r *http.Request) (stack, error) {
|
||||
all, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res struct{ Stacks []stack }
|
||||
if code != 200 || json.Unmarshal(b, &res) != nil {
|
||||
return nil, fmt.Errorf("%s answered %d", node, code)
|
||||
}
|
||||
remote, maps := "", []mountMap(nil)
|
||||
for i := range res.Stacks {
|
||||
st := &res.Stacks[i]
|
||||
if st.Editable || len(st.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
if remote == "" {
|
||||
remote, maps = s.nodeDocker(ctx, node), mountsFor(ctx, s.store)
|
||||
}
|
||||
if remote == "-" {
|
||||
continue
|
||||
}
|
||||
note := st.Note
|
||||
if st.resolve(maps); !st.Editable {
|
||||
st.Note = note
|
||||
continue
|
||||
}
|
||||
st.Remote, st.HeldBy = remote, s.hostName()
|
||||
}
|
||||
var held []stack
|
||||
for _, st := range res.Stacks {
|
||||
if st.HeldBy != "" {
|
||||
held = append(held, st)
|
||||
}
|
||||
}
|
||||
res.Stacks = append(res.Stacks, scanHomes(ctx, s.homesOf(held, false), res.Stacks)...)
|
||||
sort.Slice(res.Stacks, func(i, j int) bool { return res.Stacks[i].Name < res.Stacks[j].Name })
|
||||
return res.Stacks, nil
|
||||
}
|
||||
|
||||
var (
|
||||
nodeDockerMu sync.Mutex
|
||||
nodeDockerOK = map[string]string{} // node → "tcp://host:2375", or "-" when it doesn't answer
|
||||
)
|
||||
|
||||
// nodeDocker is the node's Docker endpoint as Dockhand-style deployments use it, if it answers.
|
||||
func (s *Server) nodeDocker(ctx context.Context, node string) string {
|
||||
nodeDockerMu.Lock()
|
||||
defer nodeDockerMu.Unlock()
|
||||
if v, ok := nodeDockerOK[node]; ok && v != "-" {
|
||||
return v
|
||||
}
|
||||
ep := "-"
|
||||
for _, n := range s.nodes.List() {
|
||||
if n.Name != node {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(n.URL); err == nil && u.Hostname() != "" {
|
||||
cand := "tcp://" + u.Hostname() + ":2375"
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
if exec.CommandContext(c, dockerTool(), "-H", cand, "version", "--format", "{{.Server.Version}}").Run() == nil {
|
||||
ep = cand
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
nodeDockerOK[node] = ep
|
||||
return ep
|
||||
}
|
||||
|
||||
// stackFor finds the stack a request means: on this machine, or (on a hub route) one of a
|
||||
// node's stacks that this hub holds the files for. handled is false when the request should
|
||||
// simply be passed on to the node.
|
||||
func (s *Server) stackFor(ctx context.Context, r *http.Request) (st stack, handled bool, err error) {
|
||||
var all []stack
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
all, err = s.hubStacks(ctx, node)
|
||||
} else {
|
||||
all, err = s.findStacks(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return stack{}, true, err
|
||||
return stack{}, err
|
||||
}
|
||||
for _, x := range all {
|
||||
if x.Name == r.PathValue("name") {
|
||||
if r.PathValue("node") != "" && x.Remote == "" {
|
||||
return x, false, nil // the node can serve this one itself
|
||||
}
|
||||
return x, true, nil
|
||||
return x, nil
|
||||
}
|
||||
}
|
||||
return stack{}, true, errors.New("no such stack")
|
||||
return stack{}, errors.New("no such stack")
|
||||
}
|
||||
|
||||
func (s *Server) stacksList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
stacks, err := s.hubStacks(ctx, node)
|
||||
if err != nil {
|
||||
s.proxyNode(w, r) // let the node's own answer (or failure) through
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"stacks": stacks})
|
||||
return
|
||||
}
|
||||
if dockerTool() == "" { // no Docker is an empty answer, not a failure
|
||||
jsonOK(w, 200, map[string]any{"stacks": []stack{}})
|
||||
return
|
||||
@@ -587,11 +494,7 @@ func target(r *http.Request, st stack) (host string, index int, ok bool) {
|
||||
func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -610,8 +513,8 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, 200, map[string]any{"stack": st, "text": string(b), "sum": sumOf(b), "index": i})
|
||||
}
|
||||
|
||||
// composeCmd is `docker compose …` for this stack, run where its files make sense and
|
||||
// pointed at the Docker that runs its containers. files are given as compose will see them.
|
||||
// composeCmd is `docker compose …` for this stack, run where its files make sense. files are
|
||||
// given as compose will see them.
|
||||
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
|
||||
return compose.Cmd(ctx, specOf(st, files), args...)
|
||||
}
|
||||
@@ -619,7 +522,7 @@ func composeCmd(ctx context.Context, st stack, files []string, args ...string) *
|
||||
// specOf is a stack as the compose runner needs it — and as a scheduled update stores it,
|
||||
// so the cron job and the console invoke compose identically.
|
||||
func specOf(st stack, files []string) compose.Spec {
|
||||
return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Remote: st.Remote, Tool: dockerTool()}
|
||||
return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Tool: dockerTool()}
|
||||
}
|
||||
|
||||
// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate.
|
||||
@@ -647,11 +550,7 @@ func (s *Server) writeAsOwner(ctx context.Context, path, text string) error {
|
||||
func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -723,11 +622,7 @@ func (s *Server) stackSave(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) stackDiff(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -777,11 +672,7 @@ func maskEnv(text string) string {
|
||||
func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -794,17 +685,10 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
flags := priv.Quote(append([]string{"-p", st.Name}, fl...))
|
||||
base := func(args string) string {
|
||||
if st.Via != "" {
|
||||
env := ""
|
||||
if st.Remote != "" {
|
||||
env = "-e " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
||||
}
|
||||
return fmt.Sprintf("%s exec %s%s docker compose %s %s", dockerTool(), env, priv.Quote([]string{st.Via}), flags, args)
|
||||
// The CLI inside the orchestrator's container is its own `docker`, whatever this host runs.
|
||||
return fmt.Sprintf("%s exec %s docker compose %s %s", dockerTool(), priv.Quote([]string{st.Via}), flags, args)
|
||||
}
|
||||
env := ""
|
||||
if st.Remote != "" { // through env(1): a quoted NAME=value in front of a command is a command name to sh, not an assignment
|
||||
env = "env " + priv.Quote([]string{"DOCKER_HOST=" + st.Remote}) + " "
|
||||
}
|
||||
return fmt.Sprintf("cd %s && %s%s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), env, dockerTool(), flags, args)
|
||||
return fmt.Sprintf("cd %s && %s compose %s %s", priv.Quote([]string{filepath.Dir(files[0])}), dockerTool(), flags, args)
|
||||
}
|
||||
// After a deploy that worked, keep a copy of exactly what was deployed: the next diff is against it.
|
||||
remember := ""
|
||||
@@ -860,7 +744,6 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
_ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run()
|
||||
})
|
||||
}
|
||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
||||
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))
|
||||
}
|
||||
@@ -871,6 +754,7 @@ type imageCheck struct {
|
||||
Image string `json:"image"`
|
||||
State string `json:"state"` // current | update | unknown
|
||||
Detail string `json:"detail,omitempty"`
|
||||
remote string // the digest the registry named, so a later pull can be recognised without asking again
|
||||
}
|
||||
|
||||
type updAnswer struct {
|
||||
@@ -883,24 +767,50 @@ var (
|
||||
updCache = map[string]updAnswer{}
|
||||
)
|
||||
|
||||
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
||||
func stackUpdatesForget(name string) { updMu.Lock(); delete(updCache, "stack:"+name); updMu.Unlock() }
|
||||
|
||||
// checkImages asks the registry whether each image is still the one we hold. remote is a
|
||||
// DOCKER_HOST for a machine whose images live elsewhere, "" for this one. Each image is one
|
||||
// registry request, which Docker Hub counts — so this is only ever called when asked for.
|
||||
func checkImages(ctx context.Context, images []string, remote string) []imageCheck {
|
||||
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
||||
if remote != "" {
|
||||
args = append([]string{"-H", remote}, args...)
|
||||
}
|
||||
return dockerRun(ctx, args...)
|
||||
// localDigests is what this machine holds for an image: the registry digests it was pulled as.
|
||||
func localDigests(ctx context.Context, img string) []string {
|
||||
out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||
var have []string
|
||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil {
|
||||
return nil
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
func holdsDigest(have []string, digest string) bool {
|
||||
for _, h := range have {
|
||||
if strings.HasSuffix(h, "@"+digest) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// settled is a cached answer brought up to date with what is here now. The registry's side
|
||||
// is kept for six hours, but this machine's side changes the moment an update pulls the
|
||||
// newer image — so an image marked behind is looked at again locally, which costs no
|
||||
// registry request, and is current once it holds the digest the registry named.
|
||||
func settled(ctx context.Context, res []imageCheck) []imageCheck {
|
||||
out := make([]imageCheck, len(res))
|
||||
copy(out, res)
|
||||
for i, ic := range out {
|
||||
if ic.State == "update" && ic.remote != "" && holdsDigest(localDigests(ctx, ic.Image), ic.remote) {
|
||||
out[i].State = "current"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkImages asks the registry whether each image is still the one we hold. Each image is
|
||||
// one registry request, which Docker Hub counts — so this is only ever called when asked for.
|
||||
func checkImages(ctx context.Context, images []string) []imageCheck {
|
||||
res := []imageCheck{}
|
||||
for _, img := range images {
|
||||
ic := imageCheck{Image: img, State: "unknown"}
|
||||
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||
var have []string
|
||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
||||
have := localDigests(ctx, img)
|
||||
if len(have) == 0 {
|
||||
ic.Detail = "built or loaded here, not pulled from a registry"
|
||||
res = append(res, ic)
|
||||
continue
|
||||
@@ -918,11 +828,9 @@ func checkImages(ctx context.Context, images []string, remote string) []imageChe
|
||||
res = append(res, ic)
|
||||
continue
|
||||
}
|
||||
ic.State = "update"
|
||||
for _, h := range have {
|
||||
if strings.HasSuffix(h, "@"+digest) {
|
||||
ic.State = "current"
|
||||
}
|
||||
ic.State, ic.remote = "update", digest
|
||||
if holdsDigest(have, digest) {
|
||||
ic.State = "current"
|
||||
}
|
||||
res = append(res, ic)
|
||||
}
|
||||
@@ -930,36 +838,33 @@ func checkImages(ctx context.Context, images []string, remote string) []imageChe
|
||||
}
|
||||
|
||||
// machineUpdates checks every image on this machine in one pass, so the container list can
|
||||
// be coloured and the fleet can be asked "what is behind?" without walking stack by stack.
|
||||
// be coloured and "what is behind?" answered without walking stack by stack.
|
||||
func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
node := r.PathValue("node")
|
||||
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 node != "" {
|
||||
stacks, err = s.hubStacks(ctx, node)
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// one entry per image, however many stacks use it
|
||||
type want struct{ remote string }
|
||||
imgs := map[string]want{}
|
||||
byImage := map[string][]string{} // image -> stacks
|
||||
for _, st := range stacks {
|
||||
for _, img := range st.Images {
|
||||
imgs[img] = want{remote: st.Remote}
|
||||
byImage[img] = append(byImage[img], st.Name)
|
||||
}
|
||||
}
|
||||
refresh := r.URL.Query().Get("refresh") == "1"
|
||||
key := "machine:" + node
|
||||
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})
|
||||
jsonOK(w, 200, map[string]any{"images": settled(ctx, c.res), "checked": c.at.UnixMilli(), "stacks": byImage})
|
||||
return
|
||||
}
|
||||
if !refresh {
|
||||
@@ -967,15 +872,11 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var list []string
|
||||
remote := ""
|
||||
for img, wnt := range imgs {
|
||||
for img := range byImage {
|
||||
list = append(list, img)
|
||||
if wnt.remote != "" {
|
||||
remote = wnt.remote
|
||||
}
|
||||
}
|
||||
sort.Strings(list)
|
||||
res := checkImages(ctx, list, remote)
|
||||
res := checkImages(ctx, list)
|
||||
updMu.Lock()
|
||||
updCache[key] = updAnswer{time.Now(), res}
|
||||
updMu.Unlock()
|
||||
@@ -988,106 +889,56 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
key := r.PathValue("node") + "/" + st.Name
|
||||
key := "stack:" + 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()})
|
||||
jsonOK(w, 200, map[string]any{"images": settled(ctx, 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, st.Remote)
|
||||
res := checkImages(ctx, st.Images)
|
||||
updMu.Lock()
|
||||
updCache[key] = updAnswer{time.Now(), res}
|
||||
updMu.Unlock()
|
||||
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli()})
|
||||
}
|
||||
|
||||
// taskOnHub serves a task this hub is running for one of its nodes' stacks; anything else
|
||||
// under /api/nodes/<node>/tasks belongs to the node.
|
||||
func (s *Server) taskOnHub(local http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.tasks.Get(r.PathValue("id")); ok {
|
||||
local(w, r)
|
||||
return
|
||||
}
|
||||
s.proxyNode(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- making and unmaking stacks ----
|
||||
|
||||
var stackNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
|
||||
// homesFor is where a new stack could go, seen from this request: this machine's homes, or —
|
||||
// on a hub route — the node's own homes plus the ones this hub holds for it.
|
||||
func (s *Server) homesFor(ctx context.Context, r *http.Request) ([]stackHome, error) {
|
||||
node := r.PathValue("node")
|
||||
if node == "" {
|
||||
stacks, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.homesOf(stacks, true), nil
|
||||
}
|
||||
stacks, err := s.hubStacks(ctx, node)
|
||||
// homesFor is where a new stack could go on this machine.
|
||||
func (s *Server) homesFor(ctx context.Context) ([]stackHome, error) {
|
||||
stacks, err := s.findStacks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var held []stack
|
||||
for _, st := range stacks {
|
||||
if st.HeldBy != "" {
|
||||
held = append(held, st)
|
||||
}
|
||||
}
|
||||
homes := s.homesOf(held, false)
|
||||
if code, b, err := s.nodes.Do(ctx, node, "GET", "/api/docker/stacks/homes", nil, nil); err == nil && code == 200 {
|
||||
var res struct{ Homes []stackHome }
|
||||
if json.Unmarshal(b, &res) == nil {
|
||||
for _, h := range res.Homes {
|
||||
if len(homes) > 0 && h.Stacks == 0 {
|
||||
continue // the node's empty ~/stacks is only worth offering when there is nothing else
|
||||
}
|
||||
h.Default = false
|
||||
homes = append(homes, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(homes) > 0 {
|
||||
homes[0].Default = true
|
||||
}
|
||||
return homes, nil
|
||||
return s.homesOf(stacks), nil
|
||||
}
|
||||
|
||||
func (s *Server) stackHomes(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
if dockerTool() == "" && r.PathValue("node") == "" {
|
||||
if dockerTool() == "" {
|
||||
jsonOK(w, 200, map[string]any{"homes": []stackHome{}})
|
||||
return
|
||||
}
|
||||
homes, err := s.homesFor(ctx, r)
|
||||
homes, err := s.homesFor(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if homes == nil { // a machine with no Docker and nothing held for it: an empty list, never null
|
||||
homes = []stackHome{}
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"homes": homes})
|
||||
}
|
||||
|
||||
@@ -1114,7 +965,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "the compose file can't be empty")
|
||||
return
|
||||
}
|
||||
homes, err := s.homesFor(ctx, r)
|
||||
homes, err := s.homesFor(ctx)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -1129,17 +980,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "that isn't one of the places this machine keeps stacks")
|
||||
return
|
||||
}
|
||||
if r.PathValue("node") != "" && home.HeldBy == "" { // the node's own folder: the node makes it
|
||||
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
var all []stack
|
||||
if node := r.PathValue("node"); node != "" {
|
||||
all, _ = s.hubStacks(ctx, node)
|
||||
} else {
|
||||
all, _ = s.findStacks(ctx)
|
||||
}
|
||||
all, _ := s.findStacks(ctx)
|
||||
for _, st := range all {
|
||||
if st.Name == name {
|
||||
jsonErr(w, 409, "there is already a stack called "+name)
|
||||
@@ -1201,7 +1042,7 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
|
||||
_ = sudo("chmod", "600", filepath.Join(dir, ".env")) // it is where the secrets go
|
||||
}
|
||||
st := stack{Name: name, Containers: []string{}, Images: []string{}, HostFiles: []string{host}, HostFile: host, Files: []string{filepath.Join(home.Inner, name, "compose.yaml")},
|
||||
Via: home.Via, Remote: home.Remote, HeldBy: home.HeldBy, Editable: true, Note: "not deployed", HasEnv: strings.TrimSpace(body.Env) != ""}
|
||||
Via: home.Via, Editable: true, Note: "not deployed", HasEnv: strings.TrimSpace(body.Env) != ""}
|
||||
st.File = st.Files[0]
|
||||
if msg, err := composeCmd(ctx, st, seenFiles(st, -1, ""), "config", "-q").CombinedOutput(); err != nil {
|
||||
undo()
|
||||
@@ -1220,12 +1061,7 @@ func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
|
||||
defer cancel()
|
||||
raw, _ := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||
st, handled, err := s.stackFor(ctx, r)
|
||||
if !handled {
|
||||
r.Body, r.ContentLength = io.NopCloser(bytes.NewReader(raw)), int64(len(raw))
|
||||
s.proxyNode(w, r)
|
||||
return
|
||||
}
|
||||
st, err := s.stackFor(ctx, r)
|
||||
if err != nil || !st.Editable {
|
||||
jsonErr(w, 404, "that stack's compose file isn't anywhere this console can reach")
|
||||
return
|
||||
@@ -1263,7 +1099,7 @@ func (s *Server) stackDelete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
_ = run("rmdir", dir) // only if nothing else was in it
|
||||
stackUpdatesForget(r.PathValue("node") + "/" + st.Name)
|
||||
stackUpdatesForget(st.Name)
|
||||
log.Printf("stack %s: taken down and %d file(s) moved to %s (from %s)", st.Name, moved, trash, clientIP(r))
|
||||
jsonOK(w, 200, map[string]any{"trash": trash, "moved": moved})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"hamlab/internal/compose"
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
@@ -119,3 +120,37 @@ func TestRepointSurvivesAMissingContainer(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
package api
|
||||
|
||||
// Previews for the Files tab: GET /api/files/thumb hands back one small JPEG of an image or
|
||||
// a video, made by internal/thumbs and kept in <data dir>/thumbs.db. The work is bounded
|
||||
// two ways — a handful of ffmpegs at once, and one per file however many rows ask for it —
|
||||
// because a folder of a thousand photos asks for every row the moment previews go on.
|
||||
//
|
||||
// Root mode (?sudo=1) makes the preview through the same root helper the file reads use,
|
||||
// so a file only root can read gets a preview too, and the probe runs as root as well: root
|
||||
// opens every render node, whatever groups the daemon's user has.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
const thumbSize = 256
|
||||
const thumbTimeout = 30 * time.Second
|
||||
const capsTTL = 10 * time.Minute // how long a probe is believed; a refresh after an install or a group change does not wait
|
||||
|
||||
type thumbSvc struct {
|
||||
cache *media.Cache
|
||||
self string
|
||||
sem chan struct{}
|
||||
mu sync.Mutex
|
||||
caps map[bool]*capsEntry // keyed by root
|
||||
calls map[string]*thumbCall
|
||||
}
|
||||
|
||||
type capsEntry struct {
|
||||
mu sync.Mutex
|
||||
caps media.Caps
|
||||
err error
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type thumbCall struct {
|
||||
done chan struct{}
|
||||
res media.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func newThumbSvc(dataDir, self string) *thumbSvc {
|
||||
n := runtime.NumCPU() / 2 // each ffmpeg is itself threaded; previews must not take the whole machine
|
||||
if n < 2 {
|
||||
n = 2
|
||||
}
|
||||
if n > 4 {
|
||||
n = 4
|
||||
}
|
||||
return &thumbSvc{cache: media.NewCache(filepath.Join(dataDir, "thumbs.db")), self: self, sem: make(chan struct{}, n), caps: map[bool]*capsEntry{}, calls: map[string]*thumbCall{}}
|
||||
}
|
||||
|
||||
// capsFor is what previews can be made with, probed once and kept for a while.
|
||||
func (t *thumbSvc) capsFor(ctx context.Context, root, refresh bool) (media.Caps, error) {
|
||||
t.mu.Lock()
|
||||
e := t.caps[root]
|
||||
if e == nil {
|
||||
e = &capsEntry{}
|
||||
t.caps[root] = e
|
||||
}
|
||||
t.mu.Unlock()
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !refresh && !e.at.IsZero() && time.Since(e.at) < capsTTL {
|
||||
return e.caps, e.err
|
||||
}
|
||||
if root {
|
||||
e.caps, e.err = media.ProbeRoot(ctx, t.self)
|
||||
} else {
|
||||
e.caps, e.err = media.Probe(ctx), nil
|
||||
}
|
||||
e.at = time.Now()
|
||||
mode := "as " + map[bool]string{false: "the daemon", true: "root"}[root]
|
||||
switch {
|
||||
case e.err != nil:
|
||||
log.Printf("thumbs: probe %s failed: %v", mode, e.err)
|
||||
case e.caps.Error != "":
|
||||
log.Printf("thumbs: %s: %s", mode, e.caps.Error)
|
||||
default:
|
||||
log.Printf("thumbs: %s: ffmpeg %s, %s", mode, e.caps.Version, describeCaps(e.caps))
|
||||
}
|
||||
return e.caps, e.err
|
||||
}
|
||||
|
||||
// describeCaps is the one line that says how previews get made here.
|
||||
func describeCaps(c media.Caps) string {
|
||||
if len(c.GPUs) == 0 {
|
||||
return "CPU only"
|
||||
}
|
||||
parts := make([]string, 0, len(c.GPUs))
|
||||
for _, g := range c.GPUs {
|
||||
name := g.Name
|
||||
if name == "" {
|
||||
name = g.Device
|
||||
}
|
||||
p := name + " (" + g.API
|
||||
if len(g.Codecs) > 0 {
|
||||
p += ": " + strings.Join(g.Codecs, ", ")
|
||||
}
|
||||
parts = append(parts, p+")")
|
||||
}
|
||||
return "GPU " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// make produces a preview, sharing the work when the same file is asked for twice at once.
|
||||
// in is a path, or a URL the hub serves another machine's file at (kind then says what it
|
||||
// is); device is the one to decode on ("" any, "cpu" none).
|
||||
func (t *thumbSvc) make(ctx context.Context, root bool, key, in, kind, device string) (media.Result, error) {
|
||||
t.mu.Lock()
|
||||
if c, ok := t.calls[key]; ok {
|
||||
t.mu.Unlock()
|
||||
select {
|
||||
case <-c.done:
|
||||
return c.res, c.err
|
||||
case <-ctx.Done():
|
||||
return media.Result{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
c := &thumbCall{done: make(chan struct{})}
|
||||
t.calls[key] = c
|
||||
t.mu.Unlock()
|
||||
defer func() {
|
||||
t.mu.Lock()
|
||||
delete(t.calls, key)
|
||||
t.mu.Unlock()
|
||||
close(c.done)
|
||||
}()
|
||||
// The work itself runs on a context of its own: the first requester going away must not
|
||||
// cancel a preview three other rows are waiting for.
|
||||
wctx, cancel := context.WithTimeout(context.Background(), thumbTimeout+5*time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case t.sem <- struct{}{}:
|
||||
defer func() { <-t.sem }()
|
||||
case <-ctx.Done():
|
||||
c.err = ctx.Err()
|
||||
return c.res, c.err
|
||||
}
|
||||
caps, err := t.capsFor(wctx, root, false)
|
||||
if err != nil {
|
||||
c.err = err
|
||||
return c.res, c.err
|
||||
}
|
||||
o := media.Opts{Size: thumbSize, Timeout: thumbTimeout, Kind: kind}
|
||||
caps = caps.Only(device)
|
||||
if root {
|
||||
c.res, c.err = media.MakeRoot(wctx, t.self, caps, in, o)
|
||||
} else {
|
||||
c.res, c.err = media.Make(wctx, caps, in, o)
|
||||
}
|
||||
return c.res, c.err
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
// thumbDeviceHeader carries the device a hub wants a video preview made on: the Transcode
|
||||
// device setting, when it is on the machine the file is on.
|
||||
const thumbDeviceHeader = "X-Hamlab-Device"
|
||||
|
||||
// filesThumb: GET /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1][&cached=1]. v is the
|
||||
// client's idea of the file, from its listing; it makes a cache hit free of a stat, and a
|
||||
// changed file a new URL. cached=1 answers from the cache or 404, and makes nothing: the hub
|
||||
// asks that before having the preview made on another machine.
|
||||
//
|
||||
// A video preview is made on the Transcode device: on the hub, by the setting (another
|
||||
// machine's device reads the file through the hub); on a node, by the hub's header.
|
||||
func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if media.Kind(p) == "" {
|
||||
jsonErr(w, 415, "no preview for this kind of file")
|
||||
return
|
||||
}
|
||||
root := r.URL.Query().Get("sudo") == "1"
|
||||
if mt, sz, ok := parseV(r.URL.Query().Get("v")); ok {
|
||||
key := media.Key(p, sz, mt)
|
||||
if r.Header.Get("If-None-Match") == `"`+key+`"` {
|
||||
w.WriteHeader(304)
|
||||
return
|
||||
}
|
||||
if s.serveCachedThumb(w, key) {
|
||||
return
|
||||
}
|
||||
}
|
||||
in, err := fsys.Stat(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
if in.Type == "dir" || in.Type == "other" || in.Broken {
|
||||
jsonErr(w, 400, "not a regular file")
|
||||
return
|
||||
}
|
||||
key := media.Key(p, in.Size, in.Mtime)
|
||||
if s.serveCachedThumb(w, key) {
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("cached") == "1" {
|
||||
jsonErr(w, 404, "not made yet")
|
||||
return
|
||||
}
|
||||
device := r.Header.Get(thumbDeviceHeader)
|
||||
if c := s.transcodeChoice(); c != nil && device == "" && media.Kind(p) == "video" {
|
||||
if c.Node != "" {
|
||||
// The hub's own file, previewed on another machine.
|
||||
if res, ok := s.thumbElsewhere(r.Context(), c, "", p, root, key); ok {
|
||||
if res.Failure != "" {
|
||||
_ = s.thumbs.cache.Put(key, media.Entry{Path: p, Failure: res.Failure})
|
||||
jsonErr(w, 422, res.Failure)
|
||||
return
|
||||
}
|
||||
s.keepThumb(w, key, p, res)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
device = c.Device
|
||||
}
|
||||
}
|
||||
res, err := s.thumbs.make(r.Context(), root, key, p, "", device)
|
||||
if err != nil {
|
||||
var de *media.DecodeError
|
||||
switch {
|
||||
case errors.As(err, &de):
|
||||
if err := s.thumbs.cache.Put(key, media.Entry{Path: p, Failure: de.Msg}); err != nil {
|
||||
log.Printf("thumbs: cache: %v", err)
|
||||
}
|
||||
log.Printf("thumbs: %s: %s", p, de.Msg)
|
||||
jsonErr(w, 422, de.Msg)
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
log.Printf("thumbs: %s: timed out", p)
|
||||
jsonErr(w, 504, "preview timed out")
|
||||
case errors.Is(err, context.Canceled):
|
||||
jsonErr(w, 499, "cancelled")
|
||||
default:
|
||||
jsonErr(w, 503, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
s.keepThumb(w, key, p, res)
|
||||
}
|
||||
|
||||
// keepThumb puts a preview in the cache and answers with it.
|
||||
func (s *Server) keepThumb(w http.ResponseWriter, key, p string, res media.Result) {
|
||||
if err := s.thumbs.cache.Put(key, media.Entry{Path: p, Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}); err != nil {
|
||||
log.Printf("thumbs: cache: %v", err)
|
||||
}
|
||||
writeThumb(w, key, res.JPEG, res.Engine)
|
||||
}
|
||||
|
||||
// ---- previews made on another machine (the Transcode device) ----
|
||||
|
||||
// thumbFrom: POST /api/files/thumb-from {url, kind, key, device} — a preview of a file on
|
||||
// another machine, read through the hub at url, made here on device. Answers the JPEG (the
|
||||
// engine in X-Hamlab-Thumb), or 422 with why the file can't be previewed.
|
||||
func (s *Server) thumbFrom(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ URL, Kind, Key, Device string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8192)).Decode(&body); err != nil || !strings.HasPrefix(body.URL, "http") || body.Key == "" {
|
||||
jsonErr(w, 400, "expected {url, kind, key, device}")
|
||||
return
|
||||
}
|
||||
res, err := s.thumbs.make(r.Context(), false, "from:"+body.Key, body.URL, body.Kind, body.Device)
|
||||
if err != nil {
|
||||
var de *media.DecodeError
|
||||
if errors.As(err, &de) {
|
||||
jsonErr(w, 422, de.Msg)
|
||||
} else {
|
||||
jsonErr(w, 503, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Hamlab-Thumb-Codec", res.Codec)
|
||||
w.Header().Set("X-Hamlab-Thumb-Size", fmt.Sprintf("%dx%d", res.Width, res.Height))
|
||||
writeThumb(w, body.Key, res.JPEG, res.Engine)
|
||||
}
|
||||
|
||||
// thumbPut: PUT /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1] — a preview another machine
|
||||
// made of this file, kept here as if made here. The body is the JPEG, or nothing with
|
||||
// X-Hamlab-Thumb-Failure saying why the file can't be previewed. Refused if the file has
|
||||
// changed since v.
|
||||
func (s *Server) thumbPut(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mt, sz, ok := parseV(r.URL.Query().Get("v"))
|
||||
if !ok {
|
||||
jsonErr(w, 400, "v=<mtime>-<size> is required")
|
||||
return
|
||||
}
|
||||
in, err := fsys.Stat(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
if in.Size != sz || in.Mtime != mt {
|
||||
jsonErr(w, 409, "the file has changed")
|
||||
return
|
||||
}
|
||||
key := media.Key(p, sz, mt)
|
||||
e := media.Entry{Path: p, Engine: r.Header.Get("X-Hamlab-Thumb"), Codec: r.Header.Get("X-Hamlab-Thumb-Codec"), Failure: r.Header.Get("X-Hamlab-Thumb-Failure")}
|
||||
fmt.Sscanf(r.Header.Get("X-Hamlab-Thumb-Size"), "%dx%d", &e.Width, &e.Height)
|
||||
if e.Failure == "" {
|
||||
if e.JPEG, err = io.ReadAll(io.LimitReader(r.Body, 4<<20)); err != nil || len(e.JPEG) == 0 {
|
||||
jsonErr(w, 400, "no picture")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.thumbs.cache.Put(key, e); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// thumbElsewhere has a video preview made on the Transcode device's machine c.Node, of a
|
||||
// file on machine node ("" the hub), and answers it. ok is false when that machine can't
|
||||
// (unreachable, an older Ham Lab): the caller makes it where the file is instead. A file
|
||||
// that can't be previewed is a result too, with the failure in it.
|
||||
func (s *Server) thumbElsewhere(ctx context.Context, c *store.Transcode, node, p string, root bool, key string) (media.Result, bool) {
|
||||
src, err := s.srcURL(c.Node, node, p, root)
|
||||
if err != nil {
|
||||
return media.Result{}, false
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"url": src, "kind": "video", "key": key, "device": c.Device})
|
||||
ctx, cancel := context.WithTimeout(ctx, thumbTimeout+10*time.Second)
|
||||
defer cancel()
|
||||
var code int
|
||||
var b []byte
|
||||
var hdr http.Header
|
||||
if c.Node == "" {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/files/thumb-from", bytes.NewReader(body)).WithContext(ctx)
|
||||
s.thumbFrom(rec, req)
|
||||
code, b, hdr = rec.Code, rec.Body.Bytes(), rec.Header()
|
||||
} else {
|
||||
code, b, hdr, err = s.nodes.DoHeaders(ctx, c.Node, "POST", "/api/files/thumb-from", body, http.Header{"Content-Type": []string{"application/json"}})
|
||||
if err != nil {
|
||||
log.Printf("thumbs: %s on %s: %v; made where it is", p, c.Node, err)
|
||||
return media.Result{}, false
|
||||
}
|
||||
}
|
||||
switch code {
|
||||
case 200:
|
||||
res := media.Result{JPEG: b, Engine: c.Node + " " + hdr.Get("X-Hamlab-Thumb"), Codec: hdr.Get("X-Hamlab-Thumb-Codec")}
|
||||
fmt.Sscanf(hdr.Get("X-Hamlab-Thumb-Size"), "%dx%d", &res.Width, &res.Height)
|
||||
if c.Node == "" {
|
||||
res.Engine = s.hostName() + " " + hdr.Get("X-Hamlab-Thumb")
|
||||
}
|
||||
return res, true
|
||||
case 422:
|
||||
var e struct{ Error string }
|
||||
_ = json.Unmarshal(b, &e)
|
||||
return media.Result{Failure: e.Error}, true
|
||||
}
|
||||
log.Printf("thumbs: %s on %s answered %d; made where it is", p, nodeLabel(c.Node), code)
|
||||
return media.Result{}, false
|
||||
}
|
||||
|
||||
// hubThumb: GET /api/nodes/{name}/files/thumb — a node's preview, through the hub. Without a
|
||||
// Transcode device setting, or for an image or a sound file's cover, it goes to the node as
|
||||
// any request does. A video is made on the setting's device: the node's own (told by header),
|
||||
// or another machine's, which reads the file through the hub; the node keeps the result in
|
||||
// its own cache either way.
|
||||
func (s *Server) hubThumb(w http.ResponseWriter, r *http.Request) {
|
||||
node := r.PathValue("name")
|
||||
proxy, ok := s.nodes.Proxy(node)
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
}
|
||||
c := s.transcodeChoice()
|
||||
q := r.URL.Query()
|
||||
p := q.Get("path")
|
||||
if c == nil || media.Kind(p) != "video" {
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if c.Node == node {
|
||||
r.Header.Set(thumbDeviceHeader, c.Device)
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
mt, sz, okV := parseV(q.Get("v"))
|
||||
if !okV {
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// The node's own cache first.
|
||||
cq := url.Values{"path": {p}, "v": {q.Get("v")}, "cached": {"1"}}
|
||||
if q.Get("sudo") == "1" {
|
||||
cq.Set("sudo", "1")
|
||||
}
|
||||
hdr := http.Header{}
|
||||
if inm := r.Header.Get("If-None-Match"); inm != "" {
|
||||
hdr.Set("If-None-Match", inm)
|
||||
}
|
||||
code, b, rh, err := s.nodes.DoHeaders(r.Context(), node, "GET", "/api/files/thumb?"+cq.Encode(), nil, hdr)
|
||||
if err == nil && code != 404 {
|
||||
for _, k := range []string{"Content-Type", "Cache-Control", "ETag", "X-Hamlab-Thumb"} {
|
||||
if v := rh.Get(k); v != "" {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(b)
|
||||
return
|
||||
}
|
||||
key := media.Key(p, sz, mt)
|
||||
res, made := s.thumbElsewhere(r.Context(), c, node, p, q.Get("sudo") == "1", key)
|
||||
if !made {
|
||||
proxy.ServeHTTP(w, r) // the node makes it itself
|
||||
return
|
||||
}
|
||||
// Kept where the file is, so it is made once.
|
||||
ph := http.Header{"X-Hamlab-Thumb": {res.Engine}, "X-Hamlab-Thumb-Codec": {res.Codec}, "X-Hamlab-Thumb-Size": {fmt.Sprintf("%dx%d", res.Width, res.Height)}}
|
||||
if res.Failure != "" {
|
||||
ph.Set("X-Hamlab-Thumb-Failure", res.Failure)
|
||||
}
|
||||
pq := url.Values{"path": {p}, "v": {q.Get("v")}}
|
||||
if q.Get("sudo") == "1" {
|
||||
pq.Set("sudo", "1")
|
||||
}
|
||||
if code, _, _, err := s.nodes.DoHeaders(r.Context(), node, "PUT", "/api/files/thumb?"+pq.Encode(), res.JPEG, ph); err != nil || code != 204 {
|
||||
log.Printf("thumbs: keeping %s's preview of %s: %d %v", node, p, code, err)
|
||||
}
|
||||
if res.Failure != "" {
|
||||
jsonErr(w, 422, res.Failure)
|
||||
return
|
||||
}
|
||||
writeThumb(w, key, res.JPEG, res.Engine)
|
||||
}
|
||||
|
||||
// serveCachedThumb answers from the cache when it can. A cache that cannot be read is a
|
||||
// miss: the preview is made again rather than refused.
|
||||
func (s *Server) serveCachedThumb(w http.ResponseWriter, key string) bool {
|
||||
e, found, err := s.thumbs.cache.Get(key)
|
||||
if err != nil {
|
||||
log.Printf("thumbs: cache: %v", err)
|
||||
}
|
||||
switch {
|
||||
case !found:
|
||||
return false
|
||||
case e.Failure != "":
|
||||
jsonErr(w, 422, e.Failure)
|
||||
default:
|
||||
writeThumb(w, key, e.JPEG, "cache ("+e.Engine+")")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeThumb(w http.ResponseWriter, key string, jpeg []byte, engine string) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(jpeg)))
|
||||
w.Header().Set("Cache-Control", "private, max-age=604800")
|
||||
w.Header().Set("ETag", `"`+key+`"`)
|
||||
w.Header().Set("X-Hamlab-Thumb", engine)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = w.Write(jpeg)
|
||||
}
|
||||
|
||||
func parseV(v string) (mtime, size int64, ok bool) {
|
||||
a, b, found := strings.Cut(v, "-")
|
||||
if !found {
|
||||
return 0, 0, false
|
||||
}
|
||||
mtime, err1 := strconv.ParseInt(a, 10, 64)
|
||||
size, err2 := strconv.ParseInt(b, 10, 64)
|
||||
return mtime, size, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
type thumbStatus struct {
|
||||
OK bool `json:"ok"`
|
||||
Engine string `json:"engine"` // gpu | cpu | ""
|
||||
Summary string `json:"summary"`
|
||||
FFmpeg string `json:"ffmpeg,omitempty"`
|
||||
GPUs []media.GPU `json:"gpus"`
|
||||
Unusable []media.GPU `json:"unusable,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Install *thumbInstall `json:"install,omitempty"` // ffmpeg is missing and this can put it on
|
||||
GPUFix bool `json:"gpu_fix,omitempty"` // a render node is there but closed to the daemon's user, and this can open it
|
||||
User string `json:"user"`
|
||||
Cache thumbCache `json:"cache"`
|
||||
}
|
||||
|
||||
type thumbCache struct {
|
||||
Path string `json:"path"`
|
||||
Entries int64 `json:"entries"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type thumbInstall struct {
|
||||
PM string `json:"pm"`
|
||||
Package string `json:"package"`
|
||||
}
|
||||
|
||||
// thumbsStatus: GET /api/files/thumbs[?sudo=1][&refresh=1] — how previews get made on this machine.
|
||||
func (s *Server) thumbsStatus(w http.ResponseWriter, r *http.Request) {
|
||||
root := r.URL.Query().Get("sudo") == "1"
|
||||
if root && s.fsSudo == nil {
|
||||
jsonErr(w, 403, "root access needs passwordless sudo on the host")
|
||||
return
|
||||
}
|
||||
caps, err := s.thumbs.capsFor(r.Context(), root, r.URL.Query().Get("refresh") == "1")
|
||||
st := thumbStatus{GPUs: []media.GPU{}, User: s.user, Cache: thumbCache{Path: s.thumbs.cache.Path}}
|
||||
st.Cache.Entries, st.Cache.Bytes = s.thumbs.cache.Stats()
|
||||
if root {
|
||||
st.User = "root"
|
||||
}
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
jsonOK(w, 200, st)
|
||||
return
|
||||
}
|
||||
st.FFmpeg, st.GPUs, st.Unusable, st.Error = caps.Version, caps.GPUs, caps.Unusable, caps.Error
|
||||
if st.GPUs == nil {
|
||||
st.GPUs = []media.GPU{}
|
||||
}
|
||||
st.OK = caps.Error == ""
|
||||
switch {
|
||||
case !st.OK:
|
||||
if pkg := ffmpegPackage(s.updates.PM()); pkg != "" && priv.Available() {
|
||||
st.Install = &thumbInstall{PM: s.updates.PM(), Package: pkg}
|
||||
}
|
||||
st.Summary = st.Error
|
||||
case len(caps.GPUs) > 0:
|
||||
st.Engine = "gpu"
|
||||
st.Summary = describeCaps(caps) + " · images on the CPU"
|
||||
default:
|
||||
st.Engine = "cpu"
|
||||
st.Summary = "CPU only"
|
||||
}
|
||||
if !root {
|
||||
for _, g := range caps.Unusable {
|
||||
if g.Permission && gpuFixPossible() {
|
||||
st.GPUFix = true
|
||||
}
|
||||
}
|
||||
}
|
||||
jsonOK(w, 200, st)
|
||||
}
|
||||
|
||||
// ffmpegPackage is what to install for ffmpeg on a package manager. Fedora ships it as
|
||||
// ffmpeg-free; the others under its own name.
|
||||
func ffmpegPackage(pm string) string {
|
||||
switch pm {
|
||||
case "":
|
||||
return ""
|
||||
case "dnf":
|
||||
if b, _ := os.ReadFile("/etc/os-release"); strings.Contains(string(b), "ID=fedora") {
|
||||
return "ffmpeg-free"
|
||||
}
|
||||
}
|
||||
return "ffmpeg"
|
||||
}
|
||||
|
||||
const hamlabUnit = "/etc/systemd/system/hamlab.service"
|
||||
|
||||
// gpuFixPossible: the daemon is an ordinary user under systemd, with root to hand.
|
||||
func gpuFixPossible() bool {
|
||||
if os.Geteuid() == 0 || !priv.Available() {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(hamlabUnit)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// thumbsInstall: POST /api/files/thumbs/install — put ffmpeg on with the machine's package manager.
|
||||
func (s *Server) thumbsInstall(w http.ResponseWriter, r *http.Request) {
|
||||
pkg := ffmpegPackage(s.updates.PM())
|
||||
if pkg == "" {
|
||||
jsonErr(w, 400, "no supported package manager on this machine")
|
||||
return
|
||||
}
|
||||
t, err := s.updates.Install([]string{pkg})
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("thumbs: installing %s as task %s (from %s)", pkg, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
// gpuAccessScript gives hamlab.service the groups that own the render nodes, by a drop-in,
|
||||
// then restarts it: a running process cannot gain a group.
|
||||
const gpuAccessScript = `set -e
|
||||
unit=` + hamlabUnit + `
|
||||
[ -f "$unit" ] || { echo "no $unit: this machine does not run Ham Lab under systemd"; exit 1; }
|
||||
groups=""
|
||||
for g in render video; do
|
||||
if getent group "$g" >/dev/null 2>&1; then groups="$groups $g"; fi
|
||||
done
|
||||
[ -n "$groups" ] || { echo "neither a render nor a video group exists here"; exit 1; }
|
||||
mkdir -p "$unit.d"
|
||||
printf '[Service]\nSupplementaryGroups=%s\n' "${groups# }" > "$unit.d/gpu.conf"
|
||||
echo "hamlab.service: supplementary groups${groups} ($unit.d/gpu.conf)"
|
||||
systemctl daemon-reload
|
||||
echo 'Ham Lab restarts in 3 seconds to pick up its new groups; the page reconnects by itself.'
|
||||
systemd-run --quiet --on-active=3 --unit=hamlab-gpu-restart systemctl restart hamlab.service
|
||||
`
|
||||
|
||||
// thumbsGPUAccess: POST /api/files/thumbs/gpu-access — open the render nodes to the daemon.
|
||||
func (s *Server) thumbsGPUAccess(w http.ResponseWriter, r *http.Request) {
|
||||
if !gpuFixPossible() {
|
||||
jsonErr(w, 400, "nothing to do: Ham Lab is root, has no root, or is not under systemd here")
|
||||
return
|
||||
}
|
||||
if t, busy := s.tasks.RunningOf("thumbs:gpu"); busy { // a second run would only collide with the first's restart timer
|
||||
jsonErr(w, 409, "already being done (task "+t.ID+")")
|
||||
return
|
||||
}
|
||||
full := priv.Wrap("sh", "-c", gpuAccessScript)
|
||||
t, err := s.tasks.Start("thumbs:gpu", "give Ham Lab access to the GPU", exec.Command(full[0], full[1:]...))
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("thumbs: GPU access drop-in as task %s, restart follows (from %s)", t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeDocker puts a `docker` on PATH whose `image inspect` answers with the given RepoDigests.
|
||||
func fakeDocker(t *testing.T, digests string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
script := "#!/bin/sh\necho '" + digests + "'\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir)
|
||||
}
|
||||
|
||||
// An update pulls the newer image: the cached registry answer must not keep calling it behind.
|
||||
func TestSettledSeesAPulledImage(t *testing.T) {
|
||||
cached := []imageCheck{
|
||||
{Image: "nginx:alpine", State: "update", remote: "sha256:new"},
|
||||
{Image: "redis:7", State: "current", remote: "sha256:r"},
|
||||
{Image: "local/thing", State: "unknown"},
|
||||
}
|
||||
fakeDocker(t, `["nginx@sha256:new"]`)
|
||||
got := settled(context.Background(), cached)
|
||||
if got[0].State != "current" {
|
||||
t.Fatalf("pulled image still marked %q", got[0].State)
|
||||
}
|
||||
if got[1].State != "current" || got[2].State != "unknown" {
|
||||
t.Fatalf("others changed: %+v", got)
|
||||
}
|
||||
if cached[0].State != "update" {
|
||||
t.Fatal("settled wrote through to the cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettledKeepsAnImageStillBehind(t *testing.T) {
|
||||
fakeDocker(t, `["nginx@sha256:old"]`)
|
||||
got := settled(context.Background(), []imageCheck{{Image: "nginx:alpine", State: "update", remote: "sha256:new"}})
|
||||
if got[0].State != "update" {
|
||||
t.Fatalf("image not yet pulled marked %q", got[0].State)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -264,6 +265,29 @@ func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// revokeIdleSessions drops the caller's sessions that have not been used for ?hours (24 by
|
||||
// default): the scripts and headless browsers that signed in once and never came back,
|
||||
// without touching the phone that was used this morning.
|
||||
func (s *Server) revokeIdleSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
if a.User == "" {
|
||||
jsonErr(w, 403, "only a signed-in user may do that")
|
||||
return
|
||||
}
|
||||
hours := 24
|
||||
if h := r.URL.Query().Get("hours"); h != "" {
|
||||
n, err := strconv.Atoi(h)
|
||||
if err != nil || n < 1 || n > 24*365 {
|
||||
jsonErr(w, 400, "hours is a whole number from 1 to 8760")
|
||||
return
|
||||
}
|
||||
hours = n
|
||||
}
|
||||
n := s.sessions.RevokeIdle(a.User, a.token, time.Duration(hours)*time.Hour)
|
||||
log.Printf("%d idle session(s) of %q revoked (idle > %dh, by %s)", n, a.User, hours, clientIP(r))
|
||||
jsonOK(w, 200, map[string]int{"revoked": n})
|
||||
}
|
||||
|
||||
func (s *Server) revokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
if a.User == "" {
|
||||
|
||||
+27
-2
@@ -26,7 +26,8 @@ import (
|
||||
)
|
||||
|
||||
type zfsState struct {
|
||||
Policies []storage.Policy `json:"policies"`
|
||||
Policies []storage.Policy `json:"policies"`
|
||||
Verify []storage.VerifyPolicy `json:"verify"` // landing-tier verify schedules (landing.go)
|
||||
}
|
||||
|
||||
func (s *Server) zfsLoad() zfsState {
|
||||
@@ -39,6 +40,9 @@ func (s *Server) zfsLoad() zfsState {
|
||||
if z.Policies == nil {
|
||||
z.Policies = []storage.Policy{}
|
||||
}
|
||||
if z.Verify == nil {
|
||||
z.Verify = []storage.VerifyPolicy{}
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
@@ -136,6 +140,19 @@ 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), and the write path — the dirty
|
||||
// buffer in RAM, each pool's write rate, and any landing tier in front of a pool.
|
||||
func (s *Server) zfsARC(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]any{"detail": s.storage.ARC(r.Context()), "live": nil, "writes": nil}
|
||||
if smp, ok := s.metrics.Latest(); ok && smp.ARC != nil {
|
||||
out["live"] = smp.ARC
|
||||
out["writes"] = smp.ZFSWrite
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
var p storage.Policy
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
|
||||
@@ -254,7 +271,15 @@ func (s *Server) zfsSnapshotDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.storage.DestroySnapshot(r.Context(), body.Name, body.Recursive); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case msg == "bad snapshot name":
|
||||
jsonErr(w, 400, msg)
|
||||
case strings.Contains(msg, "does not exist"):
|
||||
jsonErr(w, 404, msg)
|
||||
default:
|
||||
jsonErr(w, 500, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("zfs snapshot %s destroyed by %s%s", body.Name, clientIP(r), map[bool]string{true: " (recursive)", false: ""}[body.Recursive])
|
||||
|
||||
+42
-4
@@ -28,6 +28,10 @@ const (
|
||||
argonKeyLen = 32
|
||||
|
||||
SessionTTL = 30 * 24 * time.Hour
|
||||
// SessionIdle is how long a session may go unused before it is dropped, whatever its
|
||||
// expiry says. Every script, curl and headless browser that signs in leaves a session;
|
||||
// a phone that is used keeps its own alive by being used.
|
||||
SessionIdle = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
func HashPassword(pw string) (string, error) {
|
||||
@@ -189,12 +193,26 @@ func NewSessions(st *store.Store) *Sessions {
|
||||
func pruneExpired(state *store.State) {
|
||||
now := time.Now()
|
||||
for tok, ses := range state.Sessions {
|
||||
if ses.Expires.Before(now) {
|
||||
if !live(ses, now) {
|
||||
delete(state.Sessions, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// live is whether a session still counts: unexpired, and used within SessionIdle. A
|
||||
// session from before visits were recorded is judged by its creation, or failing that by
|
||||
// its expiry alone.
|
||||
func live(ses store.Session, now time.Time) bool {
|
||||
if !ses.Expires.After(now) {
|
||||
return false
|
||||
}
|
||||
seen := ses.LastSeen
|
||||
if seen.IsZero() {
|
||||
seen = ses.Created
|
||||
}
|
||||
return seen.IsZero() || now.Sub(seen) <= SessionIdle
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -234,7 +252,7 @@ func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) {
|
||||
ok, touch := false, false
|
||||
s.st.View(func(state *store.State) {
|
||||
found, have := state.Sessions[tok]
|
||||
if have && found.Expires.After(time.Now()) {
|
||||
if have && live(found, time.Now()) {
|
||||
ses, ok = found, true
|
||||
touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour
|
||||
}
|
||||
@@ -264,7 +282,7 @@ func (s *Sessions) List(user string) []store.Session {
|
||||
s.st.View(func(state *store.State) {
|
||||
now := time.Now()
|
||||
for _, ses := range state.Sessions {
|
||||
if ses.User == user && ses.Expires.After(now) {
|
||||
if ses.User == user && live(ses, now) {
|
||||
out = append(out, ses)
|
||||
}
|
||||
}
|
||||
@@ -279,7 +297,7 @@ func (s *Sessions) Count() map[string]int {
|
||||
s.st.View(func(state *store.State) {
|
||||
now := time.Now()
|
||||
for _, ses := range state.Sessions {
|
||||
if ses.Expires.After(now) {
|
||||
if live(ses, now) {
|
||||
n[ses.User]++
|
||||
}
|
||||
}
|
||||
@@ -315,6 +333,26 @@ func (s *Sessions) RevokeOthers(user, keep string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// RevokeIdle ends every session of user not seen for longer than idle, except the one
|
||||
// holding keep. Returns how many.
|
||||
func (s *Sessions) RevokeIdle(user, keep string, idle time.Duration) int {
|
||||
n := 0
|
||||
now := time.Now()
|
||||
_ = s.st.Update(func(state *store.State) {
|
||||
for tok, ses := range state.Sessions {
|
||||
seen := ses.LastSeen
|
||||
if seen.IsZero() {
|
||||
seen = ses.Created
|
||||
}
|
||||
if ses.User == user && tok != keep && now.Sub(seen) > idle {
|
||||
delete(state.Sessions, tok)
|
||||
n++
|
||||
}
|
||||
}
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
// RevokeUser ends every session of user (the account is going away).
|
||||
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// Package compose runs docker compose the way a stack was deployed — which is not always
|
||||
// simply "on this host". A stack deployed by an orchestrator running inside its own
|
||||
// container records paths in that container's filesystem, so compose has to run in there;
|
||||
// a stack deployed to another machine over tcp needs DOCKER_HOST pointed at it. Both the
|
||||
// console and the scheduled updates it writes must invoke it identically, or a redeploy
|
||||
// from one would not match the other's labels.
|
||||
// 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"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
@@ -18,7 +17,6 @@ type Spec struct {
|
||||
Project string // -p
|
||||
Files []string // -f, in order
|
||||
Via string // a container to run compose inside; "" is this host
|
||||
Remote string // a DOCKER_HOST; "" is this machine's Docker
|
||||
Tool string // "docker" or "podman"
|
||||
}
|
||||
|
||||
@@ -33,18 +31,12 @@ func Cmd(ctx context.Context, s Spec, args ...string) *exec.Cmd {
|
||||
base = append(base, "-f", f)
|
||||
}
|
||||
if s.Via != "" {
|
||||
pre := []string{"exec"}
|
||||
if s.Remote != "" {
|
||||
pre = append(pre, "-e", "DOCKER_HOST="+s.Remote)
|
||||
}
|
||||
return exec.CommandContext(ctx, tool, append(append(append(pre, s.Via, "docker"), base...), args...)...)
|
||||
// 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])
|
||||
}
|
||||
if s.Remote != "" {
|
||||
cmd.Env = append(os.Environ(), "DOCKER_HOST="+s.Remote)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -77,23 +77,6 @@ type UpdateSource struct {
|
||||
Check string `json:"check,omitempty"`
|
||||
}
|
||||
|
||||
// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access
|
||||
// to a machine, including when that machine has no OS running. Ham Lab holds the list on
|
||||
// the hub and proxies each one, because a KVM matters exactly when the machine it is
|
||||
// attached to cannot answer for itself.
|
||||
type KVM struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// URL is the device's own address, as you would type it into a browser on the LAN.
|
||||
URL string `json:"url"`
|
||||
// Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
|
||||
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
|
||||
// for a node's KVM, because a KVM earns its keep when the machine is down and cannot
|
||||
// answer for itself.
|
||||
Machine string `json:"machine"`
|
||||
Added time.Time `json:"added"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
// PasswordHash is the single login of installs before users existed. On load it becomes
|
||||
@@ -103,7 +86,6 @@ type Config struct {
|
||||
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
||||
Tokens []Token `json:"tokens,omitempty"`
|
||||
Nodes []Node `json:"nodes,omitempty"`
|
||||
KVMs []KVM `json:"kvms,omitempty"`
|
||||
// Updates: where a newer build comes from. See UpdateSource.
|
||||
Updates UpdateSource `json:"update,omitempty"`
|
||||
|
||||
|
||||
+83
-15
@@ -15,6 +15,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -23,20 +24,22 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // dir | file | link | other
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"` // "-rw-r--r--"
|
||||
Perm string `json:"perm"` // "0644"
|
||||
Mtime int64 `json:"mtime"`
|
||||
User string `json:"user"`
|
||||
Group string `json:"group"`
|
||||
Target string `json:"target,omitempty"` // link target
|
||||
Broken bool `json:"broken,omitempty"` // dangling link
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // dir | file | link | other
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"` // "-rw-r--r--"
|
||||
Perm string `json:"perm"` // "0644"
|
||||
Mtime int64 `json:"mtime"`
|
||||
User string `json:"user"`
|
||||
Group string `json:"group"`
|
||||
Target string `json:"target,omitempty"` // link target
|
||||
Broken bool `json:"broken,omitempty"` // dangling link
|
||||
Preview string `json:"preview,omitempty"` // "image" | "video": the Files tab can show a thumbnail of it
|
||||
}
|
||||
|
||||
type Listing struct {
|
||||
@@ -56,10 +59,12 @@ type FS interface {
|
||||
List(ctx context.Context, path string) (Listing, error)
|
||||
Stat(ctx context.Context, path string) (Info, error)
|
||||
Open(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) // Open from a byte offset: a range request, a player seeking
|
||||
Write(ctx context.Context, path string, r io.Reader) error
|
||||
Mkdir(ctx context.Context, path string) error
|
||||
Touch(ctx context.Context, path string) error
|
||||
Rename(ctx context.Context, from, to string) error
|
||||
Move(ctx context.Context, from, to string, progress io.Writer) error // Rename, or across filesystems a copy and a removal; long, so callers run it as a task
|
||||
Delete(ctx context.Context, path string, recursive bool) error
|
||||
Chmod(ctx context.Context, path string, perm os.FileMode) error
|
||||
}
|
||||
@@ -137,7 +142,7 @@ func entryOf(dir, name string, lst os.FileInfo) Entry {
|
||||
} else if st.IsDir() {
|
||||
e.Type = "dir" // keep dir semantics for navigation; Target says it's a link
|
||||
} else {
|
||||
e.Size = st.Size()
|
||||
e.Size, e.Mtime = st.Size(), st.ModTime().UnixMilli() // the file's, not the link's
|
||||
}
|
||||
case lst.IsDir():
|
||||
e.Type = "dir"
|
||||
@@ -146,6 +151,9 @@ func entryOf(dir, name string, lst os.FileInfo) Entry {
|
||||
default:
|
||||
e.Type = "other"
|
||||
}
|
||||
if e.Type == "file" || (e.Type == "link" && !e.Broken) {
|
||||
e.Preview = media.Kind(name)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -224,6 +232,20 @@ func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||
return os.Open(path)
|
||||
}
|
||||
|
||||
func (l Local) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
|
||||
f, err := l.Open(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if offset > 0 {
|
||||
if _, err := f.(*os.File).Seek(offset, io.SeekStart); err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Write replaces the file's contents, keeping its mode; new files get 0644 (umask).
|
||||
func (Local) Write(_ context.Context, path string, r io.Reader) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
@@ -329,7 +351,15 @@ func (r *cmdReader) Close() error {
|
||||
}
|
||||
|
||||
func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
c := s.cmd(ctx, "read", path)
|
||||
return s.open(ctx, "read", path)
|
||||
}
|
||||
|
||||
func (s Sudo) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
|
||||
return s.open(ctx, "read", "--offset", strconv.FormatInt(offset, 10), path)
|
||||
}
|
||||
|
||||
func (s Sudo) open(ctx context.Context, args ...string) (io.ReadCloser, error) {
|
||||
c := s.cmd(ctx, args...)
|
||||
errb := &strings.Builder{}
|
||||
c.Stderr = errb
|
||||
out, err := c.StdoutPipe()
|
||||
@@ -380,6 +410,30 @@ func (s Sudo) Touch(ctx context.Context, path string) error {
|
||||
func (s Sudo) Rename(ctx context.Context, from, to string) error {
|
||||
return s.runJSON(ctx, nil, "rename", from, to)
|
||||
}
|
||||
|
||||
// Move as root: the helper does the copying and talks on stdout.
|
||||
func (s Sudo) Move(ctx context.Context, from, to string, progress io.Writer) error {
|
||||
c := s.cmd(ctx, "move", from, to)
|
||||
c.Stdout = progress
|
||||
var errb strings.Builder
|
||||
c.Stderr = &errb
|
||||
if err := c.Run(); err != nil {
|
||||
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveCmd is the command a task runs for a long move: this binary's helper, as root when
|
||||
// the backend is.
|
||||
func MoveCmd(ctx context.Context, fsys FS, self, from, to string) *exec.Cmd {
|
||||
if s, ok := fsys.(Sudo); ok {
|
||||
return s.cmd(ctx, "move", from, to)
|
||||
}
|
||||
return exec.CommandContext(ctx, self, "fs", "move", from, to)
|
||||
}
|
||||
func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error {
|
||||
if recursive {
|
||||
return s.runJSON(ctx, nil, "delete", "-r", path)
|
||||
@@ -394,11 +448,16 @@ func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error {
|
||||
func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
|
||||
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
|
||||
if len(args) < 2 {
|
||||
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|delete|chmod> …"))
|
||||
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|move|delete|chmod> …"))
|
||||
}
|
||||
ctx := context.Background()
|
||||
var l Local
|
||||
op := args[0]
|
||||
var offset int64
|
||||
if op == "read" && len(args) >= 4 && args[1] == "--offset" { // read --offset N <path>
|
||||
offset, _ = strconv.ParseInt(args[2], 10, 64)
|
||||
args = append([]string{"read"}, args[3:]...)
|
||||
}
|
||||
clean := func(i int) (string, error) {
|
||||
if i >= len(args) {
|
||||
return "", errors.New("path required")
|
||||
@@ -425,7 +484,7 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
||||
}
|
||||
return emit(stdout, out, stderr)
|
||||
case "read":
|
||||
f, err := l.Open(ctx, p)
|
||||
f, err := l.OpenAt(ctx, p, offset)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
@@ -447,7 +506,7 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
case "rename":
|
||||
case "rename", "move":
|
||||
from, err := clean(1)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
@@ -456,6 +515,15 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if op == "move" {
|
||||
// A cancelled task is a SIGTERM: stop copying and take the partial copy away.
|
||||
mctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
|
||||
defer stop()
|
||||
if err := l.Move(mctx, from, to, stdout); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if err := l.Rename(ctx, from, to); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// IsCrossDevice reports a rename refused because the two ends are on different
|
||||
// filesystems — on a NAS that is every move between datasets. The Sudo backend only has
|
||||
// the helper's words for it.
|
||||
func IsCrossDevice(err error) bool {
|
||||
return errors.Is(err, syscall.EXDEV) || (err != nil && strings.Contains(err.Error(), "cross-device"))
|
||||
}
|
||||
|
||||
// Inside reports whether to lies within from — a folder cannot be moved into itself.
|
||||
func Inside(from, to string) bool {
|
||||
return to == from || strings.HasPrefix(to, strings.TrimSuffix(from, "/")+"/")
|
||||
}
|
||||
|
||||
// Move is Rename, and across filesystems a copy and a removal. The copy is built under a
|
||||
// hidden temporary name beside where it will land and renamed into place only once it is
|
||||
// complete, and the source is removed only after that: a failure part-way, or a cancel,
|
||||
// leaves the source exactly as it was. Progress goes to w, a line at a time, for the task
|
||||
// output that a long move is watched through.
|
||||
func (Local) Move(ctx context.Context, from, to string, w io.Writer) error {
|
||||
if protected[from] {
|
||||
return errors.New("refusing to move " + from)
|
||||
}
|
||||
if Inside(from, to) {
|
||||
return errors.New("a folder can't be moved into itself")
|
||||
}
|
||||
if _, err := os.Lstat(to); err == nil {
|
||||
return errors.New("destination exists")
|
||||
}
|
||||
err := os.Rename(from, to)
|
||||
if err == nil || !IsCrossDevice(err) {
|
||||
return err
|
||||
}
|
||||
return moveByCopy(ctx, from, to, w)
|
||||
}
|
||||
|
||||
// moveByCopy is the slow path: copy, rename into place, remove the original.
|
||||
func moveByCopy(ctx context.Context, from, to string, w io.Writer) error {
|
||||
st, err := os.Lstat(from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(to), "."+filepath.Base(to)+".hamlab-moving")
|
||||
_ = os.RemoveAll(tmp) // a previous attempt that died
|
||||
fmt.Fprintf(w, "%s is on another filesystem: copying, then removing the original\n", filepath.Dir(to))
|
||||
p := &progress{w: w}
|
||||
if err := copyPath(ctx, from, tmp, st, p); err != nil {
|
||||
fmt.Fprintln(w, "copy failed; removing the partial copy, the original is untouched")
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, to); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(w, "copied %s; removing the original\n", p.String())
|
||||
if err := os.RemoveAll(from); err != nil {
|
||||
return fmt.Errorf("copied to %s, but the original could not be removed: %w", to, err)
|
||||
}
|
||||
fmt.Fprintf(w, "moved %s to %s\n", from, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
type progress struct {
|
||||
w io.Writer
|
||||
files int64
|
||||
bytes int64
|
||||
shownFiles int64
|
||||
shownBytes int64
|
||||
}
|
||||
|
||||
func (p *progress) add(files, bytes int64) {
|
||||
p.files += files
|
||||
p.bytes += bytes
|
||||
if p.files-p.shownFiles >= 500 || p.bytes-p.shownBytes >= 512<<20 {
|
||||
fmt.Fprintf(p.w, "copying… %s\n", p.String())
|
||||
p.shownFiles, p.shownBytes = p.files, p.bytes
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progress) String() string {
|
||||
return fmt.Sprintf("%d files, %s", p.files, fmtSize(p.bytes))
|
||||
}
|
||||
|
||||
func fmtSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit && exp < 4; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
|
||||
// copyPath copies one entry of any kind, recursively for a directory, keeping mode, times
|
||||
// and — when running as root — ownership. Devices, pipes and sockets are refused: a copy
|
||||
// of one is not the thing itself.
|
||||
func copyPath(ctx context.Context, src, dst string, st os.FileInfo, p *progress) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := st.Mode()
|
||||
switch {
|
||||
case mode&os.ModeSymlink != 0:
|
||||
target, err := os.Readlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
chown(dst, st)
|
||||
p.add(1, 0)
|
||||
return nil
|
||||
case mode.IsDir():
|
||||
if err := os.Mkdir(dst, mode.Perm()|0o700); err != nil { // writable while it fills, exact mode after
|
||||
return err
|
||||
}
|
||||
ents, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range ents {
|
||||
lst, err := e.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyPath(ctx, filepath.Join(src, e.Name()), filepath.Join(dst, e.Name()), lst, p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
chown(dst, st)
|
||||
if err := os.Chmod(dst, mode.Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(dst, st.ModTime(), st.ModTime())
|
||||
case mode.IsRegular():
|
||||
n, err := copyFile(src, dst, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.add(1, n)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%s: can't copy a device, pipe or socket", src)
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, st os.FileInfo) (int64, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode().Perm())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := io.Copy(out, in)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return n, err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return n, err
|
||||
}
|
||||
chown(dst, st)
|
||||
if err := os.Chmod(dst, st.Mode().Perm()); err != nil { // umask has no say
|
||||
return n, err
|
||||
}
|
||||
return n, os.Chtimes(dst, st.ModTime(), st.ModTime())
|
||||
}
|
||||
|
||||
// chown keeps the owner when this process may set one; an ordinary user's copy is the
|
||||
// user's, which is what a rename would have left anyway.
|
||||
func chown(dst string, st os.FileInfo) {
|
||||
if os.Geteuid() != 0 {
|
||||
return
|
||||
}
|
||||
if sys, ok := st.Sys().(*syscall.Stat_t); ok {
|
||||
_ = os.Lchown(dst, int(sys.Uid), int(sys.Gid))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInside(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
from, to string
|
||||
want bool
|
||||
}{
|
||||
{"/a/b", "/a/b", true}, {"/a/b", "/a/b/c", true}, {"/a/b", "/a/bc", false}, {"/a/b", "/a", false}, {"/", "/x", true},
|
||||
} {
|
||||
if got := Inside(c.from, c.to); got != c.want {
|
||||
t.Errorf("Inside(%q, %q) = %v, want %v", c.from, c.to, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCrossDevice(t *testing.T) {
|
||||
if !IsCrossDevice(&os.LinkError{Op: "rename", Old: "/a", New: "/b", Err: syscall.EXDEV}) {
|
||||
t.Fatal("the kernel's EXDEV")
|
||||
}
|
||||
if !IsCrossDevice(errors.New("rename /a /b: invalid cross-device link")) {
|
||||
t.Fatal("the helper's words for it")
|
||||
}
|
||||
if IsCrossDevice(errors.New("permission denied")) || IsCrossDevice(nil) {
|
||||
t.Fatal("anything else is not")
|
||||
}
|
||||
}
|
||||
|
||||
// The copy path, exercised directly: a tree with nested folders, a symlink, an odd mode
|
||||
// and old mtimes lands whole under the new name, and the original is gone only then.
|
||||
func TestMoveByCopy(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src", "album")
|
||||
if err := os.MkdirAll(filepath.Join(src, "sub", "deep"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
write := func(p, s string, mode os.FileMode) {
|
||||
if err := os.WriteFile(p, []byte(s), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chmod(p, mode)
|
||||
_ = os.Chtimes(p, old, old)
|
||||
}
|
||||
write(filepath.Join(src, "a.txt"), "hello", 0o644)
|
||||
write(filepath.Join(src, "sub", "deep", "b.bin"), strings.Repeat("x", 100000), 0o600)
|
||||
write(filepath.Join(src, "run.sh"), "#!/bin/sh\n", 0o755)
|
||||
if err := os.Symlink("a.txt", filepath.Join(src, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chtimes(filepath.Join(src, "sub"), old, old)
|
||||
|
||||
dst := filepath.Join(root, "dst", "moved")
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out strings.Builder
|
||||
if err := moveByCopy(context.Background(), src, dst, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Lstat(src); !os.IsNotExist(err) {
|
||||
t.Fatal("the original is still there")
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dst, "a.txt")); string(b) != "hello" {
|
||||
t.Fatal("a.txt")
|
||||
}
|
||||
if st, err := os.Stat(filepath.Join(dst, "sub", "deep", "b.bin")); err != nil || st.Size() != 100000 || st.Mode().Perm() != 0o600 || !st.ModTime().Equal(old) {
|
||||
t.Fatalf("b.bin: %v %v", st, err)
|
||||
}
|
||||
if st, _ := os.Stat(filepath.Join(dst, "run.sh")); st.Mode().Perm() != 0o755 {
|
||||
t.Fatal("mode lost")
|
||||
}
|
||||
if target, err := os.Readlink(filepath.Join(dst, "link")); err != nil || target != "a.txt" {
|
||||
t.Fatal("symlink not copied as a symlink")
|
||||
}
|
||||
if st, _ := os.Stat(filepath.Join(dst, "sub")); !st.ModTime().Equal(old) {
|
||||
t.Fatal("folder mtime lost")
|
||||
}
|
||||
if left, _ := filepath.Glob(filepath.Join(root, "dst", ".*hamlab-moving*")); len(left) != 0 {
|
||||
t.Fatal("temporary copy left behind")
|
||||
}
|
||||
if !strings.Contains(out.String(), "moved ") || !strings.Contains(out.String(), "4 files") {
|
||||
t.Fatalf("progress said: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A failure part-way leaves the original as it was and no partial copy.
|
||||
func TestMoveByCopyFailsClean(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "src")
|
||||
if err := os.MkdirAll(src, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "a"), []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syscall.Mkfifo(filepath.Join(src, "pipe"), 0o644); err != nil {
|
||||
t.Skip("no fifos here")
|
||||
}
|
||||
dst := filepath.Join(root, "dst")
|
||||
err := moveByCopy(context.Background(), src, dst, &strings.Builder{})
|
||||
if err == nil || !strings.Contains(err.Error(), "pipe") {
|
||||
t.Fatalf("expected the pipe to be refused, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(src, "a")); err != nil {
|
||||
t.Fatal("the original was touched")
|
||||
}
|
||||
if _, err := os.Lstat(dst); !os.IsNotExist(err) {
|
||||
t.Fatal("a partial copy was left")
|
||||
}
|
||||
if left, _ := filepath.Glob(filepath.Join(root, ".*hamlab-moving*")); len(left) != 0 {
|
||||
t.Fatal("temporary copy left behind")
|
||||
}
|
||||
// a cancel is the same story
|
||||
if err := os.Remove(filepath.Join(src, "pipe")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := moveByCopy(ctx, src, dst, &strings.Builder{}); err == nil {
|
||||
t.Fatal("a cancelled move must fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(src, "a")); err != nil {
|
||||
t.Fatal("the original was touched by a cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// Move on one filesystem is a rename: nothing is copied, and the guards hold.
|
||||
func TestMoveSameFilesystem(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "f")
|
||||
if err := os.WriteFile(src, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var l Local
|
||||
var out strings.Builder
|
||||
if err := l.Move(context.Background(), src, filepath.Join(root, "g"), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatal("a rename has nothing to say")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "g")); err != nil {
|
||||
t.Fatal("not renamed")
|
||||
}
|
||||
if err := l.Move(context.Background(), root, filepath.Join(root, "inside"), &out); err == nil {
|
||||
t.Fatal("into itself")
|
||||
}
|
||||
if err := l.Move(context.Background(), filepath.Join(root, "g"), filepath.Join(root, "g"), &out); err == nil {
|
||||
t.Fatal("onto itself")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "h"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.Move(context.Background(), filepath.Join(root, "g"), filepath.Join(root, "h"), &out); err == nil || !strings.Contains(err.Error(), "exists") {
|
||||
t.Fatalf("onto an existing file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A link to a file carries the file's size and time, so the row and the preview key follow
|
||||
// the file; a link to a folder is a folder; a link to nothing says so.
|
||||
func TestEntryOfLinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("12345"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
_ = os.Chtimes(filepath.Join(dir, "f"), old, old)
|
||||
_ = os.Mkdir(filepath.Join(dir, "d"), 0o755)
|
||||
for _, l := range [][2]string{{"lf", "f"}, {"ld", "d"}, {"lx", "nowhere"}} {
|
||||
if err := os.Symlink(l[1], filepath.Join(dir, l[0])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ent := func(n string) Entry { st, _ := os.Lstat(filepath.Join(dir, n)); return entryOf(dir, n, st) }
|
||||
if e := ent("lf"); e.Type != "link" || e.Size != 5 || e.Mtime != old.UnixMilli() || e.Target != "f" || e.Broken {
|
||||
t.Fatalf("link to file: %+v", e)
|
||||
}
|
||||
if e := ent("ld"); e.Type != "dir" || e.Target != "d" {
|
||||
t.Fatalf("link to dir: %+v", e)
|
||||
}
|
||||
if e := ent("lx"); e.Type != "link" || !e.Broken || e.Preview != "" {
|
||||
t.Fatalf("dangling: %+v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Seeker is an FS file as http.ServeContent wants one — something to seek in — for a file
|
||||
// whose size is already known from a stat. ServeContent seeks to the end for the size, to
|
||||
// the start of the range asked for, then reads: so the backend is opened once, at that
|
||||
// position, and only ever read forward. The Sudo backend's helper is started at the offset
|
||||
// rather than streamed from the top and skipped.
|
||||
type Seeker struct {
|
||||
ctx context.Context
|
||||
fsys FS
|
||||
path string
|
||||
size int64
|
||||
pos int64 // where the next Read reads from
|
||||
at int64 // where cur is positioned, when open
|
||||
cur io.ReadCloser
|
||||
}
|
||||
|
||||
func NewSeeker(ctx context.Context, fsys FS, path string, size int64) *Seeker {
|
||||
return &Seeker{ctx: ctx, fsys: fsys, path: path, size: size}
|
||||
}
|
||||
|
||||
// Check opens the file now, from the start, so a file that cannot be read is known before
|
||||
// any headers go out. Seeking costs nothing; a Read from somewhere else reopens there.
|
||||
func (s *Seeker) Check() error {
|
||||
if s.cur != nil {
|
||||
return nil
|
||||
}
|
||||
f, err := s.fsys.OpenAt(s.ctx, s.path, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.cur, s.at = f, 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Seeker) Seek(offset int64, whence int) (int64, error) {
|
||||
var pos int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
pos = offset
|
||||
case io.SeekCurrent:
|
||||
pos = s.pos + offset
|
||||
case io.SeekEnd:
|
||||
pos = s.size + offset
|
||||
default:
|
||||
return 0, errors.New("bad whence")
|
||||
}
|
||||
if pos < 0 {
|
||||
return 0, errors.New("seek before start")
|
||||
}
|
||||
s.pos = pos // the stream, if open, stays where it is until a Read wants somewhere else
|
||||
return pos, nil
|
||||
}
|
||||
|
||||
func (s *Seeker) Read(p []byte) (int, error) {
|
||||
if s.pos >= s.size {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if s.cur != nil && s.pos != s.at {
|
||||
s.cur.Close()
|
||||
s.cur = nil
|
||||
}
|
||||
if s.cur == nil {
|
||||
f, err := s.fsys.OpenAt(s.ctx, s.path, s.pos)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.cur, s.at = f, s.pos
|
||||
}
|
||||
n, err := s.cur.Read(p)
|
||||
s.pos += int64(n)
|
||||
s.at += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Seeker) Close() error {
|
||||
if s.cur == nil {
|
||||
return nil
|
||||
}
|
||||
err := s.cur.Close()
|
||||
s.cur = nil
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
import "strings"
|
||||
|
||||
type stringsBuilder = strings.Builder
|
||||
|
||||
type countingFS struct {
|
||||
Local
|
||||
opens []int64
|
||||
}
|
||||
|
||||
func (c *countingFS) OpenAt(ctx context.Context, path string, off int64) (io.ReadCloser, error) {
|
||||
c.opens = append(c.opens, off)
|
||||
return c.Local.OpenAt(ctx, path, off)
|
||||
}
|
||||
|
||||
// ServeContent's moves — size from the end, seek to the range, read forward — open the
|
||||
// backend once, at the range; a seek to where the stream already is costs nothing.
|
||||
func TestSeeker(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "f")
|
||||
data := make([]byte, 1000)
|
||||
for i := range data {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
if err := os.WriteFile(p, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fs := &countingFS{}
|
||||
s := NewSeeker(context.Background(), fs, p, int64(len(data)))
|
||||
if n, _ := s.Seek(0, io.SeekEnd); n != 1000 {
|
||||
t.Fatalf("end = %d", n)
|
||||
}
|
||||
if _, err := s.Seek(100, io.SeekStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 50)
|
||||
if n, err := io.ReadFull(s, buf); err != nil || n != 50 || buf[0] != 100 || buf[49] != 149 {
|
||||
t.Fatalf("read at 100: %d %v %v", n, err, buf[:2])
|
||||
}
|
||||
if _, err := s.Seek(150, io.SeekStart); err != nil { // where it already is
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := io.ReadFull(s, buf); n != 50 || buf[0] != 150 {
|
||||
t.Fatal("read after a no-op seek")
|
||||
}
|
||||
if len(fs.opens) != 1 || fs.opens[0] != 100 {
|
||||
t.Fatalf("opens = %v, want [100]", fs.opens)
|
||||
}
|
||||
if _, err := s.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rest, err := io.ReadAll(s)
|
||||
if err != nil || len(rest) != 1000 || rest[999] != data[999] {
|
||||
t.Fatalf("read all from the start: %d %v", len(rest), err)
|
||||
}
|
||||
if len(fs.opens) != 2 || fs.opens[1] != 0 {
|
||||
t.Fatalf("opens = %v, want [100 0]", fs.opens)
|
||||
}
|
||||
if n, err := s.Read(buf); n != 0 || err != io.EOF {
|
||||
t.Fatal("past the end is EOF")
|
||||
}
|
||||
if _, err := s.Seek(-1, io.SeekStart); err == nil {
|
||||
t.Fatal("before the start")
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Check opens at the start and keeps that stream for a read from the start
|
||||
s2 := NewSeeker(context.Background(), fs, p, 1000)
|
||||
if err := s2.Check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2.Seek(0, io.SeekEnd)
|
||||
s2.Seek(0, io.SeekStart)
|
||||
if n, _ := io.ReadFull(s2, buf); n != 50 || buf[0] != 0 {
|
||||
t.Fatal("read from the start after Check")
|
||||
}
|
||||
if len(fs.opens) != 3 {
|
||||
t.Fatalf("Check's open was not reused: %v", fs.opens)
|
||||
}
|
||||
s2.Close()
|
||||
if err := NewSeeker(context.Background(), fs, filepath.Join(t.TempDir(), "nope"), 5).Check(); err == nil {
|
||||
t.Fatal("Check must report a file that can't be opened")
|
||||
}
|
||||
}
|
||||
|
||||
// The helper's read takes an offset, for the Sudo backend.
|
||||
func TestHelperReadOffset(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "f")
|
||||
if err := os.WriteFile(p, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errb stringsBuilder
|
||||
if rc := Helper([]string{"read", "--offset", "7", p}, nil, &out, &errb); rc != 0 {
|
||||
t.Fatalf("rc %d: %s", rc, errb.String())
|
||||
}
|
||||
if out.String() != "789" {
|
||||
t.Fatalf("got %q", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if rc := Helper([]string{"read", p}, nil, &out, &errb); rc != 0 || out.String() != "0123456789" {
|
||||
t.Fatalf("plain read: rc %d %q", rc, out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// Cache keeps finished previews in one database file under Ham Lab's own data directory —
|
||||
// thumbs.db, a bbolt key/value store: pure Go, so the binary stays static, and one file
|
||||
// per machine rather than a tree of hash-named JPEGs. An entry is one file's preview keyed
|
||||
// by its path, size and mtime, so a file that changes gets a new preview and an unchanged
|
||||
// one is never decoded twice. A file ffmpeg could not decode is kept the same way, with
|
||||
// ffmpeg's reason and no picture, so a folder of odd files does not re-run ffmpeg on every
|
||||
// visit. Nothing expires: a preview is a few kilobytes, and only files somebody actually
|
||||
// scrolled past are ever made. The file is opened the first time something is stored, so a
|
||||
// machine nobody browses with previews on never has one.
|
||||
type Cache struct {
|
||||
Path string
|
||||
mu sync.Mutex
|
||||
db *bolt.DB
|
||||
err error
|
||||
}
|
||||
|
||||
// Entry is one cached preview, or the remembered reason there is none.
|
||||
type Entry struct {
|
||||
Path string `json:"path"`
|
||||
Engine string `json:"engine,omitempty"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Made time.Time `json:"made"`
|
||||
Failure string `json:"failure,omitempty"` // set instead of JPEG when ffmpeg refused the file
|
||||
JPEG []byte `json:"-"`
|
||||
}
|
||||
|
||||
var (
|
||||
bucketThumbs = []byte("thumbs")
|
||||
bucketMeta = []byte("meta")
|
||||
keyCount = []byte("count")
|
||||
)
|
||||
|
||||
// Key identifies one file's preview. "v1" is the preview format: bump it to retire every
|
||||
// cached preview at once.
|
||||
func Key(path string, size, mtime int64) string {
|
||||
h := sha256.Sum256([]byte("v1\x00" + path + "\x00" + strconv.FormatInt(size, 10) + "\x00" + strconv.FormatInt(mtime, 10)))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func NewCache(path string) *Cache { return &Cache{Path: path} }
|
||||
|
||||
// open the database once; an open that failed is not retried, and previews still get made
|
||||
// and served — they are simply not kept, and the log says why.
|
||||
func (c *Cache) open() (*bolt.DB, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.db != nil || c.err != nil {
|
||||
return c.db, c.err
|
||||
}
|
||||
db, err := bolt.Open(c.Path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
c.err = err
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Update(func(tx *bolt.Tx) error {
|
||||
if _, err := tx.CreateBucketIfNotExists(bucketThumbs); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.CreateBucketIfNotExists(bucketMeta)
|
||||
return err
|
||||
}); err != nil {
|
||||
db.Close()
|
||||
c.err = err
|
||||
return nil, err
|
||||
}
|
||||
c.db = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close releases the file. Previews after it are made but not kept.
|
||||
func (c *Cache) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.db == nil {
|
||||
return nil
|
||||
}
|
||||
err := c.db.Close()
|
||||
c.db, c.err = nil, errors.New("cache closed")
|
||||
return err
|
||||
}
|
||||
|
||||
// Get returns the entry for a key. A database that does not exist yet is a miss, not a reason
|
||||
// to make one.
|
||||
func (c *Cache) Get(key string) (Entry, bool, error) {
|
||||
c.mu.Lock()
|
||||
db := c.db
|
||||
c.mu.Unlock()
|
||||
if db == nil {
|
||||
if _, err := os.Stat(c.Path); err != nil {
|
||||
return Entry{}, false, nil
|
||||
}
|
||||
var err error
|
||||
if db, err = c.open(); err != nil {
|
||||
return Entry{}, false, err
|
||||
}
|
||||
}
|
||||
var e Entry
|
||||
found := false
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
v := tx.Bucket(bucketThumbs).Get([]byte(key))
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
e, err = decode(v) // copies: a value is only valid inside the transaction
|
||||
found = err == nil
|
||||
return err
|
||||
})
|
||||
return e, found, err
|
||||
}
|
||||
|
||||
// Put stores an entry, replacing any under the same key.
|
||||
func (c *Cache) Put(key string, e Entry) error {
|
||||
if len(e.JPEG) == 0 && e.Failure == "" {
|
||||
return errors.New("empty preview")
|
||||
}
|
||||
if e.Made.IsZero() {
|
||||
e.Made = time.Now()
|
||||
}
|
||||
db, err := c.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := encode(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketThumbs)
|
||||
k := []byte(key)
|
||||
fresh := b.Get(k) == nil
|
||||
if err := b.Put(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if fresh {
|
||||
m := tx.Bucket(bucketMeta)
|
||||
return m.Put(keyCount, be64(count(m)+1))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Stats is how many previews are kept and how big the file is.
|
||||
func (c *Cache) Stats() (entries int64, bytes int64) {
|
||||
st, err := os.Stat(c.Path)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
bytes = st.Size()
|
||||
db, err := c.open()
|
||||
if err != nil {
|
||||
return 0, bytes
|
||||
}
|
||||
_ = db.View(func(tx *bolt.Tx) error { entries = count(tx.Bucket(bucketMeta)); return nil })
|
||||
return entries, bytes
|
||||
}
|
||||
|
||||
func count(m *bolt.Bucket) int64 {
|
||||
if v := m.Get(keyCount); len(v) == 8 {
|
||||
return int64(binary.BigEndian.Uint64(v))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func be64(n int64) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, uint64(n))
|
||||
return b
|
||||
}
|
||||
|
||||
// A value is a 4-byte header length, the header as JSON, then the JPEG bytes.
|
||||
func encode(e Entry) ([]byte, error) {
|
||||
hdr, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := make([]byte, 4+len(hdr)+len(e.JPEG))
|
||||
binary.BigEndian.PutUint32(v, uint32(len(hdr)))
|
||||
copy(v[4:], hdr)
|
||||
copy(v[4+len(hdr):], e.JPEG)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decode(v []byte) (Entry, error) {
|
||||
if len(v) < 4 {
|
||||
return Entry{}, errors.New("short value")
|
||||
}
|
||||
n := int(binary.BigEndian.Uint32(v))
|
||||
if 4+n > len(v) {
|
||||
return Entry{}, errors.New("bad header length")
|
||||
}
|
||||
var e Entry
|
||||
if err := json.Unmarshal(v[4:4+n], &e); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
if rest := v[4+n:]; len(rest) > 0 {
|
||||
e.JPEG = append([]byte(nil), rest...)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package media
|
||||
|
||||
// What to run when a video is played in the browser: the qualities it is offered in (the
|
||||
// ladder), the ways a machine could make one of them on the device asked for, in the order
|
||||
// worth trying, and the ffmpeg argument list for one piece-making run. All decisions, no
|
||||
// processes, so it can be tested. Everything is H.264 High and AAC: what every browser plays.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rung is one quality the player can pick: a size, a frame rate and a bit rate.
|
||||
type Rung struct {
|
||||
Name string `json:"name"` // in URLs: the width, "1920"
|
||||
Label string `json:"label"` // what the menu says: "1080s", "720p"
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
FPS float64 `json:"fps"`
|
||||
Bitrate int `json:"bitrate"` // video, bits a second, on average
|
||||
Peak int `json:"peak"` // what the playlist promises, sound included
|
||||
level string // H.264 level, "5.2"
|
||||
codecs string // for the master playlist
|
||||
}
|
||||
|
||||
// One step of the ladder: the long side for a 360 video (its width), the short side for a flat
|
||||
// one, and the bit rate (Mb/s ×10) at more than 30 frames a second and at 30 or fewer.
|
||||
type step struct {
|
||||
size int
|
||||
label string
|
||||
hi, lo int
|
||||
cap30 bool
|
||||
}
|
||||
|
||||
// A 360 picture is seen a quarter of its width at a time, so it needs more pixels than a flat
|
||||
// one for the same sharpness; YouTube names its sizes by the flat picture they look like
|
||||
// ("1080s"), and so does this.
|
||||
var vrSteps = []step{
|
||||
{3840, "2160s", 220, 160, false},
|
||||
{2880, "1440s", 120, 90, false},
|
||||
{1920, "1080s", 60, 45, false},
|
||||
{1280, "720s", 25, 25, true},
|
||||
}
|
||||
|
||||
var flatSteps = []step{
|
||||
{2160, "2160p", 160, 120, false},
|
||||
{1440, "1440p", 90, 65, false},
|
||||
{1080, "1080p", 50, 35, false},
|
||||
{720, "720p", 25, 25, true},
|
||||
{480, "480p", 12, 12, true},
|
||||
}
|
||||
|
||||
const audioRate = 160_000
|
||||
|
||||
func even(x float64) int { return int(math.Round(x/2)) * 2 }
|
||||
|
||||
// Ladder is the qualities a video is offered in, best first: every step no bigger than the
|
||||
// picture itself, or the picture's own size if it is smaller than all of them.
|
||||
func Ladder(i Info) []Rung {
|
||||
v := i.Video
|
||||
if v == nil || v.Width <= 0 || v.Height <= 0 {
|
||||
return nil
|
||||
}
|
||||
vr := v.Spherical
|
||||
steps := flatSteps
|
||||
w, h := v.Shown()
|
||||
src := min(w, h) // a flat picture is named by its short side
|
||||
if vr {
|
||||
steps, src = vrSteps, w
|
||||
}
|
||||
// A picture between two steps (a 2292×960 capture) gets its own size on top, at the bit
|
||||
// rate of the step above, so the best on offer is never less than the file.
|
||||
var out []Rung
|
||||
for k, s := range steps {
|
||||
if s.size > src {
|
||||
continue
|
||||
}
|
||||
if len(out) == 0 && s.size < src && k > 0 { // never above the top step: H.264 stops there
|
||||
own := steps[k-1]
|
||||
own.size, own.label, own.cap30 = src, strconv.Itoa(src)+map[bool]string{true: "s", false: "p"}[vr], false
|
||||
out = append(out, rung(i, own))
|
||||
}
|
||||
out = append(out, rung(i, s))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
last := steps[len(steps)-1]
|
||||
last.size, last.label = src, strconv.Itoa(src)+map[bool]string{true: "s", false: "p"}[vr]
|
||||
out = append(out, rung(i, last))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rung(i Info, s step) Rung {
|
||||
v := i.Video
|
||||
sw, sh := v.Shown()
|
||||
var w, h int
|
||||
switch {
|
||||
case v.Spherical:
|
||||
w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
|
||||
case sw >= sh:
|
||||
h, w = s.size, even(float64(s.size)*float64(sw)/float64(sh))
|
||||
default: // standing up
|
||||
w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
|
||||
}
|
||||
fps := v.FPS
|
||||
if fps <= 0 {
|
||||
fps = 30
|
||||
}
|
||||
for fps > 60.5 || (s.cap30 && fps > 30.5) {
|
||||
fps /= 2
|
||||
}
|
||||
fps = math.Round(fps*1000) / 1000
|
||||
br := s.lo
|
||||
if fps > 30.5 {
|
||||
br = s.hi
|
||||
}
|
||||
r := Rung{Name: strconv.Itoa(w), Label: s.label, Width: w, Height: h, FPS: fps, Bitrate: br * 100_000}
|
||||
r.Peak = r.Bitrate*3/2 + audioRate
|
||||
r.level = level(w, h, fps)
|
||||
lv, _ := strconv.ParseFloat(r.level, 64)
|
||||
r.codecs = fmt.Sprintf("avc1.6400%02x", int(math.Round(lv*10)))
|
||||
if i.Audio != nil {
|
||||
r.codecs += ",mp4a.40.2"
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// level is the lowest H.264 level that takes this many macroblocks a frame and a second.
|
||||
func level(w, h int, fps float64) string {
|
||||
mbs := ((w + 15) / 16) * ((h + 15) / 16)
|
||||
rate := float64(mbs) * fps
|
||||
for _, l := range []struct {
|
||||
name string
|
||||
mbps float64
|
||||
frame int
|
||||
}{
|
||||
{"3.1", 108000, 3600}, {"3.2", 216000, 5120}, {"4.0", 245760, 8192}, {"4.2", 522240, 8704},
|
||||
{"5.0", 589824, 22080}, {"5.1", 983040, 36864}, {"5.2", 2073600, 36864},
|
||||
} {
|
||||
if rate <= l.mbps && mbs <= l.frame {
|
||||
return l.name
|
||||
}
|
||||
}
|
||||
return "6.2"
|
||||
}
|
||||
|
||||
// fpsArg is a frame rate as ffmpeg's fps filter takes it, exactly for the NTSC rates.
|
||||
func fpsArg(f float64) string {
|
||||
for _, n := range []float64{24, 30, 60, 120} {
|
||||
if math.Abs(f-n*1000/1001) < 0.01 {
|
||||
return fmt.Sprintf("%d/1001", int(n*1000))
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(f, 'f', 3, 64), "0"), ".")
|
||||
}
|
||||
|
||||
// Recipe is one way to make a quality: the engine's name for the record, the device it runs
|
||||
// on ("cpu" for the software encoder), and the ffmpeg arguments that go before the input, the
|
||||
// video filter, and the video codec.
|
||||
type Recipe struct {
|
||||
Engine string
|
||||
Device string
|
||||
Pre []string
|
||||
VF string
|
||||
Video []string
|
||||
}
|
||||
|
||||
// CPU is the device name for software encoding.
|
||||
const CPU = "cpu"
|
||||
|
||||
// Device is one thing a machine can make H.264 on, for the Transcode device setting.
|
||||
type Device struct {
|
||||
ID string `json:"id"` // a render node, or "cpu"
|
||||
Name string `json:"name"` // "Intel Arc A380", "CPU (libx264)"
|
||||
Integrated bool `json:"integrated,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"` // when not OK: why
|
||||
}
|
||||
|
||||
// Devices lists what this machine could make the player's H.264 on: every GPU, usable or not
|
||||
// (and why not), then the CPU.
|
||||
func Devices(c Caps) []Device {
|
||||
var out []Device
|
||||
if c.Error != "" {
|
||||
return []Device{{ID: CPU, Name: "CPU", Reason: c.Error}}
|
||||
}
|
||||
for _, g := range c.GPUs {
|
||||
d := Device{ID: g.Device, Name: g.Name, Integrated: g.Integrated}
|
||||
if d.Name == "" {
|
||||
d.Name = g.Device
|
||||
}
|
||||
switch {
|
||||
case g.API != "vaapi":
|
||||
d.Reason = "only VAAPI devices encode here"
|
||||
case g.Encodes != nil && !has(g.Encodes, "h264"):
|
||||
d.Reason = "doesn't encode H.264"
|
||||
default:
|
||||
d.OK = true
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
for _, g := range c.Unusable {
|
||||
name := g.Name
|
||||
if name == "" {
|
||||
name = g.Device
|
||||
}
|
||||
out = append(out, Device{ID: g.Device, Name: name, Integrated: g.Integrated, Reason: g.Reason})
|
||||
}
|
||||
d := Device{ID: CPU, Name: "CPU (libx264)", OK: has(c.Encoders, "libx264")}
|
||||
if !d.OK {
|
||||
d.Reason = "this ffmpeg has no libx264"
|
||||
}
|
||||
return append(out, d)
|
||||
}
|
||||
|
||||
// recipes are the ways to make quality r of src on device, best first: on a GPU, decoding on
|
||||
// the same card (frames never leave it) before decoding in software and uploading; the CPU
|
||||
// last, as the fallback whatever was asked. device "" is any: a card on the bus before the
|
||||
// integrated GPU. An empty list means nothing here can.
|
||||
func recipes(c Caps, device string, src Info, r Rung) []Recipe {
|
||||
var out []Recipe
|
||||
gpus := append([]GPU(nil), c.GPUs...)
|
||||
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated })
|
||||
kbps := r.Bitrate / 1000
|
||||
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*3/2) + "k", "-bufsize", strconv.Itoa(kbps*2) + "k",
|
||||
"-profile:v", "high", "-level", r.level}
|
||||
fps := ""
|
||||
if src.Video == nil || src.Video.FPS <= 0 || math.Abs(src.Video.FPS-r.FPS) > 0.01 {
|
||||
fps = "fps=" + fpsArg(r.FPS) + ","
|
||||
}
|
||||
if device != CPU {
|
||||
for i := range gpus {
|
||||
g := &gpus[i]
|
||||
// Encodes is vainfo's answer; without vainfo there is none, and the card is tried
|
||||
// anyway, with the next recipe behind it.
|
||||
if g.API != "vaapi" || (g.Encodes != nil && !has(g.Encodes, "h264")) || (device != "" && g.Device != device) {
|
||||
continue
|
||||
}
|
||||
video := append(append([]string{"-c:v", "h264_vaapi"}, rate...), "-bf", "0")
|
||||
name := g.Name
|
||||
if name == "" {
|
||||
name = g.Device
|
||||
}
|
||||
// The card doesn't turn a phone's picture by itself (ffmpeg's own turning is for
|
||||
// pictures in memory), so it is told to, and ffmpeg is told not to.
|
||||
scale := turn(src) + fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", r.Width, r.Height)
|
||||
// HDR10 (PQ) is tone-mapped on the card, which needs the file's mastering data;
|
||||
// HLG is made to look right on an ordinary screen as it is, so it is only scaled
|
||||
// (tonemap_vaapi refuses it). Either way the next recipe tone-maps in software.
|
||||
if src.Video != nil && has(g.Codecs, src.Video.Codec) {
|
||||
vf := fps + scale
|
||||
if src.PQ() {
|
||||
vf = fps + "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + scale
|
||||
}
|
||||
out = append(out, Recipe{Engine: name, Device: g.Device, Pre: []string{"-noautorotate", "-hwaccel", "vaapi", "-hwaccel_device", g.Device, "-hwaccel_output_format", "vaapi"}, VF: vf, Video: video})
|
||||
}
|
||||
cpuVF := fmt.Sprintf("%sformat=nv12,scale=%d:%d,hwupload", fps, r.Width, r.Height)
|
||||
if src.PQ() {
|
||||
cpuVF = fps + toneMap + fmt.Sprintf(",scale=%d:%d,format=nv12,hwupload", r.Width, r.Height)
|
||||
}
|
||||
out = append(out, Recipe{Engine: name + ", decoded on the CPU", Device: g.Device, Pre: []string{"-init_hw_device", "vaapi=va:" + g.Device, "-filter_hw_device", "va"},
|
||||
VF: cpuVF, Video: video})
|
||||
}
|
||||
}
|
||||
if has(c.Encoders, "libx264") {
|
||||
vf := fmt.Sprintf("%sscale=%d:%d,format=yuv420p", fps, r.Width, r.Height)
|
||||
if src.PQ() {
|
||||
vf = fps + toneMap + fmt.Sprintf(",scale=%d:%d,format=yuv420p", r.Width, r.Height)
|
||||
}
|
||||
out = append(out, Recipe{Engine: "CPU (libx264)", Device: CPU, VF: vf, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-sc_threshold", "0"}, rate...)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// turn is the card's filter that turns a picture the way it is meant to be shown.
|
||||
func turn(src Info) string {
|
||||
if src.Video == nil {
|
||||
return ""
|
||||
}
|
||||
switch src.Video.Rotation {
|
||||
case 90:
|
||||
return "transpose_vaapi=dir=clock,"
|
||||
case 180:
|
||||
return "transpose_vaapi=dir=reversal,"
|
||||
case 270:
|
||||
return "transpose_vaapi=dir=cclock,"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toneMap turns an HDR10 picture into an ordinary one, in software.
|
||||
const toneMap = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv"
|
||||
|
||||
// Seg is the length of a piece, in seconds.
|
||||
const Seg = 3.0
|
||||
|
||||
// tsBase is added to every run's times, so pieces from different runs follow on: without it
|
||||
// a run from the top would start just below zero (the B-frame and AAC delay) and the muxer
|
||||
// would shift that run alone.
|
||||
const tsBase = 10
|
||||
|
||||
// pieceArgs is one run making quality r from piece n on, into dir as <n>.ts: an accurate seek
|
||||
// to n×Seg, a keyframe on every Seg mark and nowhere else (so every piece starts on one, and
|
||||
// pieces from any run and quality line up), times put back where that is in the film. in is a
|
||||
// path or, for a file on another machine, an http URL.
|
||||
func pieceArgs(dir, in string, src Info, rc Recipe, r Rung, n int) []string {
|
||||
at := strconv.FormatFloat(float64(n)*Seg, 'f', 3, 64)
|
||||
a := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
|
||||
a = append(a, rc.Pre...)
|
||||
if strings.HasPrefix(in, "http://") || strings.HasPrefix(in, "https://") {
|
||||
a = append(a, "-reconnect", "1", "-reconnect_on_network_error", "1", "-reconnect_delay_max", "4")
|
||||
}
|
||||
a = append(a, "-ss", at, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
|
||||
if rc.VF != "" {
|
||||
a = append(a, "-vf", rc.VF)
|
||||
}
|
||||
a = append(a, rc.Video...)
|
||||
if src.Audio == nil {
|
||||
a = append(a, "-an")
|
||||
} else {
|
||||
a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2")
|
||||
}
|
||||
gop := int(math.Ceil(r.FPS*Seg)) * 4
|
||||
a = append(a, "-g", strconv.Itoa(gop), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%g)", Seg),
|
||||
"-output_ts_offset", strconv.FormatFloat(float64(n)*Seg+tsBase, 'f', 3, 64),
|
||||
"-f", "hls", "-hls_time", strconv.FormatFloat(Seg-0.5, 'f', 2, 64), "-hls_list_size", "0",
|
||||
"-hls_flags", "temp_file", "-hls_segment_type", "mpegts", "-start_number", strconv.Itoa(n),
|
||||
"-hls_segment_filename", dir+"/%d.ts", dir+"/ffmpeg.m3u8")
|
||||
return a
|
||||
}
|
||||
|
||||
func has(list []string, x string) bool {
|
||||
for _, v := range list {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func src(codec string, w, h int, fps float64) Info {
|
||||
return Info{Duration: 100, Video: &Track{Codec: codec, Width: w, Height: h, FPS: fps}, Audio: &Track{Codec: "opus", Channels: 2}}
|
||||
}
|
||||
|
||||
func TestLadder(t *testing.T) {
|
||||
vr := src("hevc", 7680, 3840, 50)
|
||||
vr.Video.Spherical = true
|
||||
r := Ladder(vr)
|
||||
if len(r) != 4 || r[0].Name != "3840" || r[0].Height != 1920 || r[0].level != "5.2" || r[3].FPS != 25 || r[0].Label != "2160s" {
|
||||
t.Fatalf("8K 360: %+v", r)
|
||||
}
|
||||
if r[0].codecs != "avc1.640034,mp4a.40.2" {
|
||||
t.Fatalf("codecs %q", r[0].codecs)
|
||||
}
|
||||
r = Ladder(src("h264", 2292, 960, 60)) // a capture: its own size on top, then the steps under it
|
||||
if len(r) != 3 || r[0].Label != "960p" || r[0].Width != 2292 || r[0].FPS != 60 || r[0].Bitrate != 5_000_000 || r[1].Label != "720p" || r[1].Width != 1720 || r[1].FPS != 30 {
|
||||
t.Fatalf("2292×960: %+v", r)
|
||||
}
|
||||
r = Ladder(src("h264", 1920, 1080, 59.94))
|
||||
if len(r) != 3 || r[0].Label != "1080p" || r[0].FPS != 59.94 || r[1].FPS != 29.97 || r[2].Width != 854 {
|
||||
t.Fatalf("1080p: %+v", r)
|
||||
}
|
||||
if r = Ladder(src("h264", 1080, 1920, 30)); r[0].Width != 1080 || r[0].Height != 1920 { // standing up
|
||||
t.Fatalf("portrait: %+v", r)
|
||||
}
|
||||
if r = Ladder(src("h264", 320, 240, 30)); len(r) != 1 || r[0].Width != 320 || r[0].Label != "240p" {
|
||||
t.Fatalf("tiny: %+v", r)
|
||||
}
|
||||
silent := src("h264", 1280, 720, 30)
|
||||
silent.Audio = nil
|
||||
if r = Ladder(silent); strings.Contains(r[0].codecs, "mp4a") {
|
||||
t.Fatalf("no sound, no AAC: %q", r[0].codecs)
|
||||
}
|
||||
if Ladder(Info{Duration: 10}) != nil {
|
||||
t.Fatal("no picture, no ladder")
|
||||
}
|
||||
if fpsArg(29.97) != "30000/1001" || fpsArg(25) != "25" {
|
||||
t.Fatal(fpsArg(29.97), fpsArg(25))
|
||||
}
|
||||
}
|
||||
|
||||
func gridCaps() Caps {
|
||||
return Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac", "libsvtav1", "libx264", "libx265"},
|
||||
GPUs: []GPU{
|
||||
{API: "vaapi", Device: "/dev/dri/renderD128", Name: "Intel UHD Graphics 750", Integrated: true, Codecs: []string{"h264", "hevc", "vp9"}, Encodes: []string{"h264", "hevc"}},
|
||||
{API: "vaapi", Device: "/dev/dri/renderD129", Name: "Intel Arc A380", Codecs: []string{"h264", "hevc", "vp9"}, Encodes: []string{"av1", "h264", "hevc"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func engineNames(r []Recipe) []string {
|
||||
out := make([]string, len(r))
|
||||
for i := range r {
|
||||
out[i] = r[i].Engine
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRecipes(t *testing.T) {
|
||||
c := gridCaps()
|
||||
in := src("hevc", 3840, 2160, 60)
|
||||
r1080 := Ladder(in)[2]
|
||||
// any device: the card first, decoding on it; then the iGPU the same way; then the CPU
|
||||
got := strings.Join(engineNames(recipes(c, "", in, r1080)), "|")
|
||||
if got != "Intel Arc A380|Intel Arc A380, decoded on the CPU|Intel UHD Graphics 750|Intel UHD Graphics 750, decoded on the CPU|CPU (libx264)" {
|
||||
t.Fatalf("any: %s", got)
|
||||
}
|
||||
// the iGPU asked for: only it, then the CPU as the fallback
|
||||
if got = strings.Join(engineNames(recipes(c, "/dev/dri/renderD128", in, r1080)), "|"); got != "Intel UHD Graphics 750|Intel UHD Graphics 750, decoded on the CPU|CPU (libx264)" {
|
||||
t.Fatalf("iGPU: %s", got)
|
||||
}
|
||||
if got = strings.Join(engineNames(recipes(c, CPU, in, r1080)), "|"); got != "CPU (libx264)" {
|
||||
t.Fatalf("cpu: %s", got)
|
||||
}
|
||||
// an AV1 source: neither card decodes it, so no same-card recipe
|
||||
if got = strings.Join(engineNames(recipes(c, "/dev/dri/renderD129", src("av1", 1920, 1080, 30), r1080)), "|"); got != "Intel Arc A380, decoded on the CPU|CPU (libx264)" {
|
||||
t.Fatalf("av1: %s", got)
|
||||
}
|
||||
// a machine with no GPU and no x264: nothing
|
||||
bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac"}}
|
||||
if len(recipes(bare, "", in, r1080)) != 0 {
|
||||
t.Fatal("no x264 means nothing")
|
||||
}
|
||||
// 30 fps from 60 for the lower rungs; HDR tone-mapped on both paths
|
||||
r720 := Ladder(in)[3]
|
||||
hdr := src("hevc", 3840, 2160, 60)
|
||||
hdr.Video.ColorTransfer = "smpte2084"
|
||||
rs := recipes(c, "", hdr, r720)
|
||||
if !strings.HasPrefix(rs[0].VF, "fps=30,tonemap_vaapi") || !strings.Contains(rs[1].VF, "tonemap=hable") || !strings.HasSuffix(rs[1].VF, "hwupload") || !strings.Contains(rs[len(rs)-1].VF, "tonemap=hable") {
|
||||
t.Fatalf("hdr: %q … %q … %q", rs[0].VF, rs[1].VF, rs[len(rs)-1].VF)
|
||||
}
|
||||
hdr.Video.ColorTransfer = "arib-std-b67" // HLG: shown as it is, on every path
|
||||
if rs = recipes(c, "", hdr, r720); strings.Contains(rs[0].VF, "tonemap") || strings.Contains(rs[1].VF, "tonemap") {
|
||||
t.Fatalf("hlg: %q … %q", rs[0].VF, rs[1].VF)
|
||||
}
|
||||
// a phone's picture, upright: the ladder by the shown size, turned on the card
|
||||
phone := src("hevc", 1920, 1080, 30)
|
||||
phone.Video.Rotation = 90
|
||||
lr := Ladder(phone)
|
||||
if lr[0].Width != 1080 || lr[0].Height != 1920 {
|
||||
t.Fatalf("upright: %+v", lr[0])
|
||||
}
|
||||
if rs = recipes(c, "", phone, lr[0]); !strings.HasPrefix(rs[0].VF, "transpose_vaapi=dir=clock,scale_vaapi=w=1080:h=1920") || rs[0].Pre[0] != "-noautorotate" || strings.Contains(rs[1].VF, "transpose") {
|
||||
t.Fatalf("turned: %q %v / %q", rs[0].VF, rs[0].Pre, rs[1].VF)
|
||||
}
|
||||
// no vainfo: the card is tried all the same
|
||||
nov := gridCaps()
|
||||
nov.GPUs[1].Encodes = nil
|
||||
if rs = recipes(nov, "/dev/dri/renderD129", in, r1080); rs[0].Engine != "Intel Arc A380" || !Devices(nov)[1].OK {
|
||||
t.Fatalf("no vainfo: %v", engineNames(rs))
|
||||
}
|
||||
if rs := recipes(c, "", in, r1080); strings.Contains(rs[0].VF, "fps=") {
|
||||
t.Fatalf("same rate needs no fps filter: %q", rs[0].VF)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevices(t *testing.T) {
|
||||
c := gridCaps()
|
||||
c.Unusable = []GPU{{Device: "/dev/dri/renderD130", Name: "Something", Reason: "permission denied"}}
|
||||
d := Devices(c)
|
||||
if len(d) != 4 || !d[0].OK || !d[1].OK || d[2].OK || d[2].Reason == "" || d[3].ID != CPU || !d[3].OK {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
if d := Devices(Caps{Error: "ffmpeg is not installed"}); len(d) != 1 || d[0].OK {
|
||||
t.Fatalf("no ffmpeg: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPieceArgs(t *testing.T) {
|
||||
in := src("h264", 1920, 1080, 60)
|
||||
r := Ladder(in)[0]
|
||||
rc := recipes(gridCaps(), "/dev/dri/renderD129", in, r)[0]
|
||||
a := strings.Join(pieceArgs("/d", "/v.mkv", in, rc, r, 12), " ")
|
||||
for _, want := range []string{"-hwaccel vaapi -hwaccel_device /dev/dri/renderD129", "-ss 36.000 -i /v.mkv", "-map 0:v:0 -map 0:a:0?",
|
||||
"-vf scale_vaapi=w=1920:h=1080:format=nv12", "-c:v h264_vaapi -b:v 5000k -maxrate 7500k", "-level 4.2", "-c:a aac -b:a 160k -ac 2",
|
||||
"-force_key_frames expr:gte(t,n_forced*3)", "-output_ts_offset 46.000", "-hls_time 2.50", "-start_number 12", "-hls_segment_filename /d/%d.ts"} {
|
||||
if !strings.Contains(a, want) {
|
||||
t.Errorf("missing %q in\n%s", want, a)
|
||||
}
|
||||
}
|
||||
if strings.Contains(a, "-reconnect") {
|
||||
t.Error("a path needs no reconnect")
|
||||
}
|
||||
if a := strings.Join(pieceArgs("/d", "http://10.0.0.234:8100/api/play-src/x", in, rc, r, 0), " "); !strings.Contains(a, "-reconnect 1") {
|
||||
t.Error("a URL reconnects")
|
||||
}
|
||||
in.Audio = nil
|
||||
if a := strings.Join(pieceArgs("/d", "/v", in, rc, r, 0), " "); !strings.Contains(a, "-an") {
|
||||
t.Error("no sound means -an")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapsOnly(t *testing.T) {
|
||||
c := gridCaps()
|
||||
if got := c.Only("/dev/dri/renderD129"); len(got.GPUs) != 1 || got.GPUs[0].Name != "Intel Arc A380" || len(c.GPUs) != 2 {
|
||||
t.Fatalf("one card: %+v", got.GPUs)
|
||||
}
|
||||
if len(c.Only(CPU).GPUs) != 0 || len(c.Only("/dev/dri/renderD200").GPUs) != 0 || len(c.Only("").GPUs) != 2 {
|
||||
t.Fatal("cpu, a device not here, any")
|
||||
}
|
||||
// previews then go to that card, or the CPU
|
||||
if g := c.Only("/dev/dri/renderD129").pick("hevc"); g == nil || g.Device != "/dev/dri/renderD129" {
|
||||
t.Fatalf("pick: %+v", g)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package media
|
||||
|
||||
// Running one long ffmpeg: as the daemon's user directly, or as root through this binary's
|
||||
// run-ffmpeg helper when the file needs root to read. The helper is told to finish by its
|
||||
// standard input closing, which also happens by itself if the daemon goes away — so a
|
||||
// transcode never outlives the console that asked for it.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
type ffproc struct {
|
||||
cmd *exec.Cmd
|
||||
in io.WriteCloser // the helper's stdin, root mode only
|
||||
done chan struct{}
|
||||
err error
|
||||
tail *tailBuf
|
||||
}
|
||||
|
||||
// startFF launches ffmpeg with args, under nice where there is one.
|
||||
func startFF(c Caps, self string, root bool, args []string) (*ffproc, error) {
|
||||
var argv []string
|
||||
if root {
|
||||
argv = priv.Wrap(append([]string{self, "run-ffmpeg"}, args...)...)
|
||||
} else {
|
||||
argv = append([]string{c.FFmpeg}, args...)
|
||||
if c.Nice != "" {
|
||||
argv = append([]string{c.Nice, "-n", "10"}, argv...)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
p := &ffproc{cmd: cmd, done: make(chan struct{}), tail: &tailBuf{}}
|
||||
cmd.Stderr = p.tail
|
||||
cmd.Stdout = io.Discard
|
||||
if root {
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.in = in
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go func() {
|
||||
p.err = cmd.Wait()
|
||||
close(p.done)
|
||||
}()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// exited reports whether ffmpeg has finished, and how.
|
||||
func (p *ffproc) exited() (bool, error) {
|
||||
select {
|
||||
case <-p.done:
|
||||
return true, p.err
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// end asks ffmpeg to finish and waits a little for it.
|
||||
func (p *ffproc) end() {
|
||||
if done, _ := p.exited(); done {
|
||||
return
|
||||
}
|
||||
if p.in != nil {
|
||||
p.in.Close()
|
||||
} else {
|
||||
_ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM)
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) // a root helper ignores this; it ends on its own within its own grace
|
||||
select {
|
||||
case <-p.done:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pause stops ffmpeg where it is (SIGSTOP), and resume lets it go on: a stream far enough
|
||||
// ahead of its player waits rather than making the rest of the film. A root run is paused by
|
||||
// its helper, which is told on its standard input.
|
||||
func (p *ffproc) pause() error { return p.signal(syscall.SIGSTOP, "stop\n") }
|
||||
func (p *ffproc) resume() error { return p.signal(syscall.SIGCONT, "cont\n") }
|
||||
|
||||
func (p *ffproc) signal(sig syscall.Signal, word string) error {
|
||||
if done, _ := p.exited(); done {
|
||||
return errors.New("ended")
|
||||
}
|
||||
if p.in != nil {
|
||||
_, err := io.WriteString(p.in, word)
|
||||
return err
|
||||
}
|
||||
return syscall.Kill(-p.cmd.Process.Pid, sig)
|
||||
}
|
||||
|
||||
// kill ends ffmpeg now, without waiting: a paused one too.
|
||||
func (p *ffproc) kill() {
|
||||
if done, _ := p.exited(); done {
|
||||
return
|
||||
}
|
||||
if p.in != nil {
|
||||
p.in.Close() // the helper ends it, waking it first if it is paused
|
||||
return
|
||||
}
|
||||
_ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
|
||||
// lastWords is what ffmpeg said last, for the record.
|
||||
func (p *ffproc) lastWords() string { return p.tail.last() }
|
||||
|
||||
// tailBuf keeps the last few KB written to it.
|
||||
type tailBuf struct {
|
||||
mu sync.Mutex
|
||||
b []byte
|
||||
}
|
||||
|
||||
const tailKeep = 4096
|
||||
|
||||
func (t *tailBuf) Write(b []byte) (int, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.b = append(t.b, b...)
|
||||
if len(t.b) > tailKeep {
|
||||
t.b = t.b[len(t.b)-tailKeep:]
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func (t *tailBuf) last() string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return lastLine(string(bytes.TrimSpace(t.b)))
|
||||
}
|
||||
|
||||
// RunFFmpegHelper implements `hamlab run-ffmpeg <args…>`: ffmpeg as this user (root, when
|
||||
// the daemon asked through sudo), finishing when stdin closes. Returns ffmpeg's exit code.
|
||||
func RunFFmpegHelper(args []string, stdin io.Reader, stderr io.Writer) int {
|
||||
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
io.WriteString(stderr, "ffmpeg is not installed\n")
|
||||
return 1
|
||||
}
|
||||
argv := []string{ffmpeg}
|
||||
if nice, err := exec.LookPath("nice"); err == nil {
|
||||
argv = []string{nice, "-n", "10", ffmpeg}
|
||||
}
|
||||
cmd := exec.Command(argv[0], append(argv[1:], args...)...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Stderr = stderr
|
||||
cmd.Stdout = io.Discard
|
||||
if err := cmd.Start(); err != nil {
|
||||
io.WriteString(stderr, err.Error()+"\n")
|
||||
return 1
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
// "stop" and "cont" pause and resume it; the end of input (the daemon letting go)
|
||||
// ends it.
|
||||
sc := bufio.NewScanner(stdin)
|
||||
for sc.Scan() {
|
||||
switch sc.Text() {
|
||||
case "stop":
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGSTOP)
|
||||
case "cont":
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGCONT)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
}()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
var werr error
|
||||
select {
|
||||
case werr = <-done:
|
||||
case <-ctx.Done():
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGCONT) // a paused one can't act on TERM
|
||||
select {
|
||||
case werr = <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
werr = <-done
|
||||
}
|
||||
}
|
||||
var ee *exec.ExitError
|
||||
if errors.As(werr, &ee) {
|
||||
return ee.ExitCode()
|
||||
}
|
||||
if werr != nil {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// clearDir empties a folder without removing it.
|
||||
func clearDir(dir string) error {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range ents {
|
||||
if err := os.RemoveAll(dir + "/" + e.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
// Track is one stream of a media file, as ffprobe reports it.
|
||||
type Track struct {
|
||||
Index int `json:"index"`
|
||||
Type string `json:"type"` // video | audio
|
||||
Codec string `json:"codec"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
PixFmt string `json:"pix_fmt,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR
|
||||
Channels int `json:"channels,omitempty"`
|
||||
AttachedPic bool `json:"attached_pic,omitempty"`
|
||||
Spherical bool `json:"spherical,omitempty"` // an equirectangular 360 picture
|
||||
Rotation int `json:"rotation,omitempty"` // degrees clockwise to turn it to be shown: 0, 90, 180, 270 (a phone held upright)
|
||||
}
|
||||
|
||||
// Shown is the picture's size as it is shown, turned by its rotation.
|
||||
func (t *Track) Shown() (w, h int) {
|
||||
if t.Rotation == 90 || t.Rotation == 270 {
|
||||
return t.Height, t.Width
|
||||
}
|
||||
return t.Width, t.Height
|
||||
}
|
||||
|
||||
// Info is what a file holds: the picture to take, the sound to go with it, and the whole.
|
||||
type Info struct {
|
||||
Container string `json:"container"` // "matroska,webm", "mov,mp4,m4a,3gp,3g2,mj2"
|
||||
Duration float64 `json:"duration"`
|
||||
BitRate int64 `json:"bit_rate,omitempty"` // bits per second, whole file
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Video *Track `json:"video,omitempty"` // the first real picture stream; cover art is not it
|
||||
Audio *Track `json:"audio,omitempty"` // the first sound stream
|
||||
}
|
||||
|
||||
// PQ reports HDR10 (and Dolby Vision's base layer): a picture that can't be shown on an SDR
|
||||
// screen without tone mapping. HLG, the other kind, can, if a little flat.
|
||||
func (i Info) PQ() bool { return i.Video != nil && i.Video.ColorTransfer == "smpte2084" }
|
||||
|
||||
// HDR reports a picture that needs tone mapping to look right on an SDR screen. (The player
|
||||
// tone-maps only PQ: HLG is made to be shown on an SDR screen as it is.)
|
||||
func (i Info) HDR() bool {
|
||||
return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67")
|
||||
}
|
||||
|
||||
type probeJSON struct {
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Profile string `json:"profile"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
ColorTransfer string `json:"color_transfer"`
|
||||
Channels int `json:"channels"`
|
||||
Disposition struct {
|
||||
AttachedPic int `json:"attached_pic"`
|
||||
} `json:"disposition"`
|
||||
SideData []struct {
|
||||
Type string `json:"side_data_type"`
|
||||
Projection string `json:"projection"`
|
||||
Rotation float64 `json:"rotation"`
|
||||
} `json:"side_data_list"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
FormatName string `json:"format_name"`
|
||||
Duration string `json:"duration"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Size string `json:"size"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
// Inspect asks ffprobe what a file is. As root through sudo when asked, for a file only
|
||||
// root may read. An error about the file itself is a DecodeError.
|
||||
func Inspect(ctx context.Context, c Caps, path string, root bool) (Info, error) {
|
||||
if c.FFprobe == "" {
|
||||
return Info{}, errors.New("ffprobe is not installed")
|
||||
}
|
||||
argv := []string{c.FFprobe, "-v", "error", "-show_entries",
|
||||
"stream=index,codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,avg_frame_rate,color_transfer,channels,disposition:stream_side_data=side_data_type,projection,rotation:format=format_name,duration,bit_rate,size",
|
||||
"-of", "json", path}
|
||||
if root {
|
||||
argv = priv.Wrap(argv...)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
var errb bytes.Buffer
|
||||
cmd.Stderr = &errb
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return Info{}, ctx.Err()
|
||||
}
|
||||
msg := lastLine(errb.String())
|
||||
if msg == "" {
|
||||
msg = "ffprobe: " + err.Error()
|
||||
}
|
||||
return Info{}, &DecodeError{Msg: msg}
|
||||
}
|
||||
var pj probeJSON
|
||||
if err := json.Unmarshal(out, &pj); err != nil {
|
||||
return Info{}, &DecodeError{Msg: "ffprobe: unreadable answer"}
|
||||
}
|
||||
in := Info{Container: pj.Format.FormatName}
|
||||
in.Duration, _ = strconv.ParseFloat(pj.Format.Duration, 64)
|
||||
in.BitRate, _ = strconv.ParseInt(pj.Format.BitRate, 10, 64)
|
||||
in.Size, _ = strconv.ParseInt(pj.Format.Size, 10, 64)
|
||||
var art *Track
|
||||
for _, s := range pj.Streams {
|
||||
st := &Track{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile, PixFmt: s.PixFmt, Width: s.Width, Height: s.Height,
|
||||
ColorTransfer: s.ColorTransfer, Channels: s.Channels, AttachedPic: s.Disposition.AttachedPic != 0}
|
||||
// A 360 video says so in its spherical tag (ffmpeg reads the old XML one and the sv3d
|
||||
// box); an untagged one is still taken for 360 when it is 2:1 and too big for anything
|
||||
// else.
|
||||
for _, sd := range s.SideData {
|
||||
if sd.Type == "Spherical Mapping" && strings.HasPrefix(sd.Projection, "equirectangular") {
|
||||
st.Spherical = true
|
||||
}
|
||||
if sd.Type == "Display Matrix" {
|
||||
// ffprobe's rotation is counter-clockwise; ffmpeg turns it back the other way
|
||||
st.Rotation = ((-int(math.Round(sd.Rotation/90))*90)%360 + 360) % 360
|
||||
}
|
||||
}
|
||||
st.Spherical = st.Spherical || (s.CodecType == "video" && s.Width == 2*s.Height && s.Width >= 3840)
|
||||
st.FPS = fps(s.AvgFrameRate)
|
||||
if st.FPS == 0 {
|
||||
st.FPS = fps(s.RFrameRate)
|
||||
}
|
||||
switch {
|
||||
case s.CodecType == "video" && st.AttachedPic:
|
||||
if art == nil {
|
||||
art = st
|
||||
}
|
||||
case s.CodecType == "video" && in.Video == nil:
|
||||
in.Video = st
|
||||
case s.CodecType == "audio" && in.Audio == nil:
|
||||
in.Audio = st
|
||||
}
|
||||
}
|
||||
if in.Video == nil {
|
||||
in.Video = art // cover art is the only picture there is
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// fps parses ffprobe's "60000/1001".
|
||||
func fps(r string) float64 {
|
||||
n, d, ok := strings.Cut(r, "/")
|
||||
if !ok {
|
||||
v, _ := strconv.ParseFloat(r, 64)
|
||||
return v
|
||||
}
|
||||
nf, _ := strconv.ParseFloat(n, 64)
|
||||
df, _ := strconv.ParseFloat(d, 64)
|
||||
if df == 0 {
|
||||
return 0
|
||||
}
|
||||
return nf / df
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ownerOf is "root:render 0660" for a device node, the thing to say when it cannot be opened.
|
||||
func ownerOf(st os.FileInfo) string {
|
||||
sys, ok := st.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
u := strconv.Itoa(int(sys.Uid))
|
||||
if x, err := user.LookupId(u); err == nil {
|
||||
u = x.Username
|
||||
}
|
||||
g := strconv.Itoa(int(sys.Gid))
|
||||
if x, err := user.LookupGroupId(g); err == nil {
|
||||
g = x.Name
|
||||
}
|
||||
return fmt.Sprintf("%s:%s %04o", u, g, st.Mode().Perm())
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package media
|
||||
|
||||
// Playing a video in the browser at the quality the connection can take: HLS, made on the fly
|
||||
// by ffmpeg on the device asked for (the Transcode device setting), YouTube's way. The player
|
||||
// is told every quality is all there from the start, in Seg-second pieces, and each piece is
|
||||
// made when it is first asked for.
|
||||
//
|
||||
// One ffmpeg runs per play at a time. It starts at the piece asked for and runs on ahead of the
|
||||
// player, paused once it is far enough ahead; a piece asked for well past where it has got to
|
||||
// (a seek), or at another quality, ends it and starts a new one there. Every run is made the
|
||||
// same way (pieceArgs), so pieces from different runs and qualities follow on from one another.
|
||||
//
|
||||
// The file is a path on this machine or, when the device asked for is on another one, a URL
|
||||
// the hub serves it at. Nothing is kept: a play nobody asks anything of is forgotten, and the
|
||||
// folder is emptied when the daemon starts.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
runAhead = 10 // pieces a run may make past the last one asked for before it is paused
|
||||
jump = 3 // a piece more than this far past what's made is a seek: start again there
|
||||
idleRun = 45 * time.Second // a run nobody has asked anything of for this long is ended
|
||||
idlePlay = 30 * time.Minute // a play nobody has asked anything of for this long is forgotten
|
||||
budget = 2 << 30 // bytes of pieces one play keeps; those asked for least lately go first
|
||||
// PieceWait is the longest a request waits for its piece: under the hub proxy's 60 s wait
|
||||
// for a node to answer.
|
||||
PieceWait = 45 * time.Second
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("no such piece")
|
||||
|
||||
// Plays is every play on this machine.
|
||||
type Plays struct {
|
||||
dir string
|
||||
self string // this binary, for root runs through run-ffmpeg
|
||||
maxRuns int
|
||||
|
||||
mu sync.Mutex
|
||||
plays map[string]*Play
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// NewPlays keeps pieces under <dataDir>/transcode, which it empties first.
|
||||
func NewPlays(dataDir, self string, maxRuns int) *Plays {
|
||||
dir := filepath.Join(dataDir, "transcode")
|
||||
_ = os.MkdirAll(dir, 0o700)
|
||||
_ = clearDir(dir)
|
||||
m := &Plays{dir: dir, self: self, maxRuns: max(maxRuns, 1), plays: map[string]*Play{}, stop: make(chan struct{})}
|
||||
go m.tend()
|
||||
return m
|
||||
}
|
||||
|
||||
// Play is one video opened for watching.
|
||||
type Play struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // the file's name, for the record
|
||||
Info Info `json:"info"`
|
||||
Rungs []Rung `json:"rungs"` // best first
|
||||
Device string `json:"device"` // asked for: a render node, "cpu", or "" for the best here
|
||||
Remote bool `json:"remote"` // the file is on another machine, read over the network
|
||||
|
||||
caps Caps
|
||||
in string
|
||||
root bool
|
||||
segs int
|
||||
dir string
|
||||
m *Plays
|
||||
|
||||
mu sync.Mutex
|
||||
seen time.Time
|
||||
run *playRun
|
||||
tried map[int]int // per rung: recipes that failed to make anything, skipped from now on
|
||||
}
|
||||
|
||||
type playRun struct {
|
||||
rung int
|
||||
recipe Recipe
|
||||
start int // the first piece this run makes
|
||||
head int // the last piece finished (start-1 before the first)
|
||||
want int // the last piece asked for
|
||||
asked time.Time
|
||||
proc *ffproc
|
||||
paused bool
|
||||
dead bool // ended, or exited
|
||||
killed bool // ended by us, not by itself
|
||||
exited bool
|
||||
err error
|
||||
tick chan struct{} // closed when head moves or the run ends
|
||||
}
|
||||
|
||||
func newPlayID() string {
|
||||
b := make([]byte, 12)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Open starts a play of in (a path, or an http URL for a file on another machine). Nothing
|
||||
// runs until a piece is asked for.
|
||||
func (m *Plays) Open(c Caps, root bool, in, name string, src Info, device string) (*Play, error) {
|
||||
rungs := Ladder(src)
|
||||
if len(rungs) == 0 || src.Duration <= 0 {
|
||||
return nil, errors.New("this video's length or picture size isn't known, so it can't be played")
|
||||
}
|
||||
if len(recipes(c, device, src, rungs[0])) == 0 {
|
||||
return nil, errors.New("nothing on this machine encodes H.264 (no GPU that does, and no libx264)")
|
||||
}
|
||||
segs := max(int(math.Ceil((src.Duration-0.25)/Seg)), 1) // the last piece takes in what's left
|
||||
p := &Play{ID: newPlayID(), Name: name, Info: src, Rungs: rungs, Device: device, Remote: strings.HasPrefix(in, "http"),
|
||||
caps: c, in: in, root: root, segs: segs, m: m, seen: time.Now(), tried: map[int]int{}}
|
||||
p.dir = filepath.Join(m.dir, p.ID)
|
||||
m.mu.Lock()
|
||||
m.plays[p.ID] = p
|
||||
m.mu.Unlock()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Plays) Find(id string) (*Play, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.plays[id]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Close ends a play and deletes its pieces.
|
||||
func (m *Plays) Close(id string) {
|
||||
m.mu.Lock()
|
||||
p, ok := m.plays[id]
|
||||
delete(m.plays, id)
|
||||
m.mu.Unlock()
|
||||
if ok {
|
||||
p.mu.Lock()
|
||||
p.end()
|
||||
p.mu.Unlock()
|
||||
go os.RemoveAll(p.dir)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Play) rung(name string) int {
|
||||
for i, r := range p.Rungs {
|
||||
if r.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Master is the playlist of qualities, lowest first, so a player that starts on the first one
|
||||
// listed starts on the one that surely plays.
|
||||
func (p *Play) Master() []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
for i := len(p.Rungs) - 1; i >= 0; i-- {
|
||||
r := p.Rungs[i]
|
||||
fmt.Fprintf(&b, "#EXT-X-STREAM-INF:BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d,RESOLUTION=%dx%d,FRAME-RATE=%.3f,CODECS=\"%s\"\n%s.m3u8\n",
|
||||
r.Peak, r.Bitrate+audioRate, r.Width, r.Height, r.FPS, r.codecs, r.Name)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// Media is one quality's playlist: every piece, made or not.
|
||||
func (p *Play) Media(name string) ([]byte, bool) {
|
||||
if p.rung(name) < 0 {
|
||||
return nil, false
|
||||
}
|
||||
var b bytes.Buffer
|
||||
last := p.Info.Duration - float64(p.segs-1)*Seg
|
||||
fmt.Fprintf(&b, "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n",
|
||||
int(math.Ceil(math.Max(last, Seg))))
|
||||
for n := 0; n < p.segs; n++ {
|
||||
d := Seg
|
||||
if n == p.segs-1 {
|
||||
d = last
|
||||
}
|
||||
fmt.Fprintf(&b, "#EXTINF:%.6f,\n%s/%d.ts\n", d, name, n)
|
||||
}
|
||||
b.WriteString("#EXT-X-ENDLIST\n")
|
||||
return b.Bytes(), true
|
||||
}
|
||||
|
||||
func (p *Play) piece(rung, n int) string {
|
||||
return filepath.Join(p.dir, p.Rungs[rung].Name, strconv.Itoa(n)+".ts")
|
||||
}
|
||||
|
||||
// Segment answers the file of piece n at quality name, making it first if need be: it waits
|
||||
// for the run already making it, or starts one there.
|
||||
func (p *Play) Segment(done <-chan struct{}, name string, n int) (string, error) {
|
||||
ri := p.rung(name)
|
||||
if ri < 0 || n < 0 || n >= p.segs {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
path := p.piece(ri, n)
|
||||
deadline := time.After(PieceWait)
|
||||
for {
|
||||
p.mu.Lock()
|
||||
now := time.Now()
|
||||
p.seen = now
|
||||
r := p.run
|
||||
if r != nil && r.rung == ri && !r.dead {
|
||||
r.want, r.asked = n, now
|
||||
}
|
||||
if exists(path) {
|
||||
p.mu.Unlock()
|
||||
_ = os.Chtimes(path, now, now) // the budget keeps what was asked for last
|
||||
return path, nil
|
||||
}
|
||||
if r != nil && r.exited && !r.killed && r.rung == ri && r.start == n {
|
||||
// This run started at this very piece and ended without it: that way of making
|
||||
// it doesn't work here. The next one is tried, if there is one.
|
||||
p.tried[ri]++
|
||||
log.Printf("files: play %s: %s made nothing at %s piece %d: %v: %s", p.Name, r.recipe.Engine, name, n, r.err, r.proc.lastWords())
|
||||
if p.tried[ri] >= len(recipes(p.caps, p.Device, p.Info, p.Rungs[ri])) {
|
||||
err := fmt.Errorf("couldn't make %s: %s", p.Rungs[ri].Label, r.proc.lastWords())
|
||||
p.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
p.run = nil
|
||||
r = nil
|
||||
}
|
||||
if r == nil || r.dead || r.rung != ri || n < r.start || n <= r.head || n > r.head+jump {
|
||||
p.mu.Unlock()
|
||||
p.m.makeRoom(p)
|
||||
p.mu.Lock()
|
||||
if p.run == r { // nobody started one meanwhile
|
||||
if err := p.start(ri, n); err != nil {
|
||||
p.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
tick := r.tick
|
||||
p.mu.Unlock()
|
||||
select {
|
||||
case <-tick:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
case <-done:
|
||||
return "", errors.New("gone")
|
||||
case <-deadline:
|
||||
return "", fmt.Errorf("%s piece %d took longer than %s", name, n, PieceWait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// end stops this play's run. p.mu is held.
|
||||
func (p *Play) end() {
|
||||
if r := p.run; r != nil && !r.dead {
|
||||
r.dead, r.killed = true, true
|
||||
r.proc.kill()
|
||||
}
|
||||
}
|
||||
|
||||
// makeRoom ends the run asked of least lately, if every slot is taken by another play. It
|
||||
// never holds two plays' locks at once.
|
||||
func (m *Plays) makeRoom(except *Play) {
|
||||
m.mu.Lock()
|
||||
plays := make([]*Play, 0, len(m.plays))
|
||||
for _, p := range m.plays {
|
||||
if p != except {
|
||||
plays = append(plays, p)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
var oldest *Play
|
||||
var when time.Time
|
||||
running := 0
|
||||
for _, p := range plays {
|
||||
p.mu.Lock()
|
||||
if r := p.run; r != nil && !r.dead {
|
||||
running++
|
||||
if oldest == nil || r.asked.Before(when) {
|
||||
oldest, when = p, r.asked
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
if running >= m.maxRuns && oldest != nil {
|
||||
oldest.mu.Lock()
|
||||
oldest.end()
|
||||
oldest.mu.Unlock()
|
||||
log.Printf("files: %d plays encoding; ended %s's, asked of least lately", running, oldest.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// start begins a run making quality ri from piece n on, ending the one before. p.mu is held.
|
||||
func (p *Play) start(ri, n int) error {
|
||||
p.end()
|
||||
rs := recipes(p.caps, p.Device, p.Info, p.Rungs[ri])
|
||||
if p.tried[ri] >= len(rs) {
|
||||
return errors.New("nothing here can make this quality")
|
||||
}
|
||||
rc := rs[p.tried[ri]]
|
||||
out := filepath.Join(p.dir, p.Rungs[ri].Name)
|
||||
if err := os.MkdirAll(out, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
proc, err := startFF(p.caps, p.m.self, p.root, pieceArgs(out, p.in, p.Info, rc, p.Rungs[ri], n))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r := &playRun{rung: ri, recipe: rc, start: n, head: n - 1, want: n, asked: time.Now(), proc: proc, tick: make(chan struct{})}
|
||||
p.run = r
|
||||
go func() {
|
||||
<-proc.done
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
r.dead, r.exited, r.err = true, true, proc.err
|
||||
p.scan(r)
|
||||
close(r.tick)
|
||||
r.tick = make(chan struct{})
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// scan moves a run's head on past the pieces it has finished. p.mu is held.
|
||||
func (p *Play) scan(r *playRun) bool {
|
||||
moved := false
|
||||
for r.head+1 < p.segs && exists(p.piece(r.rung, r.head+1)) {
|
||||
r.head++
|
||||
moved = true
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
// PlayStatus is what a play is doing, for the player's menu.
|
||||
type PlayStatus struct {
|
||||
Engine string `json:"engine,omitempty"` // what the run going now runs on
|
||||
Quality string `json:"quality,omitempty"`
|
||||
From int `json:"from"` // seconds into the film the run started at
|
||||
Ahead int `json:"ahead"`
|
||||
Paused bool `json:"paused,omitempty"`
|
||||
}
|
||||
|
||||
func (p *Play) Status() PlayStatus {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
r := p.run
|
||||
if r == nil || r.dead {
|
||||
return PlayStatus{}
|
||||
}
|
||||
return PlayStatus{Engine: r.recipe.Engine, Quality: p.Rungs[r.rung].Label, From: int(float64(r.start) * Seg), Ahead: r.head - r.want, Paused: r.paused}
|
||||
}
|
||||
|
||||
// Engine is what this play would run on first, for the player to say before anything runs.
|
||||
func (p *Play) Engine() string {
|
||||
rs := recipes(p.caps, p.Device, p.Info, p.Rungs[0])
|
||||
if len(rs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return rs[0].Engine
|
||||
}
|
||||
|
||||
// tend, ten times a second: notices finished pieces, pauses a run that is far enough ahead and
|
||||
// wakes it when the player catches up, ends one nobody is asking anything of; and every ten
|
||||
// seconds keeps each play inside its budget and forgets plays nobody watches.
|
||||
func (m *Plays) tend() {
|
||||
t := time.NewTicker(100 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-m.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
m.mu.Lock()
|
||||
plays := make([]*Play, 0, len(m.plays))
|
||||
for _, p := range m.plays {
|
||||
plays = append(plays, p)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
now := time.Now()
|
||||
for _, p := range plays {
|
||||
p.mu.Lock()
|
||||
if r := p.run; r != nil && !r.dead {
|
||||
if p.scan(r) {
|
||||
close(r.tick)
|
||||
r.tick = make(chan struct{})
|
||||
}
|
||||
ahead := r.head - r.want
|
||||
switch {
|
||||
case now.Sub(r.asked) > idleRun:
|
||||
p.end()
|
||||
case !r.paused && ahead >= runAhead:
|
||||
if r.proc.pause() == nil {
|
||||
r.paused = true
|
||||
}
|
||||
case r.paused && ahead < runAhead-3:
|
||||
if r.proc.resume() == nil {
|
||||
r.paused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
idle := now.Sub(p.seen) > idlePlay
|
||||
p.mu.Unlock()
|
||||
if idle {
|
||||
m.Close(p.ID)
|
||||
} else if i%100 == 0 {
|
||||
p.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trim deletes the pieces asked for least lately until the play is inside its budget, never
|
||||
// the ones its run has made ahead of the player.
|
||||
func (p *Play) trim() {
|
||||
type piece struct {
|
||||
path string
|
||||
size int64
|
||||
at time.Time
|
||||
}
|
||||
var all []piece
|
||||
var total int64
|
||||
_ = filepath.WalkDir(p.dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".ts") {
|
||||
return nil
|
||||
}
|
||||
if fi, err := d.Info(); err == nil {
|
||||
all = append(all, piece{path, fi.Size(), fi.ModTime()})
|
||||
total += fi.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if total <= budget {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
keep := map[string]bool{}
|
||||
if r := p.run; r != nil && !r.dead {
|
||||
for n := r.want - 1; n <= r.head+1; n++ {
|
||||
if n >= 0 && n < p.segs {
|
||||
keep[p.piece(r.rung, n)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
sort.Slice(all, func(a, b int) bool { return all[a].at.Before(all[b].at) })
|
||||
for _, x := range all {
|
||||
if total <= budget*3/4 {
|
||||
break
|
||||
}
|
||||
if !keep[x.path] && os.Remove(x.path) == nil {
|
||||
total -= x.size
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// inspectPiece answers when a piece's first video packet is shown, whether it is a keyframe,
|
||||
// how many keyframes the piece has, and when its last packet is.
|
||||
func inspectPiece(t *testing.T, path string) (first float64, key bool, keys int, last float64) {
|
||||
t.Helper()
|
||||
b, err := exec.Command("ffprobe", "-v", "error", "-show_entries", "packet=pts_time,flags,codec_type", "-of", "json", path).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("ffprobe %s: %v", path, err)
|
||||
}
|
||||
var p struct {
|
||||
Packets []struct {
|
||||
PTS string `json:"pts_time"`
|
||||
Flags string `json:"flags"`
|
||||
Type string `json:"codec_type"`
|
||||
} `json:"packets"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &p)
|
||||
first = -1
|
||||
for _, k := range p.Packets {
|
||||
if k.Type != "video" {
|
||||
continue
|
||||
}
|
||||
x, _ := strconv.ParseFloat(k.PTS, 64)
|
||||
if first < 0 {
|
||||
first, key = x, strings.HasPrefix(k.Flags, "K")
|
||||
}
|
||||
first = math.Min(first, x)
|
||||
last = math.Max(last, x)
|
||||
if strings.HasPrefix(k.Flags, "K") {
|
||||
keys++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TestPieces makes pieces of a made-up video (or SRC, on DEVICE, to try the real thing on a
|
||||
// machine with a GPU) in order and after a seek, at two qualities, and checks they line up:
|
||||
// each starts on a keyframe at its own place in the film, with no other keyframe inside it.
|
||||
func TestPieces(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("no ffmpeg")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
in := os.Getenv("SRC")
|
||||
if in == "" {
|
||||
in = filepath.Join(dir, "in.mkv")
|
||||
if out, err := exec.Command("ffmpeg", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=50:duration=40",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=40", "-c:v", "libx264", "-preset", "ultrafast", "-g", "500",
|
||||
"-c:a", "libopus", "-shortest", in).CombinedOutput(); err != nil {
|
||||
t.Fatalf("making the test video: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
caps := Probe(context.Background())
|
||||
if caps.Error != "" {
|
||||
t.Skip(caps.Error)
|
||||
}
|
||||
info, err := Inspect(context.Background(), caps, in, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := NewPlays(dir, "", 2)
|
||||
device := os.Getenv("DEVICE")
|
||||
if device == "" {
|
||||
device = CPU
|
||||
}
|
||||
p, err := m.Open(caps, false, in, "in.mkv", info, device)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close(p.ID)
|
||||
if !strings.Contains(string(p.Master()), "RESOLUTION=") {
|
||||
t.Fatal(string(p.Master()))
|
||||
}
|
||||
low := p.Rungs[len(p.Rungs)-1].Name
|
||||
var offset float64
|
||||
far := min(p.segs-2, 60)
|
||||
for i, n := range []int{0, 1, 2, far, far + 1, 3} {
|
||||
t0 := time.Now()
|
||||
path, err := p.Segment(nil, low, n)
|
||||
if err != nil {
|
||||
t.Fatalf("piece %d: %v", n, err)
|
||||
}
|
||||
first, key, keys, last := inspectPiece(t, path)
|
||||
if i == 0 {
|
||||
offset = first
|
||||
}
|
||||
at := first - offset
|
||||
t.Logf("piece %d: %d ms on %s, starts %.3f, %d keyframe(s), %.2f s long", n, time.Since(t0).Milliseconds(), p.Status().Engine, at, keys, last-first)
|
||||
if !key || keys != 1 {
|
||||
t.Errorf("piece %d: first packet keyframe %v, %d keyframes", n, key, keys)
|
||||
}
|
||||
if math.Abs(at-float64(n)*Seg) > 0.05 {
|
||||
t.Errorf("piece %d starts at %.3f, want %.3f", n, at, float64(n)*Seg)
|
||||
}
|
||||
if n < p.segs-1 && math.Abs(last-first-Seg) > 0.1 {
|
||||
t.Errorf("piece %d is %.3f s long", n, last-first)
|
||||
}
|
||||
}
|
||||
// another quality, at a piece already made at the first: its own run, lined up too
|
||||
if len(p.Rungs) > 1 {
|
||||
path, err := p.Segment(nil, p.Rungs[0].Name, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first, key, _, _ := inspectPiece(t, path); !key || math.Abs(first-offset-2*Seg) > 0.05 {
|
||||
t.Errorf("%s piece 2 starts %.3f (keyframe %v)", p.Rungs[0].Name, first-offset, key)
|
||||
}
|
||||
}
|
||||
if _, err := p.Segment(nil, low, p.segs); err != ErrNotFound {
|
||||
t.Errorf("past the end: %v", err)
|
||||
}
|
||||
if _, err := p.Segment(nil, "999", 0); err != ErrNotFound {
|
||||
t.Errorf("no such quality: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
// Package thumbs makes the Files tab's previews: one small JPEG for an image or a video,
|
||||
// by ffmpeg, decoded on a GPU where the machine has one it can use and on the CPU
|
||||
// otherwise.
|
||||
//
|
||||
// Nothing about the machine is assumed. A probe looks ffmpeg and ffprobe up, opens every
|
||||
// /dev/dri/renderD* and exercises it (a VAAPI upload and download of a synthetic frame),
|
||||
// so a node the daemon's user cannot open, or a driver that does not speak VAAPI, is
|
||||
// reported once rather than tried per file. vainfo, where installed, says which codecs
|
||||
// each node decodes, and a video goes to the first node that lists its codec; without
|
||||
// vainfo the first working node is tried and ffmpeg falls back to software on its own when
|
||||
// the stream turns out unsupported. A hardware run that fails outright is retried on the
|
||||
// CPU before the file is called undecodable. Images are always decoded on the CPU: one
|
||||
// frame of a JPEG at a reduced DCT size costs less than the trip through a GPU.
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
// GPU is one hardware decoder as the probe found it.
|
||||
type GPU struct {
|
||||
API string `json:"api"` // vaapi | cuda
|
||||
Device string `json:"device"` // /dev/dri/renderD128; "0" for cuda (its device index)
|
||||
Name string `json:"name,omitempty"` // "Intel Arc A380"
|
||||
Codecs []string `json:"codecs,omitempty"` // ffmpeg codec names it decodes; nil when nothing could say
|
||||
Encodes []string `json:"encodes,omitempty"` // ffmpeg codec names it encodes, from vainfo
|
||||
Integrated bool `json:"integrated,omitempty"` // on the CPU die; a card on the bus is preferred for encoding
|
||||
Reason string `json:"reason,omitempty"` // Unusable only: why
|
||||
Permission bool `json:"permission,omitempty"` // Unusable only: the node exists but this user may not open it
|
||||
}
|
||||
|
||||
// Caps is what the machine can make previews with, as probed.
|
||||
type Caps struct {
|
||||
FFmpeg string `json:"ffmpeg,omitempty"`
|
||||
FFprobe string `json:"ffprobe,omitempty"`
|
||||
Nice string `json:"nice,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
GPUs []GPU `json:"gpus"` // usable, in node order
|
||||
Encoders []string `json:"encoders,omitempty"` // the encoders ffmpeg itself was built with that matter here
|
||||
Unusable []GPU `json:"unusable,omitempty"` // render nodes that exist but cannot be used, with the reason
|
||||
Error string `json:"error,omitempty"` // why nothing can be made at all
|
||||
ProbedAt time.Time `json:"probed_at"`
|
||||
}
|
||||
|
||||
// Opts shapes one preview.
|
||||
type Opts struct {
|
||||
Size int // the long edge in pixels; 256 when zero
|
||||
Timeout time.Duration // for the whole job, ffprobe and every ffmpeg attempt; 30 s when zero
|
||||
Kind string // image | video | audio; from the name when empty (a URL has none)
|
||||
}
|
||||
|
||||
// Only is the caps with one device left to decode on: a render node, CPU for none, or ""
|
||||
// for all of them — the Transcode device setting, which previews follow too. A device this
|
||||
// machine doesn't have leaves the CPU.
|
||||
func (c Caps) Only(device string) Caps {
|
||||
if device == "" {
|
||||
return c
|
||||
}
|
||||
out := c
|
||||
out.GPUs = nil
|
||||
for _, g := range c.GPUs {
|
||||
if g.Device == device {
|
||||
out.GPUs = append(out.GPUs, g)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (o Opts) defaults() Opts {
|
||||
if o.Size <= 0 {
|
||||
o.Size = 256
|
||||
}
|
||||
if o.Timeout <= 0 {
|
||||
o.Timeout = 30 * time.Second
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Result is a finished preview.
|
||||
type Result struct {
|
||||
JPEG []byte `json:"-"`
|
||||
Engine string `json:"engine"` // "cpu", or "vaapi:/dev/dri/renderD129"
|
||||
Codec string `json:"codec"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
// Failure is why the file can't be previewed, when another machine tried (no JPEG then).
|
||||
Failure string `json:"-"`
|
||||
}
|
||||
|
||||
// ErrUnsupported: not an image or a video by its name.
|
||||
var ErrUnsupported = errors.New("not an image or video")
|
||||
|
||||
// A DecodeError is ffmpeg's verdict on the file itself. It will not change until the
|
||||
// file does, so the caller may remember it.
|
||||
type DecodeError struct{ Msg string }
|
||||
|
||||
func (e *DecodeError) Error() string { return e.Msg }
|
||||
|
||||
// ---- what gets a preview ----
|
||||
|
||||
var imageExt = set("png jpg jpeg jpe jfif gif webp bmp tif tiff avif heic heif hif jxl svg ico psd tga thm")
|
||||
var videoExt = set("mp4 m4v mkv webm mov avi wmv flv mpg mpeg mpe m2v ts m2ts mts 3gp 3g2 ogv ogm mxf vob asf rm rmvb divx f4v mjpeg mjpg 360 osv insv lrf")
|
||||
var audioExt = set("mp3 flac m4a m4b aac ogg oga opus wav wma aiff aif alac ape wv mka")
|
||||
|
||||
func set(s string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, w := range strings.Fields(s) {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Kind is "image", "video", "audio" or "" for a file name. An audio file's preview is its
|
||||
// cover art, when it carries any.
|
||||
func Kind(name string) string {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
|
||||
switch {
|
||||
case imageExt[ext]:
|
||||
return "image"
|
||||
case videoExt[ext]:
|
||||
return "video"
|
||||
case audioExt[ext]:
|
||||
return "audio"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- the probe ----
|
||||
|
||||
// Probe finds out what this process can make previews with. It runs ffmpeg against every
|
||||
// render node, so it takes a few hundred milliseconds; callers keep the answer.
|
||||
func Probe(ctx context.Context) Caps {
|
||||
c := Caps{ProbedAt: time.Now(), GPUs: []GPU{}}
|
||||
c.FFmpeg, _ = exec.LookPath("ffmpeg")
|
||||
c.FFprobe, _ = exec.LookPath("ffprobe")
|
||||
c.Nice, _ = exec.LookPath("nice")
|
||||
switch {
|
||||
case c.FFmpeg == "" && c.FFprobe == "":
|
||||
c.Error = "ffmpeg is not installed"
|
||||
case c.FFmpeg == "":
|
||||
c.Error = "ffmpeg is not installed (ffprobe is)"
|
||||
case c.FFprobe == "":
|
||||
c.Error = "ffprobe is not installed (ffmpeg is)"
|
||||
}
|
||||
if c.Error != "" {
|
||||
return c
|
||||
}
|
||||
c.Version = ffmpegVersion(ctx, c.FFmpeg)
|
||||
c.Encoders = encoders(ctx, c.FFmpeg)
|
||||
accels := hwaccels(ctx, c.FFmpeg)
|
||||
if accels["vaapi"] {
|
||||
nodes, _ := filepath.Glob("/dev/dri/renderD*")
|
||||
sort.Strings(nodes)
|
||||
vainfo, _ := exec.LookPath("vainfo")
|
||||
for _, dev := range nodes {
|
||||
g := GPU{API: "vaapi", Device: dev, Name: metrics.RenderNodeName(dev), Integrated: metrics.RenderNodeIntegrated(dev)}
|
||||
if err := openable(dev); err != nil {
|
||||
g.Reason, g.Permission = err.Error(), errors.Is(err, os.ErrPermission)
|
||||
c.Unusable = append(c.Unusable, g)
|
||||
continue
|
||||
}
|
||||
if msg := hwWorks(ctx, c.FFmpeg, "vaapi=va:"+dev); msg != "" {
|
||||
g.Reason = msg
|
||||
c.Unusable = append(c.Unusable, g)
|
||||
continue
|
||||
}
|
||||
if vainfo != "" {
|
||||
g.Codecs, g.Encodes = vainfoCodecs(ctx, vainfo, dev)
|
||||
}
|
||||
c.GPUs = append(c.GPUs, g)
|
||||
}
|
||||
}
|
||||
if accels["cuda"] && exists("/dev/nvidiactl") {
|
||||
g := GPU{API: "cuda", Device: "0", Name: "NVIDIA GPU"}
|
||||
if msg := hwWorks(ctx, c.FFmpeg, "cuda=cu:0"); msg != "" {
|
||||
g.Reason = msg
|
||||
c.Unusable = append(c.Unusable, g)
|
||||
} else {
|
||||
c.GPUs = append(c.GPUs, g)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// openable: the node can be opened read-write, which is what a VAAPI driver needs. The
|
||||
// usual reason it can't be is the owning group: a render node is root:render 0660 on
|
||||
// most distributions, and a daemon running as an ordinary user is not in render.
|
||||
func openable(dev string) error {
|
||||
f, err := os.OpenFile(dev, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
owner := ""
|
||||
if st, serr := os.Stat(dev); serr == nil {
|
||||
owner = ownerOf(st)
|
||||
}
|
||||
if owner != "" {
|
||||
return fmt.Errorf("%w: %s is %s", os.ErrPermission, dev, owner)
|
||||
}
|
||||
return fmt.Errorf("%w: cannot open %s", os.ErrPermission, dev)
|
||||
}
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// hwWorks initialises a hardware device in ffmpeg and moves one synthetic frame through
|
||||
// it. "" when it works, else the last thing ffmpeg said.
|
||||
func hwWorks(ctx context.Context, ffmpeg, device string) string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
name, rest, _ := strings.Cut(device, "=") // "vaapi", "va:/dev/…"
|
||||
label, _, _ := strings.Cut(rest, ":") // "va"
|
||||
cmd := exec.CommandContext(ctx, ffmpeg, "-nostdin", "-hide_banner", "-v", "error", "-init_hw_device", device, "-filter_hw_device", label,
|
||||
"-f", "lavfi", "-i", "color=c=black:s=64x64:r=1:d=1", "-vf", "format=nv12,hwupload,hwdownload,format=nv12", "-frames:v", "1", "-f", "null", "-")
|
||||
var errb bytes.Buffer
|
||||
cmd.Stderr = &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := lastLine(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return name + ": " + msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ffmpegVersion(ctx context.Context, ffmpeg string) string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
out, _ := exec.CommandContext(ctx, ffmpeg, "-version").Output()
|
||||
f := strings.Fields(string(out)) // "ffmpeg version 7.1.5-0+deb13u1 Copyright …"
|
||||
if len(f) >= 3 && f[1] == "version" {
|
||||
v := f[2]
|
||||
if i := strings.IndexAny(v, "-+~"); i > 0 {
|
||||
v = v[:i]
|
||||
}
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hwaccels(ctx context.Context, ffmpeg string) map[string]bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
out, _ := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-hwaccels").Output()
|
||||
m := map[string]bool{}
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" && !strings.HasPrefix(l, "Hardware acceleration methods") {
|
||||
m[l] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
var vaProfileRe = regexp.MustCompile(`VAProfile([A-Za-z0-9]+?)(?:Simple|Main|High|Profile|Baseline|Constrained|Scc|Advanced|Version|Stereo|Multiview)\w*\s*:\s*VAEntrypoint(VLD|EncSlice|EncSliceLP)\b`)
|
||||
|
||||
// wantedEncoders are the encoders this package may ask ffmpeg for.
|
||||
var wantedEncoders = set("libx264 libx265 libsvtav1 libaom-av1 libopenh264 h264_vaapi hevc_vaapi av1_vaapi aac")
|
||||
|
||||
// encoders lists the wanted encoders this ffmpeg was built with.
|
||||
func encoders(ctx context.Context, ffmpeg string) []string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
out, _ := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-encoders").Output()
|
||||
var found []string
|
||||
for _, l := range strings.Split(string(out), "\n") {
|
||||
f := strings.Fields(l)
|
||||
if len(f) >= 2 && wantedEncoders[f[1]] {
|
||||
found = append(found, f[1])
|
||||
}
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found
|
||||
}
|
||||
|
||||
// vaCodec maps a VAProfile family to the ffmpeg codec it decodes.
|
||||
var vaCodec = map[string]string{"H264": "h264", "HEVC": "hevc", "VP9": "vp9", "AV1": "av1", "MPEG2": "mpeg2video", "VC1": "vc1", "VP8": "vp8", "JPEG": "mjpeg", "MPEG4": "mpeg4", "H263": "h263"}
|
||||
|
||||
func vainfoCodecs(ctx context.Context, vainfo, dev string) (decodes, encodes []string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
out, _ := exec.CommandContext(ctx, vainfo, "--display", "drm", "--device", dev).Output()
|
||||
return parseVainfo(string(out))
|
||||
}
|
||||
|
||||
// parseVainfo names the codecs the decode (VLD) and encode (EncSlice) profiles cover,
|
||||
// the way ffmpeg names them.
|
||||
func parseVainfo(text string) (decodes, encodes []string) {
|
||||
dec, enc := map[string]bool{}, map[string]bool{}
|
||||
for _, m := range vaProfileRe.FindAllStringSubmatch(text, -1) {
|
||||
c, ok := vaCodec[m[1]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if m[2] == "VLD" {
|
||||
dec[c] = true
|
||||
} else {
|
||||
enc[c] = true
|
||||
}
|
||||
}
|
||||
return keys(dec), keys(enc)
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for c := range m {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// pick is the GPU for a codec: the first that lists it, else the first that could not
|
||||
// say (ffmpeg will fall back to software by itself if that guess is wrong), else none.
|
||||
func (c Caps) pick(codec string) *GPU {
|
||||
var unknown *GPU
|
||||
for i := range c.GPUs {
|
||||
g := &c.GPUs[i]
|
||||
if g.Codecs == nil {
|
||||
if unknown == nil {
|
||||
unknown = g
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, x := range g.Codecs {
|
||||
if x == codec {
|
||||
return g
|
||||
}
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// ---- making one ----
|
||||
|
||||
type fileInfo struct {
|
||||
index string // the stream to take
|
||||
codec string
|
||||
w, h int
|
||||
duration float64
|
||||
}
|
||||
|
||||
type attempt struct {
|
||||
gpu *GPU
|
||||
lowres int
|
||||
seek float64
|
||||
}
|
||||
|
||||
func (a attempt) engine() string {
|
||||
if a.gpu == nil {
|
||||
return "cpu"
|
||||
}
|
||||
return a.gpu.API + ":" + a.gpu.Device
|
||||
}
|
||||
|
||||
// Make produces the preview of one file.
|
||||
func Make(ctx context.Context, c Caps, path string, o Opts) (Result, error) {
|
||||
kind := o.Kind
|
||||
if kind == "" {
|
||||
kind = Kind(path)
|
||||
}
|
||||
if kind == "" {
|
||||
return Result{}, ErrUnsupported
|
||||
}
|
||||
if c.Error != "" {
|
||||
return Result{}, errors.New(c.Error)
|
||||
}
|
||||
o = o.defaults()
|
||||
ctx, cancel := context.WithTimeout(ctx, o.Timeout)
|
||||
defer cancel()
|
||||
fi, err := probeFile(ctx, c, path)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
var last string
|
||||
for _, eng := range engines(c, kind, fi) {
|
||||
for _, a := range attempts(eng, kind, fi, o.Size) {
|
||||
data, msg, err := run(ctx, c, a, path, fi, o.Size)
|
||||
if err == nil && len(data) > 0 {
|
||||
return Result{JPEG: data, Engine: a.engine(), Codec: fi.codec, Width: fi.w, Height: fi.h}, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return Result{}, ctx.Err() // the clock ran out: not the file's fault
|
||||
}
|
||||
if err != nil { // the decoder refused: try the next engine, not another seek
|
||||
last = msg
|
||||
break
|
||||
}
|
||||
last = "no frame at " + strconv.FormatFloat(a.seek, 'f', 1, 64) + " s"
|
||||
}
|
||||
}
|
||||
if last == "" {
|
||||
last = "could not decode"
|
||||
}
|
||||
return Result{}, &DecodeError{Msg: last}
|
||||
}
|
||||
|
||||
// engines is the order to try: the fitting GPU first for a video, then the CPU.
|
||||
func engines(c Caps, kind string, fi fileInfo) []*GPU {
|
||||
if kind == "video" {
|
||||
if g := c.pick(fi.codec); g != nil {
|
||||
return []*GPU{g, nil}
|
||||
}
|
||||
}
|
||||
return []*GPU{nil}
|
||||
}
|
||||
|
||||
// attempts for one engine: a seek into a video, then from its start if that found no
|
||||
// frame (a wrong duration in the header); a reduced-size decode of a JPEG, then full size.
|
||||
func attempts(gpu *GPU, kind string, fi fileInfo, size int) []attempt {
|
||||
if kind == "video" {
|
||||
s := seekFor(fi.duration)
|
||||
if s > 0 {
|
||||
return []attempt{{gpu: gpu, seek: s}, {gpu: gpu}}
|
||||
}
|
||||
return []attempt{{gpu: gpu}}
|
||||
}
|
||||
if fi.codec == "mjpeg" && gpu == nil {
|
||||
if l := lowresFor(fi.w, fi.h, size); l > 0 {
|
||||
return []attempt{{lowres: l}, {}}
|
||||
}
|
||||
}
|
||||
return []attempt{{gpu: gpu}}
|
||||
}
|
||||
|
||||
// seekFor: a tenth of the way in, never more than 30 s, so a title card or a black lead-in
|
||||
// is skipped and a long file still seeks to a keyframe near its start.
|
||||
func seekFor(duration float64) float64 {
|
||||
if duration <= 0 {
|
||||
return 0
|
||||
}
|
||||
s := duration / 10
|
||||
if s > 30 {
|
||||
s = 30
|
||||
}
|
||||
if s < 0.5 {
|
||||
return 0
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// lowresFor: how many halvings a JPEG decoder may take while the picture stays at least
|
||||
// twice the preview size. libjpeg does this in the DCT, so a 24 MP photo decodes at 1.5 MP.
|
||||
func lowresFor(w, h, size int) int {
|
||||
m := w
|
||||
if h < m {
|
||||
m = h
|
||||
}
|
||||
l := 0
|
||||
for l < 3 && m>>(l+1) >= size*2 {
|
||||
l++
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
type probeOut struct {
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
Codec string `json:"codec_name"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Disposition struct {
|
||||
AttachedPic int `json:"attached_pic"`
|
||||
} `json:"disposition"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
|
||||
// probeFile asks ffprobe which stream to take and what is in it.
|
||||
func probeFile(ctx context.Context, c Caps, path string) (fileInfo, error) {
|
||||
cmd := exec.CommandContext(ctx, c.FFprobe, "-v", "error", "-select_streams", "v", "-show_entries", "stream=index,codec_name,width,height,disposition:format=duration", "-of", "json", path)
|
||||
var errb bytes.Buffer
|
||||
cmd.Stderr = &errb
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fileInfo{}, ctx.Err()
|
||||
}
|
||||
msg := lastLine(errb.String())
|
||||
if msg == "" {
|
||||
msg = "ffprobe: " + err.Error()
|
||||
}
|
||||
return fileInfo{}, &DecodeError{Msg: msg}
|
||||
}
|
||||
var po probeOut
|
||||
if err := json.Unmarshal(out, &po); err != nil {
|
||||
return fileInfo{}, &DecodeError{Msg: "ffprobe: unreadable answer"}
|
||||
}
|
||||
if len(po.Streams) == 0 {
|
||||
return fileInfo{}, &DecodeError{Msg: "no video stream"}
|
||||
}
|
||||
s := po.Streams[0]
|
||||
for _, x := range po.Streams { // cover art rides along as a video stream; the picture is not the film
|
||||
if x.Disposition.AttachedPic == 0 {
|
||||
s = x
|
||||
break
|
||||
}
|
||||
}
|
||||
fi := fileInfo{index: strconv.Itoa(s.Index), codec: s.Codec, w: s.Width, h: s.Height}
|
||||
fi.duration, _ = strconv.ParseFloat(po.Format.Duration, 64)
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
// run is one ffmpeg: the JPEG on success; on failure what ffmpeg said and the error.
|
||||
// Empty output with a clean exit means the seek found nothing.
|
||||
func run(ctx context.Context, c Caps, a attempt, path string, fi fileInfo, size int) ([]byte, string, error) {
|
||||
args := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
|
||||
if a.gpu != nil {
|
||||
args = append(args, "-hwaccel", a.gpu.API, "-hwaccel_device", a.gpu.Device)
|
||||
}
|
||||
if a.lowres > 0 {
|
||||
args = append(args, "-lowres", strconv.Itoa(a.lowres))
|
||||
}
|
||||
if a.seek > 0 {
|
||||
args = append(args, "-noaccurate_seek", "-ss", strconv.FormatFloat(a.seek, 'f', 3, 64))
|
||||
}
|
||||
args = append(args, "-i", path, "-map", "0:"+fi.index, "-frames:v", "1",
|
||||
"-vf", fmt.Sprintf("scale=w=%d:h=%d:force_original_aspect_ratio=decrease:flags=bicubic", size, size),
|
||||
"-f", "image2pipe", "-c:v", "mjpeg", "-q:v", "4", "pipe:1")
|
||||
name, argv := c.FFmpeg, args
|
||||
if c.Nice != "" { // a burst of previews must not crowd out what the machine is for
|
||||
name, argv = c.Nice, append([]string{"-n", "10", c.FFmpeg}, args...)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, name, argv...)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := lastLine(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return nil, msg, err
|
||||
}
|
||||
return out.Bytes(), "", nil
|
||||
}
|
||||
|
||||
// ---- the same, as root ----
|
||||
|
||||
// ProbeRoot is Probe run as root through this binary, for the Files tab's root mode: root
|
||||
// opens every render node, whatever group the daemon's user is in.
|
||||
func ProbeRoot(ctx context.Context, self string) (Caps, error) {
|
||||
full := priv.Wrap(self, "thumb", "--probe")
|
||||
cmd := exec.CommandContext(ctx, full[0], full[1:]...)
|
||||
var errb bytes.Buffer
|
||||
cmd.Stderr = &errb
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if msg := lastLine(errb.String()); msg != "" {
|
||||
return Caps{}, errors.New(msg)
|
||||
}
|
||||
return Caps{}, err
|
||||
}
|
||||
var c Caps
|
||||
if err := json.Unmarshal(out, &c); err != nil {
|
||||
return Caps{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
type helperOut struct {
|
||||
Engine string `json:"engine,omitempty"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
JPEG []byte `json:"jpeg,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Permanent bool `json:"permanent,omitempty"` // a DecodeError: the file, not the moment
|
||||
}
|
||||
|
||||
// MakeRoot is Make run as root through this binary, with the caps ProbeRoot found.
|
||||
func MakeRoot(ctx context.Context, self string, c Caps, path string, o Opts) (Result, error) {
|
||||
o = o.defaults()
|
||||
caps, _ := json.Marshal(c)
|
||||
full := priv.Wrap(self, "thumb", "--size", strconv.Itoa(o.Size), "--timeout", o.Timeout.String(), path)
|
||||
cmd := exec.CommandContext(ctx, full[0], full[1:]...)
|
||||
cmd.Stdin = bytes.NewReader(caps)
|
||||
var errb bytes.Buffer
|
||||
cmd.Stderr = &errb
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return Result{}, ctx.Err()
|
||||
}
|
||||
if msg := lastLine(errb.String()); msg != "" {
|
||||
return Result{}, errors.New(msg)
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
var h helperOut
|
||||
if err := json.Unmarshal(out, &h); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if h.Error != "" {
|
||||
if h.Permanent {
|
||||
return Result{}, &DecodeError{Msg: h.Error}
|
||||
}
|
||||
return Result{}, errors.New(h.Error)
|
||||
}
|
||||
return Result{JPEG: h.JPEG, Engine: h.Engine, Codec: h.Codec, Width: h.Width, Height: h.Height}, nil
|
||||
}
|
||||
|
||||
// Helper implements `hamlab thumb …`, the root side of the above. `--probe` prints Caps;
|
||||
// `[--size N] [--timeout D] <path>` reads Caps from stdin and prints the preview as JSON.
|
||||
// Returns the exit code.
|
||||
func Helper(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
|
||||
ctx := context.Background()
|
||||
o := Opts{}
|
||||
path := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch a := args[i]; {
|
||||
case a == "--probe":
|
||||
return emit(stdout, Probe(ctx), stderr)
|
||||
case a == "--size" && i+1 < len(args):
|
||||
o.Size, _ = strconv.Atoi(args[i+1])
|
||||
i++
|
||||
case a == "--timeout" && i+1 < len(args):
|
||||
o.Timeout, _ = time.ParseDuration(args[i+1])
|
||||
i++
|
||||
case strings.HasPrefix(a, "-"):
|
||||
return fail(errors.New("usage: hamlab thumb --probe | [--size N] [--timeout D] <path>"))
|
||||
default:
|
||||
path = a
|
||||
}
|
||||
}
|
||||
if path == "" || !filepath.IsAbs(path) {
|
||||
return fail(errors.New("usage: hamlab thumb --probe | [--size N] [--timeout D] <absolute path>"))
|
||||
}
|
||||
var c Caps
|
||||
if err := json.NewDecoder(io.LimitReader(stdin, 1<<20)).Decode(&c); err != nil {
|
||||
return fail(fmt.Errorf("caps on stdin: %w", err))
|
||||
}
|
||||
res, err := Make(ctx, c, path, o)
|
||||
if err != nil {
|
||||
var de *DecodeError
|
||||
return emit(stdout, helperOut{Error: err.Error(), Permanent: errors.As(err, &de)}, stderr)
|
||||
}
|
||||
return emit(stdout, helperOut{Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}, stderr)
|
||||
}
|
||||
|
||||
func emit(w io.Writer, v any, stderr io.Writer) int {
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- small helpers ----
|
||||
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimSpace(s), "\n")
|
||||
return strings.TrimSpace(lines[len(lines)-1])
|
||||
}
|
||||
|
||||
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
@@ -0,0 +1,253 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKind(t *testing.T) {
|
||||
for name, want := range map[string]string{"a.JPG": "image", "b.heic": "image", "c.mkv": "video", "GH010098.MP4": "video", "x.OSV": "video", "d.txt": "", "e": "", ".jpg": "image"} {
|
||||
if got := Kind(name); got != want {
|
||||
t.Errorf("Kind(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFollowsTheFile(t *testing.T) {
|
||||
a := Key("/x/a.jpg", 10, 20)
|
||||
if a != Key("/x/a.jpg", 10, 20) || len(a) != 64 {
|
||||
t.Fatal("key is not stable")
|
||||
}
|
||||
for _, b := range []string{Key("/x/b.jpg", 10, 20), Key("/x/a.jpg", 11, 20), Key("/x/a.jpg", 10, 21)} {
|
||||
if b == a {
|
||||
t.Fatal("key ignores part of the file's identity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVainfo(t *testing.T) {
|
||||
text := `vainfo: Driver version: Intel iHD driver for Intel(R) Gen Graphics - 25.2.3 ()
|
||||
VAProfileMPEG2Simple : VAEntrypointVLD
|
||||
VAProfileH264Main : VAEntrypointVLD
|
||||
VAProfileH264High : VAEntrypointEncSliceLP
|
||||
VAProfileJPEGBaseline : VAEntrypointVLD
|
||||
VAProfileHEVCMain10 : VAEntrypointVLD
|
||||
VAProfileHEVCMain10 : VAEntrypointEncSlice
|
||||
VAProfileHEVCMain444_12 : VAEntrypointVLD
|
||||
VAProfileVP9Profile2 : VAEntrypointVLD
|
||||
VAProfileAV1Profile0 : VAEntrypointEncSliceLP
|
||||
`
|
||||
dec, enc := parseVainfo(text)
|
||||
same := func(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !same(dec, []string{"h264", "hevc", "mjpeg", "mpeg2video", "vp9"}) {
|
||||
t.Fatalf("decodes = %v", dec)
|
||||
}
|
||||
if !same(enc, []string{"av1", "h264", "hevc"}) {
|
||||
t.Fatalf("encodes = %v", enc)
|
||||
}
|
||||
if d, e := parseVainfo(""); d != nil || e != nil {
|
||||
t.Fatal("nothing from nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickAndEngines(t *testing.T) {
|
||||
igpu := GPU{API: "vaapi", Device: "/dev/dri/renderD128", Codecs: []string{"h264", "hevc"}}
|
||||
arc := GPU{API: "vaapi", Device: "/dev/dri/renderD129", Codecs: []string{"av1", "h264", "hevc"}}
|
||||
c := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", GPUs: []GPU{igpu, arc}}
|
||||
if g := c.pick("av1"); g == nil || g.Device != arc.Device {
|
||||
t.Fatal("av1 should go to the card that lists it")
|
||||
}
|
||||
if g := c.pick("h264"); g == nil || g.Device != igpu.Device {
|
||||
t.Fatal("h264 should go to the first card that lists it")
|
||||
}
|
||||
if g := c.pick("vc1"); g != nil {
|
||||
t.Fatal("a codec nobody lists is software")
|
||||
}
|
||||
// a card nothing could describe is tried, after the ones that answered
|
||||
c.GPUs = append(c.GPUs, GPU{API: "cuda", Device: "0"})
|
||||
if g := c.pick("vc1"); g == nil || g.API != "cuda" {
|
||||
t.Fatal("an undescribed card is the guess of last resort")
|
||||
}
|
||||
// videos: GPU then CPU; images: CPU only
|
||||
if e := engines(c, "video", fileInfo{codec: "hevc"}); len(e) != 2 || e[0] == nil || e[1] != nil {
|
||||
t.Fatal("a video tries the GPU, then the CPU")
|
||||
}
|
||||
if e := engines(c, "image", fileInfo{codec: "mjpeg"}); len(e) != 1 || e[0] != nil {
|
||||
t.Fatal("an image is CPU only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttempts(t *testing.T) {
|
||||
a := attempts(nil, "video", fileInfo{duration: 120}, 256)
|
||||
if len(a) != 2 || a[0].seek != 12 || a[1].seek != 0 {
|
||||
t.Fatalf("video attempts = %+v", a)
|
||||
}
|
||||
if a := attempts(nil, "video", fileInfo{duration: 7200}, 256); a[0].seek != 30 {
|
||||
t.Fatalf("seek capped at 30 s, got %v", a[0].seek)
|
||||
}
|
||||
if a := attempts(nil, "video", fileInfo{duration: 2}, 256); len(a) != 1 || a[0].seek != 0 {
|
||||
t.Fatalf("a short clip is taken from its start, got %+v", a)
|
||||
}
|
||||
if a := attempts(nil, "image", fileInfo{codec: "mjpeg", w: 6000, h: 4000}, 256); len(a) != 2 || a[0].lowres != 2 || a[1].lowres != 0 {
|
||||
t.Fatalf("jpeg attempts = %+v", a)
|
||||
}
|
||||
if a := attempts(nil, "image", fileInfo{codec: "mjpeg", w: 800, h: 600}, 256); len(a) != 1 || a[0].lowres != 0 {
|
||||
t.Fatalf("a small jpeg decodes whole, got %+v", a)
|
||||
}
|
||||
if a := attempts(nil, "image", fileInfo{codec: "png", w: 6000, h: 4000}, 256); len(a) != 1 || a[0].lowres != 0 {
|
||||
t.Fatalf("only jpeg has lowres, got %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
c := NewCache(filepath.Join(t.TempDir(), "thumbs.db"))
|
||||
k := Key("/a", 1, 2)
|
||||
if _, found, err := c.Get(k); found || err != nil {
|
||||
t.Fatal("an empty cache found something, or complained")
|
||||
}
|
||||
if _, err := os.Stat(c.Path); err == nil {
|
||||
t.Fatal("a miss must not create the database")
|
||||
}
|
||||
if n, b := c.Stats(); n != 0 || b != 0 {
|
||||
t.Fatal("stats of nothing")
|
||||
}
|
||||
if err := c.Put(k, Entry{Path: "/a", Engine: "cpu", Codec: "png", Width: 640, Height: 360, JPEG: []byte("jpeg")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, found, err := c.Get(k)
|
||||
if err != nil || !found || string(e.JPEG) != "jpeg" || e.Engine != "cpu" || e.Width != 640 || e.Path != "/a" || e.Made.IsZero() {
|
||||
t.Fatalf("put then get: %+v %v %v", e, found, err)
|
||||
}
|
||||
k2 := Key("/b", 1, 2)
|
||||
if err := c.Put(k2, Entry{Path: "/b", Failure: "no video stream"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, found, _ := c.Get(k2); !found || e.JPEG != nil || e.Failure != "no video stream" {
|
||||
t.Fatal("a failure is remembered with its reason")
|
||||
}
|
||||
if err := c.Put(k, Entry{}); err == nil {
|
||||
t.Fatal("an empty preview must not be kept")
|
||||
}
|
||||
// replacing an entry does not count it twice
|
||||
if err := c.Put(k, Entry{Path: "/a", JPEG: []byte("jpeg2")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, b := c.Stats()
|
||||
if n != 2 || b == 0 {
|
||||
t.Fatalf("stats = %d entries, %d bytes", n, b)
|
||||
}
|
||||
// survives a reopen
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2 := NewCache(c.Path)
|
||||
if e, found, _ := c2.Get(k); !found || string(e.JPEG) != "jpeg2" {
|
||||
t.Fatal("not there after reopening")
|
||||
}
|
||||
if n, _ := c2.Stats(); n != 2 {
|
||||
t.Fatal("count lost on reopen")
|
||||
}
|
||||
c2.Close()
|
||||
}
|
||||
|
||||
// The real thing, when ffmpeg is here: a PNG and a generated video each come back as a
|
||||
// JPEG no larger than the asked size, with the aspect kept.
|
||||
func TestMakeWithFFmpeg(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("no ffmpeg")
|
||||
}
|
||||
c := Probe(context.Background())
|
||||
if c.Error != "" {
|
||||
t.Skip(c.Error)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 640, 360))
|
||||
for y := 0; y < 360; y++ {
|
||||
for x := 0; x < 640; x++ {
|
||||
img.Set(x, y, color.RGBA{uint8(x / 3), uint8(y), 90, 255})
|
||||
}
|
||||
}
|
||||
pngPath := filepath.Join(dir, "a.png")
|
||||
f, _ := os.Create(pngPath)
|
||||
_ = png.Encode(f, img)
|
||||
f.Close()
|
||||
res, err := Make(context.Background(), c, pngPath, Opts{Size: 200})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(t, res, 200, 112, "cpu")
|
||||
|
||||
// a two-second test pattern, with the always-built-in mpeg4 encoder
|
||||
vid := filepath.Join(dir, "v.mp4")
|
||||
out, err := exec.Command("ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=10:duration=2", "-c:v", "mpeg4", "-q:v", "5", vid).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Skipf("could not make a test video: %s", out)
|
||||
}
|
||||
res, err = Make(context.Background(), c, vid, Opts{Size: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(t, res, 100, 56, "")
|
||||
if res.Codec != "mpeg4" || res.Width != 320 {
|
||||
t.Fatalf("probe said %s %dx%d", res.Codec, res.Width, res.Height)
|
||||
}
|
||||
|
||||
// not media, and media that isn't
|
||||
if _, err := Make(context.Background(), c, filepath.Join(dir, "x.txt"), Opts{}); err != ErrUnsupported {
|
||||
t.Fatalf("a .txt is unsupported, got %v", err)
|
||||
}
|
||||
bad := filepath.Join(dir, "bad.mp4")
|
||||
_ = os.WriteFile(bad, []byte("not a video at all"), 0o644)
|
||||
_, err = Make(context.Background(), c, bad, Opts{})
|
||||
var de *DecodeError
|
||||
if err == nil || !isDecodeErr(err, &de) {
|
||||
t.Fatalf("garbage should be a DecodeError, got %v", err)
|
||||
}
|
||||
// the clock: a cancelled context is not the file's fault
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
|
||||
defer cancel()
|
||||
if _, err := Make(ctx, c, pngPath, Opts{}); err == nil || isDecodeErr(err, &de) {
|
||||
t.Fatalf("a timeout must not read as a decode failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func check(t *testing.T, res Result, w, h int, engine string) {
|
||||
t.Helper()
|
||||
im, err := jpeg.Decode(bytes.NewReader(res.JPEG))
|
||||
if err != nil {
|
||||
t.Fatalf("not a jpeg: %v", err)
|
||||
}
|
||||
if b := im.Bounds(); b.Dx() != w || b.Dy() < h-1 || b.Dy() > h+1 { // the short side rounds either way
|
||||
t.Fatalf("got %dx%d, want %dx%d", b.Dx(), b.Dy(), w, h)
|
||||
}
|
||||
if engine != "" && res.Engine != engine {
|
||||
t.Fatalf("engine %q, want %q", res.Engine, engine)
|
||||
}
|
||||
}
|
||||
|
||||
func isDecodeErr(err error, de **DecodeError) bool {
|
||||
x, ok := err.(*DecodeError)
|
||||
if ok {
|
||||
*de = x
|
||||
}
|
||||
return ok
|
||||
}
|
||||
@@ -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,296 @@
|
||||
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) {
|
||||
each, ok := raplWattsEach(prev, cur, max, dt)
|
||||
var w float64
|
||||
for _, x := range each {
|
||||
w += x
|
||||
}
|
||||
return w, ok
|
||||
}
|
||||
|
||||
// raplWattsEach is raplWatts per counter: one package each.
|
||||
func raplWattsEach(prev, cur []raplCount, max map[string]uint64, dt float64) ([]float64, bool) {
|
||||
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]float64, len(cur))
|
||||
for i := range cur {
|
||||
if cur[i].Dir != prev[i].Dir {
|
||||
return nil, false
|
||||
}
|
||||
if cur[i].UJ >= prev[i].UJ {
|
||||
out[i] = float64(cur[i].UJ-prev[i].UJ) / 1e6 / dt
|
||||
continue
|
||||
}
|
||||
m := max[cur[i].Dir]
|
||||
if m == 0 || prev[i].UJ > m {
|
||||
return nil, false
|
||||
}
|
||||
out[i] = float64(m-prev[i].UJ+cur[i].UJ) / 1e6 / dt
|
||||
}
|
||||
return out, 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. On a machine with more than one package
|
||||
// it also gives each one's, indexed by package id (the socket).
|
||||
func (c *Collector) cpuPower(now time.Time) (float64, []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, nil
|
||||
}
|
||||
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, nil
|
||||
}
|
||||
each, good := raplWattsEach(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
|
||||
p.prev, p.prevAt = cur, now
|
||||
if !good {
|
||||
return 0, nil
|
||||
}
|
||||
var w float64
|
||||
for _, x := range each {
|
||||
w += x
|
||||
}
|
||||
return round1(w), perPackage(cur, each)
|
||||
}
|
||||
|
||||
// perPackage puts each counter's watts at its package's id — intel-rapl:1 is "package-1" —
|
||||
// and is nil for a single package, whose figure is the total.
|
||||
func perPackage(cur []raplCount, each []float64) []float64 {
|
||||
if len(cur) < 2 {
|
||||
return nil
|
||||
}
|
||||
var out []float64
|
||||
for i, c := range cur {
|
||||
id := i
|
||||
if n, ok := strings.CutPrefix(strings.TrimSpace(readStr(filepath.Join(raplRoot, c.Dir, "name"))), "package-"); ok {
|
||||
if v, err := strconv.Atoi(n); err == nil && v >= 0 && v < 64 {
|
||||
id = v
|
||||
}
|
||||
}
|
||||
for len(out) <= id {
|
||||
out = append(out, 0)
|
||||
}
|
||||
out[id] = round1(each[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package metrics
|
||||
|
||||
import "testing"
|
||||
|
||||
// a two-socket board: two coretemp chips, and the CPU's temperature is the hotter package
|
||||
func TestCPUTempHottestPackage(t *testing.T) {
|
||||
temps := []Temp{
|
||||
{Chip: "coretemp", Label: "Core 0", C: 31},
|
||||
{Chip: "coretemp", Label: "Core 0", C: 44, Pkg: 1},
|
||||
{Chip: "coretemp", Label: "Package id 0", C: 35},
|
||||
{Chip: "coretemp", Label: "Package id 1", C: 41, Pkg: 1},
|
||||
}
|
||||
if got := cpuTemp(temps); got != 41 {
|
||||
t.Fatalf("cpuTemp = %v, want 41", got)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package metrics
|
||||
|
||||
import "strconv"
|
||||
|
||||
// One kept history point is the average of the samples behind it. At the default ten-minute
|
||||
// window that bucket is a single sample and nothing is averaged; at a day it is five minutes
|
||||
// of them, and the difference matters — a graph of the last day drawn from whichever instant
|
||||
@@ -32,6 +34,32 @@ func fold(b []Sample) Sample {
|
||||
return t / uint64(len(b))
|
||||
}
|
||||
out.CPU = sum(func(s Sample) float64 { return s.CPU })
|
||||
// CPU power: over the samples that have a reading, so a helper restart is not a dip to zero
|
||||
var pw, pn float64
|
||||
for _, s := range b {
|
||||
if s.CPUPower > 0 {
|
||||
pw, pn = pw+s.CPUPower, pn+1
|
||||
}
|
||||
}
|
||||
out.CPUPower = 0
|
||||
if pn > 0 {
|
||||
out.CPUPower = round1(pw / pn)
|
||||
}
|
||||
// and each package's, the same way
|
||||
out.CPUPowerPkgs = nil
|
||||
for _, s := range b {
|
||||
for i, w := range s.CPUPowerPkgs {
|
||||
for len(out.CPUPowerPkgs) <= i {
|
||||
out.CPUPowerPkgs = append(out.CPUPowerPkgs, 0)
|
||||
}
|
||||
out.CPUPowerPkgs[i] += w
|
||||
}
|
||||
}
|
||||
if pn > 0 {
|
||||
for i := range out.CPUPowerPkgs {
|
||||
out.CPUPowerPkgs[i] = round1(out.CPUPowerPkgs[i] / pn)
|
||||
}
|
||||
}
|
||||
out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] })
|
||||
out.Load[1] = sum(func(s Sample) float64 { return s.Load[1] })
|
||||
out.Load[2] = sum(func(s Sample) float64 { return s.Load[2] })
|
||||
@@ -59,13 +87,13 @@ func fold(b []Sample) Sample {
|
||||
temps := avgBy(b, func(s Sample) map[string][]float64 {
|
||||
m := make(map[string][]float64, len(s.Temps))
|
||||
for _, x := range s.Temps {
|
||||
m[x.Chip+"/"+x.Label] = []float64{x.C}
|
||||
m[x.Chip+"/"+x.Label+"/"+strconv.Itoa(x.Pkg)] = []float64{x.C}
|
||||
}
|
||||
return m
|
||||
})
|
||||
out.Temps = append([]Temp(nil), out.Temps...)
|
||||
for i, x := range out.Temps {
|
||||
if v, ok := temps[x.Chip+"/"+x.Label]; ok {
|
||||
if v, ok := temps[x.Chip+"/"+x.Label+"/"+strconv.Itoa(x.Pkg)]; ok {
|
||||
out.Temps[i].C = v[0]
|
||||
}
|
||||
}
|
||||
@@ -111,9 +139,54 @@ func fold(b []Sample) Sample {
|
||||
out.Disks[i].ReadBps, out.Disks[i].WriteBps, out.Disks[i].UtilPct = v[0], v[1], v[2]
|
||||
}
|
||||
}
|
||||
out.ZFSWrite = foldZFSWrites(b, out.ZFSWrite)
|
||||
return out
|
||||
}
|
||||
|
||||
// foldZFSWrites averages the write path's rates and the buffer's fill over a bucket, per pool;
|
||||
// everything else (limits, the mover's state, the last walk) is the newest sample's.
|
||||
func foldZFSWrites(b []Sample, last *ZFSWrites) *ZFSWrites {
|
||||
if last == nil {
|
||||
return nil
|
||||
}
|
||||
w := *last
|
||||
w.Pools = append([]PoolWrites(nil), last.Pools...)
|
||||
var dirty, delayed, over, n float64
|
||||
for _, s := range b {
|
||||
if s.ZFSWrite != nil {
|
||||
dirty, delayed, over, n = dirty+float64(s.ZFSWrite.Dirty), delayed+s.ZFSWrite.DelayedPerSec, over+s.ZFSWrite.OverMaxPerSec, n+1
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
w.Dirty, w.DelayedPerSec, w.OverMaxPerSec = uint64(dirty/n), round1(delayed/n), round1(over/n)
|
||||
}
|
||||
per := avgBy(b, func(s Sample) map[string][]float64 {
|
||||
m := map[string][]float64{}
|
||||
if s.ZFSWrite == nil {
|
||||
return m
|
||||
}
|
||||
for _, p := range s.ZFSWrite.Pools {
|
||||
v := []float64{p.WriteBps, p.LogicalBps, 0, 0}
|
||||
if p.Landing != nil {
|
||||
v[2], v[3] = p.Landing.InBps, p.Landing.DrainBps
|
||||
}
|
||||
m[p.Pool] = v
|
||||
}
|
||||
return m
|
||||
})
|
||||
for i, p := range w.Pools {
|
||||
if v, ok := per[p.Pool]; ok {
|
||||
w.Pools[i].WriteBps, w.Pools[i].LogicalBps = round1(v[0]), round1(v[1])
|
||||
if p.Landing != nil {
|
||||
l := *p.Landing
|
||||
l.InBps, l.DrainBps = round1(v[2]), round1(v[3])
|
||||
w.Pools[i].Landing = &l
|
||||
}
|
||||
}
|
||||
}
|
||||
return &w
|
||||
}
|
||||
|
||||
// avgBy averages each sample's named numbers across the bucket, per name.
|
||||
func avgBy(b []Sample, of func(Sample) map[string][]float64) map[string][]float64 {
|
||||
sums := map[string][]float64{}
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
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"`
|
||||
FanPct *float64 `json:"fan_pct,omitempty"` // fan speed as a % of its range: nvidia-smi's fan.speed, or the hwmon's pwm1; nil when the card has no such reading (0 is a stopped fan)
|
||||
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 read of this card has been out for gpuStallAfter; its readings are paused until it returns
|
||||
}
|
||||
|
||||
// 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, "nvidia": 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
|
||||
nv []GPUClient // nvidia: the process table from the last read (pmon), fdinfo says nothing there
|
||||
nvRead []GPUClient // the same, as the read in flight left it: only that read touches it
|
||||
pending chan gpuRead // the read in flight, nil when none is
|
||||
pendingAt time.Time
|
||||
last *GPU // the last read that came back
|
||||
readFn func(*GPU, time.Time) // nil is read (tests stand a slow one in)
|
||||
}
|
||||
|
||||
type gpuRead struct {
|
||||
x GPU
|
||||
nv []GPUClient
|
||||
}
|
||||
|
||||
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
|
||||
scan func() ([]drmFD, bool) // the walk; nil is scanClients (tests stand a fake list in)
|
||||
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
|
||||
|
||||
// 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{}
|
||||
}
|
||||
// The clients are read on every sample, like everything else in it: their engine shares
|
||||
// move on the same tick as the device's own numbers, at whatever rate is set.
|
||||
scan := c.scanClients
|
||||
if g.scan != nil {
|
||||
scan = g.scan
|
||||
}
|
||||
g.last, g.ok = scan()
|
||||
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
|
||||
if d.driver == "nvidia" { // no engine counters in its fdinfo; nvidia-smi pmon is the client list
|
||||
x.Clients, x.ClientsOK = append(x.Clients, d.nv...), true
|
||||
}
|
||||
for _, f := range g.last {
|
||||
if d.driver == "nvidia" {
|
||||
break
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// RenderNodeName is the GPU behind a render node ("/dev/dri/renderD128" → "Intel Arc A380"),
|
||||
// named the same way the Processes tab names it; "" when sysfs has nothing for it.
|
||||
func RenderNodeName(node string) string {
|
||||
dev, err := filepath.EvalSymlinks("/sys/class/drm/" + filepath.Base(node) + "/device")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
driver := ueventValue(dev+"/uevent", "DRIVER")
|
||||
if driver == "" {
|
||||
driver = filepath.Base(readLink(dev + "/driver"))
|
||||
}
|
||||
return gpuName(dev, ueventValue(dev+"/uevent", "PCI_SLOT_NAME"), driver)
|
||||
}
|
||||
|
||||
// RenderNodeIntegrated reports a render node whose GPU sits on the CPU die (PCI root bus 0,
|
||||
// or no PCI bus at all), as opposed to a card on the bus.
|
||||
func RenderNodeIntegrated(node string) bool {
|
||||
dev, err := filepath.EvalSymlinks("/sys/class/drm/" + filepath.Base(node) + "/device")
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
pci := ueventValue(dev+"/uevent", "PCI_SLOT_NAME")
|
||||
return pci == "" || strings.HasPrefix(pci, "0000:00:")
|
||||
}
|
||||
|
||||
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 read that does not come back must not take the
|
||||
// whole sampler with it — every graph on every page of this machine stops — so one still out
|
||||
// after gpuReadWithin is left running, the sample goes on with the card's last readings, and
|
||||
// no second read starts until it returns. Late is normal for nvidia-smi on a busy card; only a
|
||||
// read out for gpuStallAfter marks the card stalled, and that clears the moment it returns. A
|
||||
// sysfs read that never returns keeps it stalled, at the cost of one goroutine.
|
||||
var (
|
||||
gpuReadWithin = 3 * time.Second
|
||||
gpuStallAfter = 15 * time.Second // past both of readNvidia's nvidia-smi calls timing out (runOut, 5 s each)
|
||||
)
|
||||
|
||||
func (d *gpuDev) readWithin(x *GPU, now time.Time) {
|
||||
var r gpuRead
|
||||
back := false
|
||||
if d.pending == nil {
|
||||
done := make(chan gpuRead, 1)
|
||||
y := *x
|
||||
read := d.read
|
||||
if d.readFn != nil {
|
||||
read = d.readFn
|
||||
}
|
||||
go func() {
|
||||
read(&y, now)
|
||||
done <- gpuRead{y, d.nvRead}
|
||||
}()
|
||||
d.pending, d.pendingAt = done, now
|
||||
select {
|
||||
case r, back = <-done:
|
||||
case <-time.After(gpuReadWithin):
|
||||
}
|
||||
} else { // started on an earlier sample: taken if it is back, never waited on again
|
||||
select {
|
||||
case r, back = <-d.pending:
|
||||
default:
|
||||
}
|
||||
}
|
||||
ok := x.ClientsOK
|
||||
defer func() { x.ClientsOK = ok }()
|
||||
if back {
|
||||
if d.stalled {
|
||||
log.Printf("gpu: %s (%s): its read came back after %s; readings resume", d.name, d.card, now.Sub(d.pendingAt).Round(time.Second))
|
||||
}
|
||||
d.pending, d.nv, d.stalled, d.last = nil, r.nv, false, &r.x
|
||||
*x = r.x
|
||||
return
|
||||
}
|
||||
if d.last != nil {
|
||||
*x = *d.last
|
||||
}
|
||||
if now.Sub(d.pendingAt) >= gpuStallAfter {
|
||||
if !d.stalled {
|
||||
log.Printf("gpu: %s (%s): a read has not returned in %s; its readings are paused until it does", d.name, d.card, gpuStallAfter)
|
||||
}
|
||||
d.stalled, x.Stalled = true, true
|
||||
}
|
||||
}
|
||||
|
||||
// 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 "nvidia":
|
||||
d.readNvidia(x)
|
||||
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"))
|
||||
if pwm, err := strconv.ParseFloat(strings.TrimSpace(readStr(d.hwmon+"/pwm1")), 64); err == nil {
|
||||
top := readF(d.hwmon + "/pwm1_max")
|
||||
if top <= 0 {
|
||||
top = 255
|
||||
}
|
||||
v := round1(pwm / top * 100)
|
||||
x.FanPct = &v
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
// The proprietary NVIDIA driver exports no busy counter in sysfs and no engine totals in
|
||||
// fdinfo, so the device and its processes come from nvidia-smi — two short invocations per
|
||||
// sample (~20 ms and ~75 ms on a GTX 965M), no root needed. The PCI address selects the
|
||||
// card, so two NVIDIA GPUs stay apart.
|
||||
func (d *gpuDev) readNvidia(x *GPU) {
|
||||
if out, ok := runOut("nvidia-smi", "--id="+d.bus, "--query-gpu=utilization.gpu,utilization.encoder,utilization.decoder,memory.used,memory.total,clocks.sm,clocks.max.sm,temperature.gpu,power.draw,power.limit,fan.speed", "--format=csv,noheader,nounits"); ok {
|
||||
parseNvidiaQuery(x, out)
|
||||
}
|
||||
if out, ok := runOut("nvidia-smi", "pmon", "-c", "1", "-s", "um", "--id="+d.bus); ok {
|
||||
d.nvRead = parseNvidiaPmon(out)
|
||||
}
|
||||
}
|
||||
|
||||
// nvNum is one nvidia-smi CSV field; "[N/A]" (no sensor, no limit) is "nothing".
|
||||
func nvNum(s string) (float64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || strings.Contains(s, "N/A") {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
return v, err == nil
|
||||
}
|
||||
|
||||
// parseNvidiaQuery fills a GPU from one --query-gpu line in the field order readNvidia asks
|
||||
// for: gpu%, encoder%, decoder%, mem used MiB, mem total MiB, SM MHz, max SM MHz, °C, W, W cap,
|
||||
// fan % ("[N/A]" on a card with no fan of its own: a laptop's, a passive datacentre card).
|
||||
func parseNvidiaQuery(x *GPU, line string) {
|
||||
f := strings.Split(strings.TrimSpace(line), ",")
|
||||
if len(f) < 10 {
|
||||
return
|
||||
}
|
||||
if v, ok := nvNum(f[0]); ok {
|
||||
x.Usage, x.UsageSrc = v, "busy"
|
||||
x.Engines = map[string]float64{"render": v}
|
||||
enc, okE := nvNum(f[1])
|
||||
dec, okD := nvNum(f[2])
|
||||
if okE || okD {
|
||||
x.Engines["video"] = max(enc, dec)
|
||||
}
|
||||
}
|
||||
if v, ok := nvNum(f[3]); ok {
|
||||
x.MemUsed = uint64(v) << 20
|
||||
}
|
||||
if v, ok := nvNum(f[4]); ok {
|
||||
x.MemTotal = uint64(v) << 20
|
||||
}
|
||||
if v, ok := nvNum(f[5]); ok {
|
||||
x.MHz = v
|
||||
}
|
||||
if v, ok := nvNum(f[6]); ok {
|
||||
x.MaxMHz = v
|
||||
}
|
||||
if v, ok := nvNum(f[7]); ok && v > 0 {
|
||||
x.Temp, x.TempSrc = v, "gpu"
|
||||
}
|
||||
if v, ok := nvNum(f[8]); ok {
|
||||
x.PowerW = round1(v)
|
||||
}
|
||||
if v, ok := nvNum(f[9]); ok {
|
||||
x.PowerCapW = v
|
||||
}
|
||||
if len(f) > 10 {
|
||||
if v, ok := nvNum(f[10]); ok {
|
||||
x.FanPct = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseNvidiaPmon reads `nvidia-smi pmon -c 1 -s um`: a header naming the columns, then one
|
||||
// row per process — "-" where a process has no share. Columns vary between driver versions,
|
||||
// so they are taken by name. Busiest first, like the fdinfo clients.
|
||||
func parseNvidiaPmon(out string) []GPUClient {
|
||||
col := map[string]int{}
|
||||
var clients []GPUClient
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
if f[0] == "#" {
|
||||
if len(f) > 1 && f[1] == "gpu" { // "# gpu pid type sm mem enc dec jpg ofa fb ccpm command"
|
||||
col = map[string]int{}
|
||||
for i, name := range f[1:] {
|
||||
col[name] = i
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
get := func(name string) (float64, bool) {
|
||||
i, ok := col[name]
|
||||
if !ok || i >= len(f) || f[i] == "-" {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseFloat(f[i], 64)
|
||||
return v, err == nil
|
||||
}
|
||||
pid, ok := get("pid")
|
||||
if !ok || pid <= 0 {
|
||||
continue
|
||||
}
|
||||
cl := GPUClient{PID: int(pid), Engines: map[string]float64{}}
|
||||
if i, ok := col["command"]; ok && i < len(f) {
|
||||
cl.Name = strings.Join(f[i:], " ")
|
||||
}
|
||||
if v, ok := get("sm"); ok {
|
||||
cl.Engines["render"] = v
|
||||
}
|
||||
enc, okE := get("enc")
|
||||
dec, okD := get("dec")
|
||||
if okE || okD {
|
||||
cl.Engines["video"] = max(enc, dec)
|
||||
}
|
||||
for _, v := range cl.Engines {
|
||||
if v > cl.Usage {
|
||||
cl.Usage = v
|
||||
}
|
||||
}
|
||||
if len(cl.Engines) == 0 {
|
||||
cl.Engines = nil
|
||||
}
|
||||
if v, ok := get("fb"); ok {
|
||||
cl.Mem = uint64(v) << 20
|
||||
}
|
||||
clients = append(clients, cl)
|
||||
}
|
||||
sort.SliceStable(clients, func(i, j int) bool { return clients[i].Usage > clients[j].Usage })
|
||||
return clients
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A read that comes back late carries the last readings meanwhile and is used when it lands;
|
||||
// only one out past gpuStallAfter marks the card stalled, and its return clears that.
|
||||
func TestGPUReadLateThenStalledThenBack(t *testing.T) {
|
||||
defer func(w, s time.Duration) { gpuReadWithin, gpuStallAfter = w, s }(gpuReadWithin, gpuStallAfter)
|
||||
gpuReadWithin, gpuStallAfter = 20*time.Millisecond, 10*time.Second
|
||||
release := make(chan struct{})
|
||||
n := 0.0 // only the read goroutine writes it, one read at a time
|
||||
d := &gpuDev{name: "test", card: "card0"}
|
||||
d.readFn = func(x *GPU, _ time.Time) {
|
||||
n++
|
||||
if n == 2 {
|
||||
<-release
|
||||
}
|
||||
x.Usage = n * 10
|
||||
}
|
||||
t0 := time.Now()
|
||||
sample := func(at time.Duration) GPU {
|
||||
x := GPU{Name: "test"}
|
||||
d.readWithin(&x, t0.Add(at))
|
||||
return x
|
||||
}
|
||||
if x := sample(0); x.Usage != 10 || x.Stalled {
|
||||
t.Fatalf("first: %+v", x)
|
||||
}
|
||||
if x := sample(time.Second); x.Usage != 10 || x.Stalled { // late: the last readings
|
||||
t.Fatalf("late: %+v", x)
|
||||
}
|
||||
if x := sample(5 * time.Second); x.Usage != 10 || x.Stalled {
|
||||
t.Fatalf("still late: %+v", x)
|
||||
}
|
||||
if x := sample(12 * time.Second); !x.Stalled {
|
||||
t.Fatalf("out past gpuStallAfter, not stalled: %+v", x)
|
||||
}
|
||||
if x := sample(12*time.Second + 1); !x.Stalled {
|
||||
t.Fatal("stall did not hold")
|
||||
}
|
||||
close(release)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if x := sample(13 * time.Second); x.Usage != 20 || x.Stalled {
|
||||
t.Fatalf("back: %+v", x)
|
||||
}
|
||||
if x := sample(14 * time.Second); x.Usage != 30 || x.Stalled { // 30: no extra read ran while one was out
|
||||
t.Fatalf("next: %+v", x)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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.scan = func() ([]drmFD, bool) { return c.gpu.last, true } // 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}}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNvidiaQuery(t *testing.T) {
|
||||
var g GPU
|
||||
parseNvidiaQuery(&g, "31, 0, 12, 1488, 2048, 949, 1097, 57, 23.41, [N/A]\n")
|
||||
if g.Usage != 31 || g.UsageSrc != "busy" || g.Engines["render"] != 31 || g.Engines["video"] != 12 {
|
||||
t.Errorf("usage: %+v", g)
|
||||
}
|
||||
if g.MemUsed != 1488<<20 || g.MemTotal != 2048<<20 || g.MHz != 949 || g.MaxMHz != 1097 {
|
||||
t.Errorf("mem/clock: %+v", g)
|
||||
}
|
||||
if g.Temp != 57 || g.TempSrc != "gpu" || g.PowerW != 23.4 || g.PowerCapW != 0 {
|
||||
t.Errorf("temp/power (cap must stay 0 for [N/A]): %+v", g)
|
||||
}
|
||||
if g.FanPct != nil {
|
||||
t.Errorf("no fan field must leave the fan unset: %+v", g)
|
||||
}
|
||||
var fan, nofan GPU
|
||||
parseNvidiaQuery(&fan, "99, 0, 0, 20000, 24576, 1695, 2100, 83, 403.99, 420.00, 34\n")
|
||||
parseNvidiaQuery(&nofan, "5, 0, 0, 500, 2048, 300, 1097, 45, 8.0, [N/A], [N/A]\n")
|
||||
if fan.FanPct == nil || *fan.FanPct != 34 || nofan.FanPct != nil {
|
||||
t.Errorf("fan: %v, [N/A]: %v", fan.FanPct, nofan.FanPct)
|
||||
}
|
||||
var stopped GPU
|
||||
parseNvidiaQuery(&stopped, "0, 0, 0, 500, 24576, 210, 2100, 35, 20.0, 420.00, 0\n")
|
||||
if stopped.FanPct == nil || *stopped.FanPct != 0 {
|
||||
t.Errorf("a stopped fan must read 0, not nothing: %v", stopped.FanPct)
|
||||
}
|
||||
var short GPU
|
||||
parseNvidiaQuery(&short, "garbage")
|
||||
if short.UsageSrc != "" {
|
||||
t.Errorf("a short line must leave the GPU untouched: %+v", short)
|
||||
}
|
||||
}
|
||||
|
||||
const pmonA = `# gpu pid type sm mem enc dec jpg ofa fb ccpm command
|
||||
# Idx # C/G % % % % % % MB MB name
|
||||
0 1063 G - - - - - - 701 0 Xorg
|
||||
0 1480 G 27 22 - - - - 270 0 cinnamon
|
||||
0 13318 G 9 7 40 - - - 454 0 firefox-esr
|
||||
`
|
||||
|
||||
func TestParseNvidiaPmon(t *testing.T) {
|
||||
cl := parseNvidiaPmon(pmonA)
|
||||
if len(cl) != 3 {
|
||||
t.Fatalf("want 3 clients, got %+v", cl)
|
||||
}
|
||||
// busiest first: firefox's encoder share (40) beats cinnamon's sm (27)
|
||||
if cl[0].PID != 13318 || cl[0].Name != "firefox-esr" || cl[0].Usage != 40 || cl[0].Engines["render"] != 9 || cl[0].Engines["video"] != 40 || cl[0].Mem != 454<<20 {
|
||||
t.Errorf("firefox: %+v", cl[0])
|
||||
}
|
||||
if cl[1].PID != 1480 || cl[1].Usage != 27 || cl[1].Engines["render"] != 27 {
|
||||
t.Errorf("cinnamon: %+v", cl[1])
|
||||
}
|
||||
if cl[2].PID != 1063 || cl[2].Usage != 0 || cl[2].Engines != nil || cl[2].Mem != 701<<20 {
|
||||
t.Errorf("xorg (no shares, only memory): %+v", cl[2])
|
||||
}
|
||||
// an older driver without the jpg/ofa/ccpm columns
|
||||
old := "# gpu pid type sm mem enc dec fb command\n# Idx # C/G % % % % MB name\n 0 42 C 55 10 - - 100 python3\n"
|
||||
if cl := parseNvidiaPmon(old); len(cl) != 1 || cl[0].PID != 42 || cl[0].Usage != 55 || cl[0].Mem != 100<<20 || cl[0].Name != "python3" {
|
||||
t.Errorf("old layout: %+v", cl)
|
||||
}
|
||||
}
|
||||
+158
-45
@@ -6,7 +6,9 @@
|
||||
// lm-sensors, smartmontools or root. Sections whose source is absent (no fans, no
|
||||
// UPS daemon, no hwmon at all on a VM) simply come back empty rather than failing.
|
||||
// The only external commands are optional UPS daemons: pwrstat (CyberPower), upsc
|
||||
// (NUT) and apcaccess (apcupsd), tried in that order.
|
||||
// (NUT) and apcaccess (apcupsd), tried in that order. Two readings are root-only, the GPU
|
||||
// client walk and the CPU energy counters; without root they come from small helpers of
|
||||
// this binary started once through sudo or doas (gpu.go, cpupower.go), or not at all.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
@@ -58,6 +60,7 @@ type Temp struct {
|
||||
C float64 `json:"c"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Crit float64 `json:"crit,omitempty"`
|
||||
Pkg int `json:"pkg,omitempty"` // coretemp: the CPU package the chip belongs to — a two-socket board has two chips, both with a "Core 0"
|
||||
}
|
||||
|
||||
type Fan struct {
|
||||
@@ -99,6 +102,9 @@ type FS struct {
|
||||
// said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and
|
||||
// a remote mount is on none of them.
|
||||
Disk string `json:"disk,omitempty"`
|
||||
// Source is the device the mount is of ("/dev/nvme0n1p1"). A bind mount has the same
|
||||
// source as the filesystem it shows, so a sum over a drive counts each source once.
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type UPS struct {
|
||||
@@ -122,9 +128,10 @@ type Proc struct {
|
||||
Name string `json:"name"`
|
||||
Cmd string `json:"cmd,omitempty"`
|
||||
User string `json:"user"`
|
||||
CPU float64 `json:"cpu"` // percent of one core
|
||||
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
|
||||
RSS uint64 `json:"rss"` // bytes
|
||||
CPU float64 `json:"cpu"` // percent of one core
|
||||
GPU float64 `json:"gpu,omitempty"` // its busiest GPU engine, percent; summed over the GPUs it has open
|
||||
CPUTime float64 `json:"cpu_time"` // seconds, lifetime
|
||||
RSS uint64 `json:"rss"` // bytes
|
||||
Threads int `json:"threads"`
|
||||
State string `json:"state"`
|
||||
Nice int `json:"nice"`
|
||||
@@ -132,20 +139,25 @@ type Proc struct {
|
||||
}
|
||||
|
||||
type Sample struct {
|
||||
T int64 `json:"t"` // unix ms
|
||||
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Temps []Temp `json:"temps"`
|
||||
Fans []Fan `json:"fans"`
|
||||
Net []Net `json:"net"`
|
||||
Disks []Disk `json:"disks"`
|
||||
FS []FS `json:"fs"`
|
||||
UPS *UPS `json:"ups"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
T int64 `json:"t"` // unix ms
|
||||
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading
|
||||
CPUPowerPkgs []float64 `json:"cpu_power_pkgs_w,omitempty"` // each package's share, by socket; only with more than one
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Temps []Temp `json:"temps"`
|
||||
Fans []Fan `json:"fans"`
|
||||
Net []Net `json:"net"`
|
||||
Disks []Disk `json:"disks"`
|
||||
FS []FS `json:"fs"`
|
||||
UPS *UPS `json:"ups"`
|
||||
GPUs []GPU `json:"gpus"`
|
||||
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module
|
||||
ZFSWrite *ZFSWrites `json:"zfs_writes"` // the ZFS write path; nil without the module
|
||||
Uptime float64 `json:"uptime"`
|
||||
|
||||
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
|
||||
}
|
||||
@@ -173,6 +185,10 @@ type Collector struct {
|
||||
procAt time.Time
|
||||
lastTable *ProcTable
|
||||
upsProbe func() *UPS // nil when no UPS daemon is installed
|
||||
gpu gpuState
|
||||
power cpuPowerState
|
||||
prevARC arcPrev
|
||||
prevZW zfsWritePrev
|
||||
|
||||
subMu sync.Mutex
|
||||
subs map[chan struct{}]struct{} // told after every sample; see Subscribe
|
||||
@@ -338,6 +354,7 @@ func (c *Collector) sample() {
|
||||
|
||||
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
|
||||
s.CPU, s.Cores = c.cpu()
|
||||
s.CPUPower, s.CPUPowerPkgs = c.cpuPower(now)
|
||||
s.Load = loadavg()
|
||||
s.Mem, s.Swap = meminfo()
|
||||
s.Uptime = uptime()
|
||||
@@ -356,6 +373,9 @@ func (c *Collector) sample() {
|
||||
c.lastUPS, c.upsAt = c.upsProbe(), now
|
||||
}
|
||||
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
|
||||
s.GPUs = c.gpus(now, cpuTemp(s.Temps))
|
||||
s.ARC = c.arc(now)
|
||||
s.ZFSWrite = c.zfsWrites(now, s.ARC, s.Disks)
|
||||
|
||||
if first {
|
||||
return // deltas are meaningless on the priming pass
|
||||
@@ -529,6 +549,19 @@ func hwmonDir(d string) (chip string, temps []Temp, fans []Fan) {
|
||||
fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)})
|
||||
}
|
||||
}
|
||||
// coretemp is one chip per CPU package, and only its "Package id N" says which
|
||||
if chip == "coretemp" {
|
||||
for _, t := range temps {
|
||||
if n, ok := strings.CutPrefix(t.Label, "Package id "); ok {
|
||||
if pkg, err := strconv.Atoi(n); err == nil {
|
||||
for i := range temps {
|
||||
temps[i].Pkg = pkg
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -544,7 +577,10 @@ func hwmon() ([]Temp, []Fan) {
|
||||
if temps[i].Chip != temps[j].Chip {
|
||||
return temps[i].Chip < temps[j].Chip
|
||||
}
|
||||
return temps[i].Label < temps[j].Label
|
||||
if temps[i].Label != temps[j].Label {
|
||||
return temps[i].Label < temps[j].Label
|
||||
}
|
||||
return temps[i].Pkg < temps[j].Pkg
|
||||
})
|
||||
return temps, fans
|
||||
}
|
||||
@@ -830,7 +866,7 @@ func filesystems() []FS {
|
||||
return
|
||||
}
|
||||
bs := uint64(st.Bsize)
|
||||
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0])}
|
||||
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0]), Source: fs[0]}
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
@@ -1097,25 +1133,61 @@ func coreIDs(n int) []int {
|
||||
return coreIDsVal[:n]
|
||||
}
|
||||
|
||||
// corePkgs maps logical CPUs to the CPU package (socket) each is on, and is nil on a
|
||||
// one-socket machine: there is nothing to tell apart.
|
||||
var (
|
||||
corePkgsOnce sync.Once
|
||||
corePkgsVal []int
|
||||
)
|
||||
|
||||
func corePkgs(n int) []int {
|
||||
corePkgsOnce.Do(func() {
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for i := 0; ; i++ {
|
||||
b, err := os.ReadFile("/sys/devices/system/cpu/cpu" + strconv.Itoa(i) + "/topology/physical_package_id")
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
out, seen[id] = append(out, id), true
|
||||
}
|
||||
if len(seen) > 1 {
|
||||
corePkgsVal = out
|
||||
}
|
||||
})
|
||||
if len(corePkgsVal) < n {
|
||||
return nil
|
||||
}
|
||||
return corePkgsVal[:n]
|
||||
}
|
||||
|
||||
// ProcTable is the Processes tab payload.
|
||||
type ProcTable struct {
|
||||
T int64 `json:"t"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first
|
||||
CPUModel string `json:"cpu_model,omitempty"`
|
||||
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
||||
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
||||
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
||||
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Net []Net `json:"net"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Procs []Proc `json:"procs"`
|
||||
Total int `json:"total"`
|
||||
Running int `json:"running"`
|
||||
T int64 `json:"t"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first
|
||||
CPUModel string `json:"cpu_model,omitempty"`
|
||||
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
||||
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
||||
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
|
||||
CPUPowerPkgsW []float64 `json:"cpu_power_pkgs_w,omitempty"`
|
||||
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
||||
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
||||
CorePkgs []int `json:"core_pkgs,omitempty"` // for each logical CPU, the socket it is on; only on a machine with more than one
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Net []Net `json:"net"`
|
||||
GPUs []GPU `json:"gpus"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Procs []Proc `json:"procs"`
|
||||
Total int `json:"total"`
|
||||
Running int `json:"running"`
|
||||
}
|
||||
|
||||
var cpuModel = func() string {
|
||||
@@ -1165,18 +1237,24 @@ func cpuMHz() float64 {
|
||||
|
||||
// cpuTemp picks the sensor that best represents the CPU package.
|
||||
func cpuTemp(temps []Temp) float64 {
|
||||
best, max := 0.0, 0.0
|
||||
// the hottest package: a two-socket board has two, and the hotter one is the one to watch
|
||||
pkg, max := 0.0, 0.0
|
||||
for _, t := range temps {
|
||||
l := strings.ToLower(t.Label)
|
||||
if t.Chip == "coretemp" && strings.HasPrefix(l, "package") || t.Chip == "k10temp" && (l == "tctl" || l == "tdie") ||
|
||||
t.Chip == "thermal" && strings.Contains(l, "cpu") || t.Chip == "cpu_thermal" || t.Chip == "soc_thermal" {
|
||||
return t.C
|
||||
if t.C > pkg {
|
||||
pkg = t.C
|
||||
}
|
||||
}
|
||||
if (t.Chip == "coretemp" || t.Chip == "k10temp") && t.C > max {
|
||||
max, best = t.C, t.C
|
||||
max = t.C
|
||||
}
|
||||
}
|
||||
return best
|
||||
if pkg > 0 {
|
||||
return pkg
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
var bootTime = func() int64 {
|
||||
@@ -1274,6 +1352,20 @@ func (c *Collector) Processes() ProcTable {
|
||||
}
|
||||
}
|
||||
c.prevProc, c.procAt = cur, now
|
||||
// what each process is doing on the GPUs, from the newest sample's client lists
|
||||
gpuBy := map[int]float64{}
|
||||
gpus := []GPU{}
|
||||
if n := len(c.ring); n > 0 && c.ring[n-1].GPUs != nil {
|
||||
gpus = c.ring[n-1].GPUs
|
||||
for _, g := range gpus {
|
||||
for _, cl := range g.Clients {
|
||||
gpuBy[cl.PID] += cl.Usage
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range procs {
|
||||
procs[i].GPU = round1(gpuBy[procs[i].PID])
|
||||
}
|
||||
sort.Slice(procs, func(i, j int) bool {
|
||||
if procs[i].CPU != procs[j].CPU {
|
||||
return procs[i].CPU > procs[j].CPU
|
||||
@@ -1281,7 +1373,7 @@ func (c *Collector) Processes() ProcTable {
|
||||
return procs[i].RSS > procs[j].RSS
|
||||
})
|
||||
t := ProcTable{T: now.UnixMilli(), Procs: procs, Total: len(procs), Uptime: uptime(), Load: loadavg(),
|
||||
CPUModel: cpuModel, CPUMHz: round1(cpuMHz()), CPUMaxMHz: cpuMaxMHz, CPUHist: []float64{}, Net: []Net{}}
|
||||
CPUModel: cpuModel, CPUMHz: round1(cpuMHz()), CPUMaxMHz: cpuMaxMHz, CPUHist: []float64{}, Net: []Net{}, GPUs: gpus}
|
||||
for _, p := range procs {
|
||||
if p.State == "R" {
|
||||
t.Running++
|
||||
@@ -1290,8 +1382,9 @@ func (c *Collector) Processes() ProcTable {
|
||||
t.Mem, t.Swap = meminfo()
|
||||
if n := len(c.ring); n > 0 {
|
||||
last := c.ring[n-1]
|
||||
t.CPU, t.Cores, t.Net, t.CPUTemp = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps)
|
||||
t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW, t.CPUPowerPkgsW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower, last.CPUPowerPkgs
|
||||
t.CoreIDs = coreIDs(len(last.Cores))
|
||||
t.CorePkgs = corePkgs(len(last.Cores))
|
||||
from := n - 90
|
||||
if from < 0 {
|
||||
from = 0
|
||||
@@ -1330,12 +1423,32 @@ func Signal(pid int, sig syscall.Signal) error {
|
||||
|
||||
// ---- small helpers ----
|
||||
|
||||
// readStr reads a /proc or /sys file whole, with plain blocking syscalls and never through
|
||||
// the runtime poller. os.ReadFile would hand a sysfs file to the poller — kernfs accepts
|
||||
// epoll — and a driver that answers a non-blocking read with EAGAIN (i915's fan attribute
|
||||
// did, on grid, 2026-10-02) then leaves the reader waiting for a readiness kernfs only
|
||||
// signals on sysfs_notify: never. The sampler sat in that wait for 42 minutes.
|
||||
func readStr(p string) string {
|
||||
b, err := os.ReadFile(p)
|
||||
fd, err := syscall.Open(p, syscall.O_RDONLY|syscall.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
defer syscall.Close(fd)
|
||||
var out []byte
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := syscall.Read(fd, buf)
|
||||
if n > 0 {
|
||||
out = append(out, buf[:n]...)
|
||||
}
|
||||
if err == syscall.EINTR {
|
||||
continue
|
||||
}
|
||||
if err != nil || n == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func readInt(p string) (int64, bool) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package metrics
|
||||
|
||||
// A trimmed PCI ID database, embedded, so a GPU is named the same on every machine —
|
||||
// "Intel UHD Graphics 600" — whether or not that machine has pciutils. OpenWrt has neither
|
||||
// lspci nor pci.ids, and the router's GPU came up as "Intel GPU 3185" beside its twin's
|
||||
// "Intel UHD Graphics 600" until this. scripts/pci-ids-gpu.sh regenerates the file.
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed pciids_gpu.txt
|
||||
var pciIDsGPU string
|
||||
|
||||
var pciTable struct {
|
||||
once sync.Once
|
||||
vendors map[string]string // "8086" → "Intel Corporation"
|
||||
devices map[string]string // "8086:3185" → "GeminiLake [UHD Graphics 600]"
|
||||
}
|
||||
|
||||
// pciName looks a vendor and device ID up (hex, lower case, no 0x): the vendor's name and
|
||||
// the device's; empty where unknown.
|
||||
func pciName(vendor, device string) (string, string) {
|
||||
pciTable.once.Do(func() {
|
||||
pciTable.vendors, pciTable.devices = map[string]string{}, map[string]string{}
|
||||
cur := ""
|
||||
for _, line := range strings.Split(pciIDsGPU, "\n") {
|
||||
switch {
|
||||
case line == "" || line[0] == '#':
|
||||
case line[0] == '\t':
|
||||
if cur == "" || len(line) < 7 || line[1] == '\t' {
|
||||
continue // a subsystem, or a device before any vendor
|
||||
}
|
||||
pciTable.devices[cur+":"+line[1:5]] = strings.TrimSpace(line[5:])
|
||||
default:
|
||||
if len(line) >= 6 {
|
||||
cur = line[:4]
|
||||
pciTable.vendors[cur] = strings.TrimSpace(line[4:])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
vendor, device = strings.ToLower(vendor), strings.ToLower(device)
|
||||
return pciTable.vendors[vendor], pciTable.devices[vendor+":"+device]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+119
-1
@@ -62,6 +62,13 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
if c := cpuTemp(s.Temps); c > 0 {
|
||||
f.add("cpu.temp", "CPU temperature", "temperature", "°C", c, t, none)
|
||||
}
|
||||
if s.CPUPower > 0 {
|
||||
f.add("cpu.power", "CPU power", "power", "W", s.CPUPower, t, none)
|
||||
}
|
||||
for i, w := range s.CPUPowerPkgs {
|
||||
n := strconv.Itoa(i)
|
||||
f.add("cpu.package."+n+".power", "CPU "+strconv.Itoa(i+1)+" power", "power", "W", w, t, none, "package", n)
|
||||
}
|
||||
for i, w := range []string{"1", "5", "15"} {
|
||||
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
|
||||
}
|
||||
@@ -89,7 +96,11 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
if x.Crit > 0 {
|
||||
b.crit = sensors.F(x.Crit)
|
||||
}
|
||||
f.add(sensors.ID("temp", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "temperature", "°C", x.C, t, b, "chip", x.Chip, "label", x.Label)
|
||||
labels := []string{"chip", x.Chip, "label", x.Label}
|
||||
if x.Chip == "coretemp" { // a two-socket board has a coretemp chip per CPU, both with a "Core 0"
|
||||
labels = append(labels, "package", strconv.Itoa(x.Pkg))
|
||||
}
|
||||
f.add(sensors.ID("temp", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "temperature", "°C", x.C, t, b, labels...)
|
||||
}
|
||||
for _, x := range s.Fans {
|
||||
f.add(sensors.ID("fan", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "fan", "rpm", float64(x.RPM), t, none, "chip", x.Chip, "label", x.Label)
|
||||
@@ -126,11 +137,118 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
if x.Disk != "" {
|
||||
lbl = append(lbl, "disk", x.Disk)
|
||||
}
|
||||
if x.Source != "" {
|
||||
lbl = append(lbl, "source", x.Source)
|
||||
}
|
||||
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
|
||||
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
|
||||
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
||||
}
|
||||
|
||||
gpuSeen := map[string]int{}
|
||||
for _, g := range s.GPUs {
|
||||
// two identical cards are gpu.<name> and gpu.<name>_2, every sensor of the second under
|
||||
// its own stem (not add's suffix on the end of each id, which the page can't find)
|
||||
p := "gpu." + sensors.Seg(g.Name)
|
||||
if gpuSeen[p]++; gpuSeen[p] > 1 {
|
||||
p = fmt.Sprintf("%s_%d", p, gpuSeen[p])
|
||||
}
|
||||
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.FanPct != nil {
|
||||
f.add(p+".fan_pct", g.Name+" fan", "fan", "%", *g.FanPct, t, pct, 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 w := s.ZFSWrite; w != nil {
|
||||
f.add("zfs.write.dirty", "ZFS write buffer", "bytes", "B", float64(w.Dirty), t, upTo(float64(w.Limit)))
|
||||
f.add("zfs.write.limit", "ZFS write buffer limit", "bytes", "B", float64(w.Limit), t, none)
|
||||
f.add("zfs.write.delayed", "ZFS writes throttled", "rate", "/s", w.DelayedPerSec, t, none)
|
||||
for _, p := range w.Pools {
|
||||
pp := "zfs." + sensors.Seg(p.Pool) + "."
|
||||
f.add(pp+"write_bps", p.Pool+" writes to disk", "rate", "B/s", p.WriteBps, t, none, "pool", p.Pool)
|
||||
f.add(pp+"logical_bps", p.Pool+" writes by programs", "rate", "B/s", p.LogicalBps, t, none, "pool", p.Pool)
|
||||
if l := p.Landing; l != nil {
|
||||
lp := pp + "landing."
|
||||
lbl := []string{"pool", p.Pool, "branch", l.Branch}
|
||||
f.add(lp+"pending_bytes", p.Pool+" landing: waiting", "bytes", "B", float64(l.PendingBytes), t, upTo(float64(l.Size)), lbl...)
|
||||
f.add(lp+"pending_files", p.Pool+" landing: files waiting", "count", "", float64(l.PendingFiles), t, none, lbl...)
|
||||
if l.Size > 0 {
|
||||
f.add(lp+"used_pct", p.Pool+" landing: used", "usage", "%", round1(float64(l.Used)/float64(l.Size)*100), t, pct, lbl...)
|
||||
}
|
||||
f.add(lp+"in_bps", p.Pool+" landing: arriving", "rate", "B/s", l.InBps, t, none, lbl...)
|
||||
f.add(lp+"drain_bps", p.Pool+" landing: moving to the pool", "rate", "B/s", l.DrainBps, t, none, lbl...)
|
||||
if m := l.Mover; m != nil {
|
||||
f.add(lp+"mover", p.Pool+" landing: mover", "state", "", m.State, t, none, lbl...)
|
||||
f.add(lp+"queue", p.Pool+" landing: mover queue", "count", "", float64(m.Queue), t, none, lbl...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if u := s.UPS; u != nil {
|
||||
ut := s.upsT
|
||||
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
package metrics
|
||||
|
||||
// The ZFS write path, for the ZFS tab: what is waiting in RAM to be written, how fast each
|
||||
// pool is writing to its disks, and — where a pool has one — the landing tier in front of it.
|
||||
//
|
||||
// A landing tier is a mergerfs pool whose first branches are fast non-ZFS storage and whose
|
||||
// last branch is a ZFS dataset: new files land on the fast branch and a mover drains them onto
|
||||
// the pool. It is detected from mergerfs's own branch list (the user.mergerfs.branches xattr on
|
||||
// the pool's .mergerfs control file), so it shows wherever one exists and nowhere else. A mover
|
||||
// that writes a status file into /run/landing-mover/ (hamlet's scripts/landing-mover.py does)
|
||||
// adds what it is doing: its queue, the file in flight, totals and the last error.
|
||||
//
|
||||
// Sources, all cheap enough for every sample:
|
||||
// - arcstats anon_size: dirty data in RAM, for all pools together (ZFS has no live per-pool
|
||||
// figure; txgs only records ndirty once a txg has synced)
|
||||
// - dmu_tx: dmu_tx_dirty_delay / dmu_tx_dirty_over_max — writers slowed by the write throttle,
|
||||
// or stopped at the hard limit
|
||||
// - <pool>/txgs: nwritten per synced txg — bytes the pool actually wrote to its vdevs
|
||||
// - <pool>/objset-*: nwritten per dataset — bytes programs wrote, before compression
|
||||
// The landing branch's file walk is slower and runs every landingEvery.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
zfsKstatDir = "/proc/spl/kstat/zfs/"
|
||||
zfsParamDir = "/sys/module/zfs/parameters/"
|
||||
moverStateDir = "/run/landing-mover"
|
||||
landingEvery = 10 * time.Second
|
||||
landingWalkMax = 200000 // entries; past this the counts are marked partial
|
||||
)
|
||||
|
||||
// ZFSWrites is the write path as one sample sees it. Nil without the ZFS module.
|
||||
type ZFSWrites struct {
|
||||
Dirty uint64 `json:"dirty"` // bytes in RAM written but not yet on disk, all pools
|
||||
DirtyMax uint64 `json:"dirty_max"` // zfs_dirty_data_max
|
||||
DelayMinPct int `json:"delay_min_pct"` // zfs_delay_min_dirty_percent: the throttle starts here
|
||||
Limit uint64 `json:"limit"` // what the buffer can really hold now: the smaller of dirty_max and half the ARC target
|
||||
DelayedPerSec float64 `json:"delayed_per_sec"` // writes the throttle slowed, per second over the interval
|
||||
OverMaxPerSec float64 `json:"over_max_per_sec"` // writes stopped dead at the limit
|
||||
TxgTimeoutSec int `json:"txg_timeout_sec"` // zfs_txg_timeout
|
||||
Pools []PoolWrites `json:"pools"`
|
||||
}
|
||||
|
||||
// PoolWrites is one pool.
|
||||
type PoolWrites struct {
|
||||
Pool string `json:"pool"`
|
||||
WriteBps float64 `json:"write_bps"` // to its vdevs, after compression, parity included
|
||||
LogicalBps float64 `json:"logical_bps"` // into its datasets, as programs wrote it
|
||||
Landing *Landing `json:"landing,omitempty"`
|
||||
}
|
||||
|
||||
// Landing is a landing tier in front of a pool.
|
||||
type Landing struct {
|
||||
Mount string `json:"mount"` // the mergerfs pool, e.g. /srv/tank
|
||||
Branch string `json:"branch"` // the landing branch, e.g. /srv/landing/tank
|
||||
PoolBranch string `json:"pool_branch"`
|
||||
Device string `json:"device,omitempty"` // block device under the branch, as in Sample.Disks
|
||||
FSType string `json:"fstype,omitempty"`
|
||||
Size uint64 `json:"size"`
|
||||
Used uint64 `json:"used"`
|
||||
PendingFiles int `json:"pending_files"`
|
||||
PendingBytes uint64 `json:"pending_bytes"`
|
||||
PendingPartial bool `json:"pending_partial,omitempty"` // the walk stopped at landingWalkMax
|
||||
OldestMs int64 `json:"oldest_ms,omitempty"` // mtime of the oldest waiting file
|
||||
CountedMs int64 `json:"counted_ms"` // when the walk ran
|
||||
InBps float64 `json:"in_bps"` // the landing device's writes
|
||||
DrainBps float64 `json:"drain_bps"` // the mover's progress onto the pool
|
||||
Mover *Mover `json:"mover,omitempty"`
|
||||
}
|
||||
|
||||
// Mover is what a landing mover says about itself in its status file.
|
||||
type Mover struct {
|
||||
State string `json:"state"` // idle | moving | holding (a mount it needs is missing)
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
PID int `json:"pid"`
|
||||
StartedMs int64 `json:"started_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
Stale bool `json:"stale"` // not updated for a while: stopped or hung
|
||||
Queue int `json:"queue"`
|
||||
Settle float64 `json:"settle"`
|
||||
BWLimit string `json:"bwlimit"`
|
||||
BatchFiles int `json:"batch_files"`
|
||||
BatchBytes uint64 `json:"batch_bytes"`
|
||||
BatchStartMs int64 `json:"batch_start_ms"`
|
||||
BatchFirst string `json:"batch_first"`
|
||||
Current string `json:"current"` // the file being copied, when it can tell
|
||||
CurrentSize uint64 `json:"current_size"` // its full size
|
||||
Partial uint64 `json:"partial"` // bytes of it already on the pool
|
||||
MovedFiles uint64 `json:"moved_files"` // since the mover started
|
||||
MovedBytes uint64 `json:"moved_bytes"`
|
||||
LastMove string `json:"last_move"`
|
||||
LastMoveMs int64 `json:"last_move_ms"`
|
||||
LastError string `json:"last_error"`
|
||||
LastErrorMs int64 `json:"last_error_ms"`
|
||||
}
|
||||
|
||||
type zfsWritePrev struct {
|
||||
at time.Time
|
||||
delay uint64
|
||||
overMax uint64
|
||||
txg map[string]uint64 // pool -> highest synced txg counted
|
||||
txgAt map[string]time.Time // pool -> when that txg was first seen
|
||||
txgRate map[string]float64 // pool -> bytes/s over the last gap between syncs
|
||||
logical map[string]uint64 // pool -> sum of objset nwritten
|
||||
drained map[string]uint64 // landing branch -> moved + partial bytes, at the mover's last update
|
||||
drainedAt map[string]int64 // landing branch -> that update's time, unix ms
|
||||
drainRate map[string]float64 // landing branch -> bytes/s between its last two updates
|
||||
landing map[string]Landing // branch -> last walk
|
||||
landingAt time.Time
|
||||
}
|
||||
|
||||
func readParamU(name string) uint64 {
|
||||
v, _ := strconv.ParseUint(strings.TrimSpace(readStr(zfsParamDir+name)), 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// poolsWithKstats lists the imported pools: the directories under the kstat tree.
|
||||
func poolsWithKstats() []string {
|
||||
ents, err := os.ReadDir(zfsKstatDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
if _, err := os.Stat(zfsKstatDir + e.Name() + "/txgs"); err == nil {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// parseTxgs reads a pool's txgs kstat and returns, for every committed txg above `after`, the
|
||||
// bytes it wrote, plus the highest committed txg seen.
|
||||
func parseTxgs(text string, after uint64) (written, top uint64) {
|
||||
top = after
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
// txg birth state ndirty nread nwritten reads writes otime qtime wtime stime
|
||||
if len(f) < 12 || f[2] != "C" {
|
||||
continue
|
||||
}
|
||||
txg, err := strconv.ParseUint(f[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if txg > top {
|
||||
top = txg
|
||||
}
|
||||
if txg > after {
|
||||
n, _ := strconv.ParseUint(f[5], 10, 64)
|
||||
written += n
|
||||
}
|
||||
}
|
||||
return written, top
|
||||
}
|
||||
|
||||
// objsetWritten sums nwritten over a pool's datasets.
|
||||
func objsetWritten(pool string) uint64 {
|
||||
m, _ := filepath.Glob(zfsKstatDir + pool + "/objset-*")
|
||||
var t uint64
|
||||
for _, p := range m {
|
||||
for _, line := range strings.Split(readStr(p), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 3 && f[0] == "nwritten" {
|
||||
v, _ := strconv.ParseUint(f[2], 10, 64)
|
||||
t += v
|
||||
}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// mountEntry is one line of /proc/mounts.
|
||||
type mountEntry struct{ src, point, fstype string }
|
||||
|
||||
func readMounts() []mountEntry {
|
||||
var out []mountEntry
|
||||
for _, line := range strings.Split(readStr("/proc/mounts"), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 3 {
|
||||
out = append(out, mountEntry{src: unescapeMount(f[0]), point: unescapeMount(f[1]), fstype: f[2]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unescapeMount undoes /proc/mounts' octal escapes (\040 for a space, and so on).
|
||||
func unescapeMount(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) {
|
||||
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
||||
b.WriteByte(byte(v))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mountFor is the mount a path lives on: the longest mount point that is a prefix of it.
|
||||
func mountFor(ms []mountEntry, path string) (mountEntry, bool) {
|
||||
var best mountEntry
|
||||
found := false
|
||||
for _, m := range ms {
|
||||
if m.point == path || m.point == "/" || strings.HasPrefix(path, strings.TrimSuffix(m.point, "/")+"/") {
|
||||
if !found || len(m.point) > len(best.point) {
|
||||
best, found = m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// parseBranches reads mergerfs's branch list: "/srv/landing/tank=RW:/tank=RW,50G".
|
||||
func parseBranches(v string) (paths []string, modes []string) {
|
||||
for _, b := range strings.Split(strings.TrimSpace(v), ":") {
|
||||
if b == "" {
|
||||
continue
|
||||
}
|
||||
p, mode, _ := strings.Cut(b, "=")
|
||||
mode, _, _ = strings.Cut(mode, ",")
|
||||
if mode == "" {
|
||||
mode = "RW"
|
||||
}
|
||||
paths, modes = append(paths, p), append(modes, mode)
|
||||
}
|
||||
return paths, modes
|
||||
}
|
||||
|
||||
func getxattr(path, name string) string {
|
||||
buf := make([]byte, 4096)
|
||||
n, err := syscall.Getxattr(path, name, buf)
|
||||
if err != nil || n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
// findLandings maps pool -> the landing tiers in front of it.
|
||||
func findLandings(ms []mountEntry) map[string][]Landing {
|
||||
out := map[string][]Landing{}
|
||||
for _, m := range ms {
|
||||
if m.fstype != "fuse.mergerfs" {
|
||||
continue
|
||||
}
|
||||
paths, modes := parseBranches(getxattr(filepath.Join(m.point, ".mergerfs"), "user.mergerfs.branches"))
|
||||
// the pool branch is the first ZFS one; landing branches are writable non-ZFS ones before it
|
||||
for i, p := range paths {
|
||||
bm, ok := mountFor(ms, p)
|
||||
if !ok || bm.fstype != "zfs" {
|
||||
continue
|
||||
}
|
||||
pool, _, _ := strings.Cut(bm.src, "/")
|
||||
for j := 0; j < i; j++ {
|
||||
lm, ok := mountFor(ms, paths[j])
|
||||
if !ok || lm.fstype == "zfs" || modes[j] == "RO" {
|
||||
continue
|
||||
}
|
||||
out[pool] = append(out[pool], Landing{Mount: m.point, Branch: paths[j], PoolBranch: p, FSType: lm.fstype, Device: blockDisk(lm.src)})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blockDisk turns a mount source into the name Sample.Disks uses: /dev/nvme0n1p1 -> nvme0,
|
||||
// /dev/sdb2 -> sdb. Empty for anything that is not a plain block device.
|
||||
func blockDisk(src string) string {
|
||||
dev, err := filepath.EvalSymlinks(src)
|
||||
if err != nil || !strings.HasPrefix(dev, "/dev/") {
|
||||
return ""
|
||||
}
|
||||
name := filepath.Base(dev)
|
||||
// a partition's sysfs entry sits inside its disk's
|
||||
if p, err := filepath.EvalSymlinks("/sys/class/block/" + name); err == nil {
|
||||
if _, err := os.Stat(p + "/partition"); err == nil {
|
||||
name = filepath.Base(filepath.Dir(p))
|
||||
}
|
||||
}
|
||||
if m := nsRe.FindStringSubmatch(name); m != nil {
|
||||
return m[1] // nvme0n1 -> nvme0, as foldNVMe names it
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// walkLanding counts what is waiting on a landing branch.
|
||||
func walkLanding(l *Landing, now time.Time) {
|
||||
var st syscall.Statfs_t
|
||||
if syscall.Statfs(l.Branch, &st) == nil {
|
||||
l.Size = st.Blocks * uint64(st.Bsize)
|
||||
l.Used = (st.Blocks - st.Bfree) * uint64(st.Bsize)
|
||||
}
|
||||
seen := 0
|
||||
l.PendingFiles, l.PendingBytes, l.OldestMs, l.PendingPartial = 0, 0, 0, false
|
||||
_ = filepath.WalkDir(l.Branch, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if seen++; seen > landingWalkMax {
|
||||
l.PendingPartial = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if fi, err := d.Info(); err == nil {
|
||||
l.PendingFiles++
|
||||
l.PendingBytes += uint64(fi.Size())
|
||||
if ms := fi.ModTime().UnixMilli(); l.OldestMs == 0 || ms < l.OldestMs {
|
||||
l.OldestMs = ms
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
l.CountedMs = now.UnixMilli()
|
||||
}
|
||||
|
||||
// readMovers loads every mover status file, keyed by the branch it drains.
|
||||
func readMovers(now time.Time) map[string]*Mover {
|
||||
out := map[string]*Mover{}
|
||||
m, _ := filepath.Glob(moverStateDir + "/*.json")
|
||||
for _, p := range m {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var mv Mover
|
||||
if json.Unmarshal(b, &mv) != nil || mv.Src == "" {
|
||||
continue
|
||||
}
|
||||
mv.Stale = now.UnixMilli()-mv.UpdatedMs > 30_000
|
||||
if mv.Stale {
|
||||
mv.State = "stopped"
|
||||
}
|
||||
out[filepath.Clean(mv.Src)] = &mv
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// zfsWrites samples the write path; nil when this machine has no ZFS module loaded.
|
||||
func (c *Collector) zfsWrites(now time.Time, arc *ARC, disks []Disk) *ZFSWrites {
|
||||
if arc == nil {
|
||||
return nil
|
||||
}
|
||||
p := &c.prevZW
|
||||
dt := 0.0
|
||||
if !p.at.IsZero() {
|
||||
dt = now.Sub(p.at).Seconds()
|
||||
}
|
||||
w := &ZFSWrites{Dirty: arc.Anon, DirtyMax: readParamU("zfs_dirty_data_max"),
|
||||
DelayMinPct: int(readParamU("zfs_delay_min_dirty_percent")), TxgTimeoutSec: int(readParamU("zfs_txg_timeout")), Pools: []PoolWrites{}}
|
||||
w.Limit = w.DirtyMax
|
||||
if half := arc.Target / 2; half > 0 && half < w.Limit {
|
||||
w.Limit = half
|
||||
}
|
||||
tx := readKstat(zfsKstatDir + "dmu_tx")
|
||||
if dt > 0 && tx["dmu_tx_dirty_delay"] >= p.delay && tx["dmu_tx_dirty_over_max"] >= p.overMax {
|
||||
w.DelayedPerSec = round1(float64(tx["dmu_tx_dirty_delay"]-p.delay) / dt)
|
||||
w.OverMaxPerSec = round1(float64(tx["dmu_tx_dirty_over_max"]-p.overMax) / dt)
|
||||
}
|
||||
p.delay, p.overMax = tx["dmu_tx_dirty_delay"], tx["dmu_tx_dirty_over_max"]
|
||||
if p.txg == nil {
|
||||
p.txg, p.logical, p.drained, p.landing = map[string]uint64{}, map[string]uint64{}, map[string]uint64{}, map[string]Landing{}
|
||||
p.txgAt, p.txgRate, p.drainedAt, p.drainRate = map[string]time.Time{}, map[string]float64{}, map[string]int64{}, map[string]float64{}
|
||||
}
|
||||
|
||||
ms := readMounts()
|
||||
landings := findLandings(ms)
|
||||
walk := now.Sub(p.landingAt) >= landingEvery
|
||||
if walk {
|
||||
p.landingAt = now
|
||||
}
|
||||
movers := readMovers(now)
|
||||
diskW := map[string]float64{}
|
||||
for _, d := range disks {
|
||||
diskW[d.Name] = d.WriteBps
|
||||
}
|
||||
|
||||
for _, pool := range poolsWithKstats() {
|
||||
pw := PoolWrites{Pool: pool}
|
||||
// A txg records what it wrote only when it has synced — every few seconds, not every
|
||||
// sample — so the rate is taken over the gap between syncs and held until the next one.
|
||||
prevTop, had := p.txg[pool]
|
||||
written, top := parseTxgs(readStr(zfsKstatDir+pool+"/txgs"), prevTop)
|
||||
switch {
|
||||
case !had:
|
||||
p.txgAt[pool] = now
|
||||
case top > prevTop:
|
||||
if gap := now.Sub(p.txgAt[pool]).Seconds(); gap > 0 {
|
||||
p.txgRate[pool] = round1(float64(written) / gap)
|
||||
}
|
||||
p.txgAt[pool] = now
|
||||
case now.Sub(p.txgAt[pool]) > time.Duration(2*max(w.TxgTimeoutSec, 5))*time.Second:
|
||||
p.txgRate[pool] = 0 // no sync for two timeouts: nothing is being written
|
||||
}
|
||||
pw.WriteBps = p.txgRate[pool]
|
||||
p.txg[pool] = top
|
||||
lw := objsetWritten(pool)
|
||||
if prev, ok := p.logical[pool]; ok && dt > 0 && lw >= prev {
|
||||
pw.LogicalBps = round1(float64(lw-prev) / dt)
|
||||
}
|
||||
p.logical[pool] = lw
|
||||
|
||||
if ls := landings[pool]; len(ls) > 0 {
|
||||
l := ls[0] // one landing tier per pool is what the view shows
|
||||
if last, ok := p.landing[l.Branch]; ok && !walk {
|
||||
l.Size, l.Used, l.PendingFiles, l.PendingBytes = last.Size, last.Used, last.PendingFiles, last.PendingBytes
|
||||
l.OldestMs, l.PendingPartial, l.CountedMs = last.OldestMs, last.PendingPartial, last.CountedMs
|
||||
} else {
|
||||
walkLanding(&l, now)
|
||||
p.landing[l.Branch] = l
|
||||
}
|
||||
l.InBps = diskW[l.Device]
|
||||
if mv := movers[filepath.Clean(l.Branch)]; mv != nil {
|
||||
l.Mover = mv
|
||||
// the mover writes its status every couple of seconds: measure between its updates
|
||||
done := mv.MovedBytes + mv.Partial
|
||||
if at, ok := p.drainedAt[l.Branch]; !ok || mv.UpdatedMs != at {
|
||||
if ok && mv.UpdatedMs > at && done >= p.drained[l.Branch] {
|
||||
p.drainRate[l.Branch] = round1(float64(done-p.drained[l.Branch]) / (float64(mv.UpdatedMs-at) / 1000))
|
||||
}
|
||||
p.drained[l.Branch], p.drainedAt[l.Branch] = done, mv.UpdatedMs
|
||||
}
|
||||
if mv.State != "moving" {
|
||||
p.drainRate[l.Branch] = 0
|
||||
}
|
||||
l.DrainBps = p.drainRate[l.Branch]
|
||||
}
|
||||
pw.Landing = &l
|
||||
}
|
||||
w.Pools = append(w.Pools, pw)
|
||||
}
|
||||
p.at = now
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package metrics
|
||||
|
||||
import "testing"
|
||||
|
||||
// real rows from grid's /proc/spl/kstat/zfs/tank/txgs, 2026-10-25
|
||||
const txgsSample = `81 1 0x01 26 7072 20029594359 13953814329135
|
||||
txg birth state ndirty nread nwritten reads writes otime qtime wtime stime
|
||||
15812833 13936408791659 C 0 0 0 0 0 5119980242 2040 58385 309580
|
||||
15812834 13941528771901 C 0 0 0 0 0 5119948815 8471 65826 112612
|
||||
15812835 13946648720716 C 885760 0 2293760 0 367 5120100556 1704 56762 271264374
|
||||
15812836 13951768821272 S 4194304 0 0 0 0 5120000000 1000 50000 0
|
||||
15812837 13956888821272 O 0 0 0 0 0 0 0 0 0
|
||||
`
|
||||
|
||||
func TestParseTxgs(t *testing.T) {
|
||||
w, top := parseTxgs(txgsSample, 0)
|
||||
if w != 2293760 || top != 15812835 {
|
||||
t.Fatalf("from 0: written %d top %d", w, top)
|
||||
}
|
||||
// only txgs synced since the last look count, and an open or syncing txg never does
|
||||
w, top = parseTxgs(txgsSample, 15812834)
|
||||
if w != 2293760 || top != 15812835 {
|
||||
t.Fatalf("after 15812834: written %d top %d", w, top)
|
||||
}
|
||||
w, top = parseTxgs(txgsSample, 15812835)
|
||||
if w != 0 || top != 15812835 {
|
||||
t.Fatalf("nothing new: written %d top %d", w, top)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBranches(t *testing.T) {
|
||||
p, m := parseBranches("/srv/landing/tank=RW:/tank=NC,50G:/mnt/old=RO\n")
|
||||
if len(p) != 3 || p[0] != "/srv/landing/tank" || p[1] != "/tank" || p[2] != "/mnt/old" {
|
||||
t.Fatalf("paths %q", p)
|
||||
}
|
||||
if m[0] != "RW" || m[1] != "NC" || m[2] != "RO" {
|
||||
t.Fatalf("modes %q", m)
|
||||
}
|
||||
if p, _ := parseBranches("/a:/b"); len(p) != 2 {
|
||||
t.Fatalf("bare paths %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountFor(t *testing.T) {
|
||||
ms := []mountEntry{
|
||||
{"/dev/sdj2", "/", "ext4"},
|
||||
{"/dev/nvme0n1p1", "/srv/landing", "ext4"},
|
||||
{"tank", "/tank", "zfs"},
|
||||
{"tank/media", "/tank/media", "zfs"},
|
||||
{"tank-landing", "/srv/tank", "fuse.mergerfs"},
|
||||
}
|
||||
cases := map[string]string{
|
||||
"/srv/landing/tank": "/srv/landing", "/tank": "/tank", "/tank/media/x": "/tank/media",
|
||||
"/tankfoo": "/", "/srv/landingx": "/",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if m, ok := mountFor(ms, path); !ok || m.point != want {
|
||||
t.Errorf("%s: got %q want %q", path, m.point, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnescapeMount(t *testing.T) {
|
||||
if got := unescapeMount(`/mnt/my\040disk`); got != "/mnt/my disk" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldZFSWrites(t *testing.T) {
|
||||
mk := func(dirty uint64, bps, in, drain float64) Sample {
|
||||
return Sample{ZFSWrite: &ZFSWrites{Dirty: dirty, Limit: 100, Pools: []PoolWrites{{Pool: "tank", WriteBps: bps,
|
||||
Landing: &Landing{Branch: "/srv/landing/tank", InBps: in, DrainBps: drain, Mover: &Mover{State: "moving"}}}}}}
|
||||
}
|
||||
b := []Sample{mk(10, 100, 400, 0), mk(30, 300, 0, 60)}
|
||||
got := fold(b).ZFSWrite
|
||||
if got.Dirty != 20 || got.Pools[0].WriteBps != 200 || got.Pools[0].Landing.InBps != 200 || got.Pools[0].Landing.DrainBps != 30 {
|
||||
t.Fatalf("folded %+v / %+v", got, got.Pools[0].Landing)
|
||||
}
|
||||
// the fold must not write through to the newest sample it copied from
|
||||
if b[1].ZFSWrite.Pools[0].Landing.InBps != 0 || b[1].ZFSWrite.Dirty != 30 {
|
||||
t.Fatalf("fold changed its input")
|
||||
}
|
||||
if got.Pools[0].Landing.Mover.State != "moving" {
|
||||
t.Fatalf("mover state lost")
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -383,13 +383,19 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bo
|
||||
// WebSocket origin check (meant for browsers) doesn't reject the hub.
|
||||
// Do makes one request to a managed machine as this hub and returns the whole response.
|
||||
func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, error) {
|
||||
code, b, _, err := m.DoHeaders(ctx, name, method, path, body, hdr)
|
||||
return code, b, err
|
||||
}
|
||||
|
||||
// DoHeaders is Do that also answers the response's headers.
|
||||
func (m *Manager) DoHeaders(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, http.Header, error) {
|
||||
n, ok := m.node(name)
|
||||
if !ok {
|
||||
return 0, nil, errors.New("no such machine")
|
||||
return 0, nil, nil, errors.New("no such machine")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(n.URL, "/")+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
for k, v := range hdr {
|
||||
req.Header[k] = v
|
||||
@@ -397,11 +403,17 @@ func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte
|
||||
req.Header.Set("Authorization", "Bearer "+n.Token)
|
||||
resp, err := m.client(n).Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, errors.New(shortErr(err))
|
||||
return 0, nil, nil, errors.New(shortErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
return resp.StatusCode, b, err
|
||||
return resp.StatusCode, b, resp.Header, err
|
||||
}
|
||||
|
||||
// URL is a managed machine's address, "" for one not managed here.
|
||||
func (m *Manager) URL(name string) string {
|
||||
n, _ := m.node(name)
|
||||
return n.URL
|
||||
}
|
||||
|
||||
// Names lists the managed machines.
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package storage
|
||||
|
||||
// The ZFS caching tiers, for the ZFS tab: what the ARC has done since boot and how it is
|
||||
// tuned, and for each pool which cache devices it has — L2ARC, a separate log (SLOG), a
|
||||
// special allocation class, a dedup table — or, as is common, none of them, which the tab
|
||||
// should say plainly rather than leave a blank.
|
||||
//
|
||||
// The live numbers (size, the hit rate over the last interval) come from the sampler in
|
||||
// internal/metrics, which also publishes them as zfs.arc.* sensors; this is the slower,
|
||||
// fuller picture, read when the tab asks.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheDev is one member of a pool's cache, log, special, dedup or spare class.
|
||||
type CacheDev struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size,omitempty"`
|
||||
Alloc uint64 `json:"alloc,omitempty"`
|
||||
Health string `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// PoolCaching is what one pool has beyond its data vdevs, and the properties that steer
|
||||
// what is cached.
|
||||
type PoolCaching struct {
|
||||
Pool string `json:"pool"`
|
||||
Cache []CacheDev `json:"cache"` // L2ARC
|
||||
Log []CacheDev `json:"log"` // SLOG
|
||||
Special []CacheDev `json:"special"` // metadata / small blocks
|
||||
Dedup []CacheDev `json:"dedup"` // dedup table vdevs
|
||||
Spare []CacheDev `json:"spare"`
|
||||
// dataset properties at the pool root
|
||||
PrimaryCache string `json:"primarycache"`
|
||||
SecondaryCache string `json:"secondarycache"`
|
||||
Sync string `json:"sync"`
|
||||
LogBias string `json:"logbias"`
|
||||
DedupProp string `json:"dedup_prop"`
|
||||
Compression string `json:"compression"`
|
||||
RecordSize string `json:"recordsize"`
|
||||
DedupRatio string `json:"dedup_ratio"` // zpool's, e.g. "1.00x"
|
||||
}
|
||||
|
||||
// ARCDetail is the full picture.
|
||||
type ARCDetail struct {
|
||||
Available bool `json:"available"`
|
||||
MemTotal uint64 `json:"mem_total"`
|
||||
Tunables map[string]string `json:"tunables"` // zfs module parameters that shape the cache; "" when unset
|
||||
Lifetime struct {
|
||||
Hits, Misses uint64 `json:",omitempty"`
|
||||
DemandDataHits, DemandDataMisses uint64
|
||||
DemandMetaHits, DemandMetaMisses uint64
|
||||
PrefetchDataHits, PrefetchDataMisses uint64
|
||||
PrefetchMetaHits, PrefetchMetaMisses uint64
|
||||
L2Hits, L2Misses, L2ReadBytes, L2WriteBytes uint64
|
||||
L2Feeds, L2RebuildSize uint64
|
||||
EvictL2Eligible uint64 // bytes evicted from RAM that an L2ARC could have caught
|
||||
Deleted, EvictSkip, MutexMiss, Prune, Throttle uint64
|
||||
} `json:"lifetime"`
|
||||
Zfetch struct{ Hits, Misses, IOIssued uint64 } `json:"zfetch"` // the prefetcher
|
||||
ZIL struct {
|
||||
Commits uint64 `json:"commits"`
|
||||
NormalBytes, NormalWrite uint64 // written to the pool's own vdevs
|
||||
SlogBytes, SlogWrite uint64 // written to a separate log device
|
||||
} `json:"zil"`
|
||||
DBuf struct{ Size, Target, Hits, Misses uint64 } `json:"dbuf"` // the dbuf cache in front of the ARC
|
||||
Pools []PoolCaching `json:"pools"`
|
||||
}
|
||||
|
||||
const kstatDir = "/proc/spl/kstat/zfs/"
|
||||
|
||||
func readKstat(p string) map[string]uint64 {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]uint64{}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 3 || f[1] != "4" {
|
||||
continue
|
||||
}
|
||||
if v, err := strconv.ParseUint(f[2], 10, 64); err == nil {
|
||||
out[f[0]] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var arcTunables = []string{"zfs_arc_max", "zfs_arc_min", "zfs_arc_sys_free", "zfs_arc_meta_balance", "zfs_prefetch_disable", "l2arc_write_max", "l2arc_write_boost", "l2arc_noprefetch", "l2arc_rebuild_enabled", "zfs_dirty_data_max"}
|
||||
|
||||
const poolCachingEvery = 30 * time.Second // zpool list -v and zfs get are an exec each
|
||||
|
||||
// ARC reads the kstats now and the pool layout from a short cache.
|
||||
func (m *Manager) ARC(ctx context.Context) ARCDetail {
|
||||
d := ARCDetail{Tunables: map[string]string{}, Pools: []PoolCaching{}}
|
||||
k := readKstat(kstatDir + "arcstats")
|
||||
if k == nil {
|
||||
return d
|
||||
}
|
||||
d.Available = true
|
||||
l := &d.Lifetime
|
||||
l.Hits, l.Misses = k["hits"], k["misses"]
|
||||
l.DemandDataHits, l.DemandDataMisses = k["demand_data_hits"], k["demand_data_misses"]
|
||||
l.DemandMetaHits, l.DemandMetaMisses = k["demand_metadata_hits"], k["demand_metadata_misses"]
|
||||
l.PrefetchDataHits, l.PrefetchDataMisses = k["prefetch_data_hits"], k["prefetch_data_misses"]
|
||||
l.PrefetchMetaHits, l.PrefetchMetaMisses = k["prefetch_metadata_hits"], k["prefetch_metadata_misses"]
|
||||
l.L2Hits, l.L2Misses, l.L2ReadBytes, l.L2WriteBytes = k["l2_hits"], k["l2_misses"], k["l2_read_bytes"], k["l2_write_bytes"]
|
||||
l.L2Feeds, l.L2RebuildSize, l.EvictL2Eligible = k["l2_feeds"], k["l2_rebuild_size"], k["evict_l2_eligible"]
|
||||
l.Deleted, l.EvictSkip, l.MutexMiss, l.Prune, l.Throttle = k["deleted"], k["evict_skip"], k["mutex_miss"], k["arc_prune"], k["memory_throttle_count"]
|
||||
if z := readKstat(kstatDir + "zfetchstats"); z != nil {
|
||||
d.Zfetch.Hits, d.Zfetch.Misses, d.Zfetch.IOIssued = z["hits"], z["misses"], z["io_issued"]
|
||||
}
|
||||
if z := readKstat(kstatDir + "zil"); z != nil {
|
||||
d.ZIL.Commits = z["zil_commit_count"]
|
||||
d.ZIL.NormalBytes, d.ZIL.NormalWrite = z["zil_itx_metaslab_normal_bytes"], z["zil_itx_metaslab_normal_write"]
|
||||
d.ZIL.SlogBytes, d.ZIL.SlogWrite = z["zil_itx_metaslab_slog_bytes"], z["zil_itx_metaslab_slog_write"]
|
||||
}
|
||||
if z := readKstat(kstatDir + "dbufstats"); z != nil {
|
||||
d.DBuf.Size, d.DBuf.Target, d.DBuf.Hits, d.DBuf.Misses = z["cache_size_bytes"], z["cache_target_bytes"], z["hash_hits"], z["hash_misses"]
|
||||
}
|
||||
for _, t := range arcTunables {
|
||||
if b, err := os.ReadFile("/sys/module/zfs/parameters/" + t); err == nil {
|
||||
d.Tunables[t] = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 2 {
|
||||
d.MemTotal = u64(f[1]) << 10
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
d.Pools = m.poolCaching(ctx)
|
||||
return d
|
||||
}
|
||||
|
||||
var cachingMu sync.Mutex
|
||||
var cachingAt time.Time
|
||||
var cachingLast []PoolCaching
|
||||
|
||||
func (m *Manager) poolCaching(ctx context.Context) []PoolCaching {
|
||||
cachingMu.Lock()
|
||||
defer cachingMu.Unlock()
|
||||
if cachingLast != nil && time.Since(cachingAt) < poolCachingEvery {
|
||||
return cachingLast
|
||||
}
|
||||
out := []PoolCaching{}
|
||||
bin, ok := lookPath("zpool")
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
// zpool list -v needs no root; the vdev tree under each pool, with the class headers
|
||||
raw, code, err := m.runPlain(ctx, bin, "list", "-Hpv", "-o", "name,size,alloc,free,health")
|
||||
if err != nil || code != 0 {
|
||||
return out
|
||||
}
|
||||
out = parsePoolCaching(string(raw))
|
||||
if zfs, ok := lookPath("zfs"); ok {
|
||||
for i := range out {
|
||||
p := &out[i]
|
||||
if props, code, err := m.runPlain(ctx, zfs, "get", "-Hp", "-o", "property,value", "primarycache,secondarycache,sync,logbias,dedup,compression,recordsize", p.Pool); err == nil && code == 0 {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(props)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
switch f[0] {
|
||||
case "primarycache":
|
||||
p.PrimaryCache = f[1]
|
||||
case "secondarycache":
|
||||
p.SecondaryCache = f[1]
|
||||
case "sync":
|
||||
p.Sync = f[1]
|
||||
case "logbias":
|
||||
p.LogBias = f[1]
|
||||
case "dedup":
|
||||
p.DedupProp = f[1]
|
||||
case "compression":
|
||||
p.Compression = f[1]
|
||||
case "recordsize":
|
||||
p.RecordSize = humanSize(u64(f[1]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, pool := range parsePools(func() []byte {
|
||||
b, _, _ := m.runPlain(ctx, bin, "list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup")
|
||||
return b
|
||||
}()) {
|
||||
for i := range out {
|
||||
if out[i].Pool == pool.Name {
|
||||
out[i].DedupRatio = pool.Dedup
|
||||
}
|
||||
}
|
||||
}
|
||||
cachingLast, cachingAt = out, time.Now()
|
||||
return out
|
||||
}
|
||||
|
||||
// runPlain runs a command as ourselves, never through sudo: these are read-only zpool/zfs
|
||||
// queries that need no root on Linux, and a poll must not write to the auth log.
|
||||
func (m *Manager) runPlain(ctx context.Context, bin string, args ...string) ([]byte, int, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, bin, args...).Output()
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return out, ee.ExitCode(), nil
|
||||
}
|
||||
return out, 0, err
|
||||
}
|
||||
|
||||
var cacheClasses = map[string]bool{"logs": true, "log": true, "cache": true, "special": true, "dedup": true, "spares": true, "spare": true}
|
||||
|
||||
// parsePoolCaching reads `zpool list -Hpv -o name,size,alloc,free,health`: a pool line, then
|
||||
// its vdevs indented, with a bare class name ("logs", "cache", "special", "dedup", "spares")
|
||||
// introducing each extra class. Only those classes' members are kept; the data vdevs are the
|
||||
// pool and are shown elsewhere.
|
||||
func parsePoolCaching(text string) []PoolCaching {
|
||||
var out []PoolCaching
|
||||
var cur *PoolCaching
|
||||
class := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
indented := strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t")
|
||||
f := strings.Split(strings.TrimSpace(raw), "\t")
|
||||
name := strings.TrimSpace(f[0])
|
||||
if !indented {
|
||||
out = append(out, PoolCaching{Pool: name, Cache: []CacheDev{}, Log: []CacheDev{}, Special: []CacheDev{}, Dedup: []CacheDev{}, Spare: []CacheDev{}})
|
||||
cur, class = &out[len(out)-1], ""
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
if cacheClasses[name] && (len(f) < 2 || f[1] == "-" || f[1] == "") {
|
||||
class = name
|
||||
continue
|
||||
}
|
||||
if class == "" {
|
||||
continue // a data vdev or one of its disks
|
||||
}
|
||||
// a mirror or raidz inside a class lists its members below it; keep them all, the
|
||||
// grouping is visible in the vdev tree on the pool card
|
||||
dev := CacheDev{Name: name}
|
||||
if len(f) >= 5 {
|
||||
dev.Size, dev.Alloc, dev.Health = u64(f[1]), u64(f[2]), strings.TrimSpace(f[4])
|
||||
if f[1] == "-" {
|
||||
dev.Size = 0
|
||||
}
|
||||
}
|
||||
switch class {
|
||||
case "logs", "log":
|
||||
cur.Log = append(cur.Log, dev)
|
||||
case "cache":
|
||||
cur.Cache = append(cur.Cache, dev)
|
||||
case "special":
|
||||
cur.Special = append(cur.Special, dev)
|
||||
case "dedup":
|
||||
cur.Dedup = append(cur.Dedup, dev)
|
||||
case "spares", "spare":
|
||||
cur.Spare = append(cur.Spare, dev)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []PoolCaching{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func humanSize(b uint64) string {
|
||||
switch {
|
||||
case b >= 1<<20 && b%(1<<20) == 0:
|
||||
return strconv.FormatUint(b>>20, 10) + "M"
|
||||
case b >= 1<<10 && b%(1<<10) == 0:
|
||||
return strconv.FormatUint(b>>10, 10) + "K"
|
||||
}
|
||||
return strconv.FormatUint(b, 10)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// zpool list -Hpv on a pool with every extra class; the data raidz and its disks are skipped
|
||||
const listV = "tank\t32000000000000\t23000000000000\t9000000000000\tONLINE\n" +
|
||||
"\traidz2-0\t32000000000000\t23000000000000\t9000000000000\tONLINE\n" +
|
||||
"\t\twwn-0x1\t4000000000000\t-\t-\tONLINE\n" +
|
||||
"\t\twwn-0x2\t4000000000000\t-\t-\tONLINE\n" +
|
||||
"\tspecial\t-\t-\t-\t-\n" +
|
||||
"\t\tmirror-1\t500000000000\t120000000000\t380000000000\tONLINE\n" +
|
||||
"\t\t\tnvme0n1p1\t500000000000\t-\t-\tONLINE\n" +
|
||||
"\t\t\tnvme1n1p1\t500000000000\t-\t-\tONLINE\n" +
|
||||
"\tlogs\t-\t-\t-\t-\n" +
|
||||
"\t\tnvme2n1p1\t16000000000\t4000000\t15996000000\tONLINE\n" +
|
||||
"\tcache\t-\t-\t-\t-\n" +
|
||||
"\t\tnvme2n1p2\t400000000000\t210000000000\t190000000000\tONLINE\n" +
|
||||
"\tspares\t-\t-\t-\t-\n" +
|
||||
"\t\twwn-0x9\t4000000000000\t-\t-\tAVAIL\n" +
|
||||
"scratch\t1000000000000\t1000000000\t999000000000\tONLINE\n" +
|
||||
"\tsda\t1000000000000\t1000000000\t999000000000\tONLINE\n"
|
||||
|
||||
func TestParsePoolCaching(t *testing.T) {
|
||||
got := parsePoolCaching(listV)
|
||||
if len(got) != 2 || got[0].Pool != "tank" || got[1].Pool != "scratch" {
|
||||
t.Fatalf("pools: %+v", got)
|
||||
}
|
||||
tank := got[0]
|
||||
if len(tank.Cache) != 1 || tank.Cache[0].Name != "nvme2n1p2" || tank.Cache[0].Alloc != 210000000000 {
|
||||
t.Errorf("cache: %+v", tank.Cache)
|
||||
}
|
||||
if len(tank.Log) != 1 || tank.Log[0].Name != "nvme2n1p1" {
|
||||
t.Errorf("log: %+v", tank.Log)
|
||||
}
|
||||
if len(tank.Special) != 3 || tank.Special[0].Name != "mirror-1" || tank.Special[0].Alloc != 120000000000 {
|
||||
t.Errorf("special: %+v", tank.Special)
|
||||
}
|
||||
if len(tank.Spare) != 1 || tank.Spare[0].Health != "AVAIL" {
|
||||
t.Errorf("spares: %+v", tank.Spare)
|
||||
}
|
||||
if len(tank.Dedup) != 0 {
|
||||
t.Errorf("no dedup class: %+v", tank.Dedup)
|
||||
}
|
||||
s := got[1]
|
||||
if len(s.Cache)+len(s.Log)+len(s.Special)+len(s.Spare) != 0 {
|
||||
t.Errorf("a plain pool has no extra classes: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanSize(t *testing.T) {
|
||||
for in, want := range map[uint64]string{131072: "128K", 1048576: "1M", 16384: "16K", 1000: "1000"} {
|
||||
if got := humanSize(in); got != want {
|
||||
t.Errorf("%d → %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLiveARC reads this machine's ZFS for real: HAMLAB_LIVE=1 go test -run TestLiveARC -v ./internal/storage
|
||||
func TestLiveARC(t *testing.T) {
|
||||
if os.Getenv("HAMLAB_LIVE") == "" {
|
||||
t.Skip("set HAMLAB_LIVE=1 to read the real machine")
|
||||
}
|
||||
d := New(false).ARC(context.Background())
|
||||
b, _ := json.MarshalIndent(d, "", " ")
|
||||
t.Logf("%s", b)
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
package storage
|
||||
|
||||
// Verifying a landing tier: did everything that landed really reach the pool, intact?
|
||||
//
|
||||
// A landing tier is fast storage in front of a pool (a mergerfs pool, new files on the fast
|
||||
// branch first) with a mover draining it. The mover keeps a ledger — one JSON line per file it
|
||||
// moved: the path under the pool, its size and mtime, and the SHA-256 of the fast copy read
|
||||
// just before the move (hamlet's scripts/landing-mover.py writes /var/lib/landing-mover/
|
||||
// ledger.jsonl and names it in its status file). A verify pass reads the pool's copy of each
|
||||
// entry back and compares.
|
||||
//
|
||||
// What a pass can say about each file:
|
||||
// - match: same size, same mtime, same SHA-256 — it arrived intact
|
||||
// - mismatch: same size and mtime but different contents — the one real failure
|
||||
// - changed: the pool's copy has a different size or mtime: edited since it moved, so the
|
||||
// old hash says nothing about it (counted, not compared)
|
||||
// - gone: not on the pool and not waiting on the landing branch either — deleted or
|
||||
// renamed since it moved. A pass cannot tell that from a loss, so it lists them
|
||||
// - waiting: back on the landing branch (written again since), the mover will take it
|
||||
// - unreadable: could not be opened as this account
|
||||
// - unhashed: the mover recorded no hash for it (not a regular file, or changed mid-move)
|
||||
//
|
||||
// Scope "new" checks what has moved since the last pass, "all" re-reads the whole ledger. A
|
||||
// path moved more than once is checked against its latest entry only.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- schedules ----
|
||||
|
||||
// VerifyPolicy runs a verify pass on a schedule. The when-fields mean what they do on a
|
||||
// snapshot Policy; Scope is "new" or "all".
|
||||
type VerifyPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Every string `json:"every"`
|
||||
At string `json:"at"`
|
||||
Weekday int `json:"weekday"`
|
||||
Day int `json:"day"`
|
||||
Cron string `json:"cron,omitempty"`
|
||||
Scope string `json:"scope"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// when borrows the snapshot Policy's schedule logic, so both kinds of schedule are written,
|
||||
// validated and read back by exactly the same code.
|
||||
func (v *VerifyPolicy) when() *Policy {
|
||||
return &Policy{Every: v.Every, At: v.At, Weekday: v.Weekday, Day: v.Day, Cron: v.Cron}
|
||||
}
|
||||
|
||||
func (v *VerifyPolicy) CronExpr() (string, error) { return v.when().CronExpr() }
|
||||
func (v *VerifyPolicy) NextRun(now time.Time) time.Time { return v.when().NextRun(now) }
|
||||
|
||||
func (v *VerifyPolicy) Validate() error {
|
||||
if v.Scope != "new" && v.Scope != "all" {
|
||||
return errors.New("scope: new (what moved since the last pass) or all (the whole ledger)")
|
||||
}
|
||||
switch v.Every {
|
||||
case "hourly", "daily", "weekly", "monthly":
|
||||
case "custom":
|
||||
if strings.TrimSpace(v.Cron) == "" {
|
||||
return errors.New("a custom schedule needs a cron expression")
|
||||
}
|
||||
default:
|
||||
return errors.New("every: hourly, daily, weekly, monthly or custom")
|
||||
}
|
||||
if _, err := v.CronExpr(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := v.when().clock(); err != nil {
|
||||
return err
|
||||
}
|
||||
if v.Every == "weekly" && (v.Weekday < 0 || v.Weekday > 6) {
|
||||
return errors.New("weekday: 0 (Sunday) to 6")
|
||||
}
|
||||
if v.Every == "monthly" && (v.Day < 1 || v.Day > 28) {
|
||||
return errors.New("day: 1 to 28, so every month has one")
|
||||
}
|
||||
if len(v.Name) > 64 {
|
||||
return errors.New("name: 64 characters at most")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyCronFile holds the verify schedules; Ham Lab owns it and rewrites it whole.
|
||||
const VerifyCronFile = "/etc/cron.d/hamlab-landing"
|
||||
|
||||
// VerifyCronText renders the verify schedules as cron lines, run as Ham Lab's own account.
|
||||
func VerifyCronText(ps []VerifyPolicy, user, exe, config string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Ham Lab landing-tier verify schedules — written by the ZFS tab, rewritten whole on every change.\n")
|
||||
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
|
||||
b.WriteString("SHELL=/bin/sh\n")
|
||||
b.WriteString("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||
for i := range ps {
|
||||
p := &ps[i]
|
||||
name := strings.ReplaceAll(p.Name, "\n", " ")
|
||||
if !p.Enabled {
|
||||
b.WriteString("# " + name + " — paused\n")
|
||||
continue
|
||||
}
|
||||
expr, err := p.CronExpr()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", p.Name, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "# %s — verify %s\n", name, p.Scope)
|
||||
fmt.Fprintf(&b, "%s %s %s -config %s landing-verify %s\n\n", expr, user, exe, config, p.ID)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// WriteVerifyCron puts the verify schedules on the machine, or removes the file when none run.
|
||||
func (m *Manager) WriteVerifyCron(ctx context.Context, ps []VerifyPolicy, user, exe, config string) error {
|
||||
live := false
|
||||
for i := range ps {
|
||||
live = live || ps[i].Enabled
|
||||
}
|
||||
if !live {
|
||||
return m.RemoveRootFile(ctx, VerifyCronFile)
|
||||
}
|
||||
text, err := VerifyCronText(ps, user, exe, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.writeRoot(ctx, VerifyCronFile, text)
|
||||
}
|
||||
|
||||
// ---- finding the landing tier ----
|
||||
|
||||
// MoverStatusDir is where a landing mover writes what it is doing.
|
||||
const MoverStatusDir = "/run/landing-mover"
|
||||
|
||||
// DefaultLedger is where hamlet's mover keeps its ledger when no status file names one.
|
||||
const DefaultLedger = "/var/lib/landing-mover/ledger.jsonl"
|
||||
|
||||
// LandingTarget is one landing tier a pass can check: the branch files land on, the pool
|
||||
// directory they are moved to, and the mover's ledger.
|
||||
type LandingTarget struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Ledger string `json:"ledger"`
|
||||
}
|
||||
|
||||
// LandingTargets reads the movers' status files. Each mover names its own source, target and
|
||||
// ledger; with none running, nothing can be verified, and the page says so.
|
||||
func LandingTargets() []LandingTarget {
|
||||
var out []LandingTarget
|
||||
m, _ := filepath.Glob(MoverStatusDir + "/*.json")
|
||||
for _, p := range m {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var st struct{ Src, Dst, Ledger string }
|
||||
if json.Unmarshal(b, &st) != nil || st.Src == "" || st.Dst == "" {
|
||||
continue
|
||||
}
|
||||
if st.Ledger == "" {
|
||||
st.Ledger = DefaultLedger
|
||||
}
|
||||
out = append(out, LandingTarget{Src: filepath.Clean(st.Src), Dst: filepath.Clean(st.Dst), Ledger: st.Ledger})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Src < out[j].Src })
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- stale files ----
|
||||
|
||||
// StaleAfter is how long a file may wait on a landing branch before it counts as stale. A file
|
||||
// older than this is not necessarily lost — a big backlog drains for hours — but it is worth a
|
||||
// look, which is what the ZFS tab offers.
|
||||
const StaleAfter = time.Hour
|
||||
|
||||
// StaleFile is one file that has waited longer than StaleAfter.
|
||||
type StaleFile struct {
|
||||
Path string `json:"path"` // relative to the landing branch, which is also its path on the pool
|
||||
Size int64 `json:"size"`
|
||||
MTime int64 `json:"mtime"` // unix ms
|
||||
}
|
||||
|
||||
// StaleFiles lists what has waited on a landing branch longer than StaleAfter, oldest first,
|
||||
// at most limit of them, with the full count and size.
|
||||
func StaleFiles(branch string, limit int, now time.Time) (files []StaleFile, count int, bytes int64) {
|
||||
cut := now.Add(-StaleAfter)
|
||||
_ = filepath.WalkDir(branch, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
fi, err := d.Info()
|
||||
if err != nil || !fi.ModTime().Before(cut) {
|
||||
return nil
|
||||
}
|
||||
count++
|
||||
bytes += fi.Size()
|
||||
rel, _ := filepath.Rel(branch, p)
|
||||
files = append(files, StaleFile{Path: rel, Size: fi.Size(), MTime: fi.ModTime().UnixMilli()})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].MTime < files[j].MTime })
|
||||
if len(files) > limit {
|
||||
files = files[:limit]
|
||||
}
|
||||
return files, count, bytes
|
||||
}
|
||||
|
||||
// ---- the pass ----
|
||||
|
||||
// LedgerEntry is one line of the mover's ledger.
|
||||
type LedgerEntry struct {
|
||||
T int64 `json:"t"` // when it was moved, unix ms
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
MTimeNs int64 `json:"mtime_ns"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyIssue is one file a pass wants a person to look at.
|
||||
type VerifyIssue struct {
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"` // mismatch | gone | unreadable
|
||||
Moved int64 `json:"moved"`
|
||||
Size int64 `json:"size"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyResult is what one pass found.
|
||||
type VerifyResult struct {
|
||||
ID string `json:"id"` // the schedule that ran it, or "manual"
|
||||
Scope string `json:"scope"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished time.Time `json:"finished"`
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Since int64 `json:"since"` // ledger entries after this (unix ms); 0 for all
|
||||
Entries int `json:"entries"`
|
||||
Checked int `json:"checked"`
|
||||
Matched int `json:"matched"`
|
||||
Mismatched int `json:"mismatched"`
|
||||
Changed int `json:"changed"`
|
||||
Gone int `json:"gone"`
|
||||
Waiting int `json:"waiting"`
|
||||
Unreadable int `json:"unreadable"`
|
||||
Unhashed int `json:"unhashed"`
|
||||
BytesRead int64 `json:"bytes_read"`
|
||||
Issues []VerifyIssue `json:"issues"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Cursor int64 `json:"cursor"` // the newest entry it covered
|
||||
}
|
||||
|
||||
// OK is true when nothing needs a look: no mismatches, nothing gone or unreadable, no error.
|
||||
func (r *VerifyResult) OK() bool {
|
||||
return r.Error == "" && r.Mismatched == 0 && r.Gone == 0 && r.Unreadable == 0
|
||||
}
|
||||
|
||||
// VerifyProgress is published while a pass runs.
|
||||
type VerifyProgress struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
PID int `json:"pid"`
|
||||
Started time.Time `json:"started"`
|
||||
Updated time.Time `json:"updated"`
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
BytesRead int64 `json:"bytes_read"`
|
||||
BytesAll int64 `json:"bytes_all"`
|
||||
Current string `json:"current"`
|
||||
}
|
||||
|
||||
const maxIssues = 500
|
||||
|
||||
// readLedger loads the ledger's entries newer than since, the latest per path, oldest first.
|
||||
func readLedger(path string, since int64) ([]LedgerEntry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
latest := map[string]LedgerEntry{}
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for sc.Scan() {
|
||||
var e LedgerEntry
|
||||
if json.Unmarshal(sc.Bytes(), &e) != nil || e.Path == "" || e.T <= since {
|
||||
continue
|
||||
}
|
||||
if old, ok := latest[e.Path]; !ok || e.T >= old.T {
|
||||
latest[e.Path] = e
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]LedgerEntry, 0, len(latest))
|
||||
for _, e := range latest {
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].T < out[j].T })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inside joins rel under root and refuses anything that would climb out of it.
|
||||
func inside(root, rel string) (string, bool) {
|
||||
p := filepath.Join(root, rel)
|
||||
return p, p == root || strings.HasPrefix(p, root+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func hashFile(p string, buf []byte, onRead func(int)) (string, error) {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if n > 0 {
|
||||
h.Write(buf[:n])
|
||||
onRead(n)
|
||||
}
|
||||
if err == io.EOF {
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify runs one pass over a landing tier's ledger. progress is called about once a second.
|
||||
func Verify(t LandingTarget, id, scope string, since int64, progress func(VerifyProgress)) VerifyResult {
|
||||
r := VerifyResult{ID: id, Scope: scope, Started: time.Now(), Src: t.Src, Dst: t.Dst, Since: since, Issues: []VerifyIssue{}, Cursor: since}
|
||||
if scope == "all" {
|
||||
r.Since = 0
|
||||
}
|
||||
entries, err := readLedger(t.Ledger, r.Since)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
r.Error = "no ledger yet at " + t.Ledger + " — nothing has been moved since the mover started keeping one"
|
||||
} else {
|
||||
r.Error = "reading the ledger: " + err.Error()
|
||||
}
|
||||
r.Finished = time.Now()
|
||||
return r
|
||||
}
|
||||
r.Entries = len(entries)
|
||||
pr := VerifyProgress{ID: id, Scope: scope, PID: os.Getpid(), Started: r.Started, Total: len(entries)}
|
||||
for _, e := range entries {
|
||||
pr.BytesAll += e.Size
|
||||
}
|
||||
last := time.Time{}
|
||||
tick := func(force bool) {
|
||||
if progress != nil && (force || time.Since(last) >= time.Second) {
|
||||
pr.Updated, last = time.Now(), time.Now()
|
||||
progress(pr)
|
||||
}
|
||||
}
|
||||
issue := func(e LedgerEntry, kind, note string) {
|
||||
if len(r.Issues) < maxIssues {
|
||||
r.Issues = append(r.Issues, VerifyIssue{Path: e.Path, Kind: kind, Moved: e.T, Size: e.Size, Note: note})
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 4<<20)
|
||||
for _, e := range entries {
|
||||
pr.Current = e.Path
|
||||
tick(false)
|
||||
if e.T > r.Cursor {
|
||||
r.Cursor = e.T
|
||||
}
|
||||
r.Checked++
|
||||
pr.Done++
|
||||
dst, ok1 := inside(t.Dst, e.Path)
|
||||
src, ok2 := inside(t.Src, e.Path)
|
||||
if !ok1 || !ok2 {
|
||||
r.Unreadable++
|
||||
issue(e, "unreadable", "path climbs out of the pool")
|
||||
continue
|
||||
}
|
||||
st, err := os.Lstat(dst)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if _, err := os.Lstat(src); err == nil {
|
||||
r.Waiting++
|
||||
continue
|
||||
}
|
||||
r.Gone++
|
||||
issue(e, "gone", "not on the pool, not waiting to move: deleted or renamed since it moved?")
|
||||
continue
|
||||
}
|
||||
r.Unreadable++
|
||||
issue(e, "unreadable", err.Error())
|
||||
continue
|
||||
}
|
||||
if e.SHA256 == "" {
|
||||
r.Unhashed++
|
||||
continue
|
||||
}
|
||||
mt := st.ModTime().UnixNano()
|
||||
if st.Size() != e.Size || abs64(mt-e.MTimeNs) >= int64(time.Second) {
|
||||
r.Changed++
|
||||
continue
|
||||
}
|
||||
sum, err := hashFile(dst, buf, func(n int) { r.BytesRead += int64(n); pr.BytesRead += int64(n); tick(false) })
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EACCES) || errors.Is(err, os.ErrPermission) {
|
||||
r.Unreadable++
|
||||
issue(e, "unreadable", "permission denied")
|
||||
} else {
|
||||
r.Unreadable++
|
||||
issue(e, "unreadable", err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if sum != e.SHA256 {
|
||||
r.Mismatched++
|
||||
issue(e, "mismatch", "contents differ from the copy that landed")
|
||||
continue
|
||||
}
|
||||
r.Matched++
|
||||
}
|
||||
tick(true)
|
||||
r.Finished = time.Now()
|
||||
return r
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ---- records ----
|
||||
|
||||
func verifyDir(dataDir string) string { return filepath.Join(dataDir, "landing-verify") }
|
||||
|
||||
func writeJSON(p string, v any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p)
|
||||
}
|
||||
|
||||
func readJSON(p string, v any) bool {
|
||||
b, err := os.ReadFile(p)
|
||||
return err == nil && json.Unmarshal(b, v) == nil
|
||||
}
|
||||
|
||||
// cursorKey names a landing tier's cursor file.
|
||||
func cursorKey(t LandingTarget) string {
|
||||
return strings.Trim(strings.NewReplacer("/", "_").Replace(t.Src), "_")
|
||||
}
|
||||
|
||||
// VerifyCursor is the newest ledger entry a finished pass covered, for scope "new".
|
||||
func VerifyCursor(dataDir string, t LandingTarget) int64 {
|
||||
var c struct{ Cursor int64 }
|
||||
readJSON(filepath.Join(verifyDir(dataDir), "cursor-"+cursorKey(t)+".json"), &c)
|
||||
return c.Cursor
|
||||
}
|
||||
|
||||
// RecordVerify keeps a finished pass: as the run's last result, in the history (newest first,
|
||||
// 30 kept), and — when it read the ledger at all — as the new cursor.
|
||||
func RecordVerify(dataDir string, t LandingTarget, r VerifyResult) error {
|
||||
d := verifyDir(dataDir)
|
||||
if err := writeJSON(filepath.Join(d, "run-"+r.ID+".json"), r); err != nil {
|
||||
return err
|
||||
}
|
||||
var hist []VerifyResult
|
||||
readJSON(filepath.Join(d, "history.json"), &hist)
|
||||
hist = append([]VerifyResult{r}, hist...)
|
||||
if len(hist) > 30 {
|
||||
hist = hist[:30]
|
||||
}
|
||||
if err := writeJSON(filepath.Join(d, "history.json"), hist); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Error == "" && r.Cursor > VerifyCursor(dataDir, t) {
|
||||
return writeJSON(filepath.Join(d, "cursor-"+cursorKey(t)+".json"), map[string]int64{"cursor": r.Cursor})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyHistory is the kept passes, newest first.
|
||||
func VerifyHistory(dataDir string) []VerifyResult {
|
||||
var hist []VerifyResult
|
||||
readJSON(filepath.Join(verifyDir(dataDir), "history.json"), &hist)
|
||||
if hist == nil {
|
||||
hist = []VerifyResult{}
|
||||
}
|
||||
return hist
|
||||
}
|
||||
|
||||
// LastVerify is the last pass a schedule (or "manual") ran.
|
||||
func LastVerify(dataDir, id string) *VerifyResult {
|
||||
var r VerifyResult
|
||||
if !readJSON(filepath.Join(verifyDir(dataDir), "run-"+id+".json"), &r) {
|
||||
return nil
|
||||
}
|
||||
return &r
|
||||
}
|
||||
|
||||
// ForgetVerify drops a removed schedule's last result (the history keeps it).
|
||||
func ForgetVerify(dataDir, id string) {
|
||||
_ = os.Remove(filepath.Join(verifyDir(dataDir), "run-"+id+".json"))
|
||||
}
|
||||
|
||||
func progressPath(dataDir string) string { return filepath.Join(verifyDir(dataDir), "running.json") }
|
||||
|
||||
// WriteVerifyProgress publishes a running pass.
|
||||
func WriteVerifyProgress(dataDir string, p VerifyProgress) { _ = writeJSON(progressPath(dataDir), p) }
|
||||
|
||||
// ClearVerifyProgress removes it once the pass ends.
|
||||
func ClearVerifyProgress(dataDir string) { _ = os.Remove(progressPath(dataDir)) }
|
||||
|
||||
// RunningVerify is the pass in progress, or nil. A progress file whose process is gone, or
|
||||
// that has not moved for two minutes, is a pass that died: it is reported as not running.
|
||||
func RunningVerify(dataDir string) *VerifyProgress {
|
||||
var p VerifyProgress
|
||||
if !readJSON(progressPath(dataDir), &p) {
|
||||
return nil
|
||||
}
|
||||
if p.PID <= 0 || syscall.Kill(p.PID, 0) != nil || time.Since(p.Updated) > 2*time.Minute {
|
||||
return nil
|
||||
}
|
||||
return &p
|
||||
}
|
||||
|
||||
// LockVerify takes the one-pass-at-a-time lock; release it with the returned func.
|
||||
func LockVerify(dataDir string) (func(), error) {
|
||||
if err := os.MkdirAll(verifyDir(dataDir), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(verifyDir(dataDir), "lock"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return nil, errors.New("a verify pass is already running")
|
||||
}
|
||||
return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVerifyClassifiesEveryCase(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src, dst := filepath.Join(root, "landing"), filepath.Join(root, "pool")
|
||||
for _, d := range []string{src, dst + "/a", src + "/a"} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mtime := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
|
||||
put := func(p, body string) LedgerEntry {
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chtimes(p, mtime, mtime)
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
return LedgerEntry{Size: int64(len(body)), MTimeNs: mtime.UnixNano(), SHA256: hex.EncodeToString(sum[:])}
|
||||
}
|
||||
var ledger []LedgerEntry
|
||||
add := func(rel string, e LedgerEntry, at int64) { e.Path, e.T = rel, at; ledger = append(ledger, e) }
|
||||
|
||||
add("a/match.bin", put(dst+"/a/match.bin", "intact"), 10)
|
||||
e := put(dst+"/a/mismatch.bin", "garbled") // same size and mtime as what was hashed, other bytes
|
||||
sum := sha256.Sum256([]byte("GARBLED"))
|
||||
e.SHA256 = hex.EncodeToString(sum[:])
|
||||
add("a/mismatch.bin", e, 11)
|
||||
e = put(dst+"/a/changed.bin", "edited since, longer now")
|
||||
e.Size = 3
|
||||
add("a/changed.bin", e, 12)
|
||||
add("a/gone.bin", LedgerEntry{Size: 4, MTimeNs: mtime.UnixNano(), SHA256: "00"}, 13)
|
||||
put(src+"/a/waiting.bin", "back again")
|
||||
add("a/waiting.bin", LedgerEntry{Size: 10, MTimeNs: mtime.UnixNano(), SHA256: "00"}, 14)
|
||||
e = put(dst+"/a/unhashed", "x")
|
||||
e.SHA256, e.Note = "", "not a regular file"
|
||||
add("a/unhashed", e, 15)
|
||||
add("../escape", LedgerEntry{Size: 1, SHA256: "00"}, 16)
|
||||
// moved twice: only the newer entry counts, and it matches
|
||||
add("a/twice.bin", LedgerEntry{Size: 99, SHA256: "stale"}, 5)
|
||||
add("a/twice.bin", put(dst+"/a/twice.bin", "second time"), 17)
|
||||
|
||||
lp := filepath.Join(root, "ledger.jsonl")
|
||||
f, _ := os.Create(lp)
|
||||
for _, e := range ledger {
|
||||
b, _ := json.Marshal(e)
|
||||
f.Write(append(b, '\n'))
|
||||
}
|
||||
f.WriteString("not json\n")
|
||||
f.Close()
|
||||
|
||||
tg := LandingTarget{Src: src, Dst: dst, Ledger: lp}
|
||||
r := Verify(tg, "manual", "all", 0, nil)
|
||||
want := map[string]int{"checked": 8, "matched": 2, "mismatched": 1, "changed": 1, "gone": 1, "waiting": 1, "unhashed": 1, "unreadable": 1}
|
||||
got := map[string]int{"checked": r.Checked, "matched": r.Matched, "mismatched": r.Mismatched, "changed": r.Changed,
|
||||
"gone": r.Gone, "waiting": r.Waiting, "unhashed": r.Unhashed, "unreadable": r.Unreadable}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s: got %d want %d (%+v)", k, got[k], v, r)
|
||||
}
|
||||
}
|
||||
if r.OK() || r.Cursor != 17 || r.Error != "" {
|
||||
t.Errorf("ok=%v cursor=%d err=%q", r.OK(), r.Cursor, r.Error)
|
||||
}
|
||||
kinds := map[string]string{}
|
||||
for _, i := range r.Issues {
|
||||
kinds[i.Path] = i.Kind
|
||||
}
|
||||
if kinds["a/mismatch.bin"] != "mismatch" || kinds["a/gone.bin"] != "gone" || kinds["../escape"] != "unreadable" {
|
||||
t.Errorf("issues %+v", r.Issues)
|
||||
}
|
||||
|
||||
// "new" after a pass at cursor 14 only looks at what moved later
|
||||
r = Verify(tg, "manual", "new", 14, nil)
|
||||
if r.Checked != 3 || r.Matched != 1 || r.Unhashed != 1 || r.Unreadable != 1 {
|
||||
t.Errorf("new since 14: %+v", r)
|
||||
}
|
||||
|
||||
// records: the cursor only advances on a pass that read the ledger
|
||||
dir := t.TempDir()
|
||||
if err := RecordVerify(dir, tg, Verify(tg, "manual", "all", 0, nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := VerifyCursor(dir, tg); c != 17 {
|
||||
t.Errorf("cursor %d", c)
|
||||
}
|
||||
bad := Verify(LandingTarget{Src: src, Dst: dst, Ledger: filepath.Join(root, "missing")}, "manual", "new", 0, nil)
|
||||
if !strings.Contains(bad.Error, "no ledger yet") {
|
||||
t.Errorf("missing ledger: %q", bad.Error)
|
||||
}
|
||||
if len(VerifyHistory(dir)) != 1 || LastVerify(dir, "manual") == nil {
|
||||
t.Errorf("history/last not kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPolicy(t *testing.T) {
|
||||
p := VerifyPolicy{ID: "abc", Name: "nightly", Every: "daily", At: "05:10", Scope: "new", Enabled: true}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if x, _ := p.CronExpr(); x != "10 5 * * *" {
|
||||
t.Fatalf("cron %q", x)
|
||||
}
|
||||
txt, err := VerifyCronText([]VerifyPolicy{p, {ID: "off", Name: "paused one", Every: "hourly", Scope: "all"}}, "core", "/usr/local/bin/hamlab", "/cfg.json")
|
||||
if err != nil || !strings.Contains(txt, "10 5 * * * core /usr/local/bin/hamlab -config /cfg.json landing-verify abc") || !strings.Contains(txt, "# paused one — paused") {
|
||||
t.Fatalf("%v\n%s", err, txt)
|
||||
}
|
||||
for _, b := range []VerifyPolicy{{Every: "daily", Scope: "some"}, {Every: "sometimes", Scope: "new"}, {Every: "custom", Scope: "new"}} {
|
||||
if b.Validate() == nil {
|
||||
t.Errorf("accepted %+v", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleFiles(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
now := time.Now()
|
||||
os.MkdirAll(d+"/x", 0o755)
|
||||
for name, age := range map[string]time.Duration{"x/old.bin": 3 * time.Hour, "x/older.bin": 5 * time.Hour, "fresh.bin": time.Minute} {
|
||||
os.WriteFile(filepath.Join(d, name), []byte("12345"), 0o644)
|
||||
os.Chtimes(filepath.Join(d, name), now.Add(-age), now.Add(-age))
|
||||
}
|
||||
files, n, b := StaleFiles(d, 1, now)
|
||||
if n != 2 || b != 10 || len(files) != 1 || files[0].Path != "x/older.bin" {
|
||||
t.Fatalf("%d %d %+v", n, b, files)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ type Policy struct {
|
||||
KeepMin int `json:"keep_min"`
|
||||
Prefix string `json:"prefix"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
}
|
||||
|
||||
func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) }
|
||||
|
||||
@@ -106,8 +106,8 @@ func TestValidateRefusesNonsense(t *testing.T) {
|
||||
{Dataset: "tank", Every: "custom", KeepFor: "6w"},
|
||||
{Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"},
|
||||
{Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"},
|
||||
{Dataset: "tank", Every: "daily", At: "02:30"}, // no retention
|
||||
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
|
||||
{Dataset: "tank", Every: "daily", At: "02:30"}, // no retention
|
||||
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
|
||||
{Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one
|
||||
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"},
|
||||
}
|
||||
|
||||
@@ -71,6 +71,16 @@ type State struct {
|
||||
Mounts []RememberedMount `json:"mounts,omitempty"`
|
||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
||||
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
|
||||
// a machine and a device on it. nil is the machine the file is on, its best device.
|
||||
Transcode *Transcode `json:"transcode,omitempty"`
|
||||
}
|
||||
|
||||
// Transcode names a device on a machine: Node "" is the hub itself; Device is a render node
|
||||
// or "cpu".
|
||||
type Transcode struct {
|
||||
Node string `json:"node"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
|
||||
// UserState returns the prefs of an account, making an empty one if needed.
|
||||
|
||||
@@ -146,7 +146,7 @@ func (m *Manager) prune() {
|
||||
}
|
||||
var finished []*Task
|
||||
for _, t := range m.tasks {
|
||||
if !t.Running {
|
||||
if !t.running() {
|
||||
finished = append(finished, t)
|
||||
}
|
||||
}
|
||||
@@ -184,13 +184,20 @@ func (m *Manager) RunningOf(kind string) (*Task, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, t := range m.tasks {
|
||||
if t.Kind == kind && t.Running {
|
||||
if t.Kind == kind && t.running() {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// running reads the flag the waiter goroutine clears, under the task's own lock.
|
||||
func (t *Task) running() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.Running
|
||||
}
|
||||
|
||||
func (t *Task) Snapshot(withOutput bool) Snapshot {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
@@ -64,6 +64,9 @@ func New(sudo bool, tm *tasks.Manager) *Manager {
|
||||
return m
|
||||
}
|
||||
|
||||
// PM is the package manager found: apt | dnf | zypper | pacman | apk, or "".
|
||||
func (m *Manager) PM() string { return m.pm }
|
||||
|
||||
func (m *Manager) sudoArgs(args ...string) []string {
|
||||
if m.sudo {
|
||||
return priv.Wrap(args...)
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Trim the PCI ID database to the graphics devices of the GPU vendors, for embedding in
|
||||
# the binary (internal/metrics/pciids_gpu.txt): a machine without pciutils — OpenWrt has
|
||||
# neither lspci nor pci.ids — then names its GPU exactly as every other machine does.
|
||||
#
|
||||
# scripts/pci-ids-gpu.sh [/path/to/pci.ids] default: /usr/share/misc/pci.ids
|
||||
#
|
||||
# Kept: the vendor lines and device lines (never subsystems) of Intel 8086, AMD 1002 and
|
||||
# NVIDIA 10de whose name is bracketed (how pci.ids writes a marketing name) or mentions
|
||||
# graphics, and everything of the three virtual-GPU vendors (virtio 1af4, VMware 15ad,
|
||||
# Bochs 1234). A non-GPU device slipping through costs a line; it is only ever consulted
|
||||
# for a card the kernel already drives as a GPU.
|
||||
set -eu
|
||||
src=${1:-/usr/share/misc/pci.ids}
|
||||
out=$(dirname "$0")/../internal/metrics/pciids_gpu.txt
|
||||
{
|
||||
echo "# Trimmed from pci.ids ($(grep -m1 -i 'version:' "$src" | sed 's/^#[[:space:]]*//')) by scripts/pci-ids-gpu.sh."
|
||||
echo "# Vendor lines and the graphics device lines of Intel, AMD and NVIDIA, plus the virtual GPUs."
|
||||
echo "# pci.ids is distributed under the 3-clause BSD license (https://pci-ids.ucw.cz/)."
|
||||
awk '
|
||||
/^[0-9a-f][0-9a-f][0-9a-f][0-9a-f] / {
|
||||
vendor = substr($0, 1, 4)
|
||||
keep = (vendor == "8086" || vendor == "1002" || vendor == "10de")
|
||||
all = (vendor == "1af4" || vendor == "15ad" || vendor == "1234")
|
||||
if (keep || all) print
|
||||
next
|
||||
}
|
||||
/^\t[0-9a-f][0-9a-f][0-9a-f][0-9a-f] / {
|
||||
if (all) { print; next }
|
||||
if (!keep) next
|
||||
if ($0 ~ /\[/ || $0 ~ /Graphics|Radeon|GeForce|Quadro|Tesla|Arc |Iris|Xe |GPU|Display|VGA|FirePro|Instinct|Data Center/) print
|
||||
}
|
||||
' "$src"
|
||||
} > "$out"
|
||||
echo "$out: $(wc -l < "$out") lines, $(wc -c < "$out") bytes"
|
||||
Generated
+7
@@ -11,6 +11,7 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -1426,6 +1427,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
|
||||
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
+2
-1
@@ -7,12 +7,13 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"icons": "node scripts/icons.mjs",
|
||||
"test": "node scripts/test-graphcols.mjs"
|
||||
"test": "node scripts/test-graphcols.mjs && node scripts/test-sockets.mjs && node scripts/test-css-imports.mjs && node scripts/test-resolve-path.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// The Files player, end to end in Chromium against a running hub. Playwright is borrowed from
|
||||
// the wedding site.
|
||||
//
|
||||
// BASE=http://10.0.0.234:8100 USER=… PASS=… MACHINE=hub FILE=/path/to/video node scripts/e2e-player.mjs
|
||||
//
|
||||
// Opens FILE in the Files viewer on MACHINE ("hub" for the hub itself), plays it on Auto,
|
||||
// checks the 360 view if it is one, seeks into the middle, picks the lowest quality, tries
|
||||
// Original, and reads the menu's "Made on …". Changes nothing. DEVICE="Pixel 7" for a phone;
|
||||
// TRACE=1 prints every piece asked for. Screenshots land in $SHOTS (default /tmp/hamlab-shots).
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
const require = createRequire(import.meta.url)
|
||||
const { chromium, devices } = require('/home/core/projects/wedding-website/node_modules/playwright')
|
||||
|
||||
const { BASE = 'http://10.0.0.234:8100', USER, PASS, MACHINE = 'hub', FILE, DEVICE, TRACE } = process.env
|
||||
const SHOTS = process.env.SHOTS ?? '/tmp/hamlab-shots'
|
||||
if (!USER || !PASS || !FILE) { console.error('USER, PASS and FILE are required'); process.exit(2) }
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
|
||||
let failed = 0
|
||||
const check = (name, ok, extra = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); if (!ok) failed++ }
|
||||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
|
||||
const ctx = await browser.newContext({ ...(DEVICE ? devices[DEVICE] : { viewport: { width: 1280, height: 860 } }), colorScheme: 'dark' })
|
||||
check('signs in', (await ctx.request.post(BASE + '/api/login', { data: { username: USER, password: PASS } })).ok())
|
||||
const page = await ctx.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(String(e)))
|
||||
page.on('console', (m) => { if (m.type() === 'error' && !/401|404|favicon/.test(m.text())) errors.push(m.text()) })
|
||||
const T0 = Date.now(), trace = []
|
||||
const short = (u) => u.replace(/.*\/files\/play\/[0-9a-f]+\//, '')
|
||||
page.on('request', (r) => { if (/\/files\/play\/.*\.ts$/.test(r.url())) trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} ask ${short(r.url())}`) })
|
||||
page.on('requestfinished', (r) => { if (/\/files\/play\/.*\.ts$/.test(r.url())) trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} got ${short(r.url())}`) })
|
||||
const mark = (m) => trace.push(`${((Date.now() - T0) / 1000).toFixed(1)} -- ${m}`)
|
||||
|
||||
const dir = FILE.slice(0, FILE.lastIndexOf('/')) || '/', name = FILE.slice(FILE.lastIndexOf('/') + 1)
|
||||
await page.goto(`${BASE}/#/${MACHINE}/files/${encodeURIComponent(dir)}`)
|
||||
await page.locator('.file-row', { hasText: name }).first().click()
|
||||
await page.locator('.vp').waitFor()
|
||||
|
||||
const vstate = () => page.evaluate(() => { const v = document.querySelector('.vp video'); return v ? { t: v.currentTime, paused: v.paused, w: v.videoWidth, h: v.videoHeight, rs: v.readyState, d: v.duration } : null })
|
||||
async function playingPast(t, ms = 60000) {
|
||||
const t0 = Date.now()
|
||||
while (Date.now() - t0 < ms) {
|
||||
const s = await vstate()
|
||||
if (s && s.t > t && !s.paused && s.rs >= 3) return Date.now() - t0
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
async function controls() {
|
||||
if (await page.locator('.vp.bare').count()) {
|
||||
const b = await page.locator('.vp').boundingBox()
|
||||
if (DEVICE) await page.touchscreen.tap(b.x + 40, b.y + 40)
|
||||
else await page.mouse.move(b.x + b.width / 2 + Math.random() * 40, b.y + b.height / 3)
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
|
||||
let took = await playingPast(1)
|
||||
check('plays on Auto', took >= 0, `${((Date.now() - T0) / 1000).toFixed(1)} s from opening`)
|
||||
await page.waitForTimeout(6000)
|
||||
let s = await vstate()
|
||||
check('Auto settles on a quality', s?.w > 0, `${s?.w}×${s?.h}; “${await page.locator('.vp-q span').textContent()}”`)
|
||||
if (await page.locator('canvas.vp-sphere').count()) {
|
||||
const px = await page.evaluate(() => { const c = document.querySelector('canvas.vp-sphere'); const g = c.getContext('webgl'); const d = new Uint8Array(64); g.readPixels(c.width >> 1, c.height >> 1, 4, 4, g.RGBA, g.UNSIGNED_BYTE, d); return d.reduce((a, b) => a + b, 0) })
|
||||
check('the 360° view draws', px > 0)
|
||||
}
|
||||
await page.screenshot({ path: `${SHOTS}/h1-auto.png` })
|
||||
|
||||
// No theme touches playback: for every theme the stylesheet has rules for, no full-window
|
||||
// overlay is drawn, the picture isn't filtered, and the player's lettering is plain.
|
||||
{
|
||||
const was = await page.evaluate(() => document.documentElement.getAttribute('data-theme'))
|
||||
const themes = await page.evaluate(() => {
|
||||
const ids = new Set()
|
||||
for (const sh of document.styleSheets) { let rules; try { rules = sh.cssRules } catch { continue }
|
||||
const walk = (rs) => { for (const r of rs) { if (r.cssRules) walk(r.cssRules); for (const m of (r.selectorText ?? '').matchAll(/data-theme="([^"]+)"/g)) ids.add(m[1]) } }
|
||||
walk(rules) }
|
||||
return [...ids]
|
||||
})
|
||||
const bad = []
|
||||
for (const t of themes) {
|
||||
const r = await page.evaluate((t) => {
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
const els = [document.documentElement, document.body, document.querySelector('.app'), document.querySelector('#root')].filter(Boolean)
|
||||
const overlays = els.flatMap((e) => ['::before', '::after'].map((p) => getComputedStyle(e, p))).filter((c) => c.display !== 'none' && c.content !== 'none' && c.content !== 'normal' && (c.position === 'fixed' || c.position === 'absolute')).length
|
||||
const pics = [...document.querySelectorAll('.vp video, .vp canvas')].map((e) => getComputedStyle(e).filter).filter((f) => f !== 'none').length
|
||||
const c = getComputedStyle(document.querySelector('.vp'))
|
||||
const b = getComputedStyle(document.querySelector('.vp button'))
|
||||
return { overlays, pics, plain: !/mono|courier/i.test(b.fontFamily) && c.fontWeight === '400' && c.letterSpacing === 'normal' && c.textShadow === 'none' }
|
||||
}, t)
|
||||
if (r.overlays || r.pics || !r.plain) bad.push(`${t}: ${JSON.stringify(r)}`)
|
||||
}
|
||||
check(`no theme touches the video (${themes.length} themes with their own rules)`, themes.length > 0 && bad.length === 0, bad.join(' / ') || themes.join(', '))
|
||||
await page.evaluate((t) => t ? document.documentElement.setAttribute('data-theme', t) : document.documentElement.removeAttribute('data-theme'), was)
|
||||
}
|
||||
|
||||
// Volume: the speaker mutes and unmutes, the slider sets the level, and it is remembered.
|
||||
{
|
||||
await controls()
|
||||
const vs = () => page.evaluate(() => { const v = document.querySelector('.vp video'); return { muted: v.muted, volume: Math.round(v.volume * 100) / 100 } })
|
||||
await page.getByRole('button', { name: 'Mute' }).click()
|
||||
const a = await vs()
|
||||
await page.getByRole('button', { name: 'Unmute' }).click()
|
||||
const b = await vs()
|
||||
let c = b
|
||||
if (await page.getByLabel('Volume').isVisible()) { await page.getByLabel('Volume').fill('0.3'); c = await vs() }
|
||||
const kept = await page.evaluate(() => Object.entries(localStorage).find(([k]) => /volume/.test(k))?.[1] ?? '')
|
||||
check('volume: mute, unmute, level, remembered', a.muted && !b.muted && (c.volume === 0.3 || !(await page.getByLabel('Volume').isVisible())) && /level/.test(kept),
|
||||
`${JSON.stringify(a)} → ${JSON.stringify(b)} → ${JSON.stringify(c)}, kept ${kept}`)
|
||||
if (await page.getByLabel('Volume').isVisible()) await page.getByLabel('Volume').fill('1')
|
||||
}
|
||||
|
||||
// The 5-second buttons: back and on, the ball with them.
|
||||
{
|
||||
await controls()
|
||||
const t0 = (await vstate()).t
|
||||
await page.getByRole('button', { name: 'On 5 seconds' }).click()
|
||||
const t1 = (await vstate()).t
|
||||
await page.getByRole('button', { name: 'Back 5 seconds' }).click()
|
||||
await page.getByRole('button', { name: 'Back 5 seconds' }).click()
|
||||
const t2 = (await vstate()).t
|
||||
const ball = await page.evaluate(() => Number(document.querySelector('.vp input[type=range]').value))
|
||||
check('+5 and −5 buttons', t1 - t0 > 4 && t1 - t0 < 6.5 && t1 - t2 > 9 && t1 - t2 < 11 && Math.abs(ball - t2) < 1, `${t0.toFixed(1)} → ${t1.toFixed(1)} → ${t2.toFixed(1)}, ball ${ball.toFixed(1)}`)
|
||||
}
|
||||
|
||||
const mid = Math.floor(s.d / 2)
|
||||
mark('seek')
|
||||
await page.evaluate((m) => { document.querySelector('.vp video').currentTime = m }, mid)
|
||||
took = await playingPast(mid + 0.5)
|
||||
check('a seek into the middle plays again', took >= 0, `${(took / 1000).toFixed(1)} s`)
|
||||
|
||||
await controls()
|
||||
await page.locator('.vp-q').click()
|
||||
await page.waitForTimeout(2500) // the menu asks the machine what it is doing
|
||||
const items = page.locator('.vp-menu button')
|
||||
const n = await items.count()
|
||||
check('the menu lists Auto, Original and the qualities', n >= 3, (await items.allTextContents()).join(' | '))
|
||||
const foot = await page.locator('.vp-menu-f').textContent()
|
||||
check('it says where it is made', /Made on \S+/.test(foot), foot)
|
||||
await page.screenshot({ path: `${SHOTS}/h2-menu.png` })
|
||||
const lowLabel = await items.nth(n - 1).locator('span').nth(1).textContent()
|
||||
mark('pick ' + lowLabel)
|
||||
const before = (await vstate()).t
|
||||
const t0 = Date.now()
|
||||
await items.nth(n - 1).click()
|
||||
let ok = false
|
||||
for (let i = 0; i < 200 && !ok; i++) { await page.waitForTimeout(200); s = await vstate(); ok = s.t > before + 1 && !s.paused && (await page.locator('.vp-q span').textContent()) === lowLabel }
|
||||
check(`picking ${lowLabel} switches to it`, ok, `${s.w}×${s.h} after ${((Date.now() - t0) / 1000).toFixed(1)} s`)
|
||||
|
||||
await controls()
|
||||
await page.locator('.vp-q').click()
|
||||
await page.locator('.vp-menu button', { hasText: 'Original' }).click()
|
||||
await page.waitForTimeout(4000)
|
||||
const label = await page.locator('.vp-q span').textContent()
|
||||
const note = await page.locator('.vp-note').count() ? await page.locator('.vp-note').textContent() : ''
|
||||
check('Original plays, or falls back to Auto and says so', label === 'Original' || (/^Auto/.test(label) && /can’t play/.test(note)), `${label}${note ? ' — ' + note : ''}`)
|
||||
check('still playing', (await playingPast((await vstate()).t + 0.5)) >= 0)
|
||||
await page.screenshot({ path: `${SHOTS}/h3-after-original.png` })
|
||||
check('no page errors', errors.length === 0, errors.slice(0, 3).join(' / '))
|
||||
if (TRACE) console.log(trace.join('\n'))
|
||||
await browser.close()
|
||||
console.log(failed ? `${failed} failed` : 'all ok')
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -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)`)
|
||||
@@ -8,7 +8,7 @@ import { join } from 'node:path'
|
||||
|
||||
const out = join(mkdtempSync(join(tmpdir(), 'graphcols-')), 'graphcols.mjs')
|
||||
buildSync({ entryPoints: [new URL('../src/graphcols.ts', import.meta.url).pathname], outfile: out, format: 'esm', logLevel: 'error' })
|
||||
const { columnsOf } = await import(pathToFileURL(out).href)
|
||||
const { columnsOf, columnsAt } = await import(pathToFileURL(out).href)
|
||||
|
||||
const fails = []
|
||||
const check = (ok, what, detail) => { if (!ok) fails.push(what + (detail !== undefined ? ' — ' + JSON.stringify(detail).slice(0, 200) : '')) }
|
||||
@@ -38,5 +38,28 @@ const wide = columnsOf(base, CAP - 1, CAP, 390)
|
||||
check(wide.length === 390 && Math.max(...wide) === 900, 'a box wider than the history is filled, nothing invented', wide.length)
|
||||
check(columnsOf([], 0, CAP, COLS).length === 0 && columnsOf([5], 0, 2, 50).length >= 1, 'empty and single-point curves')
|
||||
|
||||
// Sliding with the ticks: four ticks to a point (a 250 ms rate, a 1 s stride). Every tick moves
|
||||
// the graph left by the same fraction of a column, the newest column grows in from the right, and
|
||||
// a column, once finished, keeps its value wherever it has slid to.
|
||||
for (const cols of [COLS, 390]) {
|
||||
let v = base.slice(), adv = CAP - 1, prevAt = null, back = 0, jump = 0, held = 0
|
||||
const was = new Map() // a finished column's value, by its number
|
||||
for (let t = 0; t < 4 * 30; t++) {
|
||||
const now = adv + ((t % 4) + 1) / 4
|
||||
const { cols: seen, edge, last } = columnsAt(v, adv, now, CAP, cols)
|
||||
check(edge > 0 && edge <= 1, `edge in (0, 1] (${cols} cols)`, edge)
|
||||
check(seen.length >= cols - 2 && seen.length <= cols + 1, `the box is filled, give or take the sliver (${cols} cols)`, seen.length)
|
||||
const at = last + edge // how far the graph has slid, in columns
|
||||
if (prevAt !== null) { if (at < prevAt - 1e-9) back++; if (at > prevAt + 1 + 1e-9) jump++; if (Math.abs(at - prevAt) < 1e-9) held++ }
|
||||
prevAt = at
|
||||
seen.forEach((x, i) => { const k = last - (seen.length - 1 - i); if (k < last - 1) { if (was.has(k) && was.get(k) !== x) fails.push(`column ${k} changed after it was finished`); was.set(k, x) } })
|
||||
if ((t % 4) === 3) { v.push(10); v.shift(); adv++ }
|
||||
}
|
||||
check(back === 0 && jump === 0, `the graph only slides left, less than a column a tick (${cols} cols)`, { back, jump })
|
||||
check(held < 4 * 30 / 4, `the graph moves on almost every tick, not once a point (${cols} cols)`, held)
|
||||
}
|
||||
// a point per tick (stride == rate) is the old layout exactly
|
||||
check(JSON.stringify(columnsAt(base, CAP - 1, CAP, CAP, COLS).cols.slice(-COLS)) === JSON.stringify(columnsOf(base, CAP - 1, CAP, COLS)), 'columnsOf is columnsAt at a whole point')
|
||||
|
||||
if (fails.length) { console.error('graphcols: FAIL\n ' + fails.join('\n ')); process.exit(1) }
|
||||
console.log('graphcols: ok')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// node scripts/test-resolve-path.mjs — the path box resolves like a shell: relative to here,
|
||||
// . and .. applied, trailing slashes dropped, never anything but an absolute path.
|
||||
import { readFileSync } from 'node:fs'
|
||||
const src = readFileSync(new URL('../src/Files.tsx', import.meta.url), 'utf8')
|
||||
const m = src.match(/export function resolvePath[\s\S]*?\n}\n/)
|
||||
if (!m) { console.error('resolvePath not found'); process.exit(1) }
|
||||
const resolvePath = new Function(m[0].replace('export function', 'return function').replace(/: string\[\]/g, '').replace(/: string/g, '') + ' ')()
|
||||
const cases = [
|
||||
['/tank/media', '/x', '/tank/media'], ['/tank/media/', '/x', '/tank/media'], ['', '/here', '/here'], [' ', '/here', '/here'],
|
||||
['sub', '/here', '/here/sub'], ['./sub/', '/here', '/here/sub'], ['..', '/a/b', '/a'], ['../..', '/a/b', '/'], ['../../..', '/a', '/'],
|
||||
['/', '/a', '/'], ['a//b', '/', '/a/b'], ['/tank/./x/../y', '/', '/tank/y'], ['sp ace #1', '/d', '/d/sp ace #1'],
|
||||
]
|
||||
let bad = 0
|
||||
for (const [input, here, want] of cases) { const got = resolvePath(input, here); if (got !== want) { bad++; console.error(`resolvePath(${JSON.stringify(input)}, ${here}) = ${got}, want ${want}`) } }
|
||||
if (bad) process.exit(1)
|
||||
console.log(`resolve path: ok (${cases.length} cases)`)
|
||||
@@ -0,0 +1,34 @@
|
||||
// node scripts/test-sockets.mjs — src/sockets.ts: each CPU's threads, counted under it.
|
||||
import { buildSync } from 'esbuild'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const out = join(mkdtempSync(join(tmpdir(), 'sockets-')), 'sockets.mjs')
|
||||
buildSync({ entryPoints: [new URL('../src/sockets.ts', import.meta.url).pathname], outfile: out, format: 'esm', logLevel: 'error' })
|
||||
const { socketThreads } = await import(pathToFileURL(out).href)
|
||||
|
||||
const fails = []
|
||||
const eq = (got, want, what) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||||
const range = (a, b) => Array.from({ length: b - a }, (_, i) => a + i)
|
||||
|
||||
// nexus: 2 × E5-2680 v2, as /sys/devices/system/cpu/cpu*/topology/physical_package_id reads
|
||||
const nexus = [...Array(10).fill(0), ...Array(10).fill(1), ...Array(10).fill(0), ...Array(10).fill(1)]
|
||||
const got = socketThreads(nexus)
|
||||
eq(got.map((s) => s.pkg), [0, 1], 'two sockets, in order')
|
||||
eq(got[0].cpus, [...range(0, 10), ...range(20, 30)], "CPU 1's threads")
|
||||
eq(got[1].cpus, [...range(10, 20), ...range(30, 40)], "CPU 2's threads")
|
||||
eq(got.map((s) => s.cpus.length), [20, 20], 'twenty threads each')
|
||||
// every thread is under exactly one CPU
|
||||
eq(got.flatMap((s) => s.cpus).sort((a, b) => a - b), range(0, 40), 'every thread once')
|
||||
// a thread's number under its CPU runs 0…19 on both
|
||||
for (const s of got) eq(s.cpus.map((_, n) => n), range(0, 20), `CPU ${s.pkg + 1} counts from 0`)
|
||||
|
||||
// a board that numbers each socket's threads together, and one whose socket ids are not 0 and 1
|
||||
eq(socketThreads([0, 0, 0, 0, 1, 1, 1, 1]), [{ pkg: 0, cpus: [0, 1, 2, 3] }, { pkg: 1, cpus: [4, 5, 6, 7] }], 'contiguous numbering')
|
||||
eq(socketThreads([3, 1, 3, 1]), [{ pkg: 1, cpus: [1, 3] }, { pkg: 3, cpus: [0, 2] }], 'socket ids sorted')
|
||||
eq(socketThreads([]), [], 'nothing')
|
||||
|
||||
if (fails.length) { console.error('sockets: FAIL\n ' + fails.join('\n ')); process.exit(1) }
|
||||
console.log('sockets: ok')
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+15
-12
@@ -5,6 +5,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 're
|
||||
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import StackNew, { DupFrom } from './StackNew'
|
||||
import CodeEditor from './CodeEditor'
|
||||
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
|
||||
@@ -354,10 +355,10 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
|
||||
<div className="svc-detail">
|
||||
<CloseBtn onClick={onBack} />
|
||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
|
||||
{file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</button></div> :
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||||
<CodeEditor className="stack-editor" language={file === 'env' ? 'env' : 'yaml'} value={text} onChange={setText} />}
|
||||
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
|
||||
@@ -401,7 +402,7 @@ export default function Docker() {
|
||||
const rate = useRefreshMs()
|
||||
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, [])
|
||||
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
|
||||
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
|
||||
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: rate })
|
||||
const sensors = live.data?.c ?? {}
|
||||
const byStack = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase(); const out = new Map<string, DockerContainer[]>()
|
||||
@@ -413,8 +414,11 @@ export default function Docker() {
|
||||
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.
|
||||
// Re-read after a stack task too: an update changes this machine's side of the answer.
|
||||
const [upd, setUpd] = useState<MachineUpdates | null>(null)
|
||||
const readUpd = useCallback(() => { api.machineUpdates().then(setUpd).catch(() => {}) }, [])
|
||||
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
|
||||
const stackChanged = useCallback(() => { load(); readUpd() }, [load, readUpd])
|
||||
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>
|
||||
@@ -446,7 +450,7 @@ export default function Docker() {
|
||||
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
|
||||
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
|
||||
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
|
||||
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
|
||||
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? st.host_file : st.note}</div></div>
|
||||
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -491,7 +495,7 @@ export default function Docker() {
|
||||
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
||||
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
||||
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
|
||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={stackChanged} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -521,7 +525,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
}
|
||||
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') }
|
||||
try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs(); api.machineUpdates().then(onChecked).catch(() => {}) } 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
|
||||
@@ -534,7 +538,6 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
// 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]
|
||||
@@ -547,7 +550,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
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)
|
||||
startNext(uniq)
|
||||
}
|
||||
const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))]
|
||||
return (
|
||||
@@ -566,9 +569,9 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
{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={i.state === 'update' ? 'warn-text' : 'muted small'}>{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)) && (
|
||||
{i.state === 'update' && (
|
||||
<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>
|
||||
)}
|
||||
@@ -580,8 +583,8 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
<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')
|
||||
if (t.exit_code !== 0 || t.error) toast(`${running.stack}: update failed`, 'err')
|
||||
api.machineUpdates().then(onChecked).catch(() => {}) // an image now held drops off the list
|
||||
startNext(queue)
|
||||
}} />
|
||||
</div>
|
||||
|
||||
+290
-21
@@ -1,12 +1,28 @@
|
||||
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, FileEntry, FileInfo } from './api'
|
||||
import { DragEvent, PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||
import { useHashSel } from './hashsel'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import Player from './Player'
|
||||
|
||||
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
|
||||
// A typed path, made absolute: relative to the folder being looked at, "." and ".." applied,
|
||||
// trailing slashes dropped — what a shell would make of it.
|
||||
export function resolvePath(input: string, here: string): string {
|
||||
const raw = input.trim()
|
||||
if (!raw) return here
|
||||
const parts = (raw.startsWith('/') ? [] : here.split('/')).concat(raw.split('/'))
|
||||
const out: string[] = []
|
||||
for (const p of parts) {
|
||||
if (p === '' || p === '.') continue
|
||||
if (p === '..') out.pop(); else out.push(p)
|
||||
}
|
||||
return '/' + out.join('/')
|
||||
}
|
||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
||||
const isMedia = (e: FileEntry) => !!e.preview || isImage(e.name)
|
||||
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
||||
const MAX_EDIT = 2 * 1024 * 1024
|
||||
|
||||
@@ -14,7 +30,9 @@ function icon(e: FileEntry): string {
|
||||
if (e.type === 'dir') return e.target ? '🔗' : '📁'
|
||||
if (e.type === 'link') return e.broken ? '⛓️💥' : '🔗'
|
||||
if (e.type === 'other') return '⚙️'
|
||||
if (isImage(e.name)) return '🖼️'
|
||||
if (e.preview === 'image' || isImage(e.name)) return '🖼️'
|
||||
if (e.preview === 'video') return '🎞️'
|
||||
if (e.preview === 'audio') return '🎵'
|
||||
if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️'
|
||||
if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜'
|
||||
if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄'
|
||||
@@ -22,19 +40,179 @@ function icon(e: FileEntry): string {
|
||||
}
|
||||
|
||||
type Sort = 'name' | 'size' | 'mtime'
|
||||
const PREVIEWS_KEY = 'hamlab.files.previews'
|
||||
const byName = (a: FileEntry, b: FileEntry) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||
// 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 }
|
||||
|
||||
// ---- move: walk to the destination, then "Move here" ----
|
||||
function MovePicker({ item, from, sudo, hidden, onClose, onMoved }: { item: FileEntry; from: string; sudo: boolean; hidden: boolean; onClose: () => void; onMoved: (task?: TaskInfo) => void }) {
|
||||
const src = join(from, item.name)
|
||||
const isFolder = item.type === 'dir' && !item.target
|
||||
const [hist, setHist] = useState<string[]>([from]) // where the picker has been, for back and forward
|
||||
const [at, setAt] = useState(0)
|
||||
const dir = hist[at]
|
||||
const [entries, setEntries] = useState<FileEntry[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [pathInput, setPathInput] = useState(from)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const load = useCallback(async (p: string) => {
|
||||
setEntries(null); setErr('')
|
||||
try { const l = await api.files(p, sudo); setEntries(l.entries) } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setEntries([]) }
|
||||
}, [sudo])
|
||||
useEffect(() => { setPathInput(dir); load(dir) }, [dir, load])
|
||||
useEscape(onClose)
|
||||
const go = (input: string) => {
|
||||
const p = resolvePath(input, dir)
|
||||
if (p === dir) return
|
||||
setHist((h) => [...h.slice(0, at + 1), p]); setAt(at + 1)
|
||||
}
|
||||
const up = () => { if (dir !== '/') go(dir.slice(0, dir.lastIndexOf('/')) || '/') }
|
||||
const folders = (entries ?? []).filter((e) => e.type === 'dir' && (hidden || !e.name.startsWith('.'))).sort(byName)
|
||||
const crumbs = dir === '/' ? [] : dir.slice(1).split('/')
|
||||
const dest = join(dir, item.name)
|
||||
const why = dir === from ? 'it is already here'
|
||||
: isFolder && (dir === src || dir.startsWith(src + '/')) ? 'a folder can\'t move into itself'
|
||||
: (entries ?? []).some((e) => e.name === item.name) ? `something called ${item.name} is already there` : ''
|
||||
const move = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const t = await api.fileMove(src, dest, sudo)
|
||||
if (t) toast(`${dir} is on another filesystem — copying ${item.name} there, then removing it here`, 'info')
|
||||
else toast(`moved ${item.name} to ${dir}`)
|
||||
onMoved(t)
|
||||
} catch (e) { toast(`move — ${e instanceof Error ? e.message : 'failed'}`, 'err'); setBusy(false) }
|
||||
}
|
||||
const mkdir = async () => {
|
||||
const n = await askText('New folder name')
|
||||
if (!okName(n)) return
|
||||
try { await api.fileMkdir(join(dir, n.trim()), sudo); go(join(dir, n.trim())) } catch (e) { toast(`create folder — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal mover" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Move {isFolder ? 'folder ' : ''}<span className="mono">{item.name}</span></h3>
|
||||
<div className="mover-nav">
|
||||
<button className="icon-btn" title="back" disabled={at === 0} onClick={() => setAt(at - 1)}>‹</button>
|
||||
<button className="icon-btn" title="forward" disabled={at >= hist.length - 1} onClick={() => setAt(at + 1)}>›</button>
|
||||
<button className="icon-btn" title="up one level" disabled={dir === '/'} onClick={up}>↑</button>
|
||||
<form className="crumbs" onSubmit={(e) => { e.preventDefault(); go(pathInput) }}>
|
||||
<button type="button" className="crumb" onClick={() => go('/')}>/</button>
|
||||
{crumbs.map((c, i) => <button type="button" key={i} className="crumb" onClick={() => go('/' + crumbs.slice(0, i + 1).join('/'))}>{c}</button>)}
|
||||
<input className="path-input" value={pathInput} onChange={(e) => setPathInput(e.target.value)} onBlur={() => setPathInput(dir)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
</form>
|
||||
</div>
|
||||
<div className="mover-list">
|
||||
{entries === null && <Loading label="Listing…" />}
|
||||
{err && <div className="error" style={{ margin: 10 }}>{err}</div>}
|
||||
{folders.map((e) => {
|
||||
const self = dir === from && e.name === item.name
|
||||
return (
|
||||
<div key={e.name} className={'mover-row' + (self ? ' self' : '')} onClick={() => { if (!self) go(join(dir, e.name)) }}>
|
||||
<span className="ficon">{e.target ? '🔗' : '📁'}</span>
|
||||
<span className="fname">{e.name}</span>
|
||||
{self && <span className="muted small">this is what's moving</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{entries !== null && folders.length === 0 && !err && <div className="muted small" style={{ padding: 12 }}>No folders here — “Move here” puts it in this one.</div>}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<div className="svc-actions">
|
||||
<button className="btn" onClick={mkdir}>+ folder</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<Btn className="btn on" disabled={!!why || entries === null} busy={busy} onClick={move}>Move here</Btn>
|
||||
</div>
|
||||
<div className="muted small mono mover-dest">{why || `→ ${dest}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- previews ----
|
||||
|
||||
// Rows ask for their thumbnail only as they scroll near: a browser's own lazy loading
|
||||
// watches the window, and this list scrolls inside a box of its own. One observer for the
|
||||
// list, a callback per row, each fired once.
|
||||
class Near {
|
||||
private obs: IntersectionObserver
|
||||
private cbs = new Map<Element, () => void>()
|
||||
constructor(root: Element) {
|
||||
this.obs = new IntersectionObserver((ents) => {
|
||||
for (const e of ents) if (e.isIntersecting) { const cb = this.cbs.get(e.target); if (cb) { this.forget(e.target); cb() } }
|
||||
}, { root, rootMargin: '400px 0px' })
|
||||
}
|
||||
watch(el: Element, cb: () => void) { this.cbs.set(el, cb); this.obs.observe(el); return () => this.forget(el) }
|
||||
private forget(el: Element) { this.cbs.delete(el); this.obs.unobserve(el) }
|
||||
}
|
||||
|
||||
function Thumb({ url, kind, near, fallback }: { url: string; kind: 'image' | 'video' | 'audio'; near: Near | null; fallback: string }) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const [show, setShow] = useState(false)
|
||||
const [failed, setFailed] = useState('')
|
||||
useEffect(() => { const el = ref.current; if (!el || !near || show) return; return near.watch(el, () => setShow(true)) }, [near, show])
|
||||
useEffect(() => { setFailed('') }, [url]) // a changed file is a new URL, and worth another try
|
||||
const broken = failed === url
|
||||
return (
|
||||
<span ref={ref} className={'fthumb ' + kind} title={broken ? 'no preview for this file' : undefined}>
|
||||
{show && !broken && <img src={url} alt="" decoding="async" onError={() => setFailed(url)} />}
|
||||
{(!show || broken) && <span className="ficon">{fallback}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// What the previews button says about how they get made here, and the one-tap fixes.
|
||||
function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: boolean; onChanged: () => void }) {
|
||||
const [task, setTask] = useState<{ id: string; restart: boolean } | null>(null)
|
||||
const [waiting, setWaiting] = useState(false)
|
||||
// the GPU fix ends by restarting Ham Lab: poll until it answers again, then re-read the status
|
||||
useEffect(() => {
|
||||
if (!waiting) return
|
||||
const t = setInterval(async () => { try { await api.thumbStatus(sudo, true); setWaiting(false); onChanged() } catch { /* restarting */ } }, 3000)
|
||||
return () => clearInterval(t)
|
||||
}, [waiting, sudo, onChanged])
|
||||
if (!st) return null
|
||||
const blocked = (st.unusable ?? []).filter((g) => g.permission)
|
||||
const start = async (label: string, fn: () => Promise<{ id: string }>, restart: boolean) => {
|
||||
try { const t = await fn(); setTask({ id: t.id, restart }) } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{!st.ok && (
|
||||
<div className="thumb-note notice">
|
||||
<span>Previews need ffmpeg: {st.error || 'it is not installed on this machine'}.</span>
|
||||
{st.install && !task && <Btn className="btn on" onClick={() => start('install', api.thumbInstall, false)}>Install ffmpeg with {st.install.pm}</Btn>}
|
||||
</div>
|
||||
)}
|
||||
{st.ok && blocked.length > 0 && (
|
||||
<div className="thumb-note notice">
|
||||
<span>{blocked.map((g) => g.name || g.device).join(', ')} is here, but Ham Lab runs as {st.user} and can't open {blocked.length > 1 ? 'them' : 'it'} ({blocked[0].reason}). Previews of videos are on the CPU until it can.</span>
|
||||
{st.gpu_fix && !task && !waiting && <Btn className="btn on" confirm="Ham Lab adds itself to the GPU's group and restarts. Open terminals are interrupted. Continue?" onClick={() => start('GPU access', api.thumbGPUAccess, true)}>Allow GPU access</Btn>}
|
||||
{waiting && <span className="muted small"><Spinner /> Ham Lab is restarting; the page reconnects by itself.</span>}
|
||||
</div>
|
||||
)}
|
||||
{task && <TaskOutput id={task.id} onDone={(t) => { const ok = t.exit_code === 0 && !t.error; if (ok && task.restart) setWaiting(true); else if (ok) onChanged(); else toast('failed — the output says where', 'err'); setTask(null) }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- editor / viewer ----
|
||||
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
|
||||
// steps: the files ← and → go through, in the list's order (only the pictures, videos and
|
||||
// sound when one of those is open).
|
||||
function Viewer({ path, sudo, steps, onStep, onClose, onChanged }: { path: string; sudo: boolean; steps: string[]; onStep: (p: string) => void; onClose: () => void; onChanged: () => void }) {
|
||||
const [info, setInfo] = useState<FileInfo | null>(null)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [orig, setOrig] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [cantPlay, setCantPlay] = useState(false) // the browser tried the file as it is and gave up
|
||||
const name = path.split('/').pop() ?? path
|
||||
const dirty = text !== null && text !== orig
|
||||
|
||||
useEffect(() => {
|
||||
setInfo(null); setText(null); setErr('')
|
||||
setInfo(null); setText(null); setErr(''); setCantPlay(false)
|
||||
let stop = false
|
||||
;(async () => {
|
||||
try {
|
||||
@@ -76,10 +254,49 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
}, [dirty, name])
|
||||
const dl = api.fileDownloadURL(path, sudo)
|
||||
|
||||
// Previous and next: the buttons, ← and → (unless typing, or the player has the focus and
|
||||
// seeks with them), and a sideways swipe on a picture.
|
||||
const at = steps.indexOf(path)
|
||||
const prev = at > 0 ? steps[at - 1] : null
|
||||
const next = at >= 0 && at < steps.length - 1 ? steps[at + 1] : null
|
||||
const step = useCallback(async (to: string | null) => {
|
||||
if (!to) return
|
||||
if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return
|
||||
setUnsaved(''); onStep(to)
|
||||
}, [dirty, onStep])
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t?.closest('input, textarea, select, [contenteditable], .vp')) return
|
||||
if (e.key === 'ArrowLeft' && prev) { e.preventDefault(); step(prev) }
|
||||
else if (e.key === 'ArrowRight' && next) { e.preventDefault(); step(next) }
|
||||
}
|
||||
window.addEventListener('keydown', h)
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [prev, next, step])
|
||||
// The next and previous pictures load now, so stepping onto them is instant.
|
||||
useEffect(() => {
|
||||
for (const p of [prev, next]) if (p && isImage(p)) new Image().src = api.fileReadURL(p, sudo)
|
||||
}, [prev, next, sudo])
|
||||
const swipe = useRef<{ x: number; y: number } | null>(null)
|
||||
const swipeEnd = (e: PointerEvent) => {
|
||||
const s = swipe.current
|
||||
swipe.current = null
|
||||
if (!s) return
|
||||
const dx = e.clientX - s.x, dy = e.clientY - s.y
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > 1.5 * Math.abs(dy)) step(dx < 0 ? next : prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="viewer">
|
||||
<div className="viewer-bar">
|
||||
<button className="btn" onClick={close}>‹ Back</button>
|
||||
{at >= 0 && steps.length > 1 && <>
|
||||
<button className="btn step" disabled={!prev} title="previous (←)" onClick={() => step(prev)}>←</button>
|
||||
<span className="muted num">{at + 1}/{steps.length}</span>
|
||||
<button className="btn step" disabled={!next} title="next (→)" onClick={() => step(next)}>→</button>
|
||||
</>}
|
||||
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
||||
<span className="spacer" />
|
||||
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
||||
@@ -91,9 +308,23 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
{info && text !== null && (
|
||||
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
||||
)}
|
||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
||||
{info && text === null && !isImage(name) && !err && (
|
||||
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
|
||||
{info && text === null && isImage(name) && (
|
||||
<div className="preview pic" onPointerDown={(e) => { if (e.pointerType !== 'mouse' && e.isPrimary) swipe.current = { x: e.clientX, y: e.clientY } }} onPointerUp={swipeEnd} onPointerCancel={() => { swipe.current = null }}>
|
||||
<img src={api.fileReadURL(path, sudo)} alt={name} draggable={false} />
|
||||
{prev && <button className="step-edge prev" title="previous (←)" onClick={() => step(prev)}>‹</button>}
|
||||
{next && <button className="step-edge next" title="next (→)" onClick={() => step(next)}>›</button>}
|
||||
</div>
|
||||
)}
|
||||
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
||||
{info && text === null && !isImage(name) && info.preview === 'video' && <Player key={path} path={path} sudo={sudo} name={name} />}
|
||||
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
||||
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls autoPlay preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
||||
)}
|
||||
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
|
||||
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
|
||||
)}
|
||||
{info && text === null && !isImage(name) && !info.preview && !err && (
|
||||
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.type === 'other' ? 'This is a device, pipe or socket — nothing to show.' : info.broken ? `This link points at nothing (${info.target}).` : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -114,10 +345,22 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
const [pathInput, setPathInput] = useState(path)
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [menu, setMenu] = useState<string | null>(null)
|
||||
const [moving, setMoving] = useState<FileEntry | null>(null)
|
||||
const [moveTask, setMoveTask] = useState<TaskInfo | null>(null)
|
||||
const [upload, setUpload] = useState<{ name: string; pct: number; n: number; of: number } | null>(null)
|
||||
const [drag, setDrag] = useState(false)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const canSudo = useRef(true)
|
||||
const [previews, setPreviews] = useState<boolean>(() => { try { return localStorage.getItem(PREVIEWS_KEY) === '1' } catch { return false } })
|
||||
const [thumbs, setThumbs] = useState<ThumbStatus | null>(null)
|
||||
const [listEl, setListEl] = useState<HTMLDivElement | null>(null)
|
||||
const near = useMemo(() => (listEl && previews ? new Near(listEl) : null), [listEl, previews])
|
||||
// Video previews are made on the Transcode device (Settings), wherever it is.
|
||||
const [tc, setTc] = useState<string>('')
|
||||
useEffect(() => { api.transcodeSelected().then((t) => setTc(t.selected ? `videos on ${t.host}'s ${t.name} (Settings → Transcode device), images on this machine's CPU` : '')).catch(() => {}) }, [])
|
||||
const readThumbs = useCallback((refresh = false) => { api.thumbStatus(sudo, refresh).then(setThumbs).catch((e) => setThumbs({ ok: false, engine: '', summary: '', gpus: [], error: e instanceof Error ? e.message : 'failed', user: '', cache: { path: '', entries: 0, bytes: 0 } })) }, [sudo])
|
||||
useEffect(() => { try { localStorage.setItem(PREVIEWS_KEY, previews ? '1' : '0') } catch { /* private mode */ } }, [previews])
|
||||
useEffect(() => { if (previews) readThumbs(); else setThumbs(null) }, [previews, readThumbs])
|
||||
|
||||
const load = useCallback(async (p = path, s = sudo) => {
|
||||
try {
|
||||
@@ -127,15 +370,22 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed'
|
||||
if (/not a directory/i.test(msg) && p !== '/') { // a file was typed: open it, from its folder
|
||||
setPath(p.slice(0, p.lastIndexOf('/')) || '/'); setOpen(p); return
|
||||
}
|
||||
if (/no such file/i.test(msg) && p !== '/' && !sessionStorage.getItem('hamlab.files.lost')) { // gone since last time (another machine, a deleted folder)
|
||||
sessionStorage.setItem('hamlab.files.lost', '1'); toast(`${p} isn't here — showing /`, 'info'); setPath('/'); return
|
||||
}
|
||||
setErr(msg); setEntries([])
|
||||
}
|
||||
sessionStorage.removeItem('hamlab.files.lost')
|
||||
}, [path, sudo])
|
||||
|
||||
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
|
||||
useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, [])
|
||||
useEscape(() => setMenu(null), menu !== null)
|
||||
|
||||
const go = (p: string) => { setOpen(null); setPath(p || '/') }
|
||||
const go = (p: string) => { setOpen(null); setPath(resolvePath(p, path)) }
|
||||
const crumbs = path === '/' ? [] : path.slice(1).split('/')
|
||||
|
||||
const shown = (entries ?? []).filter((e) => (hidden || !e.name.startsWith('.')) && (!q || e.name.toLowerCase().includes(q.toLowerCase())))
|
||||
@@ -147,15 +397,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
})
|
||||
|
||||
// ---- actions ----
|
||||
const act = async (label: string, fn: () => Promise<unknown>) => {
|
||||
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
const act = async (label: string, fn: () => Promise<unknown>): Promise<boolean> => {
|
||||
try { await fn(); toast(label); await load(); return true } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
|
||||
}
|
||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||
const touch = async () => { const n = await askText('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
||||
const touch = async () => { const n = await askText('New file name'); if (okName(n) && await act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo))) setOpen(join(path, n.trim())) } // opened only once it exists
|
||||
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||
const move = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
||||
const move = (e: FileEntry) => setMoving(e)
|
||||
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 del = async (e: FileEntry) => {
|
||||
const full = join(path, e.name)
|
||||
@@ -171,6 +419,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
// ---- upload ----
|
||||
const uploadFiles = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files)
|
||||
let done = 0
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const f = list[i]
|
||||
let overwrite = false
|
||||
@@ -181,15 +430,28 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
|
||||
try {
|
||||
await api.fileUpload(path, f, sudo, (pct) => setUpload({ name: f.name, pct, n: i + 1, of: list.length }), overwrite)
|
||||
done++
|
||||
} catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break }
|
||||
}
|
||||
setUpload(null)
|
||||
toast(list.length === 1 ? `uploaded ${list[0].name}` : `uploaded ${list.length} files`)
|
||||
load()
|
||||
if (done === 1) toast(`uploaded ${list.length === 1 ? list[0].name : '1 file'}`)
|
||||
else if (done > 1) toast(`uploaded ${done} files`)
|
||||
if (done > 0) load()
|
||||
}
|
||||
// A dropped folder arrives as a nameless 0-byte "file"; the browser gives no way to read it here.
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault(); setDrag(false)
|
||||
const items = Array.from(e.dataTransfer.items ?? [])
|
||||
const files = Array.from(e.dataTransfer.files).filter((_, i) => !(items[i]?.webkitGetAsEntry?.()?.isDirectory))
|
||||
if (files.length < e.dataTransfer.files.length) toast('folders can\'t be dropped — drop the files inside, or upload a zip', 'err')
|
||||
if (files.length) uploadFiles(files)
|
||||
}
|
||||
const onDrop = (e: DragEvent) => { e.preventDefault(); setDrag(false); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }
|
||||
|
||||
if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
||||
if (open) {
|
||||
const cur = shown.find((e) => join(path, e.name) === open)
|
||||
const steps = shown.filter((e) => e.type !== 'dir' && e.type !== 'other' && (!cur || !isMedia(cur) || isMedia(e))).map((e) => join(path, e.name))
|
||||
return <div className="files"><Viewer path={open} sudo={sudo} steps={steps} onStep={setOpen} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
|
||||
@@ -202,6 +464,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
<div className="files-tools">
|
||||
<button className={'btn' + (sudo ? ' on' : '')} title="browse as root" onClick={() => setSudo(!sudo)}>{sudo ? 'root' : 'sudo'}</button>
|
||||
<button className={'btn' + (hidden ? ' on' : '')} title="show hidden files" onClick={() => setHidden(!hidden)}>.hidden</button>
|
||||
<button className={'btn' + (previews ? ' on' : '')} title={'thumbnails for images and videos' + (thumbs ? ` — ${tc || thumbs.summary}` + (thumbs.cache.entries ? ` · ${thumbs.cache.entries} kept (${fmtBytes(thumbs.cache.bytes)})` : '') : '')} onClick={() => setPreviews(!previews)}>previews</button>
|
||||
<select className="btn" value={sort} onChange={(e) => setSort(e.target.value as Sort)}><option value="name">name</option><option value="size">size</option><option value="mtime">modified</option></select>
|
||||
<input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn" onClick={mkdir}>+ folder</button>
|
||||
@@ -213,10 +476,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previews && <ThumbNote st={thumbs} sudo={sudo} onChanged={() => readThumbs(true)} />}
|
||||
{moveTask && <TaskOutput id={moveTask.id} onDone={(t) => { load(); if (t.exit_code === 0 && !t.error) toast(`${moveTask.title}: done`); else toast(`${moveTask.title}: failed`, 'err') }} />}
|
||||
{moveTask && <div className="thumb-note"><button className="btn" onClick={() => setMoveTask(null)}>Hide output</button></div>}
|
||||
{upload && <div className="upload-bar"><Spinner /> {upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%<div className="meter"><div className="fill" style={{ width: `${upload.pct}%` }} /></div></div>}
|
||||
{err && <div className="error" style={{ padding: '6px 12px' }}>{err}{/permission denied/i.test(err) && !sudo && <> · <button className="link" onClick={() => setSudo(true)}>retry as root</button></>}</div>}
|
||||
|
||||
<div className="file-list">
|
||||
<div className={'file-list' + (previews ? ' thumbs' : '')} ref={setListEl}>
|
||||
{entries === null && <Loading label="Listing…" />}
|
||||
{path !== '/' && entries !== null && (
|
||||
<div className="file-row up" onClick={() => go(path.slice(0, path.lastIndexOf('/')) || '/')}><span className="ficon">↩</span><span className="fname">..</span></div>
|
||||
@@ -226,7 +492,9 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
const isDir = e.type === 'dir'
|
||||
return (
|
||||
<div key={e.name} className={'file-row' + (e.broken ? ' broken' : '')} onClick={() => (isDir ? go(full) : setOpen(full))}>
|
||||
<span className="ficon">{icon(e)}</span>
|
||||
{previews && e.preview && thumbs?.ok
|
||||
? <Thumb url={api.fileThumbURL(full, sudo, e.mtime, e.size)} kind={e.preview} near={near} fallback={icon(e)} />
|
||||
: <span className="ficon">{icon(e)}</span>}
|
||||
<span className="fname" title={e.target ? `→ ${e.target}` : e.name}>{e.name}{e.target && <span className="muted"> → {e.target}</span>}</span>
|
||||
<span className="fmeta num">{isDir ? '' : fmtBytes(e.size)}</span>
|
||||
<span className="fmeta num">{fmtTime(e.mtime)}</span>
|
||||
@@ -249,6 +517,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
{entries !== null && shown.length === 0 && !err && <div className="muted" style={{ padding: 12 }}>{q ? 'Nothing matches.' : 'Empty folder.'}{!hidden && entries.some((e) => e.name.startsWith('.')) ? ' (hidden files not shown)' : ''}</div>}
|
||||
</div>
|
||||
{drag && <div className="drop-hint">Drop to upload into {path}</div>}
|
||||
{moving && <MovePicker item={moving} from={path} sudo={sudo} hidden={hidden} onClose={() => setMoving(null)} onMoved={(t) => { setMoving(null); if (t) setMoveTask(t); load() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -37,8 +37,8 @@ export default function Gallery() {
|
||||
const rate = useRefreshMs()
|
||||
const [find, setFind] = useState('')
|
||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
// Values only, at most once a second: the catalog already holds everything that doesn't change.
|
||||
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: Math.max(1000, rate) })
|
||||
// Values only, at the rate: the catalog already holds everything that doesn't change.
|
||||
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: rate })
|
||||
const now = Date.now()
|
||||
const sensors = useMemo(() => {
|
||||
const v = live.data?.[0]
|
||||
|
||||
+13
-4
@@ -158,6 +158,10 @@ export default function Hardware() {
|
||||
const capacity = drives.reduce((n, d) => n + d.size, 0)
|
||||
const threads = cpu.threads ?? 0
|
||||
const cores = cpu.cores ?? 0
|
||||
// a two-socket board is two processors, not one with twice the cores: count and name each
|
||||
const sockets = Math.max(1, cpu.sockets ?? 1)
|
||||
const multi = sockets > 1
|
||||
const per = (n: number) => Math.round(n / sockets)
|
||||
|
||||
return (
|
||||
<div className="dash">
|
||||
@@ -171,8 +175,11 @@ export default function Hardware() {
|
||||
<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)}` : ''}</div>
|
||||
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > (multi ? 16 : 20) ? { fontSize: 16 } : undefined}>{multi ? `${sockets} × ${cpu.model}` : cpu.model}</div>
|
||||
<div className="sub">{(multi
|
||||
? [`${sockets} CPUs`, cores ? `${per(cores)} cores${threads && threads !== cores ? ` / ${per(threads)} threads` : ''} each` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : '']
|
||||
: [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>
|
||||
@@ -199,9 +206,11 @@ export default function Hardware() {
|
||||
<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` : ''}` : ''} />
|
||||
{multi && <P k="CPUs" v={`${sockets}`} />}
|
||||
<P k="Cores" v={cores ? `${multi ? `${per(cores)} per CPU · ${cores} in all` : cores}${cpu.threads_per_core && cpu.threads_per_core > 1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} />
|
||||
{multi && threads > 0 && <P k="Threads" v={`${per(threads)} per CPU · ${threads} in all`} />}
|
||||
<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={multi ? 'Sockets' : '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 />
|
||||
|
||||
-187
@@ -1,187 +0,0 @@
|
||||
// The KVM of the machine you are looking at: the device's own web page, shown in the tab.
|
||||
//
|
||||
// One per machine, and it follows the machine switcher — switch to `grid` and you get
|
||||
// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns
|
||||
// its keep exactly when that machine is down and cannot answer for itself; the hub proxies
|
||||
// the device either way.
|
||||
//
|
||||
// Nothing here knows anything about any particular KVM. It is an iframe pointed at Ham
|
||||
// Lab's own proxy, so whatever web UI the device serves is what you get.
|
||||
//
|
||||
// On the keyboard: a page cannot capture keys and forward them into an iframe, and does not
|
||||
// need to. An iframe with focus receives them directly. What the browser itself swallows —
|
||||
// Escape, Tab, Ctrl-W, the function keys — is what `navigator.keyboard.lock()` releases,
|
||||
// and that needs fullscreen and only exists in Chromium. Everywhere else you get the plain
|
||||
// version, which is most keys, and the device's own on-screen buttons for the rest.
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { KVM as Device, api } from './api'
|
||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||
|
||||
// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a
|
||||
// browser without it simply does not get the extra keys.
|
||||
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
|
||||
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
||||
|
||||
// Fullscreen, across the three ways browsers spell it. Safari on a Mac wants the webkit
|
||||
// prefix; Safari on iOS has no fullscreen for an ordinary element at all — only for a
|
||||
// <video> — so there the page expands inside the viewport instead, which is the same thing
|
||||
// minus the browser's own chrome.
|
||||
type FSElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void }
|
||||
type FSDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise<void> | void }
|
||||
const nativeFullscreen = () => {
|
||||
if (typeof document === 'undefined') return false
|
||||
const el = document.documentElement as FSElement
|
||||
return typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'
|
||||
}
|
||||
const fullscreenElement = () => (document as FSDocument).fullscreenElement ?? (document as FSDocument).webkitFullscreenElement ?? null
|
||||
async function enterFullscreen(el: FSElement) {
|
||||
if (typeof el.requestFullscreen === 'function') return el.requestFullscreen()
|
||||
if (typeof el.webkitRequestFullscreen === 'function') return el.webkitRequestFullscreen()
|
||||
throw new Error('this browser has no fullscreen for a page element')
|
||||
}
|
||||
function exitFullscreen() {
|
||||
const d = document as FSDocument
|
||||
if (typeof d.exitFullscreen === 'function') return d.exitFullscreen()
|
||||
if (typeof d.webkitExitFullscreen === 'function') return d.webkitExitFullscreen()
|
||||
}
|
||||
|
||||
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
||||
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [full, setFull] = useState(false) // native fullscreen is on
|
||||
const [expanded, setExpanded] = useState(false) // the fallback: filling the viewport in-page
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const frame = useRef<HTMLIFrameElement>(null)
|
||||
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setKvm((await api.kvms()).find((k) => k.machine === key) ?? null) }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [key])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather
|
||||
// than the browser. Both are undone together when fullscreen ends, however it ends.
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
const on = !!fullscreenElement()
|
||||
setFull(on)
|
||||
if (!on) keyboardAPI()?.unlock?.()
|
||||
}
|
||||
document.addEventListener('fullscreenchange', onChange)
|
||||
document.addEventListener('webkitfullscreenchange', onChange)
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', onChange)
|
||||
document.removeEventListener('webkitfullscreenchange', onChange)
|
||||
keyboardAPI()?.unlock?.()
|
||||
}
|
||||
}, [])
|
||||
// Expanding is in-page, so Escape is ours to handle.
|
||||
useEscape(() => setExpanded(false), expanded)
|
||||
|
||||
const goBig = async () => {
|
||||
if (!nativeFullscreen()) { setExpanded(true); frame.current?.focus(); return }
|
||||
try {
|
||||
if (box.current) await enterFullscreen(box.current)
|
||||
await keyboardAPI()?.lock?.()
|
||||
frame.current?.focus()
|
||||
} catch {
|
||||
setExpanded(true) // refused (an iframe policy, a permissions policy): fill the page instead
|
||||
frame.current?.focus()
|
||||
}
|
||||
}
|
||||
const goSmall = () => { setExpanded(false); if (fullscreenElement()) exitFullscreen() }
|
||||
|
||||
const remove = async () => {
|
||||
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
||||
try { await api.removeKVM(kvm.id); toast('removed'); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
if (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
|
||||
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
||||
|
||||
const locks = !!keyboardAPI()?.lock
|
||||
const big = full || expanded
|
||||
|
||||
return (
|
||||
<div className="kvm">
|
||||
<div className="kvm-bar">
|
||||
<span className="dname">{title}</span>
|
||||
{kvm
|
||||
? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
|
||||
: <span className="muted small">no KVM</span>}
|
||||
<span className="spacer" />
|
||||
{kvm && <>
|
||||
<Btn className="btn" onClick={goBig} title={!nativeFullscreen() ? 'fill the screen — this browser has no true fullscreen for a page element' : locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>{nativeFullscreen() ? 'Fullscreen' : 'Expand'}</Btn>
|
||||
<a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
|
||||
<Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
|
||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||
</>}
|
||||
{!kvm && <Btn className="btn on" onClick={() => setAdding(true)}>+ Add a KVM for {title}</Btn>}
|
||||
</div>
|
||||
|
||||
{kvm ? (
|
||||
<div className={'kvm-frame' + (full ? ' full' : '') + (expanded ? ' expanded' : '')} ref={box}>
|
||||
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
|
||||
allow="fullscreen; clipboard-read; clipboard-write" />
|
||||
{expanded && <button className="btn kvm-shrink" onClick={goSmall} title="back to the console">Done</button>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="svc-empty muted kvm-none">
|
||||
<div>
|
||||
<p>No KVM for <b>{title}</b>.</p>
|
||||
<p className="small">Add one with its address and its page appears here. The address is kept on the hub,
|
||||
so it still works when {title} does not — which is when you will want it.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{kvm && !big && (
|
||||
<div className="kvm-hint muted small">
|
||||
Click the picture, then type — the keys go to {title}.
|
||||
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
|
||||
</div>
|
||||
)}
|
||||
{adding && <AddDialog machine={key} title={title} existing={kvm} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
|
||||
const [url, setUrl] = useState(existing?.url ?? '')
|
||||
const [name, setName] = useState(existing?.name ?? '')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEscape(onClose)
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr(''); setBusy(true)
|
||||
try { await api.addKVM(name.trim(), url.trim(), machine); onAdded() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||
<h3>{existing ? `Change ${title}'s KVM` : `A KVM for ${title}`}</h3>
|
||||
<label className="muted">Address — whatever you would type into a browser
|
||||
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
|
||||
</label>
|
||||
<label className="muted">Name — optional; the address is used otherwise
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={`${title} KVM`} maxLength={64} />
|
||||
</label>
|
||||
<div className="muted small">
|
||||
Ham Lab serves the device from its own address, so a KVM that speaks only plain HTTP
|
||||
still works here, and it is behind this login rather than needing a second one. The
|
||||
address is kept on the hub, so it still reaches the device when {title} is down. The
|
||||
device itself is not changed and is never reachable from outside.
|
||||
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on" disabled={busy || !url.trim()}>{existing ? 'Save' : 'Add'}</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,10 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
||||
try {
|
||||
let res = await api.updateAllMachines()
|
||||
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
||||
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
||||
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) {
|
||||
const forced = await api.updateAllMachines(true)
|
||||
res = res.filter((r) => !forced.some((f) => f.name === r.name)).concat(forced) // one line per machine, the forced answer winning
|
||||
}
|
||||
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
||||
setTimeout(load, 4000)
|
||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
|
||||
+2
-2
@@ -81,7 +81,7 @@ export default function Network() {
|
||||
// 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 every = running ? rate : 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(() => {})
|
||||
@@ -93,7 +93,7 @@ export default function Network() {
|
||||
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 live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: rate })
|
||||
const sens = live.data?.n ?? {}
|
||||
const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
// A video in the Files viewer, YouTube's way: Auto, which reads the connection and picks the
|
||||
// quality (hls.js), any quality by hand, or Original — the file as it is, for a browser that
|
||||
// decodes it. The qualities are made on the fly, from wherever the viewer seeks to, on the device
|
||||
// the Transcode device setting names (Settings), which may be on another machine than the file.
|
||||
// A 360° video is drawn as the view from inside it (vr.ts): drag, pinch or scroll, and on a
|
||||
// phone the motion button. The same player as Ham Share's.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { api, nodeAPI, type PlayOpen, type PlayStatus } from './api'
|
||||
import { canMotion, Sphere } from './vr'
|
||||
|
||||
type Choice = 'auto' | 'original' | number // a number is a width: the rung's name
|
||||
|
||||
// The bandwidth hls.js measured last time, so the next video starts near the right quality
|
||||
// rather than at the bottom.
|
||||
const BW_KEY = 'hamlab.play.bw'
|
||||
const savedBW = () => { try { const n = Number(localStorage.getItem(BW_KEY)); return n > 0 ? n : 0 } catch { return 0 } }
|
||||
const saveBW = (n: number) => { try { if (n > 0) localStorage.setItem(BW_KEY, String(Math.round(n))) } catch { /* private mode */ } }
|
||||
|
||||
// hls.js reads the connection from how long each piece takes. Here a piece can be slow to
|
||||
// start because the machine is still making it (a few seconds after a seek or a change of
|
||||
// quality, at 8K), which says nothing about the connection; counted, it drags Auto down to the
|
||||
// bottom, and every drop starts the making over again. So: the wait before the first byte is
|
||||
// not counted against the connection, and hls.js doesn't give up on a piece before its first
|
||||
// byte comes. Once bytes flow, its usual rules apply.
|
||||
type AbrLike = { new(hls: Hls): object }
|
||||
const Base = Hls.DefaultConfig.abrController as unknown as AbrLike
|
||||
type Stats = { loading: { start: number; first: number } }
|
||||
type Proto = { initEstimator: () => { sampleTTFB: (ms: number) => void }; onFragBuffered: (ev: unknown, d: unknown) => void }
|
||||
const proto = Base.prototype as unknown as Proto
|
||||
class PatientAbr extends Base {
|
||||
constructor(hls: Hls) {
|
||||
super(hls)
|
||||
const self = this as unknown as { _abandonRulesCheck: (l?: unknown) => void; fragCurrent: { stats: Stats } | null }
|
||||
const orig = self._abandonRulesCheck
|
||||
self._abandonRulesCheck = (l?: unknown) => {
|
||||
const f = self.fragCurrent
|
||||
if (f && !f.stats.loading.first) return
|
||||
orig(l)
|
||||
}
|
||||
}
|
||||
initEstimator() {
|
||||
const e = proto.initEstimator.call(this)
|
||||
const sample = e.sampleTTFB.bind(e)
|
||||
e.sampleTTFB = (ms: number) => sample(Math.min(ms, 300))
|
||||
return e
|
||||
}
|
||||
onFragBuffered(ev: unknown, d: { frag: { stats: Stats }; part?: { stats: Stats & { loaded: number } } | null }) {
|
||||
const st = d.part?.stats.loaded ? d.part.stats : d.frag.stats
|
||||
const start = st.loading.start
|
||||
if (st.loading.first > start) st.loading.start = st.loading.first
|
||||
try { proto.onFragBuffered.call(this, ev, d) } finally { st.loading.start = start }
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the browser lets a page set the volume level (iOS doesn't: only mute).
|
||||
const canLevel = (() => { try { const a = document.createElement('audio'); a.volume = 0.5; return a.volume === 0.5 } catch { return false } })()
|
||||
|
||||
const mbps = (b: number) => `${b >= 10e6 ? Math.round(b / 1e6) : (b / 1e6).toFixed(1)} Mb/s`
|
||||
const clock = (sec: number) => { const t = Math.max(0, Math.floor(sec)), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), x = t % 60; return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(x).padStart(2, '0')}` }
|
||||
const size = (w?: number, h?: number, vr?: boolean) => {
|
||||
if (!w || !h) return ''
|
||||
if (vr) return `${w >= 7680 ? '8K' : w >= 5760 ? '5.7K' : w >= 3840 ? '4K' : w >= 3072 ? '3K' : `${w}×${h}`} 360°`
|
||||
const s = Math.min(w, h)
|
||||
return s >= 2160 ? '4K' : s >= 1440 ? '1440p' : s >= 1080 ? '1080p' : s >= 720 ? '720p' : `${w}×${h}`
|
||||
}
|
||||
|
||||
const I = ({ d, size: sz = 20, fill }: { d: string; size?: number; fill?: boolean }) => (
|
||||
<svg width={sz} height={sz} viewBox="0 0 24 24" fill={fill ? 'currentColor' : 'none'} stroke={fill ? 'none' : 'currentColor'} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
|
||||
)
|
||||
const PLAY = 'M7 4.5v15l12.5-7.5z', PAUSE = 'M8 5v14M16 5v14', CHECK = 'M5 12.5l4.5 4.5L19 7.5', TUNE = 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4'
|
||||
const FULL = 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', SMALL = 'M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5', PHONE = 'M8 3h8a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 9a9 9 0 0 0 0 6M21 9a9 9 0 0 1 0 6'
|
||||
|
||||
// The speaker: waves by the level, a cross when muted.
|
||||
const Speaker = ({ off, low }: { off?: boolean; low?: boolean }) => (
|
||||
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" />
|
||||
{off ? <path d="M16 9.5l5 5M21 9.5l-5 5" /> : <path d={low ? 'M15.5 9.5a3.5 3.5 0 0 1 0 5' : 'M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10'} />}
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Five seconds back or on: a circling arrow with the 5 inside.
|
||||
const Skip = ({ back }: { back?: boolean }) => (
|
||||
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d={back ? 'M12 5a7 7 0 1 1-7 7M12 5l2.5-2.5M12 5l2.5 2.5' : 'M12 5a7 7 0 1 0 7 7M12 5L9.5 2.5M12 5l-2.5 2.5'} />
|
||||
<text x="12" y="15" textAnchor="middle" fontSize="7.5" fontWeight="700" fill="currentColor" stroke="none" fontFamily="system-ui, sans-serif">5</text>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Player({ path, sudo }: { path: string; sudo: boolean; name: string }) {
|
||||
const [open, setOpen] = useState<PlayOpen | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [choice, setChoice] = useState<Choice>('auto')
|
||||
const [showing, setShowing] = useState(0) // the width playing now
|
||||
const [status, setStatus] = useState<PlayStatus | null>(null)
|
||||
const [paused, setPaused] = useState(true)
|
||||
const [waiting, setWaiting] = useState(true)
|
||||
const [time, setTime] = useState(0)
|
||||
const [scrub, setScrub] = useState<number | null>(null)
|
||||
const [buffered, setBuffered] = useState(0)
|
||||
const [menu, setMenu] = useState(false)
|
||||
const [chrome, setChrome] = useState(true)
|
||||
const [motion, setMotion] = useState(false)
|
||||
const [full, setFull] = useState(false) // the browser's full screen, or (an iPhone) the window
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const video = useRef<HTMLVideoElement>(null)
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
const hls = useRef<Hls | null>(null)
|
||||
const sphere = useRef<Sphere | null>(null)
|
||||
const hideTimer = useRef<number>()
|
||||
const resume = useRef<{ at: number; play: boolean }>({ at: 0, play: true })
|
||||
const openRef = useRef(open)
|
||||
openRef.current = open
|
||||
const menuRef = useRef(menu)
|
||||
menuRef.current = menu
|
||||
|
||||
// Open the play; let it go when this player does, and when the page goes, by a beacon.
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setOpen(null); setErr(''); setNote(''); setChoice('auto'); resume.current = { at: 0, play: true }
|
||||
api.play(path, sudo).then((o) => {
|
||||
if (!live) { api.playStop(o.node, o.play.id).catch(() => {}); return }
|
||||
setOpen(o)
|
||||
if (o.note) setNote(o.note)
|
||||
}).catch((e) => { if (live) setErr(e instanceof Error ? e.message : 'failed') })
|
||||
return () => { live = false; const o = openRef.current; if (o) api.playStop(o.node, o.play.id).catch(() => { /* gone already */ }) }
|
||||
}, [path, sudo])
|
||||
useEffect(() => {
|
||||
const h = () => { const o = openRef.current; if (o) navigator.sendBeacon(nodeAPI(o.node, `/api/files/play/${o.play.id}/close`)) }
|
||||
window.addEventListener('pagehide', h)
|
||||
return () => window.removeEventListener('pagehide', h)
|
||||
}, [])
|
||||
|
||||
const info = open?.play.info
|
||||
const vr = !!info?.video?.spherical
|
||||
|
||||
useEffect(() => {
|
||||
if (!vr || !canvas.current || !video.current) return
|
||||
try { sphere.current = new Sphere(canvas.current, video.current) } catch (e) { setErr((e as Error).message) }
|
||||
return () => { sphere.current?.destroy(); sphere.current = null }
|
||||
}, [vr, open])
|
||||
|
||||
// The source: hls.js for Auto and the chosen qualities, the file itself for Original.
|
||||
useEffect(() => {
|
||||
const v = video.current
|
||||
if (!open || !v) return
|
||||
const { at, play } = resume.current
|
||||
const start = () => { if (at) v.currentTime = at; if (play) v.play().catch(() => setPaused(true)) }
|
||||
if (choice === 'original') {
|
||||
v.src = api.fileReadURL(path, sudo)
|
||||
v.addEventListener('loadedmetadata', start, { once: true })
|
||||
setShowing(info?.video?.width ?? 0)
|
||||
const bad = () => {
|
||||
resume.current = { at: v.currentTime || at, play: true }
|
||||
setNote('This browser can’t play the file as it is. Back on Auto.')
|
||||
setChoice('auto')
|
||||
}
|
||||
v.addEventListener('error', bad, { once: true })
|
||||
return () => { v.removeEventListener('error', bad); v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
|
||||
}
|
||||
const master = nodeAPI(open.node, open.master)
|
||||
if (Hls.isSupported()) {
|
||||
const h = new Hls({
|
||||
abrController: PatientAbr as unknown as typeof Hls.DefaultConfig.abrController,
|
||||
abrEwmaDefaultEstimate: savedBW() || 5_000_000,
|
||||
startPosition: at || -1,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
backBufferLength: 60,
|
||||
// A piece being made after a seek can take a while; the machine waits up to 45 s.
|
||||
fragLoadPolicy: { default: { maxTimeToFirstByteMs: 50_000, maxLoadTimeMs: 120_000, timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 4, retryDelayMs: 1000, maxRetryDelayMs: 8000 } } },
|
||||
})
|
||||
hls.current = h
|
||||
h.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (typeof choice === 'number') {
|
||||
const i = h.levels.findIndex((l) => l.width === choice)
|
||||
if (i >= 0) { h.startLevel = i; h.currentLevel = i }
|
||||
}
|
||||
if (play) v.play().catch(() => setPaused(true))
|
||||
})
|
||||
h.on(Hls.Events.LEVEL_SWITCHED, (_, d) => setShowing(h.levels[d.level]?.width ?? 0))
|
||||
h.on(Hls.Events.FRAG_BUFFERED, () => saveBW(h.bandwidthEstimate))
|
||||
let recovered = 0
|
||||
h.on(Hls.Events.ERROR, (_, d) => {
|
||||
if (!d.fatal) return
|
||||
if (d.type === Hls.ErrorTypes.MEDIA_ERROR && recovered++ < 3) { h.recoverMediaError(); return }
|
||||
if (d.type === Hls.ErrorTypes.NETWORK_ERROR && recovered++ < 3) { h.startLoad(v.currentTime); return }
|
||||
setErr(`The video stopped loading (${d.details}).`)
|
||||
})
|
||||
h.loadSource(master)
|
||||
h.attachMedia(v)
|
||||
return () => { saveBW(h.bandwidthEstimate); h.destroy(); hls.current = null }
|
||||
}
|
||||
// Safari without Media Source (an older iPhone): its own HLS, Auto only.
|
||||
v.src = master
|
||||
v.addEventListener('loadedmetadata', start, { once: true })
|
||||
return () => { v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
|
||||
}, [open, choice === 'original' ? 'o' : 'h']) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A hand-picked quality, or back to Auto, while hls.js is already playing.
|
||||
useEffect(() => {
|
||||
const h = hls.current
|
||||
if (!h || choice === 'original' || !h.levels.length) return
|
||||
if (choice === 'auto') { h.currentLevel = -1; return }
|
||||
const i = h.levels.findIndex((l) => l.width === choice)
|
||||
if (i >= 0) h.currentLevel = i
|
||||
}, [choice])
|
||||
|
||||
// What the machine is doing, while the menu is open.
|
||||
useEffect(() => {
|
||||
if (!menu || !open) return
|
||||
let live = true
|
||||
const tick = () => api.playStatus(open.node, open.play.id).then((s) => { if (live) setStatus(s) }).catch(() => {})
|
||||
tick()
|
||||
const t = window.setInterval(tick, 2000)
|
||||
return () => { live = false; window.clearInterval(t) }
|
||||
}, [menu, open])
|
||||
|
||||
// Controls fade while it plays and nobody touches anything.
|
||||
const poke = useCallback(() => {
|
||||
setChrome(true)
|
||||
window.clearTimeout(hideTimer.current)
|
||||
hideTimer.current = window.setTimeout(() => { if (!video.current?.paused && !menuRef.current) setChrome(false) }, 3000)
|
||||
}, [])
|
||||
useEffect(() => () => window.clearTimeout(hideTimer.current), [])
|
||||
useEffect(() => { if (!menu) poke() }, [menu, poke])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
if (v.paused) void v.play().catch(() => {})
|
||||
else v.pause()
|
||||
poke()
|
||||
}, [poke])
|
||||
// The seek bar: while it is dragged the ball follows the finger (the input event); the seek
|
||||
// itself is on release (the browser's own change event, which a click, a drag and the arrow
|
||||
// keys all end with). The ball is put where the seek went at once, so it waits there while
|
||||
// the piece is made rather than going back to where the film was.
|
||||
const seekBar = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
const el = seekBar.current
|
||||
if (!el) return
|
||||
const commit = () => {
|
||||
const x = Number(el.value), v = video.current
|
||||
if (v && Math.abs(v.currentTime - x) > 0.05) v.currentTime = x
|
||||
setTime(x)
|
||||
setScrub(null)
|
||||
}
|
||||
el.addEventListener('change', commit)
|
||||
return () => el.removeEventListener('change', commit)
|
||||
})
|
||||
// Volume: a level and mute, remembered for the next video. iOS lets a page mute but not set
|
||||
// the level (it reads back as 1), so there the slider isn't shown.
|
||||
const [vol, setVol] = useState(() => { try { const v = JSON.parse(localStorage.getItem('hamlab.play.volume') ?? ''); return { level: Math.min(1, Math.max(0, Number(v.level) || 0)), muted: !!v.muted } } catch { return { level: 1, muted: false } } })
|
||||
useEffect(() => {
|
||||
const v = video.current
|
||||
if (v) { v.volume = vol.level; v.muted = vol.muted || vol.level === 0 }
|
||||
try { localStorage.setItem('hamlab.play.volume', JSON.stringify(vol)) } catch { /* private mode */ }
|
||||
}, [vol, open])
|
||||
const volBy = useCallback((d: number) => setVol((x) => ({ level: Math.round(Math.min(1, Math.max(0, x.level + d)) * 20) / 20, muted: false })), [])
|
||||
const muteToggle = useCallback(() => setVol((x) => (x.muted || x.level === 0 ? { level: x.level || 0.5, muted: false } : { ...x, muted: true })), [])
|
||||
|
||||
const seekBy = useCallback((d: number) => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
const to = Math.max(0, Math.min(info?.duration ?? v.duration, v.currentTime + d))
|
||||
v.currentTime = to
|
||||
setTime(to) // the ball goes there at once, made yet or not
|
||||
poke()
|
||||
}, [info, poke])
|
||||
// Full screen: the browser's where it has one for an element, else (an iPhone) the window.
|
||||
const fullscreen = useCallback(() => {
|
||||
const el = box.current as (HTMLDivElement & { webkitRequestFullscreen?: () => void }) | null
|
||||
if (!el) return
|
||||
if (document.fullscreenElement) { void document.exitFullscreen(); return }
|
||||
if (el.requestFullscreen) { void el.requestFullscreen().catch(() => setFull((f) => !f)); return }
|
||||
if (el.webkitRequestFullscreen) { el.webkitRequestFullscreen(); return }
|
||||
setFull((f) => !f)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const f = () => setFull(!!document.fullscreenElement)
|
||||
document.addEventListener('fullscreenchange', f)
|
||||
return () => document.removeEventListener('fullscreenchange', f)
|
||||
}, [])
|
||||
|
||||
// Keys work while the player has the focus (a click on it gives it).
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' || e.key === 'k') { e.preventDefault(); toggle() }
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'j') { e.preventDefault(); seekBy(e.key === 'j' ? -10 : -5) }
|
||||
else if (e.key === 'ArrowRight' || e.key === 'l') { e.preventDefault(); seekBy(e.key === 'l' ? 10 : 5) }
|
||||
else if (e.key === 'f') fullscreen()
|
||||
else if (e.key === 'm') muteToggle()
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); volBy(0.1) }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); volBy(-0.1) }
|
||||
else if (e.key === 'Escape') { if (menu) setMenu(false); else if (full && !document.fullscreenElement) setFull(false) }
|
||||
}
|
||||
|
||||
// Looking around a 360° video: a drag turns the view, a pinch zooms, a tap shows or hides the
|
||||
// controls.
|
||||
const pointers = useRef(new Map<number, { x: number; y: number }>())
|
||||
const moved = useRef(0)
|
||||
const pinch = useRef(0)
|
||||
const onDown = (e: React.PointerEvent) => {
|
||||
(e.target as Element).setPointerCapture(e.pointerId)
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
moved.current = 0
|
||||
if (pointers.current.size === 2) { const [a, b] = [...pointers.current.values()]; pinch.current = Math.hypot(a.x - b.x, a.y - b.y) }
|
||||
}
|
||||
const onMove = (e: React.PointerEvent) => {
|
||||
const p = pointers.current.get(e.pointerId)
|
||||
if (!p) return
|
||||
const dx = e.clientX - p.x, dy = e.clientY - p.y
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
moved.current += Math.abs(dx) + Math.abs(dy)
|
||||
if (pointers.current.size === 2) {
|
||||
const [a, b] = [...pointers.current.values()]
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
if (pinch.current) sphere.current?.zoom(pinch.current / d)
|
||||
pinch.current = d
|
||||
} else sphere.current?.drag(dx, dy)
|
||||
}
|
||||
const onUp = (e: React.PointerEvent) => {
|
||||
pointers.current.delete(e.pointerId)
|
||||
if (pointers.current.size < 2) pinch.current = 0
|
||||
if (pointers.current.size === 0 && moved.current < 6) {
|
||||
if (e.pointerType === 'mouse') toggle()
|
||||
else if (chrome && !menu) setChrome(false)
|
||||
else poke()
|
||||
setMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const v = video.current
|
||||
const dur = info?.duration ?? 0
|
||||
const shown = scrub ?? time
|
||||
const rungs = open?.play.rungs ?? []
|
||||
const label = (w: number) => rungs.find((r) => r.width === w)?.label ?? (w && w === info?.video?.width ? 'Original' : '')
|
||||
const choiceLabel = choice === 'auto' ? `Auto${showing ? ` · ${label(showing)}` : ''}` : choice === 'original' ? 'Original' : label(choice)
|
||||
const tooBig = vr && sphere.current && (info?.video?.width ?? 0) > sphere.current.maxTexture
|
||||
const fileRate = info?.bit_rate || (info?.size && dur ? (info.size * 8) / dur : 0)
|
||||
const keep = () => { if (v) resume.current = { at: v.currentTime, play: !v.paused } }
|
||||
|
||||
return (
|
||||
<div ref={box} tabIndex={0} onKeyDown={onKey} className={`vp${chrome ? '' : ' bare'}${full ? ' max' : ''}`}
|
||||
onPointerMove={(e) => { if (e.pointerType === 'mouse') poke() }}
|
||||
onWheel={vr ? (e) => { if (!(e.target as Element).closest('.vp-menu')) sphere.current?.zoom(e.deltaY > 0 ? 1.08 : 1 / 1.08) } : undefined}>
|
||||
<video ref={video} className={vr ? 'vp-v vp-hidden' : 'vp-v'} playsInline preload="auto"
|
||||
onPlay={() => { setPaused(false); poke() }} onPause={() => { setPaused(true); setChrome(true) }}
|
||||
onWaiting={() => setWaiting(true)} onPlaying={() => setWaiting(false)} onCanPlay={() => setWaiting(false)} onSeeking={() => setWaiting(true)} onSeeked={() => setWaiting(false)}
|
||||
onTimeUpdate={(e) => {
|
||||
const el = e.currentTarget
|
||||
setTime(el.currentTime)
|
||||
resume.current = { at: el.currentTime, play: !el.paused }
|
||||
for (let i = 0; i < el.buffered.length; i++) if (el.buffered.start(i) <= el.currentTime + 0.5) setBuffered(el.buffered.end(i))
|
||||
}}
|
||||
onClick={vr ? undefined : () => { if (matchMedia('(pointer: coarse)').matches) { if (chrome) setChrome(false); else poke() } else toggle() }}
|
||||
onDoubleClick={vr ? undefined : fullscreen} />
|
||||
{vr && <canvas ref={canvas} className="vp-v vp-sphere" onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onDoubleClick={fullscreen} />}
|
||||
|
||||
{(waiting || !open) && !err && <div className="vp-spin" aria-label="Loading" />}
|
||||
{err && <div className="vp-err"><p>{err}</p></div>}
|
||||
{open && paused && !waiting && !err && <button className="vp-big" onClick={toggle} aria-label="Play"><I d={PLAY} size={36} fill /></button>}
|
||||
{note && <div className="vp-note" role="status" onClick={() => setNote('')}>{note}</div>}
|
||||
{vr && open && chrome && !menu && time < 8 && !err && <div className="vp-hint">Drag to look around · scroll or pinch to zoom</div>}
|
||||
|
||||
<div className="vp-bottom">
|
||||
<span className="vp-time above">{clock(shown)} / {clock(dur)}</span>
|
||||
<div className="vp-seek" style={{ ['--p' as string]: dur ? `${(shown / dur) * 100}%` : '0%', ['--b' as string]: dur ? `${(buffered / dur) * 100}%` : '0%' }}>
|
||||
<input ref={seekBar} type="range" min={0} max={dur || 1} step={0.1} value={shown} aria-label="Seek"
|
||||
onChange={(e) => { setScrub(Number(e.target.value)); poke() }} />
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<button className="vp-icon" onClick={toggle} aria-label={paused ? 'Play' : 'Pause'}>{paused ? <I d={PLAY} fill /> : <I d={PAUSE} />}</button>
|
||||
<button className="vp-icon" onClick={() => seekBy(-5)} aria-label="Back 5 seconds" title="Back 5 seconds"><Skip back /></button>
|
||||
<button className="vp-icon" onClick={() => seekBy(5)} aria-label="On 5 seconds" title="On 5 seconds"><Skip /></button>
|
||||
<span className="vp-vol">
|
||||
<button className="vp-icon" onClick={muteToggle} aria-label={vol.muted || vol.level === 0 ? 'Unmute' : 'Mute'} title={vol.muted || vol.level === 0 ? 'Unmute (m)' : 'Mute (m)'}><Speaker off={vol.muted || vol.level === 0} low={vol.level < 0.5} /></button>
|
||||
{canLevel && <input type="range" min={0} max={1} step={0.05} value={vol.muted ? 0 : vol.level} aria-label="Volume"
|
||||
style={{ ['--v' as string]: `${(vol.muted ? 0 : vol.level) * 100}%` }}
|
||||
onChange={(e) => { const level = Number(e.target.value); setVol({ level, muted: level === 0 }); poke() }} />}
|
||||
</span>
|
||||
<span className="vp-time inrow">{clock(shown)} / {clock(dur)}</span>
|
||||
<span className="spacer" />
|
||||
{vr && canMotion() && (
|
||||
<button className={`vp-icon${motion ? ' on' : ''}`} aria-label="Look around by moving the phone" aria-pressed={motion}
|
||||
onClick={async () => { const s = sphere.current; if (s) setMotion(await s.setGyro(!s.gyroOn)) }}><I d={PHONE} /></button>
|
||||
)}
|
||||
<button className="vp-q" onClick={() => { setMenu((m) => !m); poke() }} aria-label="Quality" aria-expanded={menu}><I d={TUNE} size={16} /><span>{choiceLabel}</span></button>
|
||||
<button className="vp-icon" onClick={fullscreen} aria-label={full ? 'Exit full screen' : 'Full screen'}><I d={full ? SMALL : FULL} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menu && open && (
|
||||
<div className="vp-menu" role="menu">
|
||||
<div className="vp-menu-h">Quality</div>
|
||||
<button role="menuitemradio" aria-checked={choice === 'auto'} onClick={() => { setChoice('auto'); setMenu(false) }}>
|
||||
<span className="ck">{choice === 'auto' && <I d={CHECK} size={16} />}</span><span>Auto</span><small>{choice === 'auto' && showing ? label(showing) : 'fits the connection'}</small>
|
||||
</button>
|
||||
<button role="menuitemradio" aria-checked={choice === 'original'} disabled={!!tooBig} onClick={() => { keep(); setChoice('original'); setMenu(false) }}>
|
||||
<span className="ck">{choice === 'original' && <I d={CHECK} size={16} />}</span><span>Original</span>
|
||||
<small>{tooBig ? 'too big for this device' : [size(info?.video?.width, info?.video?.height, vr), fileRate ? mbps(fileRate) : ''].filter(Boolean).join(' · ')}</small>
|
||||
</button>
|
||||
{rungs.map((r) => (
|
||||
<button key={r.name} role="menuitemradio" aria-checked={choice === r.width} onClick={() => { keep(); setChoice(r.width); setMenu(false) }}>
|
||||
<span className="ck">{choice === r.width && <I d={CHECK} size={16} />}</span><span>{r.label}</span><small>{mbps(r.bitrate)}</small>
|
||||
</button>
|
||||
))}
|
||||
<div className="vp-menu-f">
|
||||
Made on <b>{open.host}</b> · {status?.engine || open.engine}
|
||||
{status?.engine && <> · {status.paused ? 'far enough ahead, waiting' : `from ${clock(status.from)}`}</>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-3
@@ -16,9 +16,16 @@ function fmtCPU(ns: number): string {
|
||||
const s = ns / 1e9
|
||||
return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s`
|
||||
}
|
||||
// systemd writes "Fri 2026-10-02 23:48:25 PDT", which Chromium parses and Firefox does not; read
|
||||
// the fields directly, in the browser's own zone (the machine's, on a LAN), and fall back to Date.parse.
|
||||
function parseSystemdTime(ts: string): number {
|
||||
const m = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(ts)
|
||||
if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime()
|
||||
return Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
|
||||
}
|
||||
function since(ts?: string): string {
|
||||
if (!ts) return ''
|
||||
const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
|
||||
const t = parseSystemdTime(ts)
|
||||
if (isNaN(t)) return ts
|
||||
const s = Math.max(0, (Date.now() - t) / 1000)
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
@@ -217,7 +224,9 @@ export default function Services() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
{err && units.length === 0 && loaded
|
||||
? <div className="notice" style={{ margin: 8 }}>{err}</div> // a host with no systemd, or one that is unreachable: not a filter result
|
||||
: err && <div className="error">{err}</div>}
|
||||
<div className="svc-rows">
|
||||
{!loaded && <Loading label="Listing services…" />}
|
||||
{shown.map((u) => (
|
||||
@@ -230,7 +239,7 @@ export default function Services() {
|
||||
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
{loaded && shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
{loaded && shown.length === 0 && !(err && units.length === 0) && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
</div>
|
||||
</div>
|
||||
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</div>}
|
||||
|
||||
+60
-2
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchCatalog } from './sensors'
|
||||
import { api, Me, TokenInfo, UpdateStatus } from './api'
|
||||
import { api, Me, TokenInfo, TranscodeSetting, UpdateStatus } from './api'
|
||||
import { savePrefs } from './accents'
|
||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
||||
@@ -104,6 +104,7 @@ export default function Settings({ me }: { me: Me }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TranscodeDevice />
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||
<section className="card">
|
||||
@@ -198,7 +199,7 @@ function Updates({ me }: { me: Me }) {
|
||||
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
|
||||
</div>
|
||||
)}
|
||||
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>This is the newest published build.</div>}
|
||||
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>{st.current.replace(/^v/, '') === st.latest.replace(/^v/, '') ? 'This is the newest published build.' : `This build is newer than the last published release (${st.latest}).`}</div>}
|
||||
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
|
||||
<div className="svc-actions" style={{ marginTop: 10 }}>
|
||||
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
|
||||
@@ -248,3 +249,60 @@ function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { n
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Where videos played in Files are made: one device on one machine, for every machine's files.
|
||||
// Every machine's devices are asked for live; one that can't be used says why.
|
||||
function TranscodeDevice() {
|
||||
const [t, setT] = useState<TranscodeSetting | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
useEffect(() => { api.transcode().then(setT).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
const set = async (node: string, device: string) => {
|
||||
setBusy(node + '|' + device); setErr('')
|
||||
try { setT(await api.setTranscode(node, device)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
setBusy('')
|
||||
}
|
||||
const sel = t?.selected
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar"><h2 className="section">Transcode device</h2><span className="spacer" /><span className="muted small">videos in Files, on every machine</span></div>
|
||||
<section className="card">
|
||||
{!t && !err && <div className="muted small"><Spinner /> asking every machine what it can encode on…</div>}
|
||||
{t && (
|
||||
<div className="tc-list">
|
||||
<div className="tc-machine">
|
||||
<div className="tc-host">Anywhere</div>
|
||||
<div className="chips">
|
||||
<button className={'chip' + (!sel ? ' on' : '')} disabled={!!busy} onClick={() => set('', '')}>The machine the file is on, its best device</button>
|
||||
</div>
|
||||
</div>
|
||||
{t.machines.map((m) => (
|
||||
<div key={m.node} className="tc-machine">
|
||||
<div className="tc-host">{m.host}{m.node === '' && <span className="muted small"> · this hub</span>}</div>
|
||||
{m.error && <div className="muted small">{m.error}</div>}
|
||||
<div className="chips">
|
||||
{(m.devices ?? []).map((d) => {
|
||||
const on = !!sel && sel.node === m.node && sel.device === d.id
|
||||
return (
|
||||
<button key={d.id} className={'chip' + (on ? ' on' : '') + (d.ok ? '' : ' off')} disabled={!d.ok || !!busy} title={d.ok ? d.id : d.reason}
|
||||
onClick={() => set(m.node, d.id)}>
|
||||
{busy === m.node + '|' + d.id ? <Spinner size={10} /> : null}{d.name}{d.id !== 'cpu' && d.integrated ? ' (integrated)' : ''}{!d.ok && d.reason ? ` — ${d.reason}` : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||
<div className="muted small" style={{ marginTop: 10 }}>
|
||||
Opening a video in Files plays it YouTube's way — Auto picks the quality the connection takes, any quality can be picked by hand,
|
||||
or the file as it is — and every quality is made on the fly, from wherever you seek to, on this device. Video previews
|
||||
(thumbnails) are made on it too; image previews stay on each machine's CPU. When it is on another
|
||||
machine than the file, that machine reads the file through this hub. If it can't be reached, the file's own machine makes it instead.
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+5
-7
@@ -15,7 +15,6 @@ import Hardware from './Hardware'
|
||||
import Network from './Network'
|
||||
import VPN from './VPN'
|
||||
import ZFS from './ZFS'
|
||||
import KVM from './KVM'
|
||||
import Files from './Files'
|
||||
import Top from './Top'
|
||||
import Machines from './Machines'
|
||||
@@ -27,7 +26,7 @@ import { MachineStatus, setMachine } from './api'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import Power from './Power'
|
||||
import { applyAccent } from './accents'
|
||||
import { Btn, PageBoundary, 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).
|
||||
@@ -81,7 +80,6 @@ const GROUPS: Group[] = [
|
||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||
@@ -91,8 +89,8 @@ const GROUPS: Group[] = [
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'widgets' | 'docker' | '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', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
|
||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||
|
||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||
@@ -135,6 +133,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
|
||||
const [drawer, setDrawer] = useState(false)
|
||||
useEscape(() => setDrawer(false), drawer) // the phone's drawer closes like every other overlay
|
||||
const [online, setOnline] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const handles = useRef<Record<string, TermHandle>>({})
|
||||
@@ -323,7 +322,6 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
||||
{view === 'settings' && <Settings me={hubMe} />}
|
||||
{view === 'users' && <Users me={hubMe} />}
|
||||
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
||||
{view === 'usage' && <Usage key={machine ?? ''} />}
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||
{view === 'widgets' && <Gallery />}
|
||||
@@ -365,7 +363,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === '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>
|
||||
<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 === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api, StackHome } from './api'
|
||||
import { askYes, Btn, Loading, useEscape } from './ui'
|
||||
import CodeEditor from './CodeEditor'
|
||||
|
||||
interface Form { image: string; ports: string; volumes: string; env: string; labels: string; restart: string; command: string; network: string }
|
||||
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', labels: '', restart: 'unless-stopped', command: '', network: '' }
|
||||
@@ -100,7 +101,7 @@ export default function StackNew({ mode, from, networks = [], taken, onClose, on
|
||||
{homes.length > 1 && <label className="muted">Where
|
||||
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{sameTail(h) ? h.dir : tail(h.dir)} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
|
||||
</label>}
|
||||
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
|
||||
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
|
||||
{!from && <div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={toCompose}>Compose</button></div>}
|
||||
{how === 'form' ? <>
|
||||
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
|
||||
@@ -119,7 +120,7 @@ export default function StackNew({ mode, from, networks = [], taken, onClose, on
|
||||
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
|
||||
<pre className="unit-file stack-preview">{built}</pre>
|
||||
</> : <>
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
<CodeEditor className="stack-editor" language="yaml" value={text} onChange={setText} />
|
||||
{from?.hasEnv && <label className="check"><input type="checkbox" checked={copyEnv} onChange={(e) => setCopyEnv(e.target.checked)} /> <span>Copy {from.name}'s .env too <span className="muted">— copied on the machine itself; its values are never shown here</span></span></label>}
|
||||
{!(from && copyEnv) && <label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
|
||||
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
|
||||
|
||||
+220
-58
@@ -3,14 +3,15 @@
|
||||
// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and
|
||||
// /api/procs for the table — so this works on any machine the console can see, whether or not
|
||||
// btop is installed on it.
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, ProcTable, Proc, Sample } from './api'
|
||||
import { Fragment, createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, GPU, ProcTable, Proc, Sample } from './api'
|
||||
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
|
||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
||||
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
||||
import { Loading, askYes, toast } from './ui'
|
||||
import { Theme, currentThemeId, themeById } from './themes'
|
||||
import { columnsOf } from './graphcols'
|
||||
import { columnsAt } from './graphcols'
|
||||
import { socketThreads } from './sockets'
|
||||
import { savePrefs } from './accents'
|
||||
|
||||
const MAX = 300 // points kept per curve, as the machines keep them
|
||||
@@ -68,7 +69,7 @@ function at(g: readonly string[], t: number): string {
|
||||
|
||||
// ---- the dot graph ----
|
||||
// One dot column per sample, filled from the bottom, each dot coloured by how high it sits.
|
||||
function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
|
||||
function Graph({ values, tick, advance, now, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; now: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const pal = useContext(Pal)
|
||||
@@ -79,7 +80,8 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
|
||||
ro.observe(box.current)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
// drawn before the browser paints, so a graph changes in the same frame as the numbers
|
||||
useLayoutEffect(() => {
|
||||
const c = ref.current
|
||||
if (!c || w < 4 || height < 4) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
@@ -91,14 +93,16 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
|
||||
// The box is the whole window, always: `cap` points wide (what the window holds at this
|
||||
// rate) whatever the box measures, so a full history fills it edge to edge and a machine
|
||||
// that has only just started draws in from the right, as btop does. Which points each
|
||||
// column draws — the peak of its own fixed slice of the history — is graphcols.ts.
|
||||
const seen = columnsOf(values, advance, cap, cols)
|
||||
const top = max ?? Math.max(1, ...seen)
|
||||
// column draws — the peak of its own fixed slice of the history — is graphcols.ts. The
|
||||
// columns slide by a fraction of one on every tick, so the graph moves with the numbers
|
||||
// beside it even when its points are several ticks apart.
|
||||
const { cols: seen, edge } = columnsAt(values, advance, now, cap, cols)
|
||||
const top = max ?? Math.max(1, ...seen.slice(-cols))
|
||||
const stops = pal[grad]
|
||||
for (let i = 0; i < seen.length; i++) {
|
||||
// anything above nothing lights the bottom row: the curve keeps a baseline, as btop's does
|
||||
const lit = seen[i] > 0 ? Math.max(1, Math.round((seen[i] / top) * rows)) : 0
|
||||
const x = w - (seen.length - i) * step
|
||||
const x = Math.round((w - (seen.length - 1 - i + edge) * step) * dpr) / dpr
|
||||
for (let r = 0; r < lit; r++) {
|
||||
const t = rows > 1 ? r / (rows - 1) : 1
|
||||
g.fillStyle = at(stops, t)
|
||||
@@ -106,7 +110,7 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
|
||||
g.fillRect(x, y, 2, 2)
|
||||
}
|
||||
}
|
||||
}, [values, tick, advance, cap, w, height, max, grad, flip, pal]) // tick: the curve is appended to in place
|
||||
}, [values, tick, advance, now, cap, w, height, max, grad, flip, pal]) // tick: the curve is appended to in place
|
||||
return <div className="tg" ref={box} data-n={values.length}><canvas ref={ref} style={{ width: '100%', height: '100%' }} /></div>
|
||||
}
|
||||
|
||||
@@ -186,13 +190,18 @@ function braille(values: number[], cells = 7, max = 100): string {
|
||||
}
|
||||
|
||||
// ---- the curves this page keeps ----
|
||||
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util'
|
||||
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util,gpu.*.usage'
|
||||
const RECENT = 18 // ticks a per-core strip keeps: its nine braille cells, two dots each
|
||||
|
||||
function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
|
||||
const cur = useRef<Map<string, number[]>>(new Map())
|
||||
const lastT = useRef(0)
|
||||
const binN = useRef(1)
|
||||
const adv = useRef(0) // the number of the newest point, counted since the page opened: what a graph's columns are cut by
|
||||
const now = useRef(1) // how far along the curves are, in points: adv plus the newest point's share of its stride gone
|
||||
// The per-core strips are a few seconds wide: they keep every tick, not the stride's points,
|
||||
// so they move with the numbers beside them whatever the history window is.
|
||||
const recent = useRef<Map<string, number[]>>(new Map())
|
||||
const [ticks, bump] = useState(0)
|
||||
const seed = useCallback(async () => {
|
||||
try {
|
||||
@@ -202,21 +211,30 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
|
||||
// The stride is timed on this clock only. The history's own timestamps are the machine's:
|
||||
// measured against them, a viewer whose clock runs minutes slow would wait those minutes
|
||||
// before its curves gained a single new point.
|
||||
cur.current = m; lastT.current = Date.now(); binN.current = 1
|
||||
cur.current = m; lastT.current = Date.now(); binN.current = 1; now.current = adv.current + 1
|
||||
bump((x) => x + 1)
|
||||
} catch { /* the curves fill in live instead */ }
|
||||
}, [])
|
||||
useEffect(() => { seed() }, [seed, resumed, historySec]) // a new window is a new spacing: start from what the machine now keeps
|
||||
useEffect(() => {
|
||||
// before paint: the curves take the sample in the same frame that shows its numbers
|
||||
useLayoutEffect(() => {
|
||||
if (!live || !t) return
|
||||
const { fresh, start } = nextPoint(t, lastT.current, strideMs(historySec, refreshMs), refreshMs)
|
||||
const stride = strideMs(historySec, refreshMs)
|
||||
const { fresh, start } = nextPoint(t, lastT.current, stride, refreshMs)
|
||||
binN.current = fresh ? 1 : binN.current + 1
|
||||
lastT.current = start
|
||||
if (fresh) adv.current++
|
||||
// this tick's share of the point: one tick in at its start, the whole point by its last
|
||||
now.current = adv.current + Math.max(refreshMs / stride, Math.min(1, (t - start + refreshMs) / stride))
|
||||
const n = binN.current
|
||||
for (const [id, v] of Object.entries(live)) {
|
||||
if (typeof v !== 'number') continue
|
||||
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$/.test(id)) continue
|
||||
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue
|
||||
if (id.startsWith('cpu.core.')) {
|
||||
const r = recent.current.get(id) ?? []
|
||||
r.push(v); if (r.length > RECENT) r.shift()
|
||||
recent.current.set(id, r)
|
||||
}
|
||||
const arr = cur.current.get(id) ?? []
|
||||
if (!cur.current.has(id)) cur.current.set(id, arr)
|
||||
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
|
||||
@@ -224,7 +242,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
|
||||
}
|
||||
bump((x) => x + 1)
|
||||
}, [live, t, refreshMs, historySec])
|
||||
return [cur.current, ticks, adv.current] as const
|
||||
return [cur.current, ticks, adv.current, now.current, recent.current] as const
|
||||
}
|
||||
|
||||
// ---- the page ----
|
||||
@@ -239,18 +257,51 @@ export default function Top() {
|
||||
const [ready, setReady] = useState(false)
|
||||
const stream = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
|
||||
useEffect(() => { fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])); setReady(true) }).catch(() => setReady(true)) }, [])
|
||||
const values = useMemo(() => (stream.data ? mergeValues(stream.data) : null), [stream.data])
|
||||
|
||||
// One beat for the whole page. Every sample the stream pushes is held until the process
|
||||
// table has been read for it, and the two land together: the curves, the meters, the numbers,
|
||||
// the GPU boxes and the table all change on the same tick, at the rate, never on two clocks
|
||||
// that drift past each other. A sample that arrives while the table is still being read
|
||||
// replaces the held one, so a slow read costs ticks, never order.
|
||||
const [frame, setFrame] = useState<{ data: unknown[] | null; pt: ProcTable | null }>({ data: null, pt: null })
|
||||
const [err, setErr] = useState('')
|
||||
const held = useRef<unknown[] | null>(null)
|
||||
const reading = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
useEffect(() => {
|
||||
if (!stream.data) return
|
||||
held.current = stream.data
|
||||
if (reading.current) return
|
||||
reading.current = true
|
||||
api.procs().then((pt) => ({ pt, e: '' }), (e) => ({ pt: null, e: e instanceof Error ? e.message : 'failed' })).then(({ pt, e }) => {
|
||||
reading.current = false
|
||||
if (!mounted.current) return
|
||||
const data = held.current
|
||||
setFrame((f) => ({ data, pt: pt ?? f.pt }))
|
||||
setErr(e)
|
||||
})
|
||||
}, [stream.data])
|
||||
const pt = frame.pt
|
||||
const values = useMemo(() => (frame.data ? mergeValues(frame.data) : null), [frame.data])
|
||||
useEffect(() => {
|
||||
if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return
|
||||
refetching.current = true
|
||||
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
|
||||
}, [values])
|
||||
const s: Sample | null = useMemo(() => (values && ready ? sampleOf(values, catalog.current) : null), [values, ready])
|
||||
const [curves, tick, advance] = useCurves(values, s?.t ?? 0, refreshMs, historySec, stream.resumed)
|
||||
const [curves, tick, advance, now, recent] = useCurves(values, s?.t ?? 0, refreshMs, historySec, stream.resumed)
|
||||
|
||||
// the process table, and the CPU model, come from the same place the Processes page reads
|
||||
const [pt, setPt] = useState<ProcTable | null>(null)
|
||||
// the process table, and the CPU model, come from the same place the Processes page reads,
|
||||
// on the sample's beat (above)
|
||||
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
|
||||
// which sockets' threads are folded away on a two-socket machine; remembered in this browser
|
||||
const [folded, setFolded] = useState<number[]>(() => { try { return JSON.parse(localStorage.getItem('top.folded-cpus') ?? '[]') } catch { return [] } })
|
||||
const fold = (pk: number) => setFolded((f) => {
|
||||
const n = f.includes(pk) ? f.filter((x) => x !== pk) : [...f, pk]
|
||||
try { localStorage.setItem('top.folded-cpus', JSON.stringify(n)) } catch { /* private window */ }
|
||||
return n
|
||||
})
|
||||
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
|
||||
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
|
||||
const [pools, setPools] = useState<Record<string, string>>({})
|
||||
@@ -264,14 +315,6 @@ export default function Top() {
|
||||
}).catch(() => { /* the rows simply say less */ })
|
||||
return () => { live = false }
|
||||
}, [])
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
const poll = async () => { try { const r = await api.procs(); if (!stop) { setPt(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') } }
|
||||
poll()
|
||||
const i = window.setInterval(poll, refreshMs) // exactly the rate, like every other page: nothing is throttled behind his back
|
||||
return () => { stop = true; window.clearInterval(i) }
|
||||
}, [refreshMs])
|
||||
|
||||
// the same global rate Settings sets: saved on the hub, so it sticks and every machine's sampler follows
|
||||
const rateIdx = (() => { const i = REFRESH_OPTIONS.findIndex((x) => x >= refreshMs); return i < 0 ? REFRESH_OPTIONS.length - 1 : i })()
|
||||
@@ -280,7 +323,7 @@ export default function Top() {
|
||||
// how many points the history window holds at this rate: the width of every graph
|
||||
const cap = Math.max(2, Math.min(MAX, Math.round((historySec * 1000) / strideMs(historySec, refreshMs))))
|
||||
|
||||
const [sort, setSort] = useState<'cpu' | 'mem' | 'pid' | 'name'>('cpu')
|
||||
const [sort, setSort] = useState<'cpu' | 'gpu' | 'mem' | 'pid' | 'name'>('cpu')
|
||||
const [rev, setRev] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sel, setSel] = useState<number | null>(null)
|
||||
@@ -303,8 +346,8 @@ export default function Top() {
|
||||
if (!pt) return []
|
||||
const f = filter.trim().toLowerCase()
|
||||
const list = pt.procs.filter((p) => !f || p.name.toLowerCase().includes(f) || (p.cmd ?? '').toLowerCase().includes(f) || p.user.toLowerCase().includes(f) || String(p.pid) === f)
|
||||
const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
|
||||
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a)))
|
||||
const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'gpu' ? (p.gpu ?? 0) : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
|
||||
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a) || b.cpu - a.cpu))
|
||||
return rev ? list.reverse() : list
|
||||
}, [pt, filter, sort, rev])
|
||||
|
||||
@@ -313,7 +356,7 @@ export default function Top() {
|
||||
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.pid}`) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
if (!s && !pt) return <div className="top" style={pal.vars as React.CSSProperties}><Loading label={err || 'Reading the machine…'} /></div>
|
||||
if (!s && !pt) return <div className="top" style={pal.vars as React.CSSProperties}><Loading label={err || stream.error || 'Reading the machine…'} /></div>
|
||||
|
||||
const cpu = s?.cpu ?? pt?.cpu ?? 0
|
||||
const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? []
|
||||
@@ -325,7 +368,16 @@ export default function Top() {
|
||||
const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').replace(/@.*$/, '').replace(/^\d+th Gen\s*/i, '').replace(/\s+/g, ' ').trim()
|
||||
// the hottest thing that looks like a CPU sensor, and one per core where the chip names them;
|
||||
// a second thread of a core (CPU 8 on an 8-core chip with hyper-threading) wears its core's
|
||||
const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label))?.c }
|
||||
// On a two-socket board each CPU has its own sensor chip, both counting from "Core 0", so a
|
||||
// thread's core is looked up on its own socket's chip.
|
||||
const pkgs = pt?.core_pkgs?.length === cores.length ? pt.core_pkgs : undefined
|
||||
// each CPU's own threads, counted from 0 under it (sockets.ts): the kernel's one numbering
|
||||
// of the whole machine puts cpu 0–9 and 20–29 on the first and 10–19 and 30–39 on the second
|
||||
const socks = pkgs ? socketThreads(pkgs) : []
|
||||
const sockets = socks.map((x) => x.pkg)
|
||||
const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i, pk = pkgs?.[i]; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label) && (pk === undefined || (t.pkg ?? 0) === pk))?.c }
|
||||
const sockTemp = (pk: number) => s?.temps.find((t) => t.chip === 'coretemp' && new RegExp(`^package id ${pk}$`, 'i').test(t.label))?.c
|
||||
const sockUse = (pk: number) => { const v = cores.filter((_, i) => pkgs?.[i] === pk); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0 }
|
||||
const cpuTemp = pt?.cpu_temp ?? s?.temps.find((t) => /package|tctl|tdie|cpu/i.test(t.label))?.c ?? coreTemp(0)
|
||||
// the temperature columns are there for every row once any row has one, so the % column lines up
|
||||
const anyTemp = cpuTemp !== undefined || cores.some((_, i) => coreTemp(i) !== undefined)
|
||||
@@ -342,10 +394,19 @@ export default function Top() {
|
||||
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
|
||||
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
|
||||
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
|
||||
// the GPUs, with each one's sensor stem: the same rule the server uses, so two identical
|
||||
// cards are gpu.<name> and gpu.<name>_2 here as there
|
||||
const gpus = (pt?.gpus ?? []).map((g, i, all) => {
|
||||
const seg = segOf(g.name), n = all.slice(0, i).filter((o) => segOf(o.name) === seg).length
|
||||
return { g, stem: 'gpu.' + seg + (n ? `_${n + 1}` : '') }
|
||||
})
|
||||
// a GPU with nothing to read can't light a process's Gpu% either: the column, and its sort,
|
||||
// are for a machine where at least one GPU says something
|
||||
const gpuCol = gpus.some(({ g }) => !unmeasured(g))
|
||||
|
||||
return (
|
||||
<Pal.Provider value={pal}>
|
||||
<div className="top" style={pal.vars as React.CSSProperties}>
|
||||
<div className={'top' + (gpus.length ? ' has-gpu' : '') + (gpus.length && !gpuCol ? ' gpu-slim' : '')} style={pal.vars as React.CSSProperties}>
|
||||
<Box n={1} name="cpu" className="top-cpu" right={<>
|
||||
<span className="t-clock">{clock.toLocaleTimeString([], { hour12: false })}</span>
|
||||
<span className="t-rate">
|
||||
@@ -356,33 +417,61 @@ export default function Top() {
|
||||
</>}>
|
||||
<div className="t-cpu">
|
||||
<div className="t-cpu-graph">
|
||||
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
|
||||
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
|
||||
<div className="t-up">up {upfor(uptime)}</div>
|
||||
</div>
|
||||
<div className="t-cores">
|
||||
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}</div>
|
||||
<div className="t-core">
|
||||
<div className={'t-cores' + (sockets.length > 1 ? ' multi' : '')}>
|
||||
<div className="t-cores-head"><b>{sockets.length > 1 ? `${sockets.length} × ` : ''}{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
|
||||
{pt?.cpu_power_w && sockets.length < 2 ? <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>
|
||||
{sockets.length < 2 && <div className="t-core">
|
||||
<span className="t-core-n">CPU</span>
|
||||
<Meter pct={cpu} grad="cpu" />
|
||||
<span className="t-core-p">{Math.round(cpu)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{cpuTemp !== undefined && <Meter pct={Math.min(100, (cpuTemp / 100) * 100)} grad="temp" />}</span><span className="t-core-c">{cpuTemp !== undefined ? `${Math.round(cpuTemp)}°C` : ''}</span></>}
|
||||
</div>
|
||||
{cores.map((v, i) => {
|
||||
const t = coreTemp(i)
|
||||
return (
|
||||
<div className="t-core" key={i}>
|
||||
<span className="t-core-n">C{i}</span>
|
||||
<span className="t-core-g" title={pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(curves.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
|
||||
<span className="t-core-p">{Math.round(v)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>}
|
||||
{(sockets.length > 1 ? socks : [{ pkg: undefined, cpus: cores.map((_, i) => i) }]).map(({ pkg: pk, cpus }) => (
|
||||
<Fragment key={pk ?? 'all'}>
|
||||
{pk !== undefined && (() => {
|
||||
const u = sockUse(pk), t = sockTemp(pk), w = pt?.cpu_power_pkgs_w?.[pk], open = !folded.includes(pk)
|
||||
return (
|
||||
<div className="t-core t-sock" role="button" aria-expanded={open} title={open ? 'hide its threads' : 'show its threads'} onClick={() => fold(pk)}>
|
||||
<span className="t-core-n"><span className="t-fold">{open ? '▾' : '▸'}</span>CPU{pk + 1}</span>
|
||||
<span className="t-core-w" title="this CPU's own power reading: its cores, cache and memory controller">{w ? `${w < 10 ? w.toFixed(1) : Math.round(w)} W` : ''}</span>
|
||||
<Meter pct={u} grad="cpu" />
|
||||
<span className="t-core-p">{Math.round(u)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{(pk !== undefined && folded.includes(pk) ? [] : cpus).map((i, n) => {
|
||||
// n is the thread's number under its own CPU, i the kernel's for the whole machine
|
||||
const t = coreTemp(i), v = cores[i] ?? 0
|
||||
return (
|
||||
<div className="t-core" key={i}>
|
||||
<span className="t-core-n">C{n}</span>
|
||||
<span className="t-core-g" title={pk !== undefined ? `CPU ${pk + 1} · thread ${n} · cpu ${i} to the kernel${pt?.core_ids ? ` · a thread of core ${pt.core_ids[i]}` : ''}` : pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(recent.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
|
||||
<span className="t-core-p">{Math.round(v)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="t-load">Load AVG:<span className="spacer" />{load.map((x) => x.toFixed(2)).join(' ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{gpus.length > 0 && (
|
||||
<div className="top-gpus">
|
||||
{gpus.map(({ g, stem }, i) => (
|
||||
<Box key={stem} name={`gpu${i}`} className={'top-gpu' + (unmeasured(g) ? ' slim' : '')} right={<span className="t-gpu-name" title={[g.name, g.driver, g.bus].filter(Boolean).join(' · ')}>{g.name}</span>}>
|
||||
{unmeasured(g) ? <GPULine g={g} /> : <GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} />}
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Box n={2} name="mem" className="top-mem">
|
||||
<div className="t-row"><b>Total:</b><span className="spacer" />{bytes(mem.total)}</div>
|
||||
{([['Used', mem.used, 'used'], ['Available', mem.avail, 'avail'], ['Cached', cached, 'cached'], ['Free', free, 'free']] as const).map(([k, v, g]) => (
|
||||
@@ -401,7 +490,9 @@ export default function Top() {
|
||||
// pool or an md array spans several drives, so nothing is attributed and the row
|
||||
// shows what is true of the hardware — how busy it is, and how hot.
|
||||
const on = fs.filter((f) => f.disk === d.name)
|
||||
const used = on.reduce((a, f) => a + f.used, 0)
|
||||
// a bind mount (nexus's /var/log is /data/var-log) is the same filesystem twice:
|
||||
// count each source device once, or the drive reads double
|
||||
const used = [...new Map(on.map((f) => [f.source || f.mount, f.used])).values()].reduce((a, b) => a + b, 0)
|
||||
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
|
||||
})}
|
||||
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
|
||||
@@ -421,10 +512,10 @@ export default function Top() {
|
||||
<div className="t-net-graphs">
|
||||
<div className="t-net-g">
|
||||
<span className="t-scale">{rate(peak(`net.${segOf(iface.iface)}.rx`))}</span>
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} advance={advance} cap={cap} grad="down" />
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} advance={advance} now={now} cap={cap} grad="down" />
|
||||
</div>
|
||||
<div className="t-net-g">
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} cap={cap} grad="up" flip />
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} now={now} cap={cap} grad="up" flip />
|
||||
<span className="t-scale t-scale-b">{rate(peak(`net.${segOf(iface.iface)}.tx`))}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,17 +536,17 @@ export default function Top() {
|
||||
|
||||
<Box n={5} name="proc" className="top-proc" right={<>
|
||||
<span className="t-sorts">
|
||||
{(['pid', 'name', 'mem', 'cpu'] as const).map((k) => <button key={k} className={sort === k ? 'on' : ''} onClick={() => setSort(k)}>{k}</button>)}
|
||||
{(['pid', 'name', 'mem', ...(gpuCol ? ['gpu' as const] : []), 'cpu'] as const).map((k) => <button key={k} className={sort === k ? 'on' : ''} onClick={() => setSort(k)}>{k}</button>)}
|
||||
<button className={rev ? 'on' : ''} onClick={() => setRev(!rev)}>reverse</button>
|
||||
</span>
|
||||
</>}>
|
||||
<div className="t-proc">
|
||||
<div className={'t-proc' + (gpuCol ? ' has-gpu' : '')}>
|
||||
<input className="t-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
<div className="t-thead">
|
||||
<span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-cmd">Command:</span>
|
||||
<span className="c-thr">Threads:</span><span className="c-user">User:</span><span className="c-mem">MemB</span><span className="c-g" /><span className="c-cpu">Cpu%</span>
|
||||
<span className="c-thr">Threads:</span><span className="c-user">User:</span><span className="c-mem">MemB</span><span className="c-g" />{gpuCol && <span className="c-gpu">Gpu%</span>}<span className="c-cpu">Cpu%</span>
|
||||
</div>
|
||||
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} empty={err || 'no process matches'} />
|
||||
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} gpu={gpuCol} empty={err || 'no process matches'} />
|
||||
<div className="t-foot">
|
||||
{sel !== null && procs.find((p) => p.pid === sel) ? (() => { const p = procs.find((x) => x.pid === sel)!; return <>
|
||||
<span className="t-dim">{p.pid} {p.name}</span><span className="spacer" />
|
||||
@@ -473,7 +564,7 @@ export default function Top() {
|
||||
|
||||
// The rows are the heaviest thing on the page — hundreds of them — so they are redrawn when the
|
||||
// table is read again, not on every tick of the curves above them.
|
||||
const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; empty: string }) {
|
||||
const Rows = memo(function Rows({ procs, sel, onSel, curves, gpu, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; gpu: boolean; empty: string }) {
|
||||
return (
|
||||
<div className="t-rows">
|
||||
{procs.map((p) => {
|
||||
@@ -487,6 +578,7 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: P
|
||||
<span className="c-user">{p.user}</span>
|
||||
<span className="c-mem">{shortBytes(p.rss)}</span>
|
||||
<span className="c-g">{braille(c, 7, Math.max(5, ...(c.length ? c : [0])))}</span>
|
||||
{gpu && <span className={'c-gpu' + (p.gpu ? ' lit' : '')}>{p.gpu ? p.gpu.toFixed(1) : ''}</span>}
|
||||
<span className="c-cpu">{p.cpu.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -496,6 +588,76 @@ 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, now, cap }: { g: GPU; values: number[]; tick: number; advance: number; now: 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
|
||||
return (
|
||||
<div className="t-gpu">
|
||||
<div className="t-gpu-graph">
|
||||
<Graph values={values} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
|
||||
{g.stalled && <div className="t-up">a read of this card stopped answering; its readings are paused until it does</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 || g.fan_pct !== undefined ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{[g.fan_pct !== undefined ? `${Math.round(g.fan_pct)}%` : '', g.fan_rpm ? `${g.fan_rpm} rpm` : ''].filter(Boolean).join(' · ')}</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.fan_pct === undefined && !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 read of this card stopped answering">readings paused until it answers</span>
|
||||
: <span className="t-dim" title="the driver exposes no busy counter, clock, temperature, power or memory reading">nothing measurable</span>}
|
||||
<span className="t-dim t-gpu-who" title={g.clients_ok ? undefined : 'the console has no root here'}>{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One physical drive. `used` is the bytes of the filesystems on it, left out when none can
|
||||
// be attributed to it — a pool member's share of a pool is not a number worth inventing.
|
||||
function Disk({ name, note, size, used, io, temp, mounts, pool }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[]; pool?: string }) {
|
||||
|
||||
+6
-2
@@ -52,7 +52,11 @@ export default function Updates() {
|
||||
setOpen(p.name)
|
||||
if (!changelog[p.name]) {
|
||||
try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
|
||||
catch (e) { setChangelog((c) => ({ ...c, [p.name]: e instanceof Error ? e.message : 'unavailable' })) }
|
||||
catch (e) {
|
||||
const m = e instanceof Error ? e.message : 'unavailable'
|
||||
// apt's own words for a third-party repository that publishes none are a wall of "E: Failed to fetch"
|
||||
setChangelog((c) => ({ ...c, [p.name]: /changelog unavailable|failed to fetch/i.test(m) ? 'No changelog is published for this package — its repository does not provide one.' : m }))
|
||||
}
|
||||
}
|
||||
}
|
||||
const toggle = (n: string) => setSel((s) => { const x = new Set(s); if (x.has(n)) x.delete(n); else x.add(n); return x })
|
||||
@@ -99,7 +103,7 @@ export default function Updates() {
|
||||
<span className="n">{p.name}{p.security && <span className="badge bad">security</span>}</span>
|
||||
<span className="d">{p.description}</span>
|
||||
</span>
|
||||
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span>
|
||||
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}{p.suite && <span className="muted"> · {p.suite}</span>}</span>
|
||||
</div>
|
||||
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
|
||||
</div>
|
||||
|
||||
+13
-2
@@ -133,6 +133,13 @@ function Sessions() {
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const others = list?.filter((s) => !s.current).length ?? 0
|
||||
// a day unused is the line between a device that is used and a script that signed in once
|
||||
const IDLE_H = 24
|
||||
const idle = list?.filter((s) => !s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3).length ?? 0
|
||||
const revokeIdle = async () => {
|
||||
if (!await askYes(`Sign out ${idle} ${idle === 1 ? 'session' : 'sessions'} not used in the last ${IDLE_H} hours? Devices used since then, and this one, stay signed in.`, 'Revoke')) return
|
||||
try { const r = await api.revokeIdleSessions(IDLE_H); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const revokeOthers = async () => {
|
||||
if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return
|
||||
try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
@@ -145,11 +152,15 @@ function Sessions() {
|
||||
{list && list.map((s) => (
|
||||
<div className="row" key={s.id}>
|
||||
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
|
||||
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
|
||||
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}{!s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3 ? <span className="badge" style={{ marginLeft: 6 }}>idle</span> : null}</span>
|
||||
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
|
||||
</div>
|
||||
))}
|
||||
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
|
||||
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}>
|
||||
{idle > 0 && <Btn className="btn" onClick={revokeIdle}>Sign out {idle} idle</Btn>}
|
||||
<Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn>
|
||||
<span className="muted small">A session unused for a week signs itself out.</span>
|
||||
</div>}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
|
||||
+543
-7
@@ -8,10 +8,12 @@
|
||||
// the retention in the name — rather than inventing a second convention for the two tools to
|
||||
// disagree over. An existing cron job is detected and shown, so the page tells the truth
|
||||
// about the machine whether Ham Lab or something else is doing the work.
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { FormEvent, Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ARCView, CacheDev, Landing, LandingCheck, PoolCaching, PoolWrites, VerifyPolicy, VerifyResult, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot, ZFSWrites } from './api'
|
||||
import { fmtBytes, fmtRate } from './fmt'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { fetchHistories } from './sensors'
|
||||
import Sparkline from './Sparkline'
|
||||
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
|
||||
|
||||
const ago = (t?: string | number) => {
|
||||
@@ -95,7 +97,10 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
|
||||
if (!open && !snaps) await reload()
|
||||
}
|
||||
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)
|
||||
if (!name) return
|
||||
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
|
||||
return {
|
||||
...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',
|
||||
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
|
||||
cron: simple ? '' : x.schedule.trim(),
|
||||
@@ -305,6 +310,531 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the write path: the dirty buffer in RAM, what each pool writes, and a landing tier ----
|
||||
//
|
||||
// A write reaches a pool in up to three steps. If a landing tier fronts the pool (a mergerfs
|
||||
// pool whose first branch is fast storage and whose last is the pool), new files land there
|
||||
// first and a mover drains them. Either way the data then waits in RAM as dirty buffers, and
|
||||
// each txg sync writes it to the disks. Each step's live number is here, with its history.
|
||||
|
||||
const dur = (s: number) => (!Number.isFinite(s) || s <= 0 ? '—' : s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : `${(s / 3600).toFixed(1)} h`)
|
||||
type WHist = Record<string, number[]>
|
||||
const wIds = (w: ZFSWrites) => ['zfs.write.dirty', ...w.pools.flatMap((p) => {
|
||||
const b = `zfs.${p.pool}.`
|
||||
return [b + 'write_bps', b + 'logical_bps', ...(p.landing ? [b + 'landing.pending_bytes', b + 'landing.in_bps', b + 'landing.drain_bps'] : [])]
|
||||
})]
|
||||
|
||||
// rsync's --bwlimit: a bare number is KiB/s, a K/M/G suffix is binary (60M = 60 MiB/s); 0 is none
|
||||
const bwl = (v: string) => {
|
||||
const m = /^(\d+(?:\.\d+)?)([KMG]?)$/i.exec((v ?? '').trim())
|
||||
if (!m || Number(m[1]) === 0) return ''
|
||||
return ` · rsync limited to ${m[1]} ${(m[2] || 'K').toUpperCase()}iB/s`
|
||||
}
|
||||
|
||||
function MoverLine({ l }: { l: Landing }) {
|
||||
const m = l.mover
|
||||
if (!m) return <div className="notice" style={{ marginTop: 6 }}>No mover is reporting for this landing tier (nothing in <span className="mono">/run/landing-mover/</span>). Files that land here stay here until something moves them.</div>
|
||||
const frac = m.current_size > 0 ? m.partial / m.current_size : 0
|
||||
return (
|
||||
<>
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Mover</span><span>
|
||||
<span className={'badge ' + (m.state === 'moving' ? 'on' : m.state === 'idle' ? '' : 'warn')}>{m.state}</span>
|
||||
{' '}{m.state === 'moving' && m.batch_files > 1 ? `batch of ${m.batch_files} files, ${fmtBytes(m.batch_bytes)}` : m.state === 'holding' ? 'a mount it needs is missing — waiting' : m.state === 'stopped' ? `no word since ${ago(m.updated_ms)}` : `queue ${m.queue}`}
|
||||
{m.state === 'moving' && m.queue > 0 ? ` · ${m.queue} more queued` : ''}
|
||||
</span></div>
|
||||
{m.state === 'moving' && m.current && <div><span className="k">Now</span><span className="mono small" title={m.current}>{m.current}{m.current_size ? ` · ${fmtBytes(m.partial)} of ${fmtBytes(m.current_size)}` : ''}</span></div>}
|
||||
<div><span className="k">Since start</span><span>{fmtN(m.moved_files)} files · {fmtBytes(m.moved_bytes)} moved · running {ago(m.started_ms).replace(' ago', '')}</span></div>
|
||||
{m.last_move && <div><span className="k">Last moved</span><span className="mono small" title={m.last_move}>{m.last_move} · {ago(m.last_move_ms)}</span></div>}
|
||||
<div><span className="k">Rules</span><span className="muted">a file moves once it has been closed and left alone for {m.settle} s · oldest first{bwl(m.bwlimit)}</span></div>
|
||||
{m.last_error && <div><span className="k">Last error</span><span className="warn-text" style={{ whiteSpace: 'normal' }}>{m.last_error} · {ago(m.last_error_ms)}</span></div>}
|
||||
</div>
|
||||
{m.state === 'moving' && m.current_size > 0 && <Meter frac={frac} cls="" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingBlockInner({ l, pending, inn, drain }: { l: Landing; pending: number[]; inn: number[]; drain: number[] }) {
|
||||
const eta = l.drain_bps > 0 ? l.pending_bytes / l.drain_bps : NaN
|
||||
const fill = l.size > 0 ? l.used / l.size : 0
|
||||
return (
|
||||
<div className="landing-tier">
|
||||
<div className="arc-tier"><span className="k" title="a mergerfs pool puts new files on this branch first; a mover drains them onto the pool">Landing</span>
|
||||
<span className="arc-tier-v"><span className="mono" title={`${l.branch} → ${l.pool_branch} via ${l.mount}`}>{l.branch}{l.device ? ` · ${l.device}` : ''}{l.fstype ? ` · ${l.fstype}` : ''} · in front of {l.pool_branch} as {l.mount}</span></span>
|
||||
</div>
|
||||
<div className="sub-head" style={{ marginTop: 6 }}>
|
||||
<span className="muted small">{fmtBytes(l.used)} of {fmtBytes(l.size)} used</span><span className="spacer" />
|
||||
<span className="num">{l.pending_files ? `${fmtN(l.pending_files)}${l.pending_partial ? '+' : ''} waiting · ${fmtBytes(l.pending_bytes)}` : 'nothing waiting'}</span>
|
||||
</div>
|
||||
<Meter frac={fill} />
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Waiting</span><span>{l.pending_files ? `${fmtN(l.pending_files)}${l.pending_partial ? '+' : ''} files, ${fmtBytes(l.pending_bytes)} · oldest landed ${ago(l.oldest_ms)}` : 'nothing — everything that landed is on the pool'}<span className="muted small"> · counted {ago(l.counted_ms)}</span></span></div>
|
||||
<div><span className="k">Arriving</span><span>{fmtRate(l.in_bps)}{l.device ? <span className="muted small"> · writes to {l.device}</span> : ''}</span></div>
|
||||
<div><span className="k">To the pool</span><span>{fmtRate(l.drain_bps)}{l.pending_bytes > 0 ? ` · ${Number.isFinite(eta) ? `clear in about ${dur(eta)}` : 'not draining right now'}` : ''}</span></div>
|
||||
</div>
|
||||
<MoverLine l={l} />
|
||||
<div className="arc-sparks">
|
||||
<div>
|
||||
<div className="muted small">Waiting on the landing tier · {fmtBytes(l.pending_bytes)}</div>
|
||||
<Sparkline series={[{ name: 'waiting', values: pending, color: cssVar('--s1') }]} format={fmtBytes} height={40} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="muted small">Arriving {fmtRate(l.in_bps)} · to the pool {fmtRate(l.drain_bps)}</div>
|
||||
<Sparkline series={[
|
||||
{ name: 'arriving', values: inn, color: cssVar('--s1') },
|
||||
{ name: 'to the pool', values: drain, color: cssVar('--ok') },
|
||||
]} format={fmtRate} height={40} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WritePath({ w, p, h }: { w: ZFSWrites; p: PoolWrites; h: WHist }) {
|
||||
const b = `zfs.${p.pool}.`
|
||||
const throttling = w.delayed_per_sec > 0 || w.over_max_per_sec > 0
|
||||
const delayAt = (w.limit * w.delay_min_pct) / 100
|
||||
const ratio = p.write_bps > 0 && p.logical_bps > 0 ? p.logical_bps / p.write_bps : 0
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (w.over_max_per_sec > 0 ? 'bad' : throttling ? 'warn' : 'ok')} />
|
||||
<span className="dname">{p.pool}</span>
|
||||
<span className="badge">write path</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{fmtRate(p.write_bps)} to disk</span>
|
||||
</div>
|
||||
{p.landing
|
||||
? <LandingBlockWithHist l={p.landing} h={h} b={b} />
|
||||
: <div className="arc-tiers"><div className="arc-tier"><span className="k" title="a fast tier new files land on before the pool">Landing</span><span className="arc-tier-v"><span className="none">none — writes go straight into the pool's RAM buffer</span></span></div></div>}
|
||||
<div className="arc-sparks">
|
||||
<div>
|
||||
<div className="muted small">RAM write buffer · {fmtBytes(w.dirty)} of {fmtBytes(w.limit)}</div>
|
||||
<Sparkline series={[{ name: 'waiting in RAM', values: h['zfs.write.dirty'] ?? [], color: cssVar('--s1') }]} max={w.limit || undefined} format={fmtBytes} height={44} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="muted small">Writes · {fmtRate(p.write_bps)} to disk · {fmtRate(p.logical_bps)} from programs</div>
|
||||
<Sparkline series={[
|
||||
{ name: 'to disk', values: h[b + 'write_bps'] ?? [], color: cssVar('--s1') },
|
||||
{ name: 'from programs', values: h[b + 'logical_bps'] ?? [], color: cssVar('--ok') },
|
||||
]} format={fmtRate} height={44} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="props rows" style={{ marginTop: 8 }}>
|
||||
<div><span className="k">In RAM</span><span title="dirty data: written by programs, not yet on the disks. For all pools together — ZFS keeps no live per-pool figure">{fmtBytes(w.dirty)} waiting · holds up to {fmtBytes(w.limit)} {w.limit < w.dirty_max ? `(half the ARC target; zfs_dirty_data_max is ${fmtBytes(w.dirty_max)})` : '(zfs_dirty_data_max)'}</span></div>
|
||||
<div><span className="k">Throttle</span><span title="past zfs_delay_min_dirty_percent of the buffer, ZFS makes each write wait a little so the disks can catch up; at the limit, writes stop until a sync frees room">
|
||||
{w.over_max_per_sec > 0 ? <span className="warn-text">{fmtN(w.over_max_per_sec)}/s writes stopped at the limit</span> : w.delayed_per_sec > 0 ? <span className="warn-text">slowing {fmtN(w.delayed_per_sec)} writes/s — the disks are the limit now</span> : 'not slowing anything'}
|
||||
<span className="muted"> · starts at {w.delay_min_pct}% ({fmtBytes(delayAt)})</span></span></div>
|
||||
<div><span className="k">To disk</span><span>{fmtRate(p.write_bps)} written by syncs{ratio > 0 ? ` · ${fmtRate(p.logical_bps)} from programs (${ratio >= 1 ? `${ratio.toFixed(2)}x compressed` : 'parity and metadata included'})` : ''}<span className="muted"> · a sync at least every {w.txg_timeout_sec} s, sooner as the buffer fills</span></span></div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingBlockWithHist({ l, h, b }: { l: Landing; h: WHist; b: string }) {
|
||||
return <LandingBlockInner l={l} pending={h[b + 'landing.pending_bytes'] ?? []} inn={h[b + 'landing.in_bps'] ?? []} drain={h[b + 'landing.drain_bps'] ?? []} />
|
||||
}
|
||||
|
||||
function ARCSection() {
|
||||
const [v, setV] = useState<ARCView | null>(null)
|
||||
const [hist, setHist] = useState<{ size: number[]; hit: number[] }>({ size: [], hit: [] })
|
||||
const [whist, setWhist] = useState<WHist>({})
|
||||
const [err, setErr] = useState('')
|
||||
const rate = useRefreshMs()
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
const poll = async () => {
|
||||
let r: ARCView | null = null
|
||||
try { r = await api.zfsARC(); if (!stop) { setV(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
|
||||
try {
|
||||
const extra = r?.writes ? wIds(r.writes) : []
|
||||
const hs = await fetchHistories(['zfs.arc.size', 'zfs.arc.hit_pct', ...extra].join(','))
|
||||
if (!stop) {
|
||||
setHist({ size: hs.find((h) => h.id === 'zfs.arc.size')?.v ?? [], hit: hs.find((h) => h.id === 'zfs.arc.hit_pct')?.v ?? [] })
|
||||
setWhist(Object.fromEntries(hs.filter((h) => extra.includes(h.id)).map((h) => [h.id, h.v])))
|
||||
}
|
||||
} catch { /* the sparklines stay empty; the numbers still show */ }
|
||||
}
|
||||
poll()
|
||||
const t = setInterval(poll, 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 full" style={{ marginTop: 8 }}>{d.pools.map((p) => <PoolTiers key={p.pool} p={p} v={v} />)}</div>}
|
||||
{v.writes && v.writes.pools.length > 0 && (
|
||||
<>
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Writes</h2><span className="spacer" />
|
||||
<span className="muted small">what is waiting to reach each pool, and how fast it is getting there</span>
|
||||
</div>
|
||||
<div className="cards full">{v.writes.pools.map((p) => <WritePath key={p.pool} w={v.writes!} p={p} h={whist} />)}</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- landing-tier checks: what has waited too long, and verify passes ----
|
||||
//
|
||||
// A verify pass reads the pool's copy of every file the mover moved back against the SHA-256
|
||||
// the mover took of the landing copy, from its ledger. "New moves" checks what has moved since
|
||||
// the last pass; "everything" re-reads the whole ledger. Passes run as their own process, the
|
||||
// same one a schedule's cron line starts, so a console restart does not cut one short.
|
||||
|
||||
const vDescribe = (p: VerifyPolicy) => {
|
||||
const when = p.every === 'custom' ? `on ${p.cron}`
|
||||
: p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}`
|
||||
: p.every === 'daily' ? `every day at ${p.at}`
|
||||
: p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}`
|
||||
: `on day ${p.day} of the month at ${p.at}`
|
||||
return `${when} · ${p.scope === 'all' ? 'everything in the ledger' : 'what moved since the last pass'}`
|
||||
}
|
||||
const vOK = (r: VerifyResult) => !r.error && r.mismatched === 0 && r.gone === 0 && r.unreadable === 0
|
||||
const secs = (a: string, b: string) => (new Date(b).getTime() - new Date(a).getTime()) / 1000
|
||||
|
||||
function VerifySummary({ r }: { r: VerifyResult }) {
|
||||
if (r.error) return <span className="warn-text" style={{ whiteSpace: 'normal' }}>{r.error}</span>
|
||||
const extra = [r.changed && `${fmtN(r.changed)} edited since (not compared)`, r.waiting && `${fmtN(r.waiting)} waiting to move again`, r.unhashed && `${fmtN(r.unhashed)} without a hash`].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<span>
|
||||
{vOK(r)
|
||||
? <>{r.checked === 0 ? 'nothing new to check' : <><b>{fmtN(r.matched)}</b> of {fmtN(r.checked)} files match</>}</>
|
||||
: <span className="warn-text">{[r.mismatched && `${fmtN(r.mismatched)} DIFFER`, r.gone && `${fmtN(r.gone)} not on the pool`, r.unreadable && `${fmtN(r.unreadable)} unreadable`].filter(Boolean).join(' · ')} · {fmtN(r.matched)} match</span>}
|
||||
<span className="muted"> · {fmtBytes(r.bytes_read)} read in {dur(secs(r.started, r.finished)) === '—' ? '0 s' : dur(secs(r.started, r.finished))} · {r.scope === 'all' ? 'everything' : 'new moves'} · {ago(r.finished)}</span>
|
||||
{extra && <span className="muted"> · {extra}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Issues({ r }: { r: VerifyResult }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!r.issues?.length) return null
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button className="btn" onClick={() => setOpen(!open)}>{open ? 'Hide' : 'Show'} the {r.issues.length} file{r.issues.length === 1 ? '' : 's'} to look at</button>
|
||||
{open && <div className="snap-list" style={{ marginTop: 6 }}>{r.issues.map((i) => (
|
||||
<div key={i.path + i.kind} className="snap">
|
||||
<span className={'badge ' + (i.kind === 'mismatch' ? 'bad' : 'warn')}>{i.kind === 'mismatch' ? 'differs' : i.kind === 'gone' ? 'not on pool' : 'unreadable'}</span>
|
||||
<span className="mono snap-name" title={i.path}>{i.path}</span>
|
||||
<span className="muted snap-when">moved {new Date(i.moved).toLocaleString()}</span>
|
||||
<span className="num muted">{fmtBytes(i.size)}</span>
|
||||
</div>
|
||||
))}</div>}
|
||||
{open && r.gone > 0 && <div className="muted small" style={{ marginTop: 4 }}>"Not on the pool" is a file that was moved and is no longer there — deleted or renamed since, most often. The pass cannot tell that from a loss, so it lists them.</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifyScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: VerifyPolicy; cron: string; next?: string; last: VerifyResult | null; onChanged: () => void; onEdit: () => void }) {
|
||||
const run = async () => {
|
||||
try { await api.runVerifySchedule(p.id); toast('verify pass started'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!await askYes(`Remove the verify schedule "${p.name}"? Passes it already ran stay in the history.`, 'Remove')) return
|
||||
try { await api.deleteVerifySchedule(p.id); toast('schedule removed'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const toggle = async () => {
|
||||
try { await api.saveVerifySchedule({ ...p, enabled: !p.enabled }); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (last && !vOK(last) ? 'bad' : p.enabled ? 'ok' : '')} />
|
||||
<span className="dname">{p.name || 'verify'}</span>
|
||||
{!p.enabled && <span className="badge">paused</span>}
|
||||
<span className="spacer" />
|
||||
<span className="muted small">{p.enabled ? soon(next) : 'not scheduled'}</span>
|
||||
</div>
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">When</span><span>{vDescribe(p)}</span></div>
|
||||
<div><span className="k">Cron</span><span className="mono small">{cron}</span></div>
|
||||
<div><span className="k">Last pass</span>{last ? <VerifySummary r={last} /> : <span>never run</span>}</div>
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" onClick={run}>Run now</Btn>
|
||||
<button className="btn" onClick={onEdit}>Edit</button>
|
||||
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const VBLANK: Partial<VerifyPolicy> = { name: '', every: 'daily', at: '05:10', weekday: 0, day: 1, scope: 'new', enabled: true }
|
||||
|
||||
function VerifyDialog({ p, onClose, onSaved }: { p: Partial<VerifyPolicy>; onClose: () => void; onSaved: () => void }) {
|
||||
const [f, setF] = useState<Partial<VerifyPolicy>>({ ...VBLANK, ...p })
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
const set = (k: keyof VerifyPolicy, v: unknown) => setF((x) => ({ ...x, [k]: v }))
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr('')
|
||||
try { await api.saveVerifySchedule({ ...f, name: (f.name || '').trim() || (f.scope === 'all' ? 'verify everything' : 'verify new moves') }); onSaved() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||
<h3>{f.id ? 'Edit verify schedule' : 'New verify schedule'}</h3>
|
||||
<label className="muted">Name — what this schedule is for
|
||||
<input value={f.name} onChange={(e) => set('name', e.target.value)} placeholder="nightly verify" maxLength={64} />
|
||||
</label>
|
||||
<label className="muted">What to check
|
||||
<span className="chips">
|
||||
<button type="button" className={'chip' + (f.scope === 'new' ? ' on' : '')} onClick={() => set('scope', 'new')}>new moves</button>
|
||||
<button type="button" className={'chip' + (f.scope === 'all' ? ' on' : '')} onClick={() => set('scope', 'all')}>everything</button>
|
||||
</span>
|
||||
<span className="small">{f.scope === 'all' ? 'Re-reads every file the mover has ever moved from the pool. Thorough, and as slow as the ledger is large.' : 'Reads back what has moved since the last pass — every file checked once, soon after it lands.'}</span>
|
||||
</label>
|
||||
<label className="muted">How often
|
||||
<span className="chips">
|
||||
{(['hourly', 'daily', 'weekly', 'monthly', 'custom'] as const).map((k) => (
|
||||
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
|
||||
))}
|
||||
</span>
|
||||
</label>
|
||||
{f.every === 'custom' ? (
|
||||
<label className="muted">Cron expression — minute hour day month weekday
|
||||
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 */6 * * *" spellCheck={false} required />
|
||||
</label>
|
||||
) : (
|
||||
<label className="muted">At
|
||||
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
{f.every === 'weekly' && (
|
||||
<label className="muted">On
|
||||
<select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}>
|
||||
{WEEKDAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{f.every === 'monthly' && (
|
||||
<label className="muted">Day of the month — 1 to 28, so every month has one
|
||||
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
|
||||
</label>
|
||||
)}
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
|
||||
Enabled
|
||||
</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on">Save</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingChecks() {
|
||||
const [c, setC] = useState<LandingCheck | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [showStale, setShowStale] = useState(false)
|
||||
const [editing, setEditing] = useState<Partial<VerifyPolicy> | null>(null)
|
||||
const rate = useRefreshMs()
|
||||
const load = useCallback(async () => {
|
||||
try { setC(await api.zfsLanding()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const running = !!c?.running
|
||||
useEffect(() => { const t = setInterval(load, running ? 2000 : Math.max(10000, rate * 5)); return () => clearInterval(t) }, [load, rate, running])
|
||||
|
||||
if (err && !c) return <div className="error">{err}</div>
|
||||
if (!c || (c.tiers.length === 0 && c.policies.length === 0 && c.history.length === 0)) return null
|
||||
const verify = async (scope: 'new' | 'all') => {
|
||||
if (scope === 'all' && !await askYes('Re-read every file the mover has ever moved, from the pool, and check each against its hash? It reads as much as the ledger holds.', 'Verify everything')) return
|
||||
try { await api.landingVerify(scope); toast('verify pass started'); setTimeout(load, 800) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const last = c.history[0]
|
||||
const hours = Math.round(c.stale_after_sec / 3600)
|
||||
const r = c.running
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Landing tier checks</h2><span className="spacer" />
|
||||
<span className="muted small">did everything that landed reach the pool, intact?</span>
|
||||
</div>
|
||||
<div className="cards full">
|
||||
{c.tiers.length === 0 && <div className="notice">No landing mover is reporting right now (nothing in <span className="mono">/run/landing-mover/</span>), so there is nothing to check. Past passes are below.</div>}
|
||||
{c.tiers.map((t) => (
|
||||
<section className="card" key={t.src}>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (last && !vOK(last) ? 'bad' : t.stale_count ? 'warn' : 'ok')} />
|
||||
<span className="dname">{t.dst}</span>
|
||||
<span className="badge">landing checks</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted small mono">{t.src} → {t.dst}</span>
|
||||
</div>
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Stale</span><span>
|
||||
{t.stale_count
|
||||
? <><span className="warn-text">{fmtN(t.stale_count)} file{t.stale_count === 1 ? '' : 's'}, {fmtBytes(t.stale_bytes)}, waiting over {hours} h</span>{' '}<button className="btn" onClick={() => setShowStale(!showStale)}>{showStale ? 'Hide' : 'Show'}</button></>
|
||||
: `nothing has waited on the landing tier over ${hours} h`}
|
||||
</span></div>
|
||||
<div><span className="k">Last pass</span>{last ? <VerifySummary r={last} /> : <span>never verified</span>}</div>
|
||||
<div><span className="k">Covered</span><span>{t.cursor ? `every file moved up to ${new Date(t.cursor).toLocaleString()} has been checked at least once` : 'nothing checked yet'}<span className="muted"> · ledger {t.ledger}</span></span></div>
|
||||
</div>
|
||||
{showStale && t.stale.length > 0 && (
|
||||
<div className="snap-list" style={{ marginTop: 6 }}>{t.stale.map((f) => (
|
||||
<div key={f.path} className="snap">
|
||||
<span className="mono snap-name" title={f.path}>{f.path}</span>
|
||||
<span className="muted snap-when">landed {new Date(f.mtime).toLocaleString()}</span>
|
||||
<span className="muted snap-exp">waiting {dur((Date.now() - f.mtime) / 1000)}</span>
|
||||
<span className="num muted">{fmtBytes(f.size)}</span>
|
||||
</div>
|
||||
))}{t.stale_count > t.stale.length && <div className="muted small">and {fmtN(t.stale_count - t.stale.length)} more</div>}</div>
|
||||
)}
|
||||
{r && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="sub-head"><span className="muted small">Verifying {r.scope === 'all' ? 'everything' : 'new moves'} · {fmtN(r.done)} of {fmtN(r.total)} files · {fmtBytes(r.bytes_read)} of {fmtBytes(r.bytes_all)}</span><span className="spacer" /><span className="mono small muted" title={r.current}>{r.current}</span></div>
|
||||
<Meter frac={r.bytes_all > 0 ? r.bytes_read / r.bytes_all : r.total > 0 ? r.done / r.total : 0} cls="" />
|
||||
</div>
|
||||
)}
|
||||
{last && <Issues r={last} />}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={running} onClick={() => verify('new')}>{running ? 'Verifying…' : 'Verify new moves'}</Btn>
|
||||
<Btn className="btn" disabled={running} onClick={() => verify('all')}>Verify everything</Btn>
|
||||
</div>
|
||||
{c.history.length > 1 && (
|
||||
<details style={{ marginTop: 6 }}>
|
||||
<summary className="muted small">Earlier passes</summary>
|
||||
<div className="props rows" style={{ marginTop: 4 }}>{c.history.slice(1).map((h, i) => (
|
||||
<div key={i}><span className="k">{new Date(h.finished).toLocaleString()}</span><VerifySummary r={h} /></div>
|
||||
))}</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Verify schedules</h2><span className="spacer" />
|
||||
<Btn className="btn on" onClick={() => setEditing({ ...VBLANK })}>+ New schedule</Btn>
|
||||
</div>
|
||||
{c.policies.length === 0 && <div className="notice">No verify runs on a schedule. A daily pass of new moves checks every file once, soon after it lands.</div>}
|
||||
<div className="cards full">{c.policies.map(({ policy, cron, next, last }) => (
|
||||
<VerifyScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
))}</div>
|
||||
{editing && <VerifyDialog p={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function ZFS() {
|
||||
@@ -331,14 +861,20 @@ export default function ZFS() {
|
||||
<div className="dash">
|
||||
<div className="dash-bar"><h2 className="section">Pools</h2></div>
|
||||
{zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
||||
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
||||
<div className="cards full">{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 />
|
||||
<LandingChecks />
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
|
||||
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
|
||||
</div>
|
||||
{ov.external && <ExternalNotice x={ov.external} mine={mine} onAdopt={() => setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />}
|
||||
{ov.policies.length === 0 && !ov.external && <div className="notice">Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.</div>}
|
||||
<div className="cards">{ov.policies.map(({ policy, cron, next, last }) => (
|
||||
<div className="cards full">{ov.policies.map(({ policy, cron, next, last }) => (
|
||||
<ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
))}</div>
|
||||
|
||||
|
||||
+113
-13
@@ -4,7 +4,12 @@ export class Unauthenticated extends Error { constructor(msg = 'unauthenticated'
|
||||
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
|
||||
// the machine list itself) pass local=true and are never rewritten.
|
||||
let base = ''
|
||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
|
||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : ''; machine = name ?? '' }
|
||||
let machine = ''
|
||||
// The machine the console is looking at: '' is this host.
|
||||
export const currentMachine = () => machine
|
||||
// A path under another machine's /api, whichever one the console is looking at.
|
||||
export const nodeAPI = (node: string, p: string) => (node && p.startsWith('/api/') ? `/api/nodes/${node}${p.slice(4)}` : p)
|
||||
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
|
||||
|
||||
// A machine that just failed to answer is left alone for a few seconds instead of
|
||||
@@ -66,15 +71,26 @@ export const titleOf = (name: string | null, machines: MachineStatus[], hubHost:
|
||||
export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string }
|
||||
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
|
||||
|
||||
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
|
||||
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number; pkg?: number }
|
||||
export interface Fan { chip: string; label: string; rpm: number }
|
||||
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
|
||||
export interface 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; source?: string }
|
||||
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
||||
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; gpu?: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
||||
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
|
||||
export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; uptime: number; procs: Proc[]; total: number; running: number }
|
||||
// A graphics device: usage_src says what measures it ("idle" residency, the driver's own "busy", or
|
||||
// the busiest "clients" engine) and is absent when nothing does; temp_src is "gpu" for the device's own
|
||||
// sensor or "package" for the CPU die an integrated GPU shares.
|
||||
export interface GPUClient { pid: number; name: string; usage: number; engines?: Record<string, number>; mem?: number }
|
||||
export interface GPU {
|
||||
name: string; driver: string; bus?: string; integrated?: boolean
|
||||
usage: number; usage_src?: 'idle' | 'busy' | 'clients'; engines?: Record<string, number>
|
||||
temp?: number; temp_src?: 'gpu' | 'package'; power_w?: number; power_cap_w?: number; fan_rpm?: number; fan_pct?: 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_power_pkgs_w?: number[]; cpu_temp?: number; core_ids?: number[]; core_pkgs?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number }
|
||||
export interface Sample {
|
||||
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
|
||||
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
||||
@@ -143,8 +159,8 @@ export interface Hardware { at: string; system: HWSystem; board: HWBoard; cpu: H
|
||||
|
||||
export interface DockerLogLine { t: string; m: string; err?: boolean }
|
||||
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
|
||||
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
|
||||
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
|
||||
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; editable: boolean; note?: string; has_env: boolean }
|
||||
export interface StackHome { dir: string; inner: string; via: string; stacks: number; default: boolean }
|
||||
export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string }
|
||||
export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: string }
|
||||
export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string }
|
||||
@@ -169,7 +185,6 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
|
||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
||||
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||
|
||||
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
||||
|
||||
// ---- the Network tab ----
|
||||
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
||||
@@ -215,8 +230,75 @@ export interface ZFSPolicy { id: string; name: string; dataset: string; recursiv
|
||||
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
||||
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||
export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; cron: string; next?: string; last: ZFSRun }[]; external?: ZFSExternal | null }
|
||||
// The ARC as the sampler sees it (sizes in bytes; hit_pct over the last sampling interval).
|
||||
export interface ARCLive {
|
||||
size: number; target: number; min: number; max: number
|
||||
mru: number; mfu: number; anon: number; data: number; metadata: number; meta_used: number; headers: number
|
||||
compressed: number; uncompressed: number; mru_ghost: number; mfu_ghost: number; l2_size: number; l2_asize: number
|
||||
reads_per_sec: number; misses_per_sec: number; hit_pct: number; hit_pct_known: boolean; l2_hit_pct: number
|
||||
hits: number; misses: number; lifetime_hit_pct: number; no_grow: boolean; throttles: number; prunes: number; evict_skip: number
|
||||
}
|
||||
export interface CacheDev { name: string; size?: number; alloc?: number; health?: string }
|
||||
export interface PoolCaching {
|
||||
pool: string; cache: CacheDev[]; log: CacheDev[]; special: CacheDev[]; dedup: CacheDev[]; spare: CacheDev[]
|
||||
primarycache: string; secondarycache: string; sync: string; logbias: string; dedup_prop: string; compression: string; recordsize: string; dedup_ratio: string
|
||||
}
|
||||
export interface ARCDetail {
|
||||
available: boolean; mem_total: number; tunables: Record<string, string>
|
||||
lifetime: { Hits: number; Misses: number; DemandDataHits: number; DemandDataMisses: number; DemandMetaHits: number; DemandMetaMisses: number; PrefetchDataHits: number; PrefetchDataMisses: number; PrefetchMetaHits: number; PrefetchMetaMisses: number; L2Hits: number; L2Misses: number; L2ReadBytes: number; L2WriteBytes: number; L2Feeds: number; L2RebuildSize: number; EvictL2Eligible: number; Deleted: number; EvictSkip: number; MutexMiss: number; Prune: number; Throttle: number }
|
||||
zfetch: { Hits: number; Misses: number; IOIssued: number }
|
||||
zil: { commits: number; NormalBytes: number; NormalWrite: number; SlogBytes: number; SlogWrite: number }
|
||||
dbuf: { Size: number; Target: number; Hits: number; Misses: number }
|
||||
pools: PoolCaching[]
|
||||
}
|
||||
export interface LandingMover {
|
||||
state: 'idle' | 'moving' | 'holding' | 'stopped' | string; src: string; dst: string; pid: number; started_ms: number; updated_ms: number; stale: boolean
|
||||
queue: number; settle: number; bwlimit: string; batch_files: number; batch_bytes: number; batch_start_ms: number; batch_first: string
|
||||
current: string; current_size: number; partial: number; moved_files: number; moved_bytes: number
|
||||
last_move: string; last_move_ms: number; last_error: string; last_error_ms: number
|
||||
}
|
||||
export interface Landing {
|
||||
mount: string; branch: string; pool_branch: string; device?: string; fstype?: string; size: number; used: number
|
||||
pending_files: number; pending_bytes: number; pending_partial?: boolean; oldest_ms?: number; counted_ms: number
|
||||
in_bps: number; drain_bps: number; mover?: LandingMover
|
||||
}
|
||||
export interface PoolWrites { pool: string; write_bps: number; logical_bps: number; landing?: Landing }
|
||||
export interface ZFSWrites {
|
||||
dirty: number; dirty_max: number; delay_min_pct: number; limit: number; delayed_per_sec: number; over_max_per_sec: number; txg_timeout_sec: number
|
||||
pools: PoolWrites[]
|
||||
}
|
||||
export interface VerifyPolicy { id: string; name: string; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; scope: 'new' | 'all'; enabled: boolean }
|
||||
export interface VerifyIssue { path: string; kind: 'mismatch' | 'gone' | 'unreadable'; moved: number; size: number; note?: string }
|
||||
export interface VerifyResult {
|
||||
id: string; scope: 'new' | 'all'; started: string; finished: string; src: string; dst: string; since: number
|
||||
entries: number; checked: number; matched: number; mismatched: number; changed: number; gone: number; waiting: number; unreadable: number; unhashed: number
|
||||
bytes_read: number; issues: VerifyIssue[]; error?: string; cursor: number
|
||||
}
|
||||
export interface VerifyProgress { id: string; scope: string; pid: number; started: string; updated: string; total: number; done: number; bytes_read: number; bytes_all: number; current: string }
|
||||
export interface StaleFile { path: string; size: number; mtime: number }
|
||||
export interface LandingCheck {
|
||||
tiers: { src: string; dst: string; ledger: string; cursor: number; stale_count: number; stale_bytes: number; stale: StaleFile[] }[]
|
||||
stale_after_sec: number; running: VerifyProgress | null; history: VerifyResult[]; cron_file: string
|
||||
policies: { policy: VerifyPolicy; cron: string; next?: string; last: VerifyResult | null }[]
|
||||
}
|
||||
export interface ARCView { detail: ARCDetail; live: ARCLive | null; writes: ZFSWrites | 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; preview?: 'image' | 'video' | 'audio' }
|
||||
export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number; spherical?: boolean }
|
||||
export interface PlayRung { name: string; label: string; width: number; height: number; fps: number; bitrate: number; peak: number }
|
||||
export interface PlayStatus { engine?: string; quality?: string; from: number; ahead: number; paused?: boolean }
|
||||
// A play: where it is made (node, '' the hub), its qualities best first, and the playlist under
|
||||
// that machine's /api.
|
||||
export interface PlayOpen {
|
||||
node: string; master: string; engine: string; host: string; note?: string; status: PlayStatus
|
||||
play: { id: string; name: string; device: string; remote: boolean; rungs: PlayRung[]; info: { container: string; duration: number; bit_rate?: number; size?: number; video?: PlayTrack; audio?: PlayTrack } }
|
||||
}
|
||||
export interface TranscodeDevice { id: string; name: string; integrated?: boolean; ok: boolean; reason?: string }
|
||||
export interface TranscodeMachine { node: string; host: string; devices?: TranscodeDevice[]; error?: string }
|
||||
export interface TranscodeSetting { selected: { node: string; device: string } | null; machines: TranscodeMachine[] }
|
||||
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
|
||||
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
|
||||
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }
|
||||
export interface FileInfo extends FileEntry { path: string; text: boolean }
|
||||
|
||||
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
|
||||
@@ -239,8 +321,21 @@ export const api = {
|
||||
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
|
||||
return r.text()
|
||||
},
|
||||
// Playing: opened on the hub, which picks the machine (the Transcode device setting); the
|
||||
// rest goes to that machine.
|
||||
play: (path: string, sudo: boolean) => req<PlayOpen>('POST', '/api/play', { node: currentMachine(), path, sudo }, true),
|
||||
playStatus: (node: string, id: string) => req<PlayStatus>('GET', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||
playStop: (node: string, id: string) => req<void>('DELETE', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||
transcode: () => req<TranscodeSetting>('GET', '/api/transcode', undefined, true),
|
||||
transcodeSelected: () => req<{ selected: { node: string; device: string } | null; host?: string; name?: string }>('GET', '/api/transcode?only=selected', undefined, true),
|
||||
setTranscode: (node: string, device: string) => req<TranscodeSetting>('PUT', '/api/transcode', device ? { node, device } : {}, true),
|
||||
fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`),
|
||||
fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${q(path, sudo)}`),
|
||||
// v carries the file's mtime and size, so a changed file is a new URL and an unchanged one a cached one
|
||||
fileThumbURL: (path: string, sudo: boolean, mtime: number, size: number) => apiPath(`/api/files/thumb?${q(path, sudo, `&v=${mtime}-${size}`)}`),
|
||||
thumbStatus: (sudo: boolean, refresh = false) => req<ThumbStatus>('GET', `/api/files/thumbs?${sudo ? 'sudo=1&' : ''}${refresh ? 'refresh=1' : ''}`),
|
||||
thumbInstall: () => req<TaskInfo>('POST', '/api/files/thumbs/install'),
|
||||
thumbGPUAccess: () => req<TaskInfo>('POST', '/api/files/thumbs/gpu-access'),
|
||||
fileWrite: async (path: string, text: string, sudo: boolean) => {
|
||||
const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||
if (r.status === 401) throw new Unauthenticated()
|
||||
@@ -257,16 +352,20 @@ export const api = {
|
||||
fileMkdir: (path: string, sudo: boolean) => req<void>('POST', `/api/files/mkdir${sudo ? '?sudo=1' : ''}`, { path }),
|
||||
fileTouch: (path: string, sudo: boolean) => req<void>('POST', `/api/files/touch${sudo ? '?sudo=1' : ''}`, { path }),
|
||||
fileRename: (path: string, to: string, sudo: boolean) => req<void>('POST', `/api/files/rename${sudo ? '?sudo=1' : ''}`, { path, to }),
|
||||
// a rename when the ends share a filesystem (nothing back), otherwise the copy-and-remove runs as a task (the task back)
|
||||
fileMove: (path: string, to: string, sudo: boolean) => req<TaskInfo | undefined>('POST', `/api/files/move${sudo ? '?sudo=1' : ''}`, { path, to }),
|
||||
fileDelete: (path: string, recursive: boolean, sudo: boolean) => req<void>('POST', `/api/files/delete${sudo ? '?sudo=1' : ''}`, { path, recursive }),
|
||||
fileChmod: (path: string, mode: string, sudo: boolean) => req<void>('POST', `/api/files/chmod${sudo ? '?sudo=1' : ''}`, { path, mode }),
|
||||
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
||||
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
||||
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
||||
// Always the hub's: it holds every machine's KVM, so the tab works when the machine does not.
|
||||
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
|
||||
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
|
||||
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
||||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||||
zfsLanding: () => req<LandingCheck>('GET', '/api/zfs/landing'),
|
||||
landingVerify: (scope: 'new' | 'all') => req<{ started: string }>('POST', '/api/zfs/landing/verify', { scope }),
|
||||
saveVerifySchedule: (p: Partial<VerifyPolicy>) => req<VerifyPolicy>('PUT', '/api/zfs/landing/schedules', p),
|
||||
deleteVerifySchedule: (id: string) => req<void>('DELETE', `/api/zfs/landing/schedules/${encodeURIComponent(id)}`),
|
||||
runVerifySchedule: (id: string) => req<{ started: string }>('POST', `/api/zfs/landing/schedules/${encodeURIComponent(id)}/run`),
|
||||
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||||
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
||||
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
|
||||
@@ -310,6 +409,7 @@ export const api = {
|
||||
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
||||
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
||||
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
||||
revokeIdleSessions: (hours = 24) => req<{ revoked: number }>('POST', `/api/sessions/revoke-idle?hours=${hours}`, undefined, true),
|
||||
logout: () => req<void>('POST', '/api/logout', undefined, true),
|
||||
meHere: () => req<Me>('GET', '/api/me', undefined, true),
|
||||
me: () => req<Me>('GET', '/api/me'),
|
||||
|
||||
+20
-6
@@ -16,26 +16,40 @@
|
||||
// Returns one value per column, oldest first, right-aligned: a young curve has fewer columns
|
||||
// than the box and draws in from the right.
|
||||
export function columnsOf(values: number[], advance: number, cap: number, cols: number): number[] {
|
||||
return columnsAt(values, advance, advance + 1, cap, cols).cols.slice(-cols)
|
||||
}
|
||||
|
||||
// The same columns, placed in time. `now` is how far the curve has got in points: `advance`
|
||||
// plus the share of the newest point's stride already gone (0–1], so a curve whose points are
|
||||
// several ticks apart still moves on every tick instead of jumping once a point. `edge` is how
|
||||
// much of the newest column is showing (0–1]: the box draws it that far in from the right and
|
||||
// everything else behind it, so the whole graph slides a fraction of a column each tick. One
|
||||
// more column than the box holds comes back, to fill the sliver the slide opens on the left.
|
||||
// `last` is the newest column's number, for a caller that follows a column as it slides.
|
||||
export function columnsAt(values: number[], advance: number, now: number, cap: number, cols: number): { cols: number[]; edge: number; last: number } {
|
||||
const n = Math.min(values.length, cap)
|
||||
if (n === 0 || cols < 1 || cap < 1) return []
|
||||
if (n === 0 || cols < 1 || cap < 1) return { cols: [], edge: 1, last: 0 }
|
||||
const at = (a: number) => values[values.length - 1 - (advance - a)] // the point numbered a
|
||||
const oldest = advance - n + 1
|
||||
const r = cols / cap
|
||||
const out: number[] = []
|
||||
if (cols >= cap) {
|
||||
const kLast = Math.ceil(((advance + 1) * cols) / cap) - 1
|
||||
for (let k = kLast - cols + 1; k <= kLast; k++) {
|
||||
const kLast = Math.ceil(now * r) - 1
|
||||
for (let k = kLast - cols; k <= kLast; k++) {
|
||||
const a = Math.floor((k * cap) / cols)
|
||||
if (a >= oldest && a <= advance) out.push(at(a))
|
||||
}
|
||||
return out
|
||||
return { cols: out, edge: Math.min(1, Math.max(0, now * r - kLast)) || 1, last: kLast }
|
||||
}
|
||||
// the newest column is the newest point's; until the next point lands in the next column the
|
||||
// slide waits at its edge, rather than show a column with nothing in it
|
||||
const kLast = Math.floor((advance * cols) / cap)
|
||||
for (let k = kLast - cols + 1; k <= kLast; k++) {
|
||||
for (let k = kLast - cols; k <= kLast; k++) {
|
||||
const a0 = Math.max(oldest, Math.ceil((k * cap) / cols)), a1 = Math.min(advance, Math.ceil(((k + 1) * cap) / cols) - 1)
|
||||
if (a1 < a0) continue
|
||||
let peak = at(a0)
|
||||
for (let a = a0 + 1; a <= a1; a++) peak = Math.max(peak, at(a))
|
||||
out.push(peak)
|
||||
}
|
||||
return out
|
||||
return { cols: out, edge: Math.min(1, Math.max(0, now * r - kLast)) || 1, last: kLast }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type Values = Record<string, number | boolean | string>
|
||||
export type Catalog = Map<string, Sensor>
|
||||
|
||||
// Everything the modules draw, as one stream query.
|
||||
export const MONITOR_SHAPE = ['cpu.*', 'load.*', 'uptime', 'hamlab.*', 'mem.*', 'swap.*', 'temp.*', 'fan.*', 'net.*', 'disk.*', 'fs.*', 'ups.*']
|
||||
export const MONITOR_SHAPE = ['cpu.*', 'load.*', 'uptime', 'hamlab.*', 'mem.*', 'swap.*', 'temp.*', 'fan.*', 'net.*', 'disk.*', 'fs.*', 'ups.*', 'gpu.*']
|
||||
// The stream answers a list with a list; the modules want one flat map.
|
||||
export function mergeValues(parts: unknown[]): Values {
|
||||
const out: Values = {}
|
||||
@@ -47,11 +47,11 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
|
||||
t: Date.now(), interval_ms: num(v, 'hamlab.sample_interval'),
|
||||
cpu: num(v, 'cpu.usage'), cores, load: [num(v, 'load.1'), num(v, 'load.5'), num(v, 'load.15')],
|
||||
mem: mem('mem'), swap: mem('swap'), uptime: num(v, 'uptime'),
|
||||
temps: hw('temp.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, c: num(v, id), max: s.warn, crit: s.crit })),
|
||||
temps: hw('temp.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, c: num(v, id), max: s.warn, crit: s.crit, pkg: s.labels!.package !== undefined ? Number(s.labels!.package) : undefined })),
|
||||
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
|
||||
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
|
||||
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
|
||||
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk })),
|
||||
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk, source: cat.get(p + '.size')?.labels?.source })),
|
||||
ups: v['ups.battery'] === undefined ? null : {
|
||||
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
|
||||
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Which threads belong to which CPU, on a machine with more than one.
|
||||
//
|
||||
// The kernel numbers every thread of the machine in one run, and on a two-socket board with
|
||||
// hyper-threading it deals them out a socket at a time and then goes round again: cpu 0–9 are
|
||||
// CPU 1's cores, 10–19 CPU 2's, 20–29 the second threads of CPU 1's, 30–39 of CPU 2's. Listed
|
||||
// under its CPU by that number, a 20-thread processor reads "C0…C9, C20…C29" and the one
|
||||
// beside it "C10…C19, C30…C39" — neither looks like twenty of anything. So each CPU's threads
|
||||
// are counted from 0 under it, in the kernel's order, the way a one-socket machine's are: the
|
||||
// first half are its cores, the second half their second threads.
|
||||
//
|
||||
// `pkgs[i]` is the socket logical CPU `i` is on. The answer is, for each socket (ascending),
|
||||
// the logical CPUs on it (ascending); a thread's number under its CPU is its place in that list.
|
||||
export function socketThreads(pkgs: number[]): { pkg: number; cpus: number[] }[] {
|
||||
const by = new Map<number, number[]>()
|
||||
pkgs.forEach((pk, i) => { const l = by.get(pk); if (l) l.push(i); else by.set(pk, [i]) })
|
||||
return [...by.entries()].sort((a, b) => a[0] - b[0]).map(([pkg, cpus]) => ({ pkg, cpus }))
|
||||
}
|
||||
+205
-19
@@ -5,6 +5,10 @@
|
||||
--s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18);
|
||||
--btn-fg: #111; --term-bg: var(--term-bg); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--header-h: 44px; --footer-h: 28px; --rail-w: 220px; --tabs-h: 34px;
|
||||
/* iOS 26+ draws a soft blur under the status bar of a Home Screen app that reaches ~16pt
|
||||
below it (hamlet docs/gotchas.md). Whatever starts at the safe area starts below that band
|
||||
too. Zero where there is no status bar to clear: desktop, a Safari tab. */
|
||||
--blur: min(env(safe-area-inset-top), 16px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
@@ -27,9 +31,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.login button.pweye:hover, .login button.pweye[aria-pressed="true"] { color: var(--fg); }
|
||||
.login button.pweye:disabled { opacity: .4; }
|
||||
.error { color: var(--bad); font-size: 13px; }
|
||||
.svc-list > .error, .cron-list > .error { margin: 8px 12px; }
|
||||
|
||||
/* layout */
|
||||
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
|
||||
.app { height: 100%; display: flex; flex-direction: column; padding-top: calc(env(safe-area-inset-top) + var(--blur)); }
|
||||
.header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); }
|
||||
.brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
|
||||
.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
@@ -94,6 +99,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.dash > * { flex-shrink: 0; }
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; align-items: start; }
|
||||
.cards.full { grid-template-columns: minmax(0, 1fr); } /* one card per row, the width of the page */
|
||||
.tile, .card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; }
|
||||
.tile .label, .card h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.tile .value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
||||
@@ -144,6 +150,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.svc-tools { padding: 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||
.svc-tools input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||
.chip.on { color: var(--fg); border-color: var(--accent); } .chip.bad { color: var(--bad); }
|
||||
.chip.off { opacity: .55; cursor: default; } .chip.off:hover { color: var(--muted); border-color: var(--border); }
|
||||
.tc-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.tc-host { font-weight: 600; margin-bottom: 6px; }
|
||||
.svc-rows { flex: 1; overflow-y: auto; }
|
||||
.svc-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||
.svc-row:hover { background: var(--bg3); } .svc-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
@@ -188,6 +197,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.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. */
|
||||
/* One property per row, label beside its value, the value wrapping rather than cut off: for
|
||||
sentences, like the ZFS tab's write path, that columns would truncate. */
|
||||
.props.rows { grid-template-columns: minmax(0, 1fr); gap: 3px 0; }
|
||||
.props.rows .k { width: 96px; }
|
||||
.props.rows span:not(.k) { overflow: visible; white-space: normal; overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
@@ -204,6 +218,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.log-lines { flex: 1; overflow: auto; padding: 6px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.log { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; }
|
||||
.log .lt { color: var(--muted); flex-shrink: 0; } .log .li { color: var(--accent2); flex-shrink: 0; }
|
||||
/* a phone has no room for three columns: the stamp and the unit go on one line, the message underneath */
|
||||
@media (max-width: 560px) { .log { flex-wrap: wrap; gap: 0 8px; padding: 2px 0; border-bottom: 1px solid var(--border); } .log .lm { flex-basis: 100%; } .log .lt { font-size: 11px; } }
|
||||
.log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); }
|
||||
@media (max-width: 800px) {
|
||||
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
|
||||
@@ -258,7 +274,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
than what is left, and scrolls inside itself. Its parts keep their height: in a column that
|
||||
may shrink, anything with its own scrollbar (a preview, a list) is squeezed to a sliver
|
||||
instead, and the dialog never scrolls at all. */
|
||||
.modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
|
||||
.modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: max(16px, calc(env(safe-area-inset-top) + var(--blur))) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
|
||||
.modal { --modal-bg: var(--bg2); width: min(420px, 100%); max-height: 100%; overflow-y: auto; overscroll-behavior: contain; background: var(--modal-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.modal > * { flex-shrink: 0; }
|
||||
/* a long dialog's buttons (and what went wrong) stay in reach while the rest scrolls */
|
||||
@@ -315,6 +331,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.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.ds-row .num { text-align: right; }
|
||||
@media (max-width: 800px) { .ds-row { grid-template-columns: minmax(90px, 1fr) 84px 100px; min-width: 0; gap: 6px; padding: 6px 8px; } .ds-row > :nth-child(3), .ds-row > :nth-child(5), .ds-row > :nth-child(6) { display: none; } .ds-row .ds-name, .ds-row.head > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ds-row.head { letter-spacing: 0; } }
|
||||
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
|
||||
@@ -360,8 +377,84 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
.file-editor { flex: 1; min-height: 0; border: 0; border-radius: 0; resize: none; font-size: 13px; }
|
||||
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
||||
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
||||
.preview.pic { position: relative; touch-action: pan-y pinch-zoom; }
|
||||
.step-edge { position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 72px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, .35); color: #fff; font-size: 34px; line-height: 1; cursor: pointer; opacity: .55; }
|
||||
.step-edge:hover { opacity: 1; } .step-edge.prev { left: 8px; } .step-edge.next { right: 8px; }
|
||||
.btn.step:disabled { opacity: .4; }
|
||||
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
||||
on an iPhone the whole window */
|
||||
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
||||
.vp.max { position: fixed; inset: 0; z-index: 1000; }
|
||||
.vp.max .vp-note { top: calc(env(safe-area-inset-top) + var(--blur) + 14px); }
|
||||
.vp-v { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
|
||||
.vp-hidden { opacity: 0; pointer-events: none; }
|
||||
.vp-sphere { touch-action: none; cursor: grab; }
|
||||
.vp-sphere:active { cursor: grabbing; }
|
||||
.vp-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 26px 8px calc(env(safe-area-inset-bottom) + 4px); background: linear-gradient(transparent, rgba(0, 0, 0, .75)); transition: opacity .25s; z-index: 2; }
|
||||
.vp.bare .vp-bottom, .vp.bare .vp-hint { opacity: 0; pointer-events: none; }
|
||||
.vp.bare { cursor: none; }
|
||||
.vp-row { display: flex; align-items: center; gap: 4px; }
|
||||
.vp-icon { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 19px; color: #fff; }
|
||||
.vp-icon:hover { background: rgba(255, 255, 255, .14); }
|
||||
.vp-icon.on { color: var(--accent); }
|
||||
.vp-time { font-size: 13px; font-variant-numeric: tabular-nums; padding: 0 6px; white-space: nowrap; }
|
||||
.vp-q { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 17px; font-size: 13px; font-weight: 600; white-space: nowrap; color: #fff; }
|
||||
.vp-q:hover { background: rgba(255, 255, 255, .14); }
|
||||
.vp-seek { position: relative; height: 24px; margin: 0 8px; display: flex; align-items: center; }
|
||||
.vp-seek::before { content: ''; position: absolute; left: 0; right: 0; height: 4px; border-radius: 2px;
|
||||
background: linear-gradient(to right, var(--accent) var(--p), rgba(255, 255, 255, .45) var(--p) var(--b), rgba(255, 255, 255, .2) var(--b)); }
|
||||
.vp-seek input { position: relative; width: 100%; margin: 0; background: transparent; border: 0; padding: 0; height: 24px; -webkit-appearance: none; appearance: none; cursor: pointer; }
|
||||
.vp-seek input::-webkit-slider-runnable-track { height: 4px; background: transparent; }
|
||||
.vp-seek input::-moz-range-track { height: 4px; background: transparent; }
|
||||
.vp-seek input::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; margin-top: -5px; border-radius: 50%; background: var(--accent); border: 0; }
|
||||
.vp-seek input::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 0; }
|
||||
.vp-big { position: absolute; left: 50%; top: 50%; width: 68px; height: 68px; margin: -34px 0 0 -34px; border-radius: 50%; display: grid; place-items: center; background: rgba(0, 0, 0, .55); color: #fff; z-index: 2; padding-left: 4px; }
|
||||
.vp-spin { position: absolute; left: 50%; top: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .2); border-top-color: var(--accent); animation: vp-spin .8s linear infinite; z-index: 1; pointer-events: none; }
|
||||
@keyframes vp-spin { to { transform: rotate(360deg); } }
|
||||
.vp-err { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; text-align: center; z-index: 1; }
|
||||
.vp-err p { margin: 0; max-width: 460px; line-height: 1.45; }
|
||||
.vp-note { position: absolute; left: 50%; top: 14px; transform: translateX(-50%); max-width: min(92%, 480px); padding: 9px 13px; border-radius: 8px; background: rgba(20, 20, 20, .92); font-size: 13px; line-height: 1.4; z-index: 3; cursor: pointer; }
|
||||
.vp-hint { position: absolute; left: 50%; bottom: 86px; transform: translateX(-50%); padding: 5px 11px; border-radius: 14px; background: rgba(0, 0, 0, .55); font-size: 12px; pointer-events: none; transition: opacity .25s; z-index: 1; white-space: nowrap; }
|
||||
.vp-menu { position: absolute; right: 8px; bottom: 60px; width: min(290px, calc(100% - 16px)); max-height: calc(100% - 76px); overflow-y: auto; padding: 6px; border-radius: 10px; background: rgba(24, 24, 24, .96); box-shadow: 0 8px 30px rgba(0, 0, 0, .5); z-index: 4; }
|
||||
.vp-menu-h { font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: rgba(255, 255, 255, .55); padding: 6px 10px 4px; }
|
||||
.vp-menu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border-radius: 6px; text-align: left; font-size: 14px; color: #fff; }
|
||||
.vp-menu button:hover:not(:disabled) { background: rgba(255, 255, 255, .1); }
|
||||
.vp-menu button:disabled { opacity: .45; }
|
||||
.vp-menu button small { margin-left: auto; color: rgba(255, 255, 255, .6); font-size: 12px; white-space: nowrap; }
|
||||
.vp-menu .ck { width: 16px; flex: none; color: var(--accent); display: grid; place-items: center; }
|
||||
.vp-menu-f { border-top: 1px solid rgba(255, 255, 255, .12); margin-top: 6px; padding: 8px 10px 4px; font-size: 12px; color: rgba(255, 255, 255, .6); line-height: 1.4; }
|
||||
.vp-menu-f b { color: #fff; font-weight: 600; }
|
||||
.preview.audio { align-items: flex-start; }
|
||||
.audio-box { display: flex; flex-direction: column; align-items: center; gap: 10px; width: min(560px, 100%); margin-top: 8vh; }
|
||||
.audio-box .ficon { font-size: 48px; }
|
||||
.audio-box audio { width: 100%; }
|
||||
/* previews: the icon column becomes a thumbnail box for images and videos */
|
||||
.file-list.thumbs .file-row { grid-template-columns: 72px 1fr 80px 80px 130px 32px; padding-top: 4px; padding-bottom: 4px; min-height: 52px; }
|
||||
.fthumb { position: relative; width: 72px; height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bg3); border-radius: 4px; overflow: hidden; }
|
||||
.fthumb img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
|
||||
.fthumb.video::after, .fthumb.audio::after { content: '▶'; position: absolute; right: 2px; bottom: 2px; font-size: 9px; line-height: 1; padding: 2px 3px; border-radius: 3px; background: rgba(0,0,0,.6); color: #fff; }
|
||||
.fthumb.audio::after { content: '♪'; }
|
||||
.fthumb .ficon { font-size: 18px; }
|
||||
.thumb-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 10px 0; }
|
||||
/* move: the folder picker */
|
||||
.modal.mover { width: min(560px, 100%); height: min(76vh, 100%); overflow: hidden; }
|
||||
.mover-nav { display: flex; align-items: center; gap: 2px; }
|
||||
.mover-nav .icon-btn { font-size: 18px; min-width: 30px; line-height: 1; }
|
||||
.mover-nav .icon-btn:disabled { opacity: .3; }
|
||||
.mover-nav .crumbs { flex: 1; min-width: 0; margin-left: 4px; }
|
||||
.modal.mover > .mover-list { flex: 1 1 auto; min-height: 80px; overflow-y: auto; margin: 0 -16px; border-top: 1px solid var(--border); }
|
||||
.mover-row { display: flex; align-items: center; gap: 8px; padding: 9px 16px; cursor: pointer; border-bottom: 1px solid var(--border); min-height: 38px; }
|
||||
.mover-row:hover { background: var(--bg3); }
|
||||
.mover-row.self { opacity: .5; cursor: default; }
|
||||
.mover-row .fname { flex: 1; }
|
||||
.mover-dest { overflow-wrap: anywhere; }
|
||||
.thumb-note .btn { padding-top: 3px; padding-bottom: 3px; }
|
||||
.files-bar .task-out { margin: 6px 10px 0; max-height: 160px; }
|
||||
@media (max-width: 800px) {
|
||||
.file-row { grid-template-columns: 24px 1fr 70px 32px; } .file-row .fmeta:nth-child(4), .file-row .perm { display: none; }
|
||||
.file-list.thumbs .file-row { grid-template-columns: 60px 1fr 70px 32px; min-height: 46px; }
|
||||
.fthumb { width: 60px; height: 38px; }
|
||||
.path-input { width: 100%; margin: 4px 0 0; }
|
||||
.files-tools .filter { width: 80px; }
|
||||
}
|
||||
@@ -603,7 +696,28 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.docker .svc-list { min-width: 0; max-width: 100%; } .docker .svc-rows { overflow-x: hidden; } .docker .svc-name .d { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.docker:not(.has-sel) .svc-list { flex: 1 1 0; }
|
||||
.stack-editor { flex: 1; min-height: 46vh; font-size: 13px; tab-size: 2; white-space: pre; overflow: auto; }
|
||||
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } }
|
||||
|
||||
/* The code editor: a gutter of line numbers, a painted copy of the text behind a textarea
|
||||
whose own text is transparent, both in the same monospace at the same size so they line up
|
||||
glyph for glyph. .code takes the sizing class the textarea used to (.stack-editor), so it
|
||||
sits where that did. */
|
||||
.code { display: flex; align-items: stretch; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; position: relative; padding: 0; min-height: 260px; }
|
||||
.code.ro .code-text { color: var(--muted); }
|
||||
.code-gutter { flex: 0 0 auto; min-width: 2.6em; padding: 10px 6px 10px 0; text-align: right; color: var(--muted); opacity: .7; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
|
||||
.code-body { flex: 1; position: relative; min-width: 0; }
|
||||
.code-paint, .code-text { margin: 0; padding: 10px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; tab-size: 2; white-space: pre; letter-spacing: 0; word-spacing: 0; }
|
||||
.code-paint { position: absolute; inset: 0; overflow: hidden; color: var(--fg); pointer-events: none; }
|
||||
.code-text { position: absolute; inset: 0; width: 100%; height: 100%; background: transparent; color: transparent; caret-color: var(--fg); border: 0; outline: 0; resize: none; overflow: auto; }
|
||||
.code-text::placeholder { color: var(--muted); }
|
||||
.code-text::selection { background: rgba(127, 127, 127, .35); }
|
||||
.stack-editor.code, .stack-editor .code-paint, .stack-editor .code-text, .stack-editor .code-gutter { font-size: 13px; }
|
||||
.y-key { color: var(--accent2); } .y-colon, .y-dash { color: var(--muted); } .y-string { color: var(--ok); } .y-number, .y-bool { color: var(--s2); } .y-comment { color: var(--muted); font-style: italic; } .y-anchor, .y-block { color: var(--accent); } .y-value { color: var(--fg); }
|
||||
/* a phone has no Tab key: a bar of the strokes a compose file needs, pinned to the editor's foot */
|
||||
.code-keys { position: absolute; left: 0; right: 0; bottom: 0; display: flex; gap: 6px; padding: 6px 8px; background: var(--bg2); border-top: 1px solid var(--border); z-index: 1; }
|
||||
.code-keys button { min-width: 40px; height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg3); color: var(--fg); font: 15px ui-monospace, monospace; }
|
||||
.code-keys ~ * { display: none; }
|
||||
@media (pointer: coarse) { .code .code-body { padding-bottom: 45px; } .code .code-gutter { padding-bottom: 55px; } }
|
||||
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } .stack-editor.code, .stack-editor .code-paint, .stack-editor .code-text, .stack-editor .code-gutter { font-size: 12px; } }
|
||||
|
||||
/* stacks: diff, images, .env */
|
||||
.stack-extra { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--border); padding-top: 10px; } .stack-extra .sub-head .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
@@ -636,6 +750,31 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.top-mem, .top-disks { min-height: 165px; } /* a grid row set to auto will otherwise squeeze them to nothing */
|
||||
.top-cpu { grid-column: 1 / -1; } .top-mem { grid-column: 1; grid-row: 2; } .top-disks { grid-column: 2; grid-row: 2; }
|
||||
.top-net { grid-column: 1 / 3; grid-row: 3; } .top-proc { grid-column: 3; grid-row: 2 / 4; }
|
||||
/* a machine with a GPU gets a row of gpu boxes under the cpu, as btop lays it out; the rest move down one */
|
||||
.top.has-gpu { grid-template-rows: minmax(200px, 1fr) minmax(210px, auto) auto minmax(170px, 1fr); }
|
||||
.top-gpus { grid-column: 1 / -1; grid-row: 2; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; min-width: 0; }
|
||||
.top.has-gpu .top-mem, .top.has-gpu .top-disks { grid-row: 3; } .top.has-gpu .top-net { grid-row: 4; } .top.has-gpu .top-proc { grid-row: 3 / 5; }
|
||||
.top-gpu { min-height: 210px; }
|
||||
/* a GPU with nothing to measure is one line, and on a machine with no other its row is only as
|
||||
tall as that line. Same weight as .top.has-gpu and after it, so the narrower layouts' own
|
||||
.top.has-gpu, further down, still wins there (their gpu row is already auto). */
|
||||
.top.gpu-slim { grid-template-rows: minmax(200px, 1fr) auto auto minmax(170px, 1fr); }
|
||||
.top-gpu.slim { align-self: start; } /* beside a full gpu box it keeps its one line, not that box's height */
|
||||
.t-row.t-gpu-line { flex-wrap: wrap; gap: 0 14px; } .t-gpu-line > span { white-space: nowrap; } .t-gpu-who { margin-left: auto; } /* on a phone it wraps to its own line, still on the right */
|
||||
.t-gpu { display: flex; gap: 8px; min-width: 0; flex: 1; min-height: 0; }
|
||||
/* the graph and its caption share this box: tall enough for both, so the caption never lands on the stats */
|
||||
.t-gpu-graph { flex: 1; min-width: 0; min-height: 130px; display: flex; flex-direction: column; }
|
||||
.t-gpu-stats { width: 310px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 3px; padding: 4px 6px; display: flex; flex-direction: column; gap: 1px; overflow-y: auto; scrollbar-width: thin; }
|
||||
.t-gpu-stats .t-core-n { width: 58px; } /* room for "enhance" — the cpu box's 30px fits "C15", not an engine's name */
|
||||
.t-gpu-name { color: var(--fg); overflow: hidden; text-overflow: ellipsis; max-width: 42vw; }
|
||||
.t-core-n.t-eng { color: var(--muted); font-size: 12px; }
|
||||
.t-gpu-facts { margin-top: 2px; } .t-gpu-facts .t-row { gap: 6px; } .t-fact { color: var(--fg); white-space: nowrap; }
|
||||
.t-gpu-facts .tm { flex: 1; }
|
||||
.t-gpu-clients { margin-top: 4px; border-top: 1px solid var(--border); padding-top: 2px; }
|
||||
.t-gpu-clients .t-thead, .t-crow { display: grid; grid-template-columns: 52px minmax(0, 1fr) 50px 46px; gap: 7px; align-items: center; white-space: nowrap; }
|
||||
.t-gpu-clients .t-thead { border-bottom: 0; padding-right: 0; }
|
||||
.t-crow > span { min-width: 0; overflow: hidden; } .t-crow .c-name { color: var(--ok); text-overflow: ellipsis; } .t-crow .c-cpu { color: var(--fg); }
|
||||
.t-crow-none { display: block; font-size: 12px; padding: 1px 0; }
|
||||
.tbox { position: relative; border: 1px solid var(--border); border-radius: 3px; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.tbox-in { flex: 1; min-height: 0; min-width: 0; padding: 9px 8px 6px; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.tbox-name, .tbox-right { position: absolute; top: -9px; background: var(--term-bg); padding: 0 4px; font-size: 12px; white-space: nowrap; z-index: 1; }
|
||||
@@ -655,12 +794,22 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.t-cores-head b { color: var(--fg); font-weight: 400; overflow: hidden; text-overflow: ellipsis; }
|
||||
.t-core { display: flex; align-items: center; gap: 6px; }
|
||||
.t-core-n { width: 30px; } .t-core-p { width: 34px; text-align: right; }
|
||||
/* a two-socket board: a row per socket — arrow, name, watts — with its threads indented under it */
|
||||
.t-cores.multi .t-core-n { width: 50px; } .t-cores.multi .t-core:not(.t-sock) .t-core-n { padding-left: 14px; box-sizing: border-box; }
|
||||
.t-sock { color: var(--fg); margin-top: 2px; cursor: pointer; user-select: none; }
|
||||
.t-fold { display: inline-block; width: 12px; color: var(--muted); }
|
||||
.t-core-w { width: 40px; flex-shrink: 0; text-align: right; color: var(--muted); }
|
||||
.t-core-g { flex: 1; color: var(--ok); letter-spacing: 0; overflow: hidden; white-space: nowrap; }
|
||||
.t-core .tm { flex: 1; } .t-core-t { flex: 0 0 62px; display: flex; } .t-core-c { width: 42px; text-align: right; color: var(--t-blue); }
|
||||
.t-load { display: flex; margin-top: 2px; }
|
||||
|
||||
/* the dot graph and the dotted meter */
|
||||
.tg { width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden; }
|
||||
/* The canvas is taken out of flow. Its drawing size is set from the box it sits in; if it also
|
||||
sat *in* that box and the box's height was ever content-sized, each resize would set a bigger
|
||||
canvas, which would make a bigger box, which would set a bigger canvas — the Processes tab
|
||||
growing without end, as it did when the gpu box landed in an auto-height row. */
|
||||
.tg { position: relative; width: 100%; height: 100%; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.tg canvas { position: absolute; inset: 0; }
|
||||
.tm { position: relative; display: inline-block; height: 9px; flex: 1; min-width: 24px; vertical-align: middle; }
|
||||
.tm-t, .tm-f { position: absolute; inset: 0; -webkit-mask-image: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 5px); mask-image: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 5px); }
|
||||
.tm-t { background: var(--border); }
|
||||
@@ -699,16 +848,22 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.t-prow .c-user { text-overflow: ellipsis; }
|
||||
.c-pid, .c-thr, .c-mem, .c-cpu { text-align: right; } .t-thead .c-pid, .t-thead .c-name, .t-thead .c-cmd, .t-thead .c-user { text-align: left; }
|
||||
.c-g { color: var(--muted); text-align: right; }
|
||||
/* the Gpu% column, on a machine that has one: dim at rest, lit on the rows that are using it */
|
||||
.c-gpu { text-align: right; color: var(--muted); } .c-gpu.lit { color: var(--t-magenta); }
|
||||
.t-proc.has-gpu .t-thead, .t-proc.has-gpu .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 56px 42px 42px; }
|
||||
@container tproc (max-width: 640px) {
|
||||
.t-thead, .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 42px; }
|
||||
.t-proc.has-gpu .t-thead, .t-proc.has-gpu .t-prow { grid-template-columns: 58px 116px minmax(0, 1fr) 62px 58px 50px 42px 42px; }
|
||||
.t-thead .c-g, .t-prow .c-g { display: none; }
|
||||
}
|
||||
@container tproc (max-width: 560px) {
|
||||
.t-thead, .t-prow { grid-template-columns: 58px 110px minmax(0, 1fr) 58px 50px 42px; }
|
||||
.t-proc.has-gpu .t-thead, .t-proc.has-gpu .t-prow { grid-template-columns: 58px 110px minmax(0, 1fr) 58px 50px 42px 42px; }
|
||||
.t-thead .c-thr, .t-prow .c-thr { display: none; }
|
||||
}
|
||||
@container tproc (max-width: 470px) {
|
||||
.t-thead, .t-prow { grid-template-columns: 52px minmax(0, 1fr) 52px 48px 40px; gap: 6px; }
|
||||
.t-proc.has-gpu .t-thead, .t-proc.has-gpu .t-prow { grid-template-columns: 52px minmax(0, 1fr) 52px 48px 40px 40px; }
|
||||
.t-thead .c-cmd, .t-prow .c-cmd { display: none; }
|
||||
}
|
||||
.t-foot { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--border); padding-top: 3px; margin-top: 2px; font-size: 12px; }
|
||||
@@ -721,15 +876,32 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.top-mem, .top-disks { min-height: 220px; }
|
||||
.top-cpu { grid-column: 1 / -1; grid-row: 1; } .top-mem { grid-column: 1; grid-row: 2; } .top-disks { grid-column: 2; grid-row: 2; }
|
||||
.top-net { grid-column: 1 / -1; grid-row: 3; } .top-proc { grid-column: 1 / -1; grid-row: 4; }
|
||||
.top.has-gpu { grid-template-rows: minmax(230px, auto) auto minmax(220px, auto) minmax(200px, auto) minmax(420px, 1fr); }
|
||||
.top.has-gpu .top-mem, .top.has-gpu .top-disks { grid-row: 3; } .top.has-gpu .top-net { grid-row: 4; } .top.has-gpu .top-proc { grid-row: 5; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.top { display: flex; flex-direction: column; font-size: 12px; }
|
||||
.top > .tbox { flex: 0 0 auto; }
|
||||
.top-gpus { display: flex; flex-direction: column; flex: 0 0 auto; } .top-gpu.slim { align-self: stretch; }
|
||||
.t-gpu { flex-direction: column; } .t-gpu-graph { height: 130px; flex: 0 0 130px; } .t-gpu-stats { width: auto; max-height: none; }
|
||||
.t-cpu { flex-direction: column; } .t-cpu-graph { height: 130px; flex: 0 0 130px; } .t-cores { width: auto; max-height: 40vh; }
|
||||
.t-net { flex-direction: column; } .t-net-nums { width: auto; } .t-net-graphs { flex: 0 0 130px; }
|
||||
.top-proc { height: 70vh; min-height: 420px; }
|
||||
}
|
||||
|
||||
/* ---- ZFS: the ARC card and each pool's caching tiers ---- */
|
||||
.arc-sparks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 8px; } .arc-sparks .small { margin-bottom: 2px; }
|
||||
@media (max-width: 640px) { .arc-sparks { grid-template-columns: 1fr; } }
|
||||
.arc-break { display: grid; grid-template-columns: minmax(140px, 1fr) 80px 110px 110px; gap: 2px 10px; font-size: 12px; margin-top: 10px; }
|
||||
.arc-break .head { color: var(--muted); } .arc-break .num { text-align: right; }
|
||||
@media (max-width: 640px) { .arc-break { grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 2px 8px; font-size: 11px; } .arc-break > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } }
|
||||
.arc-tiers { display: flex; flex-direction: column; gap: 5px; font-size: 13px; margin-top: 4px; }
|
||||
.arc-tier { display: flex; gap: 8px; min-width: 0; } .arc-tier .k { color: var(--muted); width: 80px; flex-shrink: 0; cursor: help; }
|
||||
.arc-tier-v { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .arc-tier-v > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.arc-tier-v .none { color: var(--muted); white-space: normal; }
|
||||
.landing-tier { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 4px 0 6px; font-size: 13px; }
|
||||
.landing-tier .props .badge { margin-right: 2px; }
|
||||
|
||||
/* ---- Space: what is using a disk ---- */
|
||||
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||
.space-bar { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); }
|
||||
@@ -751,6 +923,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.space .warn { color: var(--s2); }
|
||||
.space-tabs { padding: 8px 12px 0; }
|
||||
.space-split { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 8px 12px 12px; gap: 8px; }
|
||||
@media (max-width: 800px) { .space-split { overflow-y: auto; padding: 8px; } .space-tree { flex: 0 0 auto; height: 40vh; } .space-map { flex: 0 0 auto; height: 60vh; } }
|
||||
.space-tree { flex: 1 1 55%; min-height: 120px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: var(--bg2); overflow: hidden; }
|
||||
.space-rows { flex: 1; overflow: auto; min-height: 0; }
|
||||
.space-row { display: grid; grid-template-columns: minmax(160px, 1fr) 84px 120px 70px 64px 88px; gap: 8px; align-items: center; padding: 3px 8px; font-size: 13px; border-bottom: 1px solid var(--border); cursor: pointer; white-space: nowrap; }
|
||||
@@ -795,20 +968,33 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||
.modal .chips { margin-top: 2px; }
|
||||
|
||||
/* ---- KVM: a device's own web page, framed ---- */
|
||||
.kvm { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||
.kvm-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-wrap: wrap; }
|
||||
.kvm-bar .kvm-url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 28ch; }
|
||||
.kvm-frame { flex: 1; min-height: 0; background: #000; position: relative; }
|
||||
.kvm-frame iframe { width: 100%; height: 100%; border: 0; display: block; background: #000; }
|
||||
.kvm-frame.full { background: #000; }
|
||||
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
||||
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
||||
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
|
||||
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
|
||||
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; }
|
||||
.kvm-frame.expanded iframe { height: 100%; }
|
||||
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
|
||||
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
||||
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
||||
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
||||
|
||||
/* a phone: the time goes above the seek bar, so the buttons fit in one row */
|
||||
.vp-time.above { display: none; }
|
||||
@media (max-width: 560px) {
|
||||
.vp-time.above { display: block; padding: 0 10px 2px; }
|
||||
.vp-time.inrow { display: none; }
|
||||
}
|
||||
|
||||
|
||||
/* No theme touches playback, whatever it draws. A theme's full-window overlays (the Retro
|
||||
scanlines today, any other tomorrow) are off while a player is open; the picture is never
|
||||
filtered or blended; the player's text is plain, not the theme's lettering. Only the accent
|
||||
colour of its controls follows the theme. */
|
||||
html:has(.vp)::before, html:has(.vp)::after, body:has(.vp)::before, body:has(.vp)::after,
|
||||
.app:has(.vp)::before, .app:has(.vp)::after, #root:has(.vp)::before, #root:has(.vp)::after { display: none !important; }
|
||||
.vp { isolation: isolate; filter: none !important; mix-blend-mode: normal !important; }
|
||||
.vp video, .vp canvas { filter: none !important; mix-blend-mode: normal !important; }
|
||||
.vp { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-weight: 400; letter-spacing: normal; text-shadow: none; }
|
||||
.vp button, .vp input, .vp select { font-family: inherit !important; }
|
||||
|
||||
/* volume: the speaker mutes; the slider beside it (not on iOS, which only mutes) sets the level */
|
||||
.vp-vol { display: inline-flex; align-items: center; }
|
||||
.vp-vol input { width: 72px; height: 24px; margin: 0 6px 0 0; padding: 0; background: transparent; border: 0; -webkit-appearance: none; appearance: none; cursor: pointer; }
|
||||
.vp-vol input::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: linear-gradient(to right, #fff var(--v), rgba(255, 255, 255, .3) var(--v)); }
|
||||
.vp-vol input::-moz-range-track { height: 4px; border-radius: 2px; background: linear-gradient(to right, #fff var(--v), rgba(255, 255, 255, .3) var(--v)); }
|
||||
.vp-vol input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; margin-top: -4px; border-radius: 50%; background: #fff; border: 0; }
|
||||
.vp-vol input::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #fff; border: 0; }
|
||||
@media (max-width: 560px) { .vp-vol input { display: none; } }
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
// The 360° view: an equirectangular video drawn onto a canvas as the flat picture a person
|
||||
// would see looking out from the middle of it. Drag (or swipe) to look around, pinch or scroll
|
||||
// to zoom, and on a phone, optionally, turn the phone. One full-canvas triangle pair; the
|
||||
// fragment shader works out, for each pixel, which way it looks and where that is in the video.
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 a;
|
||||
varying vec2 p;
|
||||
void main() { p = a; gl_Position = vec4(a, 0.0, 1.0); }`
|
||||
|
||||
const FRAG = `
|
||||
precision highp float;
|
||||
uniform sampler2D tex;
|
||||
uniform mat3 view; // columns: right, up, forward
|
||||
uniform vec2 aspect; // the screen's sides over its taller side: (w/h, 1) or (1, h/w)
|
||||
uniform float fov; // radians, across the taller side
|
||||
varying vec2 p;
|
||||
const float PI = 3.14159265358979;
|
||||
void main() {
|
||||
vec2 q = p * aspect;
|
||||
// Narrow: an ordinary lens. Wide: stereographic, which goes on past 180 degrees to the
|
||||
// "little planet". Between 100 and 140 degrees one turns into the other.
|
||||
vec3 rect = normalize(vec3(q * tan(min(fov, 3.0) * 0.5), 1.0));
|
||||
float r = length(q) * 2.0 * tan(fov * 0.25);
|
||||
float th = 2.0 * atan(r * 0.5);
|
||||
vec2 dir = r > 0.0 ? q / length(q) : vec2(0.0);
|
||||
vec3 stereo = vec3(dir * sin(th), cos(th));
|
||||
float t = smoothstep(1.745, 2.443, fov);
|
||||
vec3 d = normalize(view * normalize(mix(rect, stereo, t)));
|
||||
float lon = atan(d.x, -d.z);
|
||||
float lat = asin(clamp(d.y, -1.0, 1.0));
|
||||
gl_FragColor = texture2D(tex, vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI));
|
||||
}`
|
||||
|
||||
// From a close look at something far off to the whole sphere as a little planet.
|
||||
const MIN_FOV = 8, MAX_FOV = 300
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
type Quat = [number, number, number, number] // x, y, z, w
|
||||
|
||||
function qmul(a: Quat, b: Quat): Quat {
|
||||
const [ax, ay, az, aw] = a, [bx, by, bz, bw] = b
|
||||
return [aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz]
|
||||
}
|
||||
function qrot(q: Quat, v: Vec3): Vec3 {
|
||||
const [x, y, z, w] = q
|
||||
const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2]
|
||||
return [ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x]
|
||||
}
|
||||
const axis = (v: Vec3, rad: number): Quat => { const s = Math.sin(rad / 2); return [v[0] * s, v[1] * s, v[2] * s, Math.cos(rad / 2)] }
|
||||
|
||||
// The phone's orientation as a camera's, the way the W3C spec defines the angles: Z-X'-Y''
|
||||
// intrinsic, then turned to look out of the back of the phone, then for the screen's rotation.
|
||||
function deviceQuat(alpha: number, beta: number, gamma: number, screen: number): Quat {
|
||||
const d = Math.PI / 180
|
||||
const [a, b, g] = [alpha * d, beta * d, gamma * d]
|
||||
let q = qmul(qmul(axis([0, 1, 0], a), axis([1, 0, 0], b)), axis([0, 0, 1], -g)) // YXZ, in three.js's frame
|
||||
q = qmul(q, [-Math.SQRT1_2, 0, 0, Math.SQRT1_2])
|
||||
return qmul(q, axis([0, 0, 1], -screen * d))
|
||||
}
|
||||
|
||||
export class Sphere {
|
||||
yaw = 0 // degrees, + is right
|
||||
pitch = 0 // degrees, + is up
|
||||
fov = 80 // degrees, the taller of the two sides
|
||||
private gl: WebGLRenderingContext
|
||||
private tex: WebGLTexture
|
||||
private uView: WebGLUniformLocation
|
||||
private uAspect: WebGLUniformLocation
|
||||
private uFov: WebGLUniformLocation
|
||||
private raf = 0
|
||||
private frameCb = 0
|
||||
private dirty = true
|
||||
private hasFrame = false
|
||||
private gyro: { alpha: number; beta: number; gamma: number } | null = null
|
||||
private gyroYaw = 0 // what dragging adds to the phone's own heading
|
||||
private ro: ResizeObserver
|
||||
maxTexture: number
|
||||
|
||||
constructor(private canvas: HTMLCanvasElement, private video: HTMLVideoElement) {
|
||||
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, preserveDrawingBuffer: false })
|
||||
if (!gl) throw new Error('This browser can’t draw 360° video (no WebGL).')
|
||||
this.gl = gl
|
||||
this.maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
|
||||
const sh = (type: number, src: string) => {
|
||||
const s = gl.createShader(type)!
|
||||
gl.shaderSource(s, src); gl.compileShader(s)
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'shader')
|
||||
return s
|
||||
}
|
||||
const prog = gl.createProgram()!
|
||||
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG))
|
||||
gl.linkProgram(prog); gl.useProgram(prog)
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
|
||||
const loc = gl.getAttribLocation(prog, 'a')
|
||||
gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
|
||||
this.tex = gl.createTexture()!
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex)
|
||||
// No mipmaps (a video changes every frame) and clamped: any size of picture will do.
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0]))
|
||||
this.uView = gl.getUniformLocation(prog, 'view')!
|
||||
this.uAspect = gl.getUniformLocation(prog, 'aspect')!
|
||||
this.uFov = gl.getUniformLocation(prog, 'fov')!
|
||||
this.ro = new ResizeObserver(() => { this.dirty = true })
|
||||
this.ro.observe(canvas)
|
||||
this.watchFrames()
|
||||
const loop = () => { this.draw(); this.raf = requestAnimationFrame(loop) }
|
||||
this.raf = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
// A new picture from the video: on the frame callback where there is one, else every draw
|
||||
// while it plays.
|
||||
private watchFrames() {
|
||||
const v = this.video as HTMLVideoElement & { requestVideoFrameCallback?: (cb: () => void) => number }
|
||||
if (v.requestVideoFrameCallback) {
|
||||
const cb = () => { this.upload(); this.frameCb = v.requestVideoFrameCallback!(cb) }
|
||||
this.frameCb = v.requestVideoFrameCallback(cb)
|
||||
}
|
||||
v.addEventListener('loadeddata', this.upload)
|
||||
v.addEventListener('seeked', this.upload)
|
||||
}
|
||||
|
||||
private upload = () => {
|
||||
const v = this.video
|
||||
if (v.readyState < 2 || !v.videoWidth) return
|
||||
const gl = this.gl
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex)
|
||||
try {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, v)
|
||||
this.hasFrame = true
|
||||
this.dirty = true
|
||||
} catch { /* not ready yet */ }
|
||||
}
|
||||
|
||||
private draw() {
|
||||
const v = this.video as HTMLVideoElement & { requestVideoFrameCallback?: unknown }
|
||||
if (!v.requestVideoFrameCallback && !v.paused) this.upload()
|
||||
if (!this.dirty && !this.gyro) return
|
||||
this.dirty = false
|
||||
const c = this.canvas, dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const w = Math.round(c.clientWidth * dpr), h = Math.round(c.clientHeight * dpr)
|
||||
if (!w || !h) return
|
||||
if (c.width !== w || c.height !== h) { c.width = w; c.height = h }
|
||||
const gl = this.gl
|
||||
gl.viewport(0, 0, w, h)
|
||||
if (!this.hasFrame) { gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); return }
|
||||
// The field of view applies to the taller side; on a phone held upright that is the height.
|
||||
let right: Vec3, up: Vec3, fwd: Vec3
|
||||
if (this.gyro) {
|
||||
const q = qmul(axis([0, 1, 0], (-this.gyroYaw * Math.PI) / 180), deviceQuat(this.gyro.alpha, this.gyro.beta, this.gyro.gamma, screenAngle()))
|
||||
right = qrot(q, [1, 0, 0]); up = qrot(q, [0, 1, 0]); fwd = qrot(q, [0, 0, -1])
|
||||
} else {
|
||||
const y = (this.yaw * Math.PI) / 180, p = (this.pitch * Math.PI) / 180
|
||||
fwd = [Math.sin(y) * Math.cos(p), Math.sin(p), -Math.cos(y) * Math.cos(p)]
|
||||
right = [Math.cos(y), 0, Math.sin(y)]
|
||||
up = [right[1] * fwd[2] - right[2] * fwd[1], right[2] * fwd[0] - right[0] * fwd[2], right[0] * fwd[1] - right[1] * fwd[0]]
|
||||
}
|
||||
// d = right·x + up·y + fwd·1, column-major
|
||||
gl.uniformMatrix3fv(this.uView, false, new Float32Array([...right, ...up, ...fwd]))
|
||||
gl.uniform2f(this.uAspect, w >= h ? 1 : w / h, w >= h ? h / w : 1)
|
||||
gl.uniform1f(this.uFov, (this.fov * Math.PI) / 180)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6)
|
||||
}
|
||||
|
||||
// Dragging by a pixel turns the view by the angle a pixel covers, so what is under the finger
|
||||
// stays under it.
|
||||
drag(dx: number, dy: number) {
|
||||
const per = this.fov / Math.max(this.canvas.clientWidth, this.canvas.clientHeight)
|
||||
if (this.gyro) this.gyroYaw = (this.gyroYaw - dx * per) % 360
|
||||
else {
|
||||
this.yaw = (this.yaw - dx * per) % 360
|
||||
this.pitch = Math.max(-89, Math.min(89, this.pitch + dy * per))
|
||||
}
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
zoom(factor: number) {
|
||||
this.fov = Math.max(MIN_FOV, Math.min(MAX_FOV, this.fov * factor))
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
get gyroOn() { return this.gyro !== null }
|
||||
|
||||
private onOrient = (e: DeviceOrientationEvent) => {
|
||||
if (e.alpha === null || e.beta === null || e.gamma === null) return
|
||||
if (!this.gyro) {
|
||||
// Keep looking where the drag left off: the phone's heading now is the drag's yaw.
|
||||
const q = deviceQuat(e.alpha, e.beta, e.gamma, screenAngle())
|
||||
const f = qrot(q, [0, 0, -1])
|
||||
this.gyroYaw = this.yaw - (Math.atan2(f[0], -f[2]) * 180) / Math.PI
|
||||
}
|
||||
this.gyro = { alpha: e.alpha, beta: e.beta, gamma: e.gamma }
|
||||
}
|
||||
|
||||
// Turning on motion asks for permission on an iPhone, which must happen in a tap.
|
||||
async setGyro(on: boolean): Promise<boolean> {
|
||||
if (!on) {
|
||||
window.removeEventListener('deviceorientation', this.onOrient)
|
||||
if (this.gyro) {
|
||||
const q = qmul(axis([0, 1, 0], (-this.gyroYaw * Math.PI) / 180), deviceQuat(this.gyro.alpha, this.gyro.beta, this.gyro.gamma, screenAngle()))
|
||||
const f = qrot(q, [0, 0, -1])
|
||||
this.yaw = (Math.atan2(f[0], -f[2]) * 180) / Math.PI
|
||||
this.pitch = (Math.asin(Math.max(-1, Math.min(1, f[1]))) * 180) / Math.PI
|
||||
}
|
||||
this.gyro = null
|
||||
this.dirty = true
|
||||
return false
|
||||
}
|
||||
const D = DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<string> }
|
||||
if (D.requestPermission) {
|
||||
try { if ((await D.requestPermission()) !== 'granted') return false } catch { return false }
|
||||
}
|
||||
window.addEventListener('deviceorientation', this.onOrient)
|
||||
return true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
cancelAnimationFrame(this.raf)
|
||||
const v = this.video as HTMLVideoElement & { cancelVideoFrameCallback?: (n: number) => void }
|
||||
if (this.frameCb && v.cancelVideoFrameCallback) v.cancelVideoFrameCallback(this.frameCb)
|
||||
v.removeEventListener('loadeddata', this.upload)
|
||||
v.removeEventListener('seeked', this.upload)
|
||||
window.removeEventListener('deviceorientation', this.onOrient)
|
||||
this.ro.disconnect()
|
||||
this.gl.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
}
|
||||
}
|
||||
|
||||
function screenAngle(): number {
|
||||
const o = screen.orientation?.angle
|
||||
if (typeof o === 'number') return o
|
||||
return (window as unknown as { orientation?: number }).orientation ?? 0
|
||||
}
|
||||
|
||||
export const canMotion = () => typeof DeviceOrientationEvent !== 'undefined' && matchMedia('(pointer: coarse)').matches
|
||||
Reference in New Issue
Block a user