55 Commits
Author SHA1 Message Date
Mitchell-JohnsonandClaude Opus 5.5 afd4688358 GPUs: fan speed in % per card (nvidia-smi fan.speed, hwmon pwm1); 0% shows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 17:09:10 -08:00
Mitchell-JohnsonandClaude Opus 5.5 95bb026b7f Docs: Ham Lab on core is lab.bigham.dev (was hamlab.bigham.dev)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:47:48 -08:00
Mitchell-JohnsonandClaude Opus 5.5 a6d7ff436f Drive tile counts each source device once (a bind mount doubled nexus's NVMe)
fs.* sensors carry a source label; Top.tsx dedupes by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 00:06:33 -08:00
Mitchell-JohnsonandClaude Opus 5.5 574d9855ce GPU: a slow read is late, not stalled; a stall clears when the read returns
nvidia-smi on a busy card can take past the 3 s deadline, which latched the card
as stalled until restart. A late read now carries the last readings and is used
when it lands; no second read starts while one is out; only one out for 15 s
(past both nvidia-smi timeouts) marks the card stalled, and its return clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:42:13 -08:00
Mitchell-JohnsonandClaude Opus 5.5 ad576d8c1a Top: drop the GPU busy caption; keep only the stall warning
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:55:57 -08:00
Mitchell-JohnsonandClaude c40de5067b Top: each CPU's threads are counted from 0 under it on a two-socket machine
The kernel numbers the whole machine's threads in one run and deals them out a
socket at a time, twice: nexus's first CPU listed C0–C9 and C20–C29, its second
C10–C19 and C30–C39. Each CPU now lists its own twenty as C0–C19; the kernel's
number is in the row's tooltip.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 12:19:49 -08:00
Mitchell-JohnsonandClaude Opus 5.5 0e57419d8b Top: the second of two identical GPUs draws its busy graph
Its sensors were gpu.<name>.usage_2 and so on; the page looks under gpu.<name>_2. Each
card now gets its own stem, as the page already assumed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:40:56 -08:00
Mitchell-JohnsonandClaude Opus 5.5 f1c66ce886 Top: the second CPU's core temperatures on a two-socket machine
The sensor feed dropped which socket a coretemp reading came from, so every core matched
the first CPU's chip. coretemp sensors now carry a package label, and history folding
keeps the two sockets' same-named cores apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:22:05 -08:00
Mitchell-JohnsonandClaude Opus 5.5 a516cf4cad Top: a two-socket machine shows only its two CPUs, no combined row or wattage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:09:09 -08:00
Mitchell-JohnsonandClaude Opus 5.5 e9a87af5e8 Top: each CPU's own wattage on a two-socket machine, its threads folded under it
Per-package RAPL watts in the sample, the process table and as cpu.package.N.power
sensors. The socket rows collapse with an arrow; the fold is remembered per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:07:24 -08:00
Mitchell-JohnsonandClaude Opus 5.5 1e2dcacb2c Two-socket machines: Hardware and Top show each CPU, not one with every core
Hardware counts CPUs, cores and threads per CPU. Top groups threads under a row
per socket, reads each core's temperature from its own socket's coretemp chip,
and the CPU temperature is the hotter package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:04:47 -08:00
Mitchell-JohnsonandClaude Opus 5.5 4b238ea93e Remove the KVM tab: proxy, routes, config and state fields, UI
Not needed — the KVMs are reached over WireGuard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:52:38 -08:00
Mitchell-JohnsonandClaude Opus 5.5 5edad2de6a Files viewer: previous/next — buttons, ← →, swipe on pictures; steps through media when media is open; audio autoplays
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:27:26 -07:00
Mitchell-JohnsonandClaude Opus 5.5 829617c932 ZFS tab: landing tier checks — stale files, verify passes against the mover's ledger, verify schedules
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 (its ledger, named in its status file): match, mismatch, edited
since, gone, waiting, unreadable, unhashed. 'New moves' checks what moved since the last pass,
'everything' the whole ledger. Passes run as 'hamlab landing-verify' in their own session — the
same command a schedule's line in /etc/cron.d/hamlab-landing runs — so a console restart does
not cut one short. Stale files: anything waiting on the landing branch over an hour, listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:16:55 -07:00
Mitchell-JohnsonandClaude Opus 5.5 f518df239f SENSORS: the ZFS write path's sensors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:41:23 -07:00
Mitchell-JohnsonandClaude Opus 5.5 41cb734f7f ZFS tab: show a mover's rsync limit only when there is one, in the units rsync means (60M = 60 MiB/s)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:39:33 -07:00
Mitchell-JohnsonandClaude Opus 5.5 0698f07415 ZFS tab: the write path's details one per row, wrapping instead of cut off
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:37:22 -07:00
Mitchell-JohnsonandClaude Opus 5.5 b2528afe2a ZFS tab: snapshot schedules full width too — every box on the tab spans the page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:34:55 -07:00
Mitchell-JohnsonandClaude Opus 5.5 50768e2d94 ZFS tab: pools, caching tiers and writes full width; write and drain rates measured between real updates
A txg records its bytes only when it syncs and the mover reports every 2 s, so a per-sample delta
read 0 most of the time. Rates are now taken over the gap between syncs (or mover updates) and held
until the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:34:11 -07:00
Mitchell-JohnsonandClaude Opus 5.5 86cfcd2bad ZFS tab: Writes — the RAM write buffer, each pool's write rate, and a landing tier with its mover
Live from arcstats (dirty), dmu_tx (throttled writes), each pool's txgs (bytes synced to disk)
and objset kstats (bytes from programs). A landing tier — a mergerfs pool whose first branch is
fast storage and whose last is a ZFS dataset — is detected from mergerfs's branch xattr; a mover
status file in /run/landing-mover/ adds its queue, the file in flight and totals. Sensors
zfs.write.*, zfs.<pool>.write_bps|logical_bps and zfs.<pool>.landing.* carry the history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:28:40 -07:00
Mitchell-JohnsonandClaude Opus 5.5 1a9b31ea0a PLAN: handle fuse.mergerfs (grid's landing pool) in Storage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:01:59 -07:00
Mitchell-JohnsonandClaude Opus 5.5 695e4c4f57 e2e-player: the volume slider is only there wider than a phone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:49:00 -07:00
Mitchell-JohnsonandClaude Opus 5.5 432ddc0df4 files: the player's volume — mute, a level slider, remembered (as Ham Share's)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:43:05 -07:00
Mitchell-JohnsonandClaude Opus 5.5 1c5cd2b0de No theme touches playback, any theme; the header clears the iOS status-bar blur
Every theme's full-window overlays are off while a video is open, the picture
is never filtered or blended, the player's lettering is plain. --blur, as in To
Do and Notes: the header (the top row of the app), dialogs, the full-screen
player's note and an expanded KVM start below iOS 26's scroll-edge blur in a
Home Screen app; zero on desktop and in a Safari tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:05:04 -07:00
Mitchell-JohnsonandClaude Opus 5.5 01e0da9022 files: no theme touches playback — Retro's scanlines off while a video is open, plain font
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:02:15 -07:00
Mitchell-JohnsonandClaude Opus 5.5 dbf89cd5e2 files: on a phone the player's time sits above the seek bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:52:35 -07:00
Mitchell-JohnsonandClaude Opus 5.5 033343793f files: the player's −5 s and +5 s buttons, as Ham Share's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:48:37 -07:00
Mitchell-JohnsonandClaude Opus 5.5 f2997a04c8 Settings, PLAN: video previews follow the Transcode device
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:09:28 -07:00
Mitchell-JohnsonandClaude Opus 5.5 fac8a3a180 files: the previews button says where video previews are made
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:08:45 -07:00
Mitchell-JohnsonandClaude Opus 5.5 d5c16540a1 files: video previews are made on the Transcode device too
The setting's device makes every video preview: on the file's own machine
when it is there (the hub tells it by header), else on the device's machine,
which reads the file through the hub by a one-file token, and the preview is
kept in the file's machine's own cache (PUT /api/files/thumb, refused if the
file changed). Images and covers stay on the file's machine's CPU. If the
device's machine can't, the file's makes it as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:07:00 -07:00
Mitchell-JohnsonandClaude Opus 5.5 1464e1f7a0 files: seek bar — the ball goes where the seek went and waits there; the seek is on release
Same fix as Ham Share's: a trailing change event read the bar's old position
back as a drag in progress.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:11:31 -07:00
Mitchell-JohnsonandClaude Opus 5.5 d1b3ef7efd PLAN: the Ham Share player and the Transcode device setting; e2e-player.mjs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:00:32 -07:00
Mitchell-JohnsonandClaude Opus 5.5 8c69826547 files: the player turns a phone's picture upright on the GPU, and shows HLG as it is
tonemap_vaapi refuses HLG, and the hardware path never honoured a rotation
(ffmpeg's autorotate is for frames in memory). HDR10 is still tone-mapped,
on the card and, failing that, in software before the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:56:16 -07:00
Mitchell-JohnsonandClaude Opus 5.5 fc4126a0ed files: Ham Share's player — Auto quality, any by hand, or the file; 360 view; Transcode device setting
Every video plays YouTube's way: hls.js reads the connection and picks the
quality, or one is picked by hand, or Original plays the file as it is. The
qualities (a ladder by size, H.264) are made on the fly in 3 s pieces from
wherever the viewer seeks, one ffmpeg per play, paused when far enough ahead.
360 videos open in a look-around view (drag, pinch or scroll to zoom from
8 to 300 degrees, phone motion).

Settings -> Transcode device picks the machine and device that makes them,
for every machine's files: a machine transcoding another's file reads it
through the hub by a token for that one file. Replaces the codec/size picker
and the single-quality re-encoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:52:35 -07:00
Mitchell-JohnsonandClaude Opus 5.5 f5f1cd79ba Top: curves and canvases update before paint, in the numbers' frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:16:28 -07:00
Mitchell-JohnsonandClaude Opus 5.5 70bebcb18b Top: one beat — graphs, numbers and the process table change on the same tick
Graphs stepped once a history point (1 s at a 5 min window) while the numbers changed every
tick; they now slide a fraction of a column each tick. The process table (and the GPU boxes,
MHz and watts it carries) was polled on its own timer; it is now read per sample and committed
with it. GPU clients are walked every sample, and the 1–2 s floors on the gallery, Network,
Docker and ZFS ARC live views are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:13:19 -07:00
Mitchell-JohnsonandClaude Fable 5.1 8fc71496f0 metrics: NVIDIA GPUs via nvidia-smi — device busy, VRAM, clocks, temp, power and the pmon process table
The proprietary driver's DRM card says DRIVER=nvidia, which the driver list did not
know, so the card was never enumerated; and it exposes no sysfs busy counter or
fdinfo engine totals. Read it with nvidia-smi instead (--query-gpu per sample,
pmon -c 1 -s um for clients), by PCI address. Parsers unit-tested, including an
older pmon column layout and [N/A] fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 00:29:46 -07:00
Mitchell-JohnsonandClaude Opus 5.5 450a9d3327 files: seek anywhere in a re-encode; it restarts from there
A re-encode's playlist is now the whole film on a fixed 4 s grid. A segment
that isn't on disk and isn't coming soon moves the encode there; runs stop
short of segments already made, and gaps are filled after. Every run is cut
the same way (-ss, -output_ts_offset start+10, frag_discont), so segments
from different runs join. Repackaging keeps its growing playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:08:52 -07:00
Mitchell-JohnsonandClaude Opus 5.5 ff1373e838 docker: an update clears "update available" without a page reload
The registry's answer was cached and served as-is, so the stack view and the
machine-wide list kept calling an image behind after an update had pulled it.
The cache now keeps the digest the registry named, and an image marked behind
is re-checked locally on every read: once this machine holds that digest it
is current, at no registry cost. The page re-reads the machine-wide answer
after a stack task, a queued update, or a schedule run.

Also: stack keys can no longer collide with the machine key, and the dead
"/"+stack forget in the schedule runner is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:45:47 -07:00
Mitchell-JohnsonandClaude Opus 5.5 d70ddb6d67 Cut virtualization from the app: out of scope
Ham Lab is meant to be lightweight; VMs, passthrough and their consoles
are too much. Removes the Virtualization tab, the incus package, the
/api/virt routes, the instance.* sensors and their alert default, the
virt-export command, the VGA console and the vendored spice-html5, and
VIRTUALIZATION.md. A saved instance-down rule is dropped on load.

Everything removed is kept, as it was, on the virtualization branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:47:48 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a2674bea6a thumbs: a GPU-access run already in flight refuses a second (409)
The sweep's battery hit the route three times; the second and third runs only collided
with the first's restart timer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 18:00:39 -07:00
Mitchell-JohnsonandClaude Fable 5.1 134bbc991e PLAN.md: the whole-console sweep
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 17:56:16 -07:00
Mitchell-JohnsonandClaude Fable 5.1 285278abdb A sweep of the whole console: status codes, a data race, atomic writes, dead code
From the race detector, staticcheck, a garbage-in battery over every safe route and a
headless pass through every tab on two machines. Fixed:
- docker inspect/health of a container that isn't there answered 500; now 404, and a
  daemon that is down 503 (dockerErr, with a test).
- /api/virt/instances/{name} on a machine without Incus answered 502 "incus not found";
  it now says Incus isn't ready, like its siblings.
- Deleting a ZFS snapshot with a bad name answered 500; a bad name is 400, a snapshot
  that doesn't exist 404.
- The task list read a task's Running flag without the task's lock while the waiter
  goroutine cleared it: a data race, now read under the lock.
- The Virtualization export config and run record were written in place; a crash
  mid-write would have left a half file. Atomic now, like the config and state files.
- An unused function in kvm.go, one in incus/subnet.go, an unused field in files/move.go
  and a capitalised error string, from staticcheck.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 17:52:18 -07:00
Mitchell-JohnsonandClaude Fable 5.1 05161bf7ef PLAN.md: the Files edge sweep
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 20:32:34 -07:00
Mitchell-JohnsonandClaude Fable 5.1 1038c010fa Files: a sweep of the edges
Found by walking every handler and the UI with odd inputs, then a battery of requests
against the live hub. Fixed:
- A save over the 8 MiB cap was cut short in place: the file was truncated, then the
  first 8 MiB written, and the caller told 204. Now the body is read whole first and a
  body over the cap is a 413 with the file untouched.
- A link to a file showed the link's own time; now the file's, so the row reads right
  and the preview key follows the file.
- Reading a link to nothing answered 200 with an empty body (the open failed after the
  headers in root mode); now a 404 that names the target.
- A play request with an unknown codec or size was answered by inspecting the file
  first, so an audio-only file said "no video stream" instead of what was wrong; the
  choice is checked before any work, and is a 400.
- A stream that ended in failure was handed back to the next identical request for ten
  minutes; a failed one is now dropped and a fresh one made.
- A player that left while its stream was still finding a first segment made the loop
  try the next recipe; it stops. A stream closed mid-start is not started again.
- Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's
  processes, so the wait is bounded and the helper's own grace does the rest.
- A page going away (a phone closing the tab) releases its stream by a beacon, rather
  than ten minutes later by the sweeper.
- The path box resolves like a shell — relative to the folder shown, . and .. applied,
  trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path
  that is a file opens it; a remembered folder that is gone falls back to / once.
- A dropped folder arrived as a nameless empty file; it is refused with a word. The
  upload toast counts what was actually uploaded.
- The viewer names a device, pipe or socket, and a dangling link, instead of calling
  them binary files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 20:31:10 -07:00
Mitchell-JohnsonandClaude Fable 5.1 d200e5dc2e PLAN.md: re-encoding for the browser, on the machine's GPU
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 17:46:35 -07:00
Mitchell-JohnsonandClaude Fable 5.1 f3c547adfa Files: re-encode a video the browser can't play, on the machine's own GPU, and play that
The viewer tries the file as it is first. When the browser gives up — Safari and an MKV,
HEVC without a hardware decoder — or on a tap, a picker: codec (H.264, HEVC, AV1, or the
original codec repackaged) and size (original, 1080p, 720p, 480p), each codec labelled
with the engine this machine would use. POST /api/files/play runs ffmpeg into an HLS
event playlist of fragmented-MP4 segments under <data dir>/transcode/<id>/; Safari plays
HLS itself and everything else goes through hls.js, loaded only then. The recipes are
tried best first: a card on the bus before the integrated GPU, decoding on the same card
before decoding on the CPU and uploading, the software encoders last; a recipe that
produces no first segment is dropped for the next. The seekable range grows as ffmpeg
gets ahead; status says how far. Streams are reused for the same request, closed when
the player goes, swept after ten idle minutes, and the folder is emptied at start. Root
mode runs ffmpeg as root through a helper that ends when the daemon lets go of it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 17:42:25 -07:00
Mitchell-JohnsonandClaude Fable 5.1 aa4ae57c22 gofmt after the rename
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:54:52 -07:00
Mitchell-JohnsonandClaude Fable 5.1 af189b1fa1 internal/thumbs is internal/media, with Inspect: what a file holds, from ffprobe
The package was named for its first job; it now also decides what a file is for the
Files tab (image, video, audio) and reads its streams, which the viewer will need. Inspect
is the richer probe — codec, profile, pixel format, size, frame rate, HDR transfer, the
audio beside it, container and duration — run as root through sudo when asked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:54:15 -07:00
Mitchell-JohnsonandClaude Fable 5.1 beec9269fe PLAN.md: video and audio playback in the Files viewer, as the file is
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:43:03 -07:00
Mitchell-JohnsonandClaude Fable 5.1 d7f56ee0b3 Files: a seek costs nothing; the backend reopens only when a read wants somewhere else
ServeContent seeks to the end for the size before it seeks to the range, which was
throwing away the stream Check had just opened.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:41:54 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a9bcb88212 Files: play video and audio in the viewer, as the file is; reads answer Range requests
The viewer hands a video or audio file to the browser untouched — container, codec and
all — and the browser plays what it can; when it can't, the viewer says so and offers the
download. What the server adds is the part a player needs: /api/files/read now goes
through http.ServeContent for a real file of known size, so Range requests (seeking) and
HEAD work, and a download can resume. The root backend's helper takes an offset for that,
and a Seeker opens the backend once, at the range asked for, instead of streaming from
the top. Audio files are a preview kind too: their cover art is the thumbnail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:40:42 -07:00
Mitchell-JohnsonandClaude Fable 5.1 c24f6f5cde Files: a move of something that isn't there is a 404, not a task that fails on arrival
The kernel answers EXDEV for two paths on different filesystems before it looks for the
source, so the cross-filesystem path was taken for a missing file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:30:04 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b6d72cce9f PLAN.md: Move… is a folder picker; moves across filesystems copy then remove
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:29:27 -07:00
Mitchell-JohnsonandClaude Fable 5.1 fb3b8e4b4c Files: Move… opens a folder picker, and a move across filesystems copies then removes
The picker walks the machine like the tab does — back, forward, up, breadcrumbs, a path
box, a new folder — and "Move here" puts the file or folder in the folder being looked
at; it says why it can't when it can't (already here, into itself, a name clash). POST
/api/files/move is a rename when the two ends share a filesystem and otherwise a task:
on a NAS every move between datasets is a different filesystem, and a rename there is
EXDEV. The copy is built under a hidden temporary name beside its destination, renamed
into place once complete, and only then is the original removed, so a failure or a
cancel leaves the original as it was. Modes, times, symlinks and, as root, owners are kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:27:55 -07:00
118 changed files with 8004 additions and 18507 deletions
+24
View File
@@ -752,3 +752,27 @@ before its own login).
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.
+238 -46
View File
@@ -16,12 +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
@@ -36,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 │
└──────────────────────────────────────────────────────────────┘
```
@@ -115,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
@@ -313,7 +314,13 @@ something long in one, switch away and back, refresh — it's all still there.
exposes without root; `intel_gpu_top` shows the same number) or amdgpu's own percent;
per-process and per-engine shares are DRM fdinfo deltas, the way nvtop reads them. Other
users' fdinfo needs root, so a daemon running as `core` keeps **one** root child
(`hamlab drm-clients`, over a pipe) rather than a sudo a second. The Arc A380 on i915 6.12
(`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
@@ -410,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;
@@ -676,13 +653,230 @@ something long in one, switch away and back, refresh — it's all still there.
`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 uses for
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
@@ -777,6 +971,11 @@ what it has moved.
## To do
- **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
@@ -853,14 +1052,7 @@ what it has moved.
§3 has the audit it came from. Deployed as v0.1.0-191 and checked on all five machines
through the hub.
- **A Virtualization tab** — asked for 2026-09-23; **scoped, built and deployed 2026-10-09 — see
`VIRTUALIZATION.md`** for what shipped (phases 1 and 2) and what is left. Incus 6.0 LTS as the engine; the tab detects per machine whether Incus is
running, installable, or unavailable, and whether KVM is there, and acts on the machine in
the switcher — never a default host. Containers and VMs in one list, console/shell through
the terminal machinery, snapshots, exports, and the old Proxmox estate on `tank` as a later
import. That file has the probe of every host, the per-machine layout Ham Lab computes
(pool driver, a bridge subnet that avoids the LAN's /8 and Docker's pools, the Docker
`FORWARD` fix) and the phased build.
- ~~**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
+13 -18
View File
@@ -5,6 +5,9 @@ every place the code, defaults, docs or UI assume Mitchell's hosts, network, har
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).
@@ -17,11 +20,10 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments
| 1.1 | **B** | `internal/selfupdate/selfupdate.go:36` `DefaultURL = "https://git.bigham.dev/api/v1/repos/mitchell/hamlab"` | Every install with no `update.url` checks **this** Gitea 90 s after boot and daily (`internal/api/update.go:1951, 2016`). A stranger's install phones home here by default. | Default should be **off** (or a build-time `-X` variable set at release). Installer asks or `install.sh` passes `HAMLAB_URL` through into `update.url`. |
| 1.2 | **B** | `install.sh:4, 27` `HAMLAB_URL` default; `README.md:23-25, 31` | The curl one-liner and the install source are this Gitea. | Default comes from the release that served the script (`@URL@` filled by `make release`), or is required. |
| 1.3 | **B** | `scripts/gitea-release.sh:14` `REPO` default | Publishing goes to `mitchell/hamlab` on this Gitea. | Require `HAMLAB_REPO`; no default. |
| 1.4 | **C** | `README.md:100` "On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager" | Lab deployment fact in the product README. | Move to `hamlet/docs/hosts.md`. |
| 1.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 `hamlab.bigham.dev`, `zfs.tank.used_pct`, `/api/nodes/grid/...`; `:218-220` `grid,wyse`, `{"core":…,"grid":…,"mac":…}` | Docs use real host names, pool and domain. | Generic placeholders (`<host>`, `<machine>`, `<pool>`). |
| 1.7 | **C** | `PLAN.md:3-4` "bare-metal Debian 13 host. First target: `core` (10.0.0.234)"; decisions table rows (Listen `10.0.0.234:8100`, Data `/home/core/apps/hamlab/`, Ingress NPM/`hamlab.bigham.dev`, Repo) | Product plan is written as this lab's plan. | Keep as history, or split lab facts out to `hamlet`. The repo is public. |
| 1.8 | **C** | `VIRTUALIZATION.md:339-353` per-machine probe table; `:412, 425-436` `~/apps/hamlab`, `/tank/...`, boot NVMe free space | Plan is written against the five lab hosts. | Same as 1.7. |
| 1.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. |
@@ -30,7 +32,6 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments
| # | Sev | Where | Text | General form |
|---|---|---|---|---|
| 2.1 | **C** | `web/src/Machines.tsx:116-118` | `placeholder="grid"`, `"https://10.0.0.214:8100"`, `"core"` | `nas`, `https://192.0.2.10:8100`, `admin` or the hub's own user. |
| 2.2 | **C** | `web/src/KVM.tsx:166` | `placeholder="10.0.0.217"` (the NanoKVM on `grid`) | `192.0.2.20` or `kvm.local`. |
| 2.3 | **C** | `web/src/Settings.tsx:242` | `placeholder="cpu.*, temp.*, zfs.tank.*"` | `cpu.*, temp.*, fs.*`. |
| 2.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. |
| 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. |
@@ -60,7 +61,6 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2
| # | Sev | Where | Assumes | General form |
|---|---|---|---|---|
| 4.1 | **A** | `internal/updates/updates.go:537-543` `Power()` | `systemctl reboot` / `systemctl poweroff`. On OpenWrt (`gate`) and OpenRC the Power dialog fails. | Try `systemctl`, else `reboot` / `poweroff` (busybox, OpenRC, sysvinit all have them). |
| 4.2 | **A** | `internal/incus/incus.go:1017, 1044-1058, 1069, 1075` | The Incus install script writes `/etc/systemd/system/*` drop-ins and units, adds `SupplementaryGroups` to `hamlab.service`, restarts with `systemd-run`. On OpenRC/procd the daemon never joins `incus-admin` → the tab stays in "repair" forever; the Docker FORWARD fix is never persisted. `HamlabUnit = "hamlab.service"` (`:35`) is surfaced in the API (`:280`). | Branch on the detected init: OpenRC `rc-update`/`conf.d`, procd init script edits, or fall back to `usermod` + a daemon restart via the installer's own supervisor knowledge. Docker rules via a boot hook that is not a unit. |
| 4.3 | **A** | `internal/services/services.go:163-170` | Services tab is systemd-only ("says so cleanly"). `gate` (procd) and any OpenRC/runit/s6 box have no Services tab. | `rc-service`/`rc-status` (OpenRC), `/etc/init.d/<x> status` + `ubus call service list` (procd), `sv` (runit) backends. Noted as "not done" in `PLAN.md:487-489`. |
| 4.4 | **A** | `internal/cron/cron.go:353-373, 401, 442, 532-534, 669-670` | Jobs tab reads `/etc/crontab`, `/etc/cron.d`, `/var/spool/cron{,/crontabs}` (Debian/cronie), `/usr/sbin/anacron`, last-runs from **journald**, timers via `systemctl`; writes only `/etc/crontab` and `/etc/cron.d`. Busybox crond (OpenWrt, Alpine) keeps crontabs in `/etc/crontabs/<user>` and has no `cron.d` → the Jobs tab is empty on `gate` and schedules written by 4.5 never run. | Add `/etc/crontabs/*` as a source; last-runs from syslog/logread when there is no journal. |
| 4.5 | **A** | `internal/storage/snapshots.go:402` `/etc/cron.d/hamlab-zfs`; `internal/api/dockersched.go:65` `/etc/cron.d/hamlab-docker` | ZFS snapshot and image-update schedules are cron.d files. No cron.d on busybox crond; no cron at all on a systemd-timer-only host (Fedora Server, CoreOS). | Detect: cron.d → cron.d; busybox → a line in `/etc/crontabs/<user>`; none but systemd → a timer unit. The daemon-side runner already exists (`hamlab zfs-snapshot`, `docker-update`). |
@@ -74,13 +74,11 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2
| # | Sev | Where | Assumes | General form |
|---|---|---|---|---|
| 5.1 | **B** | `internal/incus/incus.go:213-250, 351-352` | Incus can be installed with apt, dnf, pacman, apk. **zypper is missing** (the Updates tab supports it), so an openSUSE host reads "No package manager Ham Lab knows how to drive". `opkg` is known only to say "not packaged for OpenWrt". | Add zypper; phrase the opkg case as "not packaged for this distribution". |
| 5.2 | **B** | `internal/incus/incus.go:973-978` | dnf branch adds `qemu-system-x86-core`/`edk2-ovmf` or `qemu-system-aarch64-core`/`edk2-aarch64`; other arches (riscv64, ppc64le, which `IncusArch()` maps) get no QEMU. pacman and apk branches assume their package names never change. | Table per PM × arch, or let `incus` pull its own deps where the distro does that (Debian does). |
| 5.3 | **B** | `internal/incus/incus.go:958, 970` `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold`; `internal/updates/updates.go:78, 432` | Debian-isms exported unconditionally. Harmless elsewhere. | Fine; note only. |
| 5.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/incus/subnet.go:150-179` `ip -j`; `internal/space/manager.go:578-585` `timeout -s KILL` | iproute2 JSON output (busybox `ip` has no `-j`); GNU `timeout`. | Parse plain `ip route`/`ip addr`; busybox has `timeout` too but flag spelling differs — use the context deadline. |
| 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
@@ -99,8 +97,7 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2
| 6.11 | **B** | `internal/vpn/edit.go:5-7, 419-421` | Peers can be **created/edited only through OpenWrt UCI** "because that is where the server here is configured". A wg-quick server (the common case everywhere else) is read-only. | Write wg-quick `.conf` too (append a `[Peer]` block + `wg set`), guarded the same way. |
| 6.12 | **B** | `internal/vpn/vpn.go:251-253` | Peer names come from wg-quick comments or UCI descriptions — "which between them cover every machine here". NetworkManager and systemd-networkd WireGuard configs are not read. | Add `.nmconnection` and `.netdev` readers. |
| 6.13 | **B** | `internal/vpn/edit.go:515` `profile()` | Split-tunnel routes hard-code `10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12` on top of the tunnel's own prefix. | Offer the host's actual LAN prefixes (from `TakenPrefixes`-style route reading), with RFC 1918 as the fallback. |
| 6.14 | **B** | `internal/incus/subnet.go:1-9, 23-34, 55`; `web/src/Virtualization.tsx:133` | Bridge subnet candidates skip **all of 10/8** because "this LAN is 10.0.0.0/8"; order is 172.16.100–254, then 192.168.254–200, then 172.16.1–99; last-resort `172.16.100.0/24`. Works anywhere, but a lab on 172.16/12 gets 192.168.x first and a lab on 192.168/16 never gets its own range. | Read the host's routes (already done) and pick from all three RFC 1918 blocks in a neutral order. |
| 6.15 | **B** | `internal/network/network.go:170-199, 303-314`; `internal/incus/subnet.go`; `internal/vpn/edit.go:558-595`; `internal/tlsauto/tlsauto.go:533-537`; `install.sh:114-120`; `internal/setup/setup.go:648-666` | **IPv4 everywhere**: default route from `/proc/net/route` only, outside view v4 only, bridge/peer allocation v4 only, cert SANs v4 only, installer lists v4 addresses only. | `/proc/net/ipv6_route`, v6 SANs, v6 peer addresses. An IPv6-only host today has no gateway, no cert SAN and no bridge. |
| 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)
@@ -134,7 +131,7 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2
|---|---|---|---|---|
| 10.1 | **B** | `Makefile:60-61, 442-443`; `scripts/gitea-release.sh` | Publishing is Gitea/Forgejo-only (release API shape). `selfupdate` also understands a static directory, so publishing could too. | `make release` + any static host is already enough; add a GitHub/Forgejo-agnostic `release-dir` note. |
| 10.2 | **C** | `scripts/gitea-release.sh:500-515` | Needs `python3` on the publishing host. | `jq`, or do it in Go (`hamlab publish`). |
| 10.3 | **C** | `Makefile:392` `ARCHES ?= amd64 arm64 arm 386 riscv64` | Fine. `ppc64le`/`s390x` are mapped by `IncusArch()` but never built. | Add or drop them from `IncusArch`. |
| 10.3 | **C** | `Makefile:392` `ARCHES ?= amd64 arm64 arm 386 riscv64` | Fine. | — |
## 11. Things that are already general (checked, no change needed)
@@ -145,11 +142,9 @@ Listed so the sweep is known to be complete, not because they need work.
- `internal/storage` block devices: `lsblk -J` with a `/sys/block` fallback, eMMC health, NVMe namespace folding, SMART only when `smartctl` exists.
- `internal/metrics` CPU/mem/hwmon/thermal/net/disk/fs from `/proc` and `/sys`; remote-FS and pseudo-FS lists; filesystem statfs with a timeout.
- `internal/hardware`: every reader may come back empty with a note; device-tree fallbacks for non-SMBIOS boards.
- `internal/incus` detection and the per-machine "ready / repair / installable / unavailable" model (modulo 4.2, 5.1, 5.2).
- `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`.
- `internal/api/kvm.go`: "nothing in it knows anything about any particular KVM" — verified.
- `internal/compose` (modulo 3.5).
- The React UI: machine-switcher model, hash routing, per-account prefs, themes.
@@ -158,7 +153,7 @@ Listed so the sweep is known to be complete, not because they need work.
## Suggested order of work
1. **Posture and safety first (A):** 7.2 trusted proxies; 7.1 installer-written scoped sudoers; ~~3.1 drop the `:2375` probe (with 3.2 as its consequence)~~ done 2026-10-10.
2. **"Any Linux" promises the README already makes (A):** 4.1 power, 4.4/4.5 cron on busybox and timer-only hosts, 4.2 Incus install on non-systemd, 4.3 a Services backend for OpenRC/procd, 2.10 the `tank` regex.
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.1 zypper for Incus, 5.5 cgroup v1, 6.1/6.2 GPUs beyond Intel/AMD, 6.4 UPS order, 6.11/6.12 wg-quick/NetworkManager, 6.15 IPv6.
5. **Cosmetics (C):** every placeholder and comment in §1.10 and §2, then split lab facts out of `PLAN.md`, `VIRTUALIZATION.md`, `SENSORS.md`, `README.md`.
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`.
+1 -1
View File
@@ -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.
+5 -2
View File
@@ -169,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
```
@@ -200,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>
-358
View File
@@ -1,358 +0,0 @@
# Virtualization tab — plan (2026-10-09; phase 1 built the same day)
**Status:** phases 1 and 2 are **deployed to all five machines as v0.1.0-196**. **No machine
has Incus installed as of 2026-10-11**: `grid`'s install of 2026-10-09 was removed at
Mitchell's request so he can install it himself from the tab and ask questions along the way
(`hamlet/docs/hosts.md` has what was removed and what the two days established). The tab
shows Install on `grid`, `core`, the Mac mini and `hamtv`, unavailable on `gate`. Both create
forms carry a **Disk location** picker with add/remove of locations; instance storage as
plain files on the machine's own disk is what he wants on `grid`. Everything in phase 2 was
verified live on `grid` before the removal: ISO fetch with checksum, New VM from ISO, the VGA
console in a real browser (headless Chromium; `binary` subprotocol, one console operation per
VM with a data websocket per SPICE channel), a virtiofs mount into a running VM, exports and
their cron line, `instance.*` sensors and the alert default. **Left:** the LAN bridge step
(his call), cloud-init presets beyond "Use my SSH keys", a backup line for `virt/exports/`.
A tab for creating, watching, consoling into and deleting **LXC containers and KVM virtual
machines**, the way Proxmox does with `pct` and `qm` — **per machine, like every other
page.** This is the scoping PLAN.md asked for. Nothing here is built. Live state of the
hosts stays in `hamlet/docs/hosts.md`.
## The governing rule: capability, not policy
Stated by Mitchell 2026-10-09, after the first draft of this plan excluded hosts on the
strength of the homelab's deployment rules:
> Anything that's done when a different machine is selected SHOULD GO ON THAT machine. I
> want the option to do it anywhere I want, unless the hardware / software literally
> doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on
> any Linux system. If certain parts / libraries aren't available on that machine, those
> tabs should just report that.
So:
- **Every action lands on the machine in the switcher.** There is no "VM host", no default
machine, no cross-machine placement. `grid` is where he will most likely click first;
the tab does not know or care.
- **The tab is gated by what the machine can do, detected live** — never by a rule about
what *should* run there. The `hamlet/CLAUDE.md` do-not-break rules (Mac mini for
single-core workloads, `hamtv` not spare hardware) bind the *agent's* unasked
deployments. They are not feature gates for the product, and the first draft was wrong
to treat them as such.
- **Ham Lab owns its data, and its data lives in its own folder.** Everything this tab
manages — instance storage, ISOs, image tarballs, exports — sits under Ham Lab's data
directory on *that* machine (`config.DataDir()`, `~/apps/hamlab` on `core`, `grid` and
the Mac), in one `virt/` tree. Never a path elsewhere on the machine, however convenient
the existing folder. Stated 2026-10-09 after the first Library draft pointed the ISO shelf
at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it
manages its own data."* This is the rule that moved `core`'s compose files under
`apps/hamlab/stacks/` on 2026-10-05; `grid`'s and the Mac's stacks, still at
`~/stacks/<machine>/`, are the outstanding exception and are not touched by this plan.
The engine's own runtime state (`/var/lib/incus`, like `/var/lib/docker`) is the engine's,
not Ham Lab's data: Ham Lab keeps what it would need to rebuild it.
- **Ham Lab installs or reports.** It detects what is missing, offers to install it where it
knows how, and where it cannot or the hardware cannot do the job, says so in one sentence
and offers what *does* work (containers without KVM, for instance).
## Decision: Incus 6.0 LTS as the engine
| Option | Verdict |
|---|---|
| **Incus** (LXD fork, linuxcontainers.org) | **Chosen.** One daemon, one CLI and one JSON API for both containers and VMs. Packaged by Debian 13 (`incus` 6.0.4 LTS — `apt install incus` pulls `qemu-system-x86`, `swtpm`, `virtiofsd`, `incus-agent`, so VMs work out of the box), Fedora (6.23), Arch, Alpine, Ubuntu (Zabbly repo); not by OpenWrt. Storage drivers for ZFS, btrfs, LVM and plain directories, so it fits a machine with or without ZFS. Built-in image server (`images:debian/13`, `images:ubuntu/24.04`, Alpine, Fedora, Arch… as container *or* VM, per architecture), snapshot schedules with expiry, `incus export` backups, per-instance CPU/memory limits, cloud-init, text and VGA console over websocket. Everything is reachable as `incus … --format json` or `incus query`, which is exactly how Ham Lab already drives Docker: `exec.Command`, no new Go dependency, no cgo. |
| libvirt/KVM + `virsh`, plus LXC separately | Two daemons, two models, XML. libvirt's official Go binding needs cgo, which the static binary rules out (the pure-Go `go-libvirt` exists but then containers are a second project). Rejected. |
| Proxmox | An OS, not a library. Rejected. |
## What each machine can do today
Probed 2026-10-09 (`/dev/kvm`, cpu flags, kvm modules, RAM, disk, package availability).
These are **facts the tab will detect at runtime**, not a list of where instances are
allowed. The last column is what the tab would show the day it ships.
| Machine | Arch | KVM | RAM (free) | Incus from the package manager | Pool driver available | Tab state on first open |
|---|---|---|---|---|---|---|
| `grid` | x86-64 | yes — VT-x, `kvm_intel` loaded, nested on | 62 GiB (~25; ARC holds 23 of the rest, max 40) | apt: `incus` 6.0.4 | **zfs** (2.3.9, `tank`) | **Install** — containers and VMs |
| `core` | x86-64 | yes — N100, `kvm_intel` loaded | 7.5 GiB (~4) | apt: `incus` 6.0.4 | btrfs or dir (ZFS was purged 2026-09-23) | **Install** — containers and VMs; the create form shows free memory so a VM's size is an informed choice |
| Mac mini | aarch64 | yes — Asahi KVM, 16K pages | 15 GiB (~8) | dnf: `incus` 6.23, `qemu-system-aarch64-core`, `edk2-aarch64` | btrfs or dir | **Install** — containers and VMs, **arm64 images only** (the picker filters by host arch). Whether a stock 4K-page guest kernel boots under the 16K host is untested; `muvm` does exactly that, so it is expected to. |
| `hamtv` | x86-64 | yes | 3.7 GiB (~2.9) | apt: `incus` 6.0.4 | btrfs or dir | **Install** — containers and small VMs |
| `gate` | x86-64 | **no** `/dev/kvm` | 8 GiB (~6) | **none** — OpenWrt's opkg has no Incus | — | **Unavailable**: "Incus isn't packaged for OpenWrt, and this kernel has no KVM." Nothing to install. |
Nothing is installed anywhere today: no qemu, libvirt, lxc or incus on any host.
### Detection the tab runs, in order
1. `incus` on `PATH` and its socket answering → **ready**; read pools, networks, instances.
2. Else: a package manager Ham Lab knows (apt, dnf, pacman, apk) that has an `incus`
candidate → **installable**, with the Install dialog pre-filled (below).
3. Else → **unavailable**, with the reason in one sentence (no package; no root; OpenWrt).
4. Independently: `/dev/kvm` present and openable → VMs offered. Absent → **containers
only**, said once on the page, and the create form has no VM option.
5. Architecture from `uname -m`, so the image picker only shows images that can run here.
6. Pool driver: `zfs` if the module and tools are present, else `btrfs` (installing
`btrfs-progs` as part of Install), else `dir`. The dialog says which it picked and why.
## What the tab does (v1)
Mirrors the Docker tab's shape: a list, a detail page, actions that are followed tasks,
destructive ones confirmed by typing the name.
- **Install** — one task that runs *on the selected machine*: install the package(s),
`incus admin init --preseed` with the layout from the dialog, create the pool and bridge,
add Ham Lab's user to `incus-admin` (socket access, the `docker`-group shape) and add it
to the unit's `SupplementaryGroups`, apply the Docker fix where Docker is present (below),
then restart Ham Lab. Idempotent, so it doubles as "repair". The dialog shows and lets him
edit: pool driver (detected), pool size (proposed from free space), bridge subnet
(computed), whether to apply the Docker rules. Where it all goes is not a choice: Ham
Lab's folder.
- **Instances** — containers and VMs in one list: type, state, IPv4, CPU and memory live,
disk, snapshot count, uptime. Click for detail.
- **Create** — container or VM (VM only where KVM is); image from the `images:` remote,
filtered to this architecture, searchable, with the common ones pinned (Debian 13, Ubuntu
24.04, Alpine, Fedora); name, CPUs, memory (with this machine's free memory beside it),
root disk size; network (the NAT bridge, or a LAN bridge if this machine has one);
optional cloud-init user-data (SSH key, packages, a first command); profile. Create, or
create and start. Collisions (name taken) named before Create.
- **Lifecycle** — start, stop, restart, force stop, rename, delete (type the name; the
instance's snapshots go with it, and the dialog says so).
- **Console** — two buttons, both open as tabs in the Terminal page through the existing PTY
manager (`term.CreateCmd`, the `docker exec` path): **Console** (`incus console`, the
serial console — works before the network is up, shows boot output) and **Shell**
(`incus exec <n> -- login shell`). Containers and, via `incus-agent`, VMs.
- **Snapshots** — take, restore (confirmed), delete; a **schedule** per instance with an
expiry, which is Incus's own `snapshots.schedule`/`snapshots.expiry`, so no cron file of
ours this time.
- **Resources** — edit CPU and memory limits (live for containers; VMs take CPU hotplug and
memory balloon live with the agent, else on restart), grow the root disk, add a bind mount
(a host path into a container; virtiofs into a VM).
- **Library** — ISOs for VMs and images (container templates and VM images). Its own
section below.
- **Storage and networks** — the pools with their fill, the bridges with their subnets.
Read-only in v1 beyond what Create and Install need.
- **Sensors** — `instance.<name>.cpu`, `.memory`, `.running` from Incus's state, like the
`container.*` sensors, so Alerts gets an "instance down" default and widgets can show one.
## Where the data lives
One tree per machine, under Ham Lab's data directory, created by Install:
```
~/apps/hamlab/virt/
pool.img the instance pool's backing file (zfs or btrfs loop; a plain directory
pool/ when the driver is dir) — every instance's disks live in here
isos/ the ISO library: the files themselves
images/ the image library: template tarballs and VM disk images as files, the
way Proxmox keeps CT templates — Incus's image store is loaded from here
exports/ nightly `incus export` tarballs, with retention
virt.json what Ham Lab chose and when: driver, pool size, bridge subnet, ISO and
image inventory, export schedule
```
Consequences, stated once:
- The tree is on whatever disk holds Ham Lab's data directory — the boot NVMe on all three.
Install proposes the pool size from that disk's free space. On `grid` that disk has
790 GiB free; on `core`, 175 GiB.
- Bulk data a guest needs is **bind-mounted in from where it already is** (`/tank/…` into an
instance on `grid`), the same split Docker has: the instance is Ham Lab's, the media is
not. That is a device on the instance, recorded in its config, not a second home.
- On `core` the whole tree rides the existing nightly rsync to `grid` for free. On `grid`
and the Mac it is as backed up as the rest of their Ham Lab folders — which is to say the
exports are the backup, and the backup job will need to be told about them in phase 2.
- Existing material elsewhere — the 105 GiB of ISOs at `/tank/software/isos/`, the Proxmox
archives — is **imported into the tree by copying**, file by file, on request. The
library never indexes a folder it does not own.
## Library: ISOs and images (asked for 2026-10-09)
Proxmox has a storage view with **ISO Images** and **CT Templates**. Incus has the same two
things under other names, and the tab gives them one home, a **Library** sub-tab, per machine
like everything else.
| Proxmox calls it | Incus calls it | What it is here |
|---|---|---|
| ISO Images | an ISO file attached to a VM as a `disk` device (added as a ROM device; VMs only), or a custom storage volume of type `iso` | **`virt/isos/` in Ham Lab's folder**, attached by path — no copy into the pool |
| CT Templates | **images** — the `images:` remote (linuxcontainers.org), the local image store, anything imported or published | **`virt/images/` in Ham Lab's folder** — tarballs and disk images as files, loaded into Incus from there; the remote is the catalogue to pull from |
### ISOs
- **The ISO library is `virt/isos/` in Ham Lab's folder**, on every machine, full stop. The
105 GiB at `/tank/software/isos/` on `grid` (`linux/`, `windows/`, `truenas/`,
`virtio_drivers/`…) is a *source* to import from — pick the ones wanted, Ham Lab copies them
in — not the library itself.
- **Getting an ISO in:** upload from the browser (the Files tab's `PUT /api/files/upload`
with its progress bar, reused), **download from a URL** as a followed task with an optional
checksum (the usual way — an ISO starts as a link), or **import from a path** already on the
machine — which **copies** the file into `virt/isos/` and says so. Rename, delete (refused while a VM has it attached, naming the VM).
- **Using one:** attach to / detach from a VM as a ROM device by path, with `boot.priority`
set so it boots first; **New VM from ISO** makes an empty VM (`incus init --empty --vm`)
with the ISO attached, starts it and opens the console. After install, Detach is one click
and boot order falls back to the disk.
- **Honest limit:** an ISO install is an interactive graphical installer, so it needs the
**VGA console in the browser** (`spice-html5` over Incus's VGA console websocket).
Installers that run on a serial console (Debian with `console=ttyS0`, Alpine, Arch) work
through the text console before that exists. Windows needs the virtio drivers ISO attached
as a second ROM device as well — Incus's documented path is `distrobuilder repack-windows`
to bake them in; the drivers are already in `virtio_drivers/` on `tank`. So **the ISO shelf
and the VGA console ship together, in phase 2**, moved up from phase 3.
- ISO *volumes* (`incus storage volume import <pool> <iso> <name> --type=iso`) are the
alternative: read-only, attachable to several VMs at once, but they copy the file into the
pool. Not used here — a file in `virt/isos/` is already Ham Lab's, and a volume would copy it
a second time into the pool. Offered as an option only if a need shows up.
### Images (container templates and VM images)
- **The catalogue:** browse `images:` filtered to this machine's architecture, by type
(container / VM), with the common ones pinned. **Download** copies one into the local
store (`incus image copy images:debian/13 local: --alias debian-13`) so a later Create is
instant and works without the internet. Images used by Create are cached by Incus anyway;
this is just the explicit version, Proxmox's "Templates → Download".
- **The local store:** every image here with alias, type, arch, size, origin (remote,
published, imported), when it was last used, and **which instances were made from it**
(Incus records the base image on each instance). Alias, delete (refused while an
instance's base — Incus knows), "New instance from this image".
- **Import:** a unified tarball or metadata + rootfs pair (a container template from
anywhere, including the phase-3 Proxmox conversions), or a **qcow2 / raw disk with
metadata Ham Lab writes** for a VM image — Home Assistant OS, OPNsense, a vendor cloud
image — by upload, URL, or a path on the machine. Same three ways in as ISOs.
- **Publish:** a stopped instance becomes an image with an alias — Proxmox's "convert to
template", the one thing the Images section of v1 always had.
- **Where they live:** as files in `virt/images/` — the downloaded or imported tarball, the
published image exported to one (`incus image export`) — so the library is complete without
Incus and can be reloaded into a fresh install. Incus's own image store under
`/var/lib/incus/images/` is a cache derived from it, and the page says so.
## Not in v1, with reasons
| Thing | Why not yet |
|---|---|
| **VGA console in the browser** | Incus hands the VM's SPICE socket over a websocket; drawing it needs `spice-html5` in the UI. Doable (the Incus UI does it); text console + shell cover every Linux guest until then. **Phase 2, with the ISO shelf** — an ISO install is useless without it. |
| **Windows VMs** | Incus runs them (ISO repacked with virtio drivers by `distrobuilder`), but without a VGA console in the browser they are unusable from the tab. After the above. |
| **GPU passthrough to a VM** | Removes the card from the host and from whatever uses it there (Jellyfin on `grid`). Offered only with that warning, later. Sharing a GPU into a *container* (`gpu` device) leaves the host its card and is how `r720-jellyfin` used to run. Phase 3. |
| **Clustering / live migration** | Incus clusters, but these machines differ in size and one is arm64, with no shared storage. Not planned. |
| **A fleet-wide instance list** | One machine per view. The switcher is the machine. |
| **A second engine (bare LXC on OpenWrt, libvirt-only hosts)** | Would double the backend for one router that has no KVM. The tab reports Incus unavailable there and stops. |
## Layout decisions Ham Lab makes per machine (overridable in the Install dialog)
1. **Storage — revised 2026-10-11.** The create forms carry a **Disk location** picker: a
list of the machine's Incus storage pools, each described by where its bytes land, with
"+ add a location" (any folder on the machine becomes a plain-files pool). On `grid` the
one location is `local`: plain files under `virt/pool/` on the root NVMe, each VM's disk
its own sparse file sized on its own, each container a directory, nothing pre-sized.
*The first design* (a ZFS pool inside one sparse file, handed to Incus as the pool's
source, for instant snapshots and clones) was built, ran for two days, and was taken out
at Mitchell's instruction: one pre-sized file holding every disk is an opaque middle
layer with a number on it that reads as a reservation, and "zfs · hamlab-virt" on the page
read as the NAS pool. The Install dialog still offers zfs/btrfs-in-a-file for a machine
that wants instant snapshots; the labels now say exactly what each is. Bulk data is still
**bind-mounted in**, not stored in the pool.
2. **Bridge subnet, computed.** Incus's `auto` picks a random `10.x.y.0/24`, and **this
LAN is `10.0.0.0/8`** — every host has a /8 on its NIC, so any 10.x bridge collides.
Ham Lab reads the machine's routes and picks the first free /24 from `192.168.0.0/16`,
then `172.16.0.0/12`, that is neither on-link nor routed nor inside Docker's configured
pool (`172.28.0.0/14` on `grid`; `172.19`–`172.29` and `192.168.80`–`143` live on
`core`). Instances get `name.incus` DNS from Incus's dnsmasq.
3. **LAN addresses are a later, explicit step.** A `br0` on the machine's uplink lets an
instance take a lease from `gate` and a Pi-hole name, but it rewrites the host's network
config and drops it off the network for seconds — never part of Install, always its own
confirmed action (with the NanoKVM standing by on `grid`). macvlan was considered and
rejected: no host change, but host and guests cannot talk, so nothing on the host could
reach an instance.
4. **Docker and Incus on one host.** Docker sets the `FORWARD` policy to `DROP`, which
silently kills the Incus bridge's traffic. Fix is two `DOCKER-USER` accept rules for the
bridge, persisted — applied by Install wherever Docker is detected (`grid`, `core`, the
Mac, `hamtv` all run it).
5. **Memory.** A VM's memory is a hard reservation; the create form shows the machine's
free memory and warns past it, and never refuses. On a ZFS host Install offers to cap
the ARC (on `grid`, 40 GiB → 16 GiB) so guest RAM is deterministic; his choice.
6. **Privilege.** Ham Lab's user joins `incus-admin`; no sudo in the hot path. Install
itself goes through `priv` like the other installers.
7. **Backups.** Nightly `incus export --instance-only` of every instance to `virt/exports/`
with a retention, as a cron line like the ZFS schedules (`hamlab incus-export`), from
phase 2. On `core` that folder is inside the nightly rsync already; on `grid` and the Mac
the backup job gets a line for it in phase 2.
## The old Proxmox estate (importable, phase 3)
`/tank/backups/proxmox/` holds the retired `r720` node: `/etc/pve` (12 LXC confs, 2 VM
confs), 118 GiB of `vzdump` archives, and three raw VM disks. Latest of each:
| ID | Name | Latest archive | Size |
|---|---|---|---|
| 100 | r720-docker | 2026-01-11 | 20.7 GB |
| 101 | r720-cockpit | 2026-01-11 | 439 MB |
| 102 | r720-wordpress-alexblaskovich | 2026-01-11 | 1.2 GB |
| 103 | r720-spoonterm | 2025-06-14 | 494 MB |
| 104 | r720-ollama | 2026-01-11 | 11.3 GB |
| 105 | r720-transmission | 2026-01-11 | 322 MB |
| 106 | r720-apache | 2026-01-11 | 347 MB |
| 107 | r720-nut | 2025-03-29 | 205 MB |
| 108 | r720-filebrowser | 2026-01-11 | 262 MB |
| 109 | r720-bookstack | 2025-04-27 | 516 MB |
| 110 | r720-jellyfin | 2026-01-11 | 2.2 GB |
| 111 | r720-wireguard | 2025-04-13 | 510 MB |
| 112 | r720-eggwiki | 2026-01-11 | 634 MB |
| VMs | steam (102), powerpanel (105/107/112) | `.vma.zst` 2024–2025; raw disks `vm-105`, `vm-112`, `base-104` | 19 GB, 1.9 GB ×6, 97 GiB |
- **LXC:** feasible and scriptable. Unpack the rootfs (`tar --zstd -xpf … --numeric-owner`),
read `etc/vzdump/pct.conf` for arch/ostype, drop it, write `metadata.yaml`, pack, `incus
image import`, launch. Unprivileged uid shifting is Incus's job after that. The result is
an image in the Library, so a restored container is launched from there like any other
template. An import action in the tab (pick an archive on this machine) is plausible once
the script works by hand. x86-64 archives only run on x86-64 machines; the picker says so.
- **VMs:** the raw disks import directly (`incus-migrate`, or an empty VM with the disk
imported into its root volume). The `.vma.zst` archives need Proxmox's `vma` extractor (or
the Python port) to become raw disks first. Most of these are the same `powerpanel` box
backed up six times, and `tank-space-audit.md` already has them down for deletion — decide
which, if any, are wanted before anything is restored.
## Build plan
| Phase | What | Effort |
|---|---|---|
| **1 — detection, Install, the tab** | `internal/incus` (detection, CLI wrapper, JSON types, `incus monitor` for live state, the preseed builder and subnet picker), `internal/api/virt.go` (routes below), `Virtualization.tsx` (the three page states; list, detail, create dialog, snapshot panel), rail entry `virt`, terminal integration, sensors, and the Library's **Images** shelf (browse and download from `images:`, local store, import, publish). **Tested by clicking Install on `grid` first, then `core`** — two different pool drivers, both with Docker present. Then the Mac for the arm64 path. `gate` for the unavailable state. | 2–3 sessions |
| **2 — operations (done 2026-10-09, except the LAN bridge)** | The Library's **ISO** shelf (directory, upload/URL/path, attach, New VM from ISO) **with the VGA console** (`spice-html5`), snapshot schedules UI, nightly exports with retention and the per-machine export directory, limits editor, bind mounts, the LAN-bridge step, instance alert default, cloud-init presets. | 1–2 sessions |
| **3 — the rest** | Proxmox import (archives become Library images), GPU device for containers, Windows via `distrobuilder repack-windows`, GPU passthrough to a VM with its warning. | as wanted |
### API sketch
```
GET /api/virt {state: ready|installable|unavailable, reason, kvm, arch,
pool_drivers[], pools[], networks[], instances[], free_memory}
POST /api/virt/install {driver, size, path, subnet, docker_rules, arc_max?} task
GET /api/virt/images?remote=images&q= local cache + remote search, filtered to this arch
DELETE /api/virt/images/{fingerprint}
POST /api/virt/instances {name, type, image, cpu, memory, disk, network, cloudinit, profile, start} task
GET /api/virt/instances/{name} config, state, devices, snapshots
PATCH /api/virt/instances/{name} limits, description, devices
POST /api/virt/instances/{name}/{start|stop|restart|kill|delete|rename|publish|export}
POST /api/virt/instances/{name}/console → terminal {id} (incus console)
POST /api/virt/instances/{name}/exec → terminal {id} (incus exec -- sh -lc …)
POST /api/virt/instances/{name}/snapshots {name}; POST …/{snap}/restore; DELETE …/{snap}
PUT /api/virt/instances/{name}/schedule {cron, expiry}
POST /api/virt/import {path} task (export tarball, or phase-3 Proxmox archive)
GET /api/virt/isos virt/isos/, scanned: name, size, attached-to
POST /api/virt/isos/import {path} task: copy a file on this machine into virt/isos/
POST /api/virt/isos/fetch {url, sha256?} task; uploads reuse PUT /api/files/upload
DELETE /api/virt/isos/{name} refused while attached
POST /api/virt/instances/{name}/iso {iso, boot_first} attach; DELETE …/iso detach
POST /api/virt/instances/from-iso {name, iso, cpu, memory, disk, network} task → console
GET /api/virt/images/remote?q=&type= the `images:` catalogue for this arch
POST /api/virt/images/download {alias} task (incus image copy images:… local:)
POST /api/virt/images/import {path | url, alias, type} task: copy into virt/images/, then load into Incus
GET /api/virt/instances/{name}/vga websocket: Incus VGA console → spice-html5 (phase 2)
```
Everything is per machine and reaches a node through the existing `/api/nodes/{name}/…`
proxy with no hub-side code; consoles attach over the existing `/api/terminals/{id}/attach`.
Tasks stream through `/api/tasks/{id}/stream` as Docker's do.
## Sources
- Incus on Debian: https://wiki.debian.org/Incus
- Incus and Docker on one host (the `FORWARD` drop): https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/
- Bridge options and `ipv4.address: auto`: https://linuxcontainers.org/incus/docs/main/reference/network_bridge/
- Consoles (text and VGA): https://linuxcontainers.org/incus/docs/main/howto/instances_console/
- ZFS storage driver: https://linuxcontainers.org/incus/docs/main/reference/storage_zfs/
- macvlan's host-isolation: https://linuxcontainers.org/incus/docs/main/reference/network_macvlan/
- Image format for the Proxmox import: https://linuxcontainers.org/incus/docs/main/reference/image_format/
+82 -21
View File
@@ -30,7 +30,7 @@ import (
"hamlab/internal/config"
"hamlab/internal/cron"
"hamlab/internal/files"
"hamlab/internal/incus"
"hamlab/internal/media"
"hamlab/internal/metrics"
"hamlab/internal/nodes"
"hamlab/internal/priv"
@@ -41,7 +41,6 @@ import (
"hamlab/internal/store"
"hamlab/internal/tasks"
hterm "hamlab/internal/term"
"hamlab/internal/thumbs"
"hamlab/internal/tlsauto"
"hamlab/internal/updates"
"hamlab/internal/web"
@@ -64,7 +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)
virt-export [name…] export Incus instances to the library (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
@@ -84,7 +83,9 @@ func main() {
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(thumbs.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
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)
@@ -150,8 +151,8 @@ func main() {
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
log.Fatal(err)
}
case "virt-export": // the nightly cron line from the Virtualization tab; optional instance names
if err := virtExport(cfg, flag.Args()[1:]); err != nil {
case "landing-verify":
if err := landingVerify(cfg, flag.Arg(1)); err != nil {
log.Fatal(err)
}
case "users":
@@ -258,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>")
@@ -304,21 +380,6 @@ func zfsSnapshot(cfg *config.Config, id string) error {
return nil
}
// virtExport runs the instance exports the Virtualization tab scheduled, as the account Ham
// Lab runs as (which is in incus-admin), and records the run beside the data.
func virtExport(cfg *config.Config, names []string) error {
who := os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" {
who = u.Username
}
run := incus.New(cfg.DataDir(), who, version).RunExports(context.Background(), names, func(l string) { fmt.Println(l) })
fmt.Printf("exported %d, pruned %d, %.0f s\n", len(run.Exported), len(run.Pruned), run.Seconds)
if len(run.Errors) > 0 {
return errors.New(strings.Join(run.Errors, "; "))
}
return nil
}
func migrateUsers(cfg *config.Config) {
name := os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" {
+5 -18
View File
@@ -71,9 +71,6 @@ type alertSaved struct {
Channels []AlertChannel `json:"channels"`
Events []AlertEvent `json:"events"`
Seeded bool `json:"seeded"`
// SawInstanceRule marks that the instance-down default has been offered once; set so that
// removing it stays removed.
SawInstanceRule bool `json:"saw_instance_rule,omitempty"`
// Firing is what was firing when the hub last looked, so a restart (every deploy is one)
// carries on where it was and doesn't announce the same alerts all over again.
Firing map[string]alertKept `json:"firing,omitempty"`
@@ -105,7 +102,6 @@ var defaultRules = []AlertRule{
{ID: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true},
{ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
{ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true},
{ID: "instance-down", Name: "VM or container down", Sensor: "instance.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
{ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true},
{ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
{ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true},
@@ -126,23 +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}
}
// a default added after an install was seeded joins its rules once; removing it afterwards sticks
if e.saved.Seeded && !e.saved.SawInstanceRule {
has := false
// 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" {
has = true
if r.ID != "instance-down" {
kept = append(kept, r)
}
}
if !has {
for _, d := range defaultRules {
if d.ID == "instance-down" {
e.saved.Rules = append(e.saved.Rules, d)
}
}
}
e.saved.SawInstanceRule = true
}
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
View File
@@ -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 {
-1
View File
@@ -340,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
+123
View File
@@ -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)
}
}
}
-434
View File
@@ -1,434 +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"
"hamlab/internal/store"
)
const kvmPrefix = "/kvm/"
const kvmCookie = "hamlab_kvm"
type kvmProxies struct {
mu sync.Mutex
m map[string]*httputil.ReverseProxy
}
func (s *Server) kvm(id string) (config.KVM, bool) {
var found config.KVM
ok := false
s.cfg.View(func(c *config.Config) {
for _, k := range c.KVMs {
if k.ID == id {
found, ok = k, true
}
}
})
return found, ok
}
// kvmProxy is the reverse proxy for one device, made once and kept.
func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
s.kvms.mu.Lock()
defer s.kvms.mu.Unlock()
if s.kvms.m == nil {
s.kvms.m = map[string]*httputil.ReverseProxy{}
}
if p, ok := s.kvms.m[k.ID]; ok {
return p, nil
}
target, err := url.Parse(k.URL)
if err != nil || target.Host == "" {
return nil, errors.New("this KVM's address is not a URL")
}
prefix := kvmPrefix + k.ID
p := &httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL.Scheme, r.URL.Host = target.Scheme, target.Host
// Entry through the prefix keeps the path after it; anything else arrived by its
// own absolute path and is passed through untouched.
if rest, ok := strings.CutPrefix(r.URL.Path, prefix); ok {
if rest == "" {
rest = "/"
}
r.URL.Path = rest
}
r.URL.RawPath = ""
r.Host = target.Host
// Whether the console itself is on TLS: the response rewriter needs to know, and by
// then only the outbound request is in hand.
if secure(r) {
r.Header.Set(kvmSecureHeader, "1")
} else {
r.Header.Del(kvmSecureHeader)
}
// The device gets its own host and none of Ham Lab's session.
r.Header.Del("Origin")
stripCookie(r, cookieName, kvmCookie)
// What is left is the device's own login. A browser that has it teaches the hub; one
// that has not is lent what the hub remembers, so the device sees a signed-in client.
if own := r.Cookies(); len(own) > 0 {
s.rememberKVMCookies(k.ID, own)
} else {
for name, val := range s.kvmCookies(k.ID) {
r.AddCookie(&http.Cookie{Name: name, Value: val})
}
}
},
FlushInterval: -1, // a video stream must not be buffered
ModifyResponse: func(resp *http.Response) error {
// The device's own framing and transport policy would stop it being embedded, or
// send a browser off to plain HTTP. Ham Lab is the origin now, and says what it
// serves; its own headers are added on the way out.
for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} {
resp.Header.Del(h)
}
// A cookie the device sets by header is kept for Ham Lab's origin — so its Domain
// (the device's) goes, Secure follows the console rather than the device, and the
// hub remembers it too.
if set := resp.Cookies(); len(set) > 0 {
https := resp.Request != nil && resp.Request.Header.Get(kvmSecureHeader) == "1"
resp.Header.Del("Set-Cookie")
for _, c := range set {
c.Domain = ""
if c.Path == "" {
c.Path = "/"
}
c.Secure = c.Secure && https
if v := c.String(); v != "" {
resp.Header.Add("Set-Cookie", v)
}
}
s.rememberKVMCookies(k.ID, set)
}
// A redirect to the device's own root has to come back through the prefix.
if loc := resp.Header.Get("Location"); loc != "" {
if u, err := url.Parse(loc); err == nil && (u.Host == "" || u.Host == target.Host) && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, prefix) {
u.Scheme, u.Host = "", ""
u.Path = prefix + u.Path
resp.Header.Set("Location", u.String())
}
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]string{"error": k.Name + " unreachable: " + err.Error()})
},
}
s.kvms.m[k.ID] = p
return p, nil
}
// kvmSecureHeader rides on the outbound request to say whether the console was reached
// over TLS; the device ignores it.
const kvmSecureHeader = "X-Hamlab-Secure"
// kvmCookies is what the hub remembers of a device's own cookies — its login, in practice.
func (s *Server) kvmCookies(id string) map[string]string {
var out map[string]string
s.store.View(func(st *store.State) {
if m := st.KVMCookies[id]; len(m) > 0 {
out = make(map[string]string, len(m))
for k, v := range m {
out[k] = v
}
}
})
return out
}
// rememberKVMCookies keeps the cookies a browser (or the device's own Set-Cookie) carries
// for a KVM, so that a browser without them can be lent them. A cookie emptied or expired
// by the device is forgotten. Written only when something changed: this runs on every
// proxied request.
func (s *Server) rememberKVMCookies(id string, cs []*http.Cookie) {
have := s.kvmCookies(id)
changed := false
for _, c := range cs {
if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie {
continue
}
gone := c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now()))
if gone {
if _, ok := have[c.Name]; ok {
changed = true
}
continue
}
if have[c.Name] != c.Value {
changed = true
}
}
if !changed {
return
}
_ = s.store.Update(func(st *store.State) {
if st.KVMCookies == nil {
st.KVMCookies = map[string]map[string]string{}
}
m := st.KVMCookies[id]
if m == nil {
m = map[string]string{}
}
for _, c := range cs {
if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie {
continue
}
if c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) {
delete(m, c.Name)
} else {
m[c.Name] = c.Value
}
}
if len(m) == 0 {
delete(st.KVMCookies, id)
} else {
st.KVMCookies[id] = m
}
})
}
// forgetKVMCookies drops a device's remembered login — when the device is removed.
func (s *Server) forgetKVMCookies(id string) {
_ = s.store.Update(func(st *store.State) { delete(st.KVMCookies, id) })
}
// lendKVMCookies hands the browser the device cookies the hub remembers and it lacks, as
// the device's own page would have set them: readable by its scripts, for Ham Lab's whole
// origin, kept for half a year. The device's SPA checks for its token client-side before it
// ever asks the server, so lending it only on the wire would still land on the login form.
func (s *Server) lendKVMCookies(w http.ResponseWriter, r *http.Request, id string) {
for name, val := range s.kvmCookies(id) {
if _, err := r.Cookie(name); err == nil {
continue
}
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: 180 * 24 * 3600, Secure: secure(r), SameSite: http.SameSiteLaxMode})
}
}
// stripCookie removes named cookies from a request, so Ham Lab's session is never handed to
// a device and the device never sees which KVM the browser is looking at.
func stripCookie(r *http.Request, names ...string) {
cs := r.Cookies()
kept := cs[:0]
for _, c := range cs {
drop := false
for _, n := range names {
if c.Name == n {
drop = true
}
}
if !drop {
kept = append(kept, c)
}
}
r.Header.Del("Cookie")
for _, c := range kept {
r.AddCookie(c)
}
}
func (s *Server) forgetKVMProxy(id string) {
s.kvms.mu.Lock()
delete(s.kvms.m, id)
s.kvms.mu.Unlock()
}
// serveKVM is the entry point: /kvm/<id>/… . It also drops a cookie naming the device, so
// that requests the page makes later by absolute path can be traced back to it.
func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
k, ok := s.kvm(id)
if !ok {
jsonErr(w, 404, "no such KVM")
return
}
p, err := s.kvmProxy(k)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode})
s.lendKVMCookies(w, r, id)
p.ServeHTTP(w, r)
}
// kvmFor works out which device a request belongs to when it arrived by absolute path
// rather than through the prefix: the Referer names the page that asked, and a cookie
// covers what has no Referer at all. Nothing device-specific: it never has to know which
// paths a given KVM uses.
func (s *Server) kvmFor(r *http.Request) (config.KVM, bool) {
if ref := r.Header.Get("Referer"); ref != "" {
if u, err := url.Parse(ref); err == nil {
if rest, ok := strings.CutPrefix(u.Path, kvmPrefix); ok {
if id, _, _ := strings.Cut(rest, "/"); id != "" {
if k, ok := s.kvm(id); ok {
return k, true
}
}
}
}
// asked for by a page that is not a KVM: not ours to answer
return config.KVM{}, false
}
if c, err := r.Cookie(kvmCookie); err == nil && c.Value != "" {
return s.kvm(c.Value)
}
return config.KVM{}, false
}
// kvmFallback answers a request Ham Lab does not serve itself, when it came from inside a
// KVM's page. It returns false when the request has nothing to do with a KVM, and the
// caller carries on with its own 404.
func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool {
k, ok := s.kvmFor(r)
if !ok {
return false
}
p, err := s.kvmProxy(k)
if err != nil {
return false
}
p.ServeHTTP(w, r)
return true
}
func machineLabel(m string) string {
if m == "" {
return "this host"
}
return m
}
// ---- the list ----
func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) {
out := []config.KVM{}
s.cfg.View(func(c *config.Config) { out = append(out, c.KVMs...) })
jsonOK(w, 200, out)
}
// kvmForMachine is the KVM attached to a machine, "" being the hub itself.
func (s *Server) kvmForMachine(machine string) (config.KVM, bool) {
var found config.KVM
ok := false
s.cfg.View(func(c *config.Config) {
for _, k := range c.KVMs {
if k.Machine == machine {
found, ok = k, true
}
}
})
return found, ok
}
func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
var body struct{ Name, URL, Machine string }
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
raw := strings.TrimSpace(body.URL)
if raw != "" && !strings.Contains(raw, "://") {
raw = "http://" + raw // what you would type: an address, not a scheme
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
jsonErr(w, 400, "address: http(s)://host, or just the host")
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
name = u.Host
}
if len(name) > maxNameLen {
jsonErr(w, 400, "that name is too long")
return
}
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
jsonErr(w, 500, err.Error())
return
}
machine := strings.TrimSpace(body.Machine)
k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: machine, Added: time.Now()}
// One per machine: a second one for the same host replaces the first rather than sitting
// beside it, since the tab shows the machine you are looking at.
var replaced string
if err := s.cfg.Update(func(c *config.Config) {
kept := c.KVMs[:0]
for _, old := range c.KVMs {
if old.Machine == machine {
replaced = old.ID
continue
}
kept = append(kept, old)
}
c.KVMs = append(kept, k)
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
if replaced != "" {
s.forgetKVMProxy(replaced)
}
log.Printf("kvm %q added for %s (%s) by %s", k.Name, machineLabel(machine), k.URL, clientIP(r))
jsonOK(w, 201, k)
}
func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
found := false
if err := s.cfg.Update(func(c *config.Config) {
kept := c.KVMs[:0]
for _, k := range c.KVMs {
if k.ID == id {
found = true
continue
}
kept = append(kept, k)
}
c.KVMs = kept
}); err != nil {
jsonErr(w, 500, err.Error())
return
}
if !found {
jsonErr(w, 404, "no such KVM")
return
}
s.forgetKVMProxy(id)
s.forgetKVMCookies(id)
log.Printf("kvm %s removed by %s", id, clientIP(r))
w.WriteHeader(204)
}
-123
View File
@@ -1,123 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/store"
)
// A KVM's own login is a cookie its page sets in one browser. The hub remembers it from the
// requests that carry it, lends it to a browser that lacks it — both on the wire to the
// device and back to the browser, since the device's page checks for it client-side — and
// forgets it when the device says it is gone or the KVM is removed.
func TestKVMCookiesAreRememberedAndLent(t *testing.T) {
var seen []string // the Cookie header the fake device received, per request
device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Get("Cookie"))
if r.URL.Path == "/api/auth/login" {
// a device that sets its token by header, with its own domain and Secure
http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "tok-1", Domain: "10.0.0.217", Secure: true})
}
if r.URL.Path == "/api/auth/logout" {
http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "", MaxAge: -1})
}
w.WriteHeader(200)
}))
defer device.Close()
dir := t.TempDir()
cfg := config.Default(filepath.Join(dir, "config.json"))
if err := cfg.Update(func(c *config.Config) {
c.KVMs = []config.KVM{{ID: "abc123", Name: "test", URL: device.URL, Machine: ""}}
}); err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(dir, "state.json"))
if err != nil {
t.Fatal(err)
}
s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)}
k, _ := s.kvm("abc123")
p, err := s.kvmProxy(k)
if err != nil {
t.Fatal(err)
}
ask := func(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", path, nil)
r.SetPathValue("id", "abc123")
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
if strings.HasPrefix(path, kvmPrefix) {
s.serveKVM(w, r)
} else {
p.ServeHTTP(w, r)
}
return w
}
// 1. the device sets its token by header: it reaches the browser without the device's
// domain or Secure (plain-http console), and the hub remembers it
w := ask("/api/auth/login", &http.Cookie{Name: cookieName, Value: "hamlab-session"})
var set *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == "nano-kvm-token" {
set = c
}
}
if set == nil || set.Domain != "" || set.Secure || set.Value != "tok-1" || set.Path != "/" {
t.Fatalf("rewritten Set-Cookie = %+v, want nano-kvm-token=tok-1 for / without Domain or Secure", set)
}
if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-1" {
t.Fatalf("remembered = %v, want the token", got)
}
if strings.Contains(seen[0], "hamlab_session") {
t.Fatalf("the device was handed Ham Lab's session: %q", seen[0])
}
// 2. a browser that has the cookie teaches a newer value
ask("/api/vm/info", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"}, &http.Cookie{Name: cookieName, Value: "x"})
if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-2" {
t.Fatalf("remembered = %v, want tok-2", got)
}
if seen[1] != "nano-kvm-token=tok-2" {
t.Fatalf("device saw %q, want the browser's own cookie only", seen[1])
}
// 3. a browser without it is lent it: on the wire, and back as a Set-Cookie it can read
w = ask(kvmPrefix+"abc123/", &http.Cookie{Name: cookieName, Value: "x"})
if seen[2] != "nano-kvm-token=tok-2" {
t.Fatalf("device saw %q from a browser with no cookie, want the remembered token", seen[2])
}
lent := false
for _, c := range w.Result().Cookies() {
if c.Name == "nano-kvm-token" && c.Value == "tok-2" && !c.HttpOnly && c.Path == "/" && c.MaxAge > 0 {
lent = true
}
}
if !lent {
t.Fatalf("browser was not lent the token: %v", w.Result().Cookies())
}
// 4. the device logging out forgets it
ask("/api/auth/logout", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"})
if got := s.kvmCookies("abc123"); got != nil {
t.Fatalf("after logout remembered = %v, want nothing", got)
}
// 5. removing the KVM clears whatever is left
s.rememberKVMCookies("abc123", []*http.Cookie{{Name: "nano-kvm-token", Value: "tok-3"}})
s.forgetKVMCookies("abc123")
if got := s.kvmCookies("abc123"); got != nil {
t.Fatalf("after remove remembered = %v, want nothing", got)
}
_ = url.Parse
}
+216
View File
@@ -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})
}
+563
View File
@@ -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)
}
+93
View File
@@ -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"))
}
}
+120 -74
View File
@@ -34,7 +34,7 @@ import (
"hamlab/internal/cron"
"hamlab/internal/files"
"hamlab/internal/hardware"
"hamlab/internal/incus"
"hamlab/internal/media"
"hamlab/internal/metrics"
"hamlab/internal/network"
"hamlab/internal/nodes"
@@ -75,11 +75,11 @@ type Server struct {
net *network.Watcher
update updateState
events eventLog
kvms kvmProxies
virt *incus.Manager
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
@@ -99,8 +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
@@ -108,7 +110,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
if s.user == "" {
s.user = "?"
}
s.virt = incus.New(cfg.DataDir(), s.user, version)
s.migrateUsers()
// The speed test and the outside view. Its history outlives a restart, and its last result
// is published as sensors, so the line is graphable and alertable like anything else.
@@ -232,37 +233,6 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec))
m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate))
m.HandleFunc("GET /api/virt", s.auth(s.virtOverview))
m.HandleFunc("POST /api/virt/install", s.auth(s.virtInstall))
m.HandleFunc("GET /api/virt/images/remote", s.auth(s.virtRemoteImages))
m.HandleFunc("POST /api/virt/images/download", s.auth(s.virtImageDownload))
m.HandleFunc("POST /api/virt/images/import", s.auth(s.virtImageImport))
m.HandleFunc("POST /api/virt/images/publish", s.auth(s.virtImagePublish))
m.HandleFunc("POST /api/virt/images/delete", s.auth(s.virtImageDelete))
m.HandleFunc("POST /api/virt/instances", s.auth(s.virtCreate))
m.HandleFunc("GET /api/virt/instances/{name}", s.auth(s.virtInstance))
m.HandleFunc("POST /api/virt/instances/{name}/rename", s.auth(s.virtRename))
m.HandleFunc("PUT /api/virt/instances/{name}/config", s.auth(s.virtConfig))
m.HandleFunc("POST /api/virt/instances/{name}/snapshots", s.auth(s.virtSnapshotCreate))
m.HandleFunc("POST /api/virt/instances/{name}/snapshots/{snap}/{action}", s.auth(s.virtSnapshotAction))
m.HandleFunc("POST /api/virt/instances/from-iso", s.auth(s.virtFromISO))
m.HandleFunc("GET /api/virt/instances/{name}/vga", s.auth(s.virtVGA))
m.HandleFunc("POST /api/virt/instances/{name}/iso", s.auth(s.virtISOAttach))
m.HandleFunc("POST /api/virt/instances/{name}/iso/detach", s.auth(s.virtISODetach))
m.HandleFunc("POST /api/virt/instances/{name}/mounts", s.auth(s.virtMountAdd))
m.HandleFunc("POST /api/virt/instances/{name}/mounts/remove", s.auth(s.virtMountRemove))
m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction))
m.HandleFunc("POST /api/virt/isos/fetch", s.auth(s.virtISOFetch))
m.HandleFunc("POST /api/virt/isos/import", s.auth(s.virtISOImport))
m.HandleFunc("POST /api/virt/isos/delete", s.auth(s.virtISODelete))
m.HandleFunc("POST /api/virt/isos/rename", s.auth(s.virtISORename))
m.HandleFunc("GET /api/virt/exports", s.auth(s.virtExports))
m.HandleFunc("PUT /api/virt/exports", s.auth(s.virtExportsPut))
m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun))
m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete))
m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys))
m.HandleFunc("POST /api/virt/pools", s.auth(s.virtPoolCreate))
m.HandleFunc("POST /api/virt/pools/delete", s.auth(s.virtPoolDelete))
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
@@ -301,12 +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))
@@ -328,6 +313,11 @@ func (s *Server) routes() *http.ServeMux {
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))
@@ -360,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)
@@ -395,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'")
}
@@ -1920,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 == "" {
@@ -1953,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)
}
@@ -1967,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
}
@@ -2054,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)
@@ -2584,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")
+43 -12
View File
@@ -744,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(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))
}
@@ -755,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 {
@@ -767,7 +767,41 @@ 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() }
// 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.
@@ -775,9 +809,8 @@ func checkImages(ctx context.Context, images []string) []imageCheck {
res := []imageCheck{}
for _, img := range images {
ic := imageCheck{Image: img, State: "unknown"}
out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img)
var have []string
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
have := localDigests(ctx, img)
if len(have) == 0 {
ic.Detail = "built or loaded here, not pulled from a registry"
res = append(res, ic)
continue
@@ -795,12 +828,10 @@ func checkImages(ctx context.Context, images []string) []imageCheck {
res = append(res, ic)
continue
}
ic.State = "update"
for _, h := range have {
if strings.HasSuffix(h, "@"+digest) {
ic.State, ic.remote = "update", digest
if holdsDigest(have, digest) {
ic.State = "current"
}
}
res = append(res, ic)
}
return res
@@ -833,7 +864,7 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
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 {
@@ -863,13 +894,13 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 404, err.Error())
return
}
key := 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 {
-39
View File
@@ -10,7 +10,6 @@ import (
"sync"
"time"
"hamlab/internal/incus"
"hamlab/internal/priv"
"hamlab/internal/sensors"
"hamlab/internal/storage"
@@ -55,7 +54,6 @@ func newStatusSensors(s *Server) *statusSensors {
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}, acc: map[string]*bucket{}}
go p.loop("services", servicesEvery, p.services)
go p.loop("containers", containersEvery, p.containers)
go p.loop("instances", containersEvery, p.instances)
go p.loop("updates", updatesEvery, p.updates)
go p.loop("zfs", zfsEvery, p.zfs)
go p.loop("smart", smartEvery, p.smart)
@@ -322,43 +320,6 @@ type cpuMark struct {
t int64
}
// instances are Incus's containers and VMs. "Down" means it should be up and is not: an
// instance marked to start with the machine that is stopped, or one in Error — a stopped
// instance with no such mark was stopped on purpose, as with Docker containers.
func (p *statusSensors) instances(ctx context.Context, t int64) []sensors.Sensor {
if !incus.Installed() {
return nil
}
list, err := p.s.virt.InstancesFor(ctx, "sensors")
if err != nil {
return nil
}
var res []sensors.Sensor
var running, total float64
for _, in := range list {
total++
id := "instance." + sensors.Seg(in.Name)
l := map[string]string{"instance": in.Name, "type": in.Type, "image": in.Image}
up := in.Status == "Running"
if up {
running++
}
down := in.Status == "Error" || (!up && in.Config["boot.autostart"] == "true")
res = append(res,
sensors.Sensor{ID: id + ".running", Name: in.Name, Kind: "state", Value: up, T: t, Labels: l},
sensors.Sensor{ID: id + ".down", Name: in.Name + " down", Kind: "state", Value: down, T: t, Labels: l},
sensors.Sensor{ID: id + ".state", Name: in.Name + " state", Kind: "state", Value: strings.ToLower(in.Status), T: t, Labels: l})
if up {
res = append(res,
sensors.Sensor{ID: id + ".cpu", Name: in.Name + " CPU", Kind: "usage", Unit: "%", Value: float64(int(in.CPUPct*10+0.5)) / 10, T: t, Min: sensors.F(0), Labels: l},
sensors.Sensor{ID: id + ".mem", Name: in.Name + " memory", Kind: "bytes", Unit: "B", Value: float64(in.MemUsage), T: t, Labels: l})
}
}
return append(res,
sensors.Sensor{ID: "instances.total", Name: "Instances", Kind: "count", Value: total, T: t},
sensors.Sensor{ID: "instances.running", Name: "Running instances", Kind: "count", Value: running, T: t})
}
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
st := p.s.updates.Status(ctx)
if st.Manager == "" {
+263 -27
View File
@@ -10,10 +10,16 @@ package api
// 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"
@@ -23,8 +29,9 @@ import (
"sync"
"time"
"hamlab/internal/media"
"hamlab/internal/priv"
"hamlab/internal/thumbs"
"hamlab/internal/store"
)
const thumbSize = 256
@@ -32,7 +39,7 @@ 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 *thumbs.Cache
cache *media.Cache
self string
sem chan struct{}
mu sync.Mutex
@@ -42,14 +49,14 @@ type thumbSvc struct {
type capsEntry struct {
mu sync.Mutex
caps thumbs.Caps
caps media.Caps
err error
at time.Time
}
type thumbCall struct {
done chan struct{}
res thumbs.Result
res media.Result
err error
}
@@ -61,11 +68,11 @@ func newThumbSvc(dataDir, self string) *thumbSvc {
if n > 4 {
n = 4
}
return &thumbSvc{cache: thumbs.NewCache(filepath.Join(dataDir, "thumbs.db")), self: self, sem: make(chan struct{}, n), caps: map[bool]*capsEntry{}, calls: map[string]*thumbCall{}}
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) (thumbs.Caps, error) {
func (t *thumbSvc) capsFor(ctx context.Context, root, refresh bool) (media.Caps, error) {
t.mu.Lock()
e := t.caps[root]
if e == nil {
@@ -79,9 +86,9 @@ func (t *thumbSvc) capsFor(ctx context.Context, root, refresh bool) (thumbs.Caps
return e.caps, e.err
}
if root {
e.caps, e.err = thumbs.ProbeRoot(ctx, t.self)
e.caps, e.err = media.ProbeRoot(ctx, t.self)
} else {
e.caps, e.err = thumbs.Probe(ctx), nil
e.caps, e.err = media.Probe(ctx), nil
}
e.at = time.Now()
mode := "as " + map[bool]string{false: "the daemon", true: "root"}[root]
@@ -97,7 +104,7 @@ func (t *thumbSvc) capsFor(ctx context.Context, root, refresh bool) (thumbs.Caps
}
// describeCaps is the one line that says how previews get made here.
func describeCaps(c thumbs.Caps) string {
func describeCaps(c media.Caps) string {
if len(c.GPUs) == 0 {
return "CPU only"
}
@@ -117,7 +124,9 @@ func describeCaps(c thumbs.Caps) string {
}
// make produces a preview, sharing the work when the same file is asked for twice at once.
func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (thumbs.Result, error) {
// 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()
@@ -125,7 +134,7 @@ func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (thumb
case <-c.done:
return c.res, c.err
case <-ctx.Done():
return thumbs.Result{}, ctx.Err()
return media.Result{}, ctx.Err()
}
}
c := &thumbCall{done: make(chan struct{})}
@@ -153,19 +162,29 @@ func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (thumb
c.err = err
return c.res, c.err
}
o := thumbs.Opts{Size: thumbSize, Timeout: thumbTimeout}
o := media.Opts{Size: thumbSize, Timeout: thumbTimeout, Kind: kind}
caps = caps.Only(device)
if root {
c.res, c.err = thumbs.MakeRoot(wctx, t.self, caps, path, o)
c.res, c.err = media.MakeRoot(wctx, t.self, caps, in, o)
} else {
c.res, c.err = thumbs.Make(wctx, caps, path, o)
c.res, c.err = media.Make(wctx, caps, in, o)
}
return c.res, c.err
}
// ---- handlers ----
// filesThumb: GET /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=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.
// 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 {
@@ -175,13 +194,13 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if thumbs.Kind(p) == "" {
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 := thumbs.Key(p, sz, mt)
key := media.Key(p, sz, mt)
if r.Header.Get("If-None-Match") == `"`+key+`"` {
w.WriteHeader(304)
return
@@ -199,16 +218,37 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "not a regular file")
return
}
key := thumbs.Key(p, in.Size, in.Mtime)
key := media.Key(p, in.Size, in.Mtime)
if s.serveCachedThumb(w, key) {
return
}
res, err := s.thumbs.make(r.Context(), root, key, p)
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 *thumbs.DecodeError
var de *media.DecodeError
switch {
case errors.As(err, &de):
if err := s.thumbs.cache.Put(key, thumbs.Entry{Path: p, Failure: de.Msg}); err != nil {
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)
@@ -223,12 +263,204 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
}
return
}
if err := s.thumbs.cache.Put(key, thumbs.Entry{Path: p, Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}); err != nil {
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 {
@@ -272,8 +504,8 @@ type thumbStatus struct {
Engine string `json:"engine"` // gpu | cpu | ""
Summary string `json:"summary"`
FFmpeg string `json:"ffmpeg,omitempty"`
GPUs []thumbs.GPU `json:"gpus"`
Unusable []thumbs.GPU `json:"unusable,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
@@ -300,7 +532,7 @@ func (s *Server) thumbsStatus(w http.ResponseWriter, r *http.Request) {
return
}
caps, err := s.thumbs.capsFor(r.Context(), root, r.URL.Query().Get("refresh") == "1")
st := thumbStatus{GPUs: []thumbs.GPU{}, User: s.user, Cache: thumbCache{Path: s.thumbs.cache.Path}}
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"
@@ -312,7 +544,7 @@ func (s *Server) thumbsStatus(w http.ResponseWriter, r *http.Request) {
}
st.FFmpeg, st.GPUs, st.Unusable, st.Error = caps.Version, caps.GPUs, caps.Unusable, caps.Error
if st.GPUs == nil {
st.GPUs = []thumbs.GPU{}
st.GPUs = []media.GPU{}
}
st.OK = caps.Error == ""
switch {
@@ -403,6 +635,10 @@ func (s *Server) thumbsGPUAccess(w http.ResponseWriter, r *http.Request) {
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 {
+47
View File
@@ -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)
}
}
-1164
View File
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -27,6 +27,7 @@ import (
type zfsState struct {
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
}
@@ -138,11 +142,13 @@ 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).
// 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}
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)
}
@@ -265,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])
-18
View File
@@ -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"`
+70 -7
View File
@@ -15,6 +15,7 @@ import (
"io"
"os"
"os/exec"
"os/signal"
"os/user"
"path/filepath"
"sort"
@@ -23,8 +24,8 @@ import (
"syscall"
"time"
"hamlab/internal/media"
"hamlab/internal/priv"
"hamlab/internal/thumbs"
)
type Entry struct {
@@ -58,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
}
@@ -139,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"
@@ -149,7 +152,7 @@ func entryOf(dir, name string, lst os.FileInfo) Entry {
e.Type = "other"
}
if e.Type == "file" || (e.Type == "link" && !e.Broken) {
e.Preview = thumbs.Kind(name)
e.Preview = media.Kind(name)
}
return e
}
@@ -229,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)
@@ -334,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()
@@ -385,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)
@@ -399,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")
@@ -430,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)
}
@@ -452,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)
@@ -461,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)
}
+197
View File
@@ -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))
}
}
+195
View File
@@ -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)
}
}
+89
View File
@@ -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
}
+112
View File
@@ -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())
}
}
-231
View File
@@ -1,231 +0,0 @@
package incus
// Nightly exports: every instance as a tarball in virt/exports/<name>/, kept to a count, as a
// cron line like the ZFS schedules — `hamlab virt-export` in /etc/cron.d/hamlab-virt — so the
// backup runs whether or not the console is up. The settings and the last run's record are
// files beside the data, not state.json, because the cron job is a separate process.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
const CronFile = "/etc/cron.d/hamlab-virt"
type ExportConfig struct {
Enabled bool `json:"enabled"`
Hour int `json:"hour"`
Minute int `json:"minute"`
Keep int `json:"keep"` // newest exports kept per instance
InstanceOnly bool `json:"instance_only"` // leave the snapshots out of the tarball
Instances []string `json:"instances,omitempty"` // empty: every instance
Updated time.Time `json:"updated,omitempty"`
}
func DefaultExportConfig() ExportConfig {
return ExportConfig{Hour: 2, Minute: 15, Keep: 3, InstanceOnly: true}
}
func (c ExportConfig) Validate() error {
if c.Hour < 0 || c.Hour > 23 || c.Minute < 0 || c.Minute > 59 {
return errors.New("time: hour 0–23, minute 0–59")
}
if c.Keep < 1 || c.Keep > 100 {
return errors.New("keep: between 1 and 100")
}
for _, n := range c.Instances {
if !ValidName(n) {
return errors.New("instance: " + n)
}
}
return nil
}
func (c ExportConfig) CronExpr() string { return fmt.Sprintf("%d %d * * *", c.Minute, c.Hour) }
// NextRun is the next time the line fires, for the page.
func (c ExportConfig) NextRun(now time.Time) time.Time {
n := time.Date(now.Year(), now.Month(), now.Day(), c.Hour, c.Minute, 0, 0, now.Location())
if !n.After(now) {
n = n.Add(24 * time.Hour)
}
return n
}
// CronText is the whole file; rewritten on every change, never edited.
func (c ExportConfig) CronText(user, exe, config string) string {
var b strings.Builder
b.WriteString("# Ham Lab instance exports — written by the Virtualization tab, rewritten whole on every change.\n")
b.WriteString("# Edit it there, not here.\n")
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
fmt.Fprintf(&b, "# every instance to virt/exports, keep %d each\n", c.Keep)
fmt.Fprintf(&b, "%s %s %s -config %s virt-export\n", c.CronExpr(), user, exe, config)
return b.String()
}
func (m *Manager) exportConfigPath() string { return filepath.Join(m.dir, "exports.json") }
func (m *Manager) exportRunPath() string { return filepath.Join(m.dir, "export-runs.json") }
func (m *Manager) ExportConfig() ExportConfig {
c := DefaultExportConfig()
if b, err := os.ReadFile(m.exportConfigPath()); err == nil {
_ = json.Unmarshal(b, &c)
}
return c
}
func (m *Manager) SaveExportConfig(c ExportConfig) error {
if err := c.Validate(); err != nil {
return err
}
if err := m.EnsureDirs(); err != nil {
return err
}
c.Updated = time.Now()
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(m.exportConfigPath(), b, 0o644)
}
type ExportRun struct {
At time.Time `json:"at"`
Exported []string `json:"exported"`
Pruned []string `json:"pruned"`
Errors []string `json:"errors,omitempty"`
Seconds float64 `json:"seconds"`
}
func (m *Manager) LastExportRun() *ExportRun {
b, err := os.ReadFile(m.exportRunPath())
if err != nil {
return nil
}
var r ExportRun
if json.Unmarshal(b, &r) != nil {
return nil
}
return &r
}
// ExportFile is one tarball in the exports tree.
type ExportFile struct {
Instance string `json:"instance"`
Name string `json:"name"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
func (m *Manager) ExportFiles() []ExportFile {
out := []ExportFile{}
dirs, _ := os.ReadDir(m.ExportsDir())
for _, d := range dirs {
if !d.IsDir() {
continue
}
files, _ := os.ReadDir(filepath.Join(m.ExportsDir(), d.Name()))
for _, f := range files {
if f.IsDir() || strings.HasSuffix(f.Name(), ".part") {
continue
}
if info, err := f.Info(); err == nil {
out = append(out, ExportFile{Instance: d.Name(), Name: f.Name(), Size: info.Size(), Modified: info.ModTime()})
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Instance != out[j].Instance {
return out[i].Instance < out[j].Instance
}
return out[i].Modified.After(out[j].Modified)
})
return out
}
// RunExports exports the named instances (or every one when names is empty) and prunes each
// instance's folder to the configured count. Output goes to w line by line for a task to show.
func (m *Manager) RunExports(ctx context.Context, names []string, w func(string)) ExportRun {
start := time.Now()
c := m.ExportConfig()
run := ExportRun{At: start, Exported: []string{}, Pruned: []string{}}
if err := m.EnsureDirs(); err != nil {
run.Errors = append(run.Errors, err.Error())
return run
}
all, err := m.Instances(ctx)
if err != nil {
run.Errors = append(run.Errors, "listing instances: "+err.Error())
return run
}
want := map[string]bool{}
for _, n := range names {
want[n] = true
}
if len(names) == 0 && len(c.Instances) > 0 {
for _, n := range c.Instances {
want[n] = true
}
}
for _, in := range all {
if len(want) > 0 && !want[in.Name] {
continue
}
dir := filepath.Join(m.ExportsDir(), in.Name)
if err := os.MkdirAll(dir, 0o755); err != nil {
run.Errors = append(run.Errors, in.Name+": "+err.Error())
continue
}
stamp := start.Format("2006-01-02_15.04")
final := filepath.Join(dir, fmt.Sprintf("%s-%s.tar.gz", in.Name, stamp))
part := final + ".part"
args := []string{"export", in.Name, part}
if c.InstanceOnly {
args = append(args, "--instance-only")
}
w(fmt.Sprintf("exporting %s …", in.Name))
cctx, cancel := context.WithTimeout(ctx, 2*time.Hour)
out, err := exec.CommandContext(cctx, "incus", args...).CombinedOutput()
cancel()
if err != nil {
_ = os.Remove(part)
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
run.Errors = append(run.Errors, in.Name+": "+msg)
w(" failed: " + msg)
continue
}
if err := os.Rename(part, final); err != nil {
run.Errors = append(run.Errors, in.Name+": "+err.Error())
continue
}
if st, err := os.Stat(final); err == nil {
w(fmt.Sprintf(" %s (%.1f MiB)", filepath.Base(final), float64(st.Size())/(1<<20)))
}
run.Exported = append(run.Exported, filepath.Base(final))
// prune: newest first, keep c.Keep
var files []ExportFile
for _, f := range m.ExportFiles() {
if f.Instance == in.Name {
files = append(files, f)
}
}
for i := c.Keep; i < len(files); i++ {
if err := os.Remove(filepath.Join(dir, files[i].Name)); err == nil {
run.Pruned = append(run.Pruned, files[i].Name)
w(" pruned " + files[i].Name)
}
}
}
run.Seconds = time.Since(start).Seconds()
b, _ := json.MarshalIndent(run, "", " ")
_ = os.WriteFile(m.exportRunPath(), b, 0o644)
return run
}
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
package incus
import (
"os/exec"
"strings"
"testing"
)
// The install script is generated, so a quoting slip would only show on a live machine.
// Build it for the shapes of machine we have and let sh check the syntax.
func TestInstallScriptShapes(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("no sh")
}
m := New("/home/core/apps/hamlab", "core", "test")
cases := []struct {
name string
st Status
o InstallOpts
want []string
}{
{"grid: zfs, apt, docker", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"zfs", "btrfs", "dir"}, Docker: true, ZFSHost: true, DiskFree: 700 << 30, MemTotal: 62 << 30},
InstallOpts{Driver: "zfs", PoolSizeGiB: 300, Subnet: "172.16.100.1/24", DockerRules: true, ARCMaxGiB: 16},
[]string{"apt-get", "zpool create", "incus storage create local zfs source=hamlab-virt", "ipv4.address='172.16.100.1/24'", "DOCKER-USER", "zfs_arc_max", "usermod -aG incus-admin 'core'", "SupplementaryGroups=incus-admin", "systemd-run"}},
{"core: btrfs, apt, docker, no tools yet", Status{State: "installable", PM: "apt", Arch: "x86_64", Drivers: []string{"btrfs", "dir"}, Docker: true, DiskFree: 170 << 30, MemTotal: 8 << 30},
InstallOpts{Driver: "btrfs", PoolSizeGiB: 40, Subnet: "172.16.100.1/24", DockerRules: true},
[]string{"btrfs-progs", "mkfs.btrfs", "/etc/fstab", "incus storage create local btrfs source="}},
{"mac: dir, dnf, arm64", Status{State: "installable", PM: "dnf", Arch: "aarch64", Drivers: []string{"dir"}, DiskFree: 200 << 30, MemTotal: 15 << 30},
InstallOpts{Driver: "dir", Subnet: "172.16.101.1/24"},
[]string{"dnf -y install incus qemu-system-aarch64-core edk2-aarch64", "incus storage create local dir source="}},
{"repair: already installed", Status{State: "repair", PM: "apt", Arch: "x86_64", Drivers: []string{"dir"}, DiskFree: 10 << 30, MemTotal: 4 << 30},
InstallOpts{Driver: "dir", Subnet: "172.16.100.1/24"},
[]string{"already installed"}},
}
for _, c := range cases {
script, lay, err := m.InstallScript(c.st, c.o)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
for _, w := range c.want {
if !strings.Contains(script, w) {
t.Errorf("%s: script lacks %q", c.name, w)
}
}
if lay.Driver != c.o.Driver || lay.Subnet != c.o.Subnet {
t.Errorf("%s: layout %+v", c.name, lay)
}
cmd := exec.Command("sh", "-n")
cmd.Stdin = strings.NewReader(script)
if out, err := cmd.CombinedOutput(); err != nil {
t.Errorf("%s: sh -n: %v\n%s", c.name, err, out)
}
}
}
func TestInstallScriptRefusals(t *testing.T) {
m := New("/x", "core", "test")
st := Status{State: "installable", PM: "apt", Drivers: []string{"btrfs", "dir"}, DiskFree: 20 << 30, MemTotal: 8 << 30}
for _, o := range []InstallOpts{
{Driver: "zfs", PoolSizeGiB: 10, Subnet: "172.16.100.1/24"}, // not offered here
{Driver: "btrfs", PoolSizeGiB: 50, Subnet: "172.16.100.1/24"}, // more than is free
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "172.16.100.0/24"}, // the network address, not the bridge's
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "10.0.0.1/8"}, // too wide
{Driver: "btrfs", PoolSizeGiB: 10, Subnet: "172.16.100.1/24", ARCMaxGiB: 64}, // more RAM than there is
} {
if _, _, err := m.InstallScript(st, o); err == nil {
t.Errorf("accepted %+v", o)
}
}
}
func TestCreateArgs(t *testing.T) {
args, err := CreateArgs(CreateSpec{Name: "web-1", Image: "images:debian/13", VM: true, CPU: "2", Memory: "2GiB", Disk: "20GiB", Network: "incusbr0", Start: true}, true)
if err != nil {
t.Fatal(err)
}
want := "launch images:debian/13 web-1 --vm -c limits.cpu=2 -c limits.memory=2GiB -d root,size=20GiB -n incusbr0"
if a, err := CreateArgs(CreateSpec{Name: "x", Image: "images:alpine/3.22", Pool: "local"}, true); err != nil || strings.Join(a, " ") != "init images:alpine/3.22 x -d root,pool=local" {
t.Fatalf("pool override: %v %v", a, err)
}
if got := strings.Join(args, " "); got != want {
t.Fatalf("got %q", got)
}
if _, err := CreateArgs(CreateSpec{Name: "web-1", Image: "images:debian/13", VM: true}, false); err == nil {
t.Fatal("a VM without KVM must be refused")
}
if _, err := CreateArgs(CreateSpec{Name: "1bad", Image: "images:debian/13"}, true); err == nil {
t.Fatal("a name starting with a digit must be refused")
}
if _, err := CreateArgs(CreateSpec{Name: "ok", Image: "images:debian/13", Memory: "2 gigs"}, true); err == nil {
t.Fatal("a malformed size must be refused")
}
}
-130
View File
@@ -1,130 +0,0 @@
package incus
// The bridge subnet. Incus's own `auto` picks a random 10.x.y.0/24, and on a LAN that is
// itself 10.0.0.0/8 (this one) every such pick is on-link and collides. So Ham Lab chooses:
// the first /24 that nothing on the machine already routes, is not an address it holds, and
// is outside Docker's configured pools — tried in an order that also stays clear of Docker's
// *defaults* (172.17–172.31 and 192.168.0.0/16 carved into /20s), which a Docker that has no
// daemon.json will hand out later without asking anyone.
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"os"
"os/exec"
"strings"
"time"
)
// candidates in order of preference: 172.16.0.0/16 is the one RFC 1918 block Docker never
// takes by default; then the top of 192.168/16, which Docker reaches last; then the rest.
func candidates() []netip.Prefix {
var out []netip.Prefix
for i := 100; i <= 254; i++ {
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
}
for i := 254; i >= 200; i-- {
out = append(out, netip.MustParsePrefix(fmt.Sprintf("192.168.%d.0/24", i)))
}
for i := 1; i < 100; i++ {
out = append(out, netip.MustParsePrefix(fmt.Sprintf("172.16.%d.0/24", i)))
}
return out
}
func overlaps(a, b netip.Prefix) bool {
return a.Contains(b.Addr()) || b.Contains(a.Addr())
}
// PickSubnet returns the first candidate /24 clear of every prefix in taken.
func PickSubnet(taken []netip.Prefix) netip.Prefix {
for _, c := range candidates() {
clear := true
for _, t := range taken {
if overlaps(c, t) {
clear = false
break
}
}
if clear {
return c
}
}
return netip.MustParsePrefix("172.16.100.0/24") // every candidate taken: not a machine this will run on
}
// Gateway is the bridge's own address in a subnet: its first host, as CIDR, the way
// Incus's ipv4.address wants it.
func Gateway(p netip.Prefix) string {
return fmt.Sprintf("%s/%d", p.Masked().Addr().Next(), p.Bits())
}
// TakenPrefixes reads what the machine already uses: its routes, its addresses, and the
// pools Docker is configured to carve networks from.
func TakenPrefixes(ctx context.Context) []netip.Prefix {
var out []netip.Prefix
add := func(s string) {
if p, err := netip.ParsePrefix(s); err == nil && p.Addr().Is4() {
out = append(out, p.Masked())
} else if a, err := netip.ParseAddr(s); err == nil && a.Is4() {
out = append(out, netip.PrefixFrom(a, 32))
}
}
c, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "route", "show").Output(); err == nil {
var routes []struct{ Dst string }
if json.Unmarshal(b, &routes) == nil {
for _, r := range routes {
if r.Dst != "" && r.Dst != "default" {
add(r.Dst)
}
}
}
}
if b, err := exec.CommandContext(c, "ip", "-j", "-4", "addr", "show").Output(); err == nil {
var links []struct {
AddrInfo []struct {
Local string `json:"local"`
Prefixlen int `json:"prefixlen"`
} `json:"addr_info"`
}
if json.Unmarshal(b, &links) == nil {
for _, l := range links {
for _, a := range l.AddrInfo {
add(fmt.Sprintf("%s/%d", a.Local, a.Prefixlen))
}
}
}
}
for _, p := range dockerPools() {
add(p)
}
return out
}
// dockerPools reads default-address-pools from Docker's daemon.json, and falls back to
// Docker's built-in defaults when the file names none.
func dockerPools() []string {
var cfg struct {
Pools []struct {
Base string `json:"base"`
} `json:"default-address-pools"`
}
if b, err := os.ReadFile("/etc/docker/daemon.json"); err == nil && json.Unmarshal(b, &cfg) == nil && len(cfg.Pools) > 0 {
var out []string
for _, p := range cfg.Pools {
out = append(out, p.Base)
}
return out
}
if _, err := exec.LookPath("docker"); err != nil {
return nil
}
return []string{"172.17.0.0/12", "192.168.0.0/16"}
}
// String form used in the layout file and on the page.
func prefixString(p netip.Prefix) string { return strings.TrimSpace(p.Masked().String()) }
-41
View File
@@ -1,41 +0,0 @@
package incus
import (
"net/netip"
"testing"
)
func TestPickSubnetAvoidsTaken(t *testing.T) {
taken := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"), // the LAN, a /8
netip.MustParsePrefix("172.16.100.0/24"), // first choice gone
netip.MustParsePrefix("172.16.101.0/24"), // and the second
netip.MustParsePrefix("172.28.0.0/14"), // Docker's configured pool
netip.MustParsePrefix("192.168.80.0/20"), // a Docker network
}
got := PickSubnet(taken)
if got.String() != "172.16.102.0/24" {
t.Fatalf("got %s", got)
}
if Gateway(got) != "172.16.102.1/24" {
t.Fatalf("gateway %s", Gateway(got))
}
}
func TestPickSubnetWholeBlockTaken(t *testing.T) {
// 172.16.0.0/12 covers every 172.16.x candidate; the picker must move to 192.168
got := PickSubnet([]netip.Prefix{netip.MustParsePrefix("172.16.0.0/12")})
if got.String() != "192.168.254.0/24" {
t.Fatalf("got %s", got)
}
}
func TestOverlapsEitherWay(t *testing.T) {
a := netip.MustParsePrefix("172.16.5.0/24")
if !overlaps(a, netip.MustParsePrefix("172.16.0.0/12")) || !overlaps(netip.MustParsePrefix("172.16.0.0/12"), a) {
t.Fatal("a /24 inside a /12 overlaps both ways")
}
if overlaps(a, netip.MustParsePrefix("172.16.6.0/24")) {
t.Fatal("neighbours do not overlap")
}
}
-192
View File
@@ -1,192 +0,0 @@
package incus
// The graphical console. Incus exposes a VM's SPICE display as a websocket: POST to the
// instance's console endpoint with type vga and it answers an operation with two secrets,
// one for the display stream and one for a control channel. Ham Lab dials both over Incus's
// unix socket and hands the display stream to the browser, where spice-html5 draws it.
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/coder/websocket"
)
// SocketPath is where Incus's API listens; the daemon's account reaches it by being in
// incus-admin, which Install arranges.
func SocketPath() string {
if p := os.Getenv("INCUS_SOCKET"); p != "" {
return p
}
for _, p := range []string{"/var/lib/incus/unix.socket", "/run/incus/unix.socket"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return "/var/lib/incus/unix.socket"
}
func (m *Manager) apiClient() *http.Client {
sock := SocketPath()
return &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", sock)
},
ForceAttemptHTTP2: false,
}}
}
// A SPICE client opens one connection per channel — main, display, inputs, cursor, playback
// — and spice-html5 does the same with websockets, all to one URL. Incus allows exactly one
// console operation per VM at a time, but takes any number of data websockets on it. So a VGA
// session is one operation, held for as long as any channel is open, and every browser
// websocket becomes one more data websocket on it.
type vgaSession struct {
op string
secret string
control *websocket.Conn
cancel context.CancelFunc
refs int
}
// VGAChannel returns a fresh data websocket on the VM's console operation, opening the
// operation first when none is held. release must be called when the channel is done.
func (m *Manager) VGAChannel(ctx context.Context, name string) (data *websocket.Conn, release func(), err error) {
if !ValidName(name) {
return nil, nil, errors.New("bad name")
}
client := m.apiClient()
m.mu.Lock()
defer m.mu.Unlock()
if m.vga == nil {
m.vga = map[string]*vgaSession{}
}
dial := func(s *vgaSession) (*websocket.Conn, error) {
c, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", s.op, s.secret)
ws, _, err := websocket.Dial(c, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
return ws, err
}
s := m.vga[name]
if s != nil {
if ws, err := dial(s); err == nil {
s.refs++
ws.SetReadLimit(64 << 20)
return ws, m.releaser(name, s), nil
}
// the operation is gone (the VM restarted, or Incus did): start over
s.cancel()
delete(m.vga, name)
}
s, err = m.openVGA(ctx, client, name, false)
if err != nil && strings.Contains(err.Error(), "already connected") {
// a session a previous Ham Lab process held and never let go of; take it over
s, err = m.openVGA(ctx, client, name, true)
}
if err != nil {
return nil, nil, err
}
ws, err := dial(s)
if err != nil {
s.cancel()
return nil, nil, err
}
ws.SetReadLimit(64 << 20)
s.refs = 1
m.vga[name] = s
return ws, m.releaser(name, s), nil
}
func (m *Manager) releaser(name string, s *vgaSession) func() {
return func() {
m.mu.Lock()
defer m.mu.Unlock()
s.refs--
if s.refs <= 0 && m.vga[name] == s {
s.cancel()
delete(m.vga, name)
}
}
}
// openVGA asks Incus for the console operation and holds its control websocket, which is
// what keeps the operation alive.
func (m *Manager) openVGA(ctx context.Context, client *http.Client, name string, force bool) (*vgaSession, error) {
body, _ := json.Marshal(map[string]any{"type": "vga", "width": 0, "height": 0, "force": force})
c, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(c, "POST", "http://incus/1.0/instances/"+name+"/console", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
if isPermission(err) {
return nil, errors.New("Ham Lab's account can't reach Incus's socket; Finish setup on the Virtualization tab puts it in incus-admin")
}
return nil, err
}
defer resp.Body.Close()
var op struct {
Error string `json:"error"`
Metadata struct {
ID string `json:"id"`
Metadata struct {
FDs map[string]string `json:"fds"`
} `json:"metadata"`
} `json:"metadata"`
}
if err := json.NewDecoder(resp.Body).Decode(&op); err != nil {
return nil, errors.New("unreadable answer from incus")
}
if op.Error != "" {
return nil, errors.New(op.Error)
}
secret, controlSecret := op.Metadata.Metadata.FDs["0"], op.Metadata.Metadata.FDs["control"]
if op.Metadata.ID == "" || secret == "" {
return nil, errors.New("incus gave no console; is the VM running?")
}
s := &vgaSession{op: op.Metadata.ID, secret: secret}
// the control websocket lives outside any one request: it is the session
sctx, scancel := context.WithCancel(context.Background())
s.cancel = scancel
if controlSecret != "" {
u := fmt.Sprintf("ws://incus/1.0/operations/%s/websocket?secret=%s", s.op, controlSecret)
control, _, err := websocket.Dial(sctx, u, &websocket.DialOptions{HTTPClient: client, CompressionMode: websocket.CompressionDisabled})
if err != nil {
scancel()
return nil, err
}
s.control = control
go func() { // drain it; when it ends, so has the operation
for {
if _, _, err := control.Read(sctx); err != nil {
break
}
}
control.Close(websocket.StatusNormalClosure, "")
}()
go func() { <-sctx.Done(); control.Close(websocket.StatusNormalClosure, "") }()
}
return s, nil
}
func isPermission(err error) bool {
var pe *os.PathError
if errors.As(err, &pe) {
return os.IsPermission(pe)
}
var oe *net.OpError
if errors.As(err, &oe) {
return os.IsPermission(oe.Err) || isPermission(oe.Err)
}
return false
}
@@ -1,4 +1,4 @@
package thumbs
package media
import (
"crypto/sha256"
+342
View File
@@ -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
}
+171
View File
@@ -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)
}
}
+223
View File
@@ -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
}
+176
View File
@@ -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
}
@@ -1,4 +1,4 @@
package thumbs
package media
import (
"fmt"
+462
View File
@@ -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
}
}
}
+129
View File
@@ -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)
}
}
@@ -11,7 +11,7 @@
// 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 thumbs
package media
import (
"bytes"
@@ -39,6 +39,8 @@ type GPU struct {
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
}
@@ -49,7 +51,8 @@ type Caps struct {
FFprobe string `json:"ffprobe,omitempty"`
Nice string `json:"nice,omitempty"`
Version string `json:"version,omitempty"`
GPUs []GPU `json:"gpus"` // usable, in preference order
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"`
@@ -59,6 +62,24 @@ type Caps struct {
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 {
@@ -78,6 +99,8 @@ type Result struct {
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.
@@ -93,6 +116,7 @@ func (e *DecodeError) Error() string { return e.Msg }
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{}
@@ -102,7 +126,8 @@ func set(s string) map[string]bool {
return m
}
// Kind is "image", "video" or "" for a file name.
// 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 {
@@ -110,6 +135,8 @@ func Kind(name string) string {
return "image"
case videoExt[ext]:
return "video"
case audioExt[ext]:
return "audio"
}
return ""
}
@@ -135,13 +162,14 @@ func Probe(ctx context.Context) Caps {
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)}
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)
@@ -153,7 +181,7 @@ func Probe(ctx context.Context) Caps {
continue
}
if vainfo != "" {
g.Codecs = vainfoCodecs(ctx, vainfo, dev)
g.Codecs, g.Encodes = vainfoCodecs(ctx, vainfo, dev)
}
c.GPUs = append(c.GPUs, g)
}
@@ -242,28 +270,61 @@ func hwaccels(ctx context.Context, ffmpeg string) map[string]bool {
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*VAEntrypointVLD`)
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) []string {
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 keeps the decode (VLD) profiles and names their codecs the way ffmpeg does.
func parseVainfo(text string) []string {
seen := map[string]bool{}
// 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) {
if c, ok := vaCodec[m[1]]; ok {
seen[c] = true
c, ok := vaCodec[m[1]]
if !ok {
continue
}
if m[2] == "VLD" {
dec[c] = true
} else {
enc[c] = true
}
}
out := make([]string, 0, len(seen))
for c := range seen {
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)
@@ -315,7 +376,10 @@ func (a attempt) engine() string {
// Make produces the preview of one file.
func Make(ctx context.Context, c Caps, path string, o Opts) (Result, error) {
kind := Kind(path)
kind := o.Kind
if kind == "" {
kind = Kind(path)
}
if kind == "" {
return Result{}, ErrUnsupported
}
@@ -1,4 +1,4 @@
package thumbs
package media
import (
"bytes"
@@ -41,20 +41,32 @@ func TestParseVainfo(t *testing.T) {
VAProfileH264High : VAEntrypointEncSliceLP
VAProfileJPEGBaseline : VAEntrypointVLD
VAProfileHEVCMain10 : VAEntrypointVLD
VAProfileHEVCMain10 : VAEntrypointEncSlice
VAProfileHEVCMain444_12 : VAEntrypointVLD
VAProfileVP9Profile2 : VAEntrypointVLD
VAProfileAV1Profile0 : VAEntrypointEncSliceLP
`
got := parseVainfo(text)
want := []string{"h264", "hevc", "mjpeg", "mpeg2video", "vp9"}
dec, enc := parseVainfo(text)
same := func(got, want []string) bool {
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
return false
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
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) {
+52 -15
View File
@@ -95,30 +95,41 @@ func readRAPL(root string, dirs []string) ([]raplCount, bool) {
// is carried across its range. One that went backwards with no known range, or a set of
// counters that changed between readings, gives no answer rather than a wrong one.
func raplWatts(prev, cur []raplCount, max map[string]uint64, dt float64) (float64, bool) {
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
return 0, false
each, ok := raplWattsEach(prev, cur, max, dt)
var w float64
for _, x := range each {
w += x
}
var uj float64
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 0, false
return nil, false
}
if cur[i].UJ >= prev[i].UJ {
uj += float64(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 0, false
return nil, false
}
uj += float64(m - prev[i].UJ + cur[i].UJ)
out[i] = float64(m-prev[i].UJ+cur[i].UJ) / 1e6 / dt
}
return uj / 1e6 / dt, true
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.
func (c *Collector) cpuPower(now time.Time) float64 {
// 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
@@ -131,7 +142,7 @@ func (c *Collector) cpuPower(now time.Time) float64 {
}
}
if len(p.dirs) == 0 {
return 0
return 0, nil
}
var cur []raplCount
ok := false
@@ -148,14 +159,40 @@ func (c *Collector) cpuPower(now time.Time) float64 {
}
if !ok {
p.prev = nil
return 0
return 0, nil
}
w, good := raplWatts(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
each, good := raplWattsEach(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
p.prev, p.prevAt = cur, now
if !good {
return 0
return 0, nil
}
return round1(w)
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
+16
View File
@@ -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)
}
}
+64 -2
View File
@@ -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
@@ -43,6 +45,21 @@ func fold(b []Sample) Sample {
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] })
@@ -70,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]
}
}
@@ -122,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{}
+226 -24
View File
@@ -49,6 +49,7 @@ type GPU struct {
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"`
@@ -56,7 +57,7 @@ type GPU struct {
MemTotal uint64 `json:"mem_total,omitempty"`
Clients []GPUClient `json:"clients"`
ClientsOK bool `json:"clients_ok"` // false when fdinfo of other users' processes could not be read
Stalled bool `json:"stalled,omitempty"` // a sysfs read of this card never returned; its readings are paused until restart
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.
@@ -70,7 +71,7 @@ type GPUClient struct {
// 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,
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 {
@@ -78,14 +79,25 @@ type gpuDev struct {
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, reused between walks
scanAt time.Time
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
}
@@ -96,7 +108,6 @@ type clientPrev struct {
}
const gpuRelist = time.Minute // how often the card list is re-read: hotplug is rare, the readdir is cheap
const gpuScanEvery = time.Second // engine shares over less than this are noise
// gpus is the GPU section of a sample. cpuTemp stands in for an integrated GPU's
// temperature: it is the same die.
@@ -111,10 +122,13 @@ func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
if g.clients == nil {
g.clients = map[string]clientPrev{}
}
if g.scanAt.IsZero() || now.Sub(g.scanAt) >= gpuScanEvery {
g.last, g.ok = c.scanClients()
g.scanAt = now
// 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 {
@@ -129,7 +143,13 @@ func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
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
@@ -264,6 +284,17 @@ func RenderNodeName(node string) string {
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 (
@@ -328,28 +359,60 @@ func lspciName(desc string) string {
return strings.TrimSpace(vendor + " " + desc)
}
// readWithin is read with a deadline. A sysfs read that does not come back must not take
// the whole sampler with it — every graph on every page of this machine stops — so the
// device is marked stalled, its goroutine abandoned, and the rest of the sample goes on.
const gpuReadWithin = 3 * time.Second
// 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 {
x.Stalled = true
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
}
done := make(chan GPU, 1)
go func() {
y := *x
d.read(&y, now)
done <- y
}()
select {
case y := <-done:
*x = y
case <-time.After(gpuReadWithin):
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
log.Printf("gpu: %s (%s): a sysfs read did not return within %s; its readings are paused until restart", d.name, d.card, gpuReadWithin)
}
}
@@ -357,6 +420,8 @@ func (d *gpuDev) readWithin(x *GPU, now time.Time) {
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")
@@ -415,6 +480,14 @@ func (d *gpuDev) read(x *GPU, now time.Time) {
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
}
@@ -692,3 +765,132 @@ func ueventValue(p, key string) string {
}
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
}
+52
View File
@@ -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)
}
}
+63 -2
View File
@@ -61,7 +61,7 @@ func TestGPUClientsShare(t *testing.T) {
c.gpu.clients = map[string]clientPrev{}
t0 := time.Now()
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: fdinfoA}}
c.gpu.scanAt = t0 // no walk: the fake list stands
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)
@@ -73,7 +73,6 @@ func TestGPUClientsShare(t *testing.T) {
later = replace(later, "drm-engine-render: 1000000000 ns", "drm-engine-render: 1500000000 ns")
later = replace(later, "drm-engine-video: 2000000000 ns", "drm-engine-video: 4000000000 ns")
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: later}}
c.gpu.scanAt = t0.Add(time.Second)
got := c.gpus(t0.Add(time.Second), 40)
cl := got[0].Clients[0]
if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 {
@@ -170,3 +169,65 @@ func TestPCIName(t *testing.T) {
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)
}
}
+72 -7
View File
@@ -60,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 {
@@ -101,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 {
@@ -140,6 +144,7 @@ type Sample struct {
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"`
@@ -151,6 +156,7 @@ type Sample struct {
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
@@ -182,6 +188,7 @@ type Collector struct {
gpu gpuState
power cpuPowerState
prevARC arcPrev
prevZW zfsWritePrev
subMu sync.Mutex
subs map[chan struct{}]struct{} // told after every sample; see Subscribe
@@ -347,7 +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 = c.cpuPower(now)
s.CPUPower, s.CPUPowerPkgs = c.cpuPower(now)
s.Load = loadavg()
s.Mem, s.Swap = meminfo()
s.Uptime = uptime()
@@ -368,6 +375,7 @@ func (c *Collector) sample() {
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
@@ -541,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
}
@@ -556,7 +577,10 @@ func hwmon() ([]Temp, []Fan) {
if temps[i].Chip != temps[j].Chip {
return temps[i].Chip < temps[j].Chip
}
if temps[i].Label != temps[j].Label {
return temps[i].Label < temps[j].Label
}
return temps[i].Pkg < temps[j].Pkg
})
return temps, fans
}
@@ -842,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:
@@ -1109,6 +1133,38 @@ 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"`
@@ -1119,8 +1175,10 @@ type ProcTable struct {
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"`
@@ -1179,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 {
@@ -1318,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, t.CPUPowerW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower
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
+47 -1
View File
@@ -65,6 +65,10 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
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)
}
@@ -92,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)
@@ -129,13 +137,22 @@ 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)
@@ -164,6 +181,9 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
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...)
}
@@ -203,6 +223,32 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
}
}
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)
+456
View File
@@ -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
}
+86
View File
@@ -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
View File
@@ -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.
+564
View File
@@ -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
}
+137
View File
@@ -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)
}
}
+10 -4
View File
@@ -69,12 +69,18 @@ type State struct {
// orchestrator that has since been removed can still be found on disk. See
// internal/api/stacks.go.
Mounts []RememberedMount `json:"mounts,omitempty"`
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so
// the next browser — or this one after it has forgotten them — is signed into the device
// already. See internal/api/kvm.go.
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
// 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.
+9 -2
View File
@@ -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()
+7
View File
@@ -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
View File
@@ -7,12 +7,13 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"icons": "node scripts/icons.mjs",
"test": "node scripts/test-graphcols.mjs && node scripts/test-css-imports.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"
+164
View File
@@ -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)
+24 -1
View File
@@ -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')
+16
View File
@@ -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)`)
+34
View File
@@ -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')
+11 -9
View File
@@ -402,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[]>()
@@ -414,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>
@@ -492,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>
)
}
@@ -522,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
@@ -535,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]
@@ -548,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 (
@@ -567,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>
)}
@@ -581,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>
+205 -17
View File
@@ -1,13 +1,28 @@
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, FileEntry, FileInfo, ThumbStatus } 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
@@ -17,6 +32,7 @@ function icon(e: FileEntry): string {
if (e.type === 'other') 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 '📄'
@@ -25,6 +41,95 @@ 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 ----
@@ -43,7 +148,7 @@ class Near {
private forget(el: Element) { this.cbs.delete(el); this.obs.unobserve(el) }
}
function Thumb({ url, kind, near, fallback }: { url: string; kind: 'image' | 'video'; near: Near | null; fallback: string }) {
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('')
@@ -94,17 +199,20 @@ function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: bool
}
// ---- 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 {
@@ -146,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>}
@@ -161,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>
)
@@ -184,6 +345,8 @@ 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)
@@ -192,6 +355,9 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
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])
@@ -204,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())))
@@ -227,12 +400,10 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
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) && 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)
@@ -248,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
@@ -258,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}>
@@ -279,7 +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 ? ` — ${thumbs.summary}` + (thumbs.cache.entries ? ` · ${thumbs.cache.entries} kept (${fmtBytes(thumbs.cache.bytes)})` : '') : '')} onClick={() => setPreviews(!previews)}>previews</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>
@@ -292,6 +477,8 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
</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>}
@@ -330,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
View File
@@ -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
View File
@@ -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)}` : ''].filter(Boolean).join(' · ')}</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
View File
@@ -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>
)
}
+2 -2
View File
@@ -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
+418
View File
@@ -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>
)
}
+59 -1
View File
@@ -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">
@@ -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>
</>
)
}
+3 -9
View File
@@ -15,8 +15,6 @@ import Hardware from './Hardware'
import Network from './Network'
import VPN from './VPN'
import ZFS from './ZFS'
import Virtualization from './Virtualization'
import KVM from './KVM'
import Files from './Files'
import Top from './Top'
import Machines from './Machines'
@@ -82,8 +80,6 @@ const GROUPS: Group[] = [
{ id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'virt', label: 'Virtualization', icon: '▣' },
{ id: 'kvm', label: 'KVM', icon: '⌨' },
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
@@ -93,8 +89,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' },
]
type View = 'terminal' | 'widgets' | 'docker' | 'virt' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
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
@@ -326,12 +322,10 @@ 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 />}
{view === 'docker' && <Docker />}
{view === 'virt' && <Virtualization />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
{view === 'procs' && <Top />}
{view === 'services' && <Services />}
@@ -369,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 === 'virt' ? 'Virtualization' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
<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>
)
+117 -48
View File
@@ -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 { 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>
}
@@ -187,12 +191,17 @@ 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,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$|^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 })()
@@ -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)
@@ -365,29 +417,46 @@ 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` : ''}
{pt?.cpu_power_w ? <span title="the CPU's own power reading: cores, built-in graphics and memory controller, not the whole machine">{pt.cpu_mhz ? ' · ' : ''}{pt.cpu_power_w < 10 ? pt.cpu_power_w.toFixed(1) : Math.round(pt.cpu_power_w)} W</span> : null}</div>
<div className="t-core">
<div className={'t-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>}
{(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>
{cores.map((v, i) => {
const t = coreTemp(i)
)
})()}
{(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{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-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>
@@ -397,7 +466,7 @@ export default function Top() {
<div className="top-gpus">
{gpus.map(({ g, stem }, i) => (
<Box key={stem} name={`gpu${i}`} className={'top-gpu' + (unmeasured(g) ? ' slim' : '')} right={<span className="t-gpu-name" title={[g.name, g.driver, g.bus].filter(Boolean).join(' · ')}>{g.name}</span>}>
{unmeasured(g) ? <GPULine g={g} /> : <GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} cap={cap} />}
{unmeasured(g) ? <GPULine g={g} /> : <GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} />}
</Box>
))}
</div>
@@ -421,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>}
@@ -441,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>
@@ -522,16 +593,14 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, gpu, empty }: { pro
// that have it open with their share. What cannot be measured on this card is left out rather
// than shown as zero: an Arc on i915 has no temperature sensor, Asahi has no busy counter.
const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'], ['video', 'video'], ['video-enhance', 'enhance'], ['copy', 'copy']]
function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; tick: number; advance: number; cap: number }) {
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
const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts'
: g.usage_src === 'idle' ? 'busy, from idle residency' : g.usage_src === 'busy' ? 'busy, as the driver reports it' : g.usage_src === 'clients' ? 'the busiest engine, summed over its clients' : 'this driver exposes no activity counter'
return (
<div className="t-gpu">
<div className="t-gpu-graph">
<Graph values={values} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
<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>
@@ -555,7 +624,7 @@ function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; t
<span className="t-fact" title={g.req_mhz ? `${Math.round(g.req_mhz)} MHz requested${g.max_mhz ? `, ${Math.round(g.max_mhz)} MHz max` : ''}` : undefined}>{Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz</span></div> : null}
{(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ? <div className="t-row t-sub"><span>Power</span><span className="spacer" />
<span className="t-fact">{(g.power_w ?? 0).toFixed(1)}{g.power_cap_w ? ` / ${Math.round(g.power_cap_w)}` : ''} W</span></div> : null}
{g.fan_rpm ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{g.fan_rpm} rpm</span></div> : null}
{g.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>
@@ -576,13 +645,13 @@ function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; t
// Nothing on this GPU can be read — no busy counter, engine, temperature, clock, power, memory,
// nor a process with it open: the Mac's Asahi, today. Its box would be a graph that can never
// draw beside a column of dashes, so it is one line instead. A client opening it brings the box back.
const unmeasured = (g: GPU) => !g.usage_src && !g.engines && !(g.temp && g.temp > 0) && !g.mhz && !g.max_mhz && !g.power_w && !g.power_cap_w && !g.fan_rpm && !g.mem_total && !g.mem_used && g.clients.length === 0
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 sysfs read of this card stopped answering">readings paused until the console restarts</span>
? <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>
-86
View File
@@ -1,86 +0,0 @@
// The graphical console of a VM: spice-html5 (vendored, loaded on demand) drawing the SPICE
// stream that Ham Lab bridges from Incus into a canvas. Used for installers from an ISO, and
// anything else with a screen rather than a serial line.
import { useEffect, useRef, useState } from 'react'
import { wsURLPath } from './api'
import { Btn, useEscape } from './ui'
// No spice.css: upstream's stylesheet is for its own demo page and sets body, *, button and
// canvas for the whole document. Imported here it restyled every button in Ham Lab. The
// console is styled by the .vga rules in styles.css.
type Spice = typeof import('./vendor/spice-html5/src/main.js')
export default function VgaConsole({ name, onClose }: { name: string; onClose: () => void }) {
const [state, setState] = useState<'loading' | 'connecting' | 'connected' | 'error'>('loading')
const [err, setErr] = useState('')
const [fit, setFit] = useState(true)
const [size, setSize] = useState('')
const conn = useRef<InstanceType<Spice['SpiceMainConn']> | null>(null)
const mod = useRef<Spice | null>(null)
const area = useRef<HTMLDivElement>(null)
const screen = useRef<HTMLDivElement>(null)
useEscape(onClose)
useEffect(() => {
let dead = false
import('./vendor/spice-html5/src/main.js').then((m) => {
if (dead) return
mod.current = m
setState('connecting')
try {
conn.current = new m.SpiceMainConn({
uri: wsURLPath(`/api/virt/instances/${encodeURIComponent(name)}/vga`),
screen_id: 'spice-screen', message_id: 'spice-message',
onerror: (e) => { setState('error'); setErr(e instanceof Error ? e.message : (e as Event & { reason?: string })?.reason || 'the console closed') },
onsuccess: () => setState('connected'),
onagent: () => { try { m.handle_resize() } catch { /* no agent in the guest yet */ } },
})
} catch (e) { setState('error'); setErr(String(e)) }
}).catch((e) => { setState('error'); setErr('could not load the SPICE viewer: ' + String(e)) })
return () => { dead = true; try { conn.current?.stop() } catch { /* already gone */ } conn.current = null }
}, [name])
// The guest draws at its own resolution; scale the canvas to the pane unless asked not to.
useEffect(() => {
const a = area.current, s = screen.current
if (!a || !s) return
const apply = () => {
const c = s.querySelector('canvas')
if (!c) return
setSize(`${c.width}×${c.height}`)
c.tabIndex = 1
if (!fit) { s.style.transform = ''; s.style.width = ''; s.style.height = ''; return }
const k = Math.min(1, (a.clientWidth - 8) / c.width, (a.clientHeight - 8) / c.height)
s.style.transformOrigin = 'top left'
s.style.transform = `scale(${k})`
s.style.width = `${c.width}px`; s.style.height = `${c.height}px`
}
apply()
const ro = new ResizeObserver(apply); ro.observe(a)
const mo = new MutationObserver(apply); mo.observe(s, { childList: true, attributes: true, subtree: true, attributeFilter: ['width', 'height'] })
return () => { ro.disconnect(); mo.disconnect() }
}, [fit, state])
const cad = () => { if (conn.current && mod.current) mod.current.sendCtrlAltDel(conn.current) }
const focus = () => screen.current?.querySelector('canvas')?.focus()
return (
<div className="modal-back vga-back" onClick={onClose}>
<div className="vga" onClick={(e) => e.stopPropagation()}>
<div className="logs-bar vga-bar">
<span className={'dot ' + (state === 'connected' ? 'ok' : state === 'error' ? 'bad' : 'warn')} />
<span className="dname">{name}</span>
<span className="muted small">{state === 'connected' ? `VGA · ${size || 'waiting for the first frame'}` : state === 'error' ? err : state === 'loading' ? 'loading the viewer…' : 'connecting…'}</span>
<span className="spacer" />
<Btn className="btn" disabled={state !== 'connected'} onClick={cad} title="send Ctrl+Alt+Del to the guest">Ctrl+Alt+Del</Btn>
<button className={'btn' + (fit ? ' on' : '')} onClick={() => setFit(!fit)} title="scale the screen to this window, or show it pixel for pixel">Fit</button>
<button className="btn" onClick={onClose}>Close</button>
</div>
<div className="vga-area" ref={area} onClick={focus}>
<div id="spice-area"><div id="spice-screen" ref={screen} /></div>
<div id="spice-message" className="spice-message vga-msg" />
</div>
<div className="muted small vga-hint">Click the screen to type into it. Keys go to the guest while it has focus; Escape with focus elsewhere closes this.</div>
</div>
</div>
)
}
-715
View File
@@ -1,715 +0,0 @@
// Virtualization: LXC containers and KVM virtual machines through Incus, per machine. The
// page has four states — ready, repair, installable, unavailable — decided by what the
// selected machine can do, never by a rule about what should run where. Everything the
// tab makes lives under Ham Lab's own folder on that machine.
import { FormEvent, Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { api, ExportsView, LibraryFile, TaskInfo, VirtImage, VirtInstance, VirtOverview, VirtStatus } from './api'
import VgaConsole from './VgaConsole'
import TaskOutput from './TaskOutput'
import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs'
import { fmtBytes } from './fmt'
import { askText, askYes, Btn, CloseBtn, Loading, toast, useEscape } from './ui'
type Tab = 'instances' | 'images' | 'library'
const GiB = 1 << 30
const dot = (i: VirtInstance) => (i.status === 'Running' ? 'ok' : i.status === 'Stopped' ? '' : i.status === 'Error' ? 'bad' : 'warn')
const kind = (i: VirtInstance) => (i.type === 'virtual-machine' ? 'VM' : 'LXC')
const when = (iso?: string) => { if (!iso) return '—'; const d = new Date(iso); return isNaN(d.getTime()) || d.getFullYear() < 1980 ? '—' : d.toLocaleString() }
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'failed')
const imgLabel = (im: VirtImage) => im.aliases[0] ?? im.fingerprint.slice(0, 12)
export default function Virtualization() {
const [ov, setOv] = useState<VirtOverview | null>(null)
const [err, setErr] = useState('')
const [tab, setTab] = useState<Tab>('instances')
const [sel, setSel] = useHashSel('virt')
const [making, setMaking] = useState<null | { image?: string; vm?: boolean }>(null)
const [fromIso, setFromIso] = useState<string | null>(null)
const [task, setTask] = useState<{ id: string; title: string } | null>(null)
const [filter, setFilter] = useState('')
const rate = useRefreshMs()
const load = useCallback(async () => { try { setOv(await api.virt()); setErr('') } catch (e) { setErr(errMsg(e)) } }, [])
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
const startTask = (t: TaskInfo) => setTask({ id: t.id, title: t.title })
if (!ov) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Virtualization tab. Update it from Machines." : err}</div> : <Loading label="Asking Incus…" />}</div>
const st = ov.status
if (st.state !== 'ready') return <Gate st={st} onChanged={load} />
const instances = ov.instances ?? []
const images = ov.images ?? []
const cur = instances.find((i) => i.name === sel)
const running = instances.filter((i) => i.status === 'Running').length
const f = filter.trim().toLowerCase()
const shown = instances.filter((i) => !f || `${i.name} ${i.image} ${i.type} ${i.status}`.toLowerCase().includes(f))
const libCount = ov.library.images.length + ov.library.isos.length
return (
<div className={'svc virt' + (cur && tab === 'instances' ? ' has-sel' : '') + (tab !== 'instances' ? ' wide' : '')}>
<div className="svc-list">
<div className="svc-tools">
<div className="chips">
{(['instances', 'images', 'library'] as Tab[]).map((t) => (
<button key={t} className={'chip' + (tab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>
{t}{t === 'instances' ? ` · ${running}/${instances.length}` : t === 'images' ? ` · ${images.length}` : ` · ${libCount}`}
</button>
))}
</div>
{tab === 'instances' && (
<div className="svc-actions">
<button className="btn on" onClick={() => setMaking({})}>+ New instance</button>
<span className="spacer" />
<span className="muted small">{st.kvm ? 'containers and VMs' : 'containers only — no KVM on this machine'}</span>
</div>
)}
{tab === 'instances' && instances.length > 3 && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter instances" />}
{ov.error && <div className="error">{ov.error}</div>}
{task && <TaskOutput id={task.id} onDone={(t) => { load(); if (t.exit_code === 0 && !t.error) toast(`${task.title}: done`); else toast(`${task.title}: failed`, 'err') }} />}
{task && <div className="svc-actions"><button className="btn" onClick={() => setTask(null)}>Hide output</button></div>}
</div>
{tab === 'instances' && (
<div className="svc-rows">
{shown.map((i) => (
<div key={i.name} className={'svc-row' + (i.name === sel ? ' active' : '')} onClick={() => setSel(i.name)}>
<span className={'sdot ' + dot(i)} />
<div className="svc-name"><div className="n">{i.name} <span className="badge">{kind(i)}</span></div><div className="d">{i.image || '—'}{i.ipv4.length ? ` · ${i.ipv4.join(' ')}` : ''}</div></div>
<div className="svc-state">{i.status === 'Running' ? <>{fmtBytes(i.mem_usage)}<br />{i.cpu_pct.toFixed(0)}%</> : i.status.toLowerCase()}</div>
</div>
))}
{instances.length === 0 && <div className="muted small" style={{ padding: 12 }}>Nothing here yet. “+ New instance” makes a container{st.kvm ? ' or a VM' : ''} from the catalogue.</div>}
</div>
)}
{tab === 'images' && <Images st={st} images={images} instances={instances} onTask={startTask} onNew={(image, vm) => { setTab('instances'); setMaking({ image, vm }) }} onChanged={load} />}
{tab === 'library' && <Library st={st} ov={ov} onTask={startTask} onChanged={load} onNewVM={(iso) => setFromIso(iso)} />}
</div>
{tab === 'instances' && (cur ? <Detail i={cur} st={st} onBack={() => setSel('')} onChanged={load} onTask={startTask} /> : <Summary ov={ov} onChanged={load} />)}
{making && <NewInstance st={st} ov={ov} preset={making} onClose={() => setMaking(null)} onStarted={(t) => { setMaking(null); startTask(t) }} />}
{fromIso !== null && <NewVMFromISO st={st} ov={ov} iso={fromIso} onClose={() => setFromIso(null)} onStarted={(t) => { setFromIso(null); startTask(t); setTab('instances') }} />}
</div>
)
}
// ---- the three states before "ready" ----
function Gate({ st, onChanged }: { st: VirtStatus; onChanged: () => void }) {
const [open, setOpen] = useState(false)
const [task, setTask] = useState('')
const [waiting, setWaiting] = useState(false)
// the install ends by restarting Ham Lab, so poll until it answers again
useEffect(() => {
if (!waiting) return
const t = setInterval(async () => { try { const o = await api.virt(); if (o.status.state === 'ready') { setWaiting(false); onChanged() } } catch { /* restarting */ } }, 3000)
return () => clearInterval(t)
}, [waiting, onChanged])
const headline = st.state === 'installable'
? `Incus isn't installed on this machine. Ham Lab can install it with ${st.pm}: ${st.kvm ? 'containers and virtual machines' : 'containers only, since this kernel has no KVM'}.`
: st.reason
return (
<div className="dash">
<div className="dash-bar"><h2 className="section">Virtualization</h2></div>
<section className="card">
<div className="notice">{headline}</div>
<div className="props" style={{ marginTop: 8 }}>
<div><span className="k">KVM</span><span>{st.kvm ? 'yes — virtual machines possible' : 'no — containers only'}</span></div>
<div><span className="k">Architecture</span><span className="mono">{st.arch}</span></div>
{st.pm && <div><span className="k">Packages</span><span>{st.pm}</span></div>}
<div><span className="k">Pool drivers</span><span>{st.drivers.join(', ')}</span></div>
<div><span className="k">Memory</span><span>{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}</span></div>
<div><span className="k">Disk</span><span>{fmtBytes(st.disk_free)} free where Ham Lab's data lives</span></div>
<div><span className="k">Docker</span><span>{st.docker ? 'present — its FORWARD policy needs two rules, Install adds them' : 'not here'}</span></div>
<div><span className="k">Data</span><span className="mono small">{st.dir}</span></div>
{st.layout && <div><span className="k">Last attempt</span><span>{when(st.layout.attempted)} · {st.layout.driver} pool · {st.layout.subnet}</span></div>}
</div>
{(st.state === 'installable' || st.state === 'repair') && !task && (
<div className="svc-actions"><Btn className="btn on" onClick={() => setOpen(true)}>{st.state === 'repair' ? 'Finish setup' : 'Install Incus'}</Btn></div>
)}
{task && <TaskOutput id={task} onDone={(t) => { if (t.exit_code === 0 && !t.error) setWaiting(true); else { toast('install failed — the output says where', 'err'); setTask('') } }} />}
{waiting && <div className="muted small" style={{ marginTop: 8 }}>Ham Lab is restarting to pick up its new group. The page reconnects by itself.</div>}
</section>
{open && <InstallDialog st={st} onClose={() => setOpen(false)} onStarted={(id) => { setOpen(false); setTask(id) }} />}
</div>
)
}
function InstallDialog({ st, onClose, onStarted }: { st: VirtStatus; onClose: () => void; onStarted: (id: string) => void }) {
const [driver, setDriver] = useState(st.layout?.driver ?? st.drivers[0] ?? 'dir')
const [size, setSize] = useState(String(st.layout?.pool_size_gib || st.pool_size_gib || 20))
const [subnet, setSubnet] = useState(st.layout?.subnet || st.subnet || '172.16.100.1/24')
const [docker, setDocker] = useState(st.docker)
const [arc, setArc] = useState(st.layout?.arc_max_gib ? String(st.layout.arc_max_gib) : '')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEscape(onClose)
const submit = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true)
try { const t = await api.virtInstall({ driver, pool_size_gib: Number(size) || 0, subnet: subnet.trim(), docker_rules: docker, arc_max_gib: Number(arc) || 0 }); onStarted(t.id) }
catch (e) { setErr(errMsg(e)); setBusy(false) }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h3>{st.state === 'repair' ? 'Finish setting up Incus' : 'Install Incus'}</h3>
<div className="notice">Everything goes under <span className="mono">{st.dir}</span>: the pool's backing file, ISOs, images, exports. Where is not a choice; how big is.</div>
<label className="muted">Pool driver
<select value={driver} onChange={(e) => setDriver(e.target.value)}>
{st.drivers.map((d) => <option key={d} value={d}>{d === 'dir' ? 'plain files on this disk — each VM\u2019s disk is its own file, sized on its own; snapshots are copies' : d === 'zfs' ? 'zfs — one pre-sized file holding every disk; snapshots and clones are instant' : 'btrfs — one pre-sized file holding every disk; snapshots are instant'}</option>)}
</select>
</label>
{driver !== 'dir' && (
<label className="muted">Ceiling of that file, GiB — sparse, it grows only as disks fill it · {fmtBytes(st.disk_free)} free
<input type="number" min={5} max={Math.floor(st.disk_free / GiB)} value={size} onChange={(e) => setSize(e.target.value)} required />
</label>
)}
<label className="muted">Bridge address — picked clear of this machine's routes and Docker's pools
<input className="mono" value={subnet} onChange={(e) => setSubnet(e.target.value)} spellCheck={false} required />
</label>
{st.docker && (
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={docker} onChange={(e) => setDocker(e.target.checked)} style={{ width: 'auto' }} />
Add the two DOCKER-USER rules that let the bridge's traffic through Docker's FORWARD drop
</label>
)}
{st.zfs_host && (
<label className="muted">Cap the ZFS ARC, GiB — blank leaves it at {st.arc_max_gib} GiB; a cap makes guest memory deterministic
<input type="number" min={1} max={Math.floor(st.mem_total / GiB)} value={arc} onChange={(e) => setArc(e.target.value)} placeholder="leave as is" />
</label>
)}
<div className="muted small">Ham Lab's account ({st.user}) joins the incus-admin group and Ham Lab restarts at the end.</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={busy}>{busy ? 'Starting…' : 'Install'}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
// ---- the right-hand pane when nothing is selected: what this machine's Incus is ----
// A location is a plain-files storage pool at a folder of his choosing.
async function addLocation(onDone: (name: string) => void) {
const path = await askText('Folder on this machine for instance disks', '/', 'Next')
if (!path) return
const base = path.split('/').filter(Boolean).pop() ?? 'disks'
const name = await askText(`A name for ${path}`, base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, ''), 'Add')
if (!name) return
try { const r = await api.virtPoolCreate(name, path); toast(`${r.name} added at ${r.path}`); onDone(r.name) } catch (e) { toast(errMsg(e), 'err') }
}
function Summary({ ov, onChanged }: { ov: VirtOverview; onChanged: () => void }) {
const st = ov.status
return (
<div className="svc-detail">
<div className="svc-head"><span className="dname">Incus {st.version}</span><span className="badge">{st.arch}</span>{!st.kvm && <span className="badge warn">no KVM</span>}</div>
<div className="props">
<div><span className="k">Data</span><span className="mono small">{st.dir}</span></div>
<div><span className="k">Memory</span><span>{fmtBytes(st.mem_avail)} available of {fmtBytes(st.mem_total)}</span></div>
{st.layout && <div><span className="k">Installed</span><span>{when(st.layout.attempted)} by Ham Lab {st.layout.hamlab_version ?? ''}</span></div>}
{!st.layout && <div><span className="k">Installed</span><span>outside Ham Lab — pools and networks below are as found</span></div>}
</div>
<h3 className="stack-head">Disk locations <span className="muted">· where an instance\u2019s disk can go</span></h3>
<table className="virt-table"><thead><tr><th>Location</th><th>Where</th><th className="num">Used</th><th className="num">Free</th><th className="num">Disks</th><th /></tr></thead>
<tbody>{(ov.pools ?? []).map((p) => <tr key={p.name}><td>{p.name}{p.name === ov.default_pool && <span className="badge">default</span>}</td><td>{p.where}<div className="muted small mono">{p.driver} · {p.source}</div></td><td className="num">{p.total ? fmtBytes(p.used) : '—'}</td><td className="num">{p.total ? fmtBytes(p.total - p.used) : '—'}</td><td className="num">{p.used_by}</td>
<td className="num">{p.used_by === 0 && p.name !== ov.default_pool && <Btn className="btn danger" confirm={`Remove the location ${p.name}? Nothing uses it; the folder itself is left alone.`} onClick={async () => { try { await api.virtPoolDelete(p.name); toast('removed'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }}>Remove</Btn>}</td></tr>)}</tbody></table>
<div className="svc-actions"><Btn className="btn" onClick={() => addLocation(onChanged)}>+ Add a location…</Btn><span className="muted small">a folder on this machine; VM disks become files in it, containers folders</span></div>
<h3 className="stack-head">Networks</h3>
<table className="virt-table"><thead><tr><th>Network</th><th>Type</th><th>Address</th><th>NAT</th><th className="num">Users</th></tr></thead>
<tbody>{(ov.networks ?? []).filter((n) => n.managed).map((n) => <tr key={n.name}><td>{n.name}</td><td>{n.type}</td><td className="mono small">{n.address ?? '—'}</td><td>{n.nat ? 'yes' : 'no'}</td><td className="num">{n.used_by}</td></tr>)}</tbody></table>
<div className="muted small">Pick an instance on the left, or make one. Consoles open as tabs on the Terminal page.</div>
</div>
)
}
// ---- one instance ----
function Detail({ i, st, onBack, onChanged, onTask }: { i: VirtInstance; st: VirtStatus; onBack: () => void; onChanged: () => void; onTask: (t: TaskInfo) => void }) {
const [tab, setTab] = useState<'overview' | 'snapshots' | 'mounts' | 'config'>('overview')
const [vga, setVga] = useState(false)
const running = i.status === 'Running'
const isVM = i.type === 'virtual-machine'
const stopped = i.status === 'Stopped'
const act = (a: 'start' | 'stop' | 'restart' | 'kill') => async () => { try { await api.virtAction(i.name, a); toast(`${i.name}: ${a} ok`); onChanged() } catch (e) { toast(`${i.name}: ${errMsg(e)}`, 'err') } }
const term = (k: 'console' | 'exec') => async () => {
try { const t = await api.virtTerminal(i.name, k); toast(`${k === 'exec' ? 'shell' : 'console'} open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) }
catch (e) { toast(errMsg(e), 'err') }
}
const rename = async () => { const to = await askText(`Rename ${i.name} to`, i.name, 'Rename'); if (!to || to === i.name) return; try { await api.virtRename(i.name, to); toast('renamed'); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
const del = async () => {
const typed = await askText(`Delete ${i.name}${running ? ', which is running' : ''}? Its disk and ${i.snapshots.length} snapshot${i.snapshots.length === 1 ? '' : 's'} go with it. Type its name to confirm.`, '', 'Delete')
if (typed === null) return
if (typed !== i.name) { toast('name did not match', 'err'); return }
try { await api.virtDelete(i.name, running); toast(`${i.name} deleted`); onBack(); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
const publish = async () => {
const alias = await askText(`Publish ${i.name} as an image — an alias for it`, `${i.name}-image`, 'Publish')
if (!alias) return
try { onTask(await api.virtImagePublish(i.name, alias)) } catch (e) { toast(errMsg(e), 'err') }
}
const snap = async () => {
const name = await askText(`Snapshot ${i.name} — a name, or blank for the date`, '', 'Snapshot')
if (name === null) return
try { const r = await api.virtSnapshot(i.name, name); toast(`snapshot ${r.name} taken`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
return (
<div className="svc-detail">
<CloseBtn onClick={onBack} />
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(i)} /><span className="dname">{i.name}</span><span className="badge">{kind(i)}</span><span className={'badge' + (i.status === 'Error' ? ' bad' : '')}>{i.status.toLowerCase()}</span></div>
<div className="props">
<div><span className="k">Image</span><span>{i.image || (Object.keys(i.devices).some((d) => d.startsWith('iso')) ? 'none — installed from an ISO' : 'none')}{i.arch ? <span className="muted"> · {i.arch}</span> : ''}</span></div>
{i.description && <div><span className="k">About</span><span>{i.description}</span></div>}
{running && <div><span className="k">Using</span><span>{i.cpu_pct.toFixed(0)}% of a core · {fmtBytes(i.mem_usage)}{i.mem_peak ? ` (peak ${fmtBytes(i.mem_peak)})` : ''}{i.processes > 0 ? ` · ${i.processes} processes` : ''}</span></div>}
<div><span className="k">Limits</span><span>{i.cpu ? `${i.cpu} CPU` : 'all CPUs'} · {i.memory || 'unlimited memory'}</span></div>
<div><span className="k">Disk</span><span>{i.disk_total > 0 ? `${fmtBytes(i.disk_usage)} of ${fmtBytes(i.disk_total)}` : i.disk_usage > 0 ? fmtBytes(i.disk_usage) : 'no limit'}{i.devices.root?.pool ? <span className="muted"> · on {i.devices.root.pool}</span> : ''}</span></div>
<div><span className="k">Network</span><span className="mono small">{i.ipv4.length ? i.ipv4.join(' ') : running ? 'no address yet' : '—'}</span></div>
<div><span className="k">Created</span><span>{when(i.created)}</span></div>
</div>
<div className="svc-actions">
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
{running && <Btn className="btn" onClick={term('exec')}>Shell</Btn>}
<Btn className="btn" onClick={term('console')} title="the serial console: boot output, and a login prompt where the guest has one on it">Console</Btn>
{isVM && running && <Btn className="btn" onClick={() => setVga(true)} title="the VM's screen, keyboard and mouse — what an installer needs">VGA</Btn>}
{running && <Btn className="btn" confirm={`Restart ${i.name}?`} onClick={act('restart')}>Restart</Btn>}
{running && <Btn className="btn danger" confirm={`Stop ${i.name}? It is asked to shut down cleanly.`} onClick={act('stop')}>Stop</Btn>}
{running && <Btn className="btn danger" confirm={`Force stop ${i.name}? Like pulling the plug — anything unsaved inside is lost.`} onClick={act('kill')}>Force stop</Btn>}
<Btn className="btn" onClick={snap}>Snapshot</Btn>
<Btn className="btn" onClick={async () => { try { onTask(await api.virtExportsRun(i.name)) } catch (e) { toast(errMsg(e), 'err') } }} title="a tarball of it in the library's exports folder">Export</Btn>
{stopped && <Btn className="btn" onClick={rename}>Rename</Btn>}
{stopped && <Btn className="btn" onClick={publish} title="make an image of it, to launch more like it">Publish</Btn>}
<Btn className="btn danger" onClick={del}>Delete…</Btn>
</div>
<div className="tabs">
<button className={tab === 'overview' ? 'on' : ''} onClick={() => setTab('overview')}>Limits</button>
<button className={tab === 'snapshots' ? 'on' : ''} onClick={() => setTab('snapshots')}>Snapshots · {i.snapshots.length}</button>
<button className={tab === 'mounts' ? 'on' : ''} onClick={() => setTab('mounts')}>Disks · {Object.values(i.devices).filter((d) => d.type === 'disk').length}</button>
<button className={tab === 'config' ? 'on' : ''} onClick={() => setTab('config')}>Config</button>
</div>
{tab === 'mounts' && <Mounts i={i} st={st} onChanged={onChanged} />}
{vga && <VgaConsole name={i.name} onClose={() => setVga(false)} />}
{tab === 'overview' && <Limits i={i} st={st} onChanged={onChanged} />}
{tab === 'snapshots' && <Snapshots i={i} onChanged={onChanged} />}
{tab === 'config' && (
<>
<div className="virt-kv">{Object.entries(i.config).sort().map(([k, v]) => <Fragment key={k}><span className="muted mono">{k}</span><span className="mono">{v}</span></Fragment>)}</div>
<h3 className="stack-head">Devices</h3>
<div className="virt-kv">{Object.entries(i.devices).sort().map(([d, cfg]) => <Fragment key={d}><span className="muted mono">{d}</span><span className="mono">{Object.entries(cfg).map(([k, v]) => `${k}=${v}`).join(' ')}</span></Fragment>)}</div>
<div className="muted small">Profiles: {i.profiles.join(', ') || '—'}</div>
</>
)}
</div>
)
}
function Limits({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) {
const [cpu, setCpu] = useState(i.cpu ?? '')
const [mem, setMem] = useState(i.memory ?? '')
const [auto, setAuto] = useState(i.config['boot.autostart'] ?? '')
const [desc, setDesc] = useState(i.description ?? '')
useEffect(() => { setCpu(i.cpu ?? ''); setMem(i.memory ?? ''); setAuto(i.config['boot.autostart'] ?? ''); setDesc(i.description ?? '') }, [i.name, i.cpu, i.memory, i.description, i.config])
const changed = cpu !== (i.cpu ?? '') || mem !== (i.memory ?? '') || auto !== (i.config['boot.autostart'] ?? '') || desc !== (i.description ?? '')
const save = async () => { try { await api.virtConfig(i.name, { 'limits.cpu': cpu, 'limits.memory': mem, 'boot.autostart': auto, description: desc }); toast('saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
return (
<section className="card">
<div className="virt-form-row">
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder="all" /></label>
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder="unlimited · e.g. 2GiB" /></label>
</div>
<div className="virt-form-row">
<label className="muted">Start with the machine
<select value={auto} onChange={(e) => setAuto(e.target.value)}><option value="">default (as it was when the host went down)</option><option value="true">yes</option><option value="false">no</option></select>
</label>
<label className="muted">Description<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label>
</div>
<div className="muted small">{i.type === 'container' ? 'Containers take new limits live.' : 'A VM takes CPU and memory changes live where its agent is running, otherwise on its next start.'} {fmtBytes(st.mem_avail)} is free on this machine right now.</div>
<div className="svc-actions"><Btn className="btn on" disabled={!changed} onClick={save}>Save</Btn></div>
</section>
)
}
function Snapshots({ i, onChanged }: { i: VirtInstance; onChanged: () => void }) {
const [sched, setSched] = useState(i.schedule ?? '')
const [exp, setExp] = useState(i.expiry ?? '')
useEffect(() => { setSched(i.schedule ?? ''); setExp(i.expiry ?? '') }, [i.name, i.schedule, i.expiry])
const saveSched = async () => { try { await api.virtConfig(i.name, { 'snapshots.schedule': sched.trim(), 'snapshots.expiry': exp.trim() }); toast('schedule saved'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
const on = async (s: string, a: 'restore' | 'delete') => {
const ok = await askYes(a === 'restore' ? `Restore ${i.name} to snapshot ${s}? Everything since it is lost${i.status === 'Running' ? '; it is stopped first' : ''}.` : `Delete snapshot ${s} of ${i.name}?`, a === 'restore' ? 'Restore' : 'Delete')
if (!ok) return
try { await api.virtSnapshotAction(i.name, s, a); toast(`${a} ${s}: ok`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
return (
<>
{i.snapshots.length === 0 && <div className="muted small">No snapshots. “Snapshot” above takes one now; a schedule below keeps taking them.</div>}
{i.snapshots.length > 0 && (
<table className="virt-table"><thead><tr><th>Snapshot</th><th>Taken</th><th>Expires</th><th className="num">Size</th><th /></tr></thead>
<tbody>{[...i.snapshots].sort((a, b) => b.created.localeCompare(a.created)).map((s) => (
<tr key={s.name}><td className="mono">{s.name}</td><td>{when(s.created)}</td><td>{s.expires ? when(s.expires) : 'never'}</td><td className="num">{s.size ? fmtBytes(s.size) : '—'}</td>
<td className="num"><Btn className="btn" onClick={() => on(s.name, 'restore')}>Restore</Btn> <Btn className="btn danger" onClick={() => on(s.name, 'delete')}>Delete</Btn></td></tr>
))}</tbody></table>
)}
<section className="card">
<div className="virt-form-row">
<label className="muted">Schedule — @hourly, @daily, @weekly, or a cron line<input className="mono" value={sched} onChange={(e) => setSched(e.target.value)} placeholder="none" spellCheck={false} /></label>
<label className="muted">Keep for — 1d, 2w, 3M…<input className="mono" value={exp} onChange={(e) => setExp(e.target.value)} placeholder="forever" spellCheck={false} /></label>
</div>
<div className="muted small">Incus takes and expires these itself, so they carry on whether or not this console is up.</div>
<div className="svc-actions"><Btn className="btn on" disabled={sched === (i.schedule ?? '') && exp === (i.expiry ?? '')} onClick={saveSched}>Save schedule</Btn></div>
</section>
</>
)
}
// ---- making one ----
// Where an instance's disk goes. One entry is still a choice shown; a new one can be added here.
function PoolPick({ ov, value, onChange }: { ov: VirtOverview; value: string; onChange: (v: string) => void }) {
const [extra, setExtra] = useState<{ name: string; where: string }[]>([])
const pools = [...(ov.pools ?? []).map((p) => ({ name: p.name, where: p.where + (p.total ? ` · ${fmtBytes(p.total - p.used)} free` : '') })), ...extra.filter((e) => !(ov.pools ?? []).some((p) => p.name === e.name))]
return (
<label className="muted">Disk location
<select value={value} onChange={(e) => { if (e.target.value === '+') addLocation((n) => { setExtra((x) => [...x, { name: n, where: 'just added' }]); onChange(n) }); else onChange(e.target.value) }}>
{pools.map((p) => <option key={p.name} value={p.name}>{p.name} — {p.where}</option>)}
<option value="+">+ add a location…</option>
</select>
</label>
)
}
function NewInstance({ st, ov, preset, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; preset: { image?: string; vm?: boolean }; onClose: () => void; onStarted: (t: TaskInfo) => void }) {
const [vm, setVm] = useState(!!preset.vm && st.kvm)
const [image, setImage] = useState(preset.image ?? '')
const [name, setName] = useState('')
const [cpu, setCpu] = useState('')
const [mem, setMem] = useState('')
const [disk, setDisk] = useState('')
const [pool, setPool] = useState(ov.default_pool || ov.pools?.[0]?.name || '')
const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge')
const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '')
const [cloud, setCloud] = useState('')
const [showCloud, setShowCloud] = useState(false)
const [desc, setDesc] = useState('')
const [start, setStart] = useState(true)
const [q, setQ] = useState('')
const [remote, setRemote] = useState<VirtImage[] | null>(null)
const [remoteErr, setRemoteErr] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const type = vm ? 'virtual-machine' : 'container'
useEscape(onClose)
useEffect(() => { setRemote(null); setRemoteErr(''); api.virtRemote(type).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type])
const local = (ov.images ?? []).filter((im) => im.type === type)
const picks = useMemo(() => {
const f = q.trim().toLowerCase()
const match = (im: VirtImage) => !f || `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release}`.toLowerCase().includes(f)
const seen = new Set<string>()
const out: { ref: string; label: string; sub: string; here: boolean }[] = []
for (const im of local) { if (!match(im)) continue; const ref = imgLabel(im); if (seen.has(ref)) continue; seen.add(ref); out.push({ ref, label: ref, sub: `${im.description} · on this machine · ${fmtBytes(im.size)}`, here: true }) }
for (const im of remote ?? []) {
const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0]
if (!a || !match(im)) continue
const ref = `images:${a}`
if (seen.has(ref)) continue
seen.add(ref); out.push({ ref, label: a, sub: `${im.description}${im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · ${fmtBytes(im.size)}`, here: false })
}
return out.slice(0, 60)
}, [q, local, remote])
const submit = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true)
try { onStarted(await api.virtCreate({ name: name.trim(), image: image.trim(), vm, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), pool, network: net, cloud_init: showCloud ? cloud : '', profiles: [], description: desc.trim(), start })) }
catch (e) { setErr(errMsg(e)); setBusy(false) }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h3>New instance</h3>
<div className="chips">
<button type="button" className={'chip' + (!vm ? ' on' : '')} onClick={() => setVm(false)}>container</button>
<button type="button" className={'chip' + (vm ? ' on' : '')} disabled={!st.kvm} title={st.kvm ? '' : 'no KVM on this machine'} onClick={() => setVm(true)}>virtual machine</button>
</div>
<label className="muted">Image — from this machine, or the public catalogue for {st.arch}
<input className="mono" value={image} onChange={(e) => { setImage(e.target.value); setQ(e.target.value.replace(/^images:/, '')) }} placeholder="images:debian/13" spellCheck={false} autoCapitalize="off" autoCorrect="off" required />
</label>
<div className="virt-pick">
{remote === null && !remoteErr && <div className="muted small" style={{ padding: 8 }}>Reading the catalogue…</div>}
{remoteErr && <div className="error" style={{ margin: 8 }}>catalogue: {remoteErr}</div>}
{picks.map((p) => (
<div key={p.ref} className={'svc-row' + (image === p.ref ? ' active' : '')} onClick={() => setImage(p.ref)}>
<div className="svc-name"><div className="n">{p.label}{p.here && <span className="badge">here</span>}</div><div className="d">{p.sub}</div></div>
</div>
))}
{remote && picks.length === 0 && <div className="muted small" style={{ padding: 8 }}>Nothing in the catalogue matches.</div>}
</div>
<div className="virt-form-row">
<label className="muted">Name<input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-zA-Z][a-zA-Z0-9\-]{0,62}" placeholder="web-1" spellCheck={false} autoCapitalize="off" autoCorrect="off" required /></label>
<label className="muted">Network<select value={net} onChange={(e) => setNet(e.target.value)}>{nets.map((n) => <option key={n.name} value={n.name}>{n.name}{n.address ? ` · ${n.address}` : ''}</option>)}</select></label>
</div>
<div className="virt-form-row">
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} placeholder={vm ? '2' : 'all'} /></label>
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder={vm ? '2GiB' : 'unlimited'} /></label>
<label className="muted">Root disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} placeholder={vm ? '20GiB' : 'no limit'} /></label>
</div>
<PoolPick ov={ov} value={pool} onChange={setPool} />
<div className="muted small">{fmtBytes(st.mem_avail)} free on this machine.{vm ? ' A VM’s memory is a reservation.' : ''}</div>
<label className="muted">Description — optional<input value={desc} onChange={(e) => setDesc(e.target.value)} maxLength={200} /></label>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={showCloud} onChange={(e) => setShowCloud(e.target.checked)} style={{ width: 'auto' }} />cloud-init user-data (an SSH key, packages, a first command — needs a /cloud image)
</label>
{showCloud && <div className="svc-actions"><Btn className="btn" onClick={async () => { try { const k = await api.virtSSHKeys(); if (!k.keys.length) { toast('no public keys found for ' + k.user, 'info'); return } setCloud(`#cloud-config\nusers:\n - name: ${k.user}\n groups: [sudo]\n shell: /bin/bash\n sudo: ALL=(ALL) NOPASSWD:ALL\n ssh_authorized_keys:\n${k.keys.map((x) => ' - ' + x).join('\n')}\npackage_update: true\npackages: [curl, vim]\n`) } catch (e) { toast(errMsg(e), 'err') } }} title="a cloud-config that makes your account inside, with the public keys this machine's Ham Lab account holds">Use my SSH keys</Btn></div>}
{showCloud && <textarea className="mono" rows={6} value={cloud} onChange={(e) => setCloud(e.target.value)} placeholder={'#cloud-config\nusers:\n - name: core\n ssh_authorized_keys:\n - ssh-ed25519 AAAA…\n sudo: ALL=(ALL) NOPASSWD:ALL\npackages: [curl]'} spellCheck={false} />}
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={start} onChange={(e) => setStart(e.target.checked)} style={{ width: 'auto' }} />Start it once made
</label>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={busy || !image.trim() || !name.trim()}>{busy ? 'Starting…' : start ? 'Create and start' : 'Create'}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
// ---- images: the local store, the catalogue, and files brought in ----
function Images({ st, images, instances, onTask, onNew, onChanged }: { st: VirtStatus; images: VirtImage[]; instances: VirtInstance[]; onTask: (t: TaskInfo) => void; onNew: (image: string, vm: boolean) => void; onChanged: () => void }) {
const [q, setQ] = useState('')
const [type, setType] = useState<'container' | 'virtual-machine'>('container')
const [remote, setRemote] = useState<VirtImage[] | null>(null)
const [remoteErr, setRemoteErr] = useState('')
const [path, setPath] = useState('')
const [alias, setAlias] = useState('')
const [itype, setItype] = useState<'container' | 'virtual-machine'>('container')
const loadRemote = useCallback((refresh = false) => { setRemote(null); setRemoteErr(''); api.virtRemote(type, refresh).then((r) => setRemote(r.images)).catch((e) => setRemoteErr(errMsg(e))) }, [type])
useEffect(() => { loadRemote() }, [loadRemote])
const usedBy = (im: VirtImage) => instances.filter((i) => i.config['volatile.base_image'] === im.fingerprint || im.aliases.includes(i.image)).map((i) => i.name)
const f = q.trim().toLowerCase()
const found = (remote ?? []).filter((im) => f && `${im.aliases.join(' ')} ${im.description} ${im.os} ${im.release} ${im.variant}`.toLowerCase().includes(f)).slice(0, 40)
const del = async (im: VirtImage) => {
if (!(await askYes(`Delete the image ${imgLabel(im)} from this machine's store? Instances made from it are not affected; the file in the library, if any, stays.`, 'Delete'))) return
try { await api.virtImageDelete(im.fingerprint); toast('image deleted'); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
const imp = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtImageImport(path.trim(), alias.trim(), itype)); setPath(''); setAlias('') } catch (er) { toast(errMsg(er), 'err') } }
return (
<div className="svc-rows" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
<section className="card">
<div className="sub-head"><span className="dname">On this machine</span><span className="spacer" /><span className="muted small">{images.length} image{images.length === 1 ? '' : 's'} · {fmtBytes(images.reduce((a, im) => a + im.size, 0))}</span></div>
{images.length === 0 && <div className="muted small">No images yet. Make an instance and its image is kept here; or download one below so the next one is instant.</div>}
{images.length > 0 && (
<table className="virt-table"><thead><tr><th>Image</th><th>Type</th><th>What</th><th className="num">Size</th><th>Used by</th><th /></tr></thead>
<tbody>{images.map((im) => (
<tr key={im.fingerprint}><td className="mono">{im.aliases.join(', ') || im.fingerprint.slice(0, 12)}</td><td>{im.type === 'virtual-machine' ? 'VM' : 'LXC'}</td><td>{im.description}{im.cached ? <span className="muted small"> · cached from {im.source}</span> : ''}</td><td className="num">{fmtBytes(im.size)}</td><td className="small">{usedBy(im).join(' ') || '—'}</td>
<td className="num"><Btn className="btn" onClick={() => onNew(imgLabel(im), im.type === 'virtual-machine')}>New instance</Btn> <Btn className="btn danger" onClick={() => del(im)}>Delete</Btn></td></tr>
))}</tbody></table>
)}
</section>
<section className="card">
<div className="sub-head"><span className="dname">Download from the catalogue</span><span className="spacer" />
<div className="chips"><button className={'chip' + (type === 'container' ? ' on' : '')} onClick={() => setType('container')}>containers</button><button className={'chip' + (type === 'virtual-machine' ? ' on' : '')} disabled={!st.kvm} onClick={() => setType('virtual-machine')}>VMs</button></div>
<Btn className="btn" onClick={() => loadRemote(true)} title="the catalogue is kept for an hour">Refresh</Btn></div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`search images.linuxcontainers.org for ${st.arch} — debian, ubuntu 24.04, alpine…`} />
{remote === null && !remoteErr && <div className="muted small">Reading the catalogue…</div>}
{remoteErr && <div className="error">{remoteErr}</div>}
{remote && !f && <div className="muted small">{remote.length} {type === 'virtual-machine' ? 'VM' : 'container'} images for {st.arch}. Type to search. A download keeps the image in the store and a copy of it in the library.</div>}
{found.length > 0 && (
<div className="virt-pick">{found.map((im) => { const a = im.aliases.find((x) => !x.includes('/default')) ?? im.aliases[0]; return (
<div key={im.fingerprint} className="svc-row" style={{ cursor: 'default' }}>
<div className="svc-name"><div className="n">{a}</div><div className="d">{im.description}{im.variant && im.variant !== 'default' ? ` · ${im.variant}` : ''} · {fmtBytes(im.size)}</div></div>
<Btn className="btn" onClick={async () => { try { onTask(await api.virtImageDownload(a)) } catch (e) { toast(errMsg(e), 'err') } }}>Download</Btn>
</div>) })}</div>
)}
</section>
<section className="card">
<div className="sub-head"><span className="dname">Import a file from this machine</span></div>
<form onSubmit={imp} className="virt-form-row" style={{ flexWrap: 'wrap' }}>
<label className="muted" style={{ flex: '2 1 240px' }}>Path — a template tarball, or a qcow2 / raw disk for a VM<input className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder="/path/to/image.tar.xz" spellCheck={false} required /></label>
<label className="muted">Alias<input className="mono" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="my-template" spellCheck={false} required /></label>
<label className="muted">Type<select value={itype} onChange={(e) => setItype(e.target.value as 'container' | 'virtual-machine')}><option value="container">container template</option><option value="virtual-machine" disabled={!st.kvm}>VM disk image</option></select></label>
<div className="svc-actions" style={{ alignSelf: 'flex-end' }}><button className="btn on" disabled={!path.trim() || !alias.trim()}>Import</button></div>
</form>
<div className="muted small">The file is copied into <span className="mono">{st.dir}/images/</span> first — the library is Ham Lab's — then loaded into Incus.</div>
</section>
</div>
)
}
function Mounts({ i, st, onChanged }: { i: VirtInstance; st: VirtStatus; onChanged: () => void }) {
const [src, setSrc] = useState('')
const [dst, setDst] = useState('')
const [dev, setDev] = useState('')
const [ro, setRo] = useState(false)
const [shift, setShift] = useState(true)
const isos = st.dir + '/isos/'
const disks = Object.entries(i.devices).filter(([, d]) => d.type === 'disk').sort()
const add = async (e: FormEvent) => {
e.preventDefault()
try { const r = await api.virtMountAdd(i.name, { device: dev.trim(), source: src.trim(), path: dst.trim(), readonly: ro, shift }); toast(`mounted as ${r.device}`); setSrc(''); setDst(''); setDev(''); onChanged() } catch (er) { toast(errMsg(er), 'err') }
}
const remove = async (d: string, iso: boolean) => {
if (!(await askYes(iso ? `Eject ${d} from ${i.name}?` : `Remove the mount ${d} from ${i.name}? Nothing on the host is touched.`, iso ? 'Eject' : 'Remove'))) return
try { if (iso) await api.virtISODetach(i.name, d); else await api.virtMountRemove(i.name, d); toast('done'); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
return (
<>
<table className="virt-table"><thead><tr><th>Device</th><th>Source</th><th>Inside</th><th /></tr></thead>
<tbody>{disks.map(([name, d]) => { const iso = (d.source ?? '').startsWith(isos); return (
<tr key={name}><td className="mono">{name}{iso && <span className="badge">ISO</span>}{d['boot.priority'] && <span className="badge">boots first</span>}</td>
<td className="mono small">{name === 'root' ? `pool ${d.pool}${d.size ? ` · ${d.size}` : ''}` : iso ? (d.source ?? '').slice(isos.length) : d.source}</td>
<td className="mono small">{iso ? 'CD-ROM' : d.path}{d.readonly === 'true' ? ' · read only' : ''}{d.shift === 'true' ? ' · shifted' : ''}</td>
<td className="num">{name !== 'root' && <Btn className="btn danger" onClick={() => remove(name, iso)}>{iso ? 'Eject' : 'Remove'}</Btn>}</td></tr>) })}</tbody></table>
<section className="card">
<div className="sub-head"><span className="dname">Mount a folder from this machine</span></div>
<form onSubmit={add}>
<div className="virt-form-row">
<label className="muted">Host path<input className="mono" value={src} onChange={(e) => setSrc(e.target.value)} placeholder="/tank/media" spellCheck={false} required /></label>
<label className="muted">Path inside<input className="mono" value={dst} onChange={(e) => setDst(e.target.value)} placeholder="/mnt/media" spellCheck={false} required /></label>
<label className="muted">Device name — optional<input className="mono" value={dev} onChange={(e) => setDev(e.target.value)} placeholder="mnt-media" spellCheck={false} /></label>
</div>
<div className="virt-form-row" style={{ alignItems: 'center' }}>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={ro} onChange={(e) => setRo(e.target.checked)} style={{ width: 'auto' }} />read only</label>
{i.type === 'container' && <label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }} title="map the host's file ownership into the container's user range, so files look owned by the same names inside"><input type="checkbox" checked={shift} onChange={(e) => setShift(e.target.checked)} style={{ width: 'auto' }} />shift ownership</label>}
<span className="spacer" />
<button className="btn on" disabled={!src.trim() || !dst.trim()}>Mount</button>
</div>
</form>
<div className="muted small">{i.type === 'container' ? 'A bind mount; it appears at once, running or not.' : 'Shared into the VM over virtiofs; its agent mounts it. Appears at once on a running VM with the agent, otherwise at the next start.'} The data stays where it is — this is the instance’s device, not a copy.</div>
</section>
</>
)
}
function NewVMFromISO({ st, ov, iso, onClose, onStarted }: { st: VirtStatus; ov: VirtOverview; iso: string; onClose: () => void; onStarted: (t: TaskInfo) => void }) {
const isos = ov.library.isos
const [pick, setPick] = useState(iso || isos[0]?.name || '')
const [name, setName] = useState('')
const [cpu, setCpu] = useState('2')
const [mem, setMem] = useState('2GiB')
const [disk, setDisk] = useState('20GiB')
const [pool, setPool] = useState(ov.default_pool || ov.pools?.[0]?.name || '')
const nets = (ov.networks ?? []).filter((n) => n.managed || n.type === 'bridge')
const [net, setNet] = useState(nets.find((n) => n.name === 'incusbr0')?.name ?? nets[0]?.name ?? '')
const [sb, setSb] = useState(true)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
useEscape(onClose)
const submit = async (e: FormEvent) => {
e.preventDefault(); setErr(''); setBusy(true)
try { onStarted(await api.virtFromISO({ name: name.trim(), iso: pick, cpu: cpu.trim(), memory: mem.trim(), disk: disk.trim(), pool, network: net, secureboot: sb, start: true })) }
catch (e) { setErr(errMsg(e)); setBusy(false) }
}
return (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
<h3>New VM from an ISO</h3>
<label className="muted">ISO<select value={pick} onChange={(e) => setPick(e.target.value)}>{isos.map((f) => <option key={f.name} value={f.name}>{f.name} · {fmtBytes(f.size)}</option>)}</select></label>
<div className="virt-form-row">
<label className="muted">Name<input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-zA-Z][a-zA-Z0-9\-]{0,62}" placeholder="win11" spellCheck={false} autoCapitalize="off" autoCorrect="off" required /></label>
<label className="muted">Network<select value={net} onChange={(e) => setNet(e.target.value)}>{nets.map((n) => <option key={n.name} value={n.name}>{n.name}{n.address ? ` · ${n.address}` : ''}</option>)}</select></label>
</div>
<div className="virt-form-row">
<label className="muted">CPUs<input className="mono" value={cpu} onChange={(e) => setCpu(e.target.value)} /></label>
<label className="muted">Memory<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} /></label>
<label className="muted">Disk<input className="mono" value={disk} onChange={(e) => setDisk(e.target.value)} /></label>
</div>
<PoolPick ov={ov} value={pool} onChange={setPool} />
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={sb} onChange={(e) => setSb(e.target.checked)} style={{ width: 'auto' }} />Secure Boot — Windows, Debian, Ubuntu and Fedora boot with it; Alpine, Arch, TrueNAS and most others need it off
</label>
<div className="muted small">An empty VM with the ISO as its first boot device, started, so the installer is on the VGA console. {fmtBytes(st.mem_avail)} free here; a VM’s memory is a reservation. Windows also wants the virtio drivers ISO attached as a second drive — add it from the VM’s Disks tab.</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button type="submit" className="btn on" disabled={busy || !pick || !name.trim()}>{busy ? 'Starting…' : 'Create and boot the ISO'}</button>
<span className="spacer" />
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
function Library({ st, ov, onTask, onChanged, onNewVM }: { st: VirtStatus; ov: VirtOverview; onTask: (t: TaskInfo) => void; onChanged: () => void; onNewVM: (iso: string) => void }) {
const lib = ov.library
const vms = (ov.instances ?? []).filter((i) => i.type === 'virtual-machine')
const [url, setUrl] = useState(''); const [sha, setSha] = useState(''); const [path, setPath] = useState('')
const [upload, setUpload] = useState<{ name: string; pct: number } | null>(null)
const [exp, setExp] = useState<ExportsView | null>(null)
const [cfg, setCfg] = useState<ExportsView['config'] | null>(null)
const loadExports = useCallback(() => api.virtExports().then((v) => { setExp(v); setCfg(v.config) }).catch(() => setExp(null)), [])
useEffect(() => { loadExports() }, [loadExports])
const uploadFiles = async (files: FileList | null) => {
if (!files?.length) return
for (const f of Array.from(files)) {
if (!/\.(iso|img)$/i.test(f.name)) { toast(`${f.name}: only .iso or .img files`, 'err'); continue }
setUpload({ name: f.name, pct: 0 })
try { await api.fileUpload(st.dir + '/isos', f, false, (pct) => setUpload({ name: f.name, pct })); toast(`uploaded ${f.name}`) } catch (e) { toast(`${f.name}: ${errMsg(e)}`, 'err') }
}
setUpload(null); onChanged()
}
const fetchISO = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtISOFetch(url.trim(), sha.trim(), '')); setUrl(''); setSha('') } catch (er) { toast(errMsg(er), 'err') } }
const importISO = async (e: FormEvent) => { e.preventDefault(); try { onTask(await api.virtISOImport(path.trim())); setPath('') } catch (er) { toast(errMsg(er), 'err') } }
const attach = async (f: LibraryFile) => {
if (vms.length === 0) { toast('no VM to attach it to — make one first, or use New VM from ISO', 'info'); return }
const vm = vms.length === 1 ? vms[0].name : await askText(`Attach ${f.name} to which VM? (${vms.map((v) => v.name).join(', ')})`, vms[0].name, 'Attach')
if (!vm) return
const first = await askYes(`Boot ${vm} from ${f.name} first, ahead of its disk? Say no to just make it available as a CD-ROM.`, 'Boot first')
try { const r = await api.virtISOAttach(vm, f.name, first); toast(`${f.name} in ${vm} as ${r.device}`); onChanged() } catch (e) { toast(errMsg(e), 'err') }
}
const del = async (f: LibraryFile) => { if (!(await askYes(`Delete ${f.name} from the library?`, 'Delete'))) return; try { await api.virtISODelete(f.name); toast('deleted'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
const rename = async (f: LibraryFile) => { const to = await askText(`Rename ${f.name} to`, f.name, 'Rename'); if (!to || to === f.name) return; try { await api.virtISORename(f.name, to); toast('renamed'); onChanged() } catch (e) { toast(errMsg(e), 'err') } }
const saveExports = async () => { if (!cfg) return; try { await api.virtExportsPut(cfg); toast(cfg.enabled ? 'exports scheduled' : 'exports paused'); loadExports() } catch (e) { toast(errMsg(e), 'err') } }
const delExport = async (inst: string, name: string) => { if (!(await askYes(`Delete the export ${name}?`, 'Delete'))) return; try { await api.virtExportDelete(inst, name); loadExports() } catch (e) { toast(errMsg(e), 'err') } }
const table = (files: LibraryFile[]) => files.length === 0 ? <div className="muted small">empty</div> : (
<table className="virt-table"><thead><tr><th>File</th><th className="num">Size</th><th>Changed</th></tr></thead>
<tbody>{files.map((f) => <tr key={f.name}><td className="mono">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td>{when(f.modified)}</td></tr>)}</tbody></table>
)
return (
<div className="svc-rows" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
<section className="card">
<div className="sub-head"><span className="dname">ISOs</span><span className="spacer" /><span className="muted small mono">{st.dir}/isos</span></div>
{lib.isos.length === 0 && <div className="muted small">No ISOs yet. Upload one, fetch one by URL, or import a file already on this machine.</div>}
{lib.isos.length > 0 && (
<table className="virt-table"><thead><tr><th>File</th><th className="num">Size</th><th>In</th><th /></tr></thead>
<tbody>{lib.isos.map((f) => (
<tr key={f.name}><td className="mono">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td className="small">{f.attached_to?.join(' ') || '—'}</td>
<td className="num" style={{ whiteSpace: 'normal' }}>
{st.kvm && <Btn className="btn on" onClick={() => onNewVM(f.name)}>New VM</Btn>} {st.kvm && <Btn className="btn" onClick={() => attach(f)}>Attach…</Btn>} <Btn className="btn" onClick={() => rename(f)}>Rename</Btn> <Btn className="btn danger" disabled={!!f.attached_to?.length} title={f.attached_to?.length ? 'attached — eject it first' : ''} onClick={() => del(f)}>Delete</Btn>
</td></tr>
))}</tbody></table>
)}
<div className="virt-form-row" style={{ flexWrap: 'wrap', marginTop: 8 }}>
<label className="btn" style={{ alignSelf: 'flex-end' }}>{upload ? `${upload.name} · ${upload.pct}%` : 'Upload…'}<input type="file" accept=".iso,.img" multiple style={{ display: 'none' }} onChange={(e) => uploadFiles(e.target.files)} /></label>
<form onSubmit={fetchISO} className="virt-form-row" style={{ flex: '2 1 320px' }}>
<label className="muted" style={{ flex: 3 }}>Fetch from a URL<input className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…/something.iso" spellCheck={false} /></label>
<label className="muted" style={{ flex: 2 }}>sha256 — optional<input className="mono" value={sha} onChange={(e) => setSha(e.target.value)} spellCheck={false} /></label>
<button className="btn" style={{ alignSelf: 'flex-end' }} disabled={!url.trim()}>Fetch</button>
</form>
<form onSubmit={importISO} className="virt-form-row" style={{ flex: '1 1 260px' }}>
<label className="muted" style={{ flex: 1 }}>Import a file on this machine — copied in<input className="mono" value={path} onChange={(e) => setPath(e.target.value)} placeholder="/tank/software/isos/…/x.iso" spellCheck={false} /></label>
<button className="btn" style={{ alignSelf: 'flex-end' }} disabled={!path.trim()}>Import</button>
</form>
</div>
<div className="muted small">{st.kvm ? 'An ISO attaches to a VM as a CD-ROM, live or stopped; “New VM” makes an empty VM that boots it, for an installer on the VGA console.' : 'This machine has no KVM, so there is nothing here to boot an ISO.'}</div>
</section>
<section className="card">
<div className="sub-head"><span className="dname">Exports</span><span className="spacer" /><span className="muted small mono">{st.dir}/exports</span></div>
{cfg && (
<div className="virt-form-row" style={{ flexWrap: 'wrap', alignItems: 'flex-end' }}>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: '0 0 auto' }}><input type="checkbox" checked={cfg.enabled} onChange={(e) => setCfg({ ...cfg, enabled: e.target.checked })} style={{ width: 'auto' }} />nightly</label>
<label className="muted" style={{ flex: '0 0 90px' }}>at<input className="mono" type="time" value={`${String(cfg.hour).padStart(2, '0')}:${String(cfg.minute).padStart(2, '0')}`} onChange={(e) => { const [h, m] = e.target.value.split(':').map(Number); if (!isNaN(h) && !isNaN(m)) setCfg({ ...cfg, hour: h, minute: m }) }} /></label>
<label className="muted" style={{ flex: '0 0 110px' }}>keep per instance<input className="mono" type="number" min={1} max={100} value={cfg.keep} onChange={(e) => setCfg({ ...cfg, keep: Number(e.target.value) || 1 })} /></label>
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8, flex: '0 0 auto' }}><input type="checkbox" checked={!cfg.instance_only} onChange={(e) => setCfg({ ...cfg, instance_only: !e.target.checked })} style={{ width: 'auto' }} />include snapshots</label>
<span className="spacer" />
<Btn className="btn on" disabled={JSON.stringify(cfg) === JSON.stringify(exp?.config)} onClick={saveExports}>Save</Btn>
<Btn className="btn" onClick={async () => { try { onTask(await api.virtExportsRun()) } catch (e) { toast(errMsg(e), 'err') } }}>Export all now</Btn>
</div>
)}
{exp && <div className="muted small" style={{ marginTop: 6 }}>{exp.config.enabled ? <>Cron line in <span className="mono">{exp.cron_file}</span>, next {exp.next ? new Date(exp.next).toLocaleString() : '—'}.</> : 'Not scheduled.'} {exp.last ? <>Last run {when(exp.last.at)}: {exp.last.exported.length} exported, {exp.last.pruned.length} pruned{exp.last.errors?.length ? <span className="warn-text">, {exp.last.errors.length} failed: {exp.last.errors.join('; ')}</span> : ''}.</> : 'Never run.'} Every instance as a tarball Incus can import again; the folder is backed up with the rest of Ham Lab’s data.</div>}
{exp && exp.files.length > 0 && (
<table className="virt-table" style={{ marginTop: 8 }}><thead><tr><th>Instance</th><th>Export</th><th className="num">Size</th><th>Taken</th><th /></tr></thead>
<tbody>{exp.files.map((f) => <tr key={f.instance + f.name}><td>{f.instance}</td><td className="mono small">{f.name}</td><td className="num">{fmtBytes(f.size)}</td><td>{when(f.modified)}</td><td className="num"><Btn className="btn danger" onClick={() => delExport(f.instance, f.name)}>Delete</Btn></td></tr>)}</tbody></table>
)}
</section>
<section className="card"><div className="sub-head"><span className="dname">Images</span><span className="spacer" /><span className="muted small mono">{st.dir}/images</span></div>{table(lib.images)}
<div className="muted small">Every image downloaded, imported or published lands here as a file, so the library is whole without Incus and can be reloaded into a fresh install.</div></section>
</div>
)
}
+396 -9
View File
@@ -9,8 +9,8 @@
// disagree over. An existing cron job is detected and shown, so the page tells the truth
// about the machine whether Ham Lab or something else is doing the work.
import { FormEvent, Fragment, useCallback, useEffect, useState } from 'react'
import { api, ARCView, CacheDev, PoolCaching, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
import { fmtBytes } from './fmt'
import { 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'
@@ -367,22 +367,153 @@ function PoolTiers({ p, v }: { p: PoolCaching; v: ARCView }) {
)
}
// ---- 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 () => {
try { const r = await api.zfsARC(); if (!stop) { setV(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
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 hs = await fetchHistories('zfs.arc.size,zfs.arc.hit_pct')
if (!stop) setHist({ size: hs.find((h) => h.id === 'zfs.arc.size')?.v ?? [], hit: hs.find((h) => h.id === 'zfs.arc.hit_pct')?.v ?? [] })
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, Math.max(2000, rate))
const t = setInterval(poll, rate)
return () => { stop = true; clearInterval(t) }
}, [rate])
@@ -444,7 +575,262 @@ function ARCSection() {
<span><b>All reads</b></span><span className="num"><b>{fmtPct(hitPct(L.Hits, L.Misses))}</b></span><span className="num">{fmtN(L.Hits)}</span><span className="num">{fmtN(L.Misses)}</span>
</div>
</section>
{d.pools.length > 0 && <div className="cards" style={{ marginTop: 8 }}>{d.pools.map((p) => <PoolTiers key={p.pool} p={p} v={v} />)}</div>}
{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() }} />}
</>
)
}
@@ -475,19 +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>
+69 -61
View File
@@ -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,11 +71,11 @@ 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; 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 }
@@ -81,11 +86,11 @@ export interface GPUClient { pid: number; name: string; usage: number; engines?:
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
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_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number }
export interface 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 }
@@ -180,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 }
@@ -247,9 +251,51 @@ export interface ARCDetail {
dbuf: { Size: number; Target: number; Hits: number; Misses: number }
pools: PoolCaching[]
}
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
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; preview?: 'image' | 'video' }
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 } }
@@ -257,24 +303,6 @@ export interface FileInfo extends FileEntry { path: string; text: boolean }
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
// ---- Virtualization: Incus, per machine ----
export interface VirtLayout { driver: string; pool_source: string; pool_size_gib?: number; bridge: string; subnet: string; docker_rules: boolean; arc_max_gib?: number; attempted: string; hamlab_version?: string }
export interface VirtStatus { state: 'ready' | 'repair' | 'installable' | 'unavailable'; reason?: string; kvm: boolean; arch: string; version?: string; pm?: string; drivers: string[]; docker: boolean; zfs_host: boolean; arc_max_gib?: number; mem_total: number; mem_avail: number; disk_free: number; dir: string; subnet: string; pool_size_gib?: number; layout?: VirtLayout; user: string; root: boolean; unit?: string; btrfs_tools: boolean }
export interface VirtSnapshot { name: string; created: string; expires?: string; size?: number }
export interface VirtInstance { name: string; type: 'container' | 'virtual-machine'; status: string; arch: string; description?: string; image: string; os?: string; release?: string; cpu?: string; memory?: string; created: string; last_used: string; ipv4: string[]; snapshots: VirtSnapshot[]; profiles: string[]; ephemeral: boolean; cpu_pct: number; mem_usage: number; mem_peak: number; disk_usage: number; disk_total: number; pid: number; processes: number; config: Record<string, string>; devices: Record<string, Record<string, string>>; schedule?: string; expiry?: string }
export interface VirtImage { fingerprint: string; aliases: string[]; arch: string; type: string; size: number; created: string; last_used: string; description: string; os?: string; release?: string; variant?: string; cached: boolean; source?: string; public: boolean }
export interface VirtPool { name: string; driver: string; source: string; where: string; used: number; total: number; used_by: number }
export interface VirtNetwork { name: string; type: string; managed: boolean; address?: string; nat: boolean; used_by: number }
export interface LibraryFile { name: string; size: number; modified: string; attached_to?: string[] }
export interface ExportConfig { enabled: boolean; hour: number; minute: number; keep: number; instance_only: boolean; instances?: string[]; updated?: string }
export interface ExportRun { at: string; exported: string[]; pruned: string[]; errors?: string[]; seconds: number }
export interface ExportFile { instance: string; name: string; size: number; modified: string }
export interface ExportsView { config: ExportConfig; cron: string; next?: string; last: ExportRun | null; files: ExportFile[]; dir: string; cron_file: string }
export interface FromISO { name: string; iso: string; cpu: string; memory: string; disk: string; pool: string; network: string; secureboot: boolean; start: boolean }
export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; default_pool?: string; networks?: VirtNetwork[]; library: { images: LibraryFile[]; isos: LibraryFile[] } }
export interface VirtInstallOpts { driver: string; pool_size_gib: number; subnet: string; docker_rules: boolean; arc_max_gib: number }
export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; pool: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean }
export const api = {
procs: () => req<ProcTable>('GET', '/api/procs'),
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
@@ -293,6 +321,14 @@ 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
@@ -316,17 +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`),
@@ -435,37 +474,6 @@ export const api = {
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
virt: () => req<VirtOverview>('GET', '/api/virt'),
virtInstall: (o: VirtInstallOpts) => req<TaskInfo>('POST', '/api/virt/install', o),
virtRemote: (type: string, refresh = false) => req<{ images: VirtImage[] }>('GET', `/api/virt/images/remote?type=${type}${refresh ? '&refresh=1' : ''}`),
virtImageDownload: (alias: string) => req<TaskInfo>('POST', '/api/virt/images/download', { alias }),
virtImageImport: (path: string, alias: string, type: string) => req<TaskInfo>('POST', '/api/virt/images/import', { path, alias, type }),
virtImagePublish: (instance: string, alias: string) => req<TaskInfo>('POST', '/api/virt/images/publish', { instance, alias }),
virtImageDelete: (ref: string) => req<void>('POST', '/api/virt/images/delete', { ref }),
virtCreate: (c: VirtCreate) => req<TaskInfo>('POST', '/api/virt/instances', c),
virtAction: (name: string, action: 'start' | 'stop' | 'restart' | 'kill') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${action}`),
virtDelete: (name: string, force: boolean) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/delete`, { confirm: name, force }),
virtTerminal: (name: string, kind: 'console' | 'exec') => req<{ id: string; name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${kind}`),
virtRename: (name: string, to: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/rename`, { name: to }),
virtConfig: (name: string, cfg: Record<string, string>) => req<void>('PUT', `/api/virt/instances/${encodeURIComponent(name)}/config`, cfg),
virtSnapshot: (name: string, snap: string) => req<{ name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots`, { name: snap }),
virtSnapshotAction: (name: string, snap: string, action: 'restore' | 'delete') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots/${encodeURIComponent(snap)}/${action}`),
virtISOFetch: (url: string, sha256: string, name: string) => req<TaskInfo>('POST', '/api/virt/isos/fetch', { url, sha256, name }),
virtISOImport: (path: string) => req<TaskInfo>('POST', '/api/virt/isos/import', { path }),
virtISODelete: (name: string) => req<void>('POST', '/api/virt/isos/delete', { name }),
virtISORename: (name: string, to: string) => req<void>('POST', '/api/virt/isos/rename', { name, to }),
virtISOAttach: (inst: string, iso: string, bootFirst: boolean) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso`, { iso, boot_first: bootFirst }),
virtISODetach: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso/detach`, { device }),
virtFromISO: (f: FromISO) => req<TaskInfo>('POST', '/api/virt/instances/from-iso', f),
virtMountAdd: (inst: string, m: { device: string; source: string; path: string; readonly: boolean; shift: boolean }) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts`, m),
virtMountRemove: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts/remove`, { device }),
virtExports: () => req<ExportsView>('GET', '/api/virt/exports'),
virtExportsPut: (c: ExportConfig) => req<void>('PUT', '/api/virt/exports', c),
virtExportsRun: (instance = '') => req<TaskInfo>('POST', '/api/virt/exports/run', { instance }),
virtExportDelete: (instance: string, name: string) => req<void>('POST', '/api/virt/exports/delete', { instance, name }),
virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'),
virtPoolCreate: (name: string, path: string) => req<{ name: string; path: string }>('POST', '/api/virt/pools', { name, path }),
virtPoolDelete: (name: string) => req<void>('POST', '/api/virt/pools/delete', { name }),
}
export function wsURLPath(path: string): string {
+20 -6
View File
@@ -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 }
}
+2 -2
View File
@@ -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'),
+17
View File
@@ -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 }))
}
+115 -40
View File
@@ -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; }
@@ -30,7 +34,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.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; }
@@ -95,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; }
@@ -145,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); }
@@ -189,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; }
@@ -261,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 */
@@ -364,13 +377,78 @@ 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 { 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.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) {
@@ -716,6 +794,11 @@ 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; }
@@ -816,6 +899,8 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.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; }
@@ -883,43 +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); }
/* ---- Virtualization ---- */
.virt .svc-tools .task { max-height: 45vh; }
.virt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.virt-table th, .virt-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
.virt-table th { font-weight: 500; opacity: .7; }
.virt-table td.num, .virt-table th.num { text-align: right; white-space: nowrap; }
.virt-pick { max-height: 240px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
.virt-pick .svc-row { padding: 6px 10px; }
.virt-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; font-size: 12px; word-break: break-all; }
.virt-form-row { display: flex; gap: 8px; }
.virt-form-row > label { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.virt-form-row > label > input, .virt-form-row > label > select { width: 100%; box-sizing: border-box; }
.virt .modal { max-width: 640px; }
.vga-back { align-items: stretch; padding: 10px; }
.vga { display: flex; flex-direction: column; gap: 6px; width: 100%; height: 100%; max-width: none; max-height: none; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
.vga-bar { flex-shrink: 0; }
.vga-area { flex: 1; min-height: 0; overflow: auto; background: #000; border-radius: 6px; display: flex; align-items: flex-start; justify-content: flex-start; padding: 4px; }
.vga-area #spice-area { display: inline-block; }
.vga-area #spice-screen { display: inline-block; line-height: 0; }
.vga-area canvas { outline: none; }
.vga-msg { display: none; }
.vga-hint { flex-shrink: 0; }
/* 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; } }
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
-165
View File
@@ -1,165 +0,0 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
-16
View File
@@ -1,16 +0,0 @@
# spice-html5, vendored
Upstream: https://gitlab.freedesktop.org/spice/spice-html5 — commit b372aab (2026-09-21),
copied unchanged on 2026-10-09 (`src/`, `thirdparty/`). LGPL-3.0-or-later; the
licence text is beside this file. It draws a VM's SPICE display into a canvas from a websocket
carrying the raw SPICE stream — which is what Incus's VGA console websocket is — so Ham Lab's
daemon bridges the two and this does the rest. Loaded lazily by `VgaConsole.tsx`, so the main
bundle does not carry it. Not on npm in a current form (the `@spice-project/spice-html5`
package stopped at 0.2.1 in 2019), hence the copy.
Upstream's `spice.css` is deliberately **not** here. It is the stylesheet of spice-html5's
demo page, with rules for `body`, `*`, `button` and `canvas` that apply to the whole
document. It was copied and imported on 2026-10-09 and turned every button in Ham Lab into
a gradient pill shifted off its place (v0.1.0-188 to -191). The console is styled by the
`.vga` rules in `src/styles.css`; `scripts/test-css-imports.mjs` fails on any new
stylesheet import that has not been looked at.
-189
View File
@@ -1,189 +0,0 @@
"use strict";
/*
Copyright (C) 2012 by Aric Stewart <aric@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* Copyright 1990,91 by Thomas Roell, Dinkelscherben, Germany.
*
* Permission to use, copy, modify, distribute, and sell this software and its
* documentation for any purpose is hereby granted without fee, provided that
* the above copyright notice appear in all copies and that both that
* copyright notice and this permission notice appear in supporting
* documentation, and that the name of Thomas Roell not be used in
* advertising or publicity pertaining to distribution of the software without
* specific, written prior permission. Thomas Roell makes no representations
* about the suitability of this software for any purpose. It is provided
* "as is" without express or implied warranty.
*
* THOMAS ROELL DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
* INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO
* EVENT SHALL THOMAS ROELL BE LIABLE FOR ANY SPECIAL, INDIRECT OR
* CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE,
* DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
* TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*
*/
/*
* Copyright (c) 1994-2003 by The XFree86 Project, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
* THE COPYRIGHT HOLDER(S) OR AUTHOR(S) BE LIABLE FOR ANY CLAIM, DAMAGES OR
* OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* Except as contained in this notice, the name of the copyright holder(s)
* and author(s) shall not be used in advertising or otherwise to promote
* the sale, use or other dealings in this Software without prior written
* authorization from the copyright holder(s) and author(s).
*/
/*
* NOTE: The AT/MF keyboards can generate (via the 8042) two (MF: three)
* sets of scancodes. Set3 can only be generated by a MF keyboard.
* Set2 sends a makecode for keypress, and the same code prefixed by a
* F0 for keyrelease. This is a little bit ugly to handle. Thus we use
* here for X386 the PC/XT compatible Set1. This set uses 8bit scancodes.
* Bit 7 ist set if the key is released. The code E0 switches to a
* different meaning to add the new MF cursorkeys, while not breaking old
* applications. E1 is another special prefix. Since I assume that there
* will be further versions of PC/XT scancode compatible keyboards, we
* may be in trouble one day.
*
* IDEA: 1) Use Set2 on AT84 keyboards and translate it to MF Set3.
* 2) Use the keyboards native set and translate it to common keysyms.
*/
var KeyNames = {
/*
* definition of the AT84/MF101/MF102 Keyboard:
* ============================================================
* Defined Key Cap Glyphs Pressed value
* Key Name Main Also (hex) (dec)
* ---------------- ---------- ------- ------ ------
*/
KEY_Escape :/* Escape 0x01 */ 1,
KEY_1 :/* 1 ! 0x02 */ 2,
KEY_2 :/* 2 @ 0x03 */ 3,
KEY_3 :/* 3 # 0x04 */ 4,
KEY_4 :/* 4 $ 0x05 */ 5,
KEY_5 :/* 5 % 0x06 */ 6,
KEY_6 :/* 6 ^ 0x07 */ 7,
KEY_7 :/* 7 & 0x08 */ 8,
KEY_8 :/* 8 * 0x09 */ 9,
KEY_9 :/* 9 ( 0x0a */ 10,
KEY_0 :/* 0 ) 0x0b */ 11,
KEY_Minus :/* - (Minus) _ (Under) 0x0c */ 12,
KEY_Equal :/* = (Equal) + 0x0d */ 13,
KEY_BackSpace :/* Back Space 0x0e */ 14,
KEY_Tab :/* Tab 0x0f */ 15,
KEY_Q :/* Q 0x10 */ 16,
KEY_W :/* W 0x11 */ 17,
KEY_E :/* E 0x12 */ 18,
KEY_R :/* R 0x13 */ 19,
KEY_T :/* T 0x14 */ 20,
KEY_Y :/* Y 0x15 */ 21,
KEY_U :/* U 0x16 */ 22,
KEY_I :/* I 0x17 */ 23,
KEY_O :/* O 0x18 */ 24,
KEY_P :/* P 0x19 */ 25,
KEY_LBrace :/* [ { 0x1a */ 26,
KEY_RBrace :/* ] } 0x1b */ 27,
KEY_Enter :/* Enter 0x1c */ 28,
KEY_LCtrl :/* Ctrl(left) 0x1d */ 29,
KEY_A :/* A 0x1e */ 30,
KEY_S :/* S 0x1f */ 31,
KEY_D :/* D 0x20 */ 32,
KEY_F :/* F 0x21 */ 33,
KEY_G :/* G 0x22 */ 34,
KEY_H :/* H 0x23 */ 35,
KEY_J :/* J 0x24 */ 36,
KEY_K :/* K 0x25 */ 37,
KEY_L :/* L 0x26 */ 38,
KEY_SemiColon :/* ;(SemiColon) :(Colon) 0x27 */ 39,
KEY_Quote :/* ' (Apostr) " (Quote) 0x28 */ 40,
KEY_Tilde :/* ` (Accent) ~ (Tilde) 0x29 */ 41,
KEY_ShiftL :/* Shift(left) 0x2a */ 42,
KEY_BSlash :/* \(BckSlash) |(VertBar)0x2b */ 43,
KEY_Z :/* Z 0x2c */ 44,
KEY_X :/* X 0x2d */ 45,
KEY_C :/* C 0x2e */ 46,
KEY_V :/* V 0x2f */ 47,
KEY_B :/* B 0x30 */ 48,
KEY_N :/* N 0x31 */ 49,
KEY_M :/* M 0x32 */ 50,
KEY_Comma :/* , (Comma) < (Less) 0x33 */ 51,
KEY_Period :/* . (Period) >(Greater)0x34 */ 52,
KEY_Slash :/* / (Slash) ? 0x35 */ 53,
KEY_ShiftR :/* Shift(right) 0x36 */ 54,
KEY_KP_Multiply :/* * 0x37 */ 55,
KEY_Alt :/* Alt(left) 0x38 */ 56,
KEY_Space :/* (SpaceBar) 0x39 */ 57,
KEY_CapsLock :/* CapsLock 0x3a */ 58,
KEY_F1 :/* F1 0x3b */ 59,
KEY_F2 :/* F2 0x3c */ 60,
KEY_F3 :/* F3 0x3d */ 61,
KEY_F4 :/* F4 0x3e */ 62,
KEY_F5 :/* F5 0x3f */ 63,
KEY_F6 :/* F6 0x40 */ 64,
KEY_F7 :/* F7 0x41 */ 65,
KEY_F8 :/* F8 0x42 */ 66,
KEY_F9 :/* F9 0x43 */ 67,
KEY_F10 :/* F10 0x44 */ 68,
KEY_NumLock :/* NumLock 0x45 */ 69,
KEY_ScrollLock :/* ScrollLock 0x46 */ 70,
KEY_KP_7 :/* 7 Home 0x47 */ 71,
KEY_KP_8 :/* 8 Up 0x48 */ 72,
KEY_KP_9 :/* 9 PgUp 0x49 */ 73,
KEY_KP_Minus :/* - (Minus) 0x4a */ 74,
KEY_KP_4 :/* 4 Left 0x4b */ 75,
KEY_KP_5 :/* 5 0x4c */ 76,
KEY_KP_6 :/* 6 Right 0x4d */ 77,
KEY_KP_Plus :/* + (Plus) 0x4e */ 78,
KEY_KP_1 :/* 1 End 0x4f */ 79,
KEY_KP_2 :/* 2 Down 0x50 */ 80,
KEY_KP_3 :/* 3 PgDown 0x51 */ 81,
KEY_KP_0 :/* 0 Insert 0x52 */ 82,
KEY_KP_Decimal :/* . (Decimal) Delete 0x53 */ 83,
KEY_SysRequest :/* SysRequest 0x54 */ 84,
/* NOTUSED 0x55 */
KEY_Less :/* < (Less) >(Greater) 0x56 */ 86,
KEY_F11 :/* F11 0x57 */ 87,
KEY_F12 :/* F12 0x58 */ 88,
KEY_Prefix0 :/* special 0x60 */ 96,
KEY_Prefix1 :/* specail 0x61 */ 97,
};
export {
KeyNames,
};
-64
View File
@@ -1,64 +0,0 @@
"use strict";
/*
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
/*----------------------------------------------------------------------------
** bitmap.js
** Handle SPICE_IMAGE_TYPE_BITMAP
**--------------------------------------------------------------------------*/
import { Constants } from './enums.js';
/* A 32-bit source pixel is B, G, R, A in memory. Read it as a little-endian
word, A R G B, rotate it left by eight to R G B A and store that word
big-endian, so the bytes land in the R, G, B, A order ImageData wants: one
load, one store and two shifts per pixel. A DataView takes either host
byte order and any alignment. Only 32BIT and RGBA are handled; 32BIT
ignores the source's high byte and is fully opaque. */
function convert_spice_bitmap_to_web(context, spice_bitmap)
{
if (spice_bitmap.format != Constants.SPICE_BITMAP_FMT_32BIT &&
spice_bitmap.format != Constants.SPICE_BITMAP_FMT_RGBA)
return undefined;
const w = spice_bitmap.x;
const h = spice_bitmap.y;
const stride = spice_bitmap.stride;
const ret = context.createImageData(w, h);
const opaque = spice_bitmap.format == Constants.SPICE_BITMAP_FMT_32BIT;
const top_down = spice_bitmap.flags & Constants.SPICE_BITMAP_FLAGS_TOP_DOWN;
const src = new DataView(spice_bitmap.data);
const dest = new DataView(ret.data.buffer);
let d = 0;
for (let y = 0; y < h; y++)
{
let s = (top_down ? y : h - 1 - y) * stride;
for (let x = 0; x < w; x++, d += 4, s += 4)
{
const v = src.getUint32(s, true);
dest.setUint32(d, (v << 8) | (opaque ? 0xff : v >>> 24), false);
}
}
return ret;
}
export {
convert_spice_bitmap_to_web,
};
-149
View File
@@ -1,149 +0,0 @@
/*
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
*/
export const code_to_scancode = [];
code_to_scancode["Escape"] = 0x01;
code_to_scancode["Digit1"] = 0x02;
code_to_scancode["Digit2"] = 0x03;
code_to_scancode["Digit3"] = 0x04;
code_to_scancode["Digit4"] = 0x05;
code_to_scancode["Digit5"] = 0x06;
code_to_scancode["Digit6"] = 0x07;
code_to_scancode["Digit7"] = 0x08;
code_to_scancode["Digit8"] = 0x09;
code_to_scancode["Digit9"] = 0x0A;
code_to_scancode["Digit0"] = 0x0B;
code_to_scancode["Minus"] = 0x0C;
code_to_scancode["Equal"] = 0x0D;
code_to_scancode["Backspace"] = 0x0E;
code_to_scancode["Tab"] = 0x0F;
code_to_scancode["KeyQ"] = 0x10;
code_to_scancode["KeyW"] = 0x11;
code_to_scancode["KeyE"] = 0x12;
code_to_scancode["KeyR"] = 0x13;
code_to_scancode["KeyT"] = 0x14;
code_to_scancode["KeyY"] = 0x15;
code_to_scancode["KeyU"] = 0x16;
code_to_scancode["KeyI"] = 0x17;
code_to_scancode["KeyO"] = 0x18;
code_to_scancode["KeyP"] = 0x19;
code_to_scancode["BracketLeft"] = 0x1A;
code_to_scancode["BracketRight"] = 0x1B;
code_to_scancode["Enter"] = 0x1C;
code_to_scancode["ControlLeft"] = 0x1D;
code_to_scancode["KeyA"] = 0x1E;
code_to_scancode["KeyS"] = 0x1F;
code_to_scancode["KeyD"] = 0x20;
code_to_scancode["KeyF"] = 0x21;
code_to_scancode["KeyG"] = 0x22;
code_to_scancode["KeyH"] = 0x23;
code_to_scancode["KeyJ"] = 0x24;
code_to_scancode["KeyK"] = 0x25;
code_to_scancode["KeyL"] = 0x26;
code_to_scancode["Semicolon"] = 0x27;
code_to_scancode["Quote"] = 0x28;
code_to_scancode["Backquote"] = 0x29;
code_to_scancode["ShiftLeft"] = 0x2A;
code_to_scancode["Backslash"] = 0x2B;
code_to_scancode["KeyZ"] = 0x2C;
code_to_scancode["KeyX"] = 0x2D;
code_to_scancode["KeyC"] = 0x2E;
code_to_scancode["KeyV"] = 0x2F;
code_to_scancode["KeyB"] = 0x30;
code_to_scancode["KeyN"] = 0x31;
code_to_scancode["KeyM"] = 0x32;
code_to_scancode["Comma"] = 0x33;
code_to_scancode["Period"] = 0x34;
code_to_scancode["Slash"] = 0x35;
code_to_scancode["ShiftRight"] = 0x36;
code_to_scancode["NumpadMultiply"] = 0x37;
code_to_scancode["AltLeft"] = 0x38;
code_to_scancode["Space"] = 0x39;
code_to_scancode["CapsLock"] = 0x3A;
code_to_scancode["F1"] = 0x3B;
code_to_scancode["F2"] = 0x3C;
code_to_scancode["F3"] = 0x3D;
code_to_scancode["F4"] = 0x3E;
code_to_scancode["F5"] = 0x3F;
code_to_scancode["F6"] = 0x40;
code_to_scancode["F7"] = 0x41;
code_to_scancode["F8"] = 0x42;
code_to_scancode["F9"] = 0x43;
code_to_scancode["F10"] = 0x44;
code_to_scancode["Pause"] = 0x45;
code_to_scancode["ScrollLock"] = 0x46;
code_to_scancode["Numpad7"] = 0x47;
code_to_scancode["Numpad8"] = 0x48;
code_to_scancode["Numpad9"] = 0x49;
code_to_scancode["NumpadSubtract"] = 0x4A;
code_to_scancode["Numpad4"] = 0x4B;
code_to_scancode["Numpad5"] = 0x4C;
code_to_scancode["Numpad6"] = 0x4D;
code_to_scancode["NumpadAdd"] = 0x4E;
code_to_scancode["Numpad1"] = 0x4F;
code_to_scancode["Numpad2"] = 0x50;
code_to_scancode["Numpad3"] = 0x51;
code_to_scancode["Numpad0"] = 0x52;
code_to_scancode["NumpadDecimal"] = 0x53;
code_to_scancode["PrintScreen"] = 0x54;
code_to_scancode["IntlBackslash"] = 0x56;
code_to_scancode["F11"] = 0x57;
code_to_scancode["F12"] = 0x58;
code_to_scancode["NumpadEqual"] = 0x59;
code_to_scancode["F13"] = 0x64;
code_to_scancode["F14"] = 0x65;
code_to_scancode["F15"] = 0x66;
code_to_scancode["F16"] = 0x67;
code_to_scancode["F17"] = 0x68;
code_to_scancode["F18"] = 0x69;
code_to_scancode["F19"] = 0x6A;
code_to_scancode["F20"] = 0x6B;
code_to_scancode["F21"] = 0x6C;
code_to_scancode["F22"] = 0x6D;
code_to_scancode["F23"] = 0x6E;
code_to_scancode["KanaMode"] = 0x70;
code_to_scancode["IntlRo"] = 0x73;
code_to_scancode["F24"] = 0x76;
code_to_scancode["Convert"] = 0x79;
code_to_scancode["NonConvert"] = 0x7B;
code_to_scancode["IntlYen"] = 0x7D;
code_to_scancode["NumpadComma"] = 0x7E;
code_to_scancode["MediaTrackPrevious"] = 0xE0 | (0x10 << 8);
code_to_scancode["MediaTrackNext"] = 0xE0 | (0x19 << 8);
code_to_scancode["NumpadEnter"] = 0xE0 | (0x1C << 8);
code_to_scancode["ControlRight"] = 0xE0 | (0x1D << 8);
code_to_scancode["AudioVolumeMute"] = 0xE0 | (0x20 << 8);
code_to_scancode["LaunchApp2"] = 0xE0 | (0x21 << 8);
code_to_scancode["MediaPlayPause"] = 0xE0 | (0x22 << 8);
code_to_scancode["MediaStop"] = 0xE0 | (0x24 << 8);
code_to_scancode["VolumeDown"] = 0xE0 | (0x2E << 8);
code_to_scancode["VolumeUp"] = 0xE0 | (0x30 << 8);
code_to_scancode["BrowserHome"] = 0xE0 | (0x32 << 8);
code_to_scancode["NumpadDivide"] = 0xE0 | (0x35 << 8);
code_to_scancode["PrintScreen"] = 0xE0 | (0x37 << 8);
code_to_scancode["AltRight"] = 0xE0 | (0x38 << 8);
code_to_scancode["NumLock"] = 0xE0 | (0x45 << 8);
code_to_scancode["Pause"] = 0xE0 | (0x46 << 8);
code_to_scancode["Home"] = 0xE0 | (0x47 << 8);
code_to_scancode["ArrowUp"] = 0xE0 | (0x48 << 8);
code_to_scancode["PageUp"] = 0xE0 | (0x49 << 8);
code_to_scancode["ArrowLeft"] = 0xE0 | (0x4B << 8);
code_to_scancode["ArrowRight"] = 0xE0 | (0x4D << 8);
code_to_scancode["End"] = 0xE0 | (0x4F << 8);
code_to_scancode["ArrowDown"] = 0xE0 | (0x50 << 8);
code_to_scancode["PageDown"] = 0xE0 | (0x51 << 8);
code_to_scancode["Insert"] = 0xE0 | (0x52 << 8);
code_to_scancode["Delete"] = 0xE0 | (0x53 << 8);
code_to_scancode["MetaLeft"] = 0xE0 | (0x5B << 8);
code_to_scancode["MetaRight"] = 0xE0 | (0x5C << 8);
code_to_scancode["ContextMenu"] = 0xE0 | (0x5D << 8);
code_to_scancode["Power"] = 0xE0 | (0x5E << 8);
code_to_scancode["BrowserSearch"] = 0xE0 | (0x65 << 8);
code_to_scancode["BrowserFavorites"] = 0xE0 | (0x66 << 8);
code_to_scancode["BrowserRefresh"] = 0xE0 | (0x67 << 8);
code_to_scancode["BrowserStop"] = 0xE0 | (0x68 << 8);
code_to_scancode["BrowserForward"] = 0xE0 | (0x69 << 8);
code_to_scancode["BrowserBack"] = 0xE0 | (0x6A << 8);
code_to_scancode["LaunchApp1"] = 0xE0 | (0x6B << 8);
code_to_scancode["LaunchMail"] = 0xE0 | (0x6C << 8);
code_to_scancode["MediaSelect"] = 0xE0 | (0x6D << 8);
-317
View File
@@ -1,317 +0,0 @@
"use strict";
/*
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
import { create_rgba_png } from './png.js';
import { Constants } from './enums.js';
import { DEBUG } from './utils.js';
import {
SpiceMsgCursorInit,
SpiceMsgCursorMove,
SpiceMsgCursorSet,
} from './spicemsg.js';
import { SpiceSimulateCursor } from './simulatecursor.js';
import { SpiceConn } from './spiceconn.js';
/*----------------------------------------------------------------------------
** SpiceCursorConn
** Drive the Spice Cursor Channel
**--------------------------------------------------------------------------*/
function SpiceCursorConn()
{
SpiceConn.apply(this, arguments);
}
/* The largest cursor image Chromium and Firefox will show. */
var MAX_CSS_CURSOR = 128;
SpiceCursorConn.prototype = Object.create(SpiceConn.prototype);
SpiceCursorConn.prototype.process_channel_message = function(msg)
{
if (msg.type == Constants.SPICE_MSG_CURSOR_INIT)
{
var cursor_init = new SpiceMsgCursorInit(msg.data);
DEBUG > 1 && console.log("SpiceMsgCursorInit");
if (this.parent && this.parent.inputs &&
this.parent.inputs.mouse_mode == Constants.SPICE_MOUSE_MODE_SERVER)
{
// FIXME - this imagines that the server actually
// provides the current cursor position,
// instead of 0,0. As of May 11, 2012,
// that assumption was false :-(.
this.parent.inputs.mousex = cursor_init.position.x;
this.parent.inputs.mousey = cursor_init.position.y;
}
// FIXME - We don't handle most of the parameters here...
return true;
}
if (msg.type == Constants.SPICE_MSG_CURSOR_SET)
{
var cursor_set = new SpiceMsgCursorSet(msg.data);
DEBUG > 1 && console.log("SpiceMsgCursorSet");
if (cursor_set.cursor.flags & Constants.SPICE_CURSOR_FLAGS_NONE)
{
this.hide_cursor();
return true;
}
if (cursor_set.cursor.flags > 0)
this.log_warn("FIXME: No support for cursor flags " + cursor_set.cursor.flags);
/* An unconvertible shape is not an unhandled message: returning
false here made the base class log a second, misleading
"Unknown message type 103" for every one of these. */
if (! this.set_cursor(cursor_set.cursor))
this.warn_once_per_cursor_type(cursor_set.cursor.header.type);
return true;
}
if (msg.type == Constants.SPICE_MSG_CURSOR_MOVE)
{
/* Only meaningful under server mouse mode, where the server owns
the pointer position and we track it for inputs. Under client
mode the browser draws the pointer and already knows where it
is, so there is nothing to do -- but it is handled either way,
rather than reported as unimplemented on every move. */
var cursor_move = new SpiceMsgCursorMove(msg.data);
if (this.parent && this.parent.inputs &&
this.parent.inputs.mouse_mode == Constants.SPICE_MOUSE_MODE_SERVER)
{
this.parent.inputs.mousex = cursor_move.position.x;
this.parent.inputs.mousey = cursor_move.position.y;
}
return true;
}
if (msg.type == Constants.SPICE_MSG_CURSOR_HIDE)
{
DEBUG > 1 && console.log("SpiceMsgCursorHide");
this.hide_cursor();
return true;
}
if (msg.type == Constants.SPICE_MSG_CURSOR_TRAIL)
{
this.known_unimplemented(msg.type, "Cursor Trail");
return true;
}
if (msg.type == Constants.SPICE_MSG_CURSOR_RESET)
{
DEBUG > 1 && console.log("SpiceMsgCursorReset");
document.getElementById(this.parent.screen_id).style.cursor = "auto";
return true;
}
if (msg.type == Constants.SPICE_MSG_CURSOR_INVAL_ONE)
{
this.known_unimplemented(msg.type, "Cursor Inval One");
return true;
}
if (msg.type == Constants.SPICE_MSG_CURSOR_INVAL_ALL)
{
DEBUG > 1 && console.log("SpiceMsgCursorInvalAll");
// FIXME - There may be something useful to do here...
return true;
}
return false;
}
/* One warning per shape type per connection. Windows sends a cursor on
nearly every hover, so an unconvertible type used to produce an
unbounded stream of identical warnings. */
SpiceCursorConn.prototype.warn_once_per_cursor_type = function(type)
{
if (! this.warned_cursor_types)
this.warned_cursor_types = {};
if (this.warned_cursor_types[type])
return;
this.warned_cursor_types[type] = true;
this.log_warn("No support for cursor type " + type + "; leaving the cursor as it was.");
}
/* SPICE cursor shapes to the RGBA the PNG writer expects.
Returns null for a type we cannot convert.
Layouts follow spice-gtk's set_cursor() (src/channel-cursor.c). */
function cursor_to_rgba(header, data)
{
var width = header.width;
var height = header.height;
var pixels = width * height;
var u8 = new Uint8Array(data);
var rgba = new Uint8Array(pixels * 4);
var i, at;
/* SPICE ships 32bpp shapes as native-endian ARGB, which on every
platform we run on is B,G,R,A in memory; PNG wants R,G,B,A. The
swap is invisible on the black/white/transparent cursors that
dominate, which is why it went unnoticed, but colour cursors came
out with red and blue exchanged. */
if (header.type == Constants.SPICE_CURSOR_TYPE_ALPHA)
{
if (u8.length < pixels * 4)
return null;
for (i = 0, at = 0; i < pixels; i++, at += 4)
{
rgba[at] = u8[at + 2];
rgba[at + 1] = u8[at + 1];
rgba[at + 2] = u8[at];
rgba[at + 3] = u8[at + 3];
}
return rgba;
}
/* Two 1bpp masks, AND then XOR, each row padded to a byte. The four
combinations are the classic ones:
AND=1 XOR=0 transparent
AND=1 XOR=1 invert the screen
AND=0 XOR=0 black
AND=0 XOR=1 white
A CSS cursor cannot invert what is under it, so those pixels are
drawn black -- the same choice every other HTML SPICE/VNC client
makes. It is what Windows' I-beam and resize cursors use, and
black-on-transparent reads correctly against the light backgrounds
those appear over. */
if (header.type == Constants.SPICE_CURSOR_TYPE_MONO)
{
var bpl = (width + 7) >> 3;
if (u8.length < bpl * height * 2)
return null;
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var byte_at = y * bpl + (x >> 3);
var bit = 0x80 >> (x & 7);
var and_bit = (u8[byte_at] & bit) != 0;
var xor_bit = (u8[bpl * height + byte_at] & bit) != 0;
at = (y * width + x) * 4;
if (and_bit && ! xor_bit)
continue; /* transparent; rgba is zeroed */
var lum = xor_bit && ! and_bit ? 255 : 0;
rgba[at] = rgba[at + 1] = rgba[at + 2] = lum;
rgba[at + 3] = 255;
}
}
return rgba;
}
/* 32bpp BGRA followed by a 1bpp AND mask: mask set means the pixel
is transparent. spice-gtk renders the "white pixel that is also
masked" case as a dim checkerboard rather than a hole, because
that combination is how shapes encode invert; do the same. */
if (header.type == Constants.SPICE_CURSOR_TYPE_COLOR32)
{
var mask_at = pixels * 4;
if (u8.length < mask_at + ((width + 7) >> 3) * height)
return null;
for (i = 0, at = 0; i < pixels; i++, at += 4)
{
var masked = (u8[mask_at + (i >> 3)] & (0x80 >> (i & 7))) != 0;
var white = u8[at] == 0xff && u8[at + 1] == 0xff && u8[at + 2] == 0xff;
if (masked && white)
{
var dark = (((i % width) ^ ((i / width) | 0)) & 1) != 0;
rgba[at] = rgba[at + 1] = rgba[at + 2] = dark ? 0x30 : 0x50;
rgba[at + 3] = dark ? 0xc0 : 0x30;
continue;
}
rgba[at] = u8[at + 2];
rgba[at + 1] = u8[at + 1];
rgba[at + 2] = u8[at];
rgba[at + 3] = masked ? 0 : 255;
}
return rgba;
}
return null;
}
/* True if the shape was converted and applied. */
SpiceCursorConn.prototype.set_cursor = function(cursor)
{
var rgba = cursor_to_rgba(cursor.header, cursor.data);
if (! rgba)
return false;
var pngstr = create_rgba_png(cursor.header.width, cursor.header.height, rgba);
var curstr = 'url(data:image/png,' + pngstr + ') ' +
cursor.header.hot_spot_x + ' ' + cursor.header.hot_spot_y + ", default";
var screen = document.getElementById(this.parent.screen_id);
screen.style.cursor = 'auto';
screen.style.cursor = curstr;
/* Chromium and Firefox drop a cursor image over 128 px on a side
without saying so: the computed style still reads as the url,
and the pointer is the plain arrow, with the arrow's hot spot,
so it points somewhere other than the guest's cursor does. A
shape that size is drawn under the pointer instead. */
var too_large = cursor.header.width > MAX_CSS_CURSOR || cursor.header.height > MAX_CSS_CURSOR;
if (too_large || window.getComputedStyle(screen, null).cursor == 'auto')
SpiceSimulateCursor.simulate_cursor(this, cursor, screen, pngstr, too_large);
else
{
/* This cursor took effect natively, so drop the simulated one
left by an earlier browser-rejected cursor; the mousemove
handler would otherwise keep painting it alongside the real
cursor forever. */
this.remove_simulated_cursor();
}
return true;
}
/* A hidden pointer is hidden whichever way the last shape was shown:
the CSS cursor goes to none, and a simulated cursor image is removed,
or the mousemove handler would keep painting it. */
SpiceCursorConn.prototype.hide_cursor = function()
{
document.getElementById(this.parent.screen_id).style.cursor = "none";
this.remove_simulated_cursor();
}
SpiceCursorConn.prototype.remove_simulated_cursor = function()
{
if (! this.spice_simulated_cursor)
return;
if (this.spice_simulated_cursor.parentNode)
this.spice_simulated_cursor.parentNode.removeChild(this.spice_simulated_cursor);
delete this.spice_simulated_cursor;
}
SpiceCursorConn.prototype.cleanup = function()
{
/* The screen div outlives the connection: a simulated cursor left in
it would sit frozen over the next session, and a guest that ended
with its pointer hidden (or a custom shape) would leave the next
one without a visible pointer until it sends a shape of its own. */
this.remove_simulated_cursor();
var screen = document.getElementById(this.parent.screen_id);
if (screen)
screen.style.cursor = "auto";
SpiceConn.prototype.cleanup.call(this);
}
export {
SpiceCursorConn,
};
File diff suppressed because it is too large Load Diff
-384
View File
@@ -1,384 +0,0 @@
"use strict";
/*
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
/*----------------------------------------------------------------------------
** enums.js
** 'constants' for Spice
**--------------------------------------------------------------------------*/
export var Constants = {
SPICE_MAGIC : "REDQ",
SPICE_VERSION_MAJOR : 2,
SPICE_VERSION_MINOR : 2,
SPICE_CONNECT_TIMEOUT : (30 * 1000),
SPICE_COMMON_CAP_PROTOCOL_AUTH_SELECTION : 0,
SPICE_COMMON_CAP_AUTH_SPICE : 1,
SPICE_COMMON_CAP_AUTH_SASL : 2,
SPICE_COMMON_CAP_MINI_HEADER : 3,
SPICE_TICKET_KEY_PAIR_LENGTH : 1024,
SPICE_TICKET_PUBKEY_BYTES : (1024 / 8 +34), // (SPICE_TICKET_KEY_PAIR_LENGTH / 8 + 34)
SPICE_LINK_ERR_OK : 0,
SPICE_LINK_ERR_ERROR : 1,
SPICE_LINK_ERR_INVALID_MAGIC : 2,
SPICE_LINK_ERR_INVALID_DATA : 3,
SPICE_LINK_ERR_VERSION_MISMATCH : 4,
SPICE_LINK_ERR_NEED_SECURED : 5,
SPICE_LINK_ERR_NEED_UNSECURED : 6,
SPICE_LINK_ERR_PERMISSION_DENIED : 7,
SPICE_LINK_ERR_BAD_CONNECTION_ID : 8,
SPICE_LINK_ERR_CHANNEL_NOT_AVAILABLE : 9,
SPICE_MSG_MIGRATE : 1,
SPICE_MSG_MIGRATE_DATA : 2,
SPICE_MSG_SET_ACK : 3,
SPICE_MSG_PING : 4,
SPICE_MSG_WAIT_FOR_CHANNELS : 5,
SPICE_MSG_DISCONNECTING : 6,
SPICE_MSG_NOTIFY : 7,
SPICE_MSG_LIST : 8,
SPICE_MSG_MAIN_MIGRATE_BEGIN : 101,
SPICE_MSG_MAIN_MIGRATE_CANCEL : 102,
SPICE_MSG_MAIN_INIT : 103,
SPICE_MSG_MAIN_CHANNELS_LIST : 104,
SPICE_MSG_MAIN_MOUSE_MODE : 105,
SPICE_MSG_MAIN_MULTI_MEDIA_TIME : 106,
SPICE_MSG_MAIN_AGENT_CONNECTED : 107,
SPICE_MSG_MAIN_AGENT_DISCONNECTED : 108,
SPICE_MSG_MAIN_AGENT_DATA : 109,
SPICE_MSG_MAIN_AGENT_TOKEN : 110,
SPICE_MSG_MAIN_MIGRATE_SWITCH_HOST : 111,
SPICE_MSG_MAIN_MIGRATE_END : 112,
SPICE_MSG_MAIN_NAME : 113,
SPICE_MSG_MAIN_UUID : 114,
SPICE_MSG_MAIN_AGENT_CONNECTED_TOKENS : 115,
SPICE_MSG_MAIN_MIGRATE_BEGIN_SEAMLESS : 116,
SPICE_MSG_MAIN_MIGRATE_DST_SEAMLESS_ACK : 117,
SPICE_MSG_MAIN_MIGRATE_DST_SEAMLESS_NACK : 118,
SPICE_MSG_END_MAIN : 119,
SPICE_MSGC_ACK_SYNC : 1,
SPICE_MSGC_ACK : 2,
SPICE_MSGC_PONG : 3,
SPICE_MSGC_MIGRATE_FLUSH_MARK : 4,
SPICE_MSGC_MIGRATE_DATA : 5,
SPICE_MSGC_DISCONNECTING : 6,
SPICE_MSGC_MAIN_CLIENT_INFO : 101,
SPICE_MSGC_MAIN_MIGRATE_CONNECTED : 102,
SPICE_MSGC_MAIN_MIGRATE_CONNECT_ERROR : 103,
SPICE_MSGC_MAIN_ATTACH_CHANNELS : 104,
SPICE_MSGC_MAIN_MOUSE_MODE_REQUEST : 105,
SPICE_MSGC_MAIN_AGENT_START : 106,
SPICE_MSGC_MAIN_AGENT_DATA : 107,
SPICE_MSGC_MAIN_AGENT_TOKEN : 108,
SPICE_MSGC_MAIN_MIGRATE_END : 109,
SPICE_MSGC_END_MAIN : 110,
SPICE_MSG_DISPLAY_MODE : 101,
SPICE_MSG_DISPLAY_MARK : 102,
SPICE_MSG_DISPLAY_RESET : 103,
SPICE_MSG_DISPLAY_COPY_BITS : 104,
SPICE_MSG_DISPLAY_INVAL_LIST : 105,
SPICE_MSG_DISPLAY_INVAL_ALL_PIXMAPS : 106,
SPICE_MSG_DISPLAY_INVAL_PALETTE : 107,
SPICE_MSG_DISPLAY_INVAL_ALL_PALETTES: 108,
SPICE_MSG_DISPLAY_STREAM_CREATE : 122,
SPICE_MSG_DISPLAY_STREAM_DATA : 123,
SPICE_MSG_DISPLAY_STREAM_CLIP : 124,
SPICE_MSG_DISPLAY_STREAM_DESTROY : 125,
SPICE_MSG_DISPLAY_STREAM_DESTROY_ALL: 126,
SPICE_MSG_DISPLAY_DRAW_FILL : 302,
SPICE_MSG_DISPLAY_DRAW_OPAQUE : 303,
SPICE_MSG_DISPLAY_DRAW_COPY : 304,
SPICE_MSG_DISPLAY_DRAW_BLEND : 305,
SPICE_MSG_DISPLAY_DRAW_BLACKNESS : 306,
SPICE_MSG_DISPLAY_DRAW_WHITENESS : 307,
SPICE_MSG_DISPLAY_DRAW_INVERS : 308,
SPICE_MSG_DISPLAY_DRAW_ROP3 : 309,
SPICE_MSG_DISPLAY_DRAW_STROKE : 310,
SPICE_MSG_DISPLAY_DRAW_TEXT : 311,
SPICE_MSG_DISPLAY_DRAW_TRANSPARENT : 312,
SPICE_MSG_DISPLAY_DRAW_ALPHA_BLEND : 313,
SPICE_MSG_DISPLAY_SURFACE_CREATE : 314,
SPICE_MSG_DISPLAY_SURFACE_DESTROY : 315,
SPICE_MSG_DISPLAY_STREAM_DATA_SIZED : 316,
SPICE_MSG_DISPLAY_MONITORS_CONFIG : 317,
SPICE_MSG_DISPLAY_DRAW_COMPOSITE : 318,
SPICE_MSG_DISPLAY_STREAM_ACTIVATE_REPORT : 319,
SPICE_MSGC_DISPLAY_INIT : 101,
SPICE_MSGC_DISPLAY_STREAM_REPORT : 102,
SPICE_MSG_INPUTS_INIT : 101,
SPICE_MSG_INPUTS_KEY_MODIFIERS : 102,
SPICE_MSG_INPUTS_MOUSE_MOTION_ACK : 111,
SPICE_MSGC_INPUTS_KEY_DOWN : 101,
SPICE_MSGC_INPUTS_KEY_UP : 102,
SPICE_MSGC_INPUTS_KEY_MODIFIERS : 103,
SPICE_MSGC_INPUTS_MOUSE_MOTION : 111,
SPICE_MSGC_INPUTS_MOUSE_POSITION : 112,
SPICE_MSGC_INPUTS_MOUSE_PRESS : 113,
SPICE_MSGC_INPUTS_MOUSE_RELEASE : 114,
SPICE_MSG_CURSOR_INIT : 101,
SPICE_MSG_CURSOR_RESET : 102,
SPICE_MSG_CURSOR_SET : 103,
SPICE_MSG_CURSOR_MOVE : 104,
SPICE_MSG_CURSOR_HIDE : 105,
SPICE_MSG_CURSOR_TRAIL : 106,
SPICE_MSG_CURSOR_INVAL_ONE : 107,
SPICE_MSG_CURSOR_INVAL_ALL : 108,
SPICE_MSG_PLAYBACK_DATA : 101,
SPICE_MSG_PLAYBACK_MODE : 102,
SPICE_MSG_PLAYBACK_START : 103,
SPICE_MSG_PLAYBACK_STOP : 104,
SPICE_MSG_PLAYBACK_VOLUME : 105,
SPICE_MSG_PLAYBACK_MUTE : 106,
SPICE_MSG_PLAYBACK_LATENCY : 107,
SPICE_MSG_SPICEVMC_DATA : 101,
SPICE_MSG_PORT_INIT : 201,
SPICE_MSG_PORT_EVENT : 202,
SPICE_MSG_END_PORT : 203,
SPICE_MSGC_SPICEVMC_DATA : 101,
SPICE_MSGC_PORT_EVENT : 201,
SPICE_MSGC_END_PORT : 202,
SPICE_PLAYBACK_CAP_CELT_0_5_1 : 0,
SPICE_PLAYBACK_CAP_VOLUME : 1,
SPICE_PLAYBACK_CAP_LATENCY : 2,
SPICE_PLAYBACK_CAP_OPUS : 3,
SPICE_MAIN_CAP_SEMI_SEAMLESS_MIGRATE : 0,
SPICE_MAIN_CAP_NAME_AND_UUID : 1,
SPICE_MAIN_CAP_AGENT_CONNECTED_TOKENS : 2,
SPICE_MAIN_CAP_SEAMLESS_MIGRATE : 3,
SPICE_DISPLAY_CAP_SIZED_STREAM : 0,
SPICE_DISPLAY_CAP_MONITORS_CONFIG : 1,
SPICE_DISPLAY_CAP_COMPOSITE : 2,
SPICE_DISPLAY_CAP_A8_SURFACE : 3,
SPICE_DISPLAY_CAP_STREAM_REPORT : 4,
SPICE_DISPLAY_CAP_LZ4_COMPRESSION : 5,
SPICE_DISPLAY_CAP_PREF_COMPRESSION : 6,
SPICE_DISPLAY_CAP_GL_SCANOUT : 7,
SPICE_DISPLAY_CAP_MULTI_CODEC : 8,
SPICE_DISPLAY_CAP_CODEC_MJPEG : 9,
SPICE_DISPLAY_CAP_CODEC_VP8 : 10,
SPICE_AUDIO_DATA_MODE_INVALID : 0,
SPICE_AUDIO_DATA_MODE_RAW : 1,
SPICE_AUDIO_DATA_MODE_CELT_0_5_1 : 2,
SPICE_AUDIO_DATA_MODE_OPUS : 3,
SPICE_AUDIO_FMT_INVALID : 0,
SPICE_AUDIO_FMT_S16 : 1,
SPICE_CHANNEL_MAIN : 1,
SPICE_CHANNEL_DISPLAY : 2,
SPICE_CHANNEL_INPUTS : 3,
SPICE_CHANNEL_CURSOR : 4,
SPICE_CHANNEL_PLAYBACK : 5,
SPICE_CHANNEL_RECORD : 6,
SPICE_CHANNEL_TUNNEL : 7,
SPICE_CHANNEL_SMARTCARD : 8,
SPICE_CHANNEL_USBREDIR : 9,
SPICE_CHANNEL_PORT : 10,
SPICE_CHANNEL_WEBDAV : 11,
SPICE_SURFACE_FLAGS_PRIMARY : (1 << 0),
SPICE_NOTIFY_SEVERITY_INFO : 0,
SPICE_NOTIFY_SEVERITY_WARN : 1,
SPICE_NOTIFY_SEVERITY_ERROR : 2,
SPICE_MOUSE_MODE_SERVER : (1 << 0),
SPICE_MOUSE_MODE_CLIENT : (1 << 1),
SPICE_MOUSE_MODE_MASK : 0x3,
SPICE_CLIP_TYPE_NONE : 0,
SPICE_CLIP_TYPE_RECTS : 1,
SPICE_IMAGE_SCALE_MODE_INTERPOLATE : 0,
SPICE_IMAGE_SCALE_MODE_NEAREST : 1,
SPICE_IMAGE_TYPE_BITMAP : 0,
SPICE_IMAGE_TYPE_QUIC : 1,
SPICE_IMAGE_TYPE_RESERVED : 2,
SPICE_IMAGE_TYPE_LZ_PLT : 100,
SPICE_IMAGE_TYPE_LZ_RGB : 101,
SPICE_IMAGE_TYPE_GLZ_RGB : 102,
SPICE_IMAGE_TYPE_FROM_CACHE : 103,
SPICE_IMAGE_TYPE_SURFACE : 104,
SPICE_IMAGE_TYPE_JPEG : 105,
SPICE_IMAGE_TYPE_FROM_CACHE_LOSSLESS : 106,
SPICE_IMAGE_TYPE_ZLIB_GLZ_RGB : 107,
SPICE_IMAGE_TYPE_JPEG_ALPHA : 108,
SPICE_IMAGE_FLAGS_CACHE_ME : (1 << 0),
SPICE_IMAGE_FLAGS_HIGH_BITS_SET : (1 << 1),
SPICE_IMAGE_FLAGS_CACHE_REPLACE_ME : (1 << 2),
SPICE_BITMAP_FLAGS_PAL_CACHE_ME : (1 << 0),
SPICE_BITMAP_FLAGS_PAL_FROM_CACHE : (1 << 1),
SPICE_BITMAP_FLAGS_TOP_DOWN : (1 << 2),
SPICE_BITMAP_FLAGS_MASK : 0x7,
SPICE_BITMAP_FMT_INVALID : 0,
SPICE_BITMAP_FMT_1BIT_LE : 1,
SPICE_BITMAP_FMT_1BIT_BE : 2,
SPICE_BITMAP_FMT_4BIT_LE : 3,
SPICE_BITMAP_FMT_4BIT_BE : 4,
SPICE_BITMAP_FMT_8BIT : 5,
SPICE_BITMAP_FMT_16BIT : 6,
SPICE_BITMAP_FMT_24BIT : 7,
SPICE_BITMAP_FMT_32BIT : 8,
SPICE_BITMAP_FMT_RGBA : 9,
SPICE_CURSOR_FLAGS_NONE : (1 << 0),
SPICE_CURSOR_FLAGS_CACHE_ME : (1 << 1),
SPICE_CURSOR_FLAGS_FROM_CACHE : (1 << 2),
SPICE_CURSOR_FLAGS_MASK : 0x7,
SPICE_MOUSE_BUTTON_MASK_LEFT : (1 << 0),
SPICE_MOUSE_BUTTON_MASK_MIDDLE : (1 << 1),
SPICE_MOUSE_BUTTON_MASK_RIGHT : (1 << 2),
SPICE_MOUSE_BUTTON_MASK_MASK : 0x7,
SPICE_MOUSE_BUTTON_INVALID : 0,
SPICE_MOUSE_BUTTON_LEFT : 1,
SPICE_MOUSE_BUTTON_MIDDLE : 2,
SPICE_MOUSE_BUTTON_RIGHT : 3,
SPICE_MOUSE_BUTTON_UP : 4,
SPICE_MOUSE_BUTTON_DOWN : 5,
SPICE_BRUSH_TYPE_NONE : 0,
SPICE_BRUSH_TYPE_SOLID : 1,
SPICE_BRUSH_TYPE_PATTERN : 2,
SPICE_SURFACE_FMT_INVALID : 0,
SPICE_SURFACE_FMT_1_A : 1,
SPICE_SURFACE_FMT_8_A : 8,
SPICE_SURFACE_FMT_16_555 : 16,
SPICE_SURFACE_FMT_32_xRGB : 32,
SPICE_SURFACE_FMT_16_565 : 80,
SPICE_SURFACE_FMT_32_ARGB : 96,
SPICE_ROPD_INVERS_SRC : (1 << 0),
SPICE_ROPD_INVERS_BRUSH : (1 << 1),
SPICE_ROPD_INVERS_DEST : (1 << 2),
SPICE_ROPD_OP_PUT : (1 << 3),
SPICE_ROPD_OP_OR : (1 << 4),
SPICE_ROPD_OP_AND : (1 << 5),
SPICE_ROPD_OP_XOR : (1 << 6),
SPICE_ROPD_OP_BLACKNESS : (1 << 7),
SPICE_ROPD_OP_WHITENESS : (1 << 8),
SPICE_ROPD_OP_INVERS : (1 << 9),
SPICE_ROPD_INVERS_RES : (1 << 10),
SPICE_ROPD_MASK : 0x7ff,
LZ_IMAGE_TYPE_INVALID : 0,
LZ_IMAGE_TYPE_PLT1_LE : 1,
LZ_IMAGE_TYPE_PLT1_BE : 2, // PLT stands for palette
LZ_IMAGE_TYPE_PLT4_LE : 3,
LZ_IMAGE_TYPE_PLT4_BE : 4,
LZ_IMAGE_TYPE_PLT8 : 5,
LZ_IMAGE_TYPE_RGB16 : 6,
LZ_IMAGE_TYPE_RGB24 : 7,
LZ_IMAGE_TYPE_RGB32 : 8,
LZ_IMAGE_TYPE_RGBA : 9,
LZ_IMAGE_TYPE_XXXA : 10,
SPICE_INPUT_MOTION_ACK_BUNCH : 4,
SPICE_CURSOR_TYPE_ALPHA : 0,
SPICE_CURSOR_TYPE_MONO : 1,
SPICE_CURSOR_TYPE_COLOR4 : 2,
SPICE_CURSOR_TYPE_COLOR8 : 3,
SPICE_CURSOR_TYPE_COLOR16 : 4,
SPICE_CURSOR_TYPE_COLOR24 : 5,
SPICE_CURSOR_TYPE_COLOR32 : 6,
SPICE_VIDEO_CODEC_TYPE_MJPEG : 1,
SPICE_VIDEO_CODEC_TYPE_VP8 : 2,
SPICE_STREAM_FLAGS_TOP_DOWN : (1 << 0),
VD_AGENT_PROTOCOL : 1,
VD_AGENT_MAX_DATA_SIZE : 2048,
VD_AGENT_MOUSE_STATE : 1,
VD_AGENT_MONITORS_CONFIG : 2,
VD_AGENT_REPLY : 3,
VD_AGENT_CLIPBOARD : 4,
VD_AGENT_DISPLAY_CONFIG : 5,
VD_AGENT_ANNOUNCE_CAPABILITIES : 6,
VD_AGENT_CLIPBOARD_GRAB : 7,
VD_AGENT_CLIPBOARD_REQUEST : 8,
VD_AGENT_CLIPBOARD_RELEASE : 9,
VD_AGENT_FILE_XFER_START :10,
VD_AGENT_FILE_XFER_STATUS :11,
VD_AGENT_FILE_XFER_DATA :12,
VD_AGENT_CLIENT_DISCONNECTED :13,
VD_AGENT_MAX_CLIPBOARD :14,
VD_AGENT_CAP_MOUSE_STATE : 0,
VD_AGENT_CAP_MONITORS_CONFIG : 1,
VD_AGENT_CAP_REPLY : 2,
VD_AGENT_CAP_CLIPBOARD : 3,
VD_AGENT_CAP_DISPLAY_CONFIG : 4,
VD_AGENT_CAP_CLIPBOARD_BY_DEMAND : 5,
VD_AGENT_CAP_CLIPBOARD_SELECTION : 6,
VD_AGENT_CAP_SPARSE_MONITORS_CONFIG : 7,
VD_AGENT_CAP_GUEST_LINEEND_LF : 8,
VD_AGENT_CAP_GUEST_LINEEND_CRLF : 9,
VD_AGENT_CAP_MAX_CLIPBOARD : 10,
VD_AGENT_END_CAP : 11,
VD_AGENT_FILE_XFER_STATUS_CAN_SEND_DATA : 0,
VD_AGENT_FILE_XFER_STATUS_CANCELLED : 1,
VD_AGENT_FILE_XFER_STATUS_ERROR : 2,
VD_AGENT_FILE_XFER_STATUS_SUCCESS : 3,
VD_AGENT_CLIPBOARD_NONE : 0,
VD_AGENT_CLIPBOARD_UTF8_TEXT : 1,
VD_AGENT_CLIPBOARD_IMAGE_PNG : 2,
VD_AGENT_CLIPBOARD_IMAGE_BMP : 3,
VD_AGENT_CLIPBOARD_IMAGE_TIFF : 4,
VD_AGENT_CLIPBOARD_IMAGE_JPG : 5,
};
-94
View File
@@ -1,94 +0,0 @@
"use strict";
/*
Copyright (C) 2014 Red Hat, Inc.
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
function SpiceFileXferTask(id, file)
{
this.id = id;
this.file = file;
}
SpiceFileXferTask.prototype.create_progressbar = function()
{
var _this = this;
var cancel = document.createElement("input");
this.progressbar_container = document.createElement("div");
this.progressbar = document.createElement("progress");
cancel.type = 'button';
cancel.value = 'Cancel';
cancel.style.float = 'right';
cancel.onclick = function()
{
_this.cancelled = true;
_this.remove_progressbar();
};
this.progressbar.setAttribute('max', this.file.size);
this.progressbar.setAttribute('value', 0);
this.progressbar.style.width = '100%';
this.progressbar.style.margin = '4px auto';
this.progressbar.style.display = 'inline-block';
this.progressbar_container.style.width = '90%';
this.progressbar_container.style.margin = 'auto';
this.progressbar_container.style.padding = '4px';
this.progressbar_container.textContent = this.file.name;
this.progressbar_container.appendChild(cancel);
this.progressbar_container.appendChild(this.progressbar);
document.getElementById('spice-xfer-area').appendChild(this.progressbar_container);
}
SpiceFileXferTask.prototype.update_progressbar = function(value)
{
this.progressbar.setAttribute('value', value);
}
SpiceFileXferTask.prototype.remove_progressbar = function()
{
if (this.progressbar_container && this.progressbar_container.parentNode)
this.progressbar_container.parentNode.removeChild(this.progressbar_container);
}
function handle_file_dragover(e)
{
e.stopPropagation();
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
function handle_file_drop(e)
{
var sc = window.spice_connection;
var files = e.dataTransfer.files;
e.stopPropagation();
e.preventDefault();
for (var i = files.length - 1; i >= 0; i--)
{
if (files[i].type) // do not copy a directory
sc.file_xfer_start(files[i]);
}
}
export {
SpiceFileXferTask,
handle_file_dragover,
handle_file_drop,
};
-504
View File
@@ -1,504 +0,0 @@
"use strict";
/*
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
import * as Messages from './spicemsg.js';
import { Constants } from './enums.js';
import { KeyNames } from './atKeynames.js';
import { SpiceConn } from './spiceconn.js';
import { DEBUG } from './utils.js';
import { code_to_scancode } from './code_to_scancode.js';
/*----------------------------------------------------------------------------
** Modifier Keystates
** These need to be tracked because focus in and out can get the keyboard
** out of sync.
**------------------------------------------------------------------------*/
var Shift_state = -1;
var Ctrl_state = -1;
var Alt_state = -1;
var Meta_state = -1;
/* Extended (0xE0-prefixed) scancodes for the Meta/Super keys, matching
the values in the scancode maps (utils.js / code_to_scancode.js). */
var META_L_SCAN = 0xE0 | (0x5B << 8);
var META_R_SCAN = 0xE0 | (0x5C << 8);
/*----------------------------------------------------------------------------
** SpiceInputsConn
** Drive the Spice Inputs channel (e.g. mouse + keyboard)
**--------------------------------------------------------------------------*/
function SpiceInputsConn()
{
SpiceConn.apply(this, arguments);
this.mousex = undefined;
this.mousey = undefined;
this.button_state = 0;
this.waiting_for_ack = 0;
/* Modifier tracking is module state; a page that tears down one
connection and creates another must not inherit the previous
session's held keys. */
Shift_state = Ctrl_state = Alt_state = Meta_state = -1;
}
SpiceInputsConn.prototype = Object.create(SpiceConn.prototype);
SpiceInputsConn.prototype.process_channel_message = function(msg)
{
if (msg.type == Constants.SPICE_MSG_INPUTS_INIT)
{
var inputs_init = new Messages.SpiceMsgInputsInit(msg.data);
this.keyboard_modifiers = inputs_init.keyboard_modifiers;
DEBUG > 1 && console.log("MsgInputsInit - modifier " + this.keyboard_modifiers);
// FIXME - We don't do anything with the keyboard modifiers...
return true;
}
if (msg.type == Constants.SPICE_MSG_INPUTS_KEY_MODIFIERS)
{
var key = new Messages.SpiceMsgInputsKeyModifiers(msg.data);
this.keyboard_modifiers = key.keyboard_modifiers;
DEBUG > 1 && console.log("MsgInputsKeyModifiers - modifier " + this.keyboard_modifiers);
// FIXME - We don't do anything with the keyboard modifiers...
return true;
}
if (msg.type == Constants.SPICE_MSG_INPUTS_MOUSE_MOTION_ACK)
{
DEBUG > 1 && console.log("mouse motion ack");
this.waiting_for_ack -= Constants.SPICE_INPUT_MOTION_ACK_BUNCH;
return true;
}
return false;
}
function handle_mousemove(e)
{
/* Only build the message once we know it will be sent; mousemove can
fire hundreds of times a second and the discarded-motion path was
paying for two allocations and a serialize per event. */
if (this.sc && this.sc.inputs && this.sc.inputs.state === "ready")
{
if (this.sc.inputs.waiting_for_ack < (2 * Constants.SPICE_INPUT_MOTION_ACK_BUNCH))
{
var msg = new Messages.SpiceMiniData();
var move;
if (this.sc.mouse_mode == Constants.SPICE_MOUSE_MODE_CLIENT)
{
move = new Messages.SpiceMsgcMousePosition(this.sc, e)
msg.build_msg(Constants.SPICE_MSGC_INPUTS_MOUSE_POSITION, move);
}
else
{
move = new Messages.SpiceMsgcMouseMotion(this.sc, e)
msg.build_msg(Constants.SPICE_MSGC_INPUTS_MOUSE_MOTION, move);
}
this.sc.inputs.send_msg(msg);
this.sc.inputs.waiting_for_ack++;
}
else
{
DEBUG > 0 && this.sc.log_info("Discarding mouse motion");
}
}
if (this.sc && this.sc.cursor && this.sc.cursor.spice_simulated_cursor)
{
/* The image sits inside the screen element, so it is placed in
that element's own coordinates: the pointer's position within
its box, undone for any CSS scale on it. Page coordinates put
it off by the screen's offset, and by the scale, on any page
that does not draw the screen at the top-left corner. */
var sim = this.sc.cursor.spice_simulated_cursor;
var screen = sim.spice_screen;
var rect = screen.getBoundingClientRect();
var scale_x = screen.offsetWidth ? rect.width / screen.offsetWidth : 1;
var scale_y = screen.offsetHeight ? rect.height / screen.offsetHeight : 1;
sim.style.display = 'block';
sim.style.left = (e.clientX - rect.left) / scale_x - sim.spice_hot_x + 'px';
sim.style.top = (e.clientY - rect.top) / scale_y - sim.spice_hot_y + 'px';
e.preventDefault();
}
}
function handle_mousedown(e)
{
var press = new Messages.SpiceMsgcMousePress(this.sc, e)
var msg = new Messages.SpiceMiniData();
msg.build_msg(Constants.SPICE_MSGC_INPUTS_MOUSE_PRESS, press);
if (this.sc && this.sc.inputs && this.sc.inputs.state === "ready")
this.sc.inputs.send_msg(msg);
e.preventDefault();
}
function handle_contextmenu(e)
{
e.preventDefault();
return false;
}
function handle_mouseup(e)
{
var release = new Messages.SpiceMsgcMouseRelease(this.sc, e)
var msg = new Messages.SpiceMiniData();
msg.build_msg(Constants.SPICE_MSGC_INPUTS_MOUSE_RELEASE, release);
if (this.sc && this.sc.inputs && this.sc.inputs.state === "ready")
this.sc.inputs.send_msg(msg);
e.preventDefault();
}
function handle_mousewheel(e)
{
var press = new Messages.SpiceMsgcMousePress;
var release = new Messages.SpiceMsgcMouseRelease;
if (e.deltaY < 0)
press.button = release.button = Constants.SPICE_MOUSE_BUTTON_UP;
else
press.button = release.button = Constants.SPICE_MOUSE_BUTTON_DOWN;
press.buttons_state = 0;
release.buttons_state = 0;
var msg = new Messages.SpiceMiniData();
msg.build_msg(Constants.SPICE_MSGC_INPUTS_MOUSE_PRESS, press);
if (this.sc && this.sc.inputs && this.sc.inputs.state === "ready")
this.sc.inputs.send_msg(msg);
msg.build_msg(Constants.SPICE_MSGC_INPUTS_MOUSE_RELEASE, release);
if (this.sc && this.sc.inputs && this.sc.inputs.state === "ready")
this.sc.inputs.send_msg(msg);
e.preventDefault();
}
function handle_keydown(e)
{
var key = new Messages.SpiceMsgcKeyDown(e)
var msg = new Messages.SpiceMiniData();
check_and_update_modifiers(e, key.code, this.sc);
msg.build_msg(Constants.SPICE_MSGC_INPUTS_KEY_DOWN, key);
if (this.sc && this.sc.inputs && this.sc.inputs.state === "ready")
this.sc.inputs.send_msg(msg);
e.preventDefault();
}
function handle_keyup(e)
{
var key = new Messages.SpiceMsgcKeyUp(e)
var msg = new Messages.SpiceMiniData();
check_and_update_modifiers(e, key.code, this.sc);
msg.build_msg(Constants.SPICE_MSGC_INPUTS_KEY_UP, key);
if (this.sc && this.sc.inputs && this.sc.inputs.state === "ready")
this.sc.inputs.send_msg(msg);
e.preventDefault();
}
function sendCtrlAltDel(sc)
{
if (sc && sc.inputs && sc.inputs.state === "ready"){
var key = new Messages.SpiceMsgcKeyDown();
var msg = new Messages.SpiceMiniData();
update_modifier(true, KeyNames.KEY_LCtrl, sc);
update_modifier(true, KeyNames.KEY_Alt, sc);
key.code = KeyNames.KEY_KP_Decimal;
msg.build_msg(Constants.SPICE_MSGC_INPUTS_KEY_DOWN, key);
sc.inputs.send_msg(msg);
/* The server forwards scancode bytes verbatim, so the release must
carry the break bit itself, as keycode_to_end_scan does. */
key.code = 0x80 | KeyNames.KEY_KP_Decimal;
msg.build_msg(Constants.SPICE_MSGC_INPUTS_KEY_UP, key);
sc.inputs.send_msg(msg);
/* Release unless the key is positively known to be held: the state
starts as the -1 sentinel (no key seen yet), and -1 == false is
false, so testing against false left the synthetic Ctrl+Alt held
in the guest whenever this ran before the first real keydown. */
if(Ctrl_state !== true) update_modifier(false, KeyNames.KEY_LCtrl, sc);
if(Alt_state !== true) update_modifier(false, KeyNames.KEY_Alt, sc);
}
}
function update_modifier(state, code, sc)
{
var msg = new Messages.SpiceMiniData();
if (!state)
{
var key = new Messages.SpiceMsgcKeyUp()
/* Extended (two-byte) scancodes carry the break bit in their second
byte, as keycode_to_end_scan does; 0x80 would land in the 0xE0
prefix and corrupt it. */
key.code = code < 0x100 ? (0x80|code) : (0x8000|code);
msg.build_msg(Constants.SPICE_MSGC_INPUTS_KEY_UP, key);
}
else
{
var key = new Messages.SpiceMsgcKeyDown()
key.code = code;
msg.build_msg(Constants.SPICE_MSGC_INPUTS_KEY_DOWN, key);
}
sc.inputs.send_msg(msg);
}
function check_and_update_modifiers(e, code, sc)
{
if (Shift_state === -1)
{
Shift_state = e.shiftKey;
Ctrl_state = e.ctrlKey;
Alt_state = e.altKey;
Meta_state = e.metaKey;
}
if (code === KeyNames.KEY_ShiftL)
Shift_state = true;
else if (code === KeyNames.KEY_Alt)
Alt_state = true;
else if (code === KeyNames.KEY_LCtrl)
Ctrl_state = true;
else if (code === META_L_SCAN || code === META_R_SCAN)
Meta_state = true;
else if (code === (0x80|KeyNames.KEY_ShiftL))
Shift_state = false;
else if (code === (0x80|KeyNames.KEY_Alt))
Alt_state = false;
else if (code === (0x80|KeyNames.KEY_LCtrl))
Ctrl_state = false;
else if (code === (0x8000|META_L_SCAN) || code === (0x8000|META_R_SCAN))
Meta_state = false;
if (sc && sc.inputs && sc.inputs.state === "ready")
{
if (Shift_state != e.shiftKey)
{
console.log("Shift state out of sync");
update_modifier(e.shiftKey, KeyNames.KEY_ShiftL, sc);
Shift_state = e.shiftKey;
}
if (Alt_state != e.altKey)
{
console.log("Alt state out of sync");
update_modifier(e.altKey, KeyNames.KEY_Alt, sc);
Alt_state = e.altKey;
}
if (Ctrl_state != e.ctrlKey)
{
console.log("Ctrl state out of sync");
update_modifier(e.ctrlKey, KeyNames.KEY_LCtrl, sc);
Ctrl_state = e.ctrlKey;
}
if (Meta_state != e.metaKey)
{
console.log("Meta state out of sync");
update_modifier(e.metaKey, META_L_SCAN, sc);
Meta_state = e.metaKey;
}
}
}
/*----------------------------------------------------------------------------
** typeText
** Deliver a string to the guest as synthetic keystrokes on the inputs
** channel. The inputs channel speaks PC scancodes — positions on a physical
** keyboard, not characters — so this maps each character to the key that
** produces it on a US layout. A guest configured for another keymap will
** type different characters, and anything a US layout cannot produce with
** at most Shift (AltGr combinations, dead keys, non-ASCII) cannot be
** expressed at all; such characters are skipped and reported back.
**--------------------------------------------------------------------------*/
/* character → [KeyboardEvent.code, needs-shift] on a US layout. */
var US_TYPEABLE = (function ()
{
var map = {};
var i;
var letters = "abcdefghijklmnopqrstuvwxyz";
for (i = 0; i < letters.length; i++)
{
var code = "Key" + letters[i].toUpperCase();
map[letters[i]] = [code, false];
map[letters[i].toUpperCase()] = [code, true];
}
var digits = "1234567890";
var shifted_digits = "!@#$%^&*()";
for (i = 0; i < digits.length; i++)
{
map[digits[i]] = ["Digit" + digits[i], false];
map[shifted_digits[i]] = ["Digit" + digits[i], true];
}
var punctuation = [
["-", "_", "Minus"],
["=", "+", "Equal"],
["[", "{", "BracketLeft"],
["]", "}", "BracketRight"],
["\\", "|", "Backslash"],
[";", ":", "Semicolon"],
["'", "\"", "Quote"],
[",", "<", "Comma"],
[".", ">", "Period"],
["/", "?", "Slash"],
["`", "~", "Backquote"],
];
for (i = 0; i < punctuation.length; i++)
{
map[punctuation[i][0]] = [punctuation[i][2], false];
map[punctuation[i][1]] = [punctuation[i][2], true];
}
map[" "] = ["Space", false];
map["\n"] = ["Enter", false];
map["\t"] = ["Tab", false];
return map;
})();
function send_scancode(sc, scancode, down)
{
var msg = new Messages.SpiceMiniData();
var key;
if (down)
{
key = new Messages.SpiceMsgcKeyDown();
key.code = scancode;
msg.build_msg(Constants.SPICE_MSGC_INPUTS_KEY_DOWN, key);
}
else
{
key = new Messages.SpiceMsgcKeyUp();
/* Break code; every key this file types is a one-byte scancode. */
key.code = 0x80 | scancode;
msg.build_msg(Constants.SPICE_MSGC_INPUTS_KEY_UP, key);
}
sc.inputs.send_msg(msg);
}
/* A keystroke is a press and a release separated by time. Sending both in
the same tick, as this once did, makes a guest drop characters: Windows in
particular discards a key whose break code arrives in the same instant as
its make code, and the loss is silent and intermittent — a pasted URL or
password arrives subtly wrong. So hold each key down, and give Shift its
own settle time either side, before moving on. */
var KEY_HOLD_MS = 12;
var SHIFT_SETTLE_MS = 12;
/* A server-side close leaves the channel's state at "ready", since the
close handler only reports; the socket itself says whether keystrokes
can still reach the guest. */
function inputs_live(sc)
{
return sc && sc.inputs && sc.inputs.state === "ready" &&
sc.inputs.ws && sc.inputs.ws.readyState === WebSocket.OPEN;
}
/* Resolves to { typed, skipped, aborted }: how many characters went out,
which distinct characters had no US-layout key, and whether the inputs
channel died mid-string. delay_ms is the gap between characters, which
paces a large paste so a guest's keyboard buffer is not flooded. */
function typeText(sc, text, delay_ms)
{
var delay = typeof delay_ms === 'number' ? delay_ms : 25;
/* A CRLF must land as a single Enter, not Enter twice. */
var chars = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
var skipped = [];
var typed = 0;
var at = 0;
return new Promise(function (resolve)
{
function step()
{
if (! inputs_live(sc))
{
resolve({ typed: typed, skipped: skipped, aborted: at < chars.length });
return;
}
if (at >= chars.length)
{
resolve({ typed: typed, skipped: skipped, aborted: false });
return;
}
var ch = chars[at++];
var entry = US_TYPEABLE[ch];
var scancode = entry && code_to_scancode[entry[0]];
if (!scancode)
{
if (skipped.indexOf(ch) === -1)
skipped.push(ch);
step();
return;
}
/* Each phase is [what to send, how long to wait after it], run in
order; the guest sees a press, a hold, and a release. */
var shifted = entry[1];
var phases = [];
if (shifted)
phases.push([function () { send_scancode(sc, KeyNames.KEY_ShiftL, true); }, SHIFT_SETTLE_MS]);
phases.push([function () { send_scancode(sc, scancode, true); }, KEY_HOLD_MS]);
phases.push([function () { send_scancode(sc, scancode, false); }, shifted ? SHIFT_SETTLE_MS : delay]);
if (shifted)
phases.push([function () { send_scancode(sc, KeyNames.KEY_ShiftL, false); }, delay]);
typed++;
var phase = 0;
(function run()
{
if (phase >= phases.length)
{
step();
return;
}
/* The channel can die mid-character; stop rather than send a
press whose release will never follow. */
if (!(sc && sc.inputs && sc.inputs.state === "ready"))
{
resolve({ typed: typed - 1, skipped: skipped, aborted: true });
return;
}
var current = phases[phase++];
current[0]();
window.setTimeout(run, current[1]);
})();
}
step();
});
}
export {
SpiceInputsConn,
handle_mousemove,
handle_mousedown,
handle_contextmenu,
handle_mouseup,
handle_mousewheel,
handle_keydown,
handle_keyup,
sendCtrlAltDel,
typeText,
};
-181
View File
@@ -1,181 +0,0 @@
"use strict";
/*
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
import { Constants } from './enums.js';
/*----------------------------------------------------------------------------
** lz.js
** Functions for handling SPICE_IMAGE_TYPE_LZ_RGB
** Adapted from lz.c .
**--------------------------------------------------------------------------*/
function lz_rgb32_decompress(in_buf, at, out_buf, type, default_alpha)
{
var encoder = at;
var op = 0;
var ctrl;
var i = 0;
for (ctrl = in_buf[encoder++]; (op * 4) < out_buf.length; ctrl = in_buf[encoder++])
{
var ref = op;
var len = ctrl >> 5;
var ofs = (ctrl & 31) << 8;
if (ctrl >= 32) {
var code;
len--;
if (len == 7 - 1) {
do {
code = in_buf[encoder++];
len += code;
} while (code == 255);
}
code = in_buf[encoder++];
ofs += code;
if (code == 255) {
if ((ofs - code) == (31 << 8)) {
ofs = in_buf[encoder++] << 8;
ofs += in_buf[encoder++];
ofs += 8191;
}
}
len += 1;
if (type == Constants.LZ_IMAGE_TYPE_RGBA)
len += 2;
ofs += 1;
ref -= ofs;
if (ref == (op - 1)) {
var b = ref;
for (; len; --len) {
if (type == Constants.LZ_IMAGE_TYPE_RGBA)
{
out_buf[(op*4) + 3] = out_buf[(b*4)+3];
}
else
{
for (i = 0; i < 4; i++)
out_buf[(op*4) + i] = out_buf[(b*4)+i];
}
op++;
}
} else {
for (; len; --len) {
if (type == Constants.LZ_IMAGE_TYPE_RGBA)
{
out_buf[(op*4) + 3] = out_buf[(ref*4)+3];
}
else
{
for (i = 0; i < 4; i++)
out_buf[(op*4) + i] = out_buf[(ref*4)+i];
}
op++; ref++;
}
}
} else {
ctrl++;
if (type == Constants.LZ_IMAGE_TYPE_RGBA)
{
out_buf[(op*4) + 3] = in_buf[encoder++];
}
else
{
out_buf[(op*4) + 0] = in_buf[encoder + 2];
out_buf[(op*4) + 1] = in_buf[encoder + 1];
out_buf[(op*4) + 2] = in_buf[encoder + 0];
if (default_alpha)
out_buf[(op*4) + 3] = 255;
encoder += 3;
}
op++;
for (--ctrl; ctrl; ctrl--) {
if (type == Constants.LZ_IMAGE_TYPE_RGBA)
{
out_buf[(op*4) + 3] = in_buf[encoder++];
}
else
{
out_buf[(op*4) + 0] = in_buf[encoder + 2];
out_buf[(op*4) + 1] = in_buf[encoder + 1];
out_buf[(op*4) + 2] = in_buf[encoder + 0];
if (default_alpha)
out_buf[(op*4) + 3] = 255;
encoder += 3;
}
op++;
}
}
}
return encoder - 1;
}
function flip_image_data(img)
{
var wb = img.width * 4;
var h = img.height;
var temp_h = h;
var buff = new Uint8Array(img.width * img.height * 4);
while (temp_h--)
{
buff.set(img.data.subarray(temp_h * wb, (temp_h + 1) * wb), (h - temp_h - 1) * wb);
}
img.data.set(buff);
}
function convert_spice_lz_to_web(context, lz_image)
{
var at;
if (lz_image.type === Constants.LZ_IMAGE_TYPE_RGB32 || lz_image.type === Constants.LZ_IMAGE_TYPE_RGBA)
{
var u8 = new Uint8Array(lz_image.data);
var ret = context.createImageData(lz_image.width, lz_image.height);
at = lz_rgb32_decompress(u8, 0, ret.data, Constants.LZ_IMAGE_TYPE_RGB32, lz_image.type != Constants.LZ_IMAGE_TYPE_RGBA);
if (!lz_image.top_down)
flip_image_data(ret);
if (lz_image.type == Constants.LZ_IMAGE_TYPE_RGBA)
lz_rgb32_decompress(u8, at, ret.data, Constants.LZ_IMAGE_TYPE_RGBA, false);
}
else if (lz_image.type === Constants.LZ_IMAGE_TYPE_XXXA)
{
var u8 = new Uint8Array(lz_image.data);
var ret = context.createImageData(lz_image.width, lz_image.height);
lz_rgb32_decompress(u8, 0, ret.data, Constants.LZ_IMAGE_TYPE_RGBA, false);
}
else
return undefined;
return ret;
}
export {
convert_spice_lz_to_web,
};
-9
View File
@@ -1,9 +0,0 @@
// Types for what Ham Lab uses of the vendored spice-html5 (plain JavaScript beside this file).
export class SpiceMainConn {
constructor(opts: { uri: string; screen_id: string; password?: string; message_id?: string; dump_id?: string; onerror?: (e: Event | Error) => void; onsuccess?: () => void; onagent?: (sc: SpiceMainConn) => void })
stop(msg?: string): void
agent_connected?: boolean
}
export function resize_helper(sc: SpiceMainConn): void
export function handle_resize(e?: Event): void
export function sendCtrlAltDel(sc: SpiceMainConn): void
-669
View File
@@ -1,669 +0,0 @@
"use strict";
/*
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
import * as Messages from './spicemsg.js';
import { Constants } from './enums.js';
import { SpiceCursorConn } from './cursor.js';
import { SpiceConn } from './spiceconn.js';
import { DEBUG } from './utils.js';
import { SpiceFileXferTask } from './filexfer.js';
import { SpiceInputsConn, sendCtrlAltDel, typeText } from './inputs.js';
import { SpiceDisplayConn } from './display.js';
import { SpicePlaybackConn } from './playback.js';
import { SpicePortConn } from './port.js';
import { handle_file_dragover, handle_file_drop } from './filexfer.js';
import { resize_helper, handle_resize } from './resize.js';
/*----------------------------------------------------------------------------
** SpiceMainConn
** This is the master Javascript class for establishing and
** managing a connection to a Spice Server.
**
** Invocation: You must pass an object with properties as follows:
** uri (required) Uri of a WebSocket listener that is
** connected to a spice server.
** password (required) Password to send to the spice server
** message_id (optional) Identifier of an element in the DOM
** where SpiceConn will write messages.
** It will use classes spice-messages-x,
** where x is one of info, warning, or error.
** screen_id (optional) Identifier of an element in the DOM
** where SpiceConn will create any new
** client screens. This is the main UI.
** dump_id (optional) If given, an element to use for
** dumping every single image + canvas drawn.
** Sometimes useful for debugging.
** onerror (optional) If given, a function to receive async
** errors. Note that you should also catch
** errors for ones that occur inline
** onagent (optional) If given, a function to be called when
** a VD agent is connected; a good opportunity
** to request a resize
** onsuccess (optional) If given, a function to be called when the
** session is successfully connected
**
** Throws error if there are troubles. Requires a modern (by 2012 standards)
** browser, including WebSocket and WebSocket.binaryType == arraybuffer
**
**--------------------------------------------------------------------------*/
function SpiceMainConn()
{
if (typeof WebSocket === "undefined")
throw new Error("WebSocket unavailable. You need to use a different browser.");
SpiceConn.apply(this, arguments);
this.agent_msg_queue = [];
this.file_xfer_tasks = {};
this.file_xfer_task_id = 0;
this.file_xfer_read_queue = [];
this.ports = [];
this.agent_caps = [0]
}
SpiceMainConn.prototype = Object.create(SpiceConn.prototype);
SpiceMainConn.prototype.process_channel_message = function(msg)
{
if (msg.type == Constants.SPICE_MSG_MAIN_MIGRATE_BEGIN)
{
this.known_unimplemented(msg.type, "Main Migrate Begin");
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_MIGRATE_CANCEL)
{
this.known_unimplemented(msg.type, "Main Migrate Cancel");
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_INIT)
{
this.log_info("Connected to " + this.ws.url);
this.report_success("Connected")
this.main_init = new Messages.SpiceMsgMainInit(msg.data);
this.connection_id = this.main_init.session_id;
this.agent_tokens = this.main_init.agent_tokens;
if (DEBUG > 0)
{
// FIXME - there is a lot here we don't handle; mouse modes, agent,
// ram_hint, multi_media_time
this.log_info("session id " + this.main_init.session_id +
" ; display_channels_hint " + this.main_init.display_channels_hint +
" ; supported_mouse_modes " + this.main_init.supported_mouse_modes +
" ; current_mouse_mode " + this.main_init.current_mouse_mode +
" ; agent_connected " + this.main_init.agent_connected +
" ; agent_tokens " + this.main_init.agent_tokens +
" ; multi_media_time " + this.main_init.multi_media_time +
" ; ram_hint " + this.main_init.ram_hint);
}
this.our_mm_time = Date.now();
this.mm_time = this.main_init.multi_media_time;
this.handle_mouse_mode(this.main_init.current_mouse_mode,
this.main_init.supported_mouse_modes);
if (this.main_init.agent_connected)
this.connect_agent();
var attach = new Messages.SpiceMiniData;
attach.type = Constants.SPICE_MSGC_MAIN_ATTACH_CHANNELS;
attach.size = attach.buffer_size();
this.send_msg(attach);
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_MOUSE_MODE)
{
var mode = new Messages.SpiceMsgMainMouseMode(msg.data);
DEBUG > 0 && this.log_info("Mouse supported modes " + mode.supported_modes + "; current " + mode.current_mode);
this.handle_mouse_mode(mode.current_mode, mode.supported_modes);
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_MULTI_MEDIA_TIME)
{
/* The server's multimedia clock. relative_now() estimates it from
the value seeded at INIT plus elapsed wall time, which drifts as
the two clocks diverge, and stream frames are scheduled and
reported against that estimate. This message is the server
resynchronising it. */
var mm = new Messages.SpiceMsgMainMultiMediaTime(msg.data);
this.our_mm_time = Date.now();
this.mm_time = mm.time;
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_CHANNELS_LIST)
{
var i;
var chans;
DEBUG > 0 && console.log("channels");
chans = new Messages.SpiceMsgChannels(msg.data);
for (i = 0; i < chans.channels.length; i++)
{
var conn = {
uri: this.ws.url,
parent: this,
connection_id : this.connection_id,
type : chans.channels[i].type,
chan_id : chans.channels[i].id
};
if (chans.channels[i].type == Constants.SPICE_CHANNEL_DISPLAY)
{
if (chans.channels[i].id == 0) {
this.display = new SpiceDisplayConn(conn);
} else {
this.log_warn("The spice-html5 client does not handle multiple heads.");
}
}
else if (chans.channels[i].type == Constants.SPICE_CHANNEL_INPUTS)
{
this.inputs = new SpiceInputsConn(conn);
this.inputs.mouse_mode = this.mouse_mode;
}
else if (chans.channels[i].type == Constants.SPICE_CHANNEL_CURSOR)
this.cursor = new SpiceCursorConn(conn);
else if (chans.channels[i].type == Constants.SPICE_CHANNEL_PLAYBACK)
this.playback = new SpicePlaybackConn(conn);
else if (chans.channels[i].type == Constants.SPICE_CHANNEL_PORT)
this.ports.push(new SpicePortConn(conn));
else
{
if (! ("extra_channels" in this))
this.extra_channels = [];
this.extra_channels[i] = new SpiceConn(conn);
this.log_err("Channel type " + this.extra_channels[i].channel_type() + " not implemented");
}
}
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_AGENT_CONNECTED)
{
this.connect_agent();
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_AGENT_CONNECTED_TOKENS)
{
var connected_tokens = new Messages.SpiceMsgMainAgentTokens(msg.data);
this.agent_tokens = connected_tokens.num_tokens;
this.connect_agent();
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_AGENT_TOKEN)
{
var remaining_tokens, tokens = new Messages.SpiceMsgMainAgentTokens(msg.data);
this.agent_tokens += tokens.num_tokens;
this.send_agent_message_queue();
remaining_tokens = this.agent_tokens;
while (remaining_tokens > 0 && this.file_xfer_read_queue.length > 0)
{
var xfer_task = this.file_xfer_read_queue.shift();
this.file_xfer_read(xfer_task, xfer_task.read_bytes);
remaining_tokens--;
}
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_AGENT_DISCONNECTED)
{
this.agent_connected = false;
/* Fail transfers in flight rather than strand them: with the
agent gone their chunks are silently dropped, so they would
never complete, never error, and their progress bars would sit
on screen forever. */
this.agent_msg_queue = [];
this.file_xfer_read_queue = [];
for (var task_id in this.file_xfer_tasks)
this.file_xfer_completed(this.file_xfer_tasks[task_id],
new Error("Agent disconnected; file transfer aborted."));
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_AGENT_DATA)
{
var agent_data = new Messages.SpiceMsgMainAgentData(msg.data);
if (agent_data.type == Constants.VD_AGENT_ANNOUNCE_CAPABILITIES)
{
var agent_caps = new Messages.VDAgentAnnounceCapabilities(agent_data.data);
this.agent_caps = [agent_caps.caps];
if (agent_caps.request)
this.announce_agent_capabilities(0);
return true;
}
else if (agent_data.type == Constants.VD_AGENT_FILE_XFER_STATUS)
{
this.handle_file_xfer_status(new Messages.VDAgentFileXferStatusMessage(agent_data.data));
return true;
}
else if (agent_data.type == Constants.VD_AGENT_CLIPBOARD_GRAB)
{
this.handle_clipboard_grab();
return true;
}
else if (agent_data.type == Constants.VD_AGENT_CLIPBOARD)
{
this.handle_clipboard_receive(agent_data);
return true;
}
else if (agent_data.type == Constants.VD_AGENT_CLIPBOARD_REQUEST)
{
this.handle_clipboard_send();
return true;
}
else if (agent_data.type == Constants.VD_AGENT_CLIPBOARD_RELEASE)
{
// Currently we don't need to do anything when the agent releases the clipboard
return true;
}
/* An agent message we do not act on is still an agent message.
Returning false sent it back as an unhandled main-channel
message, so the console reported "Unknown message type 109"
-- naming the agent-data envelope rather than the thing
inside it, which is the part worth knowing. Name that once
per type instead. */
this.report_unhandled_agent_message(agent_data.type);
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_MIGRATE_SWITCH_HOST)
{
this.known_unimplemented(msg.type, "Main Migrate Switch Host");
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_MIGRATE_END)
{
this.known_unimplemented(msg.type, "Main Migrate End");
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_NAME)
{
this.known_unimplemented(msg.type, "Main Name");
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_UUID)
{
this.known_unimplemented(msg.type, "Main UUID");
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_MIGRATE_BEGIN_SEAMLESS)
{
this.known_unimplemented(msg.type, "Main Migrate Begin Seamless");
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_MIGRATE_DST_SEAMLESS_ACK)
{
this.known_unimplemented(msg.type, "Main Migrate Dst Seamless ACK");
return true;
}
if (msg.type == Constants.SPICE_MSG_MAIN_MIGRATE_DST_SEAMLESS_NACK)
{
this.known_unimplemented(msg.type, "Main Migrate Dst Seamless NACK");
return true;
}
return false;
}
SpiceMainConn.prototype.stop = function(msg)
{
this.state = "closing";
if (this.inputs)
{
this.inputs.cleanup();
this.inputs = undefined;
}
if (this.cursor)
{
this.cursor.cleanup();
this.cursor = undefined;
}
if (this.playback)
{
this.playback.cleanup();
this.playback = undefined;
}
for (var p = 0; p < this.ports.length; p++)
this.ports[p].cleanup();
this.ports = [];
if (this.display)
{
this.display.cleanup();
this.display.destroy_surfaces();
this.display = undefined;
}
this.cleanup();
if ("extra_channels" in this)
for (var e in this.extra_channels)
this.extra_channels[e].cleanup();
this.extra_channels = undefined;
}
SpiceMainConn.prototype.send_agent_message_queue = function(message)
{
if (!this.agent_connected)
return;
if (message)
this.agent_msg_queue.push(message);
while (this.agent_tokens > 0 && this.agent_msg_queue.length > 0)
{
var mr = this.agent_msg_queue.shift();
this.send_msg(mr);
this.agent_tokens--;
}
}
SpiceMainConn.prototype.send_agent_message = function(type, message)
{
var agent_data = new Messages.SpiceMsgcMainAgentData(type, message);
var sb = 0, maxsize = Constants.VD_AGENT_MAX_DATA_SIZE - Messages.SpiceMiniData.prototype.buffer_size();
var data = new ArrayBuffer(agent_data.buffer_size());
agent_data.to_buffer(data);
while (sb < agent_data.buffer_size())
{
var eb = Math.min(sb + maxsize, agent_data.buffer_size());
var mr = new Messages.SpiceMiniData();
mr.type = Constants.SPICE_MSGC_MAIN_AGENT_DATA;
mr.size = eb - sb;
mr.data = data.slice(sb, eb);
this.send_agent_message_queue(mr);
sb = eb;
}
}
SpiceMainConn.prototype.announce_agent_capabilities = function(request)
{
var caps = new Messages.VDAgentAnnounceCapabilities(request, (1 << Constants.VD_AGENT_CAP_MOUSE_STATE) |
(1 << Constants.VD_AGENT_CAP_MONITORS_CONFIG) |
(1 << Constants.VD_AGENT_CAP_REPLY) |
(1 << Constants.VD_AGENT_CAP_CLIPBOARD_SELECTION) |
(1 << Constants.VD_AGENT_CAP_CLIPBOARD_BY_DEMAND));
this.send_agent_message(Constants.VD_AGENT_ANNOUNCE_CAPABILITIES, caps);
}
SpiceMainConn.prototype.resize_window = function(flags, width, height, depth, x, y)
{
var monitors_config = new Messages.VDAgentMonitorsConfig(flags, width, height, depth, x, y);
this.send_agent_message(Constants.VD_AGENT_MONITORS_CONFIG, monitors_config);
}
SpiceMainConn.prototype.file_xfer_start = function(file)
{
var task_id, xfer_start, task;
task_id = this.file_xfer_task_id++;
task = new SpiceFileXferTask(task_id, file);
task.create_progressbar();
this.file_xfer_tasks[task_id] = task;
xfer_start = new Messages.VDAgentFileXferStartMessage(task_id, file.name, file.size);
this.send_agent_message(Constants.VD_AGENT_FILE_XFER_START, xfer_start);
}
SpiceMainConn.prototype.handle_file_xfer_status = function(file_xfer_status)
{
var xfer_error, xfer_task;
if (!this.file_xfer_tasks[file_xfer_status.id])
{
return;
}
xfer_task = this.file_xfer_tasks[file_xfer_status.id];
switch (file_xfer_status.result)
{
case Constants.VD_AGENT_FILE_XFER_STATUS_CAN_SEND_DATA:
this.file_xfer_read(xfer_task);
return;
case Constants.VD_AGENT_FILE_XFER_STATUS_CANCELLED:
xfer_error = "transfer is cancelled by spice agent";
break;
case Constants.VD_AGENT_FILE_XFER_STATUS_ERROR:
xfer_error = "some errors occurred in the spice agent";
break;
case Constants.VD_AGENT_FILE_XFER_STATUS_SUCCESS:
break;
default:
xfer_error = "unhandled status type: " + file_xfer_status.result;
break;
}
this.file_xfer_completed(xfer_task, xfer_error)
}
SpiceMainConn.prototype.file_xfer_read = function(file_xfer_task, start_byte)
{
var FILE_XFER_CHUNK_SIZE = 32 * Constants.VD_AGENT_MAX_DATA_SIZE;
var _this = this;
var sb, eb;
var slice, reader;
if (!file_xfer_task ||
!this.file_xfer_tasks[file_xfer_task.id] ||
(start_byte > 0 && start_byte == file_xfer_task.file.size))
{
return;
}
if (file_xfer_task.cancelled)
{
var xfer_status = new Messages.VDAgentFileXferStatusMessage(file_xfer_task.id,
Constants.VD_AGENT_FILE_XFER_STATUS_CANCELLED);
this.send_agent_message(Constants.VD_AGENT_FILE_XFER_STATUS, xfer_status);
delete this.file_xfer_tasks[file_xfer_task.id];
return;
}
sb = start_byte || 0,
eb = Math.min(sb + FILE_XFER_CHUNK_SIZE, file_xfer_task.file.size);
if (!this.agent_tokens)
{
file_xfer_task.read_bytes = sb;
this.file_xfer_read_queue.push(file_xfer_task);
return;
}
reader = new FileReader();
reader.onload = function(e)
{
var xfer_data = new Messages.VDAgentFileXferDataMessage(file_xfer_task.id,
e.target.result.byteLength,
e.target.result);
_this.send_agent_message(Constants.VD_AGENT_FILE_XFER_DATA, xfer_data);
/* Only recurse while bytes remain. The old code always recursed
and relied on the start_byte > 0 guard above to terminate, which
a zero-byte file never satisfies — it looped forever, burning an
agent token per iteration. */
if (eb < file_xfer_task.file.size)
_this.file_xfer_read(file_xfer_task, eb);
file_xfer_task.update_progressbar(eb);
};
slice = file_xfer_task.file.slice(sb, eb);
reader.readAsArrayBuffer(slice);
}
SpiceMainConn.prototype.file_xfer_completed = function(file_xfer_task, error)
{
if (error)
this.log_err(error);
else
this.log_info("transfer of '" + file_xfer_task.file.name +"' was successful");
file_xfer_task.remove_progressbar();
delete this.file_xfer_tasks[file_xfer_task.id];
}
/* The guest agent sends more than this client acts on -- a reply
acknowledging our capabilities, mouse state under server mouse mode,
monitor and display configuration. None need handling, but each was
arriving as an anonymous "unknown message" on the main channel. */
SpiceMainConn.prototype.report_unhandled_agent_message = function(type)
{
var names = {};
names[Constants.VD_AGENT_MOUSE_STATE] = "Mouse State";
names[Constants.VD_AGENT_MONITORS_CONFIG] = "Monitors Config";
names[Constants.VD_AGENT_REPLY] = "Reply";
names[Constants.VD_AGENT_DISPLAY_CONFIG] = "Display Config";
names[Constants.VD_AGENT_FILE_XFER_START] = "File Xfer Start";
names[Constants.VD_AGENT_FILE_XFER_DATA] = "File Xfer Data";
names[Constants.VD_AGENT_CLIENT_DISCONNECTED] = "Client Disconnected";
names[Constants.VD_AGENT_MAX_CLIPBOARD] = "Max Clipboard";
if (! this.reported_agent_types)
this.reported_agent_types = {};
if (this.reported_agent_types[type])
return;
this.reported_agent_types[type] = true;
this.log_warn("Agent message not handled: " + (names[type] || "type " + type) +
" [ further notices suppressed ]");
}
SpiceMainConn.prototype.handle_clipboard_grab = function()
{
DEBUG > 1 && console.log("handling clipboard grab from agent");
// agent is requesting clipboard grab, so we send a message to request clipboard contents
const type = Constants.VD_AGENT_CLIPBOARD_UTF8_TEXT;
const clipboard_request = new Messages.SpiceMsgClipboardRequest(type, this.agent_caps);
this.send_agent_message(Constants.VD_AGENT_CLIPBOARD_REQUEST, clipboard_request);
}
SpiceMainConn.prototype.handle_clipboard_receive = function(agent_data)
{
DEBUG > 1 && console.log("received clipboard data from agent");
// received clipboard data from agent, copy it to browser clipboard
const received_clipboard = new Messages.SpiceMsgClipboardReceive(agent_data, this.agent_caps);
if (received_clipboard.type === Constants.VD_AGENT_CLIPBOARD_UTF8_TEXT)
{
// write it to the browser clipboard
if (navigator.clipboard && navigator.clipboard.writeText)
{
const text = received_clipboard.get_text();
navigator.clipboard.writeText(text).catch(err => {
console.warn("Failed to write to navigator clipboard:", err);
});
}
}
else
{
console.log("Unsupported clipboard type:", received_clipboard.type);
}
}
SpiceMainConn.prototype.handle_clipboard_send = function()
{
DEBUG > 1 && console.log("sending clipboard data to agent");
// agent is requesting clipboard data, read it from browser clipboard and send it.
if (navigator.clipboard && navigator.clipboard.readText)
{
navigator.clipboard.readText().then(text => {
const type = Constants.VD_AGENT_CLIPBOARD_UTF8_TEXT;
const clipboard_msg = new Messages.SpiceMsgClipboardSend(type, text, this.agent_caps);
this.send_agent_message(Constants.VD_AGENT_CLIPBOARD, clipboard_msg);
}).catch(err => {
console.log("Failed to read clipboard:", err);
});
}
}
SpiceMainConn.prototype.send_clipboard_grab = function()
{
DEBUG > 1 && console.log("browser is asking agent to grab clipboard");
// Send clipboard grab to agent. Agent will then ask for the host os clipboard contents in a dedicated message.
const type = Constants.VD_AGENT_CLIPBOARD_UTF8_TEXT;
const grab_message = new Messages.SpiceMsgClipboardGrab(type, this.agent_caps);
this.send_agent_message(Constants.VD_AGENT_CLIPBOARD_GRAB, grab_message)
}
SpiceMainConn.prototype.connect_agent = function()
{
this.agent_connected = true;
var agent_start = new Messages.SpiceMsgcMainAgentStart(~0);
var mr = new Messages.SpiceMiniData();
mr.build_msg(Constants.SPICE_MSGC_MAIN_AGENT_START, agent_start);
this.send_msg(mr);
this.announce_agent_capabilities(1);
if (this.onagent !== undefined)
this.onagent(this);
}
SpiceMainConn.prototype.handle_mouse_mode = function(current, supported)
{
this.mouse_mode = current;
if (current != Constants.SPICE_MOUSE_MODE_CLIENT && (supported & Constants.SPICE_MOUSE_MODE_CLIENT))
{
var mode_request = new Messages.SpiceMsgcMainMouseModeRequest(Constants.SPICE_MOUSE_MODE_CLIENT);
var mr = new Messages.SpiceMiniData();
mr.build_msg(Constants.SPICE_MSGC_MAIN_MOUSE_MODE_REQUEST, mode_request);
this.send_msg(mr);
}
if (this.inputs)
this.inputs.mouse_mode = current;
}
/* Shift current time to attempt to get a time matching that of the server */
SpiceMainConn.prototype.relative_now = function()
{
var ret = (Date.now() - this.our_mm_time) + this.mm_time;
return ret;
}
export {
SpiceMainConn,
handle_file_dragover,
handle_file_drop,
resize_helper,
handle_resize,
sendCtrlAltDel,
typeText,
};
-432
View File
@@ -1,432 +0,0 @@
"use strict";
/*
Copyright (C) 2014 by Jeremy P. White <jwhite@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
/*----------------------------------------------------------------------------
** SpicePlaybackConn
** Drive the Spice Playback channel (sound out)
**--------------------------------------------------------------------------*/
import * as Utils from './utils.js';
import * as Webm from './webm.js';
import * as Messages from './spicemsg.js';
import { Constants } from './enums.js';
import { SpiceConn } from './spiceconn.js';
function SpicePlaybackConn()
{
SpiceConn.apply(this, arguments);
this.queue = new Array();
this.append_okay = false;
this.start_time = 0;
}
SpicePlaybackConn.prototype = Object.create(SpiceConn.prototype);
SpicePlaybackConn.prototype.process_channel_message = function(msg)
{
if (!window.MediaSource)
{
this.log_err('MediaSource API is not available');
return false;
}
if (msg.type == Constants.SPICE_MSG_PLAYBACK_START)
{
var start = new Messages.SpiceMsgPlaybackStart(msg.data);
Utils.PLAYBACK_DEBUG > 0 && console.log("PlaybackStart; frequency " + start.frequency);
if (start.frequency != Webm.Constants.OPUS_FREQUENCY)
{
this.log_err('This player cannot handle frequency ' + start.frequency);
return false;
}
if (start.channels != Webm.Constants.OPUS_CHANNELS)
{
this.log_err('This player cannot handle ' + start.channels + ' channels');
return false;
}
if (start.format != Constants.SPICE_AUDIO_FMT_S16)
{
this.log_err('This player cannot format ' + start.format);
return false;
}
/* Keyed on the audio element, not source_buffer: source_buffer is
only set asynchronously in handle_source_open, so a second START
arriving before sourceopen would stack a second audio element. */
if (! this.audio)
{
this.media_source = new MediaSource();
this.media_source.spiceconn = this;
this.audio = document.createElement("audio");
this.audio.spiceconn = this;
this.audio.setAttribute('autoplay', true);
this.audio.src = window.URL.createObjectURL(this.media_source);
document.getElementById(this.parent.screen_id).appendChild(this.audio);
this.media_source.addEventListener('sourceopen', handle_source_open, false);
this.media_source.addEventListener('sourceended', handle_source_ended, false);
this.media_source.addEventListener('sourceclosed', handle_source_closed, false);
this.bytes_written = 0;
return true;
}
}
if (msg.type == Constants.SPICE_MSG_PLAYBACK_DATA)
{
var data = new Messages.SpiceMsgPlaybackData(msg.data);
if (! this.source_buffer)
return true;
if (this.audio.readyState >= 3 && this.audio.buffered.length > 1 &&
this.audio.currentTime == this.audio.buffered.end(0) &&
this.audio.currentTime < this.audio.buffered.start(this.audio.buffered.length - 1))
{
console.log("Audio underrun: we appear to have fallen behind; advancing to " +
this.audio.buffered.start(this.audio.buffered.length - 1));
this.audio.currentTime = this.audio.buffered.start(this.audio.buffered.length - 1);
}
/* Around version 45, Firefox started being very particular about the
time stamps put into the Opus stream. The time stamps from the Spice server are
somewhat irregular. They mostly arrive every 10 ms, but sometimes it is 11, or sometimes
with two time stamps the same in a row. The previous logic resulted in fuzzy and/or
distorted audio streams in Firefox in a row.
In theory, the sequence mode should be appropriate for us, but as of 09/27/2016,
I was unable to make sequence mode work with Firefox.
Thus, we end up with an inelegant hack. Essentially, we force every packet to have
a 10ms time delta, unless there is an obvious gap in time stream, in which case we
will resync.
*/
if (this.start_time != 0 && data.time != (this.last_data_time + Webm.Constants.EXPECTED_PACKET_DURATION))
{
if (Math.abs(data.time - (Webm.Constants.EXPECTED_PACKET_DURATION + this.last_data_time)) < Webm.Constants.MAX_CLUSTER_TIME)
{
Utils.PLAYBACK_DEBUG > 1 && console.log("Hacking time of " + data.time + " to " +
(this.last_data_time + Webm.Constants.EXPECTED_PACKET_DURATION));
data.time = this.last_data_time + Webm.Constants.EXPECTED_PACKET_DURATION;
}
else
{
Utils.PLAYBACK_DEBUG > 1 && console.log("Apparent gap in audio time; now is " + data.time + " last was " + this.last_data_time);
}
}
this.last_data_time = data.time;
Utils.PLAYBACK_DEBUG > 1 && console.log("PlaybackData; time " + data.time + "; length " + data.data.byteLength);
if (this.start_time == 0)
this.start_playback(data);
else if (data.time - this.cluster_time >= Webm.Constants.MAX_CLUSTER_TIME)
this.new_cluster(data);
else
this.simple_block(data, false);
return true;
}
if (msg.type == Constants.SPICE_MSG_PLAYBACK_MODE)
{
var mode = new Messages.SpiceMsgPlaybackMode(msg.data);
if (mode.mode != Constants.SPICE_AUDIO_DATA_MODE_OPUS)
{
this.log_err('This player cannot handle mode ' + mode.mode);
/* Deleting just source_buffer left the audio element and its
object URL orphaned with no guard left to reclaim them. */
this.destroy_audio();
}
return true;
}
if (msg.type == Constants.SPICE_MSG_PLAYBACK_STOP)
{
Utils.PLAYBACK_DEBUG > 0 && console.log("PlaybackStop");
if (this.audio)
{
this.destroy_audio();
return true;
}
}
if (msg.type == Constants.SPICE_MSG_PLAYBACK_VOLUME)
{
this.known_unimplemented(msg.type, "Playback Volume");
return true;
}
if (msg.type == Constants.SPICE_MSG_PLAYBACK_MUTE)
{
this.known_unimplemented(msg.type, "Playback Mute");
return true;
}
if (msg.type == Constants.SPICE_MSG_PLAYBACK_LATENCY)
{
this.known_unimplemented(msg.type, "Playback Latency");
return true;
}
return false;
}
SpicePlaybackConn.prototype.destroy_audio = function()
{
if (! this.audio)
return;
if (this.audio.parentNode)
this.audio.parentNode.removeChild(this.audio);
window.URL.revokeObjectURL(this.audio.src);
delete this.source_buffer;
delete this.media_source;
delete this.audio;
this.append_okay = false;
this.queue = new Array();
this.start_time = 0;
}
/* A client-side stop must reclaim the audio element, its object URL and
the MediaSource; without this they were only freed by a server STOP,
and an application that stops and recreates connections leaked one
set per cycle. */
SpicePlaybackConn.prototype.cleanup = function()
{
this.destroy_audio();
SpiceConn.prototype.cleanup.call(this);
}
SpicePlaybackConn.prototype.start_playback = function(data)
{
this.start_time = data.time;
var h = new Webm.Header();
var te = new Webm.AudioTrackEntry;
var t = new Webm.Tracks(te);
var mb = new ArrayBuffer(h.buffer_size() + t.buffer_size())
this.bytes_written = h.to_buffer(mb);
this.bytes_written = t.to_buffer(mb, this.bytes_written);
this.source_buffer.addEventListener('error', handle_sourcebuffer_error, false);
this.source_buffer.addEventListener('updateend', handle_append_buffer_done, false);
playback_append_buffer(this, mb);
this.new_cluster(data);
}
SpicePlaybackConn.prototype.new_cluster = function(data)
{
this.cluster_time = data.time;
var c = new Webm.Cluster(data.time - this.start_time);
var mb = new ArrayBuffer(c.buffer_size());
this.bytes_written += c.to_buffer(mb);
if (this.append_okay)
playback_append_buffer(this, mb);
else
this.queue.push(mb);
this.simple_block(data, true);
}
SpicePlaybackConn.prototype.simple_block = function(data, keyframe)
{
var sb = new Webm.SimpleBlock(data.time - this.cluster_time, data.data, keyframe);
var mb = new ArrayBuffer(sb.buffer_size());
this.bytes_written += sb.to_buffer(mb);
if (this.append_okay)
playback_append_buffer(this, mb);
else
this.queue.push(mb);
}
function handle_source_open(e)
{
var p = this.spiceconn;
if (p.source_buffer)
return;
p.source_buffer = this.addSourceBuffer(Webm.Constants.SPICE_PLAYBACK_CODEC);
if (! p.source_buffer)
{
p.log_err('Codec ' + Webm.Constants.SPICE_PLAYBACK_CODEC + ' not available.');
return;
}
if (Utils.PLAYBACK_DEBUG > 0)
playback_handle_event_debug.call(this, e);
listen_for_audio_events(p);
p.source_buffer.spiceconn = p;
p.source_buffer.mode = "segments";
// FIXME - Experimentation with segments and sequences was unsatisfying.
// Switching to sequence did not solve our gap problem,
// but the browsers didn't fully support the time seek capability
// we would expect to gain from 'segments'.
// Segments worked at the time of this patch, so segments it is for now.
}
function handle_source_ended(e)
{
var p = this.spiceconn;
p.log_err('Audio source unexpectedly ended.');
}
function handle_source_closed(e)
{
var p = this.spiceconn;
p.log_err('Audio source unexpectedly closed.');
}
function condense_playback_queue(queue)
{
if (queue.length == 1)
return queue.shift();
var len = 0;
var i = 0;
for (i = 0; i < queue.length; i++)
len += queue[i].byteLength;
var mb = new ArrayBuffer(len);
var tmp = new Uint8Array(mb);
len = 0;
for (i = 0; i < queue.length; i++)
{
tmp.set(new Uint8Array(queue[i]), len);
len += queue[i].byteLength;
}
queue.length = 0;
return mb;
}
function handle_append_buffer_done(e)
{
var p = this.spiceconn;
if (Utils.PLAYBACK_DEBUG > 1)
playback_handle_event_debug.call(this, e);
if (p.queue.length > 0)
{
var mb = condense_playback_queue(p.queue);
playback_append_buffer(p, mb);
}
else
p.append_okay = true;
}
function handle_sourcebuffer_error(e)
{
var p = this.spiceconn;
p.log_err('source_buffer error ' + e.message);
}
function playback_append_buffer(p, b)
{
try
{
p.source_buffer.appendBuffer(b);
p.append_okay = false;
}
catch (e)
{
p.log_err("Error invoking appendBuffer: " + e.message);
}
}
function playback_handle_event_debug(e)
{
var p = this.spiceconn;
if (p.audio)
{
if (Utils.PLAYBACK_DEBUG > 0 || p.audio.buffered.len > 1)
console.log(p.audio.currentTime + ": event " + e.type +
Utils.dump_media_element(p.audio));
}
if (Utils.PLAYBACK_DEBUG > 1 && p.media_source)
console.log(" media_source " + Utils.dump_media_source(p.media_source));
if (Utils.PLAYBACK_DEBUG > 1 && p.source_buffer)
console.log(" source_buffer " + Utils.dump_source_buffer(p.source_buffer));
if (Utils.PLAYBACK_DEBUG > 0 || p.queue.length > 1)
console.log(' queue len ' + p.queue.length + '; append_okay: ' + p.append_okay);
}
function playback_debug_listen_for_one_event(name)
{
this.addEventListener(name, playback_handle_event_debug);
}
function listen_for_audio_events(spiceconn)
{
var audio_0_events = [
"abort", "error"
];
var audio_1_events = [
"loadstart", "suspend", "emptied", "stalled", "loadedmetadata", "loadeddata", "canplay",
"canplaythrough", "playing", "waiting", "seeking", "seeked", "ended", "durationchange",
"timeupdate", "play", "pause", "ratechange"
];
var audio_2_events = [
"progress",
"resize",
"volumechange"
];
audio_0_events.forEach(playback_debug_listen_for_one_event, spiceconn.audio);
if (Utils.PLAYBACK_DEBUG > 0)
audio_1_events.forEach(playback_debug_listen_for_one_event, spiceconn.audio);
if (Utils.PLAYBACK_DEBUG > 1)
audio_2_events.forEach(playback_debug_listen_for_one_event, spiceconn.audio);
}
export {
SpicePlaybackConn,
};
-271
View File
@@ -1,271 +0,0 @@
"use strict";
/*
Copyright (C) 2012 by Jeremy P. White <jwhite@codeweavers.com>
This file is part of spice-html5.
spice-html5 is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
spice-html5 is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with spice-html5. If not, see <http://www.gnu.org/licenses/>.
*/
/*----------------------------------------------------------------------------
** crc logic from rfc2083 ported to Javascript
**--------------------------------------------------------------------------*/
var rfc2083_crc_table = Array(256);
var rfc2083_crc_table_computed = 0;
/* Make the table for a fast CRC. */
function rfc2083_make_crc_table()
{
var c;
var n, k;
for (n = 0; n < 256; n++)
{
c = n;
for (k = 0; k < 8; k++)
{
if (c & 1)
c = ((0xedb88320 ^ (c >>> 1)) >>> 0) & 0xffffffff;
else
c = c >>> 1;
}
rfc2083_crc_table[n] = c;
}
rfc2083_crc_table_computed = 1;
}
/* Update a running CRC with the bytes buf[0..len-1]--the CRC
should be initialized to all 1's, and the transmitted value
is the 1's complement of the final running CRC (see the
crc() routine below)). */
function rfc2083_update_crc(crc, u8buf, at, len)
{
var c = crc;
var n;
if (!rfc2083_crc_table_computed)
rfc2083_make_crc_table();
for (n = 0; n < len; n++)
{
c = rfc2083_crc_table[(c ^ u8buf[at + n]) & 0xff] ^ (c >>> 8);
}
return c;
}
function rfc2083_crc(u8buf, at, len)
{
return rfc2083_update_crc(0xffffffff, u8buf, at, len) ^ 0xffffffff;
}
function crc32(mb, at, len)
{
var u8 = new Uint8Array(mb);
return rfc2083_crc(u8, at, len);
}
function PngIHDR(width, height)
{
this.width = width;
this.height = height;
this.depth = 8;
this.type = 6;
this.compression = 0;
this.filter = 0;
this.interlace = 0;
}
PngIHDR.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var orig = at;
var dv = new DataView(a);
dv.setUint32(at, this.buffer_size() - 12); at += 4;
dv.setUint8(at, 'I'.charCodeAt(0)); at++;
dv.setUint8(at, 'H'.charCodeAt(0)); at++;
dv.setUint8(at, 'D'.charCodeAt(0)); at++;
dv.setUint8(at, 'R'.charCodeAt(0)); at++;
dv.setUint32(at, this.width); at += 4;
dv.setUint32(at, this.height); at += 4;
dv.setUint8(at, this.depth); at++;
dv.setUint8(at, this.type); at++;
dv.setUint8(at, this.compression); at++;
dv.setUint8(at, this.filter); at++;
dv.setUint8(at, this.interlace); at++;
dv.setUint32(at, crc32(a, orig + 4, this.buffer_size() - 8)); at += 4;
return at;
},
buffer_size: function()
{
return 12 + 13;
}
}
function adler()
{
this.s1 = 1;
this.s2 = 0;
}
adler.prototype.update = function(b)
{
this.s1 += b;
this.s1 %= 65521;
this.s2 += this.s1;
this.s2 %= 65521;
}
/* The image as one IDAT chunk: a zlib stream of stored (uncompressed)
deflate blocks. A stored block holds at most 65535 bytes, so the
scanlines are cut into as many as it takes; a cursor larger than about
128 pixels square used to be refused here. */
var STORED_BLOCK_MAX = 65535;
function PngIDAT(width, height, bytes)
{
this.data = bytes;
this.width = width;
this.height = height;
/* Every scanline is filter type 0 followed by its pixels. */
this.raw_size = this.height * (1 + this.width * 4);
this.blocks = Math.max(1, Math.ceil(this.raw_size / STORED_BLOCK_MAX));
}
PngIDAT.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var orig = at;
var x, y, i;
var dv = new DataView(a);
var zsum = new adler();
dv.setUint32(at, this.buffer_size() - 12); at += 4;
dv.setUint8(at, 'I'.charCodeAt(0)); at++;
dv.setUint8(at, 'D'.charCodeAt(0)); at++;
dv.setUint8(at, 'A'.charCodeAt(0)); at++;
dv.setUint8(at, 'T'.charCodeAt(0)); at++;
/* zlib header: deflate, 32K window, no preset dictionary. */
dv.setUint8(at, 0x78); at++;
dv.setUint8(at, 0x01); at++;
var u8 = new Uint8Array(this.data);
var out = new Uint8Array(a);
var left = this.raw_size;
var block_left = 0;
var put = function(b)
{
if (block_left == 0)
{
/* Stored block: BFINAL in bit 0, BTYPE 00, then LEN and
its one's complement, little-endian. */
block_left = Math.min(left, STORED_BLOCK_MAX);
out[at++] = left <= STORED_BLOCK_MAX ? 1 : 0;
out[at++] = block_left & 0xff;
out[at++] = block_left >> 8;
out[at++] = ~block_left & 0xff;
out[at++] = (~block_left >> 8) & 0xff;
}
out[at++] = b;
zsum.update(b);
block_left--;
left--;
};
for (i = 0, y = 0; y < this.height; y++)
{
put(0);
for (x = 0; x < this.width; x++)
{
put(u8[i] || 0); i++;
put(u8[i] || 0); i++;
put(u8[i] || 0); i++;
put(u8[i] || 0); i++;
}
}
/* zlib checksum, big-endian. */
dv.setUint16(at, zsum.s2); at += 2;
dv.setUint16(at, zsum.s1); at += 2;
dv.setUint32(at, crc32(a, orig + 4, this.buffer_size() - 8)); at += 4;
return at;
},
buffer_size: function()
{
return 12 + 2 + this.blocks * 5 + this.raw_size + 4;
}
}
function PngIEND()
{
}
PngIEND.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var orig = at;
var dv = new DataView(a);
dv.setUint32(at, this.buffer_size() - 12); at += 4;
dv.setUint8(at, 'I'.charCodeAt(0)); at++;
dv.setUint8(at, 'E'.charCodeAt(0)); at++;
dv.setUint8(at, 'N'.charCodeAt(0)); at++;
dv.setUint8(at, 'D'.charCodeAt(0)); at++;
dv.setUint32(at, crc32(a, orig + 4, this.buffer_size() - 8)); at += 4;
return at;
},
buffer_size: function()
{
return 12;
}
}
function create_rgba_png(width, height, bytes)
{
var i;
var ihdr = new PngIHDR(width, height);
var idat = new PngIDAT(width, height, bytes);
var iend = new PngIEND;
var mb = new ArrayBuffer(ihdr.buffer_size() + idat.buffer_size() + iend.buffer_size());
var at = ihdr.to_buffer(mb);
at = idat.to_buffer(mb, at);
at = iend.to_buffer(mb, at);
var u8 = new Uint8Array(mb);
var str = "";
for (i = 0; i < at; i++)
{
str += "%";
if (u8[i] < 16)
str += "0";
str += u8[i].toString(16);
}
return "%89PNG%0D%0A%1A%0A" + str;
}
export {
create_rgba_png,
};

Some files were not shown because too many files have changed in this diff Show More