Author SHA1 Message Date
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
98 changed files with 5611 additions and 19199 deletions
+24
View File
@@ -752,3 +752,27 @@ before its own login).
404 — harmless, but a stray. 404 — harmless, but a stray.
12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you 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. 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.
+105 -46
View File
@@ -22,6 +22,7 @@ stub tab or a roadmap item.
| 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. | | 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. | | 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. | | 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 ## Layout
@@ -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; 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 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 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 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 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 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 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 had taken left untouched and fully readable here — which is what adopting its naming
bought. See `hamlet/docs/hosts.md`. bought. See `hamlet/docs/hosts.md`.
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`). - **KVM tab** — shipped 2026-09-24, **removed 2026-11-09** at Mitchell's call: not needed —
Paste a device's address; its own web page appears in the tab. **Nothing in it knows the KVMs are reached over WireGuard (see `hamlet/docs/hosts.md`). It proxied a device's web
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid` page through Ham Lab's origin; the code is in git history before that date.
at `10.0.0.217`, but none of that is in the code.
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
**The general problem, and the answer.** A device UI asks for its assets by absolute path
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
bodies per device would not be general. So the prefix is only the entry point: any request
Ham Lab does not serve itself is routed to whichever KVM the asking page belongs to, read
from the **Referer**, falling back to a **cookie** when there is none (a WebSocket upgrade).
Ham Lab's own routes always match first, so a device can never shadow the console, and the
fallback is gated on a live session. The device never receives Ham Lab's session cookie,
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
now. Redirects to the device's root are rewritten back through the prefix.
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
refused before it loaded.
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
does not need to, since one with focus receives them directly. Fullscreen plus
`navigator.keyboard.lock()` is what takes Escape, Tab and Ctrl-W back from the browser;
that is Chromium-only, and the tab says which version you are getting rather than
pretending.
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
to `grid` and you get grid's; adding a second for a machine replaces the first. The
addresses are still kept **on the hub** rather than on each machine, because a KVM earns
its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub
proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at
all. Not done: holding the device's own login on the hub, so its page does not ask for a
second password.
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark, - **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light; type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
@@ -676,7 +653,7 @@ 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 `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 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 `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`) `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 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. (`ffmpeg-free` on Fedora, `ffmpeg` elsewhere); the status re-probes when the task ends.
@@ -771,9 +748,93 @@ something long in one, switch away and back, refresh — it's all still there.
11.7×, AV1 720p 13×, all on the Arc. `core` and `hamtv` re-encode on the CPU until 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 *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. and `gate` need ffmpeg first, which the previews note offers to install.
- *Not done, on purpose:* no restart-from-a-seek beyond what is encoded (the encode is - *Not done, on purpose:* no bit-rate ladder or adaptive switching, no subtitles, no
faster than watching; a seek far ahead waits), no bit-rate ladder or adaptive keeping a finished re-encode for next time. (Restart-from-a-seek was left out here at
switching, no subtitles, no keeping a finished re-encode for next time. 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 - **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 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 through with odd inputs in mind, then a battery of ~60 requests against the live hub
@@ -910,6 +971,11 @@ what it has moved.
## To do ## 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 - **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, 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 ahead of these gaps rather than after them: service, files and NPM host all gone, with a
@@ -986,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 §3 has the audit it came from. Deployed as v0.1.0-191 and checked on all five machines
through the hub. through the hub.
- **A Virtualization tab** — asked for 2026-09-23; **scoped, built and deployed 2026-10-09 — see - ~~**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.
`VIRTUALIZATION.md`** for what shipped (phases 1 and 2) and what is left. Incus 6.0 LTS as the engine; the tab detects per machine whether Incus is
running, installable, or unavailable, and whether KVM is there, and acts on the machine in
the switcher — never a default host. Containers and VMs in one list, console/shell through
the terminal machinery, snapshots, exports, and the old Proxmox estate on `tank` as a later
import. That file has the probe of every host, the per-machine layout Ham Lab computes
(pool driver, a bridge subnet that avoids the LAN's /8 and Docker's pools, the Docker
`FORWARD` fix) and the phased build.
## Open questions ## Open questions
+10 -15
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 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. 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 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). default or phones home to this lab · **C** cosmetic (placeholder text, comments, docs).
@@ -21,7 +24,6 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments
| 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.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.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.7 | **C** | `PLAN.md:3-4` "bare-metal Debian 13 host. First target: `core` (10.0.0.234)"; decisions table rows (Listen `10.0.0.234:8100`, Data `/home/core/apps/hamlab/`, Ingress NPM/`hamlab.bigham.dev`, Repo) | Product plan is written as this lab's plan. | Keep as history, or split lab facts out to `hamlet`. The repo is public. |
| 1.8 | **C** | `VIRTUALIZATION.md:339-353` per-machine probe table; `:412, 425-436` `~/apps/hamlab`, `/tank/...`, boot NVMe free space | Plan is written against the five lab hosts. | Same as 1.7. |
| 1.9 | **C** | `BUGS.md` throughout | Bug bashes narrate `core`, `grid`, `wyse`, `hamtv`, `gate`, the Mac. | History; fine to leave, but it is public. | | 1.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. | | 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 | | # | 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.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.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.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. |
| 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. | | 2.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 | | # | 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.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.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.4 | **A** | `internal/cron/cron.go:353-373, 401, 442, 532-534, 669-670` | Jobs tab reads `/etc/crontab`, `/etc/cron.d`, `/var/spool/cron{,/crontabs}` (Debian/cronie), `/usr/sbin/anacron`, last-runs from **journald**, timers via `systemctl`; writes only `/etc/crontab` and `/etc/cron.d`. Busybox crond (OpenWrt, Alpine) keeps crontabs in `/etc/crontabs/<user>` and has no `cron.d` → the Jobs tab is empty on `gate` and schedules written by 4.5 never run. | Add `/etc/crontabs/*` as a source; last-runs from syslog/logread when there is no journal. |
| 4.5 | **A** | `internal/storage/snapshots.go:402` `/etc/cron.d/hamlab-zfs`; `internal/api/dockersched.go:65` `/etc/cron.d/hamlab-docker` | ZFS snapshot and image-update schedules are cron.d files. No cron.d on busybox crond; no cron at all on a systemd-timer-only host (Fedora Server, CoreOS). | Detect: cron.d → cron.d; busybox → a line in `/etc/crontabs/<user>`; none but systemd → a timer unit. The daemon-side runner already exists (`hamlab zfs-snapshot`, `docker-update`). | | 4.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 | | # | 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.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.2 | **B** | `internal/incus/incus.go:973-978` | dnf branch adds `qemu-system-x86-core`/`edk2-ovmf` or `qemu-system-aarch64-core`/`edk2-aarch64`; other arches (riscv64, ppc64le, which `IncusArch()` maps) get no QEMU. pacman and apk branches assume their package names never change. | Table per PM × arch, or let `incus` pull its own deps where the distro does that (Debian does). |
| 5.3 | **B** | `internal/incus/incus.go:958, 970` `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold`; `internal/updates/updates.go:78, 432` | Debian-isms exported unconditionally. Harmless elsewhere. | Fine; note only. |
| 5.4 | **B** | `scripts/pci-ids-gpu.sh:7, 41` default `/usr/share/misc/pci.ids` | Debian's path for `pci.ids`; Fedora/Arch keep it in `/usr/share/hwdata/`. Build-time only. | Try both. | | 5.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.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.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 ## 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.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.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.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/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. |
| 6.15 | **B** | `internal/network/network.go:170-199, 303-314`; `internal/incus/subnet.go`; `internal/vpn/edit.go:558-595`; `internal/tlsauto/tlsauto.go:533-537`; `install.sh:114-120`; `internal/setup/setup.go:648-666` | **IPv4 everywhere**: default route from `/proc/net/route` only, outside view v4 only, bridge/peer allocation v4 only, cert SANs v4 only, installer lists v4 addresses only. | `/proc/net/ipv6_route`, v6 SANs, v6 peer addresses. An IPv6-only host today has no gateway, no cert SAN and no bridge. |
## 7. Privilege and deployment posture (the lab's NOPASSWD-everywhere model) ## 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.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.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) ## 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/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/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/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/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design. - `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`. - `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). - `internal/compose` (modulo 3.5).
- The React UI: machine-switcher model, hash routing, per-account prefs, themes. - 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 ## 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. 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. 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. 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`, `VIRTUALIZATION.md`, `SENSORS.md`, `README.md`. 5. **Cosmetics (C):** every placeholder and comment in §1.10 and §2, then split lab facts out of `PLAN.md`, `SENSORS.md`, `README.md`.
+3
View File
@@ -169,6 +169,9 @@ container.<name>.down should be up and isn't: Docker c
restart it — *not* a container someone stopped restart it — *not* a container someone stopped
updates.pending|security|reboot_required every 5 min updates.pending|security|reboot_required every 5 min
zfs.<pool>.health|healthy|size|used|used_pct|frag every 1 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 smart.<dev>.passed|warnings|power_on|wear every 10 min, never waking a sleeping disk
``` ```
-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/
+78 -19
View File
@@ -30,7 +30,6 @@ import (
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/incus"
"hamlab/internal/media" "hamlab/internal/media"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/nodes" "hamlab/internal/nodes"
@@ -64,7 +63,7 @@ func main() {
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped) passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
users list the accounts users list the accounts
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls) zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
virt-export [name…] export Incus instances to the library (what its cron line calls) 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) docker-update <id> run one image-update schedule (what its cron line calls)
user-rm <user> remove an account and sign it out everywhere user-rm <user> remove an account and sign it out everywhere
check verify the config and state load, then exit check verify the config and state load, then exit
@@ -152,8 +151,8 @@ func main() {
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil { if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
log.Fatal(err) log.Fatal(err)
} }
case "virt-export": // the nightly cron line from the Virtualization tab; optional instance names case "landing-verify":
if err := virtExport(cfg, flag.Args()[1:]); err != nil { if err := landingVerify(cfg, flag.Arg(1)); err != nil {
log.Fatal(err) log.Fatal(err)
} }
case "users": case "users":
@@ -260,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 // 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/. // 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 { func zfsSnapshot(cfg *config.Config, id string) error {
if strings.TrimSpace(id) == "" { if strings.TrimSpace(id) == "" {
return errors.New("usage: hamlab zfs-snapshot <schedule-id>") return errors.New("usage: hamlab zfs-snapshot <schedule-id>")
@@ -306,21 +380,6 @@ func zfsSnapshot(cfg *config.Config, id string) error {
return nil return nil
} }
// virtExport runs the instance exports the Virtualization tab scheduled, as the account Ham
// Lab runs as (which is in incus-admin), and records the run beside the data.
func virtExport(cfg *config.Config, names []string) error {
who := os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" {
who = u.Username
}
run := incus.New(cfg.DataDir(), who, version).RunExports(context.Background(), names, func(l string) { fmt.Println(l) })
fmt.Printf("exported %d, pruned %d, %.0f s\n", len(run.Exported), len(run.Pruned), run.Seconds)
if len(run.Errors) > 0 {
return errors.New(strings.Join(run.Errors, "; "))
}
return nil
}
func migrateUsers(cfg *config.Config) { func migrateUsers(cfg *config.Config) {
name := os.Getenv("USER") name := os.Getenv("USER")
if u, err := user.Current(); err == nil && u.Username != "" { if u, err := user.Current(); err == nil && u.Username != "" {
+6 -19
View File
@@ -71,9 +71,6 @@ type alertSaved struct {
Channels []AlertChannel `json:"channels"` Channels []AlertChannel `json:"channels"`
Events []AlertEvent `json:"events"` Events []AlertEvent `json:"events"`
Seeded bool `json:"seeded"` Seeded bool `json:"seeded"`
// SawInstanceRule marks that the instance-down default has been offered once; set so that
// removing it stays removed.
SawInstanceRule bool `json:"saw_instance_rule,omitempty"`
// Firing is what was firing when the hub last looked, so a restart (every deploy is one) // Firing is what was firing when the hub last looked, so a restart (every deploy is one)
// carries on where it was and doesn't announce the same alerts all over again. // carries on where it was and doesn't announce the same alerts all over again.
Firing map[string]alertKept `json:"firing,omitempty"` Firing map[string]alertKept `json:"firing,omitempty"`
@@ -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: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true},
{ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true}, {ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
{ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true}, {ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true},
{ID: "instance-down", Name: "VM or container down", Sensor: "instance.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
{ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true}, {ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true},
{ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true}, {ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
{ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true}, {ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true},
@@ -126,23 +122,14 @@ func newAlertEngine(s *Server) *alertEngine {
for key, k := range e.saved.Firing { for key, k := range e.saved.Firing {
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value} e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
} }
// a default added after an install was seeded joins its rules once; removing it afterwards sticks // the Virtualization tab's default went with the tab (2026-10-15): nothing publishes instance.* now
if e.saved.Seeded && !e.saved.SawInstanceRule { kept := e.saved.Rules[:0]
has := false for _, r := range e.saved.Rules {
for _, r := range e.saved.Rules { if r.ID != "instance-down" {
if r.ID == "instance-down" { kept = append(kept, r)
has = true
}
} }
if !has {
for _, d := range defaultRules {
if d.ID == "instance-down" {
e.saved.Rules = append(e.saved.Rules, d)
}
}
}
e.saved.SawInstanceRule = true
} }
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 for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
if r.ID == "container-down" && r.Sensor == "container.*.running" { if r.ID == "container-down" && r.Sensor == "container.*.running" {
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
-1
View File
@@ -340,7 +340,6 @@ func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) {
rec.Error = err.Error() rec.Error = err.Error()
} }
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec) _ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
stackUpdatesForget("/" + job.Stack)
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000)) jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
return return
-420
View File
@@ -1,420 +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)
}
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})
}
+499 -105
View File
@@ -1,141 +1,162 @@
package api package api
// Playing a video the browser cannot play as it is: POST /api/files/play starts an HLS // Playing a video in the Files viewer, YouTube's way: Auto (the player reads the connection),
// re-encode on this machine (internal/media), GET …/play/{id}/index.m3u8 and the segments // any quality by hand, or the file itself. The qualities are made on the fly as HLS by
// feed the player, GET …/play/{id} is its progress, DELETE ends it. /api/files/play-caps // internal/media, on the device the Transcode device setting names — which may be on another
// says which codecs this machine can make and on what. Root mode (?sudo=1) reads the file // machine than the file.
// and runs ffmpeg as root, through the same sudo the file reads use. //
// 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 ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"net/url"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/media" "hamlab/internal/media"
"hamlab/internal/store"
) )
const playLimit = 3 // re-encodes at once on one machine; a GPU does two or three, a CPU one // 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 playCaps struct { type playReq struct {
Codecs []playCodec `json:"codecs"` Path string `json:"path,omitempty"`
Qualities []media.Quality `json:"qualities"` URL string `json:"url,omitempty"` // a file on another machine, as the hub serves it
Error string `json:"error,omitempty"` Name string `json:"name,omitempty"`
Device string `json:"device,omitempty"`
} }
type playCodec struct { type playOut struct {
media.Codec Node string `json:"node"` // the machine the play is on: "" for the hub; filled in by the hub
Engine string `json:"engine,omitempty"` // "" when nothing here encodes it 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"`
} }
// playCapsHandler: GET /api/files/play-caps[?sudo=1] // playDevices: GET /api/files/play-devices — what this machine could make the player's H.264
func (s *Server) playCapsHandler(w http.ResponseWriter, r *http.Request) { // 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" root := r.URL.Query().Get("sudo") == "1"
if root && s.fsSudo == nil { if root && s.fsSudo == nil {
jsonErr(w, 403, "root access needs passwordless sudo on the host") jsonErr(w, 403, "root access needs passwordless sudo on the host")
return return
} }
caps, err := s.thumbs.capsFor(r.Context(), root, false) out, code, err := s.openPlay(r.Context(), body, root)
pc := playCaps{Codecs: []playCodec{}, Qualities: media.Qualities}
if err != nil { if err != nil {
pc.Error = err.Error() jsonErr(w, code, err.Error())
} else if caps.Error != "" { return
pc.Error = caps.Error
} }
for _, c := range media.Codecs { jsonOK(w, 200, out)
x := playCodec{Codec: c}
if pc.Error == "" {
x.Engine = media.EngineFor(caps, c.ID)
}
pc.Codecs = append(pc.Codecs, x)
}
jsonOK(w, 200, pc)
} }
// playStart: POST /api/files/play[?sudo=1] {path, codec, quality} func (s *Server) openPlay(ctx context.Context, body playReq, root bool) (playOut, int, error) {
func (s *Server) playStart(w http.ResponseWriter, r *http.Request) { if root && s.fsSudo == nil && body.URL == "" {
fsys, ok := s.fsFor(w, r) return playOut{}, 403, errors.New("root access needs passwordless sudo on the host")
if !ok {
return
} }
root := r.URL.Query().Get("sudo") == "1" in, name := body.Path, body.Name
var body struct{ Path, Codec, Quality string } if body.URL != "" {
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil { u, err := url.Parse(body.URL)
jsonErr(w, 400, "bad request") if err != nil || (u.Scheme != "http" && u.Scheme != "https") || body.Path != "" {
return 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)
} }
p, err := files.Clean(body.Path)
if err != nil { if err != nil {
jsonErr(w, 400, err.Error()) return playOut{}, 503, err
return
} }
if media.Kind(p) != "video" { ictx, cancel := context.WithTimeout(ctx, 30*time.Second)
jsonErr(w, 415, "not a video") src, err := media.Inspect(ictx, caps, in, root)
return
}
if err := media.ValidChoice(body.Codec, body.Quality); err != nil {
jsonErr(w, 400, err.Error())
return
}
if _, err := fsys.Stat(r.Context(), p); err != nil {
fsErr(w, err)
return
}
caps, err := s.thumbs.capsFor(r.Context(), root, false)
if err != nil {
jsonErr(w, 503, err.Error())
return
}
if caps.Error != "" {
jsonErr(w, 503, caps.Error)
return
}
ictx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
src, err := media.Inspect(ictx, caps, p, root)
cancel() cancel()
if err != nil { if err != nil {
playErr(w, err) var de *media.DecodeError
return if errors.As(err, &de) {
return playOut{}, 422, errors.New(de.Msg)
}
return playOut{}, 504, err
} }
st, err := s.plays.Open(r.Context(), caps, root, p, body.Codec, body.Quality, src) p, err := s.plays.Open(caps, root, in, name, src, body.Device)
if err != nil { if err != nil {
playErr(w, err) return playOut{}, 422, err
return
}
log.Printf("files: play %s as %s %s on %s (from %s)", p, st.Codec, st.Quality, st.Engine, clientIP(r))
jsonOK(w, 200, map[string]any{"stream": st, "url": "/api/files/play/" + st.ID + "/index.m3u8"})
}
func playErr(w http.ResponseWriter, err error) {
var de *media.DecodeError
switch {
case errors.As(err, &de):
jsonErr(w, 422, de.Msg)
case strings.Contains(err.Error(), "already being re-encoded"):
jsonErr(w, 429, err.Error())
case strings.HasPrefix(err.Error(), "unknown ") || strings.HasPrefix(err.Error(), "nothing on this machine encodes"):
jsonErr(w, 400, err.Error())
case errors.Is(err, context.DeadlineExceeded):
jsonErr(w, 504, "the file took too long to read")
default:
jsonErr(w, 500, err.Error())
} }
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} // playStatus: GET /api/files/play/{id}
func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) {
st, ok := s.plays.Find(r.PathValue("id")) p, ok := s.plays.Find(r.PathValue("id"))
if !ok { if !ok {
jsonErr(w, 404, "no such stream") jsonErr(w, 404, "no such play")
return return
} }
jsonOK(w, 200, st.Status()) jsonOK(w, 200, p.Status())
} }
// playStop: DELETE /api/files/play/{id}, or POST …/close — the form a page can send on its // playStop: DELETE /api/files/play/{id}, or POST …/close — the form a page can send on its
@@ -145,25 +166,398 @@ func (s *Server) playStop(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204) w.WriteHeader(204)
} }
// playFile: GET /api/files/play/{id}/{file} — the playlist, the init segment, a segment. // playPlaylist: GET /api/files/play/{id}/{file} — master.m3u8, or one quality's <name>.m3u8.
func (s *Server) playFile(w http.ResponseWriter, r *http.Request) { func (s *Server) playPlaylist(w http.ResponseWriter, r *http.Request) {
st, ok := s.plays.Find(r.PathValue("id")) p, ok := s.plays.Find(r.PathValue("id"))
if !ok { if !ok {
jsonErr(w, 404, "no such stream") jsonErr(w, 404, "no such play")
return return
} }
p, ok := st.File(r.PathValue("file")) f := r.PathValue("file")
if !ok { var b []byte
jsonErr(w, 404, "not a stream file") 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 return
} }
switch filepath.Ext(p) { w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
case ".m3u8":
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
default:
w.Header().Set("Content-Type", "video/mp4")
}
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff") _, _ = w.Write(b)
http.ServeFile(w, r, p) }
// 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"))
}
}
+21 -66
View File
@@ -34,7 +34,6 @@ import (
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files" "hamlab/internal/files"
"hamlab/internal/hardware" "hamlab/internal/hardware"
"hamlab/internal/incus"
"hamlab/internal/media" "hamlab/internal/media"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/network" "hamlab/internal/network"
@@ -76,12 +75,10 @@ type Server struct {
net *network.Watcher net *network.Watcher
update updateState update updateState
events eventLog events eventLog
kvms kvmProxies
virt *incus.Manager
fsLocal files.FS fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo fsSudo files.FS // nil without passwordless sudo
thumbs *thumbSvc thumbs *thumbSvc
plays *media.Streamer plays *media.Plays
self string // this binary, for helpers run as tasks self string // this binary, for helpers run as tasks
nodes *nodes.Manager nodes *nodes.Manager
fingerprint string fingerprint string
@@ -105,7 +102,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
s.self = self s.self = self
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space")) s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
s.thumbs = newThumbSvc(cfg.DataDir(), self) s.thumbs = newThumbSvc(cfg.DataDir(), self)
s.plays = media.NewStreamer(cfg.DataDir(), self, playLimit) s.plays = media.NewPlays(cfg.DataDir(), self, playRuns)
s.hostName() // read it once now so the first request doesn't have to s.hostName() // read it once now so the first request doesn't have to
if u, err := user.Current(); err == nil && u.Username != "" { if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username s.user = u.Username
@@ -113,7 +110,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
if s.user == "" { if s.user == "" {
s.user = "?" s.user = "?"
} }
s.virt = incus.New(cfg.DataDir(), s.user, version)
s.migrateUsers() s.migrateUsers()
// The speed test and the outside view. Its history outlives a restart, and its last result // 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. // is published as sensors, so the line is graphable and alertable like anything else.
@@ -237,37 +233,6 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents)) 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}/exec", s.auth(s.dockerExec))
m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate)) m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate))
m.HandleFunc("GET /api/virt", s.auth(s.virtOverview))
m.HandleFunc("POST /api/virt/install", s.auth(s.virtInstall))
m.HandleFunc("GET /api/virt/images/remote", s.auth(s.virtRemoteImages))
m.HandleFunc("POST /api/virt/images/download", s.auth(s.virtImageDownload))
m.HandleFunc("POST /api/virt/images/import", s.auth(s.virtImageImport))
m.HandleFunc("POST /api/virt/images/publish", s.auth(s.virtImagePublish))
m.HandleFunc("POST /api/virt/images/delete", s.auth(s.virtImageDelete))
m.HandleFunc("POST /api/virt/instances", s.auth(s.virtCreate))
m.HandleFunc("GET /api/virt/instances/{name}", s.auth(s.virtInstance))
m.HandleFunc("POST /api/virt/instances/{name}/rename", s.auth(s.virtRename))
m.HandleFunc("PUT /api/virt/instances/{name}/config", s.auth(s.virtConfig))
m.HandleFunc("POST /api/virt/instances/{name}/snapshots", s.auth(s.virtSnapshotCreate))
m.HandleFunc("POST /api/virt/instances/{name}/snapshots/{snap}/{action}", s.auth(s.virtSnapshotAction))
m.HandleFunc("POST /api/virt/instances/from-iso", s.auth(s.virtFromISO))
m.HandleFunc("GET /api/virt/instances/{name}/vga", s.auth(s.virtVGA))
m.HandleFunc("POST /api/virt/instances/{name}/iso", s.auth(s.virtISOAttach))
m.HandleFunc("POST /api/virt/instances/{name}/iso/detach", s.auth(s.virtISODetach))
m.HandleFunc("POST /api/virt/instances/{name}/mounts", s.auth(s.virtMountAdd))
m.HandleFunc("POST /api/virt/instances/{name}/mounts/remove", s.auth(s.virtMountRemove))
m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction))
m.HandleFunc("POST /api/virt/isos/fetch", s.auth(s.virtISOFetch))
m.HandleFunc("POST /api/virt/isos/import", s.auth(s.virtISOImport))
m.HandleFunc("POST /api/virt/isos/delete", s.auth(s.virtISODelete))
m.HandleFunc("POST /api/virt/isos/rename", s.auth(s.virtISORename))
m.HandleFunc("GET /api/virt/exports", s.auth(s.virtExports))
m.HandleFunc("PUT /api/virt/exports", s.auth(s.virtExportsPut))
m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun))
m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete))
m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys))
m.HandleFunc("POST /api/virt/pools", s.auth(s.virtPoolCreate))
m.HandleFunc("POST /api/virt/pools/delete", s.auth(s.virtPoolDelete))
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet)) m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut)) m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest)) m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
@@ -310,15 +275,23 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete)) m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod)) m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
m.HandleFunc("GET /api/files/thumb", s.auth(s.filesThumb)) 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("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/install", s.auth(s.thumbsInstall))
m.HandleFunc("POST /api/files/thumbs/gpu-access", s.auth(s.thumbsGPUAccess)) m.HandleFunc("POST /api/files/thumbs/gpu-access", s.auth(s.thumbsGPUAccess))
m.HandleFunc("GET /api/files/play-caps", s.auth(s.playCapsHandler)) m.HandleFunc("GET /api/files/play-devices", s.auth(s.playDevices))
m.HandleFunc("POST /api/files/play", s.auth(s.playStart)) m.HandleFunc("POST /api/files/play", s.auth(s.playStart))
m.HandleFunc("GET /api/files/play/{id}", s.auth(s.playStatus)) m.HandleFunc("GET /api/files/play/{id}", s.auth(s.playStatus))
m.HandleFunc("DELETE /api/files/play/{id}", s.auth(s.playStop)) 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("POST /api/files/play/{id}/close", s.auth(s.playStop))
m.HandleFunc("GET /api/files/play/{id}/{file}", s.auth(s.playFile)) 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/hardware", s.auth(s.hardwareReport))
m.HandleFunc("GET /api/network", s.auth(s.networkOverview)) m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport)) m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
@@ -340,6 +313,11 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete)) m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete))
m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow)) m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow))
m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete)) 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", s.auth(s.spaceOverview))
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus)) m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan)) m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
@@ -372,18 +350,7 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler)) m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow)) m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow)) m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
m.HandleFunc("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) { m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) {
if s.kvmFallback(w, r) {
return
}
jsonErr(w, 404, "not found") jsonErr(w, 404, "not found")
})) }))
m.HandleFunc("/", s.static) m.HandleFunc("/", s.static)
@@ -407,16 +374,10 @@ func sensorPath(p string) bool {
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header() h := w.Header()
// The console must never be framed. Two exceptions: an embedded widget exists to be // The console must never be framed. The one exception is an embedded widget, which
// (a page that can only read sensors, and only with a read-only token in its URL), // exists to be (a page that can only read sensors, and only with a read-only token in
// and a proxied KVM is framed by the console itself — 'none' forbids that even from // its URL).
// the same origin, so those paths say 'self' instead of opening up. if r.URL.Path != "/embed/widget" {
switch {
case r.URL.Path == "/embed/widget":
case strings.HasPrefix(r.URL.Path, kvmPrefix):
h.Set("X-Frame-Options", "SAMEORIGIN")
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
default:
h.Set("X-Frame-Options", "DENY") h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "frame-ancestors 'none'") h.Set("Content-Security-Policy", "frame-ancestors 'none'")
} }
@@ -2675,12 +2636,6 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
// A path with no file of ours behind it may belong to a KVM's page, which asks for its
// own assets by absolute path. Checked here rather than in the router because this
// handler is the catch-all — and gated on a session, since this handler is public.
if s.signedIn(r) && s.kvmFallback(w, r) {
return
}
// SPA fallback // SPA fallback
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
http.ServeFileFS(w, r, s.ui, "index.html") http.ServeFileFS(w, r, s.ui, "index.html")
+44 -13
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() _ = 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)) log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false)) jsonOK(w, 202, t.Snapshot(false))
} }
@@ -755,6 +754,7 @@ type imageCheck struct {
Image string `json:"image"` Image string `json:"image"`
State string `json:"state"` // current | update | unknown State string `json:"state"` // current | update | unknown
Detail string `json:"detail,omitempty"` 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 { type updAnswer struct {
@@ -767,7 +767,41 @@ var (
updCache = map[string]updAnswer{} 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 // 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. // 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{} res := []imageCheck{}
for _, img := range images { for _, img := range images {
ic := imageCheck{Image: img, State: "unknown"} ic := imageCheck{Image: img, State: "unknown"}
out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img) have := localDigests(ctx, img)
var have []string if len(have) == 0 {
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
ic.Detail = "built or loaded here, not pulled from a registry" ic.Detail = "built or loaded here, not pulled from a registry"
res = append(res, ic) res = append(res, ic)
continue continue
@@ -795,11 +828,9 @@ func checkImages(ctx context.Context, images []string) []imageCheck {
res = append(res, ic) res = append(res, ic)
continue continue
} }
ic.State = "update" ic.State, ic.remote = "update", digest
for _, h := range have { if holdsDigest(have, digest) {
if strings.HasSuffix(h, "@"+digest) { ic.State = "current"
ic.State = "current"
}
} }
res = append(res, ic) res = append(res, ic)
} }
@@ -833,7 +864,7 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
c, ok := updCache[key] c, ok := updCache[key]
updMu.Unlock() updMu.Unlock()
if ok && time.Since(c.at) < 6*time.Hour && !refresh { 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 return
} }
if !refresh { if !refresh {
@@ -863,13 +894,13 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 404, err.Error()) jsonErr(w, 404, err.Error())
return return
} }
key := st.Name key := "stack:" + st.Name
updMu.Lock() updMu.Lock()
c, ok := updCache[key] c, ok := updCache[key]
updMu.Unlock() updMu.Unlock()
refresh := r.URL.Query().Get("refresh") == "1" refresh := r.URL.Query().Get("refresh") == "1"
if ok && time.Since(c.at) < 6*time.Hour && !refresh { 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 return
} }
if !refresh { if !refresh {
-39
View File
@@ -10,7 +10,6 @@ import (
"sync" "sync"
"time" "time"
"hamlab/internal/incus"
"hamlab/internal/priv" "hamlab/internal/priv"
"hamlab/internal/sensors" "hamlab/internal/sensors"
"hamlab/internal/storage" "hamlab/internal/storage"
@@ -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{}} p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}, acc: map[string]*bucket{}}
go p.loop("services", servicesEvery, p.services) go p.loop("services", servicesEvery, p.services)
go p.loop("containers", containersEvery, p.containers) go p.loop("containers", containersEvery, p.containers)
go p.loop("instances", containersEvery, p.instances)
go p.loop("updates", updatesEvery, p.updates) go p.loop("updates", updatesEvery, p.updates)
go p.loop("zfs", zfsEvery, p.zfs) go p.loop("zfs", zfsEvery, p.zfs)
go p.loop("smart", smartEvery, p.smart) go p.loop("smart", smartEvery, p.smart)
@@ -322,43 +320,6 @@ type cpuMark struct {
t int64 t int64
} }
// instances are Incus's containers and VMs. "Down" means it should be up and is not: an
// instance marked to start with the machine that is stopped, or one in Error — a stopped
// instance with no such mark was stopped on purpose, as with Docker containers.
func (p *statusSensors) instances(ctx context.Context, t int64) []sensors.Sensor {
if !incus.Installed() {
return nil
}
list, err := p.s.virt.InstancesFor(ctx, "sensors")
if err != nil {
return nil
}
var res []sensors.Sensor
var running, total float64
for _, in := range list {
total++
id := "instance." + sensors.Seg(in.Name)
l := map[string]string{"instance": in.Name, "type": in.Type, "image": in.Image}
up := in.Status == "Running"
if up {
running++
}
down := in.Status == "Error" || (!up && in.Config["boot.autostart"] == "true")
res = append(res,
sensors.Sensor{ID: id + ".running", Name: in.Name, Kind: "state", Value: up, T: t, Labels: l},
sensors.Sensor{ID: id + ".down", Name: in.Name + " down", Kind: "state", Value: down, T: t, Labels: l},
sensors.Sensor{ID: id + ".state", Name: in.Name + " state", Kind: "state", Value: strings.ToLower(in.Status), T: t, Labels: l})
if up {
res = append(res,
sensors.Sensor{ID: id + ".cpu", Name: in.Name + " CPU", Kind: "usage", Unit: "%", Value: float64(int(in.CPUPct*10+0.5)) / 10, T: t, Min: sensors.F(0), Labels: l},
sensors.Sensor{ID: id + ".mem", Name: in.Name + " memory", Kind: "bytes", Unit: "B", Value: float64(in.MemUsage), T: t, Labels: l})
}
}
return append(res,
sensors.Sensor{ID: "instances.total", Name: "Instances", Kind: "count", Value: total, T: t},
sensors.Sensor{ID: "instances.running", Name: "Running instances", Kind: "count", Value: running, T: t})
}
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor { func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
st := p.s.updates.Status(ctx) st := p.s.updates.Status(ctx)
if st.Manager == "" { if st.Manager == "" {
+239 -7
View File
@@ -10,10 +10,16 @@ package api
// opens every render node, whatever groups the daemon's user has. // opens every render node, whatever groups the daemon's user has.
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"io"
"log" "log"
"net/http" "net/http"
"net/http/httptest"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -25,6 +31,7 @@ import (
"hamlab/internal/media" "hamlab/internal/media"
"hamlab/internal/priv" "hamlab/internal/priv"
"hamlab/internal/store"
) )
const thumbSize = 256 const thumbSize = 256
@@ -117,7 +124,9 @@ func describeCaps(c media.Caps) string {
} }
// make produces a preview, sharing the work when the same file is asked for twice at once. // 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) (media.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() t.mu.Lock()
if c, ok := t.calls[key]; ok { if c, ok := t.calls[key]; ok {
t.mu.Unlock() t.mu.Unlock()
@@ -153,19 +162,29 @@ func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (media
c.err = err c.err = err
return c.res, c.err return c.res, c.err
} }
o := media.Opts{Size: thumbSize, Timeout: thumbTimeout} o := media.Opts{Size: thumbSize, Timeout: thumbTimeout, Kind: kind}
caps = caps.Only(device)
if root { if root {
c.res, c.err = media.MakeRoot(wctx, t.self, caps, path, o) c.res, c.err = media.MakeRoot(wctx, t.self, caps, in, o)
} else { } else {
c.res, c.err = media.Make(wctx, caps, path, o) c.res, c.err = media.Make(wctx, caps, in, o)
} }
return c.res, c.err return c.res, c.err
} }
// ---- handlers ---- // ---- handlers ----
// filesThumb: GET /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1]. v is the client's idea of // thumbDeviceHeader carries the device a hub wants a video preview made on: the Transcode
// the file, from its listing; it makes a cache hit free of a stat, and a changed file a new URL. // 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) { func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
fsys, ok := s.fsFor(w, r) fsys, ok := s.fsFor(w, r)
if !ok { if !ok {
@@ -203,7 +222,28 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
if s.serveCachedThumb(w, key) { if s.serveCachedThumb(w, key) {
return 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 { if err != nil {
var de *media.DecodeError var de *media.DecodeError
switch { switch {
@@ -223,12 +263,204 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
s.keepThumb(w, key, p, res)
}
// keepThumb puts a preview in the cache and answers with it.
func (s *Server) keepThumb(w http.ResponseWriter, key, p string, res media.Result) {
if err := s.thumbs.cache.Put(key, media.Entry{Path: p, Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}); err != nil { 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) log.Printf("thumbs: cache: %v", err)
} }
writeThumb(w, key, res.JPEG, res.Engine) 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 // 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. // miss: the preview is made again rather than refused.
func (s *Server) serveCachedThumb(w http.ResponseWriter, key string) bool { func (s *Server) serveCachedThumb(w http.ResponseWriter, key string) bool {
+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)
}
}
-1167
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -26,7 +26,8 @@ import (
) )
type zfsState struct { type zfsState struct {
Policies []storage.Policy `json:"policies"` Policies []storage.Policy `json:"policies"`
Verify []storage.VerifyPolicy `json:"verify"` // landing-tier verify schedules (landing.go)
} }
func (s *Server) zfsLoad() zfsState { func (s *Server) zfsLoad() zfsState {
@@ -39,6 +40,9 @@ func (s *Server) zfsLoad() zfsState {
if z.Policies == nil { if z.Policies == nil {
z.Policies = []storage.Policy{} z.Policies = []storage.Policy{}
} }
if z.Verify == nil {
z.Verify = []storage.VerifyPolicy{}
}
return z 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) // 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 // 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) { 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 { if smp, ok := s.metrics.Latest(); ok && smp.ARC != nil {
out["live"] = smp.ARC out["live"] = smp.ARC
out["writes"] = smp.ZFSWrite
} }
jsonOK(w, 200, out) jsonOK(w, 200, out)
} }
-18
View File
@@ -77,23 +77,6 @@ type UpdateSource struct {
Check string `json:"check,omitempty"` Check string `json:"check,omitempty"`
} }
// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access
// to a machine, including when that machine has no OS running. Ham Lab holds the list on
// the hub and proxies each one, because a KVM matters exactly when the machine it is
// attached to cannot answer for itself.
type KVM struct {
ID string `json:"id"`
Name string `json:"name"`
// URL is the device's own address, as you would type it into a browser on the LAN.
URL string `json:"url"`
// Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
// for a node's KVM, because a KVM earns its keep when the machine is down and cannot
// answer for itself.
Machine string `json:"machine"`
Added time.Time `json:"added"`
}
type Config struct { type Config struct {
Listen string `json:"listen"` Listen string `json:"listen"`
// PasswordHash is the single login of installs before users existed. On load it becomes // PasswordHash is the single login of installs before users existed. On load it becomes
@@ -103,7 +86,6 @@ type Config struct {
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
Tokens []Token `json:"tokens,omitempty"` Tokens []Token `json:"tokens,omitempty"`
Nodes []Node `json:"nodes,omitempty"` Nodes []Node `json:"nodes,omitempty"`
KVMs []KVM `json:"kvms,omitempty"`
// Updates: where a newer build comes from. See UpdateSource. // Updates: where a newer build comes from. See UpdateSource.
Updates UpdateSource `json:"update,omitempty"` Updates UpdateSource `json:"update,omitempty"`
-232
View File
@@ -1,232 +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"
"hamlab/internal/store"
"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 store.WriteFileAtomic(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, "", " ")
_ = store.WriteFileAtomic(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")
}
}
-126
View File
@@ -1,126 +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"
"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"}
}
-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("this account can't reach Incus's socket; Finish setup on the Virtualization tab puts Ham Lab 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
}
+274 -154
View File
@@ -1,217 +1,337 @@
package media package media
// What to run when a file is to be re-encoded for a browser that cannot play it as it is: // What to run when a video is played in the browser: the qualities it is offered in (the
// the codecs and sizes on offer, the output size and bit rate a choice comes to for a // ladder), the ways a machine could make one of them on the device asked for, in the order
// given source, the ways this machine could do it in the order worth trying, and the // worth trying, and the ffmpeg argument list for one piece-making run. All decisions, no
// ffmpeg argument list for one of them. All decisions, no processes, so it can be tested. // processes, so it can be tested. Everything is H.264 High and AAC: what every browser plays.
import ( import (
"fmt" "fmt"
"math" "math"
"sort" "sort"
"strconv" "strconv"
"strings"
) )
// Codec is one target a stream may be re-encoded to. "copy" keeps the codec and only // Rung is one quality the player can pick: a size, a frame rate and a bit rate.
// repackages: for a browser that lacks the container, not the decoder. type Rung struct {
type Codec struct { Name string `json:"name"` // in URLs: the width, "1920"
ID string `json:"id"` Label string `json:"label"` // what the menu says: "1080s", "720p"
Label string `json:"label"` 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
} }
var Codecs = []Codec{{"h264", "H.264"}, {"hevc", "HEVC"}, {"av1", "AV1"}, {"copy", "Original codec, repackaged"}} // 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.
// Quality is a picture height; the bit rate follows from the size the output ends up at. type step struct {
type Quality struct { size int
ID string `json:"id"` label string
Label string `json:"label"` hi, lo int
Height int `json:"height"` // 0: the source's own cap30 bool
} }
var Qualities = []Quality{{"original", "Original size", 0}, {"1080p", "1080p", 1080}, {"720p", "720p", 720}, {"480p", "480p", 480}} // 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},
}
func qualityByID(id string) (Quality, bool) { var flatSteps = []step{
for _, q := range Qualities { {2160, "2160p", 160, 120, false},
if q.ID == id { {1440, "1440p", 90, 65, false},
return q, true {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
return Quality{}, false 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))
// ValidChoice says whether a codec and quality are ones on offer — checked before any work.
func ValidChoice(codec, quality string) error {
if !codecKnown(codec) {
return fmt.Errorf("unknown codec %q", codec)
}
if _, ok := qualityByID(quality); !ok {
return fmt.Errorf("unknown quality %q", quality)
}
return nil
}
func codecKnown(id string) bool {
for _, c := range Codecs {
if c.ID == id {
return true
} }
out = append(out, rung(i, s))
} }
return false 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
} }
// fit is the output: never larger than the source, even dimensions, and a bit rate by the func rung(i Info, s step) Rung {
// size it comes to — a quarter more at 50 fps and up, less for the codecs that need less. v := i.Video
func fit(q Quality, src Info, codec string) (w, h, kbps int) { sw, sh := v.Shown()
w, h = src.Video.Width, src.Video.Height var w, h int
if q.Height > 0 && h > q.Height {
w = int(math.Round(float64(w) * float64(q.Height) / float64(h)))
h = q.Height
}
w, h = w&^1, h&^1
switch { switch {
case h >= 2160: case v.Spherical:
kbps = 20000 w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
case h >= 1440: case sw >= sh:
kbps = 12000 h, w = s.size, even(float64(s.size)*float64(sw)/float64(sh))
case h >= 1080: default: // standing up
kbps = 8000 w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
case h >= 720:
kbps = 4000
default:
kbps = 2000
} }
if src.Video.FPS >= 50 { fps := v.FPS
kbps = kbps * 5 / 4 if fps <= 0 {
fps = 30
} }
if codec == "hevc" || codec == "av1" { for fps > 60.5 || (s.cap30 && fps > 30.5) {
kbps = kbps * 6 / 10 fps /= 2
} }
return w, h, kbps 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
} }
// Recipe is one way to make the stream: the engine's name for the record, and the ffmpeg // level is the lowest H.264 level that takes this many macroblocks a frame and a second.
// arguments that go before the input, the video filter, and the video codec. 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 { type Recipe struct {
Engine string Engine string
Device string
Pre []string Pre []string
VF string VF string
Video []string Video []string
} }
// recipes are the ways to try, best first: a card on the bus before the integrated GPU; // CPU is the device name for software encoding.
// on a GPU, decoding on the same card (frames never leave it) before decoding in software const CPU = "cpu"
// and uploading; the software encoders last. An empty list means nothing here can.
func recipes(c Caps, codec string, src Info, w, h, kbps int) []Recipe { // Device is one thing a machine can make H.264 on, for the Transcode device setting.
if codec == "copy" { type Device struct {
v := []string{"-c:v", "copy"} ID string `json:"id"` // a render node, or "cpu"
if src.Video.Codec == "hevc" { Name string `json:"name"` // "Intel Arc A380", "CPU (libx264)"
v = append(v, "-tag:v", "hvc1") // Safari reads hvc1, not hev1 Integrated bool `json:"integrated,omitempty"`
} OK bool `json:"ok"`
return []Recipe{{Engine: "Original codec", Video: v}} 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}}
} }
var out []Recipe for _, g := range c.GPUs {
gpus := append([]GPU(nil), c.GPUs...) d := Device{ID: g.Device, Name: g.Name, Integrated: g.Integrated}
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated }) if d.Name == "" {
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"} d.Name = g.Device
for i := range gpus {
g := &gpus[i]
if g.API != "vaapi" || !has(g.Encodes, codec) {
continue
} }
video := append([]string{"-c:v", codec + "_vaapi"}, rate...) switch {
switch codec { case g.API != "vaapi":
case "h264": d.Reason = "only VAAPI devices encode here"
video = append(video, "-profile:v", "high") case g.Encodes != nil && !has(g.Encodes, "h264"):
case "hevc": d.Reason = "doesn't encode H.264"
video = append(video, "-tag:v", "hvc1") default:
d.OK = true
} }
out = append(out, d)
}
for _, g := range c.Unusable {
name := g.Name name := g.Name
if name == "" { if name == "" {
name = g.Device name = g.Device
} }
scale := fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", w, h) out = append(out, Device{ID: g.Device, Name: name, Integrated: g.Integrated, Reason: g.Reason})
if has(g.Codecs, src.Video.Codec) { }
vf := scale d := Device{ID: CPU, Name: "CPU (libx264)", OK: has(c.Encoders, "libx264")}
if src.HDR() { if !d.OK {
vf = "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + vf 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
} }
out = append(out, Recipe{Engine: name + " (vaapi)", Pre: []string{"-hwaccel", "vaapi", "-hwaccel_device", g.Device, "-hwaccel_output_format", "vaapi"}, VF: vf, Video: video}) 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})
} }
out = append(out, Recipe{Engine: name + " (vaapi, decoded on the CPU)", Pre: []string{"-init_hw_device", "vaapi=va:" + g.Device, "-filter_hw_device", "va"},
VF: fmt.Sprintf("format=nv12,scale=%d:%d,hwupload", w, h), Video: video})
} }
swVF := fmt.Sprintf("scale=%d:%d,format=yuv420p", w, h) if has(c.Encoders, "libx264") {
if src.HDR() { vf := fmt.Sprintf("%sscale=%d:%d,format=yuv420p", fps, r.Width, r.Height)
swVF = fmt.Sprintf("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709,scale=%d:%d,format=yuv420p", w, h) if src.PQ() {
} vf = fps + toneMap + fmt.Sprintf(",scale=%d:%d,format=yuv420p", r.Width, r.Height)
cap := []string{"-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"} }
switch { out = append(out, Recipe{Engine: "CPU (libx264)", Device: CPU, VF: vf, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-sc_threshold", "0"}, rate...)})
case codec == "h264" && has(c.Encoders, "libx264"):
out = append(out, Recipe{Engine: "CPU (libx264)", VF: swVF, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-profile:v", "high"}, cap...)})
case codec == "hevc" && has(c.Encoders, "libx265"):
out = append(out, Recipe{Engine: "CPU (libx265)", VF: swVF, Video: append([]string{"-c:v", "libx265", "-preset", "fast", "-crf", "26", "-tag:v", "hvc1"}, cap...)})
case codec == "av1" && has(c.Encoders, "libsvtav1"):
out = append(out, Recipe{Engine: "CPU (SVT-AV1)", VF: swVF, Video: append([]string{"-c:v", "libsvtav1", "-preset", "8", "-crf", "35"}, cap...)})
} }
return out return out
} }
// EngineFor is the engine the picker will say a codec runs on, "" when none can. // turn is the card's filter that turns a picture the way it is meant to be shown.
func EngineFor(c Caps, codec string) string { func turn(src Info) string {
if c.Error != "" { if src.Video == nil {
return "" return ""
} }
if codec == "copy" { switch src.Video.Rotation {
return "Original codec" case 90:
return "transpose_vaapi=dir=clock,"
case 180:
return "transpose_vaapi=dir=reversal,"
case 270:
return "transpose_vaapi=dir=cclock,"
} }
r := recipes(c, codec, Info{Video: &Track{Codec: "h264", Width: 1920, Height: 1080}}, 1920, 1080, 8000) return ""
if len(r) == 0 {
return ""
}
return r[0].Engine
} }
// hlsArgs is the whole ffmpeg argument list for one recipe: the input, the first video and // toneMap turns an HDR10 picture into an ordinary one, in software.
// audio streams, the picture through the recipe, sound as AAC unless it already is, a const toneMap = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv"
// keyframe every segment, and an event playlist of fragmented-MP4 segments in dir.
func hlsArgs(dir, in string, src Info, r Recipe, copyCodec bool) []string { // 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 := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
a = append(a, r.Pre...) a = append(a, rc.Pre...)
a = append(a, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn") if strings.HasPrefix(in, "http://") || strings.HasPrefix(in, "https://") {
if r.VF != "" { a = append(a, "-reconnect", "1", "-reconnect_on_network_error", "1", "-reconnect_delay_max", "4")
a = append(a, "-vf", r.VF)
} }
a = append(a, r.Video...) a = append(a, "-ss", at, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
switch { if rc.VF != "" {
case src.Audio == nil: a = append(a, "-vf", rc.VF)
}
a = append(a, rc.Video...)
if src.Audio == nil {
a = append(a, "-an") a = append(a, "-an")
case src.Audio.Codec == "aac": } else {
a = append(a, "-c:a", "copy")
default:
a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2") a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2")
} }
if !copyCodec { gop := int(math.Ceil(r.FPS*Seg)) * 4
fps := src.Video.FPS a = append(a, "-g", strconv.Itoa(gop), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%g)", Seg),
if fps <= 0 { "-output_ts_offset", strconv.FormatFloat(float64(n)*Seg+tsBase, 'f', 3, 64),
fps = 30 "-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),
gop := int(math.Round(fps * hlsSeconds)) "-hls_segment_filename", dir+"/%d.ts", dir+"/ffmpeg.m3u8")
if gop < 24 {
gop = 24
}
if gop > 300 {
gop = 300
}
a = append(a, "-g", strconv.Itoa(gop), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", hlsSeconds))
}
a = append(a, "-f", "hls", "-hls_time", strconv.Itoa(hlsSeconds), "-hls_playlist_type", "event", "-hls_segment_type", "fmp4",
"-hls_fmp4_init_filename", "init.mp4", "-hls_segment_filename", dir+"/seg%05d.m4s", "-hls_flags", "independent_segments+temp_file",
"-progress", dir+"/progress", "-stats_period", "1", dir+"/index.m3u8")
return a return a
} }
const hlsSeconds = 4
func has(list []string, x string) bool { func has(list []string, x string) bool {
for _, v := range list { for _, v := range list {
if v == x { if v == x {
+116 -73
View File
@@ -9,25 +9,40 @@ 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}} return Info{Duration: 100, Video: &Track{Codec: codec, Width: w, Height: h, FPS: fps}, Audio: &Track{Codec: "opus", Channels: 2}}
} }
func TestFit(t *testing.T) { func TestLadder(t *testing.T) {
q := func(id string) Quality { x, _ := qualityByID(id); return x } vr := src("hevc", 7680, 3840, 50)
for _, c := range []struct { vr.Video.Spherical = true
q string r := Ladder(vr)
in Info 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" {
codec string t.Fatalf("8K 360: %+v", r)
w, h, kbs int }
}{ if r[0].codecs != "avc1.640034,mp4a.40.2" {
{"1080p", src("hevc", 3840, 2160, 60), "h264", 1920, 1080, 10000}, t.Fatalf("codecs %q", r[0].codecs)
{"original", src("hevc", 3840, 2160, 30), "h264", 3840, 2160, 20000}, }
{"720p", src("h264", 2292, 960, 60), "h264", 1718, 720, 5000}, // odd width made even r = Ladder(src("h264", 2292, 960, 60)) // a capture: its own size on top, then the steps under it
{"1080p", src("h264", 1280, 720, 30), "h264", 1280, 720, 4000}, // never upscaled 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 {
{"480p", src("h264", 1920, 1080, 30), "hevc", 852, 480, 1200}, // 853.3 rounds down, then even t.Fatalf("2292×960: %+v", r)
{"original", src("av1", 640, 360, 24), "av1", 640, 360, 1200}, }
} { r = Ladder(src("h264", 1920, 1080, 59.94))
w, h, k := fit(q(c.q), c.in, c.codec) if len(r) != 3 || r[0].Label != "1080p" || r[0].FPS != 59.94 || r[1].FPS != 29.97 || r[2].Width != 854 {
if w != c.w || h != c.h || k != c.kbs { t.Fatalf("1080p: %+v", r)
t.Errorf("%s %s %dx%d@%.0f → %dx%d %dk, want %dx%d %dk", c.q, c.codec, c.in.Video.Width, c.in.Video.Height, c.in.Video.FPS, w, h, k, c.w, c.h, c.kbs) }
} 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))
} }
} }
@@ -47,82 +62,110 @@ func engineNames(r []Recipe) []string {
return out return out
} }
func TestRecipesOrder(t *testing.T) { func TestRecipes(t *testing.T) {
c := gridCaps() c := gridCaps()
// h264 from hevc: the card first, decoding on it; then the iGPU the same way; then the CPU in := src("hevc", 3840, 2160, 60)
got := engineNames(recipes(c, "h264", src("hevc", 3840, 2160, 60), 1920, 1080, 10000)) r1080 := Ladder(in)[2]
want := []string{"Intel Arc A380 (vaapi)", "Intel Arc A380 (vaapi, decoded on the CPU)", "Intel UHD Graphics 750 (vaapi)", "Intel UHD Graphics 750 (vaapi, decoded on the CPU)", "CPU (libx264)"} // any device: the card first, decoding on it; then the iGPU the same way; then the CPU
if strings.Join(got, "|") != strings.Join(want, "|") { got := strings.Join(engineNames(recipes(c, "", in, r1080)), "|")
t.Fatalf("got %v", got) 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)
} }
// av1 source: neither card decodes it, so no same-card recipe, and only the Arc encodes av1 // the iGPU asked for: only it, then the CPU as the fallback
got = engineNames(recipes(c, "av1", src("av1", 1920, 1080, 30), 1920, 1080, 4800)) 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)" {
want = []string{"Intel Arc A380 (vaapi, decoded on the CPU)", "CPU (SVT-AV1)"} t.Fatalf("iGPU: %s", got)
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("av1: got %v", got)
} }
// copy is one recipe, no filter, hvc1 for hevc if got = strings.Join(engineNames(recipes(c, CPU, in, r1080)), "|"); got != "CPU (libx264)" {
r := recipes(c, "copy", src("hevc", 1920, 1080, 30), 1920, 1080, 0) t.Fatalf("cpu: %s", got)
if len(r) != 1 || r[0].VF != "" || !has(r[0].Video, "hvc1") || !has(r[0].Video, "copy") {
t.Fatalf("copy: %+v", r)
} }
// a machine with no GPU and no x264: nothing for h264, something for av1 // an AV1 source: neither card decodes it, so no same-card recipe
bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac", "libsvtav1"}} 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)" {
if len(recipes(bare, "h264", src("h264", 1280, 720, 30), 1280, 720, 4000)) != 0 { t.Fatalf("av1: %s", got)
t.Fatal("no x264 means no h264")
} }
if EngineFor(bare, "av1") != "CPU (SVT-AV1)" || EngineFor(bare, "h264") != "" || EngineFor(bare, "copy") != "Original codec" { // a machine with no GPU and no x264: nothing
t.Fatal("engineFor") bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac"}}
if len(recipes(bare, "", in, r1080)) != 0 {
t.Fatal("no x264 means nothing")
} }
if EngineFor(c, "hevc") != "Intel Arc A380 (vaapi)" { // 30 fps from 60 for the lower rungs; HDR tone-mapped on both paths
t.Fatalf("engineFor on grid: %q", EngineFor(c, "hevc")) r720 := Ladder(in)[3]
}
// HDR gets tone-mapped on both paths
hdr := src("hevc", 3840, 2160, 60) hdr := src("hevc", 3840, 2160, 60)
hdr.Video.ColorTransfer = "smpte2084" hdr.Video.ColorTransfer = "smpte2084"
r = recipes(c, "h264", hdr, 1920, 1080, 10000) rs := recipes(c, "", hdr, r720)
if !strings.HasPrefix(r[0].VF, "tonemap_vaapi") || !strings.Contains(r[len(r)-1].VF, "tonemap=hable") { 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", r[0].VF, r[len(r)-1].VF) 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 TestHLSArgs(t *testing.T) { 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) in := src("h264", 1920, 1080, 60)
r := recipes(gridCaps(), "h264", in, 1920, 1080, 10000)[0] r := Ladder(in)[0]
a := strings.Join(hlsArgs("/d", "/v.mkv", in, r, false), " ") rc := recipes(gridCaps(), "/dev/dri/renderD129", in, r)[0]
for _, want := range []string{"-hwaccel vaapi -hwaccel_device /dev/dri/renderD129", "-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 10000k -maxrate 12500k", "-c:a aac -b:a 160k -ac 2", "-g 240 -force_key_frames expr:gte(t,n_forced*4)", "-hls_segment_type fmp4", "-hls_segment_filename /d/seg%05d.m4s", "/d/index.m3u8"} { 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) { if !strings.Contains(a, want) {
t.Errorf("missing %q in\n%s", want, a) t.Errorf("missing %q in\n%s", want, a)
} }
} }
// aac stays as it is; no audio means none; copy sets no keyframes if strings.Contains(a, "-reconnect") {
in.Audio.Codec = "aac" t.Error("a path needs no reconnect")
if a := strings.Join(hlsArgs("/d", "/v", in, r, false), " "); !strings.Contains(a, "-c:a copy") { }
t.Error("aac should be copied") 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 in.Audio = nil
if a := strings.Join(hlsArgs("/d", "/v", in, r, false), " "); !strings.Contains(a, " -an ") { if a := strings.Join(pieceArgs("/d", "/v", in, rc, r, 0), " "); !strings.Contains(a, "-an") {
t.Error("no audio stream → -an") t.Error("no sound means -an")
}
cp := recipes(gridCaps(), "copy", in, 1920, 1080, 0)[0]
if a := strings.Join(hlsArgs("/d", "/v", in, cp, true), " "); strings.Contains(a, "force_key_frames") || strings.Contains(a, "-vf") {
t.Error("copy must not ask for keyframes or filter")
} }
} }
func TestParseProgress(t *testing.T) { func TestCapsOnly(t *testing.T) {
b := []byte("frame=10\nout_time_us=1000000\nspeed=2.5x\nprogress=continue\nframe=20\nout_time_us=2500000\nspeed=3.1x\nprogress=continue\n") c := gridCaps()
s, sp := parseProgress(b) if got := c.Only("/dev/dri/renderD129"); len(got.GPUs) != 1 || got.GPUs[0].Name != "Intel Arc A380" || len(c.GPUs) != 2 {
if s != 2.5 || sp != 3.1 { t.Fatalf("one card: %+v", got.GPUs)
t.Fatalf("got %v %v", s, sp)
} }
} 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")
func TestValidChoice(t *testing.T) {
if err := ValidChoice("h264", "720p"); err != nil {
t.Fatal(err)
} }
if ValidChoice("vp8", "720p") == nil || ValidChoice("h264", "9p") == nil || ValidChoice("", "") == nil { // previews then go to that card, or the CPU
t.Fatal("bad choices must be refused") if g := c.Only("/dev/dri/renderD129").pick("hevc"); g == nil || g.Device != "/dev/dri/renderD129" {
t.Fatalf("pick: %+v", g)
} }
} }
+42 -7
View File
@@ -6,13 +6,13 @@ package media
// transcode never outlives the console that asked for it. // transcode never outlives the console that asked for it.
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"errors" "errors"
"io" "io"
"os" "os"
"os/exec" "os/exec"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -92,6 +92,35 @@ func (p *ffproc) end() {
} }
} }
// 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. // lastWords is what ffmpeg said last, for the record.
func (p *ffproc) lastWords() string { return p.tail.last() } func (p *ffproc) lastWords() string { return p.tail.last() }
@@ -141,7 +170,17 @@ func RunFFmpegHelper(args []string, stdin io.Reader, stderr io.Writer) int {
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
go func() { go func() {
_, _ = io.Copy(io.Discard, stdin) // returns when the daemon lets go // "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() cancel()
}() }()
done := make(chan error, 1) done := make(chan error, 1)
@@ -151,6 +190,7 @@ func RunFFmpegHelper(args []string, stdin io.Reader, stderr io.Writer) int {
case werr = <-done: case werr = <-done:
case <-ctx.Done(): case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGCONT) // a paused one can't act on TERM
select { select {
case werr = <-done: case werr = <-done:
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
@@ -181,8 +221,3 @@ func clearDir(dir string) error {
} }
return nil return nil
} }
func hasSegments(playlist string) bool {
b, err := os.ReadFile(playlist)
return err == nil && strings.Contains(string(b), "#EXTINF")
}
-344
View File
@@ -1,344 +0,0 @@
package media
// Playing what the browser cannot play as it is: an HLS stream, re-encoded on demand by
// ffmpeg on the machine's own GPU where it has one it can use, on its CPU otherwise. A
// stream is one file at one codec and size, in a folder of its own under
// <data dir>/transcode/: ffmpeg writes an event playlist and fragmented-MP4 segments there
// as fast as it can, and the player reads them as they land, so the range it can seek in
// grows until the whole file is through — on a GPU, several times faster than watching.
// Nothing is kept: a stream nobody has touched for a while is ended and its folder
// removed, and the folder is emptied when the daemon starts.
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Stream is one re-encode, in progress or finished.
type Stream struct {
ID string `json:"id"`
Path string `json:"path"`
Codec string `json:"codec"`
Quality string `json:"quality"`
Engine string `json:"engine"`
Width int `json:"width"`
Height int `json:"height"`
Kbps int `json:"kbps"`
Source Info `json:"source"`
Started time.Time `json:"started"`
Root bool `json:"root"`
key string
dir string
mu sync.Mutex
used time.Time
proc *ffproc
finished bool
failure string
gone bool // forgotten by the Streamer
}
// StreamStatus is what the player asks for while the file is still going through.
type StreamStatus struct {
Running bool `json:"running"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
Progress float64 `json:"progress"` // seconds of output written
Speed float64 `json:"speed"` // × realtime
Segments int `json:"segments"`
}
// Streamer keeps one machine's streams.
type Streamer struct {
dir string
self string
limit int
mu sync.Mutex
streams map[string]*Stream
}
const streamIdle = 10 * time.Minute
const firstSegmentWait = 40 * time.Second
var streamFile = regexp.MustCompile(`^(index\.m3u8|init\.mp4|seg\d{5}\.m4s)$`)
// NewStreamer empties the folder — a daemon restart orphans every stream — and starts the
// sweeper.
func NewStreamer(dataDir, self string, limit int) *Streamer {
m := &Streamer{dir: filepath.Join(dataDir, "transcode"), self: self, limit: limit, streams: map[string]*Stream{}}
_ = os.MkdirAll(m.dir, 0o755)
_ = clearDir(m.dir)
go func() {
for range time.Tick(30 * time.Second) {
m.Sweep(streamIdle)
}
}()
return m
}
// Open starts a stream, or hands back the one already serving the same request. It
// returns once the first segment is on disk, so the player's first fetch is answered.
func (m *Streamer) Open(ctx context.Context, c Caps, root bool, path, codec, quality string, src Info) (*Stream, error) {
if c.Error != "" {
return nil, errors.New(c.Error)
}
if err := ValidChoice(codec, quality); err != nil {
return nil, err
}
q, _ := qualityByID(quality)
if src.Video == nil {
return nil, &DecodeError{Msg: "no video stream"}
}
if src.Video.Width <= 0 || src.Video.Height <= 0 {
return nil, &DecodeError{Msg: "the picture's size is unknown"}
}
key := fmt.Sprintf("%s|%d|%.0f|%s|%s|%v", path, src.Size, src.Duration, codec, quality, root)
m.mu.Lock()
for _, s := range m.streams {
if s.key == key {
if s.failed() { // not the stream to hand back: let it go and make a fresh one
delete(m.streams, s.ID)
_ = os.RemoveAll(s.dir)
break
}
m.mu.Unlock()
s.touch()
return s, nil
}
}
running := 0
for _, s := range m.streams {
if s.running() {
running++
}
}
if running >= m.limit {
m.mu.Unlock()
return nil, fmt.Errorf("%d videos are already being re-encoded on this machine; one must finish or be closed first", running)
}
s := &Stream{ID: newID(), Path: path, Codec: codec, Quality: quality, Source: src, Started: time.Now(), Root: root, key: key, used: time.Now()}
s.dir = filepath.Join(m.dir, s.ID)
m.streams[s.ID] = s
m.mu.Unlock()
s.Width, s.Height, s.Kbps = fit(q, src, codec)
if codec == "copy" {
s.Width, s.Height, s.Kbps = src.Video.Width, src.Video.Height, int(src.BitRate/1000)
}
rs := recipes(c, codec, src, s.Width, s.Height, s.Kbps)
if len(rs) == 0 {
m.forget(s)
return nil, errors.New("nothing on this machine encodes " + codec)
}
var last string
for _, r := range rs {
if err := os.MkdirAll(s.dir, 0o755); err != nil { // the root helper's ffmpeg writes here too
m.forget(s)
return nil, err
}
_ = clearDir(s.dir)
args := hlsArgs(s.dir, path, src, r, codec == "copy")
p, err := startFF(c, m.self, root, args)
if err != nil {
last = r.Engine + ": " + err.Error()
continue
}
s.mu.Lock()
s.proc, s.Engine = p, r.Engine
s.mu.Unlock()
msg, ok := s.waitFirstSegment(ctx)
if ok {
go s.watch()
log.Printf("media: stream %s: %s → %s %s %dx%d %dk on %s", s.ID, filepath.Base(path), codec, quality, s.Width, s.Height, s.Kbps, r.Engine)
return s, nil
}
p.end()
if ctx.Err() != nil || s.closed() { // the caller left, or somebody closed it meanwhile: not the recipe's fault
m.forget(s)
return nil, errors.New("cancelled")
}
last = r.Engine + ": " + msg
log.Printf("media: stream %s on %s: %s", filepath.Base(path), r.Engine, msg)
}
m.forget(s)
return nil, &DecodeError{Msg: last}
}
// waitFirstSegment watches for the playlist's first segment, or ffmpeg giving up.
func (s *Stream) waitFirstSegment(ctx context.Context) (string, bool) {
deadline := time.Now().Add(firstSegmentWait)
playlist := filepath.Join(s.dir, "index.m3u8")
for {
if hasSegments(playlist) {
return "", true
}
if done, err := s.proc.exited(); done {
msg := s.proc.lastWords()
if msg == "" && err != nil {
msg = err.Error()
}
if msg == "" {
msg = "ffmpeg ended before writing a segment"
}
return msg, false
}
if time.Now().After(deadline) {
return "no segment after " + firstSegmentWait.String(), false
}
select {
case <-ctx.Done():
return ctx.Err().Error(), false
case <-time.After(250 * time.Millisecond):
}
}
}
// watch records how the encode ended.
func (s *Stream) watch() {
<-s.proc.done
s.mu.Lock()
s.finished = true
if s.proc.err != nil {
s.failure = s.proc.lastWords()
if s.failure == "" {
s.failure = s.proc.err.Error()
}
}
s.mu.Unlock()
if s.failure != "" {
log.Printf("media: stream %s ended early: %s", s.ID, s.failure)
}
}
func (m *Streamer) Find(id string) (*Stream, bool) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.streams[id]
return s, ok
}
// Close ends a stream and removes its folder.
func (m *Streamer) Close(id string) {
m.mu.Lock()
s, ok := m.streams[id]
m.mu.Unlock()
if !ok {
return
}
s.mu.Lock()
p := s.proc
s.mu.Unlock()
if p != nil {
p.end()
}
m.forget(s)
}
func (m *Streamer) forget(s *Stream) {
m.mu.Lock()
delete(m.streams, s.ID)
m.mu.Unlock()
s.mu.Lock()
s.gone = true
s.mu.Unlock()
_ = os.RemoveAll(s.dir)
}
func (s *Stream) closed() bool { s.mu.Lock(); defer s.mu.Unlock(); return s.gone }
func (s *Stream) failed() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.finished && s.failure != ""
}
// Sweep closes what nobody has touched for a while.
func (m *Streamer) Sweep(idle time.Duration) {
m.mu.Lock()
var old []string
for id, s := range m.streams {
s.mu.Lock()
if time.Since(s.used) > idle {
old = append(old, id)
}
s.mu.Unlock()
}
m.mu.Unlock()
for _, id := range old {
log.Printf("media: stream %s idle, closed", id)
m.Close(id)
}
}
// File is the on-disk path of one of the stream's files — the playlist, the init segment
// or a media segment — and nothing else.
func (s *Stream) File(name string) (string, bool) {
if !streamFile.MatchString(name) {
return "", false
}
s.touch()
return filepath.Join(s.dir, name), true
}
func (s *Stream) touch() { s.mu.Lock(); s.used = time.Now(); s.mu.Unlock() }
func (s *Stream) running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.proc != nil && !s.finished
}
// Status is read from ffmpeg's own progress file and the playlist.
func (s *Stream) Status() StreamStatus {
s.mu.Lock()
st := StreamStatus{Running: s.proc != nil && !s.finished, Done: s.finished && s.failure == "", Error: s.failure}
s.mu.Unlock()
if b, err := os.ReadFile(filepath.Join(s.dir, "progress")); err == nil {
st.Progress, st.Speed = parseProgress(b)
}
if b, err := os.ReadFile(filepath.Join(s.dir, "index.m3u8")); err == nil {
st.Segments = strings.Count(string(b), "#EXTINF")
}
return st
}
// parseProgress takes the last out_time_us and speed from ffmpeg's -progress output.
func parseProgress(b []byte) (seconds, speed float64) {
sc := bufio.NewScanner(strings.NewReader(string(b)))
for sc.Scan() {
k, v, ok := strings.Cut(sc.Text(), "=")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch k {
case "out_time_us":
if us, err := strconv.ParseInt(v, 10, 64); err == nil && us >= 0 {
seconds = float64(us) / 1e6
}
case "speed":
if x, err := strconv.ParseFloat(strings.TrimSuffix(v, "x"), 64); err == nil {
speed = x
}
}
}
return seconds, speed
}
func newID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
-85
View File
@@ -1,85 +0,0 @@
package media
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
// The real thing, when ffmpeg is here: a generated clip goes through as H.264 and comes out
// as a playlist of segments, the status follows it to the end, and closing removes it all.
func TestStreamWithFFmpeg(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("no ffmpeg")
}
c := Probe(context.Background())
if c.Error != "" {
t.Skip(c.Error)
}
if EngineFor(c, "h264") == "" {
t.Skip("nothing here encodes h264")
}
dir := t.TempDir()
clip := filepath.Join(dir, "clip.mp4")
out, err := exec.Command("ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "testsrc=size=640x360:rate=25:duration=9", "-f", "lavfi", "-i", "sine=frequency=440:duration=9",
"-c:v", "mpeg4", "-q:v", "5", "-c:a", "aac", "-shortest", clip).CombinedOutput()
if err != nil {
t.Skipf("could not make a clip: %s", out)
}
src, err := Inspect(context.Background(), c, clip, false)
if err != nil || src.Video == nil || src.Audio == nil || src.Video.Codec != "mpeg4" || src.Duration < 8 {
t.Fatalf("inspect: %+v %v", src, err)
}
m := NewStreamer(dir, "hamlab", 2)
s, err := m.Open(context.Background(), c, false, clip, "h264", "480p", src)
if err != nil {
t.Fatal(err)
}
if s.Width != 640 || s.Height != 360 || s.Engine == "" {
t.Fatalf("stream: %+v", s)
}
if p, ok := s.File("index.m3u8"); !ok {
t.Fatal("no playlist")
} else if !hasSegments(p) {
t.Fatal("Open returned before the first segment")
}
for _, bad := range []string{"../x", "progress", "seg1.m4s", "index.m3u8/", "init.mp4.tmp"} {
if _, ok := s.File(bad); ok {
t.Fatalf("%q must not be served", bad)
}
}
// the same request is the same stream
if again, err := m.Open(context.Background(), c, false, clip, "h264", "480p", src); err != nil || again.ID != s.ID {
t.Fatal("not reused")
}
var st StreamStatus
for i := 0; i < 120; i++ {
st = s.Status()
if st.Done || st.Error != "" {
break
}
time.Sleep(250 * time.Millisecond)
}
if !st.Done || st.Error != "" || st.Segments < 2 || st.Progress < 8 {
t.Fatalf("status at the end: %+v", st)
}
if init, _ := s.File("init.mp4"); !exists(init) {
t.Fatal("no init segment")
}
m.Close(s.ID)
if _, ok := m.Find(s.ID); ok || exists(s.dir) {
t.Fatal("close must forget and remove")
}
// an unplayable source is a DecodeError, and leaves nothing behind
bad := filepath.Join(dir, "bad.mkv")
_ = os.WriteFile(bad, []byte("not a video"), 0o644)
if _, err := Inspect(context.Background(), c, bad, false); err == nil {
t.Fatal("inspect of garbage")
}
if left, _ := os.ReadDir(m.dir); len(left) != 0 {
t.Fatalf("left behind: %v", left)
}
}
+36 -2
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"math"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@@ -25,6 +26,16 @@ type Track struct {
ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR
Channels int `json:"channels,omitempty"` Channels int `json:"channels,omitempty"`
AttachedPic bool `json:"attached_pic,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. // Info is what a file holds: the picture to take, the sound to go with it, and the whole.
@@ -37,7 +48,12 @@ type Info struct {
Audio *Track `json:"audio,omitempty"` // the first sound stream Audio *Track `json:"audio,omitempty"` // the first sound stream
} }
// HDR reports a picture that needs tone mapping to look right on an SDR screen. // 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 { func (i Info) HDR() bool {
return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67") return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67")
} }
@@ -58,6 +74,11 @@ type probeJSON struct {
Disposition struct { Disposition struct {
AttachedPic int `json:"attached_pic"` AttachedPic int `json:"attached_pic"`
} `json:"disposition"` } `json:"disposition"`
SideData []struct {
Type string `json:"side_data_type"`
Projection string `json:"projection"`
Rotation float64 `json:"rotation"`
} `json:"side_data_list"`
} `json:"streams"` } `json:"streams"`
Format struct { Format struct {
FormatName string `json:"format_name"` FormatName string `json:"format_name"`
@@ -74,7 +95,7 @@ func Inspect(ctx context.Context, c Caps, path string, root bool) (Info, error)
return Info{}, errors.New("ffprobe is not installed") return Info{}, errors.New("ffprobe is not installed")
} }
argv := []string{c.FFprobe, "-v", "error", "-show_entries", 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:format=format_name,duration,bit_rate,size", "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} "-of", "json", path}
if root { if root {
argv = priv.Wrap(argv...) argv = priv.Wrap(argv...)
@@ -105,6 +126,19 @@ func Inspect(ctx context.Context, c Caps, path string, root bool) (Info, error)
for _, s := range pj.Streams { 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, 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} 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) st.FPS = fps(s.AvgFrameRate)
if st.FPS == 0 { if st.FPS == 0 {
st.FPS = fps(s.RFrameRate) st.FPS = fps(s.RFrameRate)
+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)
}
}
+24 -1
View File
@@ -62,6 +62,24 @@ type Caps struct {
type Opts struct { type Opts struct {
Size int // the long edge in pixels; 256 when zero 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 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 { func (o Opts) defaults() Opts {
@@ -81,6 +99,8 @@ type Result struct {
Codec string `json:"codec"` Codec string `json:"codec"`
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` 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. // ErrUnsupported: not an image or a video by its name.
@@ -356,7 +376,10 @@ func (a attempt) engine() string {
// Make produces the preview of one file. // Make produces the preview of one file.
func Make(ctx context.Context, c Caps, path string, o Opts) (Result, error) { 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 == "" { if kind == "" {
return Result{}, ErrUnsupported return Result{}, ErrUnsupported
} }
+45
View File
@@ -122,9 +122,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.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 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. // 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 { func avgBy(b []Sample, of func(Sample) map[string][]float64) map[string][]float64 {
sums := map[string][]float64{} sums := map[string][]float64{}
+143 -9
View File
@@ -70,7 +70,7 @@ type GPUClient struct {
// gpuDrivers are DRM drivers that drive a GPU, not just a scanout (simpledrm, ast, the // 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. // 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} "asahi": true, "panfrost": true, "panthor": true, "v3d": true, "msm": true, "etnaviv": true, "lima": true, "powervr": true, "virtio_gpu": true}
type gpuDev struct { type gpuDev struct {
@@ -78,14 +78,15 @@ type gpuDev struct {
integrated, stalled bool integrated, stalled bool
prevIdle, prevEnergy float64 prevIdle, prevEnergy float64
prevAt time.Time prevAt time.Time
nv []GPUClient // nvidia: the process table from the last read (pmon), fdinfo says nothing there
} }
type gpuState struct { type gpuState struct {
devs []*gpuDev devs []*gpuDev
listedAt time.Time listedAt time.Time
clients map[string]clientPrev // device key + client id → engine totals at the previous scan clients map[string]clientPrev // device key + client id → engine totals at the previous scan
last []drmFD // the newest client walk, reused between walks last []drmFD // the newest client walk
scanAt time.Time scan func() ([]drmFD, bool) // the walk; nil is scanClients (tests stand a fake list in)
helper *drmHelper helper *drmHelper
ok bool // the last walk saw every process ok bool // the last walk saw every process
} }
@@ -95,8 +96,7 @@ type clientPrev struct {
at time.Time at time.Time
} }
const gpuRelist = time.Minute // how often the card list is re-read: hotplug is rare, the readdir is cheap 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 // gpus is the GPU section of a sample. cpuTemp stands in for an integrated GPU's
// temperature: it is the same die. // temperature: it is the same die.
@@ -111,10 +111,13 @@ func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
if g.clients == nil { if g.clients == nil {
g.clients = map[string]clientPrev{} g.clients = map[string]clientPrev{}
} }
if g.scanAt.IsZero() || now.Sub(g.scanAt) >= gpuScanEvery { // The clients are read on every sample, like everything else in it: their engine shares
g.last, g.ok = c.scanClients() // move on the same tick as the device's own numbers, at whatever rate is set.
g.scanAt = now scan := c.scanClients
if g.scan != nil {
scan = g.scan
} }
g.last, g.ok = scan()
out := make([]GPU, 0, len(g.devs)) out := make([]GPU, 0, len(g.devs))
live := map[string]bool{} live := map[string]bool{}
for _, d := range g.devs { for _, d := range g.devs {
@@ -129,7 +132,13 @@ func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
var order []string var order []string
engines := map[string]float64{} engines := map[string]float64{}
var resident uint64 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 { for _, f := range g.last {
if d.driver == "nvidia" {
break
}
fi := parseFdinfo(f.Info) fi := parseFdinfo(f.Info)
if fi.pdev != "" && fi.pdev != d.bus || fi.pdev == "" && fi.driver != d.driver { if fi.pdev != "" && fi.pdev != d.bus || fi.pdev == "" && fi.driver != d.driver {
continue continue
@@ -368,6 +377,8 @@ func (d *gpuDev) readWithin(x *GPU, now time.Time) {
func (d *gpuDev) read(x *GPU, now time.Time) { func (d *gpuDev) read(x *GPU, now time.Time) {
dt := now.Sub(d.prevAt).Seconds() dt := now.Sub(d.prevAt).Seconds()
switch d.driver { switch d.driver {
case "nvidia":
d.readNvidia(x)
case "i915": case "i915":
x.MHz = readF(d.card + "/gt_act_freq_mhz") x.MHz = readF(d.card + "/gt_act_freq_mhz")
x.ReqMHz = readF(d.card + "/gt_cur_freq_mhz") x.ReqMHz = readF(d.card + "/gt_cur_freq_mhz")
@@ -703,3 +714,126 @@ func ueventValue(p, key string) string {
} }
return "" 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", "--format=csv,noheader,nounits"); ok {
parseNvidiaQuery(x, out)
}
if out, ok := runOut("nvidia-smi", "pmon", "-c", "1", "-s", "um", "--id="+d.bus); ok {
d.nv = 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.
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
}
}
// 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
}
+49 -2
View File
@@ -61,7 +61,7 @@ func TestGPUClientsShare(t *testing.T) {
c.gpu.clients = map[string]clientPrev{} c.gpu.clients = map[string]clientPrev{}
t0 := time.Now() t0 := time.Now()
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: fdinfoA}} 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) first := c.gpus(t0, 40)
if len(first) != 1 || len(first[0].Clients) != 1 || first[0].Clients[0].Usage != 0 { if len(first) != 1 || len(first[0].Clients) != 1 || first[0].Clients[0].Usage != 0 {
t.Fatalf("first pass: %+v", first) 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-render: 1000000000 ns", "drm-engine-render: 1500000000 ns")
later = replace(later, "drm-engine-video: 2000000000 ns", "drm-engine-video: 4000000000 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.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: later}}
c.gpu.scanAt = t0.Add(time.Second)
got := c.gpus(t0.Add(time.Second), 40) got := c.gpus(t0.Add(time.Second), 40)
cl := got[0].Clients[0] cl := got[0].Clients[0]
if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 { if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 {
@@ -170,3 +169,51 @@ func TestPCIName(t *testing.T) {
t.Errorf("an unknown device should be empty, got %q", d) t.Errorf("an unknown device should be empty, got %q", d)
} }
} }
func TestParseNvidiaQuery(t *testing.T) {
var g GPU
parseNvidiaQuery(&g, "31, 0, 12, 1488, 2048, 949, 1097, 57, 23.41, [N/A]\n")
if g.Usage != 31 || g.UsageSrc != "busy" || g.Engines["render"] != 31 || g.Engines["video"] != 12 {
t.Errorf("usage: %+v", g)
}
if g.MemUsed != 1488<<20 || g.MemTotal != 2048<<20 || g.MHz != 949 || g.MaxMHz != 1097 {
t.Errorf("mem/clock: %+v", g)
}
if g.Temp != 57 || g.TempSrc != "gpu" || g.PowerW != 23.4 || g.PowerCapW != 0 {
t.Errorf("temp/power (cap must stay 0 for [N/A]): %+v", g)
}
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)
}
}
+4 -1
View File
@@ -150,7 +150,8 @@ type Sample struct {
FS []FS `json:"fs"` FS []FS `json:"fs"`
UPS *UPS `json:"ups"` UPS *UPS `json:"ups"`
GPUs []GPU `json:"gpus"` GPUs []GPU `json:"gpus"`
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module 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"` Uptime float64 `json:"uptime"`
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
@@ -182,6 +183,7 @@ type Collector struct {
gpu gpuState gpu gpuState
power cpuPowerState power cpuPowerState
prevARC arcPrev prevARC arcPrev
prevZW zfsWritePrev
subMu sync.Mutex subMu sync.Mutex
subs map[chan struct{}]struct{} // told after every sample; see Subscribe subs map[chan struct{}]struct{} // told after every sample; see Subscribe
@@ -368,6 +370,7 @@ func (c *Collector) sample() {
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli() s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
s.GPUs = c.gpus(now, cpuTemp(s.Temps)) s.GPUs = c.gpus(now, cpuTemp(s.Temps))
s.ARC = c.arc(now) s.ARC = c.arc(now)
s.ZFSWrite = c.zfsWrites(now, s.ARC, s.Disks)
if first { if first {
return // deltas are meaningless on the priming pass return // deltas are meaningless on the priming pass
+26
View File
@@ -203,6 +203,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 { if u := s.UPS; u != nil {
ut := s.upsT ut := s.upsT
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model) f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
+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. // 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. // 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) { 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) n, ok := m.node(name)
if !ok { 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)) req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(n.URL, "/")+path, bytes.NewReader(body))
if err != nil { if err != nil {
return 0, nil, err return 0, nil, nil, err
} }
for k, v := range hdr { for k, v := range hdr {
req.Header[k] = v 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) req.Header.Set("Authorization", "Bearer "+n.Token)
resp, err := m.client(n).Do(req) resp, err := m.client(n).Do(req)
if err != nil { if err != nil {
return 0, nil, errors.New(shortErr(err)) return 0, nil, nil, errors.New(shortErr(err))
} }
defer resp.Body.Close() defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) 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. // 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)
}
}
+13 -7
View File
@@ -68,13 +68,19 @@ type State struct {
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an // Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
// orchestrator that has since been removed can still be found on disk. See // orchestrator that has since been removed can still be found on disk. See
// internal/api/stacks.go. // internal/api/stacks.go.
Mounts []RememberedMount `json:"mounts,omitempty"` Mounts []RememberedMount `json:"mounts,omitempty"`
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
// the next browser — or this one after it has forgotten them — is signed into the device HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
// already. See internal/api/kvm.go. // Transcode is where videos played in the Files viewer are made (internal/api/play.go):
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"` // a machine and a device on it. nil is the machine the file is on, its best device.
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate Transcode *Transcode `json:"transcode,omitempty"`
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one }
// 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. // UserState returns the prefs of an account, making an empty one if needed.
+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') 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' }) 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 fails = []
const check = (ok, what, detail) => { if (!ok) fails.push(what + (detail !== undefined ? ' — ' + JSON.stringify(detail).slice(0, 200) : '')) } 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(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') 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) } if (fails.length) { console.error('graphcols: FAIL\n ' + fails.join('\n ')); process.exit(1) }
console.log('graphcols: ok') console.log('graphcols: ok')
+11 -9
View File
@@ -402,7 +402,7 @@ export default function Docker() {
const rate = useRefreshMs() 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([])) }, []) 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]) 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 sensors = live.data?.c ?? {}
const byStack = useMemo(() => { const byStack = useMemo(() => {
const f = filter.trim().toLowerCase(); const out = new Map<string, DockerContainer[]>() 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]) 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 // 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. // 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 [upd, setUpd] = useState<MachineUpdates | null>(null)
const readUpd = useCallback(() => { api.machineUpdates().then(setUpd).catch(() => {}) }, [])
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, []) 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 imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div> if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
@@ -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>))} {shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />} {making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />} {shownTab === '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> </div>
) )
} }
@@ -522,7 +525,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
} }
const run = async (j: UpdateJob) => { 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 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) => { 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 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. // machine at once.
const [queue, setQueue] = useState<string[]>([]) const [queue, setQueue] = useState<string[]>([])
const [running, setRunning] = useState<{ stack: string; id: string } | null>(null) 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 stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd])
const startNext = useCallback(async (rest: string[]) => { const startNext = useCallback(async (rest: string[]) => {
const next = rest[0] const next = rest[0]
@@ -548,7 +550,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
const uniq = [...new Set(names)] const uniq = [...new Set(names)]
if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return } 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 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)))] const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))]
return ( return (
@@ -567,9 +569,9 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
{imgs.filter((i) => i.state !== 'current').map((i) => ( {imgs.filter((i) => i.state !== 'current').map((i) => (
<div className="row" key={i.image}> <div className="row" key={i.image}>
<span className="k mono small">{i.image}</span> <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> <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'} <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> 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 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> <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) => { <TaskOutput id={running.id} onDone={(t) => {
if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack]) if (t.exit_code !== 0 || t.error) toast(`${running.stack}: update failed`, 'err')
else toast(`${running.stack}: update failed`, 'err') api.machineUpdates().then(onChecked).catch(() => {}) // an image now held drops off the list
startNext(queue) startNext(queue)
}} /> }} />
</div> </div>
+63 -8
View File
@@ -1,4 +1,4 @@
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DragEvent, PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api' import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
import { fmtBytes } from './fmt' import { fmtBytes } from './fmt'
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui' import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
@@ -22,6 +22,7 @@ export function resolvePath(input: string, here: string): string {
} }
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 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 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 shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
const MAX_EDIT = 2 * 1024 * 1024 const MAX_EDIT = 2 * 1024 * 1024
@@ -198,7 +199,9 @@ function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: bool
} }
// ---- editor / viewer ---- // ---- 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 [info, setInfo] = useState<FileInfo | null>(null)
const [text, setText] = useState<string | null>(null) const [text, setText] = useState<string | null>(null)
const [orig, setOrig] = useState('') const [orig, setOrig] = useState('')
@@ -251,10 +254,49 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
}, [dirty, name]) }, [dirty, name])
const dl = api.fileDownloadURL(path, sudo) 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 ( return (
<div className="viewer"> <div className="viewer">
<div className="viewer-bar"> <div className="viewer-bar">
<button className="btn" onClick={close}>‹ Back</button> <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="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
<span className="spacer" /> <span className="spacer" />
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>} {info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
@@ -266,11 +308,17 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
{info && text !== null && ( {info && text !== null && (
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} /> <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) && (
{/* Video: the browser's own decoder first, and a re-encode on the machine when that fails. Audio as it is. */} <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 }}>
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />} <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 && ( {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 preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div> <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 && ( {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> <div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
@@ -307,6 +355,9 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
const [thumbs, setThumbs] = useState<ThumbStatus | null>(null) const [thumbs, setThumbs] = useState<ThumbStatus | null>(null)
const [listEl, setListEl] = useState<HTMLDivElement | null>(null) const [listEl, setListEl] = useState<HTMLDivElement | null>(null)
const near = useMemo(() => (listEl && previews ? new Near(listEl) : null), [listEl, previews]) 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]) 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(() => { try { localStorage.setItem(PREVIEWS_KEY, previews ? '1' : '0') } catch { /* private mode */ } }, [previews])
useEffect(() => { if (previews) readThumbs(); else setThumbs(null) }, [previews, readThumbs]) useEffect(() => { if (previews) readThumbs(); else setThumbs(null) }, [previews, readThumbs])
@@ -396,7 +447,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
if (files.length) uploadFiles(files) if (files.length) uploadFiles(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 ( return (
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}> <div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
@@ -409,7 +464,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
<div className="files-tools"> <div className="files-tools">
<button className={'btn' + (sudo ? ' on' : '')} title="browse as root" onClick={() => setSudo(!sudo)}>{sudo ? 'root' : 'sudo'}</button> <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' + (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> <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)} /> <input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn" onClick={mkdir}>+ folder</button> <button className="btn" onClick={mkdir}>+ folder</button>
+2 -2
View File
@@ -37,8 +37,8 @@ export default function Gallery() {
const rate = useRefreshMs() const rate = useRefreshMs()
const [find, setFind] = useState('') const [find, setFind] = useState('')
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, []) 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. // Values only, at the rate: the catalog already holds everything that doesn't change.
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: Math.max(1000, rate) }) const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: rate })
const now = Date.now() const now = Date.now()
const sensors = useMemo(() => { const sensors = useMemo(() => {
const v = live.data?.[0] const v = live.data?.[0]
-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 // 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. // the page is a read and asks again only now and then.
useEffect(() => { 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(() => { const t = setInterval(() => {
if (document.visibilityState !== 'visible') return if (document.visibilityState !== 'visible') return
if (running) api.speedtest().then((r) => setD((old) => (old ? { ...old, run: r } : old))).catch(() => {}) 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 const phase = run?.phase
useEffect(() => { if (phase === 'done') load() }, [phase, load]) 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 sens = live.data?.n ?? {}
const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value
+389 -118
View File
@@ -1,145 +1,416 @@
// A video in the Files viewer. First the file as it is, handed to the browser's own // A video in the Files viewer, YouTube's way: Auto, which reads the connection and picks the
// decoder; when that fails — Safari and an MKV, HEVC without a hardware decoder — a // quality (hls.js), any quality by hand, or Original — the file as it is, for a browser that
// picker: codec and size, and the machine re-encodes it to HLS on its own GPU (or CPU), // decodes it. The qualities are made on the fly, from wherever the viewer seeks to, on the device
// which plays natively on Safari and through hls.js everywhere else. The same picker is // the Transcode device setting names (Settings), which may be on another machine than the file.
// one tap away even when the file plays, for a 36 Mbps capture over a slow link. // 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 { useCallback, useEffect, useRef, useState } from 'react'
import { api, apiPath, PlayCaps, PlayStatus, PlayStream } from './api' import Hls from 'hls.js'
import { Btn, Spinner } from './ui' import { api, nodeAPI, type PlayOpen, type PlayStatus } from './api'
import { canMotion, Sphere } from './vr'
type Mode = 'native' | 'pick' | 'hls' type Choice = 'auto' | 'original' | number // a number is a width: the rung's name
const CODEC_KEY = 'hamlab.play.codec'
const QUALITY_KEY = 'hamlab.play.quality'
const remember = (k: string, v: string) => { try { localStorage.setItem(k, v) } catch { /* private mode */ } }
const recall = (k: string, d: string) => { try { return localStorage.getItem(k) || d } catch { return d } }
const fmtMbps = (bps?: number) => (bps ? `${(bps / 1e6).toFixed(bps >= 10e6 ? 0 : 1)} Mbps` : '') // The bandwidth hls.js measured last time, so the next video starts near the right quality
const describe = (s: PlayStream) => { // rather than at the bottom.
const v = s.source.video const BW_KEY = 'hamlab.play.bw'
const src = v ? `${v.codec}${v.profile ? ' ' + v.profile : ''} ${v.width}×${v.height}${v.fps ? ` ${Math.round(v.fps)} fps` : ''}${s.source.bit_rate ? ` ${fmtMbps(s.source.bit_rate)}` : ''} in ${s.source.container.split(',')[0]}` : s.source.container const savedBW = () => { try { const n = Number(localStorage.getItem(BW_KEY)); return n > 0 ? n : 0 } catch { return 0 } }
const out = s.codec === 'copy' ? 'repackaged' : `${s.codec === 'h264' ? 'H.264' : s.codec.toUpperCase()} ${s.width}×${s.height} ${(s.kbps / 1000).toFixed(s.kbps >= 10000 ? 0 : 1)} Mbps` const saveBW = (n: number) => { try { if (n > 0) localStorage.setItem(BW_KEY, String(Math.round(n))) } catch { /* private mode */ } }
return `${src} → ${out} on ${s.engine}`
// 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 }
}
} }
export default function Player({ path, sudo, name }: { path: string; sudo: boolean; name: string }) { // Whether the browser lets a page set the volume level (iOS doesn't: only mute).
const [mode, setMode] = useState<Mode>('native') const canLevel = (() => { try { const a = document.createElement('audio'); a.volume = 0.5; return a.volume === 0.5 } catch { return false } })()
const [why, setWhy] = useState('') // what brought the picker up
const [caps, setCaps] = useState<PlayCaps | null>(null) const mbps = (b: number) => `${b >= 10e6 ? Math.round(b / 1e6) : (b / 1e6).toFixed(1)} Mb/s`
const [codec, setCodec] = useState(() => recall(CODEC_KEY, 'h264')) 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 [quality, setQuality] = useState(() => recall(QUALITY_KEY, '1080p')) const size = (w?: number, h?: number, vr?: boolean) => {
const [stream, setStream] = useState<{ s: PlayStream; url: string } | null>(null) if (!w || !h) return ''
const [status, setStatus] = useState<PlayStatus | null>(null) 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 [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 video = useRef<HTMLVideoElement>(null)
const streamRef = useRef(stream) const canvas = useRef<HTMLCanvasElement>(null)
streamRef.current = stream 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
useEffect(() => { setMode('native'); setWhy(''); setStream(null); setStatus(null); setErr('') }, [path, sudo]) // Open the play; let it go when this player does, and when the page goes, by a beacon.
useEffect(() => { remember(CODEC_KEY, codec) }, [codec])
useEffect(() => { remember(QUALITY_KEY, quality) }, [quality])
useEffect(() => { if (mode === 'pick' && !caps) api.playCaps(sudo).then(setCaps).catch((e) => setCaps({ codecs: [], qualities: [], error: e instanceof Error ? e.message : 'failed' })) }, [mode, caps, sudo])
// a stream is the machine's work: let go of it when this player does — and when the page
// itself goes, by a beacon, the one request a browser still sends on its way out
const stop = useCallback(() => { const s = streamRef.current; if (s) { api.playStop(s.s.id).catch(() => { /* gone already */ }); setStream(null); setStatus(null) } }, [])
useEffect(() => () => stop(), [stop])
useEffect(() => { useEffect(() => {
const h = () => { const s = streamRef.current; if (s) navigator.sendBeacon(apiPath(`/api/files/play/${s.s.id}/close`)) } 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) window.addEventListener('pagehide', h)
return () => window.removeEventListener('pagehide', h) return () => window.removeEventListener('pagehide', h)
}, []) }, [])
const start = async () => { const info = open?.play.info
setErr('') const vr = !!info?.video?.spherical
try {
stop()
const r = await api.playStart(path, codec, quality, sudo)
setStream({ s: r.stream, url: r.url }); setMode('hls')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
// feed the stream to the element: Safari plays HLS itself, the rest through hls.js 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(() => { useEffect(() => {
const v = video.current const v = video.current
if (mode !== 'hls' || !stream || !v) return if (!open || !v) return
const url = apiPath(stream.url) const { at, play } = resume.current
let hls: { destroy: () => void } | null = null const start = () => { if (at) v.currentTime = at; if (play) v.play().catch(() => setPaused(true)) }
let gone = false if (choice === 'original') {
;(async () => { v.src = api.fileReadURL(path, sudo)
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.addEventListener('loadedmetadata', start, { once: true })
v.src = url setShowing(info?.video?.width ?? 0)
} else { const bad = () => {
const { default: Hls } = await import('hls.js') resume.current = { at: v.currentTime || at, play: true }
if (gone) return setNote('This browser can’t play the file as it is. Back on Auto.')
if (!Hls.isSupported()) { setErr('this browser has neither HLS nor MediaSource — nothing can play here'); return } setChoice('auto')
const h = new Hls({ maxBufferLength: 60, backBufferLength: 30 })
hls = h
h.on(Hls.Events.ERROR, (_e, d) => { if (d.fatal) setErr(`playback failed: ${d.details}`) })
h.loadSource(url)
h.attachMedia(v)
} }
v.play().catch(() => { /* needs a tap on some phones */ }) v.addEventListener('error', bad, { once: true })
})() return () => { v.removeEventListener('error', bad); v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
return () => { gone = true; hls?.destroy() } }
}, [mode, stream]) 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
// follow the re-encode while it runs // A hand-picked quality, or back to Auto, while hls.js is already playing.
useEffect(() => { useEffect(() => {
if (mode !== 'hls' || !stream) return const h = hls.current
let stopPolling = false if (!h || choice === 'original' || !h.levels.length) return
const tick = async () => { try { const st = await api.playStatus(stream.s.id); if (!stopPolling) { setStatus(st); if (st.running) setTimeout(tick, 2000) } } catch { /* ended */ } } if (choice === 'auto') { h.currentLevel = -1; return }
tick() const i = h.levels.findIndex((l) => l.width === choice)
return () => { stopPolling = true } if (i >= 0) h.currentLevel = i
}, [mode, stream]) }, [choice])
const pick = (reason: string) => { setWhy(reason); setMode('pick') } // What the machine is doing, while the menu is open.
const codecs = caps?.codecs ?? [] 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 ( return (
<div className="player"> <div ref={box} tabIndex={0} onKeyDown={onKey} className={`vp${chrome ? '' : ' bare'}${full ? ' max' : ''}`}
{mode === 'native' && ( 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}>
<div className="play-bar"><span>as it is</span><span className="spacer" /><button className="btn" onClick={() => pick('')}>Re-encode…</button></div> <video ref={video} className={vr ? 'vp-v vp-hidden' : 'vp-v'} playsInline preload="auto"
<div className="preview"><video controls playsInline preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => pick(`This browser can't play ${name} as it is — the container or codec isn't one it decodes.`)} /></div> 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) => {
{mode === 'pick' && ( const el = e.currentTarget
<div className="play-pick notice"> setTime(el.currentTime)
<div>{why || `Re-encode ${name} on this machine and play that instead.`}</div> resume.current = { at: el.currentTime, play: !el.paused }
{caps?.error && <div className="error" style={{ marginTop: 8 }}>{caps.error}</div>} for (let i = 0; i < el.buffered.length; i++) if (el.buffered.start(i) <= el.currentTime + 0.5) setBuffered(el.buffered.end(i))
{!caps && !err && <div className="muted small" style={{ marginTop: 8 }}><Spinner /> asking the machine what it can encode…</div>} }}
{caps && !caps.error && ( onClick={vr ? undefined : () => { if (matchMedia('(pointer: coarse)').matches) { if (chrome) setChrome(false); else poke() } else toggle() }}
<div className="row"> onDoubleClick={vr ? undefined : fullscreen} />
<label>codec {vr && <canvas ref={canvas} className="vp-v vp-sphere" onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onDoubleClick={fullscreen} />}
<select value={codec} onChange={(e) => setCodec(e.target.value)}>
{codecs.map((c) => <option key={c.id} value={c.id} disabled={!c.engine}>{c.label}{c.engine ? ` — ${c.engine}` : ' — not on this machine'}</option>)} {(waiting || !open) && !err && <div className="vp-spin" aria-label="Loading" />}
</select> {err && <div className="vp-err"><p>{err}</p></div>}
</label> {open && paused && !waiting && !err && <button className="vp-big" onClick={toggle} aria-label="Play"><I d={PLAY} size={36} fill /></button>}
<label>size {note && <div className="vp-note" role="status" onClick={() => setNote('')}>{note}</div>}
<select value={quality} onChange={(e) => setQuality(e.target.value)} disabled={codec === 'copy'}> {vr && open && chrome && !menu && time < 8 && !err && <div className="vp-hint">Drag to look around · scroll or pinch to zoom</div>}
{caps.qualities.map((q) => <option key={q.id} value={q.id}>{q.label}</option>)}
</select> <div className="vp-bottom">
</label> <span className="vp-time above">{clock(shown)} / {clock(dur)}</span>
<Btn className="btn on" disabled={!codecs.find((c) => c.id === codec)?.engine} onClick={start}>Play</Btn> <div className="vp-seek" style={{ ['--p' as string]: dur ? `${(shown / dur) * 100}%` : '0%', ['--b' as string]: dur ? `${(buffered / dur) * 100}%` : '0%' }}>
<a className="btn" href={api.fileDownloadURL(path, sudo)} download={name}>Download instead</a> <input ref={seekBar} type="range" min={0} max={dur || 1} step={0.1} value={shown} aria-label="Seek"
</div> onChange={(e) => { setScrub(Number(e.target.value)); poke() }} />
)}
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
</div> </div>
)} <div className="vp-row">
{mode === 'hls' && stream && ( <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>
<div className="play-bar"> <button className="vp-icon" onClick={() => seekBy(5)} aria-label="On 5 seconds" title="On 5 seconds"><Skip /></button>
<span title={describe(stream.s)}>{describe(stream.s)}</span> <span className="vp-vol">
{status && status.running && <span>· {status.speed ? `${status.speed.toFixed(1)}×` : ''} {stream.s.source.duration ? `${Math.min(100, Math.round(status.progress / stream.s.source.duration * 100))}% through` : `${Math.round(status.progress)} s done`} — seek within what's done</span>} <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>
{status?.done && <span>· all of it is ready</span>} {canLevel && <input type="range" min={0} max={1} step={0.05} value={vol.muted ? 0 : vol.level} aria-label="Volume"
{status?.error && <span className="error">· stopped early: {status.error}</span>} style={{ ['--v' as string]: `${(vol.muted ? 0 : vol.level) * 100}%` }}
<span className="spacer" /> onChange={(e) => { const level = Number(e.target.value); setVol({ level, muted: level === 0 }); poke() }} />}
<button className="btn" onClick={() => { stop(); setMode('pick'); setWhy('') }}>Change…</button> </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 className="preview"><video ref={video} controls autoPlay playsInline /></div> </div>
{err && <div className="error" style={{ margin: 12 }}>{err}</div>}
</>
)} )}
</div> </div>
) )
+59 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { fetchCatalog } from './sensors' import { fetchCatalog } from './sensors'
import { api, Me, TokenInfo, UpdateStatus } from './api' import { api, Me, TokenInfo, TranscodeSetting, UpdateStatus } from './api'
import { savePrefs } from './accents' import { savePrefs } from './accents'
import { applyTheme, currentThemeId, THEMES } from './themes' import { applyTheme, currentThemeId, THEMES } from './themes'
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui' import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
@@ -104,6 +104,7 @@ export default function Settings({ me }: { me: Me }) {
</div> </div>
</section> </section>
<TranscodeDevice />
<div className="dash-bar"><h2 className="section">Access tokens</h2></div> <div className="dash-bar"><h2 className="section">Access tokens</h2></div>
<section className="card"> <section className="card">
@@ -248,3 +249,60 @@ function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { n
</div> </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 Network from './Network'
import VPN from './VPN' import VPN from './VPN'
import ZFS from './ZFS' import ZFS from './ZFS'
import Virtualization from './Virtualization'
import KVM from './KVM'
import Files from './Files' import Files from './Files'
import Top from './Top' import Top from './Top'
import Machines from './Machines' import Machines from './Machines'
@@ -82,8 +80,6 @@ const GROUPS: Group[] = [
{ id: 'usage', label: 'Usage', icon: '▧' }, { id: 'usage', label: 'Usage', icon: '▧' },
{ id: 'zfs', label: 'ZFS', icon: '⛁' }, { id: 'zfs', label: 'ZFS', icon: '⛁' },
{ id: 'docker', label: 'Docker', icon: DockerIcon }, { id: 'docker', label: 'Docker', icon: DockerIcon },
{ id: 'virt', label: 'Virtualization', icon: '▣' },
{ id: 'kvm', label: 'KVM', icon: '⌨' },
{ id: 'hardware', label: 'Hardware', icon: '⬡' }, { id: 'hardware', label: 'Hardware', icon: '⬡' },
{ id: 'updates', label: 'Updates', icon: '⇪' }, { id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'alerts', label: 'Alerts', icon: BellIcon }, { id: 'alerts', label: 'Alerts', icon: BellIcon },
@@ -93,8 +89,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' }, { 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' 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', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and // URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
@@ -326,12 +322,10 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}> <PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
{view === 'settings' && <Settings me={hubMe} />} {view === 'settings' && <Settings me={hubMe} />}
{view === 'users' && <Users me={hubMe} />} {view === 'users' && <Users me={hubMe} />}
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
{view === 'usage' && <Usage key={machine ?? ''} />} {view === 'usage' && <Usage key={machine ?? ''} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />} {view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
{view === 'widgets' && <Gallery />} {view === 'widgets' && <Gallery />}
{view === 'docker' && <Docker />} {view === 'docker' && <Docker />}
{view === 'virt' && <Virtualization />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
{view === 'procs' && <Top />} {view === 'procs' && <Top />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
@@ -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>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === '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> </footer>
</div> </div>
) )
+68 -33
View File
@@ -3,14 +3,14 @@
// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and // 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 // /api/procs for the table — so this works on any machine the console can see, whether or not
// btop is installed on it. // btop is installed on it.
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { api, GPU, ProcTable, Proc, Sample } from './api' import { api, GPU, ProcTable, Proc, Sample } from './api'
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter' import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors' import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs' import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { Loading, askYes, toast } from './ui' import { Loading, askYes, toast } from './ui'
import { Theme, currentThemeId, themeById } from './themes' import { Theme, currentThemeId, themeById } from './themes'
import { columnsOf } from './graphcols' import { columnsAt } from './graphcols'
import { savePrefs } from './accents' import { savePrefs } from './accents'
const MAX = 300 // points kept per curve, as the machines keep them const MAX = 300 // points kept per curve, as the machines keep them
@@ -68,7 +68,7 @@ function at(g: readonly string[], t: number): string {
// ---- the dot graph ---- // ---- the dot graph ----
// One dot column per sample, filled from the bottom, each dot coloured by how high it sits. // 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 ref = useRef<HTMLCanvasElement>(null)
const box = useRef<HTMLDivElement>(null) const box = useRef<HTMLDivElement>(null)
const pal = useContext(Pal) const pal = useContext(Pal)
@@ -79,7 +79,8 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
ro.observe(box.current) ro.observe(box.current)
return () => ro.disconnect() 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 const c = ref.current
if (!c || w < 4 || height < 4) return if (!c || w < 4 || height < 4) return
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
@@ -91,14 +92,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 // 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 // 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 // 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. // column draws — the peak of its own fixed slice of the history — is graphcols.ts. The
const seen = columnsOf(values, advance, cap, cols) // columns slide by a fraction of one on every tick, so the graph moves with the numbers
const top = max ?? Math.max(1, ...seen) // 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] const stops = pal[grad]
for (let i = 0; i < seen.length; i++) { for (let i = 0; i < seen.length; i++) {
// anything above nothing lights the bottom row: the curve keeps a baseline, as btop's does // 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 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++) { for (let r = 0; r < lit; r++) {
const t = rows > 1 ? r / (rows - 1) : 1 const t = rows > 1 ? r / (rows - 1) : 1
g.fillStyle = at(stops, t) g.fillStyle = at(stops, t)
@@ -106,7 +109,7 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
g.fillRect(x, y, 2, 2) 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> return <div className="tg" ref={box} data-n={values.length}><canvas ref={ref} style={{ width: '100%', height: '100%' }} /></div>
} }
@@ -187,12 +190,17 @@ function braille(values: number[], cells = 7, max = 100): string {
// ---- the curves this page keeps ---- // ---- the curves this page keeps ----
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util,gpu.*.usage' 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) { function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
const cur = useRef<Map<string, number[]>>(new Map()) const cur = useRef<Map<string, number[]>>(new Map())
const lastT = useRef(0) const lastT = useRef(0)
const binN = useRef(1) 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 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 [ticks, bump] = useState(0)
const seed = useCallback(async () => { const seed = useCallback(async () => {
try { try {
@@ -202,21 +210,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: // 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 // measured against them, a viewer whose clock runs minutes slow would wait those minutes
// before its curves gained a single new point. // 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) bump((x) => x + 1)
} catch { /* the curves fill in live instead */ } } 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(() => { 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 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 binN.current = fresh ? 1 : binN.current + 1
lastT.current = start lastT.current = start
if (fresh) adv.current++ 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 const n = binN.current
for (const [id, v] of Object.entries(live)) { for (const [id, v] of Object.entries(live)) {
if (typeof v !== 'number') continue if (typeof v !== 'number') continue
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue
if (id.startsWith('cpu.core.')) {
const r = recent.current.get(id) ?? []
r.push(v); if (r.length > RECENT) r.shift()
recent.current.set(id, r)
}
const arr = cur.current.get(id) ?? [] const arr = cur.current.get(id) ?? []
if (!cur.current.has(id)) cur.current.set(id, arr) if (!cur.current.has(id)) cur.current.set(id, arr)
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() } if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
@@ -224,7 +241,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
} }
bump((x) => x + 1) bump((x) => x + 1)
}, [live, t, refreshMs, historySec]) }, [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 ---- // ---- the page ----
@@ -239,17 +256,43 @@ export default function Top() {
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const stream = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs }) 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)) }, []) 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(() => { useEffect(() => {
if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return
refetching.current = true refetching.current = true
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000)) fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
}, [values]) }, [values])
const s: Sample | null = useMemo(() => (values && ready ? sampleOf(values, catalog.current) : null), [values, ready]) 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 // the process table, and the CPU model, come from the same place the Processes page reads,
const [pt, setPt] = useState<ProcTable | null>(null) // on the sample's beat (above)
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of // 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. // its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
@@ -264,14 +307,6 @@ export default function Top() {
}).catch(() => { /* the rows simply say less */ }) }).catch(() => { /* the rows simply say less */ })
return () => { live = false } 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 // 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 })() const rateIdx = (() => { const i = REFRESH_OPTIONS.findIndex((x) => x >= refreshMs); return i < 0 ? REFRESH_OPTIONS.length - 1 : i })()
@@ -313,7 +348,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') } 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 cpu = s?.cpu ?? pt?.cpu ?? 0
const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? [] const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? []
@@ -365,7 +400,7 @@ export default function Top() {
</>}> </>}>
<div className="t-cpu"> <div className="t-cpu">
<div className="t-cpu-graph"> <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 className="t-up">up {upfor(uptime)}</div>
</div> </div>
<div className="t-cores"> <div className="t-cores">
@@ -382,7 +417,7 @@ export default function Top() {
return ( return (
<div className="t-core" key={i}> <div className="t-core" key={i}>
<span className="t-core-n">C{i}</span> <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-g" title={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> <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></>} {anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
</div> </div>
@@ -397,7 +432,7 @@ export default function Top() {
<div className="top-gpus"> <div className="top-gpus">
{gpus.map(({ g, stem }, i) => ( {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>}> <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> </Box>
))} ))}
</div> </div>
@@ -441,10 +476,10 @@ export default function Top() {
<div className="t-net-graphs"> <div className="t-net-graphs">
<div className="t-net-g"> <div className="t-net-g">
<span className="t-scale">{rate(peak(`net.${segOf(iface.iface)}.rx`))}</span> <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>
<div className="t-net-g"> <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> <span className="t-scale t-scale-b">{rate(peak(`net.${segOf(iface.iface)}.tx`))}</span>
</div> </div>
</div> </div>
@@ -522,7 +557,7 @@ 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 // 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. // 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']] 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 engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : 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' const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts'
@@ -530,7 +565,7 @@ function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; t
return ( return (
<div className="t-gpu"> <div className="t-gpu">
<div className="t-gpu-graph"> <div className="t-gpu-graph">
<Graph values={values} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" /> <Graph values={values} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div> <div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
</div> </div>
<div className="t-gpu-stats"> <div className="t-gpu-stats">
-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 // disagree over. An existing cron job is detected and shown, so the page tells the truth
// about the machine whether Ham Lab or something else is doing the work. // about the machine whether Ham Lab or something else is doing the work.
import { FormEvent, Fragment, useCallback, useEffect, useState } from 'react' import { FormEvent, Fragment, useCallback, useEffect, useState } from 'react'
import { api, ARCView, CacheDev, PoolCaching, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api' import { api, ARCView, CacheDev, Landing, LandingCheck, PoolCaching, PoolWrites, VerifyPolicy, VerifyResult, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot, ZFSWrites } from './api'
import { fmtBytes } from './fmt' import { fmtBytes, fmtRate } from './fmt'
import { useRefreshMs } from './prefs' import { useRefreshMs } from './prefs'
import { fetchHistories } from './sensors' import { fetchHistories } from './sensors'
import Sparkline from './Sparkline' 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() { function ARCSection() {
const [v, setV] = useState<ARCView | null>(null) const [v, setV] = useState<ARCView | null>(null)
const [hist, setHist] = useState<{ size: number[]; hit: number[] }>({ size: [], hit: [] }) const [hist, setHist] = useState<{ size: number[]; hit: number[] }>({ size: [], hit: [] })
const [whist, setWhist] = useState<WHist>({})
const [err, setErr] = useState('') const [err, setErr] = useState('')
const rate = useRefreshMs() const rate = useRefreshMs()
useEffect(() => { useEffect(() => {
let stop = false let stop = false
const poll = async () => { 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 { try {
const hs = await fetchHistories('zfs.arc.size,zfs.arc.hit_pct') const extra = r?.writes ? wIds(r.writes) : []
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 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 */ } } catch { /* the sparklines stay empty; the numbers still show */ }
} }
poll() poll()
const t = setInterval(poll, Math.max(2000, rate)) const t = setInterval(poll, rate)
return () => { stop = true; clearInterval(t) } return () => { stop = true; clearInterval(t) }
}, [rate]) }, [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> <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> </div>
</section> </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">
<div className="dash-bar"><h2 className="section">Pools</h2></div> <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>} {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" /> <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> <span className="muted small">the ARC in RAM, and what each pool has in front of its disks</span>
</div> </div>
<ARCSection /> <ARCSection />
<LandingChecks />
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" /> <div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn> <Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
</div> </div>
{ov.external && <ExternalNotice x={ov.external} mine={mine} onAdopt={() => setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />} {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>} {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)} /> <ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
))}</div> ))}</div>
+62 -65
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, // 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. // the machine list itself) pass local=true and are never rewritten.
let base = '' 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) 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 // A machine that just failed to answer is left alone for a few seconds instead of
@@ -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 SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number } export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
// ---- the Network tab ---- // ---- the Network tab ----
export interface IfAddr { ip: string; prefix: number; v6?: boolean } export interface IfAddr { ip: string; prefix: number; v6?: boolean }
@@ -247,13 +251,51 @@ export interface ARCDetail {
dbuf: { Size: number; Target: number; Hits: number; Misses: number } dbuf: { Size: number; Target: number; Hits: number; Misses: number }
pools: PoolCaching[] 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' | 'audio' } 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 PlayCaps { codecs: { id: string; label: string; engine?: string }[]; qualities: { id: string; label: string; height: number }[]; error?: string } export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number; spherical?: boolean }
export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number } export interface PlayRung { name: string; label: string; width: number; height: number; fps: number; bitrate: number; peak: number }
export interface PlayStream { id: string; codec: string; quality: string; engine: string; width: number; height: number; kbps: number; source: { container: string; duration: number; bit_rate?: number; video?: PlayTrack; audio?: PlayTrack } } export interface PlayStatus { engine?: string; quality?: string; from: number; ahead: number; paused?: boolean }
export interface PlayStatus { running: boolean; done: boolean; error?: string; progress: number; speed: number; segments: number } // 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 } 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. // How previews get made on the machine: ok with an engine, or why not and what would fix it.
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } } export interface 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 } }
@@ -261,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}` const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
// ---- Virtualization: Incus, per machine ----
export interface VirtLayout { driver: string; pool_source: string; pool_size_gib?: number; bridge: string; subnet: string; docker_rules: boolean; arc_max_gib?: number; attempted: string; hamlab_version?: string }
export interface VirtStatus { state: 'ready' | 'repair' | 'installable' | 'unavailable'; reason?: string; kvm: boolean; arch: string; version?: string; pm?: string; drivers: string[]; docker: boolean; zfs_host: boolean; arc_max_gib?: number; mem_total: number; mem_avail: number; disk_free: number; dir: string; subnet: string; pool_size_gib?: number; layout?: VirtLayout; user: string; root: boolean; unit?: string; btrfs_tools: boolean }
export interface VirtSnapshot { name: string; created: string; expires?: string; size?: number }
export interface VirtInstance { name: string; type: 'container' | 'virtual-machine'; status: string; arch: string; description?: string; image: string; os?: string; release?: string; cpu?: string; memory?: string; created: string; last_used: string; ipv4: string[]; snapshots: VirtSnapshot[]; profiles: string[]; ephemeral: boolean; cpu_pct: number; mem_usage: number; mem_peak: number; disk_usage: number; disk_total: number; pid: number; processes: number; config: Record<string, string>; devices: Record<string, Record<string, string>>; schedule?: string; expiry?: string }
export interface VirtImage { fingerprint: string; aliases: string[]; arch: string; type: string; size: number; created: string; last_used: string; description: string; os?: string; release?: string; variant?: string; cached: boolean; source?: string; public: boolean }
export interface VirtPool { name: string; driver: string; source: string; where: string; used: number; total: number; used_by: number }
export interface VirtNetwork { name: string; type: string; managed: boolean; address?: string; nat: boolean; used_by: number }
export interface LibraryFile { name: string; size: number; modified: string; attached_to?: string[] }
export interface ExportConfig { enabled: boolean; hour: number; minute: number; keep: number; instance_only: boolean; instances?: string[]; updated?: string }
export interface ExportRun { at: string; exported: string[]; pruned: string[]; errors?: string[]; seconds: number }
export interface ExportFile { instance: string; name: string; size: number; modified: string }
export interface ExportsView { config: ExportConfig; cron: string; next?: string; last: ExportRun | null; files: ExportFile[]; dir: string; cron_file: string }
export interface FromISO { name: string; iso: string; cpu: string; memory: string; disk: string; pool: string; network: string; secureboot: boolean; start: boolean }
export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; default_pool?: string; networks?: VirtNetwork[]; library: { images: LibraryFile[]; isos: LibraryFile[] } }
export interface VirtInstallOpts { driver: string; pool_size_gib: number; subnet: string; docker_rules: boolean; arc_max_gib: number }
export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; pool: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean }
export const api = { export const api = {
procs: () => req<ProcTable>('GET', '/api/procs'), procs: () => req<ProcTable>('GET', '/api/procs'),
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'), space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
@@ -297,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) } if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
return r.text() 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)}`), fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`),
fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${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 // v carries the file's mtime and size, so a changed file is a new URL and an unchanged one a cached one
@@ -304,11 +336,6 @@ export const api = {
thumbStatus: (sudo: boolean, refresh = false) => req<ThumbStatus>('GET', `/api/files/thumbs?${sudo ? 'sudo=1&' : ''}${refresh ? 'refresh=1' : ''}`), thumbStatus: (sudo: boolean, refresh = false) => req<ThumbStatus>('GET', `/api/files/thumbs?${sudo ? 'sudo=1&' : ''}${refresh ? 'refresh=1' : ''}`),
thumbInstall: () => req<TaskInfo>('POST', '/api/files/thumbs/install'), thumbInstall: () => req<TaskInfo>('POST', '/api/files/thumbs/install'),
thumbGPUAccess: () => req<TaskInfo>('POST', '/api/files/thumbs/gpu-access'), thumbGPUAccess: () => req<TaskInfo>('POST', '/api/files/thumbs/gpu-access'),
// playback of what the browser can't play as it is: an HLS re-encode on the machine
playCaps: (sudo: boolean) => req<PlayCaps>('GET', `/api/files/play-caps${sudo ? '?sudo=1' : ''}`),
playStart: (path: string, codec: string, quality: string, sudo: boolean) => req<{ stream: PlayStream; url: string }>('POST', `/api/files/play${sudo ? '?sudo=1' : ''}`, { path, codec, quality }),
playStatus: (id: string) => req<PlayStatus>('GET', `/api/files/play/${id}`),
playStop: (id: string) => req<void>('DELETE', `/api/files/play/${id}`),
fileWrite: async (path: string, text: string, sudo: boolean) => { fileWrite: async (path: string, text: string, sudo: boolean) => {
const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } }) const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
if (r.status === 401) throw new Unauthenticated() if (r.status === 401) throw new Unauthenticated()
@@ -332,12 +359,13 @@ export const api = {
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'), storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`), smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`), smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
// Always the hub's: it holds every machine's KVM, so the tab works when the machine does not.
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'), zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'), 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), saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`), deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`), runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
@@ -446,37 +474,6 @@ export const api = {
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }), createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }), renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`), deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
virt: () => req<VirtOverview>('GET', '/api/virt'),
virtInstall: (o: VirtInstallOpts) => req<TaskInfo>('POST', '/api/virt/install', o),
virtRemote: (type: string, refresh = false) => req<{ images: VirtImage[] }>('GET', `/api/virt/images/remote?type=${type}${refresh ? '&refresh=1' : ''}`),
virtImageDownload: (alias: string) => req<TaskInfo>('POST', '/api/virt/images/download', { alias }),
virtImageImport: (path: string, alias: string, type: string) => req<TaskInfo>('POST', '/api/virt/images/import', { path, alias, type }),
virtImagePublish: (instance: string, alias: string) => req<TaskInfo>('POST', '/api/virt/images/publish', { instance, alias }),
virtImageDelete: (ref: string) => req<void>('POST', '/api/virt/images/delete', { ref }),
virtCreate: (c: VirtCreate) => req<TaskInfo>('POST', '/api/virt/instances', c),
virtAction: (name: string, action: 'start' | 'stop' | 'restart' | 'kill') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${action}`),
virtDelete: (name: string, force: boolean) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/delete`, { confirm: name, force }),
virtTerminal: (name: string, kind: 'console' | 'exec') => req<{ id: string; name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${kind}`),
virtRename: (name: string, to: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/rename`, { name: to }),
virtConfig: (name: string, cfg: Record<string, string>) => req<void>('PUT', `/api/virt/instances/${encodeURIComponent(name)}/config`, cfg),
virtSnapshot: (name: string, snap: string) => req<{ name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots`, { name: snap }),
virtSnapshotAction: (name: string, snap: string, action: 'restore' | 'delete') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots/${encodeURIComponent(snap)}/${action}`),
virtISOFetch: (url: string, sha256: string, name: string) => req<TaskInfo>('POST', '/api/virt/isos/fetch', { url, sha256, name }),
virtISOImport: (path: string) => req<TaskInfo>('POST', '/api/virt/isos/import', { path }),
virtISODelete: (name: string) => req<void>('POST', '/api/virt/isos/delete', { name }),
virtISORename: (name: string, to: string) => req<void>('POST', '/api/virt/isos/rename', { name, to }),
virtISOAttach: (inst: string, iso: string, bootFirst: boolean) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso`, { iso, boot_first: bootFirst }),
virtISODetach: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso/detach`, { device }),
virtFromISO: (f: FromISO) => req<TaskInfo>('POST', '/api/virt/instances/from-iso', f),
virtMountAdd: (inst: string, m: { device: string; source: string; path: string; readonly: boolean; shift: boolean }) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts`, m),
virtMountRemove: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts/remove`, { device }),
virtExports: () => req<ExportsView>('GET', '/api/virt/exports'),
virtExportsPut: (c: ExportConfig) => req<void>('PUT', '/api/virt/exports', c),
virtExportsRun: (instance = '') => req<TaskInfo>('POST', '/api/virt/exports/run', { instance }),
virtExportDelete: (instance: string, name: string) => req<void>('POST', '/api/virt/exports/delete', { instance, name }),
virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'),
virtPoolCreate: (name: string, path: string) => req<{ name: string; path: string }>('POST', '/api/virt/pools', { name, path }),
virtPoolDelete: (name: string) => req<void>('POST', '/api/virt/pools/delete', { name }),
} }
export function wsURLPath(path: string): string { export function wsURLPath(path: string): string {
+20 -6
View File
@@ -16,26 +16,40 @@
// Returns one value per column, oldest first, right-aligned: a young curve has fewer columns // Returns one value per column, oldest first, right-aligned: a young curve has fewer columns
// than the box and draws in from the right. // than the box and draws in from the right.
export function columnsOf(values: number[], advance: number, cap: number, cols: number): number[] { 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) 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 at = (a: number) => values[values.length - 1 - (advance - a)] // the point numbered a
const oldest = advance - n + 1 const oldest = advance - n + 1
const r = cols / cap
const out: number[] = [] const out: number[] = []
if (cols >= cap) { if (cols >= cap) {
const kLast = Math.ceil(((advance + 1) * cols) / cap) - 1 const kLast = Math.ceil(now * r) - 1
for (let k = kLast - cols + 1; k <= kLast; k++) { for (let k = kLast - cols; k <= kLast; k++) {
const a = Math.floor((k * cap) / cols) const a = Math.floor((k * cap) / cols)
if (a >= oldest && a <= advance) out.push(at(a)) 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) 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) 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 if (a1 < a0) continue
let peak = at(a0) let peak = at(a0)
for (let a = a0 + 1; a <= a1; a++) peak = Math.max(peak, at(a)) for (let a = a0 + 1; a <= a1; a++) peak = Math.max(peak, at(a))
out.push(peak) out.push(peak)
} }
return out return { cols: out, edge: Math.min(1, Math.max(0, now * r - kLast)) || 1, last: kLast }
} }
+91 -46
View File
@@ -5,6 +5,10 @@
--s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18); --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; --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; --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; } * { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; } 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; } .svc-list > .error, .cron-list > .error { margin: 8px 12px; }
/* layout */ /* layout */
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); } .app { height: 100%; display: flex; flex-direction: column; padding-top: 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); } .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; } .brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; } .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; } .dash > * { flex-shrink: 0; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; } .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 { 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, .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 .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; } .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 { 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; } .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.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-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 { 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); } .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; } .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 /* 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. */ 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 { grid-template-columns: 1fr; gap: 10px 0; }
.props.stacked > div { flex-direction: column; gap: 2px; } .props.stacked > div { flex-direction: column; gap: 2px; }
.props.stacked .k { width: auto; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; } .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 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 may shrink, anything with its own scrollbar (a preview, a list) is squeezed to a sliver
instead, and the dialog never scrolls at all. */ 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 { --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; } .modal > * { flex-shrink: 0; }
/* a long dialog's buttons (and what went wrong) stay in reach while the rest scrolls */ /* a long dialog's buttons (and what went wrong) stay in reach while the rest scrolls */
@@ -365,13 +378,53 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; } .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 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 video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
.player { flex: 1; min-height: 0; display: flex; flex-direction: column; } .preview.pic { position: relative; touch-action: pan-y pinch-zoom; }
.player .preview { flex: 1; min-height: 0; } .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; }
.play-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 12px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); } .step-edge:hover { opacity: 1; } .step-edge.prev { left: 8px; } .step-edge.next { right: 8px; }
.play-bar select { padding: 4px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 12px; } .btn.step:disabled { opacity: .4; }
.play-pick { margin: 12px; } /* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
.play-pick .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; } on an iPhone the whole window */
.play-pick label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); } .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; } .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 { 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 .ficon { font-size: 48px; }
@@ -841,6 +894,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 { 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 { 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; } .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: what is using a disk ---- */
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; } .space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
@@ -908,43 +963,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 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; } .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 { margin-left: 6px; font-size: 11px; font-weight: 700; }
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); } .updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
/* ---- Virtualization ---- */ /* a phone: the time goes above the seek bar, so the buttons fit in one row */
.virt .svc-tools .task { max-height: 45vh; } .vp-time.above { display: none; }
.virt-table { width: 100%; border-collapse: collapse; font-size: 13px; } @media (max-width: 560px) {
.virt-table th, .virt-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } .vp-time.above { display: block; padding: 0 10px 2px; }
.virt-table th { font-weight: 500; opacity: .7; } .vp-time.inrow { display: none; }
.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; } /* No theme touches playback, whatever it draws. A theme's full-window overlays (the Retro
.virt-form-row { display: flex; gap: 8px; } scanlines today, any other tomorrow) are off while a player is open; the picture is never
.virt-form-row > label { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } filtered or blended; the player's text is plain, not the theme's lettering. Only the accent
.virt-form-row > label > input, .virt-form-row > label > select { width: 100%; box-sizing: border-box; } colour of its controls follows the theme. */
.virt .modal { max-width: 640px; } html:has(.vp)::before, html:has(.vp)::after, body:has(.vp)::before, body:has(.vp)::after,
.vga-back { align-items: stretch; padding: 10px; } .app:has(.vp)::before, .app:has(.vp)::after, #root:has(.vp)::before, #root:has(.vp)::after { display: none !important; }
.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; } .vp { isolation: isolate; filter: none !important; mix-blend-mode: normal !important; }
.vga-bar { flex-shrink: 0; } .vp video, .vp canvas { filter: none !important; mix-blend-mode: normal !important; }
.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; } .vp { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-weight: 400; letter-spacing: normal; text-shadow: none; }
.vga-area #spice-area { display: inline-block; } .vp button, .vp input, .vp select { font-family: inherit !important; }
.vga-area #spice-screen { display: inline-block; line-height: 0; }
.vga-area canvas { outline: none; } /* volume: the speaker mutes; the slider beside it (not on iOS, which only mutes) sets the level */
.vga-msg { display: none; } .vp-vol { display: inline-flex; align-items: center; }
.vga-hint { flex-shrink: 0; } .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,
};
-94
View File
@@ -1,94 +0,0 @@
"use strict";
/*
Copyright (C) 2016 by Oliver Gutierrez <ogutsua@gmail.com>
Miroslav Chodil <mchodil@redhat.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';
import { DEBUG, arraybuffer_to_str } from './utils.js';
import { SpiceConn } from './spiceconn.js';
import { SpiceMsgPortInit } from './spicemsg.js';
/*----------------------------------------------------------------------------
** SpicePortConn
** Drive the Spice Port Channel
**--------------------------------------------------------------------------*/
function SpicePortConn()
{
DEBUG > 0 && console.log('SPICE port: created SPICE port channel. Args:', arguments);
SpiceConn.apply(this, arguments);
this.portName = null;
}
SpicePortConn.prototype = Object.create(SpiceConn.prototype);
SpicePortConn.prototype.process_channel_message = function(msg)
{
if (msg.type == Constants.SPICE_MSG_PORT_INIT)
{
if (this.portName === null)
{
var m = new SpiceMsgPortInit(msg.data);
this.portName = arraybuffer_to_str(new Uint8Array(m.name));
this.portOpened = m.opened
DEBUG > 0 && console.log('SPICE port: Port', this.portName, 'initialized');
return true;
}
DEBUG > 0 && console.log('SPICE port: Port', this.portName, 'is already initialized.');
}
else if (msg.type == Constants.SPICE_MSG_PORT_EVENT)
{
DEBUG > 0 && console.log('SPICE port: Port event received for', this.portName, msg);
var event = new CustomEvent('spice-port-event', {
detail: {
channel: this,
spiceEvent: new Uint8Array(msg.data)
},
bubbles: true,
cancelable: true
});
window.dispatchEvent(event);
return true;
}
else if (msg.type == Constants.SPICE_MSG_SPICEVMC_DATA)
{
DEBUG > 0 && console.log('SPICE port: Data received in port', this.portName, msg);
var event = new CustomEvent('spice-port-data', {
detail: {
channel: this,
data: msg.data
},
bubbles: true,
cancelable: true
});
window.dispatchEvent(event);
return true;
}
else
{
DEBUG > 0 && console.log('SPICE port: SPICE message type not recognized:', msg)
}
return false;
};
export {
SpicePortConn,
};
File diff suppressed because it is too large Load Diff
-87
View File
@@ -1,87 +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/>.
*/
/*----------------------------------------------------------------------------
** resize.js
** This bit of Javascript is a set of logic to help with window
** resizing, using the agent channel to request screen resizes.
**
** It's a bit tricky, as we want to wait for resizing to settle down
** before sending a size. Further, while horizontal resizing to use the whole
** browser width is fairly easy to arrange with css, resizing an element to use
** the whole vertical space (or to force a middle div to consume the bulk of the browser
** window size) is tricky, and the consensus seems to be that Javascript is
** the only right way to do it.
**--------------------------------------------------------------------------*/
function resize_helper(sc)
{
var w = document.getElementById(sc.screen_id).clientWidth;
var m = document.getElementById(sc.message_id);
/* Resize vertically; basically we leave a 20 pixel margin
at the bottom, and use the position of the message window
to figure out how to resize */
var h = window.innerHeight - 20;
/* Screen height based on message console visibility. A hidden console
occupies no space, so only a visible one reduces the screen; its
offsetHeight already includes the client area, so subtract it once. */
if (m != null &&
window.getComputedStyle(m).getPropertyValue("display") != 'none')
{
h = h - m.offsetHeight;
}
/* Xorg requires height be a multiple of 8; round down */
if (h % 8 > 0)
h -= (h % 8);
/* Xorg requires width be a multiple of 8; round down */
if (w % 8 > 0)
w -= (w % 8);
sc.resize_window(0, w, h, 32, 0, 0);
sc.spice_resize_timer = undefined;
}
function handle_resize(e)
{
var sc = window.spice_connection;
/* The listener can outlive the connection it was registered for. */
if (!sc)
return;
if (sc.spice_resize_timer)
{
window.clearTimeout(sc.spice_resize_timer);
sc.spice_resize_timer = undefined;
}
sc.spice_resize_timer = window.setTimeout(resize_helper, 200, sc);
}
export {
resize_helper,
handle_resize,
};
-206
View File
@@ -1,206 +0,0 @@
"use strict";
/*
Copyright (C) 2013 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/>.
*/
/*----------------------------------------------------------------------------
** SpiceSimulateCursor
** Internet Explorer 10 does not support data uri's in cursor assignment.
** This file provides a number of gimmicks to compensate. First, if there
** is a preloaded cursor available, we will use that. Failing that, we will
** simulate a cursor using an image that is moved around the screen.
**--------------------------------------------------------------------------*/
import { hex_sha1 } from './thirdparty/sha1.js';
var SpiceSimulateCursor = {
cursors : new Array(),
unknown_cursors : new Array(),
warned: false,
add_cursor: function(sha1, value)
{
SpiceSimulateCursor.cursors[sha1] = value;
},
unknown_cursor: function(sha1, curdata)
{
if (! SpiceSimulateCursor.warned)
{
SpiceSimulateCursor.warned = true;
/* Once a modal alert; a console does not stop the session for it. */
console.warn("This browser does not take dynamic cursors; cursors will be " +
"simulated with images. See the console for how to supply static cursor files.");
}
if (! SpiceSimulateCursor.unknown_cursors[sha1])
{
SpiceSimulateCursor.unknown_cursors[sha1] = curdata;
console.log('Unknown cursor. Simulation required. To avoid simulation for this cursor, create and include a custom javascript file, and add the following line:');
console.log('SpiceSimulateCursor.add_cursor("' + sha1 + '", "<your filename here>.cur");');
console.log('And then run following command, redirecting output into <your filename here>.cur:');
console.log('php -r "echo urldecode(\'' + curdata + '\');"');
}
},
/* force: the browser cannot show this shape as a CSS cursor (too large),
so it is drawn under the pointer without consulting the computed style. */
simulate_cursor: function (spicecursor, cursor, screen, pngstr, force)
{
var cursor_sha = hex_sha1(pngstr + ' ' + cursor.header.hot_spot_x + ' ' + cursor.header.hot_spot_y);
if (! force && typeof SpiceSimulateCursor.cursors != 'undefined')
if (typeof SpiceSimulateCursor.cursors[cursor_sha] != 'undefined')
{
var curstr = 'url(' + SpiceSimulateCursor.cursors[cursor_sha] + '), default';
screen.style.cursor = curstr;
}
if (force || window.getComputedStyle(screen, null).cursor == 'auto')
{
if (! force)
SpiceSimulateCursor.unknown_cursor(cursor_sha,
SpiceSimulateCursor.create_icondir(cursor.header.width, cursor.header.height,
cursor.data.byteLength, cursor.header.hot_spot_x, cursor.header.hot_spot_y) + pngstr);
document.getElementById(spicecursor.parent.screen_id).style.cursor = 'none';
if (! spicecursor.spice_simulated_cursor)
{
spicecursor.spice_simulated_cursor = document.createElement('img');
spicecursor.spice_simulated_cursor.style.position = 'absolute';
spicecursor.spice_simulated_cursor.style.display = 'none';
spicecursor.spice_simulated_cursor.style.overflow = 'hidden';
spicecursor.spice_simulated_cursor.spice_screen = document.getElementById(spicecursor.parent.screen_id);
spicecursor.spice_simulated_cursor.addEventListener('mousemove', SpiceSimulateCursor.handle_sim_mousemove);
spicecursor.spice_simulated_cursor.spice_screen.appendChild(spicecursor.spice_simulated_cursor);
}
spicecursor.spice_simulated_cursor.src = 'data:image/png,' + pngstr;
spicecursor.spice_simulated_cursor.spice_hot_x = cursor.header.hot_spot_x;
spicecursor.spice_simulated_cursor.spice_hot_y = cursor.header.hot_spot_y;
spicecursor.spice_simulated_cursor.style.pointerEvents = "none";
}
else
{
if (spicecursor.spice_simulated_cursor)
{
spicecursor.spice_simulated_cursor.spice_screen.removeChild(spicecursor.spice_simulated_cursor);
delete spicecursor.spice_simulated_cursor;
}
}
},
handle_sim_mousemove: function(e)
{
var f = SpiceSimulateCursor.duplicate_mouse_event(e, this.spice_screen);
return this.spice_screen.dispatchEvent(f);
},
duplicate_mouse_event: function(e, target)
{
var evt = document.createEvent("mouseevent");
evt.initMouseEvent(e.type, true, true, e.view, e.detail,
e.screenX, e.screenY, e.clientX, e.clientY,
e.ctrlKey, e.altKey, e.shiftKey, e.metaKey, e.button, e.relatedTarget);
return evt;
},
ICONDIR: function ()
{
},
ICONDIRENTRY: function(width, height, bytes, hot_x, hot_y)
{
this.width = width;
this.height = height;
this.bytes = bytes;
this.hot_x = hot_x;
this.hot_y = hot_y;
},
create_icondir: function (width, height, bytes, hot_x, hot_y)
{
var i;
var header = new SpiceSimulateCursor.ICONDIR();
var entry = new SpiceSimulateCursor.ICONDIRENTRY(width, height, bytes, hot_x, hot_y);
var mb = new ArrayBuffer(header.buffer_size() + entry.buffer_size());
var at = header.to_buffer(mb);
at = entry.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 str;
},
};
SpiceSimulateCursor.ICONDIR.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
dv.setUint16(at, 0, true); at += 2;
dv.setUint16(at, 2, true); at += 2;
dv.setUint16(at, 1, true); at += 2;
return at;
},
buffer_size: function()
{
return 6;
}
};
SpiceSimulateCursor.ICONDIRENTRY.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
dv.setUint8(at, this.width); at++;
dv.setUint8(at, this.height); at++;
dv.setUint8(at, 0); at++; /* color palette count, unused */
dv.setUint8(at, 0); at++; /* reserved */
dv.setUint16(at, this.hot_x, true); at += 2;
dv.setUint16(at, this.hot_y, true); at += 2;
dv.setUint32(at, this.bytes, true); at += 4;
dv.setUint32(at, at + 4, true); at += 4; /* Offset to bytes */
return at;
},
buffer_size: function()
{
return 16;
}
};
export { SpiceSimulateCursor };
-58
View File
@@ -1,58 +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/>.
*/
/*----------------------------------------------------------------------------
** SpiceArrayBufferSlice
** This function is a work around for IE 10, which has no slice()
** method in it's subclass.
**--------------------------------------------------------------------------*/
function SpiceArrayBufferSlice(start, end)
{
start = start || 0;
end = end || this.byteLength;
if (end < 0)
end = this.byteLength + end;
if (start < 0)
start = this.byteLength + start;
if (start < 0)
start = 0;
if (end < 0)
end = 0;
if (end > this.byteLength)
end = this.byteLength;
if (start > end)
start = end;
var ret = new ArrayBuffer(end - start);
var in1 = new Uint8Array(this, start, end - start);
var out = new Uint8Array(ret);
var i;
for (i = 0; i < end - start; i++)
out[i] = in1[i];
return ret;
}
if (! ArrayBuffer.prototype.slice)
{
ArrayBuffer.prototype.slice = SpiceArrayBufferSlice;
console.log("WARNING: ArrayBuffer.slice() is missing; we are extending ArrayBuffer to compensate");
}
-557
View File
@@ -1,557 +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/>.
*/
/*----------------------------------------------------------------------------
** SpiceConn
** This is the base Javascript class for establishing and
** managing a connection to a Spice Server.
** It is used to provide core functionality to the Spice main,
** display, inputs, and cursor channels. See main.js for
** usage.
**--------------------------------------------------------------------------*/
import { Constants } from './enums.js';
import { SpiceWireReader } from './wire.js';
import {
SpiceLinkHeader,
SpiceLinkMess,
SpiceLinkReply,
SpiceLinkAuthTicket,
SpiceLinkAuthReply,
SpiceMiniData,
SpiceMsgcDisplayInit,
SpiceMsgSetAck,
SpiceMsgcAckSync,
SpiceMsgNotify,
} from './spicemsg.js';
import { DEBUG, hexdump_buffer } from './utils.js';
import * as Webm from './webm.js';
import { rsa_encrypt } from './ticket.js';
function SpiceConn(o)
{
if (o === undefined || o.uri === undefined || ! o.uri)
throw new Error("You must specify a uri");
this.ws = new WebSocket(o.uri, 'binary');
if (! this.ws.binaryType)
throw new Error("WebSocket doesn't support binaryType. Try a different browser.");
this.connection_id = o.connection_id !== undefined ? o.connection_id : 0;
this.type = o.type !== undefined ? o.type : Constants.SPICE_CHANNEL_MAIN;
this.chan_id = o.chan_id !== undefined ? o.chan_id : 0;
if (o.parent !== undefined)
{
this.parent = o.parent;
this.message_id = o.parent.message_id;
this.password = o.parent.password;
/* Without this a child channel that dies mid-session (display,
inputs, cursor...) reports nothing: the close handler requires
an onerror, and report_error without one throws uncaught inside
a WebSocket event handler. The application only ever hears
about the main channel. */
this.onerror = o.parent.onerror;
}
if (o.screen_id !== undefined)
this.screen_id = o.screen_id;
if (o.dump_id !== undefined)
this.dump_id = o.dump_id;
if (o.message_id !== undefined)
this.message_id = o.message_id;
if (o.password !== undefined)
this.password = o.password;
if (o.onerror !== undefined)
this.onerror = o.onerror;
if (o.onsuccess !== undefined)
this.onsuccess = o.onsuccess;
if (o.onagent !== undefined)
this.onagent = o.onagent;
this.state = "connecting";
this.ws.parent = this;
this.wire_reader = new SpiceWireReader(this, this.process_inbound);
this.messages_sent = 0;
this.warnings = [];
this.ws.addEventListener('open', function(e) {
DEBUG > 0 && console.log(">> WebSockets.onopen");
DEBUG > 0 && console.log("id " + this.parent.connection_id +"; type " + this.parent.type);
/***********************************************************************
** WHERE IT ALL REALLY BEGINS
***********************************************************************/
this.parent.send_hdr();
this.parent.wire_reader.request(SpiceLinkHeader.prototype.buffer_size());
this.parent.state = "start";
});
this.ws.addEventListener('error', function(e) {
if ('url' in e.target) {
this.parent.log_err("WebSocket error: Can't connect to websocket on URL: " + e.target.url);
}
this.parent.report_error(e);
});
this.ws.addEventListener('close', function(e) {
DEBUG > 0 && console.log(">> WebSockets.onclose");
DEBUG > 0 && console.log("id " + this.parent.connection_id +"; type " + this.parent.type);
DEBUG > 0 && console.log(e);
if (this.parent.state != "closing" && this.parent.state != "error" && this.parent.onerror !== undefined)
{
var e;
if (this.parent.state == "connecting")
e = new Error("Connection refused.");
else if (this.parent.state == "start" || this.parent.state == "link")
e = new Error("Unexpected protocol mismatch.");
else if (this.parent.state == "ticket")
e = new Error("Bad password.");
else
e = new Error("Unexpected close while " + this.parent.state);
this.parent.onerror(e);
this.parent.log_err(e.toString());
}
});
if (this.ws.readyState == 2 || this.ws.readyState == 3)
throw new Error("Unable to connect to " + o.uri);
this.timeout = window.setTimeout(spiceconn_timeout, Constants.SPICE_CONNECT_TIMEOUT, this);
}
SpiceConn.prototype =
{
send_hdr : function ()
{
var hdr = new SpiceLinkHeader;
var msg = new SpiceLinkMess;
msg.connection_id = this.connection_id;
msg.channel_type = this.type;
msg.channel_id = this.chan_id;
msg.common_caps.push(
(1 << Constants.SPICE_COMMON_CAP_PROTOCOL_AUTH_SELECTION) |
(1 << Constants.SPICE_COMMON_CAP_MINI_HEADER)
);
if (msg.channel_type == Constants.SPICE_CHANNEL_PLAYBACK)
{
var caps = 0;
if ('MediaSource' in window && MediaSource.isTypeSupported(Webm.Constants.SPICE_PLAYBACK_CODEC))
caps |= (1 << Constants.SPICE_PLAYBACK_CAP_OPUS);
msg.channel_caps.push(caps);
}
else if (msg.channel_type == Constants.SPICE_CHANNEL_MAIN)
{
msg.channel_caps.push(
(1 << Constants.SPICE_MAIN_CAP_AGENT_CONNECTED_TOKENS)
);
}
else if (msg.channel_type == Constants.SPICE_CHANNEL_DISPLAY)
{
var caps = (1 << Constants.SPICE_DISPLAY_CAP_SIZED_STREAM) |
(1 << Constants.SPICE_DISPLAY_CAP_STREAM_REPORT) |
(1 << Constants.SPICE_DISPLAY_CAP_MULTI_CODEC) |
(1 << Constants.SPICE_DISPLAY_CAP_CODEC_MJPEG);
if ('MediaSource' in window && MediaSource.isTypeSupported(Webm.Constants.SPICE_VP8_CODEC))
caps |= (1 << Constants.SPICE_DISPLAY_CAP_CODEC_VP8);
msg.channel_caps.push(caps);
}
hdr.size = msg.buffer_size();
var mb = new ArrayBuffer(hdr.buffer_size() + msg.buffer_size());
hdr.to_buffer(mb);
msg.to_buffer(mb, hdr.buffer_size());
DEBUG > 1 && console.log("Sending header:");
DEBUG > 2 && hexdump_buffer(mb);
this.ws.send(mb);
},
send_ticket: function(ticket)
{
var hdr = new SpiceLinkAuthTicket();
hdr.auth_mechanism = Constants.SPICE_COMMON_CAP_AUTH_SPICE;
// FIXME - we need to implement RSA to make this work right
hdr.encrypted_data = ticket;
var mb = new ArrayBuffer(hdr.buffer_size());
hdr.to_buffer(mb);
DEBUG > 1 && console.log("Sending ticket:");
DEBUG > 2 && hexdump_buffer(mb);
this.ws.send(mb);
},
send_msg: function(msg)
{
var mb = new ArrayBuffer(msg.buffer_size());
msg.to_buffer(mb);
this.messages_sent++;
DEBUG > 0 && console.log(">> hdr " + this.channel_type() + " type " + msg.type + " size " + mb.byteLength);
DEBUG > 2 && hexdump_buffer(mb);
this.ws.send(mb);
},
process_inbound: function(mb, saved_header)
{
DEBUG > 2 && console.log(this.type + ": processing message of size " + mb.byteLength + "; state is " + this.state);
if (this.state == "ready")
{
if (saved_header == undefined)
{
var msg = new SpiceMiniData(mb);
if (msg.type > 500)
{
if (DEBUG > 0)
{
alert("Something has gone very wrong; we think we have message of type " + msg.type);
debugger;
}
}
if (msg.size == 0)
{
this.process_message(msg);
this.wire_reader.request(SpiceMiniData.prototype.buffer_size());
}
else
{
this.wire_reader.request(msg.size);
this.wire_reader.save_header(msg);
}
}
else
{
saved_header.data = mb;
this.process_message(saved_header);
this.wire_reader.request(SpiceMiniData.prototype.buffer_size());
this.wire_reader.save_header(undefined);
}
}
else if (this.state == "start")
{
this.reply_hdr = new SpiceLinkHeader(mb);
if (this.reply_hdr.magic != Constants.SPICE_MAGIC)
{
this.state = "error";
var e = new Error('Error: magic mismatch: ' + this.reply_hdr.magic);
this.report_error(e);
}
else
{
// FIXME - Determine major/minor version requirements
this.wire_reader.request(this.reply_hdr.size);
this.state = "link";
}
}
else if (this.state == "link")
{
this.reply_link = new SpiceLinkReply(mb);
// FIXME - Screen the caps - require minihdr at least, right?
if (this.reply_link.error)
{
this.state = "error";
var e = new Error('Error: reply link error ' + this.reply_link.error);
this.report_error(e);
}
else
{
this.send_ticket(rsa_encrypt(this.reply_link.pub_key, this.password + String.fromCharCode(0)));
this.state = "ticket";
this.wire_reader.request(SpiceLinkAuthReply.prototype.buffer_size());
}
}
else if (this.state == "ticket")
{
this.auth_reply = new SpiceLinkAuthReply(mb);
if (this.auth_reply.auth_code == Constants.SPICE_LINK_ERR_OK)
{
DEBUG > 0 && console.log(this.type + ': Connected');
if (this.type == Constants.SPICE_CHANNEL_DISPLAY)
{
// FIXME - pixmap and glz dictionary config info?
var dinit = new SpiceMsgcDisplayInit();
var reply = new SpiceMiniData();
reply.build_msg(Constants.SPICE_MSGC_DISPLAY_INIT, dinit);
DEBUG > 0 && console.log("Request display init");
this.send_msg(reply);
}
this.state = "ready";
this.wire_reader.request(SpiceMiniData.prototype.buffer_size());
if (this.timeout)
{
window.clearTimeout(this.timeout);
delete this.timeout;
}
}
else
{
this.state = "error";
if (this.auth_reply.auth_code == Constants.SPICE_LINK_ERR_PERMISSION_DENIED)
{
var e = new Error("Permission denied.");
}
else
{
var e = new Error("Unexpected link error " + this.auth_reply.auth_code);
}
this.report_error(e);
}
}
},
process_common_messages : function(msg)
{
if (msg.type == Constants.SPICE_MSG_SET_ACK)
{
var ack = new SpiceMsgSetAck(msg.data);
// FIXME - what to do with generation?
this.ack_window = ack.window;
DEBUG > 1 && console.log(this.type + ": set ack to " + ack.window);
this.msgs_until_ack = this.ack_window;
var ackack = new SpiceMsgcAckSync(ack);
var reply = new SpiceMiniData();
reply.build_msg(Constants.SPICE_MSGC_ACK_SYNC, ackack);
this.send_msg(reply);
return true;
}
if (msg.type == Constants.SPICE_MSG_PING)
{
DEBUG > 1 && console.log("ping!");
var pong = new SpiceMiniData;
pong.type = Constants.SPICE_MSGC_PONG;
if (msg.data)
{
pong.data = msg.data.slice(0, 12);
}
pong.size = pong.buffer_size();
this.send_msg(pong);
return true;
}
if (msg.type == Constants.SPICE_MSG_NOTIFY)
{
// FIXME - Visibility + what
var notify = new SpiceMsgNotify(msg.data);
if (notify.severity == Constants.SPICE_NOTIFY_SEVERITY_ERROR)
this.log_err(notify.message);
else if (notify.severity == Constants.SPICE_NOTIFY_SEVERITY_WARN )
this.log_warn(notify.message);
else
this.log_info(notify.message);
return true;
}
return false;
},
process_message: function(msg)
{
var rc;
var start = Date.now();
DEBUG > 0 && console.log("<< hdr " + this.channel_type() + " type " + msg.type + " size " + (msg.data && msg.data.byteLength));
/* A handler that throws must not take the channel with it: the
caller re-arms the wire reader only after this returns, so an
escaped exception left every later byte read as a header and
the channel decoding garbage until it closed. Report it and
carry on with the next message. */
try
{
rc = this.process_common_messages(msg);
if (! rc)
{
if (this.process_channel_message)
{
rc = this.process_channel_message(msg);
if (! rc)
this.log_warn(this.channel_type() + ": Unknown message type " + msg.type + "!");
}
else
this.log_err(this.channel_type() + ": No message handlers for this channel; message " + msg.type);
}
}
catch (e)
{
this.log_err(this.channel_type() + ": exception handling message type " + msg.type + ": " + e.message);
DEBUG > 0 && console.error(e);
}
if (this.msgs_until_ack !== undefined && this.ack_window)
{
this.msgs_until_ack--;
if (this.msgs_until_ack <= 0)
{
this.msgs_until_ack = this.ack_window;
var ack = new SpiceMiniData();
ack.type = Constants.SPICE_MSGC_ACK;
this.send_msg(ack);
DEBUG > 1 && console.log(this.type + ": sent ack");
}
}
var delta = Date.now() - start;
if (DEBUG > 0 || delta > Webm.Constants.GAP_DETECTION_THRESHOLD)
console.log("delta " + this.channel_type() + ":" + msg.type + " " + delta);
return rc;
},
channel_type: function()
{
if (this.type == Constants.SPICE_CHANNEL_MAIN)
return "main";
else if (this.type == Constants.SPICE_CHANNEL_DISPLAY)
return "display";
else if (this.type == Constants.SPICE_CHANNEL_INPUTS)
return "inputs";
else if (this.type == Constants.SPICE_CHANNEL_CURSOR)
return "cursor";
else if (this.type == Constants.SPICE_CHANNEL_PLAYBACK)
return "playback";
else if (this.type == Constants.SPICE_CHANNEL_RECORD)
return "record";
else if (this.type == Constants.SPICE_CHANNEL_TUNNEL)
return "tunnel";
else if (this.type == Constants.SPICE_CHANNEL_SMARTCARD)
return "smartcard";
else if (this.type == Constants.SPICE_CHANNEL_USBREDIR)
return "usbredir";
else if (this.type == Constants.SPICE_CHANNEL_PORT)
return "port";
else if (this.type == Constants.SPICE_CHANNEL_WEBDAV)
return "webdav";
return "unknown-" + this.type;
},
log_info: function()
{
var msg = Array.prototype.join.call(arguments, " ");
console.log(msg);
if (this.message_id)
{
var p = document.createElement("p");
p.appendChild(document.createTextNode(msg));
p.className += "spice-message-info";
document.getElementById(this.message_id).appendChild(p);
}
},
log_warn: function()
{
var msg = Array.prototype.join.call(arguments, " ");
console.log("WARNING: " + msg);
if (this.message_id)
{
var p = document.createElement("p");
p.appendChild(document.createTextNode(msg));
p.className += "spice-message-warning";
document.getElementById(this.message_id).appendChild(p);
}
},
log_err: function()
{
var msg = Array.prototype.join.call(arguments, " ");
console.log("ERROR: " + msg);
if (this.message_id)
{
var p = document.createElement("p");
p.appendChild(document.createTextNode(msg));
p.className += "spice-message-error";
document.getElementById(this.message_id).appendChild(p);
}
},
known_unimplemented: function(type, msg)
{
if ( (!this.warnings[type]) || DEBUG > 1)
{
var str = "";
if (DEBUG <= 1)
str = " [ further notices suppressed ]";
this.log_warn("Unimplemented function " + type + "(" + msg + ")" + str);
this.warnings[type] = true;
}
},
report_error: function(e)
{
this.log_err(e.toString());
if (this.onerror != undefined)
this.onerror(e);
else
throw(e);
},
report_success: function(m)
{
if (this.onsuccess != undefined)
this.onsuccess(m);
},
cleanup: function()
{
/* Deliberate teardown: without this the ws close event arrives
with a live state and reports "Unexpected close" through
onerror. */
this.state = "closing";
if (this.timeout)
{
window.clearTimeout(this.timeout);
delete this.timeout;
}
if (this.ws)
{
this.ws.close();
this.ws = undefined;
}
},
handle_timeout: function()
{
var e = new Error("Connection timed out.");
/* Actually shut the connection down. Reporting alone left the
socket open, and a slow handshake could still complete after
the application had already given up on it — a zombie session
with its own inputs and display channels. Cleanup before
report so the socket dies even if report_error throws; the
error state also keeps the close event from double-reporting. */
this.cleanup();
this.state = "error";
this.report_error(e);
},
}
function spiceconn_timeout(sc)
{
SpiceConn.prototype.handle_timeout.call(sc);
}
export {
SpiceConn,
};
File diff suppressed because it is too large Load Diff
-497
View File
@@ -1,497 +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/>.
*/
/*----------------------------------------------------------------------------
** Spice types
** This file contains classes for common spice types.
** Generally, they are used as helpers in reading and writing messages
** to and from the server.
**--------------------------------------------------------------------------*/
import { Constants } from './enums.js';
import { SpiceQuic } from './quic.js';
function SpiceChannelId()
{
}
SpiceChannelId.prototype =
{
from_dv: function(dv, at, mb)
{
this.type = dv.getUint8(at, true); at ++;
this.id = dv.getUint8(at, true); at ++;
return at;
},
}
function SpiceRect()
{
}
SpiceRect.prototype =
{
from_dv: function(dv, at, mb)
{
this.top = dv.getUint32(at, true); at += 4;
this.left = dv.getUint32(at, true); at += 4;
this.bottom = dv.getUint32(at, true); at += 4;
this.right = dv.getUint32(at, true); at += 4;
return at;
},
is_same_size : function(r)
{
if ((this.bottom - this.top) == (r.bottom - r.top) &&
(this.right - this.left) == (r.right - r.left) )
return true;
return false;
},
}
function SpiceClipRects()
{
}
SpiceClipRects.prototype =
{
from_dv: function(dv, at, mb)
{
var i;
this.num_rects = dv.getUint32(at, true); at += 4;
if (this.num_rects > 0)
this.rects = [];
for (i = 0; i < this.num_rects; i++)
{
this.rects[i] = new SpiceRect();
at = this.rects[i].from_dv(dv, at, mb);
}
return at;
},
}
function SpiceClip()
{
}
SpiceClip.prototype =
{
from_dv: function(dv, at, mb)
{
this.type = dv.getUint8(at, true); at ++;
if (this.type == Constants.SPICE_CLIP_TYPE_RECTS)
{
this.rects = new SpiceClipRects();
at = this.rects.from_dv(dv, at, mb);
}
return at;
},
}
function SpiceImageDescriptor()
{
}
SpiceImageDescriptor.prototype =
{
from_dv: function(dv, at, mb)
{
this.id = dv.getBigUint64(at, true); at += 8;
this.type = dv.getUint8(at, true); at ++;
this.flags = dv.getUint8(at, true); at ++;
this.width = dv.getUint32(at, true); at += 4;
this.height= dv.getUint32(at, true); at += 4;
return at;
},
}
function SpicePalette()
{
}
SpicePalette.prototype =
{
from_dv: function(dv, at, mb)
{
var i;
this.unique = dv.getBigUint64(at, true); at += 8;
this.num_ents = dv.getUint16(at, true); at += 2;
this.ents = [];
for (i = 0; i < this.num_ents; i++)
{
this.ents[i] = dv.getUint32(at, true); at += 4;
}
return at;
},
}
function SpiceBitmap()
{
}
SpiceBitmap.prototype =
{
from_dv: function(dv, at, mb)
{
this.format = dv.getUint8(at, true); at++;
this.flags = dv.getUint8(at, true); at++;
this.x = dv.getUint32(at, true); at += 4;
this.y = dv.getUint32(at, true); at += 4;
this.stride = dv.getUint32(at, true); at += 4;
if (this.flags & Constants.SPICE_BITMAP_FLAGS_PAL_FROM_CACHE)
{
this.palette_id = dv.getBigUint64(at, true); at += 8;
}
else
{
var offset = dv.getUint32(at, true); at += 4;
if (offset == 0)
this.palette = null;
else
{
this.palette = new SpicePalette;
this.palette.from_dv(dv, offset, mb);
}
}
// FIXME - should probably constrain this to the offset
// of palette, if non zero
this.data = mb.slice(at);
at += this.data.byteLength;
return at;
},
}
function SpiceImage()
{
}
SpiceImage.prototype =
{
from_dv: function(dv, at, mb)
{
this.descriptor = new SpiceImageDescriptor;
at = this.descriptor.from_dv(dv, at, mb);
if (this.descriptor.type == Constants.SPICE_IMAGE_TYPE_LZ_RGB)
{
this.lz_rgb = new Object();
this.lz_rgb.length = dv.getUint32(at, true); at += 4;
var initial_at = at;
this.lz_rgb.magic = "";
for (var i = 3; i >= 0; i--)
this.lz_rgb.magic += String.fromCharCode(dv.getUint8(at + i));
at += 4;
// NOTE: The endian change is *correct*
this.lz_rgb.version = dv.getUint32(at); at += 4;
this.lz_rgb.type = dv.getUint32(at); at += 4;
this.lz_rgb.width = dv.getUint32(at); at += 4;
this.lz_rgb.height = dv.getUint32(at); at += 4;
this.lz_rgb.stride = dv.getUint32(at); at += 4;
this.lz_rgb.top_down = dv.getUint32(at); at += 4;
var header_size = at - initial_at;
this.lz_rgb.data = mb.slice(at, this.lz_rgb.length + at - header_size);
at += this.lz_rgb.data.byteLength;
}
if (this.descriptor.type == Constants.SPICE_IMAGE_TYPE_BITMAP)
{
this.bitmap = new SpiceBitmap;
at = this.bitmap.from_dv(dv, at, mb);
}
if (this.descriptor.type == Constants.SPICE_IMAGE_TYPE_SURFACE)
{
this.surface_id = dv.getUint32(at, true); at += 4;
}
if (this.descriptor.type == Constants.SPICE_IMAGE_TYPE_JPEG)
{
this.jpeg = new Object;
this.jpeg.data_size = dv.getUint32(at, true); at += 4;
this.jpeg.data = mb.slice(at);
at += this.jpeg.data.byteLength;
}
if (this.descriptor.type == Constants.SPICE_IMAGE_TYPE_JPEG_ALPHA)
{
this.jpeg_alpha = new Object;
this.jpeg_alpha.flags = dv.getUint8(at, true); at += 1;
this.jpeg_alpha.jpeg_size = dv.getUint32(at, true); at += 4;
this.jpeg_alpha.data_size = dv.getUint32(at, true); at += 4;
this.jpeg_alpha.data = mb.slice(at, this.jpeg_alpha.jpeg_size + at);
at += this.jpeg_alpha.data.byteLength;
// Alpha channel is an LZ image
this.jpeg_alpha.alpha = new Object();
this.jpeg_alpha.alpha.length = this.jpeg_alpha.data_size - this.jpeg_alpha.jpeg_size;
var initial_at = at;
this.jpeg_alpha.alpha.magic = "";
for (var i = 3; i >= 0; i--)
this.jpeg_alpha.alpha.magic += String.fromCharCode(dv.getUint8(at + i));
at += 4;
// NOTE: The endian change is *correct*
this.jpeg_alpha.alpha.version = dv.getUint32(at); at += 4;
this.jpeg_alpha.alpha.type = dv.getUint32(at); at += 4;
this.jpeg_alpha.alpha.width = dv.getUint32(at); at += 4;
this.jpeg_alpha.alpha.height = dv.getUint32(at); at += 4;
this.jpeg_alpha.alpha.stride = dv.getUint32(at); at += 4;
this.jpeg_alpha.alpha.top_down = dv.getUint32(at); at += 4;
var header_size = at - initial_at;
this.jpeg_alpha.alpha.data = mb.slice(at, this.jpeg_alpha.alpha.length + at - header_size);
at += this.jpeg_alpha.alpha.data.byteLength;
}
if (this.descriptor.type == Constants.SPICE_IMAGE_TYPE_QUIC)
{
this.quic = new SpiceQuic;
at = this.quic.from_dv(dv, at, mb);
}
return at;
},
}
function SpiceQMask()
{
}
SpiceQMask.prototype =
{
from_dv: function(dv, at, mb)
{
this.flags = dv.getUint8(at, true); at++;
this.pos = new SpicePoint;
at = this.pos.from_dv(dv, at, mb);
var offset = dv.getUint32(at, true); at += 4;
if (offset == 0)
{
this.bitmap = null;
return at;
}
this.bitmap = new SpiceImage;
return this.bitmap.from_dv(dv, offset, mb);
},
}
function SpicePattern()
{
}
SpicePattern.prototype =
{
from_dv: function(dv, at, mb)
{
var offset = dv.getUint32(at, true); at += 4;
if (offset == 0)
{
this.pat = null;
}
else
{
this.pat = new SpiceImage;
this.pat.from_dv(dv, offset, mb);
}
this.pos = new SpicePoint;
return this.pos.from_dv(dv, at, mb);
}
}
function SpiceBrush()
{
}
SpiceBrush.prototype =
{
from_dv: function(dv, at, mb)
{
this.type = dv.getUint8(at, true); at ++;
if (this.type == Constants.SPICE_BRUSH_TYPE_SOLID)
{
this.color = dv.getUint32(at, true); at += 4;
}
else if (this.type == Constants.SPICE_BRUSH_TYPE_PATTERN)
{
this.pattern = new SpicePattern;
at = this.pattern.from_dv(dv, at, mb);
}
return at;
},
}
function SpiceFill()
{
}
SpiceFill.prototype =
{
from_dv: function(dv, at, mb)
{
this.brush = new SpiceBrush;
at = this.brush.from_dv(dv, at, mb);
this.rop_descriptor = dv.getUint16(at, true); at += 2;
this.mask = new SpiceQMask;
return this.mask.from_dv(dv, at, mb);
},
}
function SpiceCopy()
{
}
SpiceCopy.prototype =
{
from_dv: function(dv, at, mb)
{
var offset = dv.getUint32(at, true); at += 4;
if (offset == 0)
{
this.src_bitmap = null;
}
else
{
this.src_bitmap = new SpiceImage;
this.src_bitmap.from_dv(dv, offset, mb);
}
this.src_area = new SpiceRect;
at = this.src_area.from_dv(dv, at, mb);
this.rop_descriptor = dv.getUint16(at, true); at += 2;
this.scale_mode = dv.getUint8(at, true); at ++;
this.mask = new SpiceQMask;
return this.mask.from_dv(dv, at, mb);
},
}
function SpicePoint16()
{
}
SpicePoint16.prototype =
{
from_dv: function(dv, at, mb)
{
this.x = dv.getUint16(at, true); at += 2;
this.y = dv.getUint16(at, true); at += 2;
return at;
},
}
function SpicePoint()
{
}
SpicePoint.prototype =
{
from_dv: function(dv, at, mb)
{
this.x = dv.getUint32(at, true); at += 4;
this.y = dv.getUint32(at, true); at += 4;
return at;
},
}
function SpiceCursorHeader()
{
}
SpiceCursorHeader.prototype =
{
from_dv: function(dv, at, mb)
{
this.unique = dv.getBigUint64(at, true); at += 8;
this.type = dv.getUint8(at, true); at ++;
this.width = dv.getUint16(at, true); at += 2;
this.height = dv.getUint16(at, true); at += 2;
this.hot_spot_x = dv.getUint16(at, true); at += 2;
this.hot_spot_y = dv.getUint16(at, true); at += 2;
return at;
},
}
function SpiceCursor()
{
}
SpiceCursor.prototype =
{
from_dv: function(dv, at, mb)
{
this.flags = dv.getUint16(at, true); at += 2;
if (this.flags & Constants.SPICE_CURSOR_FLAGS_NONE)
this.header = null;
else
{
this.header = new SpiceCursorHeader;
at = this.header.from_dv(dv, at, mb);
this.data = mb.slice(at);
at += this.data.byteLength;
}
return at;
},
}
function SpiceSurface()
{
}
SpiceSurface.prototype =
{
from_dv: function(dv, at, mb)
{
this.surface_id = dv.getUint32(at, true); at += 4;
this.width = dv.getUint32(at, true); at += 4;
this.height = dv.getUint32(at, true); at += 4;
this.format = dv.getUint32(at, true); at += 4;
this.flags = dv.getUint32(at, true); at += 4;
return at;
},
}
/* FIXME - SpiceImage types lz_plt, jpeg, zlib_glz, and jpeg_alpha are
completely unimplemented */
export {
SpiceChannelId,
SpiceRect,
SpiceClipRects,
SpiceClip,
SpiceImageDescriptor,
SpicePalette,
SpiceBitmap,
SpiceImage,
SpiceQMask,
SpicePattern,
SpiceBrush,
SpiceFill,
SpiceCopy,
SpicePoint16,
SpicePoint,
SpiceCursorHeader,
SpiceCursor,
SpiceSurface,
};
-593
View File
@@ -1,593 +0,0 @@
// Downloaded from http://www-cs-students.stanford.edu/~tjw/jsbn/ by Jeremy White on 6/1/2012
/*
* Copyright (c) 2003-2005 Tom Wu
* All Rights Reserved.
*
* 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" AND WITHOUT WARRANTY OF ANY KIND,
* EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY
* WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
*
* IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL,
* INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER
* RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF
* THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT
* OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
* In addition, the following condition applies:
*
* All redistributions must retain an intact copy of this copyright notice
* and disclaimer.
*/
// Basic JavaScript BN library - subset useful for RSA encryption.
// Bits per digit
var dbits;
// JavaScript engine analysis
var canary = 0xdeadbeefcafe;
var j_lm = ((canary&0xffffff)==0xefcafe);
// (public) Constructor
function BigInteger(a,b,c) {
if(a != null)
if("number" == typeof a) this.fromNumber(a,b,c);
else if(b == null && "string" != typeof a) this.fromString(a,256);
else this.fromString(a,b);
}
// return new, unset BigInteger
function nbi() { return new BigInteger(null); }
// am: Compute w_j += (x*this_i), propagate carries,
// c is initial carry, returns final carry.
// c < 3*dvalue, x < 2*dvalue, this_i < dvalue
// We need to select the fastest one that works in this environment.
// am1: use a single mult and divide to get the high bits,
// max digit bits should be 26 because
// max internal value = 2*dvalue^2-2*dvalue (< 2^53)
function am1(i,x,w,j,c,n) {
while(--n >= 0) {
var v = x*this[i++]+w[j]+c;
c = Math.floor(v/0x4000000);
w[j++] = v&0x3ffffff;
}
return c;
}
// am2 avoids a big mult-and-extract completely.
// Max digit bits should be <= 30 because we do bitwise ops
// on values up to 2*hdvalue^2-hdvalue-1 (< 2^31)
function am2(i,x,w,j,c,n) {
var xl = x&0x7fff, xh = x>>15;
while(--n >= 0) {
var l = this[i]&0x7fff;
var h = this[i++]>>15;
var m = xh*l+h*xl;
l = xl*l+((m&0x7fff)<<15)+w[j]+(c&0x3fffffff);
c = (l>>>30)+(m>>>15)+xh*h+(c>>>30);
w[j++] = l&0x3fffffff;
}
return c;
}
// Alternately, set max digit bits to 28 since some
// browsers slow down when dealing with 32-bit numbers.
function am3(i,x,w,j,c,n) {
var xl = x&0x3fff, xh = x>>14;
while(--n >= 0) {
var l = this[i]&0x3fff;
var h = this[i++]>>14;
var m = xh*l+h*xl;
l = xl*l+((m&0x3fff)<<14)+w[j]+c;
c = (l>>28)+(m>>14)+xh*h;
w[j++] = l&0xfffffff;
}
return c;
}
if(j_lm && (navigator.appName == "Microsoft Internet Explorer")) {
BigInteger.prototype.am = am2;
dbits = 30;
}
else if(j_lm && (navigator.appName != "Netscape")) {
BigInteger.prototype.am = am1;
dbits = 26;
}
else { // Mozilla/Netscape seems to prefer am3
BigInteger.prototype.am = am3;
dbits = 28;
}
BigInteger.prototype.DB = dbits;
BigInteger.prototype.DM = ((1<<dbits)-1);
BigInteger.prototype.DV = (1<<dbits);
var BI_FP = 52;
BigInteger.prototype.FV = Math.pow(2,BI_FP);
BigInteger.prototype.F1 = BI_FP-dbits;
BigInteger.prototype.F2 = 2*dbits-BI_FP;
// Digit conversions
var BI_RM = "0123456789abcdefghijklmnopqrstuvwxyz";
var BI_RC = new Array();
var rr,vv;
rr = "0".charCodeAt(0);
for(vv = 0; vv <= 9; ++vv) BI_RC[rr++] = vv;
rr = "a".charCodeAt(0);
for(vv = 10; vv < 36; ++vv) BI_RC[rr++] = vv;
rr = "A".charCodeAt(0);
for(vv = 10; vv < 36; ++vv) BI_RC[rr++] = vv;
function int2char(n) { return BI_RM.charAt(n); }
function intAt(s,i) {
var c = BI_RC[s.charCodeAt(i)];
return (c==null)?-1:c;
}
// (protected) copy this to r
function bnpCopyTo(r) {
for(var i = this.t-1; i >= 0; --i) r[i] = this[i];
r.t = this.t;
r.s = this.s;
}
// (protected) set from integer value x, -DV <= x < DV
function bnpFromInt(x) {
this.t = 1;
this.s = (x<0)?-1:0;
if(x > 0) this[0] = x;
else if(x < -1) this[0] = x+DV;
else this.t = 0;
}
// return bigint initialized to value
function nbv(i) { var r = nbi(); r.fromInt(i); return r; }
// (protected) set from string and radix
function bnpFromString(s,b) {
var k;
if(b == 16) k = 4;
else if(b == 8) k = 3;
else if(b == 256) k = 8; // byte array
else if(b == 2) k = 1;
else if(b == 32) k = 5;
else if(b == 4) k = 2;
else { this.fromRadix(s,b); return; }
this.t = 0;
this.s = 0;
var i = s.length, mi = false, sh = 0;
while(--i >= 0) {
var x = (k==8)?s[i]&0xff:intAt(s,i);
if(x < 0) {
if(s.charAt(i) == "-") mi = true;
continue;
}
mi = false;
if(sh == 0)
this[this.t++] = x;
else if(sh+k > this.DB) {
this[this.t-1] |= (x&((1<<(this.DB-sh))-1))<<sh;
this[this.t++] = (x>>(this.DB-sh));
}
else
this[this.t-1] |= x<<sh;
sh += k;
if(sh >= this.DB) sh -= this.DB;
}
if(k == 8 && (s[0]&0x80) != 0) {
this.s = -1;
if(sh > 0) this[this.t-1] |= ((1<<(this.DB-sh))-1)<<sh;
}
this.clamp();
if(mi) BigInteger.ZERO.subTo(this,this);
}
// (protected) clamp off excess high words
function bnpClamp() {
var c = this.s&this.DM;
while(this.t > 0 && this[this.t-1] == c) --this.t;
}
// (public) return string representation in given radix
function bnToString(b) {
if(this.s < 0) return "-"+this.negate().toString(b);
var k;
if(b == 16) k = 4;
else if(b == 8) k = 3;
else if(b == 2) k = 1;
else if(b == 32) k = 5;
else if(b == 4) k = 2;
else return this.toRadix(b);
var km = (1<<k)-1, d, m = false, r = "", i = this.t;
var p = this.DB-(i*this.DB)%k;
if(i-- > 0) {
if(p < this.DB && (d = this[i]>>p) > 0) { m = true; r = int2char(d); }
while(i >= 0) {
if(p < k) {
d = (this[i]&((1<<p)-1))<<(k-p);
d |= this[--i]>>(p+=this.DB-k);
}
else {
d = (this[i]>>(p-=k))&km;
if(p <= 0) { p += this.DB; --i; }
}
if(d > 0) m = true;
if(m) r += int2char(d);
}
}
return m?r:"0";
}
// (public) -this
function bnNegate() { var r = nbi(); BigInteger.ZERO.subTo(this,r); return r; }
// (public) |this|
function bnAbs() { return (this.s<0)?this.negate():this; }
// (public) return + if this > a, - if this < a, 0 if equal
function bnCompareTo(a) {
var r = this.s-a.s;
if(r != 0) return r;
var i = this.t;
r = i-a.t;
if(r != 0) return r;
while(--i >= 0) if((r=this[i]-a[i]) != 0) return r;
return 0;
}
// returns bit length of the integer x
function nbits(x) {
var r = 1, t;
if((t=x>>>16) != 0) { x = t; r += 16; }
if((t=x>>8) != 0) { x = t; r += 8; }
if((t=x>>4) != 0) { x = t; r += 4; }
if((t=x>>2) != 0) { x = t; r += 2; }
if((t=x>>1) != 0) { x = t; r += 1; }
return r;
}
// (public) return the number of bits in "this"
function bnBitLength() {
if(this.t <= 0) return 0;
return this.DB*(this.t-1)+nbits(this[this.t-1]^(this.s&this.DM));
}
// (protected) r = this << n*DB
function bnpDLShiftTo(n,r) {
var i;
for(i = this.t-1; i >= 0; --i) r[i+n] = this[i];
for(i = n-1; i >= 0; --i) r[i] = 0;
r.t = this.t+n;
r.s = this.s;
}
// (protected) r = this >> n*DB
function bnpDRShiftTo(n,r) {
for(var i = n; i < this.t; ++i) r[i-n] = this[i];
r.t = Math.max(this.t-n,0);
r.s = this.s;
}
// (protected) r = this << n
function bnpLShiftTo(n,r) {
var bs = n%this.DB;
var cbs = this.DB-bs;
var bm = (1<<cbs)-1;
var ds = Math.floor(n/this.DB), c = (this.s<<bs)&this.DM, i;
for(i = this.t-1; i >= 0; --i) {
r[i+ds+1] = (this[i]>>cbs)|c;
c = (this[i]&bm)<<bs;
}
for(i = ds-1; i >= 0; --i) r[i] = 0;
r[ds] = c;
r.t = this.t+ds+1;
r.s = this.s;
r.clamp();
}
// (protected) r = this >> n
function bnpRShiftTo(n,r) {
r.s = this.s;
var ds = Math.floor(n/this.DB);
if(ds >= this.t) { r.t = 0; return; }
var bs = n%this.DB;
var cbs = this.DB-bs;
var bm = (1<<bs)-1;
r[0] = this[ds]>>bs;
for(var i = ds+1; i < this.t; ++i) {
r[i-ds-1] |= (this[i]&bm)<<cbs;
r[i-ds] = this[i]>>bs;
}
if(bs > 0) r[this.t-ds-1] |= (this.s&bm)<<cbs;
r.t = this.t-ds;
r.clamp();
}
// (protected) r = this - a
function bnpSubTo(a,r) {
var i = 0, c = 0, m = Math.min(a.t,this.t);
while(i < m) {
c += this[i]-a[i];
r[i++] = c&this.DM;
c >>= this.DB;
}
if(a.t < this.t) {
c -= a.s;
while(i < this.t) {
c += this[i];
r[i++] = c&this.DM;
c >>= this.DB;
}
c += this.s;
}
else {
c += this.s;
while(i < a.t) {
c -= a[i];
r[i++] = c&this.DM;
c >>= this.DB;
}
c -= a.s;
}
r.s = (c<0)?-1:0;
if(c < -1) r[i++] = this.DV+c;
else if(c > 0) r[i++] = c;
r.t = i;
r.clamp();
}
// (protected) r = this * a, r != this,a (HAC 14.12)
// "this" should be the larger one if appropriate.
function bnpMultiplyTo(a,r) {
var x = this.abs(), y = a.abs();
var i = x.t;
r.t = i+y.t;
while(--i >= 0) r[i] = 0;
for(i = 0; i < y.t; ++i) r[i+x.t] = x.am(0,y[i],r,i,0,x.t);
r.s = 0;
r.clamp();
if(this.s != a.s) BigInteger.ZERO.subTo(r,r);
}
// (protected) r = this^2, r != this (HAC 14.16)
function bnpSquareTo(r) {
var x = this.abs();
var i = r.t = 2*x.t;
while(--i >= 0) r[i] = 0;
for(i = 0; i < x.t-1; ++i) {
var c = x.am(i,x[i],r,2*i,0,1);
if((r[i+x.t]+=x.am(i+1,2*x[i],r,2*i+1,c,x.t-i-1)) >= x.DV) {
r[i+x.t] -= x.DV;
r[i+x.t+1] = 1;
}
}
if(r.t > 0) r[r.t-1] += x.am(i,x[i],r,2*i,0,1);
r.s = 0;
r.clamp();
}
// (protected) divide this by m, quotient and remainder to q, r (HAC 14.20)
// r != q, this != m. q or r may be null.
function bnpDivRemTo(m,q,r) {
var pm = m.abs();
if(pm.t <= 0) return;
var pt = this.abs();
if(pt.t < pm.t) {
if(q != null) q.fromInt(0);
if(r != null) this.copyTo(r);
return;
}
if(r == null) r = nbi();
var y = nbi(), ts = this.s, ms = m.s;
var nsh = this.DB-nbits(pm[pm.t-1]); // normalize modulus
if(nsh > 0) { pm.lShiftTo(nsh,y); pt.lShiftTo(nsh,r); }
else { pm.copyTo(y); pt.copyTo(r); }
var ys = y.t;
var y0 = y[ys-1];
if(y0 == 0) return;
var yt = y0*(1<<this.F1)+((ys>1)?y[ys-2]>>this.F2:0);
var d1 = this.FV/yt, d2 = (1<<this.F1)/yt, e = 1<<this.F2;
var i = r.t, j = i-ys, t = (q==null)?nbi():q;
y.dlShiftTo(j,t);
if(r.compareTo(t) >= 0) {
r[r.t++] = 1;
r.subTo(t,r);
}
BigInteger.ONE.dlShiftTo(ys,t);
t.subTo(y,y); // "negative" y so we can replace sub with am later
while(y.t < ys) y[y.t++] = 0;
while(--j >= 0) {
// Estimate quotient digit
var qd = (r[--i]==y0)?this.DM:Math.floor(r[i]*d1+(r[i-1]+e)*d2);
if((r[i]+=y.am(0,qd,r,j,0,ys)) < qd) { // Try it out
y.dlShiftTo(j,t);
r.subTo(t,r);
while(r[i] < --qd) r.subTo(t,r);
}
}
if(q != null) {
r.drShiftTo(ys,q);
if(ts != ms) BigInteger.ZERO.subTo(q,q);
}
r.t = ys;
r.clamp();
if(nsh > 0) r.rShiftTo(nsh,r); // Denormalize remainder
if(ts < 0) BigInteger.ZERO.subTo(r,r);
}
// (public) this mod a
function bnMod(a) {
var r = nbi();
this.abs().divRemTo(a,null,r);
if(this.s < 0 && r.compareTo(BigInteger.ZERO) > 0) a.subTo(r,r);
return r;
}
// Modular reduction using "classic" algorithm
function Classic(m) { this.m = m; }
function cConvert(x) {
if(x.s < 0 || x.compareTo(this.m) >= 0) return x.mod(this.m);
else return x;
}
function cRevert(x) { return x; }
function cReduce(x) { x.divRemTo(this.m,null,x); }
function cMulTo(x,y,r) { x.multiplyTo(y,r); this.reduce(r); }
function cSqrTo(x,r) { x.squareTo(r); this.reduce(r); }
Classic.prototype.convert = cConvert;
Classic.prototype.revert = cRevert;
Classic.prototype.reduce = cReduce;
Classic.prototype.mulTo = cMulTo;
Classic.prototype.sqrTo = cSqrTo;
// (protected) return "-1/this % 2^DB"; useful for Mont. reduction
// justification:
// xy == 1 (mod m)
// xy = 1+km
// xy(2-xy) = (1+km)(1-km)
// x[y(2-xy)] = 1-k^2m^2
// x[y(2-xy)] == 1 (mod m^2)
// if y is 1/x mod m, then y(2-xy) is 1/x mod m^2
// should reduce x and y(2-xy) by m^2 at each step to keep size bounded.
// JS multiply "overflows" differently from C/C++, so care is needed here.
function bnpInvDigit() {
if(this.t < 1) return 0;
var x = this[0];
if((x&1) == 0) return 0;
var y = x&3; // y == 1/x mod 2^2
y = (y*(2-(x&0xf)*y))&0xf; // y == 1/x mod 2^4
y = (y*(2-(x&0xff)*y))&0xff; // y == 1/x mod 2^8
y = (y*(2-(((x&0xffff)*y)&0xffff)))&0xffff; // y == 1/x mod 2^16
// last step - calculate inverse mod DV directly;
// assumes 16 < DB <= 32 and assumes ability to handle 48-bit ints
y = (y*(2-x*y%this.DV))%this.DV; // y == 1/x mod 2^dbits
// we really want the negative inverse, and -DV < y < DV
return (y>0)?this.DV-y:-y;
}
// Montgomery reduction
function Montgomery(m) {
this.m = m;
this.mp = m.invDigit();
this.mpl = this.mp&0x7fff;
this.mph = this.mp>>15;
this.um = (1<<(m.DB-15))-1;
this.mt2 = 2*m.t;
}
// xR mod m
function montConvert(x) {
var r = nbi();
x.abs().dlShiftTo(this.m.t,r);
r.divRemTo(this.m,null,r);
if(x.s < 0 && r.compareTo(BigInteger.ZERO) > 0) this.m.subTo(r,r);
return r;
}
// x/R mod m
function montRevert(x) {
var r = nbi();
x.copyTo(r);
this.reduce(r);
return r;
}
// x = x/R mod m (HAC 14.32)
function montReduce(x) {
while(x.t <= this.mt2) // pad x so am has enough room later
x[x.t++] = 0;
for(var i = 0; i < this.m.t; ++i) {
// faster way of calculating u0 = x[i]*mp mod DV
var j = x[i]&0x7fff;
var u0 = (j*this.mpl+(((j*this.mph+(x[i]>>15)*this.mpl)&this.um)<<15))&x.DM;
// use am to combine the multiply-shift-add into one call
j = i+this.m.t;
x[j] += this.m.am(0,u0,x,i,0,this.m.t);
// propagate carry
while(x[j] >= x.DV) { x[j] -= x.DV; x[++j]++; }
}
x.clamp();
x.drShiftTo(this.m.t,x);
if(x.compareTo(this.m) >= 0) x.subTo(this.m,x);
}
// r = "x^2/R mod m"; x != r
function montSqrTo(x,r) { x.squareTo(r); this.reduce(r); }
// r = "xy/R mod m"; x,y != r
function montMulTo(x,y,r) { x.multiplyTo(y,r); this.reduce(r); }
Montgomery.prototype.convert = montConvert;
Montgomery.prototype.revert = montRevert;
Montgomery.prototype.reduce = montReduce;
Montgomery.prototype.mulTo = montMulTo;
Montgomery.prototype.sqrTo = montSqrTo;
// (protected) true iff this is even
function bnpIsEven() { return ((this.t>0)?(this[0]&1):this.s) == 0; }
// (protected) this^e, e < 2^32, doing sqr and mul with "r" (HAC 14.79)
function bnpExp(e,z) {
if(e > 0xffffffff || e < 1) return BigInteger.ONE;
var r = nbi(), r2 = nbi(), g = z.convert(this), i = nbits(e)-1;
g.copyTo(r);
while(--i >= 0) {
z.sqrTo(r,r2);
if((e&(1<<i)) > 0) z.mulTo(r2,g,r);
else { var t = r; r = r2; r2 = t; }
}
return z.revert(r);
}
// (public) this^e % m, 0 <= e < 2^32
function bnModPowInt(e,m) {
var z;
if(e < 256 || m.isEven()) z = new Classic(m); else z = new Montgomery(m);
return this.exp(e,z);
}
// protected
BigInteger.prototype.copyTo = bnpCopyTo;
BigInteger.prototype.fromInt = bnpFromInt;
BigInteger.prototype.fromString = bnpFromString;
BigInteger.prototype.clamp = bnpClamp;
BigInteger.prototype.dlShiftTo = bnpDLShiftTo;
BigInteger.prototype.drShiftTo = bnpDRShiftTo;
BigInteger.prototype.lShiftTo = bnpLShiftTo;
BigInteger.prototype.rShiftTo = bnpRShiftTo;
BigInteger.prototype.subTo = bnpSubTo;
BigInteger.prototype.multiplyTo = bnpMultiplyTo;
BigInteger.prototype.squareTo = bnpSquareTo;
BigInteger.prototype.divRemTo = bnpDivRemTo;
BigInteger.prototype.invDigit = bnpInvDigit;
BigInteger.prototype.isEven = bnpIsEven;
BigInteger.prototype.exp = bnpExp;
// public
BigInteger.prototype.toString = bnToString;
BigInteger.prototype.negate = bnNegate;
BigInteger.prototype.abs = bnAbs;
BigInteger.prototype.compareTo = bnCompareTo;
BigInteger.prototype.bitLength = bnBitLength;
BigInteger.prototype.mod = bnMod;
BigInteger.prototype.modPowInt = bnModPowInt;
// "constants"
BigInteger.ZERO = nbv(0);
BigInteger.ONE = nbv(1);
export {
BigInteger,
};
-84
View File
@@ -1,84 +0,0 @@
// Downloaded from http://www-cs-students.stanford.edu/~tjw/jsbn/ by Jeremy White on 6/1/2012
/*
* Copyright (c) 2003-2005 Tom Wu
* All Rights Reserved.
*
* 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" AND WITHOUT WARRANTY OF ANY KIND,
* EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY
* WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
*
* IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL,
* INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER
* RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF
* THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT
* OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
* In addition, the following condition applies:
*
* All redistributions must retain an intact copy of this copyright notice
* and disclaimer.
*/
// prng4.js - uses Arcfour as a PRNG
function Arcfour() {
this.i = 0;
this.j = 0;
this.S = new Array();
}
// Initialize arcfour context from key, an array of ints, each from [0..255]
function ARC4init(key) {
var i, j, t;
for(i = 0; i < 256; ++i)
this.S[i] = i;
j = 0;
for(i = 0; i < 256; ++i) {
j = (j + this.S[i] + key[i % key.length]) & 255;
t = this.S[i];
this.S[i] = this.S[j];
this.S[j] = t;
}
this.i = 0;
this.j = 0;
}
function ARC4next() {
var t;
this.i = (this.i + 1) & 255;
this.j = (this.j + this.S[this.i]) & 255;
t = this.S[this.i];
this.S[this.i] = this.S[this.j];
this.S[this.j] = t;
return this.S[(t + this.S[this.i]) & 255];
}
Arcfour.prototype.init = ARC4init;
Arcfour.prototype.next = ARC4next;
// Plug in your RNG constructor here
function prng_newstate() {
return new Arcfour();
}
// Pool size must be a multiple of 4 and greater than 32.
// An array of bytes the size of the pool will be passed to init()
var rng_psize = 256;
export {
prng_newstate,
rng_psize,
};
-107
View File
@@ -1,107 +0,0 @@
// Downloaded from http://www-cs-students.stanford.edu/~tjw/jsbn/ by Jeremy White on 6/1/2012
/*
* Copyright (c) 2003-2005 Tom Wu
* All Rights Reserved.
*
* 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" AND WITHOUT WARRANTY OF ANY KIND,
* EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY
* WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
*
* IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL,
* INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER
* RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF
* THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT
* OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
* In addition, the following condition applies:
*
* All redistributions must retain an intact copy of this copyright notice
* and disclaimer.
*/
// Random number generator - requires a PRNG backend, e.g. prng4.js
import { prng_newstate, rng_psize } from './prng4.js';
// For best results, put code like
// <body onClick='rng_seed_time();' onKeyPress='rng_seed_time();'>
// in your main HTML document.
var rng_state;
var rng_pool;
var rng_pptr;
// Mix in a 32-bit integer into the pool
function rng_seed_int(x) {
rng_pool[rng_pptr++] ^= x & 255;
rng_pool[rng_pptr++] ^= (x >> 8) & 255;
rng_pool[rng_pptr++] ^= (x >> 16) & 255;
rng_pool[rng_pptr++] ^= (x >> 24) & 255;
if(rng_pptr >= rng_psize) rng_pptr -= rng_psize;
}
// Mix in the current time (w/milliseconds) into the pool
function rng_seed_time() {
rng_seed_int(new Date().getTime());
}
// Initialize the pool with junk if needed.
if(rng_pool == null) {
rng_pool = new Array();
rng_pptr = 0;
var t;
if(navigator.appName == "Netscape" && navigator.appVersion < "5" && window.crypto) {
// Extract entropy (256 bits) from NS4 RNG if available
var z = window.crypto.random(32);
for(t = 0; t < z.length; ++t)
rng_pool[rng_pptr++] = z.charCodeAt(t) & 255;
}
while(rng_pptr < rng_psize) { // extract some randomness from Math.random()
t = Math.floor(65536 * Math.random());
rng_pool[rng_pptr++] = t >>> 8;
rng_pool[rng_pptr++] = t & 255;
}
rng_pptr = 0;
rng_seed_time();
//rng_seed_int(window.screenX);
//rng_seed_int(window.screenY);
}
function rng_get_byte() {
if(rng_state == null) {
rng_seed_time();
rng_state = prng_newstate();
rng_state.init(rng_pool);
for(rng_pptr = 0; rng_pptr < rng_pool.length; ++rng_pptr)
rng_pool[rng_pptr] = 0;
rng_pptr = 0;
//rng_pool = null;
}
// TODO: allow reseeding after first request
return rng_state.next();
}
function rng_get_bytes(ba) {
var i;
for(i = 0; i < ba.length; ++i) ba[i] = rng_get_byte();
}
function SecureRandom() {}
SecureRandom.prototype.nextBytes = rng_get_bytes;
export {
SecureRandom,
};
-153
View File
@@ -1,153 +0,0 @@
// Downloaded from http://www-cs-students.stanford.edu/~tjw/jsbn/ by Jeremy White on 6/1/2012
// Converted into an ES6 module
/*
* Copyright (c) 2003-2005 Tom Wu
* All Rights Reserved.
*
* 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" AND WITHOUT WARRANTY OF ANY KIND,
* EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY
* WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
*
* IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL,
* INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER
* RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF
* THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT
* OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
* In addition, the following condition applies:
*
* All redistributions must retain an intact copy of this copyright notice
* and disclaimer.
*/
// Depends on jsbn.js and rng.js
import { BigInteger } from './jsbn.js';
import { SecureRandom } from './rng.js';
// Version 1.1: support utf-8 encoding in pkcs1pad2
// convert a (hex) string to a bignum object
function parseBigInt(str,r) {
return new BigInteger(str,r);
}
function linebrk(s,n) {
var ret = "";
var i = 0;
while(i + n < s.length) {
ret += s.substring(i,i+n) + "\n";
i += n;
}
return ret + s.substring(i,s.length);
}
function byte2Hex(b) {
if(b < 0x10)
return "0" + b.toString(16);
else
return b.toString(16);
}
// PKCS#1 (type 2, random) pad input string s to n bytes, and return a bigint
function pkcs1pad2(s,n) {
if(n < s.length + 11) { // TODO: fix for utf-8
alert("Message too long for RSA");
return null;
}
var ba = new Array();
var i = s.length - 1;
while(i >= 0 && n > 0) {
var c = s.charCodeAt(i--);
if(c < 128) { // encode using utf-8
ba[--n] = c;
}
else if((c > 127) && (c < 2048)) {
ba[--n] = (c & 63) | 128;
ba[--n] = (c >> 6) | 192;
}
else {
ba[--n] = (c & 63) | 128;
ba[--n] = ((c >> 6) & 63) | 128;
ba[--n] = (c >> 12) | 224;
}
}
ba[--n] = 0;
var rng = new SecureRandom();
var x = new Array();
while(n > 2) { // random non-zero pad
x[0] = 0;
while(x[0] == 0) rng.nextBytes(x);
ba[--n] = x[0];
}
ba[--n] = 2;
ba[--n] = 0;
return new BigInteger(ba);
}
// "empty" RSA key constructor
function RSAKey() {
this.n = null;
this.e = 0;
this.d = null;
this.p = null;
this.q = null;
this.dmp1 = null;
this.dmq1 = null;
this.coeff = null;
}
// Set the public key fields N and e from hex strings
function RSASetPublic(N,E) {
if(N != null && E != null && N.length > 0 && E.length > 0) {
this.n = parseBigInt(N,16);
this.e = parseInt(E,16);
}
else
alert("Invalid RSA public key");
}
// Perform raw public operation on "x": return x^e (mod n)
function RSADoPublic(x) {
return x.modPowInt(this.e, this.n);
}
// Return the PKCS#1 RSA encryption of "text" as an even-length hex string
function RSAEncrypt(text) {
var m = pkcs1pad2(text,(this.n.bitLength()+7)>>3);
if(m == null) return null;
var c = this.doPublic(m);
if(c == null) return null;
var h = c.toString(16);
if((h.length & 1) == 0) return h; else return "0" + h;
}
// Return the PKCS#1 RSA encryption of "text" as a Base64-encoded string
//function RSAEncryptB64(text) {
// var h = this.encrypt(text);
// if(h) return hex2b64(h); else return null;
//}
// protected
RSAKey.prototype.doPublic = RSADoPublic;
// public
RSAKey.prototype.setPublic = RSASetPublic;
RSAKey.prototype.encrypt = RSAEncrypt;
//RSAKey.prototype.encrypt_b64 = RSAEncryptB64;
export {
RSAKey,
};
-351
View File
@@ -1,351 +0,0 @@
/*
* A JavaScript implementation of the Secure Hash Algorithm, SHA-1, as defined
* in FIPS 180-1
* Version 2.2 Copyright Paul Johnston 2000 - 2009.
* Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
* Distributed under the BSD License
* See http://pajhome.org.uk/crypt/md5 for details.
*/
/* Downloaded 6/1/2012 from the above address by Jeremy White.
License reproduce here for completeness:
Copyright (c) 1998 - 2009, Paul Johnston & Contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/*
* Configurable variables. You may need to tweak these to be compatible with
* the server-side, but the defaults work in most cases.
*/
var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */
var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */
/*
* These are the functions you'll usually want to call
* They take string arguments and return either hex or base-64 encoded strings
*/
function hex_sha1(s) { return rstr2hex(rstr_sha1(str2rstr_utf8(s))); }
function b64_sha1(s) { return rstr2b64(rstr_sha1(str2rstr_utf8(s))); }
function any_sha1(s, e) { return rstr2any(rstr_sha1(str2rstr_utf8(s)), e); }
function hex_hmac_sha1(k, d)
{ return rstr2hex(rstr_hmac_sha1(str2rstr_utf8(k), str2rstr_utf8(d))); }
function b64_hmac_sha1(k, d)
{ return rstr2b64(rstr_hmac_sha1(str2rstr_utf8(k), str2rstr_utf8(d))); }
function any_hmac_sha1(k, d, e)
{ return rstr2any(rstr_hmac_sha1(str2rstr_utf8(k), str2rstr_utf8(d)), e); }
/*
* Perform a simple self-test to see if the VM is working
*/
function sha1_vm_test()
{
return hex_sha1("abc").toLowerCase() == "a9993e364706816aba3e25717850c26c9cd0d89d";
}
/*
* Calculate the SHA1 of a raw string
*/
function rstr_sha1(s)
{
return binb2rstr(binb_sha1(rstr2binb(s), s.length * 8));
}
/*
* Calculate the HMAC-SHA1 of a key and some data (raw strings)
*/
function rstr_hmac_sha1(key, data)
{
var bkey = rstr2binb(key);
if(bkey.length > 16) bkey = binb_sha1(bkey, key.length * 8);
var ipad = Array(16), opad = Array(16);
for(var i = 0; i < 16; i++)
{
ipad[i] = bkey[i] ^ 0x36363636;
opad[i] = bkey[i] ^ 0x5C5C5C5C;
}
var hash = binb_sha1(ipad.concat(rstr2binb(data)), 512 + data.length * 8);
return binb2rstr(binb_sha1(opad.concat(hash), 512 + 160));
}
/*
* Convert a raw string to a hex string
*/
function rstr2hex(input)
{
try { hexcase } catch(e) { hexcase=0; }
var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef";
var output = "";
var x;
for(var i = 0; i < input.length; i++)
{
x = input.charCodeAt(i);
output += hex_tab.charAt((x >>> 4) & 0x0F)
+ hex_tab.charAt( x & 0x0F);
}
return output;
}
/*
* Convert a raw string to a base-64 string
*/
function rstr2b64(input)
{
try { b64pad } catch(e) { b64pad=''; }
var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var output = "";
var len = input.length;
for(var i = 0; i < len; i += 3)
{
var triplet = (input.charCodeAt(i) << 16)
| (i + 1 < len ? input.charCodeAt(i+1) << 8 : 0)
| (i + 2 < len ? input.charCodeAt(i+2) : 0);
for(var j = 0; j < 4; j++)
{
if(i * 8 + j * 6 > input.length * 8) output += b64pad;
else output += tab.charAt((triplet >>> 6*(3-j)) & 0x3F);
}
}
return output;
}
/*
* Convert a raw string to an arbitrary string encoding
*/
function rstr2any(input, encoding)
{
var divisor = encoding.length;
var remainders = Array();
var i, q, x, quotient;
/* Convert to an array of 16-bit big-endian values, forming the dividend */
var dividend = Array(Math.ceil(input.length / 2));
for(i = 0; i < dividend.length; i++)
{
dividend[i] = (input.charCodeAt(i * 2) << 8) | input.charCodeAt(i * 2 + 1);
}
/*
* Repeatedly perform a long division. The binary array forms the dividend,
* the length of the encoding is the divisor. Once computed, the quotient
* forms the dividend for the next step. We stop when the dividend is zero.
* All remainders are stored for later use.
*/
while(dividend.length > 0)
{
quotient = Array();
x = 0;
for(i = 0; i < dividend.length; i++)
{
x = (x << 16) + dividend[i];
q = Math.floor(x / divisor);
x -= q * divisor;
if(quotient.length > 0 || q > 0)
quotient[quotient.length] = q;
}
remainders[remainders.length] = x;
dividend = quotient;
}
/* Convert the remainders to the output string */
var output = "";
for(i = remainders.length - 1; i >= 0; i--)
output += encoding.charAt(remainders[i]);
/* Append leading zero equivalents */
var full_length = Math.ceil(input.length * 8 /
(Math.log(encoding.length) / Math.log(2)))
for(i = output.length; i < full_length; i++)
output = encoding[0] + output;
return output;
}
/*
* Encode a string as utf-8.
* For efficiency, this assumes the input is valid utf-16.
*/
function str2rstr_utf8(input)
{
var output = "";
var i = -1;
var x, y;
while(++i < input.length)
{
/* Decode utf-16 surrogate pairs */
x = input.charCodeAt(i);
y = i + 1 < input.length ? input.charCodeAt(i + 1) : 0;
if(0xD800 <= x && x <= 0xDBFF && 0xDC00 <= y && y <= 0xDFFF)
{
x = 0x10000 + ((x & 0x03FF) << 10) + (y & 0x03FF);
i++;
}
/* Encode output as utf-8 */
if(x <= 0x7F)
output += String.fromCharCode(x);
else if(x <= 0x7FF)
output += String.fromCharCode(0xC0 | ((x >>> 6 ) & 0x1F),
0x80 | ( x & 0x3F));
else if(x <= 0xFFFF)
output += String.fromCharCode(0xE0 | ((x >>> 12) & 0x0F),
0x80 | ((x >>> 6 ) & 0x3F),
0x80 | ( x & 0x3F));
else if(x <= 0x1FFFFF)
output += String.fromCharCode(0xF0 | ((x >>> 18) & 0x07),
0x80 | ((x >>> 12) & 0x3F),
0x80 | ((x >>> 6 ) & 0x3F),
0x80 | ( x & 0x3F));
}
return output;
}
/*
* Encode a string as utf-16
*/
function str2rstr_utf16le(input)
{
var output = "";
for(var i = 0; i < input.length; i++)
output += String.fromCharCode( input.charCodeAt(i) & 0xFF,
(input.charCodeAt(i) >>> 8) & 0xFF);
return output;
}
function str2rstr_utf16be(input)
{
var output = "";
for(var i = 0; i < input.length; i++)
output += String.fromCharCode((input.charCodeAt(i) >>> 8) & 0xFF,
input.charCodeAt(i) & 0xFF);
return output;
}
/*
* Convert a raw string to an array of big-endian words
* Characters >255 have their high-byte silently ignored.
*/
function rstr2binb(input)
{
var output = Array(input.length >> 2);
for(var i = 0; i < output.length; i++)
output[i] = 0;
for(var i = 0; i < input.length * 8; i += 8)
output[i>>5] |= (input.charCodeAt(i / 8) & 0xFF) << (24 - i % 32);
return output;
}
/*
* Convert an array of big-endian words to a string
*/
function binb2rstr(input)
{
var output = "";
for(var i = 0; i < input.length * 32; i += 8)
output += String.fromCharCode((input[i>>5] >>> (24 - i % 32)) & 0xFF);
return output;
}
/*
* Calculate the SHA-1 of an array of big-endian words, and a bit length
*/
function binb_sha1(x, len)
{
/* append padding */
x[len >> 5] |= 0x80 << (24 - len % 32);
x[((len + 64 >> 9) << 4) + 15] = len;
var w = Array(80);
var a = 1732584193;
var b = -271733879;
var c = -1732584194;
var d = 271733878;
var e = -1009589776;
for(var i = 0; i < x.length; i += 16)
{
var olda = a;
var oldb = b;
var oldc = c;
var oldd = d;
var olde = e;
for(var j = 0; j < 80; j++)
{
if(j < 16) w[j] = x[i + j];
else w[j] = bit_rol(w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16], 1);
var t = safe_add(safe_add(bit_rol(a, 5), sha1_ft(j, b, c, d)),
safe_add(safe_add(e, w[j]), sha1_kt(j)));
e = d;
d = c;
c = bit_rol(b, 30);
b = a;
a = t;
}
a = safe_add(a, olda);
b = safe_add(b, oldb);
c = safe_add(c, oldc);
d = safe_add(d, oldd);
e = safe_add(e, olde);
}
return Array(a, b, c, d, e);
}
/*
* Perform the appropriate triplet combination function for the current
* iteration
*/
function sha1_ft(t, b, c, d)
{
if(t < 20) return (b & c) | ((~b) & d);
if(t < 40) return b ^ c ^ d;
if(t < 60) return (b & c) | (b & d) | (c & d);
return b ^ c ^ d;
}
/*
* Determine the appropriate additive constant for the current iteration
*/
function sha1_kt(t)
{
return (t < 20) ? 1518500249 : (t < 40) ? 1859775393 :
(t < 60) ? -1894007588 : -899497514;
}
/*
* Add integers, wrapping at 2^32. This uses 16-bit operations internally
* to work around bugs in some JS interpreters.
*/
function safe_add(x, y)
{
var lsw = (x & 0xFFFF) + (y & 0xFFFF);
var msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xFFFF);
}
/*
* Bitwise rotate a 32-bit number to the left.
*/
function bit_rol(num, cnt)
{
return (num << cnt) | (num >>> (32 - cnt));
}
export {
hex_sha1,
rstr_sha1,
};
-268
View File
@@ -1,268 +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 { RSAKey } from './thirdparty/rsa.js';
import { BigInteger } from './thirdparty/jsbn.js';
import { SecureRandom } from './thirdparty/rng.js';
import { rstr_sha1 } from './thirdparty/sha1.js';
var SHA_DIGEST_LENGTH = 20;
/*----------------------------------------------------------------------------
** General ticket RSA encryption functions - just good enough to
** support what we need to send back an encrypted ticket.
**--------------------------------------------------------------------------*/
/*----------------------------------------------------------------------------
** OAEP padding functions. Inspired by the OpenSSL implementation.
**--------------------------------------------------------------------------*/
function MGF1(mask, seed)
{
var i, j, outlen;
for (i = 0, outlen = 0; outlen < mask.length; i++)
{
var combo_buf = new String;
for (j = 0; j < seed.length; j++)
combo_buf += String.fromCharCode(seed[j]);
combo_buf += String.fromCharCode((i >> 24) & 255);
combo_buf += String.fromCharCode((i >> 16) & 255);
combo_buf += String.fromCharCode((i >> 8) & 255);
combo_buf += String.fromCharCode((i) & 255);
var combo_hash = rstr_sha1(combo_buf);
for (j = 0; j < combo_hash.length && outlen < mask.length; j++, outlen++)
{
mask[outlen] = combo_hash.charCodeAt(j);
}
}
}
function RSA_padding_add_PKCS1_OAEP(tolen, from, param)
{
var seed = new Array(SHA_DIGEST_LENGTH);
var rand = new SecureRandom();
rand.nextBytes(seed);
var dblen = tolen - 1 - seed.length;
var db = new Array(dblen);
var padlen = dblen - from.length - 1;
var i;
if (param === undefined)
param = "";
if (padlen < SHA_DIGEST_LENGTH)
{
console.log("Error - data too large for key size.");
return null;
}
for (i = 0; i < padlen; i++)
db[i] = 0;
var param_hash = rstr_sha1(param);
for (i = 0; i < param_hash.length; i++)
db[i] = param_hash.charCodeAt(i);
db[padlen] = 1;
for (i = 0; i < from.length; i++)
db[i + padlen + 1] = from.charCodeAt(i);
var dbmask = new Array(dblen);
if (MGF1(dbmask, seed) < 0)
return null;
for (i = 0; i < dbmask.length; i++)
db[i] ^= dbmask[i];
var seedmask = Array(SHA_DIGEST_LENGTH);
if (MGF1(seedmask, db) < 0)
return null;
for (i = 0; i < seedmask.length; i++)
seed[i] ^= seedmask[i];
var ret = new String;
ret += String.fromCharCode(0);
for (i = 0; i < seed.length; i++)
ret += String.fromCharCode(seed[i]);
for (i = 0; i < db.length; i++)
ret += String.fromCharCode(db[i]);
return ret;
}
function asn_get_length(u8, at)
{
var len = u8[at++];
if (len > 0x80)
{
if (len != 0x81)
{
console.log("Error: we lazily don't support keys bigger than 255 bytes. It'd be easy to fix.");
return null;
}
len = u8[at++];
}
return [ at, len];
}
function find_sequence(u8, at)
{
var lenblock;
at = at || 0;
if (u8[at++] != 0x30)
{
console.log("Error: public key should start with a sequence flag.");
return null;
}
lenblock = asn_get_length(u8, at);
if (! lenblock)
return null;
return lenblock;
}
/*----------------------------------------------------------------------------
** Extract an RSA key from a memory buffer
**--------------------------------------------------------------------------*/
function create_rsa_from_mb(mb, at)
{
var u8 = new Uint8Array(mb);
var lenblock;
var seq;
var ba;
var i;
var ret;
/* We have a sequence which contains a sequence followed by a bit string */
seq = find_sequence(u8, at);
if (! seq)
return null;
at = seq[0];
seq = find_sequence(u8, at);
if (! seq)
return null;
/* Skip over the contained sequence */
at = seq[0] + seq[1];
if (u8[at++] != 0x3)
{
console.log("Error: expecting bit string next.");
return null;
}
/* Get the bit string, which is *itself* a sequence. Having fun yet? */
lenblock = asn_get_length(u8, at);
if (! lenblock)
return null;
at = lenblock[0];
/* Or, not and: the bit string is malformed if EITHER the unused-bits
byte is nonzero or the sequence tag is missing. Requiring both to
be wrong let malformed keys through to be garbage-parsed. */
if (u8[at] != 0 || u8[at + 1] != 0x30)
{
console.log("Error: unexpected values in bit string.");
return null;
}
/* Okay, now we have a sequence of two binary values, we hope. */
seq = find_sequence(u8, at + 1);
if (! seq)
return null;
at = seq[0];
if (u8[at++] != 0x02)
{
console.log("Error: expecting integer n next.");
return null;
}
lenblock = asn_get_length(u8, at);
if (! lenblock)
return null;
at = lenblock[0];
ba = new Array(lenblock[1]);
for (i = 0; i < lenblock[1]; i++)
ba[i] = u8[at + i];
ret = new RSAKey();
ret.n = new BigInteger(ba);
at += lenblock[1];
if (u8[at++] != 0x02)
{
console.log("Error: expecting integer e next.");
return null;
}
lenblock = asn_get_length(u8, at);
if (! lenblock)
return null;
at = lenblock[0];
ret.e = u8[at++];
for (i = 1; i < lenblock[1]; i++)
{
ret.e <<= 8;
ret.e |= u8[at++];
}
return ret;
}
function rsa_encrypt(rsa, str)
{
var i;
var ret = [];
var oaep = RSA_padding_add_PKCS1_OAEP((rsa.n.bitLength()+7)>>3, str);
if (! oaep)
return null;
var ba = new Array(oaep.length);
for (i = 0; i < oaep.length; i++)
ba[i] = oaep.charCodeAt(i);
var bigint = new BigInteger(ba);
var enc = rsa.doPublic(bigint);
var h = enc.toString(16);
/* BigInteger.toString() drops leading zeros, and the ticket field the
caller writes this into is padded at the tail, so a short result
would shift the whole ciphertext. Pad back to the modulus width. */
var hexlen = ((rsa.n.bitLength() + 7) >> 3) * 2;
while (h.length < hexlen)
h = "0" + h;
for (i = 0; i < h.length; i += 2)
ret[i / 2] = parseInt(h.substring(i, i + 2), 16);
return ret;
}
export {
create_rsa_from_mb,
rsa_encrypt,
};
-368
View File
@@ -1,368 +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 { KeyNames } from './atKeynames.js';
import { code_to_scancode } from './code_to_scancode.js';
/*----------------------------------------------------------------------------
** Utility settings and functions for Spice
**--------------------------------------------------------------------------*/
var DEBUG = 0;
var PLAYBACK_DEBUG = 0;
var STREAM_DEBUG = 0;
var DUMP_DRAWS = false;
var DUMP_CANVASES = false;
/*----------------------------------------------------------------------------
** We use an Image temporarily, and the image/src does not get garbage
** collected as quickly as we might like. This blank image helps with that.
**--------------------------------------------------------------------------*/
var EMPTY_GIF_IMAGE = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
/*----------------------------------------------------------------------------
** combine_array_buffers
** Combine two array buffers.
** FIXME - this can't be optimal. See wire.js about eliminating the need.
**--------------------------------------------------------------------------*/
function combine_array_buffers(a1, a2)
{
var in1 = new Uint8Array(a1);
var in2 = new Uint8Array(a2);
var ret = new ArrayBuffer(a1.byteLength + a2.byteLength);
var out = new Uint8Array(ret);
var o = 0;
var i;
for (i = 0; i < in1.length; i++)
out[o++] = in1[i];
for (i = 0; i < in2.length; i++)
out[o++] = in2[i];
return ret;
}
/*----------------------------------------------------------------------------
** hexdump_buffer
**--------------------------------------------------------------------------*/
function hexdump_buffer(a)
{
var mg = new Uint8Array(a);
var hex = "";
var str = "";
var last_zeros = 0;
for (var i = 0; i < mg.length; i++)
{
var h = Number(mg[i]).toString(16);
if (h.length == 1)
hex += "0";
hex += h + " ";
if (mg[i] == 10 || mg[i] == 13 || mg[i] == 8)
str += ".";
else
str += String.fromCharCode(mg[i]);
if ((i % 16 == 15) || (i == (mg.length - 1)))
{
while (i % 16 != 15)
{
hex += " ";
i++;
}
if (last_zeros == 0)
console.log(hex + " | " + str);
if (hex == "00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ")
{
if (last_zeros == 1)
{
console.log(".");
last_zeros++;
}
else if (last_zeros == 0)
last_zeros++;
}
else
last_zeros = 0;
hex = str = "";
}
}
}
/*----------------------------------------------------------------------------
** Convert arraybuffer to string
**--------------------------------------------------------------------------*/
function arraybuffer_to_str(buf) {
return String.fromCharCode.apply(null, new Uint16Array(buf));
}
/*----------------------------------------------------------------------------
** Converting browser keycodes to AT scancodes is very hard.
** Spice transmits keys using the original AT scan codes, often
** described as 'Scan Code Set 1'.
** There is a confusion of other scan codes; Xorg synthesizes it's
** own in the same atKeynames.c file that has the XT codes.
** Scan code set 2 and 3 are more common, and use different values.
** Further, there is no formal specification for keycodes
** returned by browsers, so we have done our mapping largely with
** empirical testing.
** There has been little rigorous testing with International keyboards,
** and this would be an easy area for non English speakers to contribute.
**--------------------------------------------------------------------------*/
var common_scanmap = [];
/* The following appear to be keycodes that work in most browsers */
common_scanmap['1'.charCodeAt(0)] = KeyNames.KEY_1;
common_scanmap['2'.charCodeAt(0)] = KeyNames.KEY_2;
common_scanmap['3'.charCodeAt(0)] = KeyNames.KEY_3;
common_scanmap['4'.charCodeAt(0)] = KeyNames.KEY_4;
common_scanmap['5'.charCodeAt(0)] = KeyNames.KEY_5;
common_scanmap['6'.charCodeAt(0)] = KeyNames.KEY_6;
common_scanmap['7'.charCodeAt(0)] = KeyNames.KEY_7;
common_scanmap['8'.charCodeAt(0)] = KeyNames.KEY_8;
common_scanmap['9'.charCodeAt(0)] = KeyNames.KEY_9;
common_scanmap['0'.charCodeAt(0)] = KeyNames.KEY_0;
common_scanmap[145] = KeyNames.KEY_ScrollLock;
common_scanmap[103] = KeyNames.KEY_KP_7;
common_scanmap[104] = KeyNames.KEY_KP_8;
common_scanmap[105] = KeyNames.KEY_KP_9;
common_scanmap[100] = KeyNames.KEY_KP_4;
common_scanmap[101] = KeyNames.KEY_KP_5;
common_scanmap[102] = KeyNames.KEY_KP_6;
common_scanmap[107] = KeyNames.KEY_KP_Plus;
common_scanmap[97] = KeyNames.KEY_KP_1;
common_scanmap[98] = KeyNames.KEY_KP_2;
common_scanmap[99] = KeyNames.KEY_KP_3;
common_scanmap[96] = KeyNames.KEY_KP_0;
common_scanmap[109] = KeyNames.KEY_Minus;
common_scanmap[110] = KeyNames.KEY_KP_Decimal;
common_scanmap[191] = KeyNames.KEY_Slash;
common_scanmap[190] = KeyNames.KEY_Period;
common_scanmap[188] = KeyNames.KEY_Comma;
common_scanmap[220] = KeyNames.KEY_BSlash;
common_scanmap[192] = KeyNames.KEY_Tilde;
common_scanmap[222] = KeyNames.KEY_Quote;
common_scanmap[219] = KeyNames.KEY_LBrace;
common_scanmap[221] = KeyNames.KEY_RBrace;
common_scanmap['Q'.charCodeAt(0)] = KeyNames.KEY_Q;
common_scanmap['W'.charCodeAt(0)] = KeyNames.KEY_W;
common_scanmap['E'.charCodeAt(0)] = KeyNames.KEY_E;
common_scanmap['R'.charCodeAt(0)] = KeyNames.KEY_R;
common_scanmap['T'.charCodeAt(0)] = KeyNames.KEY_T;
common_scanmap['Y'.charCodeAt(0)] = KeyNames.KEY_Y;
common_scanmap['U'.charCodeAt(0)] = KeyNames.KEY_U;
common_scanmap['I'.charCodeAt(0)] = KeyNames.KEY_I;
common_scanmap['O'.charCodeAt(0)] = KeyNames.KEY_O;
common_scanmap['P'.charCodeAt(0)] = KeyNames.KEY_P;
common_scanmap['A'.charCodeAt(0)] = KeyNames.KEY_A;
common_scanmap['S'.charCodeAt(0)] = KeyNames.KEY_S;
common_scanmap['D'.charCodeAt(0)] = KeyNames.KEY_D;
common_scanmap['F'.charCodeAt(0)] = KeyNames.KEY_F;
common_scanmap['G'.charCodeAt(0)] = KeyNames.KEY_G;
common_scanmap['H'.charCodeAt(0)] = KeyNames.KEY_H;
common_scanmap['J'.charCodeAt(0)] = KeyNames.KEY_J;
common_scanmap['K'.charCodeAt(0)] = KeyNames.KEY_K;
common_scanmap['L'.charCodeAt(0)] = KeyNames.KEY_L;
common_scanmap['Z'.charCodeAt(0)] = KeyNames.KEY_Z;
common_scanmap['X'.charCodeAt(0)] = KeyNames.KEY_X;
common_scanmap['C'.charCodeAt(0)] = KeyNames.KEY_C;
common_scanmap['V'.charCodeAt(0)] = KeyNames.KEY_V;
common_scanmap['B'.charCodeAt(0)] = KeyNames.KEY_B;
common_scanmap['N'.charCodeAt(0)] = KeyNames.KEY_N;
common_scanmap['M'.charCodeAt(0)] = KeyNames.KEY_M;
common_scanmap[' '.charCodeAt(0)] = KeyNames.KEY_Space;
common_scanmap[13] = KeyNames.KEY_Enter;
common_scanmap[27] = KeyNames.KEY_Escape;
common_scanmap[8] = KeyNames.KEY_BackSpace;
common_scanmap[9] = KeyNames.KEY_Tab;
common_scanmap[16] = KeyNames.KEY_ShiftL;
common_scanmap[17] = KeyNames.KEY_LCtrl;
common_scanmap[18] = KeyNames.KEY_Alt;
common_scanmap[20] = KeyNames.KEY_CapsLock;
common_scanmap[44] = KeyNames.KEY_SysReqest;
common_scanmap[144] = KeyNames.KEY_NumLock;
common_scanmap[112] = KeyNames.KEY_F1;
common_scanmap[113] = KeyNames.KEY_F2;
common_scanmap[114] = KeyNames.KEY_F3;
common_scanmap[115] = KeyNames.KEY_F4;
common_scanmap[116] = KeyNames.KEY_F5;
common_scanmap[117] = KeyNames.KEY_F6;
common_scanmap[118] = KeyNames.KEY_F7;
common_scanmap[119] = KeyNames.KEY_F8;
common_scanmap[120] = KeyNames.KEY_F9;
common_scanmap[121] = KeyNames.KEY_F10;
common_scanmap[122] = KeyNames.KEY_F11;
common_scanmap[123] = KeyNames.KEY_F12;
/* TODO: Break and Print are complex scan codes. XSpice cheats and
uses Xorg synthesized codes to simplify them. Fixing this will
require XSpice to handle the scan codes correctly, and then
fix spice-html5 to send the complex scan codes. */
common_scanmap[42] = 99; // Print, XSpice only
common_scanmap[19] = 101;// Break, XSpice only
/* Handle the so called 'GREY' keys, for the extended keys that
were grey on the original AT keyboard. These are
prefixed, as they were on the PS/2 controller, with an
0xE0 byte to indicate that they are extended */
common_scanmap[111] = 0xE0 | (KeyNames.KEY_Slash << 8);// KP_Divide
common_scanmap[106] = 0xE0 | (KeyNames.KEY_KP_Multiply << 8); // KP_Multiply
common_scanmap[36] = 0xE0 | (KeyNames.KEY_KP_7 << 8); // Home
common_scanmap[38] = 0xE0 | (KeyNames.KEY_KP_8 << 8); // Up
common_scanmap[33] = 0xE0 | (KeyNames.KEY_KP_9 << 8); // PgUp
common_scanmap[37] = 0xE0 | (KeyNames.KEY_KP_4 << 8); // Left
common_scanmap[39] = 0xE0 | (KeyNames.KEY_KP_6 << 8); // Right
common_scanmap[35] = 0xE0 | (KeyNames.KEY_KP_1 << 8); // End
common_scanmap[40] = 0xE0 | (KeyNames.KEY_KP_2 << 8); // Down
common_scanmap[34] = 0xE0 | (KeyNames.KEY_KP_3 << 8); // PgDown
common_scanmap[45] = 0xE0 | (KeyNames.KEY_KP_0 << 8); // Insert
common_scanmap[46] = 0xE0 | (KeyNames.KEY_KP_Decimal << 8); // Delete
common_scanmap[91] = 0xE0 | (0x5B << 8); //KeyNames.KEY_LMeta
common_scanmap[92] = 0xE0 | (0x5C << 8); //KeyNames.KEY_RMeta
common_scanmap[93] = 0xE0 | (0x5D << 8); //KeyNames.KEY_Menu
/* Firefox/Mozilla codes */
var firefox_scanmap = [];
firefox_scanmap[173] = KeyNames.KEY_Minus;
firefox_scanmap[61] = KeyNames.KEY_Equal;
firefox_scanmap[59] = KeyNames.KEY_SemiColon;
/* DOM3 codes */
var DOM_scanmap = [];
DOM_scanmap[189] = KeyNames.KEY_Minus;
DOM_scanmap[187] = KeyNames.KEY_Equal;
DOM_scanmap[186] = KeyNames.KEY_SemiColon;
function get_scancode(keyCode, code)
{
if (code_to_scancode[code] !== undefined) {
return code_to_scancode[code];
}
if (common_scanmap[keyCode] === undefined)
{
if (navigator.userAgent.indexOf("Firefox") != -1)
return firefox_scanmap[keyCode];
else
return DOM_scanmap[keyCode];
}
else
return common_scanmap[keyCode];
}
function keycode_to_start_scan(keyCode, code)
{
var scancode = get_scancode(keyCode, code);
if (scancode === undefined)
{
/* Not an alert: a modal steals the keyboard mid-typing, and an
unmapped key (e.g. Firefox reports keyCode 224 for Meta) is
an ordinary event, not an emergency. */
console.warn('no map for ' + keyCode);
return 0;
}
return scancode;
}
function keycode_to_end_scan(keyCode, code)
{
var scancode = get_scancode(keyCode, code);
if (scancode === undefined)
return 0;
if (scancode < 0x100) {
return scancode | 0x80;
} else {
return scancode | 0x8000;
}
}
function dump_media_element(m)
{
var ret =
"[networkState " + m.networkState +
"|readyState " + m.readyState +
"|error " + m.error +
"|seeking " + m.seeking +
"|duration " + m.duration +
"|paused " + m.paused +
"|ended " + m.error +
"|buffered " + dump_timerange(m.buffered) +
"]";
return ret;
}
function dump_media_source(ms)
{
var ret =
"[duration " + ms.duration +
"|readyState " + ms.readyState + "]";
return ret;
}
function dump_source_buffer(sb)
{
var ret =
"[appendWindowStart " + sb.appendWindowStart +
"|appendWindowEnd " + sb.appendWindowEnd +
"|buffered " + dump_timerange(sb.buffered) +
"|timeStampOffset " + sb.timeStampOffset +
"|updating " + sb.updating +
"]";
return ret;
}
function dump_timerange(tr)
{
var ret;
if (tr)
{
var i = tr.length;
ret = "{len " + i;
if (i > 0)
ret += "; start " + tr.start(0) + "; end " + tr.end(i - 1);
ret += "}";
}
else
ret = "N/A";
return ret;
}
export {
DEBUG,
PLAYBACK_DEBUG,
STREAM_DEBUG,
DUMP_DRAWS,
DUMP_CANVASES,
EMPTY_GIF_IMAGE,
combine_array_buffers,
hexdump_buffer,
arraybuffer_to_str,
keycode_to_start_scan,
keycode_to_end_scan,
dump_media_element,
dump_media_source,
dump_source_buffer,
};
-673
View File
@@ -1,673 +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/>.
*/
/*----------------------------------------------------------------------------
** EBML identifiers
**--------------------------------------------------------------------------*/
var EBML_HEADER = [ 0x1a, 0x45, 0xdf, 0xa3 ];
var EBML_HEADER_VERSION = [ 0x42, 0x86 ];
var EBML_HEADER_READ_VERSION = [ 0x42, 0xf7 ];
var EBML_HEADER_MAX_ID_LENGTH = [ 0x42, 0xf2 ];
var EBML_HEADER_MAX_SIZE_LENGTH = [ 0x42, 0xf3 ];
var EBML_HEADER_DOC_TYPE = [ 0x42, 0x82 ];
var EBML_HEADER_DOC_TYPE_VERSION = [ 0x42, 0x87 ];
var EBML_HEADER_DOC_TYPE_READ_VERSION = [ 0x42, 0x85 ];
var WEBM_SEGMENT_HEADER = [ 0x18, 0x53, 0x80, 0x67 ];
var WEBM_SEGMENT_INFORMATION = [ 0x15, 0x49, 0xA9, 0x66 ];
var WEBM_TIMECODE_SCALE = [ 0x2A, 0xD7, 0xB1 ];
var WEBM_MUXING_APP = [ 0x4D, 0x80 ];
var WEBM_WRITING_APP = [ 0x57, 0x41 ];
var WEBM_SEEK_HEAD = [ 0x11, 0x4D, 0x9B, 0x74 ];
var WEBM_SEEK = [ 0x4D, 0xBB ];
var WEBM_SEEK_ID = [ 0x53, 0xAB ];
var WEBM_SEEK_POSITION = [ 0x53, 0xAC ];
var WEBM_TRACKS = [ 0x16, 0x54, 0xAE, 0x6B ];
var WEBM_TRACK_ENTRY = [ 0xAE ];
var WEBM_TRACK_NUMBER = [ 0xD7 ];
var WEBM_TRACK_UID = [ 0x73, 0xC5 ];
var WEBM_TRACK_TYPE = [ 0x83 ];
var WEBM_FLAG_ENABLED = [ 0xB9 ];
var WEBM_FLAG_DEFAULT = [ 0x88 ];
var WEBM_FLAG_FORCED = [ 0x55, 0xAA ];
var WEBM_FLAG_LACING = [ 0x9C ];
var WEBM_MIN_CACHE = [ 0x6D, 0xE7 ];
var WEBM_MAX_BLOCK_ADDITION_ID = [ 0x55, 0xEE ];
var WEBM_CODEC_DECODE_ALL = [ 0xAA ];
var WEBM_SEEK_PRE_ROLL = [ 0x56, 0xBB ];
var WEBM_CODEC_DELAY = [ 0x56, 0xAA ];
var WEBM_CODEC_PRIVATE = [ 0x63, 0xA2 ];
var WEBM_CODEC_ID = [ 0x86 ];
var WEBM_VIDEO = [ 0xE0 ] ;
var WEBM_FLAG_INTERLACED = [ 0x9A ] ;
var WEBM_PIXEL_WIDTH = [ 0xB0 ] ;
var WEBM_PIXEL_HEIGHT = [ 0xBA ] ;
var WEBM_AUDIO = [ 0xE1 ] ;
var WEBM_SAMPLING_FREQUENCY = [ 0xB5 ] ;
var WEBM_CHANNELS = [ 0x9F ] ;
var WEBM_CLUSTER = [ 0x1F, 0x43, 0xB6, 0x75 ];
var WEBM_TIME_CODE = [ 0xE7 ] ;
var WEBM_SIMPLE_BLOCK = [ 0xA3 ] ;
/*----------------------------------------------------------------------------
** Various OPUS / Webm constants
**--------------------------------------------------------------------------*/
var Constants = {
CLUSTER_SIMPLEBLOCK_FLAG_KEYFRAME : 1 << 7,
OPUS_FREQUENCY : 48000,
OPUS_CHANNELS : 2,
SPICE_PLAYBACK_CODEC : 'audio/webm; codecs="opus"',
MAX_CLUSTER_TIME : 1000,
EXPECTED_PACKET_DURATION : 10,
GAP_DETECTION_THRESHOLD : 50,
SPICE_VP8_CODEC : 'video/webm; codecs="vp8"',
};
/*----------------------------------------------------------------------------
** EBML utility functions
** These classes can create the binary representation of a webm file
**--------------------------------------------------------------------------*/
function EBML_write_u1_data_len(len, dv, at)
{
var b = 0x80 | len;
dv.setUint8(at, b);
return at + 1;
}
function EBML_write_u8_value(id, val, dv, at)
{
at = EBML_write_array(id, dv, at);
at = EBML_write_u1_data_len(1, dv, at);
dv.setUint8(at, val);
return at + 1;
}
function EBML_write_u32_value(id, val, dv, at)
{
at = EBML_write_array(id, dv, at);
at = EBML_write_u1_data_len(4, dv, at);
dv.setUint32(at, val);
return at + 4;
}
function EBML_write_u16_value(id, val, dv, at)
{
at = EBML_write_array(id, dv, at);
at = EBML_write_u1_data_len(2, dv, at);
dv.setUint16(at, val);
return at + 2;
}
function EBML_write_float_value(id, val, dv, at)
{
at = EBML_write_array(id, dv, at);
at = EBML_write_u1_data_len(4, dv, at);
dv.setFloat32(at, val);
return at + 4;
}
function EBML_write_u64_data_len(len, dv, at)
{
/* Javascript doesn't do 64 bit ints, so this cheats and
just has a max of 32 bits. Fine for our purposes */
dv.setUint8(at++, 0x01);
dv.setUint8(at++, 0x00);
dv.setUint8(at++, 0x00);
dv.setUint8(at++, 0x00);
var val = len & 0xFFFFFFFF;
for (var shift = 24; shift >= 0; shift -= 8)
dv.setUint8(at++, val >> shift);
return at;
}
function EBML_write_array(arr, dv, at)
{
for (var i = 0; i < arr.length; i++)
dv.setUint8(at + i, arr[i]);
return at + arr.length;
}
function EBML_write_string(str, dv, at)
{
for (var i = 0; i < str.length; i++)
dv.setUint8(at + i, str.charCodeAt(i));
return at + str.length;
}
function EBML_write_data(id, data, dv, at)
{
at = EBML_write_array(id, dv, at);
if (data.length < 127)
at = EBML_write_u1_data_len(data.length, dv, at);
else
at = EBML_write_u64_data_len(data.length, dv, at);
if ((typeof data) == "string")
at = EBML_write_string(data, dv, at);
else
at = EBML_write_array(data, dv, at);
return at;
}
/*----------------------------------------------------------------------------
** Webm objects
** These classes can create the binary representation of a webm file
**--------------------------------------------------------------------------*/
function EBMLHeader()
{
this.id = EBML_HEADER;
this.Version = 1;
this.ReadVersion = 1;
this.MaxIDLength = 4;
this.MaxSizeLength = 8;
this.DocType = "webm";
this.DocTypeVersion = 2; /* Not well specified by the WebM guys, but functionally required for Firefox */
this.DocTypeReadVersion = 2;
}
EBMLHeader.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(0x1f, dv, at);
at = EBML_write_u8_value(EBML_HEADER_VERSION, this.Version, dv, at);
at = EBML_write_u8_value(EBML_HEADER_READ_VERSION, this.ReadVersion, dv, at);
at = EBML_write_u8_value(EBML_HEADER_MAX_ID_LENGTH, this.MaxIDLength, dv, at);
at = EBML_write_u8_value(EBML_HEADER_MAX_SIZE_LENGTH, this.MaxSizeLength, dv, at);
at = EBML_write_data(EBML_HEADER_DOC_TYPE, this.DocType, dv, at);
at = EBML_write_u8_value(EBML_HEADER_DOC_TYPE_VERSION, this.DocTypeVersion, dv, at);
at = EBML_write_u8_value(EBML_HEADER_DOC_TYPE_READ_VERSION, this.DocTypeReadVersion, dv, at);
return at;
},
buffer_size: function()
{
return 0x1f + 8 + this.id.length;
},
}
function webm_Segment()
{
this.id = WEBM_SEGMENT_HEADER;
}
webm_Segment.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
dv.setUint8(at++, 0xff);
return at;
},
buffer_size: function()
{
return this.id.length + 1;
},
}
function webm_SegmentInformation()
{
this.id = WEBM_SEGMENT_INFORMATION;
this.timecode_scale = 1000000; /* 1 ms */
this.muxing_app = "spice";
this.writing_app = "spice-html5";
}
webm_SegmentInformation.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(this.buffer_size() - 8 - this.id.length, dv, at);
at = EBML_write_u32_value(WEBM_TIMECODE_SCALE, this.timecode_scale, dv, at);
at = EBML_write_data(WEBM_MUXING_APP, this.muxing_app, dv, at);
at = EBML_write_data(WEBM_WRITING_APP, this.writing_app, dv, at);
return at;
},
buffer_size: function()
{
return this.id.length + 8 +
WEBM_TIMECODE_SCALE.length + 1 + 4 +
WEBM_MUXING_APP.length + 1 + this.muxing_app.length +
WEBM_WRITING_APP.length + 1 + this.writing_app.length;
},
}
function webm_Audio(frequency)
{
this.id = WEBM_AUDIO;
this.sampling_frequency = frequency;
this.channels = Constants.OPUS_CHANNELS;
}
webm_Audio.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(this.buffer_size() - 8 - this.id.length, dv, at);
at = EBML_write_u8_value(WEBM_CHANNELS, this.channels, dv, at);
at = EBML_write_float_value(WEBM_SAMPLING_FREQUENCY, this.sampling_frequency, dv, at);
return at;
},
buffer_size: function()
{
return this.id.length + 8 +
WEBM_SAMPLING_FREQUENCY.length + 1 + 4 +
WEBM_CHANNELS.length + 1 + 1;
},
}
function webm_Video(width, height)
{
this.id = WEBM_VIDEO;
this.flag_interlaced = 0;
this.width = width;
this.height = height;
}
webm_Video.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(this.buffer_size() - 8 - this.id.length, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_INTERLACED, this.flag_interlaced, dv, at);
at = EBML_write_u16_value(WEBM_PIXEL_WIDTH, this.width, dv, at)
at = EBML_write_u16_value(WEBM_PIXEL_HEIGHT, this.height, dv, at)
return at;
},
buffer_size: function()
{
return this.id.length + 8 +
WEBM_FLAG_INTERLACED.length + 1 + 1 +
WEBM_PIXEL_WIDTH.length + 1 + 2 +
WEBM_PIXEL_HEIGHT.length + 1 + 2;
},
}
/* ---------------------------
SeekHead not currently used. Hopefully not needed.
*/
function webm_Seek(seekid, pos)
{
this.id = WEBM_SEEK;
this.pos = pos;
this.seekid = seekid;
}
webm_Seek.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u1_data_len(this.buffer_size() - 1 - this.id.length, dv, at);
at = EBML_write_data(WEBM_SEEK_ID, this.seekid, dv, at)
at = EBML_write_u16_value(WEBM_SEEK_POSITION, this.pos, dv, at)
return at;
},
buffer_size: function()
{
return this.id.length + 1 +
WEBM_SEEK_ID.length + 1 + this.seekid.length +
WEBM_SEEK_POSITION.length + 1 + 2;
},
}
function webm_SeekHead(info_pos, track_pos)
{
this.id = WEBM_SEEK_HEAD;
this.info = new webm_Seek(WEBM_SEGMENT_INFORMATION, info_pos);
this.track = new webm_Seek(WEBM_TRACKS, track_pos);
}
webm_SeekHead.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(this.buffer_size() - 8 - this.id.length, dv, at);
at = this.info.to_buffer(a, at);
at = this.track.to_buffer(a, at);
return at;
},
buffer_size: function()
{
return this.id.length + 8 +
this.info.buffer_size() +
this.track.buffer_size();
},
}
/* -------------------------------
End of Seek Head
*/
function webm_AudioTrackEntry()
{
this.id = WEBM_TRACK_ENTRY;
this.number = 1;
this.uid = 2; // Arbitrary id; most likely makes no difference
this.type = 2; // Audio
this.flag_enabled = 1;
this.flag_default = 1;
this.flag_forced = 1;
this.flag_lacing = 0;
this.min_cache = 0; // fixme - check
this.max_block_addition_id = 0;
this.codec_decode_all = 0; // fixme - check
this.seek_pre_roll = 0; // 80000000; // fixme - check
this.codec_delay = 80000000; // Must match codec_private.preskip
this.codec_id = "A_OPUS";
this.audio = new webm_Audio(Constants.OPUS_FREQUENCY);
// See: http://tools.ietf.org/html/draft-terriberry-oggopus-01
this.codec_private = [ 0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // OpusHead
0x01, // Version
Constants.OPUS_CHANNELS,
0x00, 0x0F, // Preskip - 3840 samples - should be 8ms at 48kHz
0x80, 0xbb, 0x00, 0x00, // 48000
0x00, 0x00, // Output gain
0x00 // Channel mapping family
];
}
webm_AudioTrackEntry.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(this.buffer_size() - 8 - this.id.length, dv, at);
at = EBML_write_u8_value(WEBM_TRACK_NUMBER, this.number, dv, at);
at = EBML_write_u8_value(WEBM_TRACK_UID, this.uid, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_ENABLED, this.flag_enabled, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_DEFAULT, this.flag_default, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_FORCED, this.flag_forced, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_LACING, this.flag_lacing, dv, at);
at = EBML_write_data(WEBM_CODEC_ID, this.codec_id, dv, at);
at = EBML_write_u8_value(WEBM_MIN_CACHE, this.min_cache, dv, at);
at = EBML_write_u8_value(WEBM_MAX_BLOCK_ADDITION_ID, this.max_block_addition_id, dv, at);
at = EBML_write_u8_value(WEBM_CODEC_DECODE_ALL, this.codec_decode_all, dv, at);
at = EBML_write_u32_value(WEBM_CODEC_DELAY, this.codec_delay, dv, at);
at = EBML_write_u32_value(WEBM_SEEK_PRE_ROLL, this.seek_pre_roll, dv, at);
at = EBML_write_u8_value(WEBM_TRACK_TYPE, this.type, dv, at);
at = EBML_write_data(WEBM_CODEC_PRIVATE, this.codec_private, dv, at);
at = this.audio.to_buffer(a, at);
return at;
},
buffer_size: function()
{
return this.id.length + 8 +
WEBM_TRACK_NUMBER.length + 1 + 1 +
WEBM_TRACK_UID.length + 1 + 1 +
WEBM_TRACK_TYPE.length + 1 + 1 +
WEBM_FLAG_ENABLED.length + 1 + 1 +
WEBM_FLAG_DEFAULT.length + 1 + 1 +
WEBM_FLAG_FORCED.length + 1 + 1 +
WEBM_FLAG_LACING.length + 1 + 1 +
WEBM_MIN_CACHE.length + 1 + 1 +
WEBM_MAX_BLOCK_ADDITION_ID.length + 1 + 1 +
WEBM_CODEC_DECODE_ALL.length + 1 + 1 +
WEBM_SEEK_PRE_ROLL.length + 1 + 4 +
WEBM_CODEC_DELAY.length + 1 + 4 +
WEBM_CODEC_ID.length + this.codec_id.length + 1 +
WEBM_CODEC_PRIVATE.length + 1 + this.codec_private.length +
this.audio.buffer_size();
},
}
function webm_VideoTrackEntry(width, height)
{
/*
** In general, we follow specifications found by looking here:
** https://www.webmproject.org/docs/container/
** which points here:
** https://www.matroska.org/technical/specs/index.html
** and here:
** https://datatracker.ietf.org/doc/draft-ietf-cellar-matroska/
** Our goal is to supply mandatory values, and note where we differ
** from the default.
*/
this.id = WEBM_TRACK_ENTRY;
this.number = 1;
this.uid = 1;
this.type = 1; // Video
this.flag_enabled = 1;
this.flag_default = 1;
this.flag_forced = 1; // Different than default; we wish to force
this.flag_lacing = 1;
this.min_cache = 0;
this.max_block_addition_id = 0;
this.codec_id = "V_VP8";
this.codec_decode_all = 1;
this.seek_pre_roll = 0;
this.video = new webm_Video(width, height);
}
webm_VideoTrackEntry.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(this.buffer_size() - 8 - this.id.length, dv, at);
at = EBML_write_u8_value(WEBM_TRACK_NUMBER, this.number, dv, at);
at = EBML_write_u8_value(WEBM_TRACK_UID, this.uid, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_ENABLED, this.flag_enabled, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_DEFAULT, this.flag_default, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_FORCED, this.flag_forced, dv, at);
at = EBML_write_u8_value(WEBM_FLAG_LACING, this.flag_lacing, dv, at);
at = EBML_write_data(WEBM_CODEC_ID, this.codec_id, dv, at);
at = EBML_write_u8_value(WEBM_MIN_CACHE, this.min_cache, dv, at);
at = EBML_write_u8_value(WEBM_MAX_BLOCK_ADDITION_ID, this.max_block_addition_id, dv, at);
at = EBML_write_u8_value(WEBM_CODEC_DECODE_ALL, this.codec_decode_all, dv, at);
at = EBML_write_u32_value(WEBM_SEEK_PRE_ROLL, this.seek_pre_roll, dv, at);
at = EBML_write_u8_value(WEBM_TRACK_TYPE, this.type, dv, at);
at = this.video.to_buffer(a, at);
return at;
},
buffer_size: function()
{
return this.id.length + 8 +
WEBM_TRACK_NUMBER.length + 1 + 1 +
WEBM_TRACK_UID.length + 1 + 1 +
WEBM_FLAG_ENABLED.length + 1 + 1 +
WEBM_FLAG_DEFAULT.length + 1 + 1 +
WEBM_FLAG_FORCED.length + 1 + 1 +
WEBM_FLAG_LACING.length + 1 + 1 +
WEBM_CODEC_ID.length + this.codec_id.length + 1 +
WEBM_MIN_CACHE.length + 1 + 1 +
WEBM_MAX_BLOCK_ADDITION_ID.length + 1 + 1 +
WEBM_CODEC_DECODE_ALL.length + 1 + 1 +
WEBM_SEEK_PRE_ROLL.length + 1 + 4 +
WEBM_TRACK_TYPE.length + 1 + 1 +
this.video.buffer_size();
},
}
function webm_Tracks(entry)
{
this.id = WEBM_TRACKS;
this.track_entry = entry;
}
webm_Tracks.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(this.buffer_size() - 8 - this.id.length, dv, at);
at = this.track_entry.to_buffer(a, at);
return at;
},
buffer_size: function()
{
return this.id.length + 8 +
this.track_entry.buffer_size();
},
}
function webm_Cluster(timecode, data)
{
this.id = WEBM_CLUSTER;
this.timecode = timecode;
this.data = data;
}
webm_Cluster.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
dv.setUint8(at++, 0xff);
at = EBML_write_u32_value(WEBM_TIME_CODE, this.timecode, dv, at);
return at;
},
buffer_size: function()
{
return this.id.length + 1 +
WEBM_TIME_CODE.length + 1 + 4;
},
}
function webm_SimpleBlock(timecode, data, keyframe)
{
this.id = WEBM_SIMPLE_BLOCK;
this.timecode = timecode;
this.data = data;
this.keyframe = keyframe;
}
webm_SimpleBlock.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
var dv = new DataView(a);
at = EBML_write_array(this.id, dv, at);
at = EBML_write_u64_data_len(this.data.byteLength + 4, dv, at);
at = EBML_write_u1_data_len(1, dv, at); // Track #
dv.setUint16(at, this.timecode); at += 2; // timecode - relative to cluster
dv.setUint8(at, this.keyframe ? Constants.CLUSTER_SIMPLEBLOCK_FLAG_KEYFRAME : 0); at += 1; // flags
// FIXME - There should be a better way to copy
var u8 = new Uint8Array(this.data);
for (var i = 0; i < this.data.byteLength; i++)
dv.setUint8(at++, u8[i]);
return at;
},
buffer_size: function()
{
return this.id.length + 8 +
1 + 2 + 1 +
this.data.byteLength;
},
}
function webm_Header()
{
this.ebml = new EBMLHeader;
this.segment = new webm_Segment;
this.seek_head = new webm_SeekHead(0, 0);
this.seek_head.info.pos = this.segment.buffer_size() + this.seek_head.buffer_size();
this.info = new webm_SegmentInformation;
this.seek_head.track.pos = this.seek_head.info.pos + this.info.buffer_size();
}
webm_Header.prototype =
{
to_buffer: function(a, at)
{
at = at || 0;
at = this.ebml.to_buffer(a, at);
at = this.segment.to_buffer(a, at);
at = this.info.to_buffer(a, at);
return at;
},
buffer_size: function()
{
return this.ebml.buffer_size() +
this.segment.buffer_size() +
this.info.buffer_size();
},
}
export {
Constants,
webm_Audio as Audio,
webm_Video as Video,
webm_AudioTrackEntry as AudioTrackEntry,
webm_VideoTrackEntry as VideoTrackEntry,
webm_Tracks as Tracks,
webm_Cluster as Cluster,
webm_SimpleBlock as SimpleBlock,
webm_Header as Header,
};
-140
View File
@@ -1,140 +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/>.
*/
/*--------------------------------------------------------------------------------------
** SpiceWireReader
** This class will receive messages from a WebSocket and relay it to a given
** callback. It will optionally save and pass along a header, useful in processing
** the mini message format.
**--------------------------------------------------------------------------------------*/
import { DEBUG } from './utils.js';
import { combine_array_buffers } from './utils.js';
function SpiceWireReader(sc, callback)
{
this.sc = sc;
this.callback = callback;
this.needed = 0;
this.size = 0;
this.buffers = [];
this.sc.ws.wire_reader = this;
this.sc.ws.binaryType = "arraybuffer";
this.sc.ws.addEventListener('message', wire_blob_catcher);
}
SpiceWireReader.prototype =
{
/*------------------------------------------------------------------------
** Process messages coming in from our WebSocket
**----------------------------------------------------------------------*/
inbound: function (mb)
{
/* Just buffer if we don't need anything yet */
if (this.needed == 0)
{
this.buffers.push(mb);
this.size += mb.byteLength;
return;
}
/* Optimization - if we have just one inbound block, and it's
suitable for our needs, just use it. */
if (this.buffers.length == 0 && mb.byteLength >= this.needed)
{
if (mb.byteLength > this.needed)
{
this.size = mb.byteLength - this.needed;
this.buffers.push(mb.slice(this.needed));
mb = mb.slice(0, this.needed);
}
this.callback.call(this.sc, mb,
this.saved_msg_header || undefined);
}
else
{
this.buffers.push(mb);
this.size += mb.byteLength;
}
/* Optimization - All it takes is one combine */
while (this.size >= this.needed) {
var count = 0;
var frame = new ArrayBuffer(this.needed);
var view = new Uint8Array(frame);
while (count < frame.byteLength && this.buffers.length > 0) {
var buf = this.buffers.shift();
if (!buf) {
return;
}
var uint8 = new Uint8Array(buf);
var step = frame.byteLength - count;
/* Optimization - use dataview.set() instead of combine_array_buffers() */
if (uint8.length <= step) {
view.set(uint8, count);
count += uint8.length;
this.size -= uint8.length;
} else {
var temp = uint8.slice(0, step);
view.set(temp, count);
count += temp.length;
this.size -= temp.length;
this.buffers.unshift(uint8.slice(step));
}
}
this.callback.call(this.sc, frame, this.saved_msg_header || undefined);
}
},
request: function(n)
{
this.needed = n;
},
save_header: function(h)
{
this.saved_msg_header = h;
},
clear_header: function()
{
this.saved_msg_header = undefined;
},
}
function wire_blob_catcher(e)
{
DEBUG > 1 && console.log(">> WebSockets.onmessage");
DEBUG > 1 && console.log("id " + this.wire_reader.sc.connection_id +"; type " + this.wire_reader.sc.type);
SpiceWireReader.prototype.inbound.call(this.wire_reader, e.data);
}
export {
SpiceWireReader,
};
+241
View File
@@ -0,0 +1,241 @@
// The 360° view: an equirectangular video drawn onto a canvas as the flat picture a person
// would see looking out from the middle of it. Drag (or swipe) to look around, pinch or scroll
// to zoom, and on a phone, optionally, turn the phone. One full-canvas triangle pair; the
// fragment shader works out, for each pixel, which way it looks and where that is in the video.
const VERT = `
attribute vec2 a;
varying vec2 p;
void main() { p = a; gl_Position = vec4(a, 0.0, 1.0); }`
const FRAG = `
precision highp float;
uniform sampler2D tex;
uniform mat3 view; // columns: right, up, forward
uniform vec2 aspect; // the screen's sides over its taller side: (w/h, 1) or (1, h/w)
uniform float fov; // radians, across the taller side
varying vec2 p;
const float PI = 3.14159265358979;
void main() {
vec2 q = p * aspect;
// Narrow: an ordinary lens. Wide: stereographic, which goes on past 180 degrees to the
// "little planet". Between 100 and 140 degrees one turns into the other.
vec3 rect = normalize(vec3(q * tan(min(fov, 3.0) * 0.5), 1.0));
float r = length(q) * 2.0 * tan(fov * 0.25);
float th = 2.0 * atan(r * 0.5);
vec2 dir = r > 0.0 ? q / length(q) : vec2(0.0);
vec3 stereo = vec3(dir * sin(th), cos(th));
float t = smoothstep(1.745, 2.443, fov);
vec3 d = normalize(view * normalize(mix(rect, stereo, t)));
float lon = atan(d.x, -d.z);
float lat = asin(clamp(d.y, -1.0, 1.0));
gl_FragColor = texture2D(tex, vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI));
}`
// From a close look at something far off to the whole sphere as a little planet.
const MIN_FOV = 8, MAX_FOV = 300
type Vec3 = [number, number, number]
type Quat = [number, number, number, number] // x, y, z, w
function qmul(a: Quat, b: Quat): Quat {
const [ax, ay, az, aw] = a, [bx, by, bz, bw] = b
return [aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz]
}
function qrot(q: Quat, v: Vec3): Vec3 {
const [x, y, z, w] = q
const ix = w * v[0] + y * v[2] - z * v[1], iy = w * v[1] + z * v[0] - x * v[2], iz = w * v[2] + x * v[1] - y * v[0], iw = -x * v[0] - y * v[1] - z * v[2]
return [ix * w + iw * -x + iy * -z - iz * -y, iy * w + iw * -y + iz * -x - ix * -z, iz * w + iw * -z + ix * -y - iy * -x]
}
const axis = (v: Vec3, rad: number): Quat => { const s = Math.sin(rad / 2); return [v[0] * s, v[1] * s, v[2] * s, Math.cos(rad / 2)] }
// The phone's orientation as a camera's, the way the W3C spec defines the angles: Z-X'-Y''
// intrinsic, then turned to look out of the back of the phone, then for the screen's rotation.
function deviceQuat(alpha: number, beta: number, gamma: number, screen: number): Quat {
const d = Math.PI / 180
const [a, b, g] = [alpha * d, beta * d, gamma * d]
let q = qmul(qmul(axis([0, 1, 0], a), axis([1, 0, 0], b)), axis([0, 0, 1], -g)) // YXZ, in three.js's frame
q = qmul(q, [-Math.SQRT1_2, 0, 0, Math.SQRT1_2])
return qmul(q, axis([0, 0, 1], -screen * d))
}
export class Sphere {
yaw = 0 // degrees, + is right
pitch = 0 // degrees, + is up
fov = 80 // degrees, the taller of the two sides
private gl: WebGLRenderingContext
private tex: WebGLTexture
private uView: WebGLUniformLocation
private uAspect: WebGLUniformLocation
private uFov: WebGLUniformLocation
private raf = 0
private frameCb = 0
private dirty = true
private hasFrame = false
private gyro: { alpha: number; beta: number; gamma: number } | null = null
private gyroYaw = 0 // what dragging adds to the phone's own heading
private ro: ResizeObserver
maxTexture: number
constructor(private canvas: HTMLCanvasElement, private video: HTMLVideoElement) {
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, preserveDrawingBuffer: false })
if (!gl) throw new Error('This browser can’t draw 360° video (no WebGL).')
this.gl = gl
this.maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
const sh = (type: number, src: string) => {
const s = gl.createShader(type)!
gl.shaderSource(s, src); gl.compileShader(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'shader')
return s
}
const prog = gl.createProgram()!
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG))
gl.linkProgram(prog); gl.useProgram(prog)
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
const loc = gl.getAttribLocation(prog, 'a')
gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0)
this.tex = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, this.tex)
// No mipmaps (a video changes every frame) and clamped: any size of picture will do.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0]))
this.uView = gl.getUniformLocation(prog, 'view')!
this.uAspect = gl.getUniformLocation(prog, 'aspect')!
this.uFov = gl.getUniformLocation(prog, 'fov')!
this.ro = new ResizeObserver(() => { this.dirty = true })
this.ro.observe(canvas)
this.watchFrames()
const loop = () => { this.draw(); this.raf = requestAnimationFrame(loop) }
this.raf = requestAnimationFrame(loop)
}
// A new picture from the video: on the frame callback where there is one, else every draw
// while it plays.
private watchFrames() {
const v = this.video as HTMLVideoElement & { requestVideoFrameCallback?: (cb: () => void) => number }
if (v.requestVideoFrameCallback) {
const cb = () => { this.upload(); this.frameCb = v.requestVideoFrameCallback!(cb) }
this.frameCb = v.requestVideoFrameCallback(cb)
}
v.addEventListener('loadeddata', this.upload)
v.addEventListener('seeked', this.upload)
}
private upload = () => {
const v = this.video
if (v.readyState < 2 || !v.videoWidth) return
const gl = this.gl
gl.bindTexture(gl.TEXTURE_2D, this.tex)
try {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, v)
this.hasFrame = true
this.dirty = true
} catch { /* not ready yet */ }
}
private draw() {
const v = this.video as HTMLVideoElement & { requestVideoFrameCallback?: unknown }
if (!v.requestVideoFrameCallback && !v.paused) this.upload()
if (!this.dirty && !this.gyro) return
this.dirty = false
const c = this.canvas, dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = Math.round(c.clientWidth * dpr), h = Math.round(c.clientHeight * dpr)
if (!w || !h) return
if (c.width !== w || c.height !== h) { c.width = w; c.height = h }
const gl = this.gl
gl.viewport(0, 0, w, h)
if (!this.hasFrame) { gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); return }
// The field of view applies to the taller side; on a phone held upright that is the height.
let right: Vec3, up: Vec3, fwd: Vec3
if (this.gyro) {
const q = qmul(axis([0, 1, 0], (-this.gyroYaw * Math.PI) / 180), deviceQuat(this.gyro.alpha, this.gyro.beta, this.gyro.gamma, screenAngle()))
right = qrot(q, [1, 0, 0]); up = qrot(q, [0, 1, 0]); fwd = qrot(q, [0, 0, -1])
} else {
const y = (this.yaw * Math.PI) / 180, p = (this.pitch * Math.PI) / 180
fwd = [Math.sin(y) * Math.cos(p), Math.sin(p), -Math.cos(y) * Math.cos(p)]
right = [Math.cos(y), 0, Math.sin(y)]
up = [right[1] * fwd[2] - right[2] * fwd[1], right[2] * fwd[0] - right[0] * fwd[2], right[0] * fwd[1] - right[1] * fwd[0]]
}
// d = right·x + up·y + fwd·1, column-major
gl.uniformMatrix3fv(this.uView, false, new Float32Array([...right, ...up, ...fwd]))
gl.uniform2f(this.uAspect, w >= h ? 1 : w / h, w >= h ? h / w : 1)
gl.uniform1f(this.uFov, (this.fov * Math.PI) / 180)
gl.drawArrays(gl.TRIANGLES, 0, 6)
}
// Dragging by a pixel turns the view by the angle a pixel covers, so what is under the finger
// stays under it.
drag(dx: number, dy: number) {
const per = this.fov / Math.max(this.canvas.clientWidth, this.canvas.clientHeight)
if (this.gyro) this.gyroYaw = (this.gyroYaw - dx * per) % 360
else {
this.yaw = (this.yaw - dx * per) % 360
this.pitch = Math.max(-89, Math.min(89, this.pitch + dy * per))
}
this.dirty = true
}
zoom(factor: number) {
this.fov = Math.max(MIN_FOV, Math.min(MAX_FOV, this.fov * factor))
this.dirty = true
}
get gyroOn() { return this.gyro !== null }
private onOrient = (e: DeviceOrientationEvent) => {
if (e.alpha === null || e.beta === null || e.gamma === null) return
if (!this.gyro) {
// Keep looking where the drag left off: the phone's heading now is the drag's yaw.
const q = deviceQuat(e.alpha, e.beta, e.gamma, screenAngle())
const f = qrot(q, [0, 0, -1])
this.gyroYaw = this.yaw - (Math.atan2(f[0], -f[2]) * 180) / Math.PI
}
this.gyro = { alpha: e.alpha, beta: e.beta, gamma: e.gamma }
}
// Turning on motion asks for permission on an iPhone, which must happen in a tap.
async setGyro(on: boolean): Promise<boolean> {
if (!on) {
window.removeEventListener('deviceorientation', this.onOrient)
if (this.gyro) {
const q = qmul(axis([0, 1, 0], (-this.gyroYaw * Math.PI) / 180), deviceQuat(this.gyro.alpha, this.gyro.beta, this.gyro.gamma, screenAngle()))
const f = qrot(q, [0, 0, -1])
this.yaw = (Math.atan2(f[0], -f[2]) * 180) / Math.PI
this.pitch = (Math.asin(Math.max(-1, Math.min(1, f[1]))) * 180) / Math.PI
}
this.gyro = null
this.dirty = true
return false
}
const D = DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<string> }
if (D.requestPermission) {
try { if ((await D.requestPermission()) !== 'granted') return false } catch { return false }
}
window.addEventListener('deviceorientation', this.onOrient)
return true
}
destroy() {
cancelAnimationFrame(this.raf)
const v = this.video as HTMLVideoElement & { cancelVideoFrameCallback?: (n: number) => void }
if (this.frameCb && v.cancelVideoFrameCallback) v.cancelVideoFrameCallback(this.frameCb)
v.removeEventListener('loadeddata', this.upload)
v.removeEventListener('seeked', this.upload)
window.removeEventListener('deviceorientation', this.onOrient)
this.ro.disconnect()
this.gl.getExtension('WEBGL_lose_context')?.loseContext()
}
}
function screenAngle(): number {
const o = screen.orientation?.angle
if (typeof o === 'number') return o
return (window as unknown as { orientation?: number }).orientation ?? 0
}
export const canMotion = () => typeof DeviceOrientationEvent !== 'undefined' && matchMedia('(pointer: coarse)').matches