Compare commits
40
Commits
virtualization
...
main
@@ -752,3 +752,27 @@ before its own login).
|
||||
404 — harmless, but a stray.
|
||||
12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you
|
||||
can"), but worth a second look now that Users includes removing accounts.
|
||||
|
||||
# 2026-10-23: the rate obeyed, but not in step
|
||||
|
||||
**Graphs and numbers on Top moved on different beats, whatever the rate.** Every machine was
|
||||
sampling at exactly the set rate (measured: 500 ms between samples on all five nodes), so the
|
||||
sampler was not the fault. Four things in the console were:
|
||||
|
||||
1. ✅ **Graphs stepped once a point, not once a tick.** A point is the history window split 300
|
||||
ways — at 5 min that is 1 s — so at 250 or 500 ms the numbers changed two to four times for
|
||||
every move of a graph, and a narrow box (phone, net graph) moved once a *column*, every few
|
||||
seconds. The columns now slide by a fraction of one on every tick (`columnsAt` in
|
||||
`graphcols.ts`), the newest growing in from the right; a finished column still never changes.
|
||||
The per-core strips keep the last 18 ticks rather than stride points.
|
||||
2. ✅ **The process table, MHz, CPU watts and every GPU box ran on their own timer** — a
|
||||
`setInterval` on `/api/procs` with an arbitrary phase against the sensor stream, so half the
|
||||
page changed up to a tick after the other half. The table is now read on each sample and the
|
||||
two are committed together: one render per tick for the whole page.
|
||||
3. ✅ **GPU clients were walked at most once a second** (`gpuScanEvery`), so per-process GPU% and
|
||||
the engine meters lagged the device's numbers. Walked every sample now.
|
||||
4. ✅ **Hidden floors on other live views:** Sensors gallery (1 s), Network live rates (1 s) and
|
||||
speed-test needle (0.4–1 s), Docker live (2 s), ZFS ARC (2 s). All at the rate now.
|
||||
|
||||
Not changed: container status sensors are read every 15 s (`docker ps` plus an inspect) and
|
||||
filesystem usage every 30 s — slow-moving facts behind an exec or a statfs of every mount.
|
||||
|
||||
@@ -16,12 +16,13 @@ stub tab or a roadmap item.
|
||||
| Service | `hamlab.service`, `User=core`, `Restart=always`, binary at `/usr/local/bin/hamlab` | Same pattern as `calorie-counter.service`. Runs as `core` so `sudo` works in the terminal without a password, as on the SSH path. |
|
||||
| Listen | `10.0.0.234:8100` (free) | NPM is a container, so loopback-only won't do. Firewalling to NPM is a later hardening step. |
|
||||
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
||||
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
||||
| Ingress | NPM proxy host `lab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
||||
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
||||
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
|
||||
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
||||
| Capability, not policy | **A tab offers what the selected machine can do, detected live — never what a rule says should run there.** Where the software is missing but installable, Ham Lab installs it; where it cannot be installed or the hardware lacks it, the tab says so in a sentence and offers what does work. Every action lands on the machine in the switcher. | Mitchell, 2026-10-09, after the Virtualization plan's first draft excluded the Mac mini and `hamtv` on the strength of `hamlet/CLAUDE.md`'s deployment rules: *"I want the option to do it anywhere I want, unless the hardware / software literally doesn't support it. Ham Lab needs to be built in a general-purpose way that can run on any Linux system."* Those rules bind the agent's unasked deployments, not the product. |
|
||||
| Ham Lab's data lives in Ham Lab's folder | **Everything a tab manages sits under the machine's data directory (`config.DataDir()`), never at a convenient path elsewhere.** Existing material is imported by copying. The engine's runtime state (`/var/lib/docker`, `/var/lib/incus`) is the engine's; Ham Lab keeps what would rebuild it. | Mitchell, 2026-10-09, when the Virtualization plan pointed its ISO library at `/tank/software/isos/`: *"ALL data needs to be under a HamLab folder, because it manages its own data."* The same reason `core`'s compose files moved under `apps/hamlab/stacks/` on 2026-10-05. `grid`'s and the Mac's stacks at `~/stacks/<machine>/` were the standing exception; since 2026-10-10 the stacks code offers `<data dir>/stacks` as the default home everywhere and such folders only as discovered extras. |
|
||||
| No virtualization | **Ham Lab does not manage VMs or system containers** — no Incus, no QEMU, no passthrough, no VM consoles. It is a lightweight console; Docker is its one container engine. | Mitchell, 2026-10-15: *"Virtualization is out of scope for HamLab. This is supposed to be a lightweight application, and VMs and pass through and stuff is too much."* The tab built 2026-10-09 (Incus, phases 1–2, deployed as v0.1.0-196) was cut from `main` the same day and kept on the **`virtualization` branch** (its last commit is `main` as of `a2674be`, with `VIRTUALIZATION.md` and the vendored spice-html5). Don't rebuild it on `main`. |
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -36,7 +37,7 @@ stub tab or a roadmap item.
|
||||
│ Cron │ │
|
||||
│ … │ │
|
||||
├──────────────┴───────────────────────────────────────────────┤
|
||||
│ footer: v0.1.0 · up 12d · hamlab.bigham.dev │
|
||||
│ footer: v0.1.0 · up 12d · lab.bigham.dev │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -115,7 +116,7 @@ hamlab/
|
||||
- Mobile drawer, key bar, fit-on-rotate.
|
||||
- `manifest.webmanifest`, icons (Ham logo on black, same recipe as HamTV), service
|
||||
worker caching the app shell only (never `/api`).
|
||||
- NPM proxy host for `hamlab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2.
|
||||
- NPM proxy host for `lab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2.
|
||||
- Stub groups for Services / Cron / ZFS / Dashboard.
|
||||
|
||||
**Done when:** from the phone, open the PWA, log in, open two terminals, run
|
||||
@@ -313,7 +314,13 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
exposes without root; `intel_gpu_top` shows the same number) or amdgpu's own percent;
|
||||
per-process and per-engine shares are DRM fdinfo deltas, the way nvtop reads them. Other
|
||||
users' fdinfo needs root, so a daemon running as `core` keeps **one** root child
|
||||
(`hamlab drm-clients`, over a pipe) rather than a sudo a second. The Arc A380 on i915 6.12
|
||||
(`hamlab drm-clients`, over a pipe) rather than a sudo a second. **NVIDIA's proprietary
|
||||
driver (2026-10-23)** has neither a sysfs busy counter nor fdinfo engine totals, and its DRM
|
||||
card reports `DRIVER=nvidia` (not `nvidia-drm`), so until then such a card was not even
|
||||
listed; now it is read through `nvidia-smi` — `--query-gpu` for busy/encoder/decoder, VRAM,
|
||||
SM clock, temperature and power, and `pmon -c 1 -s um` for the per-process table (sm/enc/dec
|
||||
shares, framebuffer MiB) — two ~20–75 ms calls per sample, selected by PCI address, no root.
|
||||
First machine: `link`, the Surface Studio's GTX 965M. The Arc A380 on i915 6.12
|
||||
exposes power, energy and fan but **no temperature**, so its box shows none; an integrated
|
||||
GPU shows the CPU package temperature marked `*`. Asahi (the Mac mini) has neither a busy
|
||||
counter nor fdinfo in 6.19, so it lists the device and says so. No NVIDIA path: nothing here
|
||||
@@ -410,39 +417,9 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
||||
had taken left untouched and fully readable here — which is what adopting its naming
|
||||
bought. See `hamlet/docs/hosts.md`.
|
||||
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`).
|
||||
Paste a device's address; its own web page appears in the tab. **Nothing in it knows
|
||||
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid`
|
||||
at `10.0.0.217`, but none of that is in the code.
|
||||
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
|
||||
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
|
||||
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
|
||||
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
|
||||
**The general problem, and the answer.** A device UI asks for its assets by absolute path
|
||||
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
|
||||
bodies per device would not be general. So the prefix is only the entry point: any request
|
||||
Ham Lab does not serve itself is routed to whichever KVM the asking page belongs to, read
|
||||
from the **Referer**, falling back to a **cookie** when there is none (a WebSocket upgrade).
|
||||
Ham Lab's own routes always match first, so a device can never shadow the console, and the
|
||||
fallback is gated on a live session. The device never receives Ham Lab's session cookie,
|
||||
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
|
||||
now. Redirects to the device's root are rewritten back through the prefix.
|
||||
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
|
||||
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
|
||||
refused before it loaded.
|
||||
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
|
||||
does not need to, since one with focus receives them directly. Fullscreen plus
|
||||
`navigator.keyboard.lock()` is what takes Escape, Tab and Ctrl-W back from the browser;
|
||||
that is Chromium-only, and the tab says which version you are getting rather than
|
||||
pretending.
|
||||
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
|
||||
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
|
||||
to `grid` and you get grid's; adding a second for a machine replaces the first. The
|
||||
addresses are still kept **on the hub** rather than on each machine, because a KVM earns
|
||||
its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub
|
||||
proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at
|
||||
all. Not done: holding the device's own login on the hub, so its page does not ask for a
|
||||
second password.
|
||||
- **KVM tab** — shipped 2026-09-24, **removed 2026-11-09** at Mitchell's call: not needed —
|
||||
the KVMs are reached over WireGuard (see `hamlet/docs/hosts.md`). It proxied a device's web
|
||||
page through Ham Lab's origin; the code is in git history before that date.
|
||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||
@@ -676,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
|
||||
and the owner, with an **Allow GPU access** button (confirms, then a task writes
|
||||
`hamlab.service.d/gpu.conf` adding `render`/`video` as supplementary groups and
|
||||
restarts Ham Lab three seconds later, the shape the Incus install uses for
|
||||
restarts Ham Lab three seconds later, the shape the Incus install used for
|
||||
`incus-admin`). Not pressed anywhere yet. Where ffmpeg is missing (the Mac mini, `gate`)
|
||||
the note offers **Install ffmpeg with dnf/apk** through the Updates machinery
|
||||
(`ffmpeg-free` on Fedora, `ffmpeg` elsewhere); the status re-probes when the task ends.
|
||||
@@ -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
|
||||
*Allow GPU access* is pressed (libx264 at 9.4× for a 720p→480p clip on `core`); the Mac
|
||||
and `gate` need ffmpeg first, which the previews note offers to install.
|
||||
- *Not done, on purpose:* no restart-from-a-seek beyond what is encoded (the encode is
|
||||
faster than watching; a seek far ahead waits), no bit-rate ladder or adaptive
|
||||
switching, no subtitles, no keeping a finished re-encode for next time.
|
||||
- *Not done, on purpose:* no bit-rate ladder or adaptive switching, no subtitles, no
|
||||
keeping a finished re-encode for next time. (Restart-from-a-seek was left out here at
|
||||
first; it came 2026-10-20, the next entry.)
|
||||
- **Seek anywhere in a re-encode** — **2026-10-20**. Asked for as "there's no way to seek in
|
||||
the video and have the service just start transcoding from that point onward".
|
||||
- **The playlist is the whole film, up front:** a VOD playlist on a fixed 4 s grid
|
||||
(`segCount`, the last 0.3 s left out so sound running past the picture never lists a
|
||||
segment no frame starts), so the seek bar is the film's from the first frame.
|
||||
- **A segment not on disk** is waited for when the run going now will write it within 3
|
||||
segments; otherwise it is a seek: that run is ended and one starts at the segment asked
|
||||
for. A run stops half a frame short of the next segment already on disk, never over it.
|
||||
When a run ends by itself the next gap is filled, wrapping round, until every segment is
|
||||
there. The newest request moves the run; an older one still waiting never moves it back.
|
||||
A run we end closes its half-done segment short on the way out, so whatever it finished
|
||||
after being asked to stop is deleted.
|
||||
- **Runs are interchangeable** because every one, the first included, is made the same way
|
||||
(`segArgs`): accurate `-ss` to the segment, timestamps put back with
|
||||
`-output_ts_offset start+10` (the +10 keeps every run clear of the muxer's shift for the
|
||||
B-frame delay, which a run from the top would otherwise get and a seek wouldn't), kept
|
||||
absolute in each fragment (`movflags=+frag_discont`), `-start_number`. Tested on MKV
|
||||
and on MPEG-TS with a 2.8 s start time (where `-copyts` was a second out): the init
|
||||
segments come out byte-identical, and a seeked run's segments join a from-the-top run's
|
||||
within a frame. Only the first run's init is served; a later run writes `init-run.mp4`
|
||||
and the log notes if it ever differs.
|
||||
- Repackaged ("copy") streams keep the growing event playlist: the source's keyframes
|
||||
don't sit on a grid, and repackaging is near-instant anyway.
|
||||
- A segment waits at most 45 s, under the hub proxy's 60 s for a node to answer; hls.js
|
||||
is told a segment's first byte may take 50 s. The bar reads "7.3× from 12:00 · 43%
|
||||
ready — seek anywhere".
|
||||
- Tested (`TestSeekRestartsTheEncode`): a 64 s 1080p clip, a request for segment 12 right
|
||||
after the start runs from 48 s, the gap is filled, all 16 segments joined behind the one
|
||||
init decode with no hole.
|
||||
- **The player is Ham Share's; a Transcode device setting** — **2026-10-23** (v0.1.0-226,
|
||||
`internal/media/{encode,play}.go`, `internal/api/play.go`, `Player.tsx`, `vr.ts`,
|
||||
`Settings.tsx`). Asked for as "redo the HamLab one with the one you sent … a 'Transcode
|
||||
Device' setting … the A380 on Grid selected … other options being the other transcode
|
||||
devices like the iGPUs on the various CPUs that have HamLab installed". **Replaces** the
|
||||
previous two entries' design: no codec/size picker, no single-quality re-encode, no
|
||||
repackaging; the old streamer (`hls.go`) is gone.
|
||||
- **YouTube's way:** Auto (hls.js reads the connection), any quality by hand, or Original
|
||||
(the file as it is, `/api/files/read`, falling back to Auto when the browser can't). A
|
||||
ladder by size (`Ladder`): flat 2160p/1440p/1080p/720p/480p by the short side, plus the
|
||||
file's own size on top when it sits between steps (a 2292×960 capture gets 960p); 360
|
||||
files by width, named as YouTube does (2160s 3840 … 720s). 720 and down at ≤30 fps.
|
||||
H.264 High + AAC only.
|
||||
- **Pieces made when asked for**, 3 s MPEG-TS, every quality's playlist written whole up
|
||||
front. One ffmpeg per play: it starts at the piece asked, runs ahead, and is paused
|
||||
(SIGSTOP; a root run through `run-ffmpeg`, which now reads `stop`/`cont` on stdin) 10
|
||||
pieces ahead; a piece more than 3 past what's made, or at another quality, starts a new
|
||||
run there. Keyframes only on the 3 s marks and every run's times offset alike
|
||||
(`-output_ts_offset start+10`), so pieces from any run and quality join (`TestPieces`
|
||||
checks it; `SRC=… DEVICE=… go test ./internal/media -run Pieces` on a machine with a GPU).
|
||||
At most 3 runs a machine, the one asked of least lately ended for a fourth; 2 GiB of
|
||||
pieces a play; forgotten after 30 min.
|
||||
- **hls.js is patched** (`PatientAbr`): the wait for a piece being made isn't counted
|
||||
against the connection, and a piece isn't abandoned before its first byte. Without it
|
||||
Auto dropped to the bottom after every seek.
|
||||
- **Transcode device** (Settings, hub-side, `store.Transcode`): every machine's devices
|
||||
asked live (`GET /api/files/play-devices` on each), unusable ones greyed with the reason
|
||||
(hamtv's UHD 600: the render permission; mac and gate: no ffmpeg). Set to **grid's Arc
|
||||
A380**. "The machine the file is on" is the other choice. `POST /api/play` on the hub
|
||||
opens the play where the setting says; **a machine transcoding another's file reads it
|
||||
through the hub** at `/api/play-src/<token>` — a random token good for that one file, 12
|
||||
h from its last use, ranges passed through to the file's machine (ffmpeg reconnects).
|
||||
If that machine fails, the file's own makes it and the player says so.
|
||||
- Recipes per device: same-card decode first, then CPU decode + upload, then libx264 as
|
||||
the fallback whatever was asked. A card without `vainfo` (core) is tried anyway: core's
|
||||
UHD encodes. **Rotation:** the card path turns a phone's picture itself
|
||||
(`transpose_vaapi`, `-noautorotate`; ffmpeg's autorotate never applied to VAAPI frames,
|
||||
so iPhone clips came out sideways). **HLG** is shown as it is (tonemap_vaapi refuses it);
|
||||
HDR10 is tone-mapped on the card, else zscale/hable before the upload.
|
||||
- 360 files: the look-around view, zoom 8°–300° (ordinary lens into stereographic, the
|
||||
"little planet"), drag/pinch/wheel, phone motion. Full screen is the element's, or the
|
||||
whole window on an iPhone.
|
||||
- **Video previews follow the setting too** (same day, asked for as "it should use the
|
||||
same video generation device"). On the device's own machine the hub says which device
|
||||
by header (`X-Hamlab-Device`); for a file elsewhere the hub asks the file's machine's
|
||||
cache first (`?cached=1`), else has the device's machine make it from the hub's one-file
|
||||
URL (`POST /api/files/thumb-from`) and keeps it in the file's machine's cache (`PUT
|
||||
/api/files/thumb`, refused if the file changed since its listing), so each is made once.
|
||||
A failure is kept as a failure. Unreachable → made where the file is, as before. Images
|
||||
and audio covers stay on the file's machine's CPU (a JPEG is cheaper there than a GPU
|
||||
round trip). Measured: a `hamtv` video previewed on grid's A380 in 0.22 s, then 2 ms
|
||||
from hamtv's cache. The previews button's tooltip names the device.
|
||||
- Measured (`web/scripts/e2e-player.mjs`, Chromium): an iPhone HLG clip on `core` made on
|
||||
grid's A380 — 3.2 s to play, a seek 1.0 s, a quality change 1.5 s; the 8K 360 Disney day on
|
||||
grid — 3.1 s, 2160s on Auto, a seek 4.9 s (10 s source keyframes).
|
||||
- **Files edge sweep** — **2026-10-11** (v0.1.0-214), asked for as "a full bug sweep of
|
||||
the Files section, every example and edge case". Every handler and both UI files read
|
||||
through with odd inputs in mind, then a battery of ~60 requests against the live hub
|
||||
@@ -910,6 +971,11 @@ what it has moved.
|
||||
|
||||
## To do
|
||||
|
||||
- **Handle `fuse.mergerfs` in the Storage view** (2026-10-25). `grid` has a mergerfs pool at
|
||||
`/srv/tank` (the NVMe landing area + `/tank`; `hamlet`'s `docs/tank-landing-plan.md`).
|
||||
`fuse.mergerfs` isn't in `remoteFS` or `pseudoFS` (`internal/metrics/metrics.go`), so it shows
|
||||
as a local filesystem whose size is `tank` and the NVMe added together. That counts both twice
|
||||
in any storage total. Show it, labelled as a pool of its branches, and leave it out of totals.
|
||||
- **Grow the Files tab** — asked for 2026-09-23 as two items (Files polish, and deprecating
|
||||
copyparty), merged 2026-09-24. **copyparty was retired 2026-10-11** at Mitchell's call,
|
||||
ahead of these gaps rather than after them: service, files and NPM host all gone, with a
|
||||
@@ -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
|
||||
through the hub.
|
||||
|
||||
- **A Virtualization tab** — asked for 2026-09-23; **scoped, built and deployed 2026-10-09 — see
|
||||
`VIRTUALIZATION.md`** for what shipped (phases 1 and 2) and what is left. Incus 6.0 LTS as the engine; the tab detects per machine whether Incus is
|
||||
running, installable, or unavailable, and whether KVM is there, and acts on the machine in
|
||||
the switcher — never a default host. Containers and VMs in one list, console/shell through
|
||||
the terminal machinery, snapshots, exports, and the old Proxmox estate on `tank` as a later
|
||||
import. That file has the probe of every host, the per-machine layout Ham Lab computes
|
||||
(pool driver, a bridge subnet that avoids the LAN's /8 and Docker's pools, the Docker
|
||||
`FORWARD` fix) and the phased build.
|
||||
- ~~**A Virtualization tab**~~ — built 2026-10-09, **cut 2026-10-15**: out of scope (see Decisions). The code, its plan and its tests are on the `virtualization` branch.
|
||||
|
||||
|
||||
## Open questions
|
||||
|
||||
+13
-18
@@ -5,6 +5,9 @@ every place the code, defaults, docs or UI assume Mitchell's hosts, network, har
|
||||
distro, init system, orchestrator history or posture. **Nothing here is fixed yet** — this
|
||||
is the list to work from. Each item: where, what it assumes, what the general form is.
|
||||
|
||||
Virtualization was cut from the app 2026-10-15 (kept on the `virtualization` branch); its
|
||||
items — 1.8, 4.2, 5.1, 5.2, 6.14 and the Incus halves of 5.3, 5.7, 6.15, 10.3 — went with it.
|
||||
|
||||
Severity key: **A** breaks or misbehaves on an ordinary host · **B** works but is wrong by
|
||||
default or phones home to this lab · **C** cosmetic (placeholder text, comments, docs).
|
||||
|
||||
@@ -17,11 +20,10 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments
|
||||
| 1.1 | **B** | `internal/selfupdate/selfupdate.go:36` `DefaultURL = "https://git.bigham.dev/api/v1/repos/mitchell/hamlab"` | Every install with no `update.url` checks **this** Gitea 90 s after boot and daily (`internal/api/update.go:1951, 2016`). A stranger's install phones home here by default. | Default should be **off** (or a build-time `-X` variable set at release). Installer asks or `install.sh` passes `HAMLAB_URL` through into `update.url`. |
|
||||
| 1.2 | **B** | `install.sh:4, 27` `HAMLAB_URL` default; `README.md:23-25, 31` | The curl one-liner and the install source are this Gitea. | Default comes from the release that served the script (`@URL@` filled by `make release`), or is required. |
|
||||
| 1.3 | **B** | `scripts/gitea-release.sh:14` `REPO` default | Publishing goes to `mitchell/hamlab` on this Gitea. | Require `HAMLAB_REPO`; no default. |
|
||||
| 1.4 | **C** | `README.md:100` "On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager" | Lab deployment fact in the product README. | Move to `hamlet/docs/hosts.md`. |
|
||||
| 1.4 | **C** | `README.md:100` "On `core` it is public at `https://lab.bigham.dev` behind Nginx Proxy Manager" | Lab deployment fact in the product README. | Move to `hamlet/docs/hosts.md`. |
|
||||
| 1.5 | **C** | `examples/external-demo/index.html:8` `base: 'http://10.0.0.234:8100'` | Demo config names the hub's LAN IP. | `http://<hamlab-host>:8100`. |
|
||||
| 1.6 | **C** | `SENSORS.md:149` Prometheus target `10.0.0.234:8100`; `:187` `&machine=grid`; `:196-208` Glance examples at `hamlab.bigham.dev`, `zfs.tank.used_pct`, `/api/nodes/grid/...`; `:218-220` `grid,wyse`, `{"core":…,"grid":…,"mac":…}` | Docs use real host names, pool and domain. | Generic placeholders (`<host>`, `<machine>`, `<pool>`). |
|
||||
| 1.7 | **C** | `PLAN.md:3-4` "bare-metal Debian 13 host. First target: `core` (10.0.0.234)"; decisions table rows (Listen `10.0.0.234:8100`, Data `/home/core/apps/hamlab/`, Ingress NPM/`hamlab.bigham.dev`, Repo) | Product plan is written as this lab's plan. | Keep as history, or split lab facts out to `hamlet`. The repo is public. |
|
||||
| 1.8 | **C** | `VIRTUALIZATION.md:339-353` per-machine probe table; `:412, 425-436` `~/apps/hamlab`, `/tank/...`, boot NVMe free space | Plan is written against the five lab hosts. | Same as 1.7. |
|
||||
| 1.6 | **C** | `SENSORS.md:149` Prometheus target `10.0.0.234:8100`; `:187` `&machine=grid`; `:196-208` Glance examples at `lab.bigham.dev`, `zfs.tank.used_pct`, `/api/nodes/grid/...`; `:218-220` `grid,wyse`, `{"core":…,"grid":…,"mac":…}` | Docs use real host names, pool and domain. | Generic placeholders (`<host>`, `<machine>`, `<pool>`). |
|
||||
| 1.7 | **C** | `PLAN.md:3-4` "bare-metal Debian 13 host. First target: `core` (10.0.0.234)"; decisions table rows (Listen `10.0.0.234:8100`, Data `/home/core/apps/hamlab/`, Ingress NPM/`lab.bigham.dev`, Repo) | Product plan is written as this lab's plan. | Keep as history, or split lab facts out to `hamlet`. The repo is public. |
|
||||
| 1.9 | **C** | `BUGS.md` throughout | Bug bashes narrate `core`, `grid`, `wyse`, `hamtv`, `gate`, the Mac. | History; fine to leave, but it is public. |
|
||||
| 1.10 | **C** | `cmd/hamlab/main.go:1` "Proxmox-style web console for a bare-metal Debian host"; `internal/hardware/hardware.go:5` "what Debian and its kin already have"; `internal/hardware/hardware.go:29` `// "GMKtec NucBox G3"`; `internal/metrics/metrics.go:1358` "did, on grid, 2026-10-02"; `internal/api/server.go:1382-1383` `["grid", …]`, `?machines=grid,wyse`; `internal/api/stacks.go:10` `/home/core/apps/dockhand/stacks/…`; `internal/api/dockersched.go:5` "unbound, nginx and pihole"; `internal/api/zfs.go:5` "grid already runs zfSnap"; `internal/storage/snapshots.go:6` "grid has run zfSnap v1.11.1"; `internal/api/guests.go:8` "Mitchell, 2026-09-27"; `internal/network/network.go:306` "the public address here is a DHCP lease"; `internal/metrics/gpu.go:17` "Asahi, today"; `internal/metrics/pciids.go:4-6` "the router's GPU"; `web/src/Top.tsx:522, 576`; `web/src/hashsel.ts:1-9`; `web/src/Embed.tsx:3` `&machine=grid` | Comments explain decisions by naming lab hosts. | Harmless, but rewrite the ones in package doc comments so a reader of the source doesn't need the lab to understand the code. |
|
||||
|
||||
@@ -30,7 +32,6 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments
|
||||
| # | Sev | Where | Text | General form |
|
||||
|---|---|---|---|---|
|
||||
| 2.1 | **C** | `web/src/Machines.tsx:116-118` | `placeholder="grid"`, `"https://10.0.0.214:8100"`, `"core"` | `nas`, `https://192.0.2.10:8100`, `admin` or the hub's own user. |
|
||||
| 2.2 | **C** | `web/src/KVM.tsx:166` | `placeholder="10.0.0.217"` (the NanoKVM on `grid`) | `192.0.2.20` or `kvm.local`. |
|
||||
| 2.3 | **C** | `web/src/Settings.tsx:242` | `placeholder="cpu.*, temp.*, zfs.tank.*"` | `cpu.*, temp.*, fs.*`. |
|
||||
| 2.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. |
|
||||
| 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. |
|
||||
@@ -60,7 +61,6 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 4.1 | **A** | `internal/updates/updates.go:537-543` `Power()` | `systemctl reboot` / `systemctl poweroff`. On OpenWrt (`gate`) and OpenRC the Power dialog fails. | Try `systemctl`, else `reboot` / `poweroff` (busybox, OpenRC, sysvinit all have them). |
|
||||
| 4.2 | **A** | `internal/incus/incus.go:1017, 1044-1058, 1069, 1075` | The Incus install script writes `/etc/systemd/system/*` drop-ins and units, adds `SupplementaryGroups` to `hamlab.service`, restarts with `systemd-run`. On OpenRC/procd the daemon never joins `incus-admin` → the tab stays in "repair" forever; the Docker FORWARD fix is never persisted. `HamlabUnit = "hamlab.service"` (`:35`) is surfaced in the API (`:280`). | Branch on the detected init: OpenRC `rc-update`/`conf.d`, procd init script edits, or fall back to `usermod` + a daemon restart via the installer's own supervisor knowledge. Docker rules via a boot hook that is not a unit. |
|
||||
| 4.3 | **A** | `internal/services/services.go:163-170` | Services tab is systemd-only ("says so cleanly"). `gate` (procd) and any OpenRC/runit/s6 box have no Services tab. | `rc-service`/`rc-status` (OpenRC), `/etc/init.d/<x> status` + `ubus call service list` (procd), `sv` (runit) backends. Noted as "not done" in `PLAN.md:487-489`. |
|
||||
| 4.4 | **A** | `internal/cron/cron.go:353-373, 401, 442, 532-534, 669-670` | Jobs tab reads `/etc/crontab`, `/etc/cron.d`, `/var/spool/cron{,/crontabs}` (Debian/cronie), `/usr/sbin/anacron`, last-runs from **journald**, timers via `systemctl`; writes only `/etc/crontab` and `/etc/cron.d`. Busybox crond (OpenWrt, Alpine) keeps crontabs in `/etc/crontabs/<user>` and has no `cron.d` → the Jobs tab is empty on `gate` and schedules written by 4.5 never run. | Add `/etc/crontabs/*` as a source; last-runs from syslog/logread when there is no journal. |
|
||||
| 4.5 | **A** | `internal/storage/snapshots.go:402` `/etc/cron.d/hamlab-zfs`; `internal/api/dockersched.go:65` `/etc/cron.d/hamlab-docker` | ZFS snapshot and image-update schedules are cron.d files. No cron.d on busybox crond; no cron at all on a systemd-timer-only host (Fedora Server, CoreOS). | Detect: cron.d → cron.d; busybox → a line in `/etc/crontabs/<user>`; none but systemd → a timer unit. The daemon-side runner already exists (`hamlab zfs-snapshot`, `docker-update`). |
|
||||
@@ -74,13 +74,11 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2
|
||||
|
||||
| # | Sev | Where | Assumes | General form |
|
||||
|---|---|---|---|---|
|
||||
| 5.1 | **B** | `internal/incus/incus.go:213-250, 351-352` | Incus can be installed with apt, dnf, pacman, apk. **zypper is missing** (the Updates tab supports it), so an openSUSE host reads "No package manager Ham Lab knows how to drive". `opkg` is known only to say "not packaged for OpenWrt". | Add zypper; phrase the opkg case as "not packaged for this distribution". |
|
||||
| 5.2 | **B** | `internal/incus/incus.go:973-978` | dnf branch adds `qemu-system-x86-core`/`edk2-ovmf` or `qemu-system-aarch64-core`/`edk2-aarch64`; other arches (riscv64, ppc64le, which `IncusArch()` maps) get no QEMU. pacman and apk branches assume their package names never change. | Table per PM × arch, or let `incus` pull its own deps where the distro does that (Debian does). |
|
||||
| 5.3 | **B** | `internal/incus/incus.go:958, 970` `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold`; `internal/updates/updates.go:78, 432` | Debian-isms exported unconditionally. Harmless elsewhere. | Fine; note only. |
|
||||
| 5.3 | **B** | `DEBIAN_FRONTEND`, `apt-get … -o Dpkg::Options::=--force-confold` in `internal/updates/updates.go:78, 432` | Debian-isms exported unconditionally. Harmless elsewhere. | Fine; note only. |
|
||||
| 5.4 | **B** | `scripts/pci-ids-gpu.sh:7, 41` default `/usr/share/misc/pci.ids` | Debian's path for `pci.ids`; Fedora/Arch keep it in `/usr/share/hwdata/`. Build-time only. | Try both. |
|
||||
| 5.5 | **B** | `internal/api/status_sensors.go:283`, `internal/api/docker.go:133` | Container memory/CPU read from three cgroup paths: cgroup v2 + systemd driver, cgroupfs, libpod. cgroup v1 hosts (older Debian/Ubuntu, some Alpine, OpenWrt) get no `container.*.mem/cpu`. | Add the v1 `memory/docker/<id>` and `cpu,cpuacct` paths, or fall back to `docker stats --no-stream` once in a while. |
|
||||
| 5.6 | **B** | `internal/metrics/metrics.go:1064` `clkTck = 100` | USER_HZ assumed 100. True on every mainstream build; wrong on a kernel built otherwise. | `sysconf` via `getconf CLK_TCK` once at start. |
|
||||
| 5.7 | **B** | `internal/incus/subnet.go:150-179` `ip -j`; `internal/space/manager.go:578-585` `timeout -s KILL` | iproute2 JSON output (busybox `ip` has no `-j`); GNU `timeout`. | Parse plain `ip route`/`ip addr`; busybox has `timeout` too but flag spelling differs — use the context deadline. |
|
||||
| 5.7 | **B** | `internal/space/manager.go:578-585` `timeout -s KILL` | GNU `timeout`. | Busybox has `timeout` too but flag spelling differs — use the context deadline. |
|
||||
|
||||
## 6. Hardware and vendor assumptions
|
||||
|
||||
@@ -99,8 +97,7 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2
|
||||
| 6.11 | **B** | `internal/vpn/edit.go:5-7, 419-421` | Peers can be **created/edited only through OpenWrt UCI** "because that is where the server here is configured". A wg-quick server (the common case everywhere else) is read-only. | Write wg-quick `.conf` too (append a `[Peer]` block + `wg set`), guarded the same way. |
|
||||
| 6.12 | **B** | `internal/vpn/vpn.go:251-253` | Peer names come from wg-quick comments or UCI descriptions — "which between them cover every machine here". NetworkManager and systemd-networkd WireGuard configs are not read. | Add `.nmconnection` and `.netdev` readers. |
|
||||
| 6.13 | **B** | `internal/vpn/edit.go:515` `profile()` | Split-tunnel routes hard-code `10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12` on top of the tunnel's own prefix. | Offer the host's actual LAN prefixes (from `TakenPrefixes`-style route reading), with RFC 1918 as the fallback. |
|
||||
| 6.14 | **B** | `internal/incus/subnet.go:1-9, 23-34, 55`; `web/src/Virtualization.tsx:133` | Bridge subnet candidates skip **all of 10/8** because "this LAN is 10.0.0.0/8"; order is 172.16.100–254, then 192.168.254–200, then 172.16.1–99; last-resort `172.16.100.0/24`. Works anywhere, but a lab on 172.16/12 gets 192.168.x first and a lab on 192.168/16 never gets its own range. | Read the host's routes (already done) and pick from all three RFC 1918 blocks in a neutral order. |
|
||||
| 6.15 | **B** | `internal/network/network.go:170-199, 303-314`; `internal/incus/subnet.go`; `internal/vpn/edit.go:558-595`; `internal/tlsauto/tlsauto.go:533-537`; `install.sh:114-120`; `internal/setup/setup.go:648-666` | **IPv4 everywhere**: default route from `/proc/net/route` only, outside view v4 only, bridge/peer allocation v4 only, cert SANs v4 only, installer lists v4 addresses only. | `/proc/net/ipv6_route`, v6 SANs, v6 peer addresses. An IPv6-only host today has no gateway, no cert SAN and no bridge. |
|
||||
| 6.15 | **B** | `internal/network/network.go:170-199, 303-314`; `internal/vpn/edit.go:558-595`; `internal/tlsauto/tlsauto.go:533-537`; `install.sh:114-120`; `internal/setup/setup.go:648-666` | **IPv4 everywhere**: default route from `/proc/net/route` only, outside view v4 only, peer allocation v4 only, cert SANs v4 only, installer lists v4 addresses only. | `/proc/net/ipv6_route`, v6 SANs, v6 peer addresses. An IPv6-only host today has no gateway, and no cert SAN. |
|
||||
|
||||
## 7. Privilege and deployment posture (the lab's NOPASSWD-everywhere model)
|
||||
|
||||
@@ -134,7 +131,7 @@ The installer supports systemd, procd and OpenRC (`internal/setup/setup.go:217-2
|
||||
|---|---|---|---|---|
|
||||
| 10.1 | **B** | `Makefile:60-61, 442-443`; `scripts/gitea-release.sh` | Publishing is Gitea/Forgejo-only (release API shape). `selfupdate` also understands a static directory, so publishing could too. | `make release` + any static host is already enough; add a GitHub/Forgejo-agnostic `release-dir` note. |
|
||||
| 10.2 | **C** | `scripts/gitea-release.sh:500-515` | Needs `python3` on the publishing host. | `jq`, or do it in Go (`hamlab publish`). |
|
||||
| 10.3 | **C** | `Makefile:392` `ARCHES ?= amd64 arm64 arm 386 riscv64` | Fine. `ppc64le`/`s390x` are mapped by `IncusArch()` but never built. | Add or drop them from `IncusArch`. |
|
||||
| 10.3 | **C** | `Makefile:392` `ARCHES ?= amd64 arm64 arm 386 riscv64` | Fine. | — |
|
||||
|
||||
## 11. Things that are already general (checked, no change needed)
|
||||
|
||||
@@ -145,11 +142,9 @@ Listed so the sweep is known to be complete, not because they need work.
|
||||
- `internal/storage` block devices: `lsblk -J` with a `/sys/block` fallback, eMMC health, NVMe namespace folding, SMART only when `smartctl` exists.
|
||||
- `internal/metrics` CPU/mem/hwmon/thermal/net/disk/fs from `/proc` and `/sys`; remote-FS and pseudo-FS lists; filesystem statfs with a timeout.
|
||||
- `internal/hardware`: every reader may come back empty with a note; device-tree fallbacks for non-SMBIOS boards.
|
||||
- `internal/incus` detection and the per-machine "ready / repair / installable / unavailable" model (modulo 4.2, 5.1, 5.2).
|
||||
- `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
|
||||
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
|
||||
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`.
|
||||
- `internal/api/kvm.go`: "nothing in it knows anything about any particular KVM" — verified.
|
||||
- `internal/compose` (modulo 3.5).
|
||||
- The React UI: machine-switcher model, hash routing, per-account prefs, themes.
|
||||
|
||||
@@ -158,7 +153,7 @@ Listed so the sweep is known to be complete, not because they need work.
|
||||
## Suggested order of work
|
||||
|
||||
1. **Posture and safety first (A):** 7.2 trusted proxies; 7.1 installer-written scoped sudoers; ~~3.1 drop the `:2375` probe (with 3.2 as its consequence)~~ done 2026-10-10.
|
||||
2. **"Any Linux" promises the README already makes (A):** 4.1 power, 4.4/4.5 cron on busybox and timer-only hosts, 4.2 Incus install on non-systemd, 4.3 a Services backend for OpenRC/procd, 2.10 the `tank` regex.
|
||||
2. **"Any Linux" promises the README already makes (A):** 4.1 power, 4.4/4.5 cron on busybox and timer-only hosts, 4.3 a Services backend for OpenRC/procd, 2.10 the `tank` regex.
|
||||
3. **Lab defaults that phone home or mislead (B):** 1.1–1.3 update/install URLs, 2.9 schedule defaults, 6.9/6.10 speed-test sources and an off switch, 8.1 memory limit, ~~3.3/9.2 stack home = DataDir~~ done 2026-10-10.
|
||||
4. **Breadth (B):** 5.1 zypper for Incus, 5.5 cgroup v1, 6.1/6.2 GPUs beyond Intel/AMD, 6.4 UPS order, 6.11/6.12 wg-quick/NetworkManager, 6.15 IPv6.
|
||||
5. **Cosmetics (C):** every placeholder and comment in §1.10 and §2, then split lab facts out of `PLAN.md`, `VIRTUALIZATION.md`, `SENSORS.md`, `README.md`.
|
||||
4. **Breadth (B):** 5.5 cgroup v1, 6.1/6.2 GPUs beyond Intel/AMD, 6.4 UPS order, 6.11/6.12 wg-quick/NetworkManager, 6.15 IPv6.
|
||||
5. **Cosmetics (C):** every placeholder and comment in §1.10 and §2, then split lab facts out of `PLAN.md`, `SENSORS.md`, `README.md`.
|
||||
|
||||
@@ -97,5 +97,5 @@ page flags machines running an older version and offers the same push per machin
|
||||
Mixed architectures are fine: `make deploy` also cross-compiles every other architecture,
|
||||
and the hub sends each machine the build for its own.
|
||||
|
||||
- On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager.
|
||||
- On `core` it is public at `https://lab.bigham.dev` behind Nginx Proxy Manager.
|
||||
- Installable as a PWA.
|
||||
|
||||
+5
-2
@@ -169,6 +169,9 @@ container.<name>.down should be up and isn't: Docker c
|
||||
restart it — *not* a container someone stopped
|
||||
updates.pending|security|reboot_required every 5 min
|
||||
zfs.<pool>.health|healthy|size|used|used_pct|frag every 1 min
|
||||
zfs.write.dirty|limit|delayed the RAM write buffer (all pools) and writes the throttle is slowing
|
||||
zfs.<pool>.write_bps|logical_bps bytes/s synced to disk, and written by programs
|
||||
zfs.<pool>.landing.pending_bytes|pending_files|used_pct|in_bps|drain_bps|mover|queue a landing tier, where one fronts the pool
|
||||
smart.<dev>.passed|warnings|power_on|wear every 10 min, never waking a sleeping disk
|
||||
```
|
||||
|
||||
@@ -200,13 +203,13 @@ facts, never live. An `iframe` of the embed is live.
|
||||
```yaml
|
||||
- type: iframe
|
||||
title: core CPU
|
||||
source: https://hamlab.bigham.dev/embed/widget?type=gauge&sensor=cpu.usage&bare=1&token=<read-only token>
|
||||
source: https://lab.bigham.dev/embed/widget?type=gauge&sensor=cpu.usage&bare=1&token=<read-only token>
|
||||
height: 130
|
||||
|
||||
- type: custom-api
|
||||
title: grid
|
||||
cache: 1m
|
||||
url: https://hamlab.bigham.dev/api/nodes/grid/sensors/query?pool=zfs.tank.used_pct&failed=services.failed&updates=updates.pending
|
||||
url: https://lab.bigham.dev/api/nodes/grid/sensors/query?pool=zfs.tank.used_pct&failed=services.failed&updates=updates.pending
|
||||
headers: { Authorization: Bearer <read-only token> }
|
||||
template: |
|
||||
<div>tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates</div>
|
||||
|
||||
@@ -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
@@ -30,7 +30,6 @@ import (
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/metrics"
|
||||
"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)
|
||||
users list the accounts
|
||||
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
|
||||
virt-export [name…] export Incus instances to the library (what its cron line calls)
|
||||
landing-verify <new|all|id> verify a landing tier against its mover's ledger (a schedule's id is what its cron line calls)
|
||||
docker-update <id> run one image-update schedule (what its cron line calls)
|
||||
user-rm <user> remove an account and sign it out everywhere
|
||||
check verify the config and state load, then exit
|
||||
@@ -152,8 +151,8 @@ func main() {
|
||||
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case "virt-export": // the nightly cron line from the Virtualization tab; optional instance names
|
||||
if err := virtExport(cfg, flag.Args()[1:]); err != nil {
|
||||
case "landing-verify":
|
||||
if err := landingVerify(cfg, flag.Arg(1)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case "users":
|
||||
@@ -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
|
||||
// anything written from out here; the outcome goes beside it instead, in zfs-runs/.
|
||||
// landingVerify runs one verify pass over every landing tier on this machine: by hand with a
|
||||
// scope ("new" or "all"), or as a schedule by its id, which is what the cron line passes.
|
||||
func landingVerify(cfg *config.Config, arg string) error {
|
||||
scope, id := arg, "manual"
|
||||
if arg != "new" && arg != "all" {
|
||||
if strings.TrimSpace(arg) == "" {
|
||||
return errors.New("usage: hamlab landing-verify <new|all|schedule-id>")
|
||||
}
|
||||
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
st.View(func(s *store.State) { raw = s.ZFS })
|
||||
var z struct {
|
||||
Verify []storage.VerifyPolicy `json:"verify"`
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &z); err != nil {
|
||||
return fmt.Errorf("reading the schedules: %w", err)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, p := range z.Verify {
|
||||
if p.ID == arg {
|
||||
scope, id, found = p.Scope, p.ID, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("no verify schedule %q — it may have been removed; this cron line is stale", arg)
|
||||
}
|
||||
}
|
||||
dir := cfg.DataDir()
|
||||
unlock, err := storage.LockVerify(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
defer storage.ClearVerifyProgress(dir)
|
||||
targets := storage.LandingTargets()
|
||||
if len(targets) == 0 {
|
||||
r := storage.VerifyResult{ID: id, Scope: scope, Started: time.Now(), Finished: time.Now(), Issues: []storage.VerifyIssue{},
|
||||
Error: "no landing mover is reporting (nothing in " + storage.MoverStatusDir + "), so there is nothing to verify"}
|
||||
_ = storage.RecordVerify(dir, storage.LandingTarget{}, r)
|
||||
return errors.New(r.Error)
|
||||
}
|
||||
var firstErr error
|
||||
for _, t := range targets {
|
||||
r := storage.Verify(t, id, scope, storage.VerifyCursor(dir, t), func(p storage.VerifyProgress) { storage.WriteVerifyProgress(dir, p) })
|
||||
if err := storage.RecordVerify(dir, t, r); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "recording the pass:", err)
|
||||
}
|
||||
fmt.Printf("%s -> %s (%s): %d checked, %d match, %d mismatch, %d gone, %d unreadable, %d changed since, %d waiting, %d unhashed, %s read in %s\n",
|
||||
t.Src, t.Dst, scope, r.Checked, r.Matched, r.Mismatched, r.Gone, r.Unreadable, r.Changed, r.Waiting, r.Unhashed,
|
||||
humanBytes(r.BytesRead), r.Finished.Sub(r.Started).Round(time.Second))
|
||||
if r.Error != "" && firstErr == nil {
|
||||
firstErr = errors.New(r.Error)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const u = 1024
|
||||
if n < u {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(u), 0
|
||||
for v := n / u; v >= u; v /= u {
|
||||
div *= u
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func zfsSnapshot(cfg *config.Config, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return errors.New("usage: hamlab zfs-snapshot <schedule-id>")
|
||||
@@ -306,21 +380,6 @@ func zfsSnapshot(cfg *config.Config, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// virtExport runs the instance exports the Virtualization tab scheduled, as the account Ham
|
||||
// Lab runs as (which is in incus-admin), and records the run beside the data.
|
||||
func virtExport(cfg *config.Config, names []string) error {
|
||||
who := os.Getenv("USER")
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
who = u.Username
|
||||
}
|
||||
run := incus.New(cfg.DataDir(), who, version).RunExports(context.Background(), names, func(l string) { fmt.Println(l) })
|
||||
fmt.Printf("exported %d, pruned %d, %.0f s\n", len(run.Exported), len(run.Pruned), run.Seconds)
|
||||
if len(run.Errors) > 0 {
|
||||
return errors.New(strings.Join(run.Errors, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(cfg *config.Config) {
|
||||
name := os.Getenv("USER")
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
|
||||
+6
-19
@@ -71,9 +71,6 @@ type alertSaved struct {
|
||||
Channels []AlertChannel `json:"channels"`
|
||||
Events []AlertEvent `json:"events"`
|
||||
Seeded bool `json:"seeded"`
|
||||
// SawInstanceRule marks that the instance-down default has been offered once; set so that
|
||||
// removing it stays removed.
|
||||
SawInstanceRule bool `json:"saw_instance_rule,omitempty"`
|
||||
// Firing is what was firing when the hub last looked, so a restart (every deploy is one)
|
||||
// carries on where it was and doesn't announce the same alerts all over again.
|
||||
Firing map[string]alertKept `json:"firing,omitempty"`
|
||||
@@ -105,7 +102,6 @@ var defaultRules = []AlertRule{
|
||||
{ID: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true},
|
||||
{ID: "instance-down", Name: "VM or container down", Sensor: "instance.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true},
|
||||
@@ -126,23 +122,14 @@ func newAlertEngine(s *Server) *alertEngine {
|
||||
for key, k := range e.saved.Firing {
|
||||
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
|
||||
}
|
||||
// a default added after an install was seeded joins its rules once; removing it afterwards sticks
|
||||
if e.saved.Seeded && !e.saved.SawInstanceRule {
|
||||
has := false
|
||||
for _, r := range e.saved.Rules {
|
||||
if r.ID == "instance-down" {
|
||||
has = true
|
||||
}
|
||||
// the Virtualization tab's default went with the tab (2026-10-15): nothing publishes instance.* now
|
||||
kept := e.saved.Rules[:0]
|
||||
for _, r := range e.saved.Rules {
|
||||
if r.ID != "instance-down" {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
if !has {
|
||||
for _, d := range defaultRules {
|
||||
if d.ID == "instance-down" {
|
||||
e.saved.Rules = append(e.saved.Rules, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.saved.SawInstanceRule = true
|
||||
}
|
||||
e.saved.Rules = kept
|
||||
for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
|
||||
if r.ID == "container-down" && r.Sensor == "container.*.running" {
|
||||
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
|
||||
|
||||
@@ -340,7 +340,6 @@ func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) {
|
||||
rec.Error = err.Error()
|
||||
}
|
||||
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
|
||||
stackUpdatesForget("/" + job.Stack)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -1,141 +1,162 @@
|
||||
package api
|
||||
|
||||
// Playing a video the browser cannot play as it is: POST /api/files/play starts an HLS
|
||||
// re-encode on this machine (internal/media), GET …/play/{id}/index.m3u8 and the segments
|
||||
// feed the player, GET …/play/{id} is its progress, DELETE ends it. /api/files/play-caps
|
||||
// says which codecs this machine can make and on what. Root mode (?sudo=1) reads the file
|
||||
// and runs ffmpeg as root, through the same sudo the file reads use.
|
||||
// Playing a video in the Files viewer, YouTube's way: Auto (the player reads the connection),
|
||||
// any quality by hand, or the file itself. The qualities are made on the fly as HLS by
|
||||
// internal/media, on the device the Transcode device setting names — which may be on another
|
||||
// machine than the file.
|
||||
//
|
||||
// On every machine:
|
||||
// POST /api/files/play[?sudo=1] {path | url, name, device} open a play
|
||||
// GET /api/files/play/{id}/master.m3u8, /{rung}.m3u8, /{rung}/{n}.ts
|
||||
// GET /api/files/play/{id} what it is doing
|
||||
// DELETE /api/files/play/{id} (or POST …/close, for a beacon) end it
|
||||
// GET /api/files/play-devices what it can encode on
|
||||
// On the hub:
|
||||
// POST /api/play {node, path, sudo} open a play of a file on node, wherever the setting
|
||||
// says, answering which machine the play is on
|
||||
// GET|PUT /api/transcode the setting, and every machine's devices
|
||||
// GET /api/play-src/{token} a file, for a machine transcoding it, by a token
|
||||
// that names that one file; ranges pass through
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
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 {
|
||||
Codecs []playCodec `json:"codecs"`
|
||||
Qualities []media.Quality `json:"qualities"`
|
||||
Error string `json:"error,omitempty"`
|
||||
type playReq struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
URL string `json:"url,omitempty"` // a file on another machine, as the hub serves it
|
||||
Name string `json:"name,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type playCodec struct {
|
||||
media.Codec
|
||||
Engine string `json:"engine,omitempty"` // "" when nothing here encodes it
|
||||
type playOut struct {
|
||||
Node string `json:"node"` // the machine the play is on: "" for the hub; filled in by the hub
|
||||
Play *media.Play `json:"play"`
|
||||
Master string `json:"master"` // the playlist, under that machine's /api
|
||||
Engine string `json:"engine"`
|
||||
Host string `json:"host"` // that machine's name, for the menu
|
||||
Note string `json:"note,omitempty"`
|
||||
Status media.PlayStatus `json:"status"`
|
||||
}
|
||||
|
||||
// playCapsHandler: GET /api/files/play-caps[?sudo=1]
|
||||
func (s *Server) playCapsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// playDevices: GET /api/files/play-devices — what this machine could make the player's H.264
|
||||
// on, probed as the daemon's own user (a play from another machine never runs as root).
|
||||
func (s *Server) playDevices(w http.ResponseWriter, r *http.Request) {
|
||||
caps, err := s.thumbs.capsFor(r.Context(), false, false)
|
||||
if err != nil {
|
||||
caps.Error = err.Error()
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"host": s.hostName(), "devices": media.Devices(caps)})
|
||||
}
|
||||
|
||||
// playStart: POST /api/files/play[?sudo=1] — open a play of a file here, or of a URL the hub
|
||||
// serves a file on another machine at.
|
||||
func (s *Server) playStart(w http.ResponseWriter, r *http.Request) {
|
||||
var body playReq
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
root := r.URL.Query().Get("sudo") == "1"
|
||||
if root && s.fsSudo == nil {
|
||||
jsonErr(w, 403, "root access needs passwordless sudo on the host")
|
||||
return
|
||||
}
|
||||
caps, err := s.thumbs.capsFor(r.Context(), root, false)
|
||||
pc := playCaps{Codecs: []playCodec{}, Qualities: media.Qualities}
|
||||
out, code, err := s.openPlay(r.Context(), body, root)
|
||||
if err != nil {
|
||||
pc.Error = err.Error()
|
||||
} else if caps.Error != "" {
|
||||
pc.Error = caps.Error
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
for _, c := range media.Codecs {
|
||||
x := playCodec{Codec: c}
|
||||
if pc.Error == "" {
|
||||
x.Engine = media.EngineFor(caps, c.ID)
|
||||
}
|
||||
pc.Codecs = append(pc.Codecs, x)
|
||||
}
|
||||
jsonOK(w, 200, pc)
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
// playStart: POST /api/files/play[?sudo=1] {path, codec, quality}
|
||||
func (s *Server) playStart(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
func (s *Server) openPlay(ctx context.Context, body playReq, root bool) (playOut, int, error) {
|
||||
if root && s.fsSudo == nil && body.URL == "" {
|
||||
return playOut{}, 403, errors.New("root access needs passwordless sudo on the host")
|
||||
}
|
||||
root := r.URL.Query().Get("sudo") == "1"
|
||||
var body struct{ Path, Codec, Quality string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
in, name := body.Path, body.Name
|
||||
if body.URL != "" {
|
||||
u, err := url.Parse(body.URL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || body.Path != "" {
|
||||
return playOut{}, 400, errors.New("a play is of a path or of an http URL")
|
||||
}
|
||||
in, root = body.URL, false
|
||||
} else {
|
||||
p, err := files.Clean(body.Path)
|
||||
if err != nil {
|
||||
return playOut{}, 400, err
|
||||
}
|
||||
if media.Kind(p) != "video" {
|
||||
return playOut{}, 415, errors.New("not a video")
|
||||
}
|
||||
fsys := s.fsLocal
|
||||
if root {
|
||||
fsys = s.fsSudo
|
||||
}
|
||||
if _, err := fsys.Stat(ctx, p); err != nil {
|
||||
return playOut{}, 404, err
|
||||
}
|
||||
in = p
|
||||
if name == "" {
|
||||
name = filepath.Base(p)
|
||||
}
|
||||
}
|
||||
caps, err := s.thumbs.capsFor(ctx, root, false)
|
||||
if err == nil && caps.Error != "" {
|
||||
err = errors.New(caps.Error)
|
||||
}
|
||||
p, err := files.Clean(body.Path)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
return playOut{}, 503, err
|
||||
}
|
||||
if media.Kind(p) != "video" {
|
||||
jsonErr(w, 415, "not a video")
|
||||
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)
|
||||
ictx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
src, err := media.Inspect(ictx, caps, in, root)
|
||||
cancel()
|
||||
if err != nil {
|
||||
playErr(w, err)
|
||||
return
|
||||
var de *media.DecodeError
|
||||
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 {
|
||||
playErr(w, 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())
|
||||
return playOut{}, 422, err
|
||||
}
|
||||
log.Printf("files: play %s on %s (%d qualities, %s)", name, p.Engine(), len(p.Rungs), map[bool]string{true: "from another machine", false: "here"}[p.Remote])
|
||||
return playOut{Play: p, Master: "/api/files/play/" + p.ID + "/master.m3u8", Engine: p.Engine(), Host: s.hostName(), Status: p.Status()}, 200, nil
|
||||
}
|
||||
|
||||
// playStatus: GET /api/files/play/{id}
|
||||
func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := s.plays.Find(r.PathValue("id"))
|
||||
p, ok := s.plays.Find(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such stream")
|
||||
jsonErr(w, 404, "no such play")
|
||||
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
|
||||
@@ -145,25 +166,398 @@ func (s *Server) playStop(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// playFile: GET /api/files/play/{id}/{file} — the playlist, the init segment, a segment.
|
||||
func (s *Server) playFile(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := s.plays.Find(r.PathValue("id"))
|
||||
// playPlaylist: GET /api/files/play/{id}/{file} — master.m3u8, or one quality's <name>.m3u8.
|
||||
func (s *Server) playPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.plays.Find(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such stream")
|
||||
jsonErr(w, 404, "no such play")
|
||||
return
|
||||
}
|
||||
p, ok := st.File(r.PathValue("file"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "not a stream file")
|
||||
f := r.PathValue("file")
|
||||
var b []byte
|
||||
if f == "master.m3u8" {
|
||||
b = p.Master()
|
||||
} else if name, isList := strings.CutSuffix(f, ".m3u8"); isList {
|
||||
if b, ok = p.Media(name); !ok {
|
||||
jsonErr(w, 404, "no such quality")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
jsonErr(w, 404, "not a play file")
|
||||
return
|
||||
}
|
||||
switch filepath.Ext(p) {
|
||||
case ".m3u8":
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
default:
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeFile(w, r, p)
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
// playPiece: GET /api/files/play/{id}/{rung}/{seg} — a piece, made first if need be.
|
||||
func (s *Server) playPiece(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.plays.Find(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such play")
|
||||
return
|
||||
}
|
||||
ns, isTS := strings.CutSuffix(r.PathValue("seg"), ".ts")
|
||||
n, err := strconv.Atoi(ns)
|
||||
if !isTS || err != nil || strconv.Itoa(n) != ns {
|
||||
jsonErr(w, 404, "not a piece")
|
||||
return
|
||||
}
|
||||
file, err := p.Segment(r.Context().Done(), r.PathValue("rung"), n)
|
||||
switch {
|
||||
case errors.Is(err, media.ErrNotFound):
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
case r.Context().Err() != nil:
|
||||
return // the player moved on
|
||||
case err != nil:
|
||||
jsonErr(w, 503, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
http.ServeFile(w, r, file)
|
||||
}
|
||||
|
||||
// ---- on the hub: where plays are made ----
|
||||
|
||||
// transcodeChoice is the setting; nil is the machine the file is on.
|
||||
func (s *Server) transcodeChoice() *store.Transcode {
|
||||
var t *store.Transcode
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
s.store.View(func(st *store.State) {
|
||||
if st.Transcode != nil {
|
||||
c := *st.Transcode
|
||||
t = &c
|
||||
}
|
||||
})
|
||||
return t
|
||||
}
|
||||
|
||||
type transcodeMachine struct {
|
||||
Node string `json:"node"` // "" is the hub
|
||||
Host string `json:"host"`
|
||||
Devices []media.Device `json:"devices"`
|
||||
Error string `json:"error,omitempty"` // the machine didn't answer
|
||||
}
|
||||
|
||||
// getTranscode: GET /api/transcode — the setting, and what every machine can encode on,
|
||||
// asked of each now. ?only=selected asks only the chosen machine, for the device's name.
|
||||
func (s *Server) getTranscode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("only") == "selected" {
|
||||
c := s.transcodeChoice()
|
||||
out := map[string]any{"selected": c}
|
||||
if c != nil {
|
||||
name := c.Device
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
var devs []media.Device
|
||||
if c.Node == "" {
|
||||
caps, _ := s.thumbs.capsFor(ctx, false, false)
|
||||
devs = media.Devices(caps)
|
||||
} else if code, b, err := s.nodes.Do(ctx, c.Node, "GET", "/api/files/play-devices", nil, nil); err == nil && code == 200 {
|
||||
var d struct{ Devices []media.Device }
|
||||
_ = json.Unmarshal(b, &d)
|
||||
devs = d.Devices
|
||||
}
|
||||
for _, d := range devs {
|
||||
if d.ID == c.Device {
|
||||
name = d.Name
|
||||
}
|
||||
}
|
||||
host := c.Node
|
||||
if host == "" {
|
||||
host = s.hostName()
|
||||
}
|
||||
out["host"], out["name"] = host, name
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
return
|
||||
}
|
||||
names := []string{""}
|
||||
if s.nodes != nil {
|
||||
names = append(names, s.nodes.Names()...)
|
||||
}
|
||||
out := make([]transcodeMachine, len(names))
|
||||
var wg sync.WaitGroup
|
||||
for i, n := range names {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
m := transcodeMachine{Node: n, Host: n}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
if n == "" {
|
||||
caps, err := s.thumbs.capsFor(ctx, false, false)
|
||||
if err != nil {
|
||||
caps.Error = err.Error()
|
||||
}
|
||||
m.Host, m.Devices = s.hostName(), media.Devices(caps)
|
||||
} else {
|
||||
code, b, err := s.nodes.Do(ctx, n, "GET", "/api/files/play-devices", nil, nil)
|
||||
var d struct {
|
||||
Host string `json:"host"`
|
||||
Devices []media.Device `json:"devices"`
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
m.Error = err.Error()
|
||||
case code == 404:
|
||||
m.Error = "runs an older Ham Lab"
|
||||
case code != 200 || json.Unmarshal(b, &d) != nil:
|
||||
m.Error = fmt.Sprintf("answered %d", code)
|
||||
default:
|
||||
m.Devices = d.Devices
|
||||
}
|
||||
}
|
||||
out[i] = m
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
jsonOK(w, 200, map[string]any{"selected": s.transcodeChoice(), "machines": out})
|
||||
}
|
||||
|
||||
// putTranscode: PUT /api/transcode {node, device}, or {} for the machine the file is on.
|
||||
func (s *Server) putTranscode(w http.ResponseWriter, r *http.Request) {
|
||||
var body *store.Transcode
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "expected {\"node\": …, \"device\": …}")
|
||||
return
|
||||
}
|
||||
if body != nil && body.Device == "" {
|
||||
body = nil
|
||||
}
|
||||
if body != nil {
|
||||
known := body.Node == ""
|
||||
if s.nodes != nil {
|
||||
for _, n := range s.nodes.Names() {
|
||||
known = known || n == body.Node
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
jsonErr(w, 400, "no such machine")
|
||||
return
|
||||
}
|
||||
if body.Device != media.CPU && !strings.HasPrefix(body.Device, "/dev/dri/") {
|
||||
jsonErr(w, 400, "a device is a render node or \"cpu\"")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.Update(func(st *store.State) { st.Transcode = body }); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
s.getTranscode(w, r)
|
||||
}
|
||||
|
||||
// hubPlay: POST /api/play {node, path, sudo} — open a play of a file on node ("" the hub) on
|
||||
// the device the setting names. When that is on another machine, that machine reads the file
|
||||
// through this hub, by a token good for that one file. If it can't be reached, the file's own
|
||||
// machine makes it instead, and the answer says so.
|
||||
func (s *Server) hubPlay(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Node string `json:"node"`
|
||||
Path string `json:"path"`
|
||||
Sudo bool `json:"sudo"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
p, err := files.Clean(body.Path)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if body.Node != "" && (s.nodes == nil || !has(s.nodes.Names(), body.Node)) {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
}
|
||||
choice := s.transcodeChoice()
|
||||
var note string
|
||||
if choice != nil && choice.Node != body.Node {
|
||||
if s.nodes != nil && choice.Node != "" && !has(s.nodes.Names(), choice.Node) {
|
||||
note = "The transcode machine " + choice.Node + " is no longer managed here; made where the file is."
|
||||
} else {
|
||||
src, err := s.srcURL(choice.Node, body.Node, p, body.Sudo)
|
||||
if err == nil {
|
||||
req := playReq{URL: src, Name: filepath.Base(p), Device: choice.Device}
|
||||
out, code, err := s.playOn(r.Context(), choice.Node, req, false)
|
||||
if err == nil {
|
||||
jsonOK(w, 200, out)
|
||||
return
|
||||
}
|
||||
if code == 422 || code == 415 {
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Printf("files: play on %s failed (%v); making it where the file is", nodeLabel(choice.Node), err)
|
||||
note = "Couldn't make it on " + nodeLabel(choice.Node) + " (" + err.Error() + "), so it is made where the file is."
|
||||
}
|
||||
}
|
||||
device := ""
|
||||
if choice != nil && choice.Node == body.Node {
|
||||
device = choice.Device
|
||||
}
|
||||
out, code, err := s.playOn(r.Context(), body.Node, playReq{Path: p, Device: device}, body.Sudo)
|
||||
if err != nil {
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
out.Note = note
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func nodeLabel(n string) string {
|
||||
if n == "" {
|
||||
return "the hub"
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func has(list []string, x string) bool {
|
||||
for _, v := range list {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// playOn opens a play on a machine: here, or through that node's API.
|
||||
func (s *Server) playOn(ctx context.Context, node string, req playReq, sudo bool) (playOut, int, error) {
|
||||
if node == "" {
|
||||
out, code, err := s.openPlay(ctx, req, sudo)
|
||||
return out, code, err
|
||||
}
|
||||
b, _ := json.Marshal(req)
|
||||
path := "/api/files/play"
|
||||
if sudo {
|
||||
path += "?sudo=1"
|
||||
}
|
||||
code, resp, err := s.nodes.Do(ctx, node, "POST", path, b, http.Header{"Content-Type": []string{"application/json"}})
|
||||
if err != nil {
|
||||
return playOut{}, 502, err
|
||||
}
|
||||
if code != 200 {
|
||||
var e struct{ Error string }
|
||||
_ = json.Unmarshal(resp, &e)
|
||||
if e.Error == "" {
|
||||
e.Error = fmt.Sprintf("%s answered %d", node, code)
|
||||
}
|
||||
return playOut{}, code, errors.New(e.Error)
|
||||
}
|
||||
var out playOut
|
||||
if err := json.Unmarshal(resp, &out); err != nil {
|
||||
return playOut{}, 502, err
|
||||
}
|
||||
out.Node = node
|
||||
return out, 200, nil
|
||||
}
|
||||
|
||||
// ---- the file, for the machine transcoding it ----
|
||||
|
||||
type srcGrant struct {
|
||||
node, path string
|
||||
sudo bool
|
||||
until time.Time
|
||||
}
|
||||
|
||||
type srcGrants struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*srcGrant
|
||||
}
|
||||
|
||||
const srcLife = 12 * time.Hour // from its last use: a film paused overnight still plays
|
||||
|
||||
var grants = &srcGrants{m: map[string]*srcGrant{}}
|
||||
|
||||
func (g *srcGrants) mint(node, path string, sudo bool) string {
|
||||
b := make([]byte, 24)
|
||||
_, _ = rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
now := time.Now()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for k, v := range g.m {
|
||||
if now.After(v.until) {
|
||||
delete(g.m, k)
|
||||
}
|
||||
}
|
||||
g.m[tok] = &srcGrant{node: node, path: path, sudo: sudo, until: now.Add(srcLife)}
|
||||
return tok
|
||||
}
|
||||
|
||||
func (g *srcGrants) use(tok string) (srcGrant, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
v, ok := g.m[tok]
|
||||
if !ok || time.Now().After(v.until) {
|
||||
return srcGrant{}, false
|
||||
}
|
||||
v.until = time.Now().Add(srcLife)
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// srcURL is where the machine `to` can read a file on machine `from` through this hub: this
|
||||
// hub's own address as `to` reaches it, and a token for that one file.
|
||||
func (s *Server) srcURL(to, from, path string, sudo bool) (string, error) {
|
||||
host, port, err := net.SplitHostPort(s.cfg.Listen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if host == "" || ip == nil || ip.IsUnspecified() || ip.IsLoopback() {
|
||||
// Listening everywhere: the address this machine reaches `to` from is one it answers on.
|
||||
target := "127.0.0.1:1"
|
||||
if to != "" {
|
||||
if u, err := url.Parse(s.nodes.URL(to)); err == nil && u.Host != "" {
|
||||
target = u.Host
|
||||
}
|
||||
}
|
||||
c, err := net.Dial("udp", target)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
host = c.LocalAddr().(*net.UDPAddr).IP.String()
|
||||
c.Close()
|
||||
}
|
||||
scheme := "http"
|
||||
if s.fingerprint != "" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + net.JoinHostPort(host, port) + "/api/play-src/" + grants.mint(from, path, sudo), nil
|
||||
}
|
||||
|
||||
// playSrc: GET /api/play-src/{token} — the one file a token names, ranges and all, read from
|
||||
// its machine. The token is the only key: it is minted for a machine transcoding that file.
|
||||
func (s *Server) playSrc(w http.ResponseWriter, r *http.Request) {
|
||||
g, ok := grants.use(r.PathValue("token"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such file")
|
||||
return
|
||||
}
|
||||
q := url.Values{"path": {g.path}}
|
||||
if g.sudo {
|
||||
q.Set("sudo", "1")
|
||||
}
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.RawQuery = q.Encode()
|
||||
r2.Header.Del("Cookie")
|
||||
if g.node == "" {
|
||||
r2.URL.Path = "/api/files/read"
|
||||
s.filesRead(w, r2)
|
||||
return
|
||||
}
|
||||
p, ok := s.nodes.Proxy(g.node)
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
}
|
||||
r2.URL.Path = "/api/nodes/" + g.node + "/files/read"
|
||||
p.ServeHTTP(w, r2)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/files"
|
||||
)
|
||||
|
||||
// The file a machine transcodes from another is read through the hub by a token naming that
|
||||
// one file: ranges pass through, the query can't point it anywhere else, and an unknown token
|
||||
// is nothing.
|
||||
func TestPlaySrc(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "film.mp4")
|
||||
if err := os.WriteFile(p, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "other.mp4"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{fsLocal: files.Local{}, cfg: &config.Config{Listen: "10.0.0.234:8100"}}
|
||||
u, err := s.srcURL("", "", p, false)
|
||||
if err != nil || !strings.HasPrefix(u, "http://10.0.0.234:8100/api/play-src/") {
|
||||
t.Fatalf("url %q %v", u, err)
|
||||
}
|
||||
tok := strings.TrimPrefix(u, "http://10.0.0.234:8100/api/play-src/")
|
||||
get := func(tok, query, rng string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", "/api/play-src/"+tok+query, nil)
|
||||
r.SetPathValue("token", tok)
|
||||
if rng != "" {
|
||||
r.Header.Set("Range", rng)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.playSrc(w, r)
|
||||
return w
|
||||
}
|
||||
if w := get(tok, "", "bytes=2-4"); w.Code != 206 || w.Body.String() != "234" {
|
||||
t.Fatalf("range: %d %q", w.Code, w.Body)
|
||||
}
|
||||
if w := get(tok, "?path="+filepath.Join(dir, "other.mp4"), ""); w.Body.String() != "0123456789" {
|
||||
t.Fatalf("the query moved it: %q", w.Body)
|
||||
}
|
||||
if w := get("nope", "", ""); w.Code != 404 {
|
||||
t.Fatalf("unknown token: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A preview made on another machine is kept by the file's own: only while the file is still
|
||||
// the one it was made of, and a failure is kept as a failure.
|
||||
func TestThumbPut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "film.mp4")
|
||||
if err := os.WriteFile(p, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, _ := os.Stat(p)
|
||||
s := &Server{fsLocal: files.Local{}, thumbs: newThumbSvc(dir, "")}
|
||||
v := fmt.Sprintf("%d-%d", fi.ModTime().UnixMilli(), fi.Size())
|
||||
put := func(v string, body string, hdr map[string]string) int {
|
||||
r := httptest.NewRequest("PUT", "/api/files/thumb?path="+url.QueryEscape(p)+"&v="+v, strings.NewReader(body))
|
||||
for k, x := range hdr {
|
||||
r.Header.Set(k, x)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.thumbPut(w, r)
|
||||
return w.Code
|
||||
}
|
||||
get := func() *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", "/api/files/thumb?cached=1&path="+url.QueryEscape(p)+"&v="+v, nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.filesThumb(w, r)
|
||||
return w
|
||||
}
|
||||
if w := get(); w.Code != 404 {
|
||||
t.Fatalf("cached=1 before anything: %d", w.Code)
|
||||
}
|
||||
if c := put("1-10", "jpeg", nil); c != 409 {
|
||||
t.Fatalf("a changed file: %d", c)
|
||||
}
|
||||
if c := put(v, "jpeg-bytes", map[string]string{"X-Hamlab-Thumb": "grid vaapi:/dev/dri/renderD129"}); c != 204 {
|
||||
t.Fatalf("put: %d", c)
|
||||
}
|
||||
if w := get(); w.Code != 200 || w.Body.String() != "jpeg-bytes" || !strings.Contains(w.Header().Get("X-Hamlab-Thumb"), "grid") {
|
||||
t.Fatalf("kept: %d %q %q", w.Code, w.Body, w.Header().Get("X-Hamlab-Thumb"))
|
||||
}
|
||||
}
|
||||
+21
-66
@@ -34,7 +34,6 @@ import (
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/hardware"
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/network"
|
||||
@@ -76,12 +75,10 @@ type Server struct {
|
||||
net *network.Watcher
|
||||
update updateState
|
||||
events eventLog
|
||||
kvms kvmProxies
|
||||
virt *incus.Manager
|
||||
fsLocal files.FS
|
||||
fsSudo files.FS // nil without passwordless sudo
|
||||
thumbs *thumbSvc
|
||||
plays *media.Streamer
|
||||
plays *media.Plays
|
||||
self string // this binary, for helpers run as tasks
|
||||
nodes *nodes.Manager
|
||||
fingerprint string
|
||||
@@ -105,7 +102,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
s.self = self
|
||||
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
|
||||
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
|
||||
if u, err := user.Current(); err == nil && 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 == "" {
|
||||
s.user = "?"
|
||||
}
|
||||
s.virt = incus.New(cfg.DataDir(), s.user, version)
|
||||
s.migrateUsers()
|
||||
// The speed test and the outside view. Its history outlives a restart, and its last result
|
||||
// is published as sensors, so the line is graphable and alertable like anything else.
|
||||
@@ -237,37 +233,6 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
|
||||
m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec))
|
||||
m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate))
|
||||
m.HandleFunc("GET /api/virt", s.auth(s.virtOverview))
|
||||
m.HandleFunc("POST /api/virt/install", s.auth(s.virtInstall))
|
||||
m.HandleFunc("GET /api/virt/images/remote", s.auth(s.virtRemoteImages))
|
||||
m.HandleFunc("POST /api/virt/images/download", s.auth(s.virtImageDownload))
|
||||
m.HandleFunc("POST /api/virt/images/import", s.auth(s.virtImageImport))
|
||||
m.HandleFunc("POST /api/virt/images/publish", s.auth(s.virtImagePublish))
|
||||
m.HandleFunc("POST /api/virt/images/delete", s.auth(s.virtImageDelete))
|
||||
m.HandleFunc("POST /api/virt/instances", s.auth(s.virtCreate))
|
||||
m.HandleFunc("GET /api/virt/instances/{name}", s.auth(s.virtInstance))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/rename", s.auth(s.virtRename))
|
||||
m.HandleFunc("PUT /api/virt/instances/{name}/config", s.auth(s.virtConfig))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/snapshots", s.auth(s.virtSnapshotCreate))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/snapshots/{snap}/{action}", s.auth(s.virtSnapshotAction))
|
||||
m.HandleFunc("POST /api/virt/instances/from-iso", s.auth(s.virtFromISO))
|
||||
m.HandleFunc("GET /api/virt/instances/{name}/vga", s.auth(s.virtVGA))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/iso", s.auth(s.virtISOAttach))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/iso/detach", s.auth(s.virtISODetach))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/mounts", s.auth(s.virtMountAdd))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/mounts/remove", s.auth(s.virtMountRemove))
|
||||
m.HandleFunc("POST /api/virt/instances/{name}/{action}", s.auth(s.virtAction))
|
||||
m.HandleFunc("POST /api/virt/isos/fetch", s.auth(s.virtISOFetch))
|
||||
m.HandleFunc("POST /api/virt/isos/import", s.auth(s.virtISOImport))
|
||||
m.HandleFunc("POST /api/virt/isos/delete", s.auth(s.virtISODelete))
|
||||
m.HandleFunc("POST /api/virt/isos/rename", s.auth(s.virtISORename))
|
||||
m.HandleFunc("GET /api/virt/exports", s.auth(s.virtExports))
|
||||
m.HandleFunc("PUT /api/virt/exports", s.auth(s.virtExportsPut))
|
||||
m.HandleFunc("POST /api/virt/exports/run", s.auth(s.virtExportsRun))
|
||||
m.HandleFunc("POST /api/virt/exports/delete", s.auth(s.virtExportDelete))
|
||||
m.HandleFunc("GET /api/virt/sshkeys", s.auth(s.virtSSHKeys))
|
||||
m.HandleFunc("POST /api/virt/pools", s.auth(s.virtPoolCreate))
|
||||
m.HandleFunc("POST /api/virt/pools/delete", s.auth(s.virtPoolDelete))
|
||||
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
|
||||
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
|
||||
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
|
||||
@@ -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/chmod", s.auth(s.filesChmod))
|
||||
m.HandleFunc("GET /api/files/thumb", s.auth(s.filesThumb))
|
||||
m.HandleFunc("PUT /api/files/thumb", s.auth(s.thumbPut))
|
||||
m.HandleFunc("POST /api/files/thumb-from", s.auth(s.thumbFrom))
|
||||
m.HandleFunc("GET /api/nodes/{name}/files/thumb", s.auth(s.hubThumb))
|
||||
m.HandleFunc("GET /api/files/thumbs", s.auth(s.thumbsStatus))
|
||||
m.HandleFunc("POST /api/files/thumbs/install", s.auth(s.thumbsInstall))
|
||||
m.HandleFunc("POST /api/files/thumbs/gpu-access", s.auth(s.thumbsGPUAccess))
|
||||
m.HandleFunc("GET /api/files/play-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("GET /api/files/play/{id}", s.auth(s.playStatus))
|
||||
m.HandleFunc("DELETE /api/files/play/{id}", s.auth(s.playStop))
|
||||
m.HandleFunc("POST /api/files/play/{id}/close", s.auth(s.playStop))
|
||||
m.HandleFunc("GET /api/files/play/{id}/{file}", s.auth(s.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/network", s.auth(s.networkOverview))
|
||||
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("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow))
|
||||
m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete))
|
||||
m.HandleFunc("GET /api/zfs/landing", s.auth(s.landingOverview))
|
||||
m.HandleFunc("POST /api/zfs/landing/verify", s.auth(s.landingVerifyNow))
|
||||
m.HandleFunc("PUT /api/zfs/landing/schedules", s.auth(s.landingPolicyPut))
|
||||
m.HandleFunc("DELETE /api/zfs/landing/schedules/{id}", s.auth(s.landingPolicyDelete))
|
||||
m.HandleFunc("POST /api/zfs/landing/schedules/{id}/run", s.auth(s.landingPolicyRun))
|
||||
m.HandleFunc("GET /api/space", s.auth(s.spaceOverview))
|
||||
m.HandleFunc("GET /api/space/{disk}", s.auth(s.spaceStatus))
|
||||
m.HandleFunc("POST /api/space/{disk}/scan", s.auth(s.spaceScan))
|
||||
@@ -372,18 +350,7 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
||||
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
||||
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
||||
m.HandleFunc("GET /api/kvms", s.auth(s.listKVMs))
|
||||
m.HandleFunc("POST /api/kvms", s.auth(s.addKVM))
|
||||
m.HandleFunc("DELETE /api/kvms/{id}", s.auth(s.removeKVM))
|
||||
m.HandleFunc("/kvm/{id}/{rest...}", s.auth(s.serveKVM))
|
||||
m.HandleFunc("/kvm/{id}", s.auth(s.serveKVM))
|
||||
// A path Ham Lab does not serve may still belong to a KVM's page, which asks for its
|
||||
// own assets by absolute path. Ham Lab's own routes matched first, so this can only
|
||||
// catch what would otherwise have been a 404.
|
||||
m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.kvmFallback(w, r) {
|
||||
return
|
||||
}
|
||||
jsonErr(w, 404, "not found")
|
||||
}))
|
||||
m.HandleFunc("/", s.static)
|
||||
@@ -407,16 +374,10 @@ func sensorPath(p string) bool {
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
// The console must never be framed. Two exceptions: an embedded widget exists to be
|
||||
// (a page that can only read sensors, and only with a read-only token in its URL),
|
||||
// and a proxied KVM is framed by the console itself — 'none' forbids that even from
|
||||
// the same origin, so those paths say 'self' instead of opening up.
|
||||
switch {
|
||||
case r.URL.Path == "/embed/widget":
|
||||
case strings.HasPrefix(r.URL.Path, kvmPrefix):
|
||||
h.Set("X-Frame-Options", "SAMEORIGIN")
|
||||
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
|
||||
default:
|
||||
// The console must never be framed. The one exception is an embedded widget, which
|
||||
// exists to be (a page that can only read sensors, and only with a read-only token in
|
||||
// its URL).
|
||||
if r.URL.Path != "/embed/widget" {
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
}
|
||||
@@ -2675,12 +2636,6 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// A path with no file of ours behind it may belong to a KVM's page, which asks for its
|
||||
// own assets by absolute path. Checked here rather than in the router because this
|
||||
// handler is the catch-all — and gated on a session, since this handler is public.
|
||||
if s.signedIn(r) && s.kvmFallback(w, r) {
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFileFS(w, r, s.ui, "index.html")
|
||||
|
||||
+44
-13
@@ -744,7 +744,6 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
||||
_ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run()
|
||||
})
|
||||
}
|
||||
stackUpdatesForget(st.Name)
|
||||
log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
@@ -755,6 +754,7 @@ type imageCheck struct {
|
||||
Image string `json:"image"`
|
||||
State string `json:"state"` // current | update | unknown
|
||||
Detail string `json:"detail,omitempty"`
|
||||
remote string // the digest the registry named, so a later pull can be recognised without asking again
|
||||
}
|
||||
|
||||
type updAnswer struct {
|
||||
@@ -767,7 +767,41 @@ var (
|
||||
updCache = map[string]updAnswer{}
|
||||
)
|
||||
|
||||
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
||||
func stackUpdatesForget(name string) { updMu.Lock(); delete(updCache, "stack:"+name); updMu.Unlock() }
|
||||
|
||||
// localDigests is what this machine holds for an image: the registry digests it was pulled as.
|
||||
func localDigests(ctx context.Context, img string) []string {
|
||||
out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||
var have []string
|
||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil {
|
||||
return nil
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
func holdsDigest(have []string, digest string) bool {
|
||||
for _, h := range have {
|
||||
if strings.HasSuffix(h, "@"+digest) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// settled is a cached answer brought up to date with what is here now. The registry's side
|
||||
// is kept for six hours, but this machine's side changes the moment an update pulls the
|
||||
// newer image — so an image marked behind is looked at again locally, which costs no
|
||||
// registry request, and is current once it holds the digest the registry named.
|
||||
func settled(ctx context.Context, res []imageCheck) []imageCheck {
|
||||
out := make([]imageCheck, len(res))
|
||||
copy(out, res)
|
||||
for i, ic := range out {
|
||||
if ic.State == "update" && ic.remote != "" && holdsDigest(localDigests(ctx, ic.Image), ic.remote) {
|
||||
out[i].State = "current"
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkImages asks the registry whether each image is still the one we hold. Each image is
|
||||
// one registry request, which Docker Hub counts — so this is only ever called when asked for.
|
||||
@@ -775,9 +809,8 @@ func checkImages(ctx context.Context, images []string) []imageCheck {
|
||||
res := []imageCheck{}
|
||||
for _, img := range images {
|
||||
ic := imageCheck{Image: img, State: "unknown"}
|
||||
out, err := dockerRun(ctx, "image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||
var have []string
|
||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
||||
have := localDigests(ctx, img)
|
||||
if len(have) == 0 {
|
||||
ic.Detail = "built or loaded here, not pulled from a registry"
|
||||
res = append(res, ic)
|
||||
continue
|
||||
@@ -795,11 +828,9 @@ func checkImages(ctx context.Context, images []string) []imageCheck {
|
||||
res = append(res, ic)
|
||||
continue
|
||||
}
|
||||
ic.State = "update"
|
||||
for _, h := range have {
|
||||
if strings.HasSuffix(h, "@"+digest) {
|
||||
ic.State = "current"
|
||||
}
|
||||
ic.State, ic.remote = "update", digest
|
||||
if holdsDigest(have, digest) {
|
||||
ic.State = "current"
|
||||
}
|
||||
res = append(res, ic)
|
||||
}
|
||||
@@ -833,7 +864,7 @@ func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := updCache[key]
|
||||
updMu.Unlock()
|
||||
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli(), "stacks": byImage})
|
||||
jsonOK(w, 200, map[string]any{"images": settled(ctx, c.res), "checked": c.at.UnixMilli(), "stacks": byImage})
|
||||
return
|
||||
}
|
||||
if !refresh {
|
||||
@@ -863,13 +894,13 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
key := st.Name
|
||||
key := "stack:" + st.Name
|
||||
updMu.Lock()
|
||||
c, ok := updCache[key]
|
||||
updMu.Unlock()
|
||||
refresh := r.URL.Query().Get("refresh") == "1"
|
||||
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli()})
|
||||
jsonOK(w, 200, map[string]any{"images": settled(ctx, c.res), "checked": c.at.UnixMilli()})
|
||||
return
|
||||
}
|
||||
if !refresh {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/incus"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/sensors"
|
||||
"hamlab/internal/storage"
|
||||
@@ -55,7 +54,6 @@ func newStatusSensors(s *Server) *statusSensors {
|
||||
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}, acc: map[string]*bucket{}}
|
||||
go p.loop("services", servicesEvery, p.services)
|
||||
go p.loop("containers", containersEvery, p.containers)
|
||||
go p.loop("instances", containersEvery, p.instances)
|
||||
go p.loop("updates", updatesEvery, p.updates)
|
||||
go p.loop("zfs", zfsEvery, p.zfs)
|
||||
go p.loop("smart", smartEvery, p.smart)
|
||||
@@ -322,43 +320,6 @@ type cpuMark struct {
|
||||
t int64
|
||||
}
|
||||
|
||||
// instances are Incus's containers and VMs. "Down" means it should be up and is not: an
|
||||
// instance marked to start with the machine that is stopped, or one in Error — a stopped
|
||||
// instance with no such mark was stopped on purpose, as with Docker containers.
|
||||
func (p *statusSensors) instances(ctx context.Context, t int64) []sensors.Sensor {
|
||||
if !incus.Installed() {
|
||||
return nil
|
||||
}
|
||||
list, err := p.s.virt.InstancesFor(ctx, "sensors")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var res []sensors.Sensor
|
||||
var running, total float64
|
||||
for _, in := range list {
|
||||
total++
|
||||
id := "instance." + sensors.Seg(in.Name)
|
||||
l := map[string]string{"instance": in.Name, "type": in.Type, "image": in.Image}
|
||||
up := in.Status == "Running"
|
||||
if up {
|
||||
running++
|
||||
}
|
||||
down := in.Status == "Error" || (!up && in.Config["boot.autostart"] == "true")
|
||||
res = append(res,
|
||||
sensors.Sensor{ID: id + ".running", Name: in.Name, Kind: "state", Value: up, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".down", Name: in.Name + " down", Kind: "state", Value: down, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".state", Name: in.Name + " state", Kind: "state", Value: strings.ToLower(in.Status), T: t, Labels: l})
|
||||
if up {
|
||||
res = append(res,
|
||||
sensors.Sensor{ID: id + ".cpu", Name: in.Name + " CPU", Kind: "usage", Unit: "%", Value: float64(int(in.CPUPct*10+0.5)) / 10, T: t, Min: sensors.F(0), Labels: l},
|
||||
sensors.Sensor{ID: id + ".mem", Name: in.Name + " memory", Kind: "bytes", Unit: "B", Value: float64(in.MemUsage), T: t, Labels: l})
|
||||
}
|
||||
}
|
||||
return append(res,
|
||||
sensors.Sensor{ID: "instances.total", Name: "Instances", Kind: "count", Value: total, T: t},
|
||||
sensors.Sensor{ID: "instances.running", Name: "Running instances", Kind: "count", Value: running, T: t})
|
||||
}
|
||||
|
||||
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
|
||||
st := p.s.updates.Status(ctx)
|
||||
if st.Manager == "" {
|
||||
|
||||
+239
-7
@@ -10,10 +10,16 @@ package api
|
||||
// opens every render node, whatever groups the daemon's user has.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -25,6 +31,7 @@ import (
|
||||
|
||||
"hamlab/internal/media"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
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.
|
||||
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()
|
||||
if c, ok := t.calls[key]; ok {
|
||||
t.mu.Unlock()
|
||||
@@ -153,19 +162,29 @@ func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (media
|
||||
c.err = 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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
// filesThumb: GET /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1]. v is the client's idea of
|
||||
// the file, from its listing; it makes a cache hit free of a stat, and a changed file a new URL.
|
||||
// thumbDeviceHeader carries the device a hub wants a video preview made on: the Transcode
|
||||
// device setting, when it is on the machine the file is on.
|
||||
const thumbDeviceHeader = "X-Hamlab-Device"
|
||||
|
||||
// filesThumb: GET /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1][&cached=1]. v is the
|
||||
// client's idea of the file, from its listing; it makes a cache hit free of a stat, and a
|
||||
// changed file a new URL. cached=1 answers from the cache or 404, and makes nothing: the hub
|
||||
// asks that before having the preview made on another machine.
|
||||
//
|
||||
// A video preview is made on the Transcode device: on the hub, by the setting (another
|
||||
// machine's device reads the file through the hub); on a node, by the hub's header.
|
||||
func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
@@ -203,7 +222,28 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
||||
if s.serveCachedThumb(w, key) {
|
||||
return
|
||||
}
|
||||
res, err := s.thumbs.make(r.Context(), root, key, p)
|
||||
if r.URL.Query().Get("cached") == "1" {
|
||||
jsonErr(w, 404, "not made yet")
|
||||
return
|
||||
}
|
||||
device := r.Header.Get(thumbDeviceHeader)
|
||||
if c := s.transcodeChoice(); c != nil && device == "" && media.Kind(p) == "video" {
|
||||
if c.Node != "" {
|
||||
// The hub's own file, previewed on another machine.
|
||||
if res, ok := s.thumbElsewhere(r.Context(), c, "", p, root, key); ok {
|
||||
if res.Failure != "" {
|
||||
_ = s.thumbs.cache.Put(key, media.Entry{Path: p, Failure: res.Failure})
|
||||
jsonErr(w, 422, res.Failure)
|
||||
return
|
||||
}
|
||||
s.keepThumb(w, key, p, res)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
device = c.Device
|
||||
}
|
||||
}
|
||||
res, err := s.thumbs.make(r.Context(), root, key, p, "", device)
|
||||
if err != nil {
|
||||
var de *media.DecodeError
|
||||
switch {
|
||||
@@ -223,12 +263,204 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
s.keepThumb(w, key, p, res)
|
||||
}
|
||||
|
||||
// keepThumb puts a preview in the cache and answers with it.
|
||||
func (s *Server) keepThumb(w http.ResponseWriter, key, p string, res media.Result) {
|
||||
if err := s.thumbs.cache.Put(key, media.Entry{Path: p, Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}); err != nil {
|
||||
log.Printf("thumbs: cache: %v", err)
|
||||
}
|
||||
writeThumb(w, key, res.JPEG, res.Engine)
|
||||
}
|
||||
|
||||
// ---- previews made on another machine (the Transcode device) ----
|
||||
|
||||
// thumbFrom: POST /api/files/thumb-from {url, kind, key, device} — a preview of a file on
|
||||
// another machine, read through the hub at url, made here on device. Answers the JPEG (the
|
||||
// engine in X-Hamlab-Thumb), or 422 with why the file can't be previewed.
|
||||
func (s *Server) thumbFrom(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ URL, Kind, Key, Device string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8192)).Decode(&body); err != nil || !strings.HasPrefix(body.URL, "http") || body.Key == "" {
|
||||
jsonErr(w, 400, "expected {url, kind, key, device}")
|
||||
return
|
||||
}
|
||||
res, err := s.thumbs.make(r.Context(), false, "from:"+body.Key, body.URL, body.Kind, body.Device)
|
||||
if err != nil {
|
||||
var de *media.DecodeError
|
||||
if errors.As(err, &de) {
|
||||
jsonErr(w, 422, de.Msg)
|
||||
} else {
|
||||
jsonErr(w, 503, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Hamlab-Thumb-Codec", res.Codec)
|
||||
w.Header().Set("X-Hamlab-Thumb-Size", fmt.Sprintf("%dx%d", res.Width, res.Height))
|
||||
writeThumb(w, body.Key, res.JPEG, res.Engine)
|
||||
}
|
||||
|
||||
// thumbPut: PUT /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1] — a preview another machine
|
||||
// made of this file, kept here as if made here. The body is the JPEG, or nothing with
|
||||
// X-Hamlab-Thumb-Failure saying why the file can't be previewed. Refused if the file has
|
||||
// changed since v.
|
||||
func (s *Server) thumbPut(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mt, sz, ok := parseV(r.URL.Query().Get("v"))
|
||||
if !ok {
|
||||
jsonErr(w, 400, "v=<mtime>-<size> is required")
|
||||
return
|
||||
}
|
||||
in, err := fsys.Stat(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
if in.Size != sz || in.Mtime != mt {
|
||||
jsonErr(w, 409, "the file has changed")
|
||||
return
|
||||
}
|
||||
key := media.Key(p, sz, mt)
|
||||
e := media.Entry{Path: p, Engine: r.Header.Get("X-Hamlab-Thumb"), Codec: r.Header.Get("X-Hamlab-Thumb-Codec"), Failure: r.Header.Get("X-Hamlab-Thumb-Failure")}
|
||||
fmt.Sscanf(r.Header.Get("X-Hamlab-Thumb-Size"), "%dx%d", &e.Width, &e.Height)
|
||||
if e.Failure == "" {
|
||||
if e.JPEG, err = io.ReadAll(io.LimitReader(r.Body, 4<<20)); err != nil || len(e.JPEG) == 0 {
|
||||
jsonErr(w, 400, "no picture")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.thumbs.cache.Put(key, e); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// thumbElsewhere has a video preview made on the Transcode device's machine c.Node, of a
|
||||
// file on machine node ("" the hub), and answers it. ok is false when that machine can't
|
||||
// (unreachable, an older Ham Lab): the caller makes it where the file is instead. A file
|
||||
// that can't be previewed is a result too, with the failure in it.
|
||||
func (s *Server) thumbElsewhere(ctx context.Context, c *store.Transcode, node, p string, root bool, key string) (media.Result, bool) {
|
||||
src, err := s.srcURL(c.Node, node, p, root)
|
||||
if err != nil {
|
||||
return media.Result{}, false
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"url": src, "kind": "video", "key": key, "device": c.Device})
|
||||
ctx, cancel := context.WithTimeout(ctx, thumbTimeout+10*time.Second)
|
||||
defer cancel()
|
||||
var code int
|
||||
var b []byte
|
||||
var hdr http.Header
|
||||
if c.Node == "" {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/files/thumb-from", bytes.NewReader(body)).WithContext(ctx)
|
||||
s.thumbFrom(rec, req)
|
||||
code, b, hdr = rec.Code, rec.Body.Bytes(), rec.Header()
|
||||
} else {
|
||||
code, b, hdr, err = s.nodes.DoHeaders(ctx, c.Node, "POST", "/api/files/thumb-from", body, http.Header{"Content-Type": []string{"application/json"}})
|
||||
if err != nil {
|
||||
log.Printf("thumbs: %s on %s: %v; made where it is", p, c.Node, err)
|
||||
return media.Result{}, false
|
||||
}
|
||||
}
|
||||
switch code {
|
||||
case 200:
|
||||
res := media.Result{JPEG: b, Engine: c.Node + " " + hdr.Get("X-Hamlab-Thumb"), Codec: hdr.Get("X-Hamlab-Thumb-Codec")}
|
||||
fmt.Sscanf(hdr.Get("X-Hamlab-Thumb-Size"), "%dx%d", &res.Width, &res.Height)
|
||||
if c.Node == "" {
|
||||
res.Engine = s.hostName() + " " + hdr.Get("X-Hamlab-Thumb")
|
||||
}
|
||||
return res, true
|
||||
case 422:
|
||||
var e struct{ Error string }
|
||||
_ = json.Unmarshal(b, &e)
|
||||
return media.Result{Failure: e.Error}, true
|
||||
}
|
||||
log.Printf("thumbs: %s on %s answered %d; made where it is", p, nodeLabel(c.Node), code)
|
||||
return media.Result{}, false
|
||||
}
|
||||
|
||||
// hubThumb: GET /api/nodes/{name}/files/thumb — a node's preview, through the hub. Without a
|
||||
// Transcode device setting, or for an image or a sound file's cover, it goes to the node as
|
||||
// any request does. A video is made on the setting's device: the node's own (told by header),
|
||||
// or another machine's, which reads the file through the hub; the node keeps the result in
|
||||
// its own cache either way.
|
||||
func (s *Server) hubThumb(w http.ResponseWriter, r *http.Request) {
|
||||
node := r.PathValue("name")
|
||||
proxy, ok := s.nodes.Proxy(node)
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such machine")
|
||||
return
|
||||
}
|
||||
c := s.transcodeChoice()
|
||||
q := r.URL.Query()
|
||||
p := q.Get("path")
|
||||
if c == nil || media.Kind(p) != "video" {
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if c.Node == node {
|
||||
r.Header.Set(thumbDeviceHeader, c.Device)
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
mt, sz, okV := parseV(q.Get("v"))
|
||||
if !okV {
|
||||
proxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// The node's own cache first.
|
||||
cq := url.Values{"path": {p}, "v": {q.Get("v")}, "cached": {"1"}}
|
||||
if q.Get("sudo") == "1" {
|
||||
cq.Set("sudo", "1")
|
||||
}
|
||||
hdr := http.Header{}
|
||||
if inm := r.Header.Get("If-None-Match"); inm != "" {
|
||||
hdr.Set("If-None-Match", inm)
|
||||
}
|
||||
code, b, rh, err := s.nodes.DoHeaders(r.Context(), node, "GET", "/api/files/thumb?"+cq.Encode(), nil, hdr)
|
||||
if err == nil && code != 404 {
|
||||
for _, k := range []string{"Content-Type", "Cache-Control", "ETag", "X-Hamlab-Thumb"} {
|
||||
if v := rh.Get(k); v != "" {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(b)
|
||||
return
|
||||
}
|
||||
key := media.Key(p, sz, mt)
|
||||
res, made := s.thumbElsewhere(r.Context(), c, node, p, q.Get("sudo") == "1", key)
|
||||
if !made {
|
||||
proxy.ServeHTTP(w, r) // the node makes it itself
|
||||
return
|
||||
}
|
||||
// Kept where the file is, so it is made once.
|
||||
ph := http.Header{"X-Hamlab-Thumb": {res.Engine}, "X-Hamlab-Thumb-Codec": {res.Codec}, "X-Hamlab-Thumb-Size": {fmt.Sprintf("%dx%d", res.Width, res.Height)}}
|
||||
if res.Failure != "" {
|
||||
ph.Set("X-Hamlab-Thumb-Failure", res.Failure)
|
||||
}
|
||||
pq := url.Values{"path": {p}, "v": {q.Get("v")}}
|
||||
if q.Get("sudo") == "1" {
|
||||
pq.Set("sudo", "1")
|
||||
}
|
||||
if code, _, _, err := s.nodes.DoHeaders(r.Context(), node, "PUT", "/api/files/thumb?"+pq.Encode(), res.JPEG, ph); err != nil || code != 204 {
|
||||
log.Printf("thumbs: keeping %s's preview of %s: %d %v", node, p, code, err)
|
||||
}
|
||||
if res.Failure != "" {
|
||||
jsonErr(w, 422, res.Failure)
|
||||
return
|
||||
}
|
||||
writeThumb(w, key, res.JPEG, res.Engine)
|
||||
}
|
||||
|
||||
// serveCachedThumb answers from the cache when it can. A cache that cannot be read is a
|
||||
// miss: the preview is made again rather than refused.
|
||||
func (s *Server) serveCachedThumb(w http.ResponseWriter, key string) bool {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -26,7 +26,8 @@ import (
|
||||
)
|
||||
|
||||
type zfsState struct {
|
||||
Policies []storage.Policy `json:"policies"`
|
||||
Policies []storage.Policy `json:"policies"`
|
||||
Verify []storage.VerifyPolicy `json:"verify"` // landing-tier verify schedules (landing.go)
|
||||
}
|
||||
|
||||
func (s *Server) zfsLoad() zfsState {
|
||||
@@ -39,6 +40,9 @@ func (s *Server) zfsLoad() zfsState {
|
||||
if z.Policies == nil {
|
||||
z.Policies = []storage.Policy{}
|
||||
}
|
||||
if z.Verify == nil {
|
||||
z.Verify = []storage.VerifyPolicy{}
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
@@ -138,11 +142,13 @@ func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// zfsARC is the cache: the sampler's live ARC (size, the hit rate over the last interval)
|
||||
// beside the storage package's fuller picture (since-boot counters, tunables, and each
|
||||
// pool's cache/log/special devices or the lack of them).
|
||||
// pool's cache/log/special devices or the lack of them), and the write path — the dirty
|
||||
// buffer in RAM, each pool's write rate, and any landing tier in front of a pool.
|
||||
func (s *Server) zfsARC(w http.ResponseWriter, r *http.Request) {
|
||||
out := map[string]any{"detail": s.storage.ARC(r.Context()), "live": nil}
|
||||
out := map[string]any{"detail": s.storage.ARC(r.Context()), "live": nil, "writes": nil}
|
||||
if smp, ok := s.metrics.Latest(); ok && smp.ARC != nil {
|
||||
out["live"] = smp.ARC
|
||||
out["writes"] = smp.ZFSWrite
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
@@ -77,23 +77,6 @@ type UpdateSource struct {
|
||||
Check string `json:"check,omitempty"`
|
||||
}
|
||||
|
||||
// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access
|
||||
// to a machine, including when that machine has no OS running. Ham Lab holds the list on
|
||||
// the hub and proxies each one, because a KVM matters exactly when the machine it is
|
||||
// attached to cannot answer for itself.
|
||||
type KVM struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// URL is the device's own address, as you would type it into a browser on the LAN.
|
||||
URL string `json:"url"`
|
||||
// Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
|
||||
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
|
||||
// for a node's KVM, because a KVM earns its keep when the machine is down and cannot
|
||||
// answer for itself.
|
||||
Machine string `json:"machine"`
|
||||
Added time.Time `json:"added"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
// PasswordHash is the single login of installs before users existed. On load it becomes
|
||||
@@ -103,7 +86,6 @@ type Config struct {
|
||||
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
||||
Tokens []Token `json:"tokens,omitempty"`
|
||||
Nodes []Node `json:"nodes,omitempty"`
|
||||
KVMs []KVM `json:"kvms,omitempty"`
|
||||
// Updates: where a newer build comes from. See UpdateSource.
|
||||
Updates UpdateSource `json:"update,omitempty"`
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,217 +1,337 @@
|
||||
package media
|
||||
|
||||
// What to run when a file is to be re-encoded for a browser that cannot play it as it is:
|
||||
// the codecs and sizes on offer, the output size and bit rate a choice comes to for a
|
||||
// given source, the ways this machine could do it in the order worth trying, and the
|
||||
// ffmpeg argument list for one of them. All decisions, no processes, so it can be tested.
|
||||
// What to run when a video is played in the browser: the qualities it is offered in (the
|
||||
// ladder), the ways a machine could make one of them on the device asked for, in the order
|
||||
// worth trying, and the ffmpeg argument list for one piece-making run. All decisions, no
|
||||
// processes, so it can be tested. Everything is H.264 High and AAC: what every browser plays.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Codec is one target a stream may be re-encoded to. "copy" keeps the codec and only
|
||||
// repackages: for a browser that lacks the container, not the decoder.
|
||||
type Codec struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
// Rung is one quality the player can pick: a size, a frame rate and a bit rate.
|
||||
type Rung struct {
|
||||
Name string `json:"name"` // in URLs: the width, "1920"
|
||||
Label string `json:"label"` // what the menu says: "1080s", "720p"
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
FPS float64 `json:"fps"`
|
||||
Bitrate int `json:"bitrate"` // video, bits a second, on average
|
||||
Peak int `json:"peak"` // what the playlist promises, sound included
|
||||
level string // H.264 level, "5.2"
|
||||
codecs string // for the master playlist
|
||||
}
|
||||
|
||||
var Codecs = []Codec{{"h264", "H.264"}, {"hevc", "HEVC"}, {"av1", "AV1"}, {"copy", "Original codec, repackaged"}}
|
||||
|
||||
// Quality is a picture height; the bit rate follows from the size the output ends up at.
|
||||
type Quality struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Height int `json:"height"` // 0: the source's own
|
||||
// One step of the ladder: the long side for a 360 video (its width), the short side for a flat
|
||||
// one, and the bit rate (Mb/s ×10) at more than 30 frames a second and at 30 or fewer.
|
||||
type step struct {
|
||||
size int
|
||||
label string
|
||||
hi, lo int
|
||||
cap30 bool
|
||||
}
|
||||
|
||||
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) {
|
||||
for _, q := range Qualities {
|
||||
if q.ID == id {
|
||||
return q, true
|
||||
var flatSteps = []step{
|
||||
{2160, "2160p", 160, 120, false},
|
||||
{1440, "1440p", 90, 65, false},
|
||||
{1080, "1080p", 50, 35, false},
|
||||
{720, "720p", 25, 25, true},
|
||||
{480, "480p", 12, 12, true},
|
||||
}
|
||||
|
||||
const audioRate = 160_000
|
||||
|
||||
func even(x float64) int { return int(math.Round(x/2)) * 2 }
|
||||
|
||||
// Ladder is the qualities a video is offered in, best first: every step no bigger than the
|
||||
// picture itself, or the picture's own size if it is smaller than all of them.
|
||||
func Ladder(i Info) []Rung {
|
||||
v := i.Video
|
||||
if v == nil || v.Width <= 0 || v.Height <= 0 {
|
||||
return nil
|
||||
}
|
||||
vr := v.Spherical
|
||||
steps := flatSteps
|
||||
w, h := v.Shown()
|
||||
src := min(w, h) // a flat picture is named by its short side
|
||||
if vr {
|
||||
steps, src = vrSteps, w
|
||||
}
|
||||
// A picture between two steps (a 2292×960 capture) gets its own size on top, at the bit
|
||||
// rate of the step above, so the best on offer is never less than the file.
|
||||
var out []Rung
|
||||
for k, s := range steps {
|
||||
if s.size > src {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return Quality{}, false
|
||||
}
|
||||
|
||||
// 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
|
||||
if len(out) == 0 && s.size < src && k > 0 { // never above the top step: H.264 stops there
|
||||
own := steps[k-1]
|
||||
own.size, own.label, own.cap30 = src, strconv.Itoa(src)+map[bool]string{true: "s", false: "p"}[vr], false
|
||||
out = append(out, rung(i, own))
|
||||
}
|
||||
out = append(out, rung(i, s))
|
||||
}
|
||||
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
|
||||
// size it comes to — a quarter more at 50 fps and up, less for the codecs that need less.
|
||||
func fit(q Quality, src Info, codec string) (w, h, kbps int) {
|
||||
w, h = src.Video.Width, src.Video.Height
|
||||
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
|
||||
func rung(i Info, s step) Rung {
|
||||
v := i.Video
|
||||
sw, sh := v.Shown()
|
||||
var w, h int
|
||||
switch {
|
||||
case h >= 2160:
|
||||
kbps = 20000
|
||||
case h >= 1440:
|
||||
kbps = 12000
|
||||
case h >= 1080:
|
||||
kbps = 8000
|
||||
case h >= 720:
|
||||
kbps = 4000
|
||||
default:
|
||||
kbps = 2000
|
||||
case v.Spherical:
|
||||
w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
|
||||
case sw >= sh:
|
||||
h, w = s.size, even(float64(s.size)*float64(sw)/float64(sh))
|
||||
default: // standing up
|
||||
w, h = s.size, even(float64(s.size)*float64(sh)/float64(sw))
|
||||
}
|
||||
if src.Video.FPS >= 50 {
|
||||
kbps = kbps * 5 / 4
|
||||
fps := v.FPS
|
||||
if fps <= 0 {
|
||||
fps = 30
|
||||
}
|
||||
if codec == "hevc" || codec == "av1" {
|
||||
kbps = kbps * 6 / 10
|
||||
for fps > 60.5 || (s.cap30 && fps > 30.5) {
|
||||
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
|
||||
// arguments that go before the input, the video filter, and the video codec.
|
||||
// level is the lowest H.264 level that takes this many macroblocks a frame and a second.
|
||||
func level(w, h int, fps float64) string {
|
||||
mbs := ((w + 15) / 16) * ((h + 15) / 16)
|
||||
rate := float64(mbs) * fps
|
||||
for _, l := range []struct {
|
||||
name string
|
||||
mbps float64
|
||||
frame int
|
||||
}{
|
||||
{"3.1", 108000, 3600}, {"3.2", 216000, 5120}, {"4.0", 245760, 8192}, {"4.2", 522240, 8704},
|
||||
{"5.0", 589824, 22080}, {"5.1", 983040, 36864}, {"5.2", 2073600, 36864},
|
||||
} {
|
||||
if rate <= l.mbps && mbs <= l.frame {
|
||||
return l.name
|
||||
}
|
||||
}
|
||||
return "6.2"
|
||||
}
|
||||
|
||||
// fpsArg is a frame rate as ffmpeg's fps filter takes it, exactly for the NTSC rates.
|
||||
func fpsArg(f float64) string {
|
||||
for _, n := range []float64{24, 30, 60, 120} {
|
||||
if math.Abs(f-n*1000/1001) < 0.01 {
|
||||
return fmt.Sprintf("%d/1001", int(n*1000))
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(f, 'f', 3, 64), "0"), ".")
|
||||
}
|
||||
|
||||
// Recipe is one way to make a quality: the engine's name for the record, the device it runs
|
||||
// on ("cpu" for the software encoder), and the ffmpeg arguments that go before the input, the
|
||||
// video filter, and the video codec.
|
||||
type Recipe struct {
|
||||
Engine string
|
||||
Device string
|
||||
Pre []string
|
||||
VF string
|
||||
Video []string
|
||||
}
|
||||
|
||||
// recipes are the ways to try, best first: a card on the bus before the integrated GPU;
|
||||
// on a GPU, decoding on the same card (frames never leave it) before decoding in software
|
||||
// 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 {
|
||||
if codec == "copy" {
|
||||
v := []string{"-c:v", "copy"}
|
||||
if src.Video.Codec == "hevc" {
|
||||
v = append(v, "-tag:v", "hvc1") // Safari reads hvc1, not hev1
|
||||
}
|
||||
return []Recipe{{Engine: "Original codec", Video: v}}
|
||||
// CPU is the device name for software encoding.
|
||||
const CPU = "cpu"
|
||||
|
||||
// Device is one thing a machine can make H.264 on, for the Transcode device setting.
|
||||
type Device struct {
|
||||
ID string `json:"id"` // a render node, or "cpu"
|
||||
Name string `json:"name"` // "Intel Arc A380", "CPU (libx264)"
|
||||
Integrated bool `json:"integrated,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"` // when not OK: why
|
||||
}
|
||||
|
||||
// Devices lists what this machine could make the player's H.264 on: every GPU, usable or not
|
||||
// (and why not), then the CPU.
|
||||
func Devices(c Caps) []Device {
|
||||
var out []Device
|
||||
if c.Error != "" {
|
||||
return []Device{{ID: CPU, Name: "CPU", Reason: c.Error}}
|
||||
}
|
||||
var out []Recipe
|
||||
gpus := append([]GPU(nil), c.GPUs...)
|
||||
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated })
|
||||
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"}
|
||||
for i := range gpus {
|
||||
g := &gpus[i]
|
||||
if g.API != "vaapi" || !has(g.Encodes, codec) {
|
||||
continue
|
||||
for _, g := range c.GPUs {
|
||||
d := Device{ID: g.Device, Name: g.Name, Integrated: g.Integrated}
|
||||
if d.Name == "" {
|
||||
d.Name = g.Device
|
||||
}
|
||||
video := append([]string{"-c:v", codec + "_vaapi"}, rate...)
|
||||
switch codec {
|
||||
case "h264":
|
||||
video = append(video, "-profile:v", "high")
|
||||
case "hevc":
|
||||
video = append(video, "-tag:v", "hvc1")
|
||||
switch {
|
||||
case g.API != "vaapi":
|
||||
d.Reason = "only VAAPI devices encode here"
|
||||
case g.Encodes != nil && !has(g.Encodes, "h264"):
|
||||
d.Reason = "doesn't encode H.264"
|
||||
default:
|
||||
d.OK = true
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
for _, g := range c.Unusable {
|
||||
name := g.Name
|
||||
if name == "" {
|
||||
name = g.Device
|
||||
}
|
||||
scale := fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", w, h)
|
||||
if has(g.Codecs, src.Video.Codec) {
|
||||
vf := scale
|
||||
if src.HDR() {
|
||||
vf = "tonemap_vaapi=format=nv12:t=bt709:p=bt709:m=bt709," + vf
|
||||
out = append(out, Device{ID: g.Device, Name: name, Integrated: g.Integrated, Reason: g.Reason})
|
||||
}
|
||||
d := Device{ID: CPU, Name: "CPU (libx264)", OK: has(c.Encoders, "libx264")}
|
||||
if !d.OK {
|
||||
d.Reason = "this ffmpeg has no libx264"
|
||||
}
|
||||
return append(out, d)
|
||||
}
|
||||
|
||||
// recipes are the ways to make quality r of src on device, best first: on a GPU, decoding on
|
||||
// the same card (frames never leave it) before decoding in software and uploading; the CPU
|
||||
// last, as the fallback whatever was asked. device "" is any: a card on the bus before the
|
||||
// integrated GPU. An empty list means nothing here can.
|
||||
func recipes(c Caps, device string, src Info, r Rung) []Recipe {
|
||||
var out []Recipe
|
||||
gpus := append([]GPU(nil), c.GPUs...)
|
||||
sort.SliceStable(gpus, func(i, j int) bool { return !gpus[i].Integrated && gpus[j].Integrated })
|
||||
kbps := r.Bitrate / 1000
|
||||
rate := []string{"-b:v", strconv.Itoa(kbps) + "k", "-maxrate", strconv.Itoa(kbps*3/2) + "k", "-bufsize", strconv.Itoa(kbps*2) + "k",
|
||||
"-profile:v", "high", "-level", r.level}
|
||||
fps := ""
|
||||
if src.Video == nil || src.Video.FPS <= 0 || math.Abs(src.Video.FPS-r.FPS) > 0.01 {
|
||||
fps = "fps=" + fpsArg(r.FPS) + ","
|
||||
}
|
||||
if device != CPU {
|
||||
for i := range gpus {
|
||||
g := &gpus[i]
|
||||
// Encodes is vainfo's answer; without vainfo there is none, and the card is tried
|
||||
// anyway, with the next recipe behind it.
|
||||
if g.API != "vaapi" || (g.Encodes != nil && !has(g.Encodes, "h264")) || (device != "" && g.Device != device) {
|
||||
continue
|
||||
}
|
||||
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 src.HDR() {
|
||||
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)
|
||||
}
|
||||
cap := []string{"-maxrate", strconv.Itoa(kbps*5/4) + "k", "-bufsize", strconv.Itoa(kbps*5/2) + "k"}
|
||||
switch {
|
||||
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...)})
|
||||
if has(c.Encoders, "libx264") {
|
||||
vf := fmt.Sprintf("%sscale=%d:%d,format=yuv420p", fps, r.Width, r.Height)
|
||||
if src.PQ() {
|
||||
vf = fps + toneMap + fmt.Sprintf(",scale=%d:%d,format=yuv420p", r.Width, r.Height)
|
||||
}
|
||||
out = append(out, Recipe{Engine: "CPU (libx264)", Device: CPU, VF: vf, Video: append([]string{"-c:v", "libx264", "-preset", "veryfast", "-sc_threshold", "0"}, rate...)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EngineFor is the engine the picker will say a codec runs on, "" when none can.
|
||||
func EngineFor(c Caps, codec string) string {
|
||||
if c.Error != "" {
|
||||
// turn is the card's filter that turns a picture the way it is meant to be shown.
|
||||
func turn(src Info) string {
|
||||
if src.Video == nil {
|
||||
return ""
|
||||
}
|
||||
if codec == "copy" {
|
||||
return "Original codec"
|
||||
switch src.Video.Rotation {
|
||||
case 90:
|
||||
return "transpose_vaapi=dir=clock,"
|
||||
case 180:
|
||||
return "transpose_vaapi=dir=reversal,"
|
||||
case 270:
|
||||
return "transpose_vaapi=dir=cclock,"
|
||||
}
|
||||
r := recipes(c, codec, Info{Video: &Track{Codec: "h264", Width: 1920, Height: 1080}}, 1920, 1080, 8000)
|
||||
if len(r) == 0 {
|
||||
return ""
|
||||
}
|
||||
return r[0].Engine
|
||||
return ""
|
||||
}
|
||||
|
||||
// hlsArgs is the whole ffmpeg argument list for one recipe: the input, the first video and
|
||||
// audio streams, the picture through the recipe, sound as AAC unless it already is, a
|
||||
// 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 {
|
||||
// toneMap turns an HDR10 picture into an ordinary one, in software.
|
||||
const toneMap = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=hable,zscale=t=bt709:m=bt709:r=tv"
|
||||
|
||||
// Seg is the length of a piece, in seconds.
|
||||
const Seg = 3.0
|
||||
|
||||
// tsBase is added to every run's times, so pieces from different runs follow on: without it
|
||||
// a run from the top would start just below zero (the B-frame and AAC delay) and the muxer
|
||||
// would shift that run alone.
|
||||
const tsBase = 10
|
||||
|
||||
// pieceArgs is one run making quality r from piece n on, into dir as <n>.ts: an accurate seek
|
||||
// to n×Seg, a keyframe on every Seg mark and nowhere else (so every piece starts on one, and
|
||||
// pieces from any run and quality line up), times put back where that is in the film. in is a
|
||||
// path or, for a file on another machine, an http URL.
|
||||
func pieceArgs(dir, in string, src Info, rc Recipe, r Rung, n int) []string {
|
||||
at := strconv.FormatFloat(float64(n)*Seg, 'f', 3, 64)
|
||||
a := []string{"-nostdin", "-hide_banner", "-loglevel", "error", "-y"}
|
||||
a = append(a, r.Pre...)
|
||||
a = append(a, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
|
||||
if r.VF != "" {
|
||||
a = append(a, "-vf", r.VF)
|
||||
a = append(a, rc.Pre...)
|
||||
if strings.HasPrefix(in, "http://") || strings.HasPrefix(in, "https://") {
|
||||
a = append(a, "-reconnect", "1", "-reconnect_on_network_error", "1", "-reconnect_delay_max", "4")
|
||||
}
|
||||
a = append(a, r.Video...)
|
||||
switch {
|
||||
case src.Audio == nil:
|
||||
a = append(a, "-ss", at, "-i", in, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
|
||||
if rc.VF != "" {
|
||||
a = append(a, "-vf", rc.VF)
|
||||
}
|
||||
a = append(a, rc.Video...)
|
||||
if src.Audio == nil {
|
||||
a = append(a, "-an")
|
||||
case src.Audio.Codec == "aac":
|
||||
a = append(a, "-c:a", "copy")
|
||||
default:
|
||||
} else {
|
||||
a = append(a, "-c:a", "aac", "-b:a", "160k", "-ac", "2")
|
||||
}
|
||||
if !copyCodec {
|
||||
fps := src.Video.FPS
|
||||
if fps <= 0 {
|
||||
fps = 30
|
||||
}
|
||||
gop := int(math.Round(fps * hlsSeconds))
|
||||
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")
|
||||
gop := int(math.Ceil(r.FPS*Seg)) * 4
|
||||
a = append(a, "-g", strconv.Itoa(gop), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%g)", Seg),
|
||||
"-output_ts_offset", strconv.FormatFloat(float64(n)*Seg+tsBase, 'f', 3, 64),
|
||||
"-f", "hls", "-hls_time", strconv.FormatFloat(Seg-0.5, 'f', 2, 64), "-hls_list_size", "0",
|
||||
"-hls_flags", "temp_file", "-hls_segment_type", "mpegts", "-start_number", strconv.Itoa(n),
|
||||
"-hls_segment_filename", dir+"/%d.ts", dir+"/ffmpeg.m3u8")
|
||||
return a
|
||||
}
|
||||
|
||||
const hlsSeconds = 4
|
||||
|
||||
func has(list []string, x string) bool {
|
||||
for _, v := range list {
|
||||
if v == x {
|
||||
|
||||
+116
-73
@@ -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}}
|
||||
}
|
||||
|
||||
func TestFit(t *testing.T) {
|
||||
q := func(id string) Quality { x, _ := qualityByID(id); return x }
|
||||
for _, c := range []struct {
|
||||
q string
|
||||
in Info
|
||||
codec string
|
||||
w, h, kbs int
|
||||
}{
|
||||
{"1080p", src("hevc", 3840, 2160, 60), "h264", 1920, 1080, 10000},
|
||||
{"original", src("hevc", 3840, 2160, 30), "h264", 3840, 2160, 20000},
|
||||
{"720p", src("h264", 2292, 960, 60), "h264", 1718, 720, 5000}, // odd width made even
|
||||
{"1080p", src("h264", 1280, 720, 30), "h264", 1280, 720, 4000}, // never upscaled
|
||||
{"480p", src("h264", 1920, 1080, 30), "hevc", 852, 480, 1200}, // 853.3 rounds down, then even
|
||||
{"original", src("av1", 640, 360, 24), "av1", 640, 360, 1200},
|
||||
} {
|
||||
w, h, k := fit(q(c.q), c.in, c.codec)
|
||||
if w != c.w || h != c.h || k != c.kbs {
|
||||
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)
|
||||
}
|
||||
func TestLadder(t *testing.T) {
|
||||
vr := src("hevc", 7680, 3840, 50)
|
||||
vr.Video.Spherical = true
|
||||
r := Ladder(vr)
|
||||
if len(r) != 4 || r[0].Name != "3840" || r[0].Height != 1920 || r[0].level != "5.2" || r[3].FPS != 25 || r[0].Label != "2160s" {
|
||||
t.Fatalf("8K 360: %+v", r)
|
||||
}
|
||||
if r[0].codecs != "avc1.640034,mp4a.40.2" {
|
||||
t.Fatalf("codecs %q", r[0].codecs)
|
||||
}
|
||||
r = Ladder(src("h264", 2292, 960, 60)) // a capture: its own size on top, then the steps under it
|
||||
if len(r) != 3 || r[0].Label != "960p" || r[0].Width != 2292 || r[0].FPS != 60 || r[0].Bitrate != 5_000_000 || r[1].Label != "720p" || r[1].Width != 1720 || r[1].FPS != 30 {
|
||||
t.Fatalf("2292×960: %+v", r)
|
||||
}
|
||||
r = Ladder(src("h264", 1920, 1080, 59.94))
|
||||
if len(r) != 3 || r[0].Label != "1080p" || r[0].FPS != 59.94 || r[1].FPS != 29.97 || r[2].Width != 854 {
|
||||
t.Fatalf("1080p: %+v", r)
|
||||
}
|
||||
if r = Ladder(src("h264", 1080, 1920, 30)); r[0].Width != 1080 || r[0].Height != 1920 { // standing up
|
||||
t.Fatalf("portrait: %+v", r)
|
||||
}
|
||||
if r = Ladder(src("h264", 320, 240, 30)); len(r) != 1 || r[0].Width != 320 || r[0].Label != "240p" {
|
||||
t.Fatalf("tiny: %+v", r)
|
||||
}
|
||||
silent := src("h264", 1280, 720, 30)
|
||||
silent.Audio = nil
|
||||
if r = Ladder(silent); strings.Contains(r[0].codecs, "mp4a") {
|
||||
t.Fatalf("no sound, no AAC: %q", r[0].codecs)
|
||||
}
|
||||
if Ladder(Info{Duration: 10}) != nil {
|
||||
t.Fatal("no picture, no ladder")
|
||||
}
|
||||
if fpsArg(29.97) != "30000/1001" || fpsArg(25) != "25" {
|
||||
t.Fatal(fpsArg(29.97), fpsArg(25))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,82 +62,110 @@ func engineNames(r []Recipe) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRecipesOrder(t *testing.T) {
|
||||
func TestRecipes(t *testing.T) {
|
||||
c := gridCaps()
|
||||
// h264 from hevc: the card first, decoding on it; then the iGPU the same way; then the CPU
|
||||
got := engineNames(recipes(c, "h264", src("hevc", 3840, 2160, 60), 1920, 1080, 10000))
|
||||
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)"}
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("got %v", got)
|
||||
in := src("hevc", 3840, 2160, 60)
|
||||
r1080 := Ladder(in)[2]
|
||||
// any device: the card first, decoding on it; then the iGPU the same way; then the CPU
|
||||
got := strings.Join(engineNames(recipes(c, "", in, r1080)), "|")
|
||||
if got != "Intel Arc A380|Intel Arc A380, decoded on the CPU|Intel UHD Graphics 750|Intel UHD Graphics 750, decoded on the CPU|CPU (libx264)" {
|
||||
t.Fatalf("any: %s", got)
|
||||
}
|
||||
// av1 source: neither card decodes it, so no same-card recipe, and only the Arc encodes av1
|
||||
got = engineNames(recipes(c, "av1", src("av1", 1920, 1080, 30), 1920, 1080, 4800))
|
||||
want = []string{"Intel Arc A380 (vaapi, decoded on the CPU)", "CPU (SVT-AV1)"}
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("av1: got %v", got)
|
||||
// the iGPU asked for: only it, then the CPU as the fallback
|
||||
if got = strings.Join(engineNames(recipes(c, "/dev/dri/renderD128", in, r1080)), "|"); got != "Intel UHD Graphics 750|Intel UHD Graphics 750, decoded on the CPU|CPU (libx264)" {
|
||||
t.Fatalf("iGPU: %s", got)
|
||||
}
|
||||
// copy is one recipe, no filter, hvc1 for hevc
|
||||
r := recipes(c, "copy", src("hevc", 1920, 1080, 30), 1920, 1080, 0)
|
||||
if len(r) != 1 || r[0].VF != "" || !has(r[0].Video, "hvc1") || !has(r[0].Video, "copy") {
|
||||
t.Fatalf("copy: %+v", r)
|
||||
if got = strings.Join(engineNames(recipes(c, CPU, in, r1080)), "|"); got != "CPU (libx264)" {
|
||||
t.Fatalf("cpu: %s", got)
|
||||
}
|
||||
// a machine with no GPU and no x264: nothing for h264, something for av1
|
||||
bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac", "libsvtav1"}}
|
||||
if len(recipes(bare, "h264", src("h264", 1280, 720, 30), 1280, 720, 4000)) != 0 {
|
||||
t.Fatal("no x264 means no h264")
|
||||
// an AV1 source: neither card decodes it, so no same-card recipe
|
||||
if got = strings.Join(engineNames(recipes(c, "/dev/dri/renderD129", src("av1", 1920, 1080, 30), r1080)), "|"); got != "Intel Arc A380, decoded on the CPU|CPU (libx264)" {
|
||||
t.Fatalf("av1: %s", got)
|
||||
}
|
||||
if EngineFor(bare, "av1") != "CPU (SVT-AV1)" || EngineFor(bare, "h264") != "" || EngineFor(bare, "copy") != "Original codec" {
|
||||
t.Fatal("engineFor")
|
||||
// a machine with no GPU and no x264: nothing
|
||||
bare := Caps{FFmpeg: "ffmpeg", FFprobe: "ffprobe", Encoders: []string{"aac"}}
|
||||
if len(recipes(bare, "", in, r1080)) != 0 {
|
||||
t.Fatal("no x264 means nothing")
|
||||
}
|
||||
if EngineFor(c, "hevc") != "Intel Arc A380 (vaapi)" {
|
||||
t.Fatalf("engineFor on grid: %q", EngineFor(c, "hevc"))
|
||||
}
|
||||
// HDR gets tone-mapped on both paths
|
||||
// 30 fps from 60 for the lower rungs; HDR tone-mapped on both paths
|
||||
r720 := Ladder(in)[3]
|
||||
hdr := src("hevc", 3840, 2160, 60)
|
||||
hdr.Video.ColorTransfer = "smpte2084"
|
||||
r = recipes(c, "h264", hdr, 1920, 1080, 10000)
|
||||
if !strings.HasPrefix(r[0].VF, "tonemap_vaapi") || !strings.Contains(r[len(r)-1].VF, "tonemap=hable") {
|
||||
t.Fatalf("hdr: %q … %q", r[0].VF, r[len(r)-1].VF)
|
||||
rs := recipes(c, "", hdr, r720)
|
||||
if !strings.HasPrefix(rs[0].VF, "fps=30,tonemap_vaapi") || !strings.Contains(rs[1].VF, "tonemap=hable") || !strings.HasSuffix(rs[1].VF, "hwupload") || !strings.Contains(rs[len(rs)-1].VF, "tonemap=hable") {
|
||||
t.Fatalf("hdr: %q … %q … %q", rs[0].VF, rs[1].VF, rs[len(rs)-1].VF)
|
||||
}
|
||||
hdr.Video.ColorTransfer = "arib-std-b67" // HLG: shown as it is, on every path
|
||||
if rs = recipes(c, "", hdr, r720); strings.Contains(rs[0].VF, "tonemap") || strings.Contains(rs[1].VF, "tonemap") {
|
||||
t.Fatalf("hlg: %q … %q", rs[0].VF, rs[1].VF)
|
||||
}
|
||||
// a phone's picture, upright: the ladder by the shown size, turned on the card
|
||||
phone := src("hevc", 1920, 1080, 30)
|
||||
phone.Video.Rotation = 90
|
||||
lr := Ladder(phone)
|
||||
if lr[0].Width != 1080 || lr[0].Height != 1920 {
|
||||
t.Fatalf("upright: %+v", lr[0])
|
||||
}
|
||||
if rs = recipes(c, "", phone, lr[0]); !strings.HasPrefix(rs[0].VF, "transpose_vaapi=dir=clock,scale_vaapi=w=1080:h=1920") || rs[0].Pre[0] != "-noautorotate" || strings.Contains(rs[1].VF, "transpose") {
|
||||
t.Fatalf("turned: %q %v / %q", rs[0].VF, rs[0].Pre, rs[1].VF)
|
||||
}
|
||||
// no vainfo: the card is tried all the same
|
||||
nov := gridCaps()
|
||||
nov.GPUs[1].Encodes = nil
|
||||
if rs = recipes(nov, "/dev/dri/renderD129", in, r1080); rs[0].Engine != "Intel Arc A380" || !Devices(nov)[1].OK {
|
||||
t.Fatalf("no vainfo: %v", engineNames(rs))
|
||||
}
|
||||
if rs := recipes(c, "", in, r1080); strings.Contains(rs[0].VF, "fps=") {
|
||||
t.Fatalf("same rate needs no fps filter: %q", rs[0].VF)
|
||||
}
|
||||
}
|
||||
|
||||
func 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)
|
||||
r := recipes(gridCaps(), "h264", in, 1920, 1080, 10000)[0]
|
||||
a := strings.Join(hlsArgs("/d", "/v.mkv", in, r, false), " ")
|
||||
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"} {
|
||||
r := Ladder(in)[0]
|
||||
rc := recipes(gridCaps(), "/dev/dri/renderD129", in, r)[0]
|
||||
a := strings.Join(pieceArgs("/d", "/v.mkv", in, rc, r, 12), " ")
|
||||
for _, want := range []string{"-hwaccel vaapi -hwaccel_device /dev/dri/renderD129", "-ss 36.000 -i /v.mkv", "-map 0:v:0 -map 0:a:0?",
|
||||
"-vf scale_vaapi=w=1920:h=1080:format=nv12", "-c:v h264_vaapi -b:v 5000k -maxrate 7500k", "-level 4.2", "-c:a aac -b:a 160k -ac 2",
|
||||
"-force_key_frames expr:gte(t,n_forced*3)", "-output_ts_offset 46.000", "-hls_time 2.50", "-start_number 12", "-hls_segment_filename /d/%d.ts"} {
|
||||
if !strings.Contains(a, want) {
|
||||
t.Errorf("missing %q in\n%s", want, a)
|
||||
}
|
||||
}
|
||||
// aac stays as it is; no audio means none; copy sets no keyframes
|
||||
in.Audio.Codec = "aac"
|
||||
if a := strings.Join(hlsArgs("/d", "/v", in, r, false), " "); !strings.Contains(a, "-c:a copy") {
|
||||
t.Error("aac should be copied")
|
||||
if strings.Contains(a, "-reconnect") {
|
||||
t.Error("a path needs no reconnect")
|
||||
}
|
||||
if a := strings.Join(pieceArgs("/d", "http://10.0.0.234:8100/api/play-src/x", in, rc, r, 0), " "); !strings.Contains(a, "-reconnect 1") {
|
||||
t.Error("a URL reconnects")
|
||||
}
|
||||
in.Audio = nil
|
||||
if a := strings.Join(hlsArgs("/d", "/v", in, r, false), " "); !strings.Contains(a, " -an ") {
|
||||
t.Error("no audio stream → -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")
|
||||
if a := strings.Join(pieceArgs("/d", "/v", in, rc, r, 0), " "); !strings.Contains(a, "-an") {
|
||||
t.Error("no sound means -an")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProgress(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")
|
||||
s, sp := parseProgress(b)
|
||||
if s != 2.5 || sp != 3.1 {
|
||||
t.Fatalf("got %v %v", s, sp)
|
||||
func TestCapsOnly(t *testing.T) {
|
||||
c := gridCaps()
|
||||
if got := c.Only("/dev/dri/renderD129"); len(got.GPUs) != 1 || got.GPUs[0].Name != "Intel Arc A380" || len(c.GPUs) != 2 {
|
||||
t.Fatalf("one card: %+v", got.GPUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidChoice(t *testing.T) {
|
||||
if err := ValidChoice("h264", "720p"); err != nil {
|
||||
t.Fatal(err)
|
||||
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")
|
||||
}
|
||||
if ValidChoice("vp8", "720p") == nil || ValidChoice("h264", "9p") == nil || ValidChoice("", "") == nil {
|
||||
t.Fatal("bad choices must be refused")
|
||||
// previews then go to that card, or the CPU
|
||||
if g := c.Only("/dev/dri/renderD129").pick("hevc"); g == nil || g.Device != "/dev/dri/renderD129" {
|
||||
t.Fatalf("pick: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-7
@@ -6,13 +6,13 @@ package media
|
||||
// transcode never outlives the console that asked for it.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"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.
|
||||
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())
|
||||
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()
|
||||
}()
|
||||
done := make(chan error, 1)
|
||||
@@ -151,6 +190,7 @@ func RunFFmpegHelper(args []string, stdin io.Reader, stderr io.Writer) int {
|
||||
case werr = <-done:
|
||||
case <-ctx.Done():
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGCONT) // a paused one can't act on TERM
|
||||
select {
|
||||
case werr = <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
@@ -181,8 +221,3 @@ func clearDir(dir string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasSegments(playlist string) bool {
|
||||
b, err := os.ReadFile(playlist)
|
||||
return err == nil && strings.Contains(string(b), "#EXTINF")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -25,6 +26,16 @@ type Track struct {
|
||||
ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR
|
||||
Channels int `json:"channels,omitempty"`
|
||||
AttachedPic bool `json:"attached_pic,omitempty"`
|
||||
Spherical bool `json:"spherical,omitempty"` // an equirectangular 360 picture
|
||||
Rotation int `json:"rotation,omitempty"` // degrees clockwise to turn it to be shown: 0, 90, 180, 270 (a phone held upright)
|
||||
}
|
||||
|
||||
// Shown is the picture's size as it is shown, turned by its rotation.
|
||||
func (t *Track) Shown() (w, h int) {
|
||||
if t.Rotation == 90 || t.Rotation == 270 {
|
||||
return t.Height, t.Width
|
||||
}
|
||||
return t.Width, t.Height
|
||||
}
|
||||
|
||||
// Info is what a file holds: the picture to take, the sound to go with it, and the whole.
|
||||
@@ -37,7 +48,12 @@ type Info struct {
|
||||
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 {
|
||||
return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67")
|
||||
}
|
||||
@@ -58,6 +74,11 @@ type probeJSON struct {
|
||||
Disposition struct {
|
||||
AttachedPic int `json:"attached_pic"`
|
||||
} `json:"disposition"`
|
||||
SideData []struct {
|
||||
Type string `json:"side_data_type"`
|
||||
Projection string `json:"projection"`
|
||||
Rotation float64 `json:"rotation"`
|
||||
} `json:"side_data_list"`
|
||||
} `json:"streams"`
|
||||
Format struct {
|
||||
FormatName string `json:"format_name"`
|
||||
@@ -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")
|
||||
}
|
||||
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}
|
||||
if root {
|
||||
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 {
|
||||
st := &Track{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile, PixFmt: s.PixFmt, Width: s.Width, Height: s.Height,
|
||||
ColorTransfer: s.ColorTransfer, Channels: s.Channels, AttachedPic: s.Disposition.AttachedPic != 0}
|
||||
// A 360 video says so in its spherical tag (ffmpeg reads the old XML one and the sv3d
|
||||
// box); an untagged one is still taken for 360 when it is 2:1 and too big for anything
|
||||
// else.
|
||||
for _, sd := range s.SideData {
|
||||
if sd.Type == "Spherical Mapping" && strings.HasPrefix(sd.Projection, "equirectangular") {
|
||||
st.Spherical = true
|
||||
}
|
||||
if sd.Type == "Display Matrix" {
|
||||
// ffprobe's rotation is counter-clockwise; ffmpeg turns it back the other way
|
||||
st.Rotation = ((-int(math.Round(sd.Rotation/90))*90)%360 + 360) % 360
|
||||
}
|
||||
}
|
||||
st.Spherical = st.Spherical || (s.CodecType == "video" && s.Width == 2*s.Height && s.Width >= 3840)
|
||||
st.FPS = fps(s.AvgFrameRate)
|
||||
if st.FPS == 0 {
|
||||
st.FPS = fps(s.RFrameRate)
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
package media
|
||||
|
||||
// Playing a video in the browser at the quality the connection can take: HLS, made on the fly
|
||||
// by ffmpeg on the device asked for (the Transcode device setting), YouTube's way. The player
|
||||
// is told every quality is all there from the start, in Seg-second pieces, and each piece is
|
||||
// made when it is first asked for.
|
||||
//
|
||||
// One ffmpeg runs per play at a time. It starts at the piece asked for and runs on ahead of the
|
||||
// player, paused once it is far enough ahead; a piece asked for well past where it has got to
|
||||
// (a seek), or at another quality, ends it and starts a new one there. Every run is made the
|
||||
// same way (pieceArgs), so pieces from different runs and qualities follow on from one another.
|
||||
//
|
||||
// The file is a path on this machine or, when the device asked for is on another one, a URL
|
||||
// the hub serves it at. Nothing is kept: a play nobody asks anything of is forgotten, and the
|
||||
// folder is emptied when the daemon starts.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
runAhead = 10 // pieces a run may make past the last one asked for before it is paused
|
||||
jump = 3 // a piece more than this far past what's made is a seek: start again there
|
||||
idleRun = 45 * time.Second // a run nobody has asked anything of for this long is ended
|
||||
idlePlay = 30 * time.Minute // a play nobody has asked anything of for this long is forgotten
|
||||
budget = 2 << 30 // bytes of pieces one play keeps; those asked for least lately go first
|
||||
// PieceWait is the longest a request waits for its piece: under the hub proxy's 60 s wait
|
||||
// for a node to answer.
|
||||
PieceWait = 45 * time.Second
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("no such piece")
|
||||
|
||||
// Plays is every play on this machine.
|
||||
type Plays struct {
|
||||
dir string
|
||||
self string // this binary, for root runs through run-ffmpeg
|
||||
maxRuns int
|
||||
|
||||
mu sync.Mutex
|
||||
plays map[string]*Play
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// NewPlays keeps pieces under <dataDir>/transcode, which it empties first.
|
||||
func NewPlays(dataDir, self string, maxRuns int) *Plays {
|
||||
dir := filepath.Join(dataDir, "transcode")
|
||||
_ = os.MkdirAll(dir, 0o700)
|
||||
_ = clearDir(dir)
|
||||
m := &Plays{dir: dir, self: self, maxRuns: max(maxRuns, 1), plays: map[string]*Play{}, stop: make(chan struct{})}
|
||||
go m.tend()
|
||||
return m
|
||||
}
|
||||
|
||||
// Play is one video opened for watching.
|
||||
type Play struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // the file's name, for the record
|
||||
Info Info `json:"info"`
|
||||
Rungs []Rung `json:"rungs"` // best first
|
||||
Device string `json:"device"` // asked for: a render node, "cpu", or "" for the best here
|
||||
Remote bool `json:"remote"` // the file is on another machine, read over the network
|
||||
|
||||
caps Caps
|
||||
in string
|
||||
root bool
|
||||
segs int
|
||||
dir string
|
||||
m *Plays
|
||||
|
||||
mu sync.Mutex
|
||||
seen time.Time
|
||||
run *playRun
|
||||
tried map[int]int // per rung: recipes that failed to make anything, skipped from now on
|
||||
}
|
||||
|
||||
type playRun struct {
|
||||
rung int
|
||||
recipe Recipe
|
||||
start int // the first piece this run makes
|
||||
head int // the last piece finished (start-1 before the first)
|
||||
want int // the last piece asked for
|
||||
asked time.Time
|
||||
proc *ffproc
|
||||
paused bool
|
||||
dead bool // ended, or exited
|
||||
killed bool // ended by us, not by itself
|
||||
exited bool
|
||||
err error
|
||||
tick chan struct{} // closed when head moves or the run ends
|
||||
}
|
||||
|
||||
func newPlayID() string {
|
||||
b := make([]byte, 12)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Open starts a play of in (a path, or an http URL for a file on another machine). Nothing
|
||||
// runs until a piece is asked for.
|
||||
func (m *Plays) Open(c Caps, root bool, in, name string, src Info, device string) (*Play, error) {
|
||||
rungs := Ladder(src)
|
||||
if len(rungs) == 0 || src.Duration <= 0 {
|
||||
return nil, errors.New("this video's length or picture size isn't known, so it can't be played")
|
||||
}
|
||||
if len(recipes(c, device, src, rungs[0])) == 0 {
|
||||
return nil, errors.New("nothing on this machine encodes H.264 (no GPU that does, and no libx264)")
|
||||
}
|
||||
segs := max(int(math.Ceil((src.Duration-0.25)/Seg)), 1) // the last piece takes in what's left
|
||||
p := &Play{ID: newPlayID(), Name: name, Info: src, Rungs: rungs, Device: device, Remote: strings.HasPrefix(in, "http"),
|
||||
caps: c, in: in, root: root, segs: segs, m: m, seen: time.Now(), tried: map[int]int{}}
|
||||
p.dir = filepath.Join(m.dir, p.ID)
|
||||
m.mu.Lock()
|
||||
m.plays[p.ID] = p
|
||||
m.mu.Unlock()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Plays) Find(id string) (*Play, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.plays[id]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Close ends a play and deletes its pieces.
|
||||
func (m *Plays) Close(id string) {
|
||||
m.mu.Lock()
|
||||
p, ok := m.plays[id]
|
||||
delete(m.plays, id)
|
||||
m.mu.Unlock()
|
||||
if ok {
|
||||
p.mu.Lock()
|
||||
p.end()
|
||||
p.mu.Unlock()
|
||||
go os.RemoveAll(p.dir)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Play) rung(name string) int {
|
||||
for i, r := range p.Rungs {
|
||||
if r.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Master is the playlist of qualities, lowest first, so a player that starts on the first one
|
||||
// listed starts on the one that surely plays.
|
||||
func (p *Play) Master() []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
for i := len(p.Rungs) - 1; i >= 0; i-- {
|
||||
r := p.Rungs[i]
|
||||
fmt.Fprintf(&b, "#EXT-X-STREAM-INF:BANDWIDTH=%d,AVERAGE-BANDWIDTH=%d,RESOLUTION=%dx%d,FRAME-RATE=%.3f,CODECS=\"%s\"\n%s.m3u8\n",
|
||||
r.Peak, r.Bitrate+audioRate, r.Width, r.Height, r.FPS, r.codecs, r.Name)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// Media is one quality's playlist: every piece, made or not.
|
||||
func (p *Play) Media(name string) ([]byte, bool) {
|
||||
if p.rung(name) < 0 {
|
||||
return nil, false
|
||||
}
|
||||
var b bytes.Buffer
|
||||
last := p.Info.Duration - float64(p.segs-1)*Seg
|
||||
fmt.Fprintf(&b, "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:%d\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-INDEPENDENT-SEGMENTS\n",
|
||||
int(math.Ceil(math.Max(last, Seg))))
|
||||
for n := 0; n < p.segs; n++ {
|
||||
d := Seg
|
||||
if n == p.segs-1 {
|
||||
d = last
|
||||
}
|
||||
fmt.Fprintf(&b, "#EXTINF:%.6f,\n%s/%d.ts\n", d, name, n)
|
||||
}
|
||||
b.WriteString("#EXT-X-ENDLIST\n")
|
||||
return b.Bytes(), true
|
||||
}
|
||||
|
||||
func (p *Play) piece(rung, n int) string {
|
||||
return filepath.Join(p.dir, p.Rungs[rung].Name, strconv.Itoa(n)+".ts")
|
||||
}
|
||||
|
||||
// Segment answers the file of piece n at quality name, making it first if need be: it waits
|
||||
// for the run already making it, or starts one there.
|
||||
func (p *Play) Segment(done <-chan struct{}, name string, n int) (string, error) {
|
||||
ri := p.rung(name)
|
||||
if ri < 0 || n < 0 || n >= p.segs {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
path := p.piece(ri, n)
|
||||
deadline := time.After(PieceWait)
|
||||
for {
|
||||
p.mu.Lock()
|
||||
now := time.Now()
|
||||
p.seen = now
|
||||
r := p.run
|
||||
if r != nil && r.rung == ri && !r.dead {
|
||||
r.want, r.asked = n, now
|
||||
}
|
||||
if exists(path) {
|
||||
p.mu.Unlock()
|
||||
_ = os.Chtimes(path, now, now) // the budget keeps what was asked for last
|
||||
return path, nil
|
||||
}
|
||||
if r != nil && r.exited && !r.killed && r.rung == ri && r.start == n {
|
||||
// This run started at this very piece and ended without it: that way of making
|
||||
// it doesn't work here. The next one is tried, if there is one.
|
||||
p.tried[ri]++
|
||||
log.Printf("files: play %s: %s made nothing at %s piece %d: %v: %s", p.Name, r.recipe.Engine, name, n, r.err, r.proc.lastWords())
|
||||
if p.tried[ri] >= len(recipes(p.caps, p.Device, p.Info, p.Rungs[ri])) {
|
||||
err := fmt.Errorf("couldn't make %s: %s", p.Rungs[ri].Label, r.proc.lastWords())
|
||||
p.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
p.run = nil
|
||||
r = nil
|
||||
}
|
||||
if r == nil || r.dead || r.rung != ri || n < r.start || n <= r.head || n > r.head+jump {
|
||||
p.mu.Unlock()
|
||||
p.m.makeRoom(p)
|
||||
p.mu.Lock()
|
||||
if p.run == r { // nobody started one meanwhile
|
||||
if err := p.start(ri, n); err != nil {
|
||||
p.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
tick := r.tick
|
||||
p.mu.Unlock()
|
||||
select {
|
||||
case <-tick:
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
case <-done:
|
||||
return "", errors.New("gone")
|
||||
case <-deadline:
|
||||
return "", fmt.Errorf("%s piece %d took longer than %s", name, n, PieceWait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// end stops this play's run. p.mu is held.
|
||||
func (p *Play) end() {
|
||||
if r := p.run; r != nil && !r.dead {
|
||||
r.dead, r.killed = true, true
|
||||
r.proc.kill()
|
||||
}
|
||||
}
|
||||
|
||||
// makeRoom ends the run asked of least lately, if every slot is taken by another play. It
|
||||
// never holds two plays' locks at once.
|
||||
func (m *Plays) makeRoom(except *Play) {
|
||||
m.mu.Lock()
|
||||
plays := make([]*Play, 0, len(m.plays))
|
||||
for _, p := range m.plays {
|
||||
if p != except {
|
||||
plays = append(plays, p)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
var oldest *Play
|
||||
var when time.Time
|
||||
running := 0
|
||||
for _, p := range plays {
|
||||
p.mu.Lock()
|
||||
if r := p.run; r != nil && !r.dead {
|
||||
running++
|
||||
if oldest == nil || r.asked.Before(when) {
|
||||
oldest, when = p, r.asked
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
if running >= m.maxRuns && oldest != nil {
|
||||
oldest.mu.Lock()
|
||||
oldest.end()
|
||||
oldest.mu.Unlock()
|
||||
log.Printf("files: %d plays encoding; ended %s's, asked of least lately", running, oldest.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// start begins a run making quality ri from piece n on, ending the one before. p.mu is held.
|
||||
func (p *Play) start(ri, n int) error {
|
||||
p.end()
|
||||
rs := recipes(p.caps, p.Device, p.Info, p.Rungs[ri])
|
||||
if p.tried[ri] >= len(rs) {
|
||||
return errors.New("nothing here can make this quality")
|
||||
}
|
||||
rc := rs[p.tried[ri]]
|
||||
out := filepath.Join(p.dir, p.Rungs[ri].Name)
|
||||
if err := os.MkdirAll(out, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
proc, err := startFF(p.caps, p.m.self, p.root, pieceArgs(out, p.in, p.Info, rc, p.Rungs[ri], n))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r := &playRun{rung: ri, recipe: rc, start: n, head: n - 1, want: n, asked: time.Now(), proc: proc, tick: make(chan struct{})}
|
||||
p.run = r
|
||||
go func() {
|
||||
<-proc.done
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
r.dead, r.exited, r.err = true, true, proc.err
|
||||
p.scan(r)
|
||||
close(r.tick)
|
||||
r.tick = make(chan struct{})
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// scan moves a run's head on past the pieces it has finished. p.mu is held.
|
||||
func (p *Play) scan(r *playRun) bool {
|
||||
moved := false
|
||||
for r.head+1 < p.segs && exists(p.piece(r.rung, r.head+1)) {
|
||||
r.head++
|
||||
moved = true
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
// PlayStatus is what a play is doing, for the player's menu.
|
||||
type PlayStatus struct {
|
||||
Engine string `json:"engine,omitempty"` // what the run going now runs on
|
||||
Quality string `json:"quality,omitempty"`
|
||||
From int `json:"from"` // seconds into the film the run started at
|
||||
Ahead int `json:"ahead"`
|
||||
Paused bool `json:"paused,omitempty"`
|
||||
}
|
||||
|
||||
func (p *Play) Status() PlayStatus {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
r := p.run
|
||||
if r == nil || r.dead {
|
||||
return PlayStatus{}
|
||||
}
|
||||
return PlayStatus{Engine: r.recipe.Engine, Quality: p.Rungs[r.rung].Label, From: int(float64(r.start) * Seg), Ahead: r.head - r.want, Paused: r.paused}
|
||||
}
|
||||
|
||||
// Engine is what this play would run on first, for the player to say before anything runs.
|
||||
func (p *Play) Engine() string {
|
||||
rs := recipes(p.caps, p.Device, p.Info, p.Rungs[0])
|
||||
if len(rs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return rs[0].Engine
|
||||
}
|
||||
|
||||
// tend, ten times a second: notices finished pieces, pauses a run that is far enough ahead and
|
||||
// wakes it when the player catches up, ends one nobody is asking anything of; and every ten
|
||||
// seconds keeps each play inside its budget and forgets plays nobody watches.
|
||||
func (m *Plays) tend() {
|
||||
t := time.NewTicker(100 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for i := 0; ; i++ {
|
||||
select {
|
||||
case <-m.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
m.mu.Lock()
|
||||
plays := make([]*Play, 0, len(m.plays))
|
||||
for _, p := range m.plays {
|
||||
plays = append(plays, p)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
now := time.Now()
|
||||
for _, p := range plays {
|
||||
p.mu.Lock()
|
||||
if r := p.run; r != nil && !r.dead {
|
||||
if p.scan(r) {
|
||||
close(r.tick)
|
||||
r.tick = make(chan struct{})
|
||||
}
|
||||
ahead := r.head - r.want
|
||||
switch {
|
||||
case now.Sub(r.asked) > idleRun:
|
||||
p.end()
|
||||
case !r.paused && ahead >= runAhead:
|
||||
if r.proc.pause() == nil {
|
||||
r.paused = true
|
||||
}
|
||||
case r.paused && ahead < runAhead-3:
|
||||
if r.proc.resume() == nil {
|
||||
r.paused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
idle := now.Sub(p.seen) > idlePlay
|
||||
p.mu.Unlock()
|
||||
if idle {
|
||||
m.Close(p.ID)
|
||||
} else if i%100 == 0 {
|
||||
p.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trim deletes the pieces asked for least lately until the play is inside its budget, never
|
||||
// the ones its run has made ahead of the player.
|
||||
func (p *Play) trim() {
|
||||
type piece struct {
|
||||
path string
|
||||
size int64
|
||||
at time.Time
|
||||
}
|
||||
var all []piece
|
||||
var total int64
|
||||
_ = filepath.WalkDir(p.dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".ts") {
|
||||
return nil
|
||||
}
|
||||
if fi, err := d.Info(); err == nil {
|
||||
all = append(all, piece{path, fi.Size(), fi.ModTime()})
|
||||
total += fi.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if total <= budget {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
keep := map[string]bool{}
|
||||
if r := p.run; r != nil && !r.dead {
|
||||
for n := r.want - 1; n <= r.head+1; n++ {
|
||||
if n >= 0 && n < p.segs {
|
||||
keep[p.piece(r.rung, n)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
sort.Slice(all, func(a, b int) bool { return all[a].at.Before(all[b].at) })
|
||||
for _, x := range all {
|
||||
if total <= budget*3/4 {
|
||||
break
|
||||
}
|
||||
if !keep[x.path] && os.Remove(x.path) == nil {
|
||||
total -= x.size
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// inspectPiece answers when a piece's first video packet is shown, whether it is a keyframe,
|
||||
// how many keyframes the piece has, and when its last packet is.
|
||||
func inspectPiece(t *testing.T, path string) (first float64, key bool, keys int, last float64) {
|
||||
t.Helper()
|
||||
b, err := exec.Command("ffprobe", "-v", "error", "-show_entries", "packet=pts_time,flags,codec_type", "-of", "json", path).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("ffprobe %s: %v", path, err)
|
||||
}
|
||||
var p struct {
|
||||
Packets []struct {
|
||||
PTS string `json:"pts_time"`
|
||||
Flags string `json:"flags"`
|
||||
Type string `json:"codec_type"`
|
||||
} `json:"packets"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &p)
|
||||
first = -1
|
||||
for _, k := range p.Packets {
|
||||
if k.Type != "video" {
|
||||
continue
|
||||
}
|
||||
x, _ := strconv.ParseFloat(k.PTS, 64)
|
||||
if first < 0 {
|
||||
first, key = x, strings.HasPrefix(k.Flags, "K")
|
||||
}
|
||||
first = math.Min(first, x)
|
||||
last = math.Max(last, x)
|
||||
if strings.HasPrefix(k.Flags, "K") {
|
||||
keys++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TestPieces makes pieces of a made-up video (or SRC, on DEVICE, to try the real thing on a
|
||||
// machine with a GPU) in order and after a seek, at two qualities, and checks they line up:
|
||||
// each starts on a keyframe at its own place in the film, with no other keyframe inside it.
|
||||
func TestPieces(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("no ffmpeg")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
in := os.Getenv("SRC")
|
||||
if in == "" {
|
||||
in = filepath.Join(dir, "in.mkv")
|
||||
if out, err := exec.Command("ffmpeg", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=50:duration=40",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=40", "-c:v", "libx264", "-preset", "ultrafast", "-g", "500",
|
||||
"-c:a", "libopus", "-shortest", in).CombinedOutput(); err != nil {
|
||||
t.Fatalf("making the test video: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
caps := Probe(context.Background())
|
||||
if caps.Error != "" {
|
||||
t.Skip(caps.Error)
|
||||
}
|
||||
info, err := Inspect(context.Background(), caps, in, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := NewPlays(dir, "", 2)
|
||||
device := os.Getenv("DEVICE")
|
||||
if device == "" {
|
||||
device = CPU
|
||||
}
|
||||
p, err := m.Open(caps, false, in, "in.mkv", info, device)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close(p.ID)
|
||||
if !strings.Contains(string(p.Master()), "RESOLUTION=") {
|
||||
t.Fatal(string(p.Master()))
|
||||
}
|
||||
low := p.Rungs[len(p.Rungs)-1].Name
|
||||
var offset float64
|
||||
far := min(p.segs-2, 60)
|
||||
for i, n := range []int{0, 1, 2, far, far + 1, 3} {
|
||||
t0 := time.Now()
|
||||
path, err := p.Segment(nil, low, n)
|
||||
if err != nil {
|
||||
t.Fatalf("piece %d: %v", n, err)
|
||||
}
|
||||
first, key, keys, last := inspectPiece(t, path)
|
||||
if i == 0 {
|
||||
offset = first
|
||||
}
|
||||
at := first - offset
|
||||
t.Logf("piece %d: %d ms on %s, starts %.3f, %d keyframe(s), %.2f s long", n, time.Since(t0).Milliseconds(), p.Status().Engine, at, keys, last-first)
|
||||
if !key || keys != 1 {
|
||||
t.Errorf("piece %d: first packet keyframe %v, %d keyframes", n, key, keys)
|
||||
}
|
||||
if math.Abs(at-float64(n)*Seg) > 0.05 {
|
||||
t.Errorf("piece %d starts at %.3f, want %.3f", n, at, float64(n)*Seg)
|
||||
}
|
||||
if n < p.segs-1 && math.Abs(last-first-Seg) > 0.1 {
|
||||
t.Errorf("piece %d is %.3f s long", n, last-first)
|
||||
}
|
||||
}
|
||||
// another quality, at a piece already made at the first: its own run, lined up too
|
||||
if len(p.Rungs) > 1 {
|
||||
path, err := p.Segment(nil, p.Rungs[0].Name, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first, key, _, _ := inspectPiece(t, path); !key || math.Abs(first-offset-2*Seg) > 0.05 {
|
||||
t.Errorf("%s piece 2 starts %.3f (keyframe %v)", p.Rungs[0].Name, first-offset, key)
|
||||
}
|
||||
}
|
||||
if _, err := p.Segment(nil, low, p.segs); err != ErrNotFound {
|
||||
t.Errorf("past the end: %v", err)
|
||||
}
|
||||
if _, err := p.Segment(nil, "999", 0); err != ErrNotFound {
|
||||
t.Errorf("no such quality: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,24 @@ type Caps struct {
|
||||
type Opts struct {
|
||||
Size int // the long edge in pixels; 256 when zero
|
||||
Timeout time.Duration // for the whole job, ffprobe and every ffmpeg attempt; 30 s when zero
|
||||
Kind string // image | video | audio; from the name when empty (a URL has none)
|
||||
}
|
||||
|
||||
// Only is the caps with one device left to decode on: a render node, CPU for none, or ""
|
||||
// for all of them — the Transcode device setting, which previews follow too. A device this
|
||||
// machine doesn't have leaves the CPU.
|
||||
func (c Caps) Only(device string) Caps {
|
||||
if device == "" {
|
||||
return c
|
||||
}
|
||||
out := c
|
||||
out.GPUs = nil
|
||||
for _, g := range c.GPUs {
|
||||
if g.Device == device {
|
||||
out.GPUs = append(out.GPUs, g)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (o Opts) defaults() Opts {
|
||||
@@ -81,6 +99,8 @@ type Result struct {
|
||||
Codec string `json:"codec"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
// Failure is why the file can't be previewed, when another machine tried (no JPEG then).
|
||||
Failure string `json:"-"`
|
||||
}
|
||||
|
||||
// ErrUnsupported: not an image or a video by its name.
|
||||
@@ -356,7 +376,10 @@ func (a attempt) engine() string {
|
||||
|
||||
// Make produces the preview of one file.
|
||||
func Make(ctx context.Context, c Caps, path string, o Opts) (Result, error) {
|
||||
kind := Kind(path)
|
||||
kind := o.Kind
|
||||
if kind == "" {
|
||||
kind = Kind(path)
|
||||
}
|
||||
if kind == "" {
|
||||
return Result{}, ErrUnsupported
|
||||
}
|
||||
|
||||
@@ -95,30 +95,41 @@ func readRAPL(root string, dirs []string) ([]raplCount, bool) {
|
||||
// is carried across its range. One that went backwards with no known range, or a set of
|
||||
// counters that changed between readings, gives no answer rather than a wrong one.
|
||||
func raplWatts(prev, cur []raplCount, max map[string]uint64, dt float64) (float64, bool) {
|
||||
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
|
||||
return 0, false
|
||||
each, ok := raplWattsEach(prev, cur, max, dt)
|
||||
var w float64
|
||||
for _, x := range each {
|
||||
w += x
|
||||
}
|
||||
var uj float64
|
||||
return w, ok
|
||||
}
|
||||
|
||||
// raplWattsEach is raplWatts per counter: one package each.
|
||||
func raplWattsEach(prev, cur []raplCount, max map[string]uint64, dt float64) ([]float64, bool) {
|
||||
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]float64, len(cur))
|
||||
for i := range cur {
|
||||
if cur[i].Dir != prev[i].Dir {
|
||||
return 0, false
|
||||
return nil, false
|
||||
}
|
||||
if cur[i].UJ >= prev[i].UJ {
|
||||
uj += float64(cur[i].UJ - prev[i].UJ)
|
||||
out[i] = float64(cur[i].UJ-prev[i].UJ) / 1e6 / dt
|
||||
continue
|
||||
}
|
||||
m := max[cur[i].Dir]
|
||||
if m == 0 || prev[i].UJ > m {
|
||||
return 0, false
|
||||
return nil, false
|
||||
}
|
||||
uj += float64(m - prev[i].UJ + cur[i].UJ)
|
||||
out[i] = float64(m-prev[i].UJ+cur[i].UJ) / 1e6 / dt
|
||||
}
|
||||
return uj / 1e6 / dt, true
|
||||
return out, true
|
||||
}
|
||||
|
||||
// cpuPower is the CPU packages' power since the previous sample, in watts; 0 when this
|
||||
// machine has no counters or no way to read them.
|
||||
func (c *Collector) cpuPower(now time.Time) float64 {
|
||||
// machine has no counters or no way to read them. On a machine with more than one package
|
||||
// it also gives each one's, indexed by package id (the socket).
|
||||
func (c *Collector) cpuPower(now time.Time) (float64, []float64) {
|
||||
p := &c.power
|
||||
if !p.probed {
|
||||
p.probed = true
|
||||
@@ -131,7 +142,7 @@ func (c *Collector) cpuPower(now time.Time) float64 {
|
||||
}
|
||||
}
|
||||
if len(p.dirs) == 0 {
|
||||
return 0
|
||||
return 0, nil
|
||||
}
|
||||
var cur []raplCount
|
||||
ok := false
|
||||
@@ -148,14 +159,40 @@ func (c *Collector) cpuPower(now time.Time) float64 {
|
||||
}
|
||||
if !ok {
|
||||
p.prev = nil
|
||||
return 0
|
||||
return 0, nil
|
||||
}
|
||||
w, good := raplWatts(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
|
||||
each, good := raplWattsEach(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
|
||||
p.prev, p.prevAt = cur, now
|
||||
if !good {
|
||||
return 0
|
||||
return 0, nil
|
||||
}
|
||||
return round1(w)
|
||||
var w float64
|
||||
for _, x := range each {
|
||||
w += x
|
||||
}
|
||||
return round1(w), perPackage(cur, each)
|
||||
}
|
||||
|
||||
// perPackage puts each counter's watts at its package's id — intel-rapl:1 is "package-1" —
|
||||
// and is nil for a single package, whose figure is the total.
|
||||
func perPackage(cur []raplCount, each []float64) []float64 {
|
||||
if len(cur) < 2 {
|
||||
return nil
|
||||
}
|
||||
var out []float64
|
||||
for i, c := range cur {
|
||||
id := i
|
||||
if n, ok := strings.CutPrefix(strings.TrimSpace(readStr(filepath.Join(raplRoot, c.Dir, "name"))), "package-"); ok {
|
||||
if v, err := strconv.Atoi(n); err == nil && v >= 0 && v < 64 {
|
||||
id = v
|
||||
}
|
||||
}
|
||||
for len(out) <= id {
|
||||
out = append(out, 0)
|
||||
}
|
||||
out[id] = round1(each[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// energyHelper is the root child that answers each newline with the package counters, one
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package metrics
|
||||
|
||||
import "testing"
|
||||
|
||||
// a two-socket board: two coretemp chips, and the CPU's temperature is the hotter package
|
||||
func TestCPUTempHottestPackage(t *testing.T) {
|
||||
temps := []Temp{
|
||||
{Chip: "coretemp", Label: "Core 0", C: 31},
|
||||
{Chip: "coretemp", Label: "Core 0", C: 44, Pkg: 1},
|
||||
{Chip: "coretemp", Label: "Package id 0", C: 35},
|
||||
{Chip: "coretemp", Label: "Package id 1", C: 41, Pkg: 1},
|
||||
}
|
||||
if got := cpuTemp(temps); got != 41 {
|
||||
t.Fatalf("cpuTemp = %v, want 41", got)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package metrics
|
||||
|
||||
import "strconv"
|
||||
|
||||
// One kept history point is the average of the samples behind it. At the default ten-minute
|
||||
// window that bucket is a single sample and nothing is averaged; at a day it is five minutes
|
||||
// of them, and the difference matters — a graph of the last day drawn from whichever instant
|
||||
@@ -43,6 +45,21 @@ func fold(b []Sample) Sample {
|
||||
if pn > 0 {
|
||||
out.CPUPower = round1(pw / pn)
|
||||
}
|
||||
// and each package's, the same way
|
||||
out.CPUPowerPkgs = nil
|
||||
for _, s := range b {
|
||||
for i, w := range s.CPUPowerPkgs {
|
||||
for len(out.CPUPowerPkgs) <= i {
|
||||
out.CPUPowerPkgs = append(out.CPUPowerPkgs, 0)
|
||||
}
|
||||
out.CPUPowerPkgs[i] += w
|
||||
}
|
||||
}
|
||||
if pn > 0 {
|
||||
for i := range out.CPUPowerPkgs {
|
||||
out.CPUPowerPkgs[i] = round1(out.CPUPowerPkgs[i] / pn)
|
||||
}
|
||||
}
|
||||
out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] })
|
||||
out.Load[1] = sum(func(s Sample) float64 { return s.Load[1] })
|
||||
out.Load[2] = sum(func(s Sample) float64 { return s.Load[2] })
|
||||
@@ -70,13 +87,13 @@ func fold(b []Sample) Sample {
|
||||
temps := avgBy(b, func(s Sample) map[string][]float64 {
|
||||
m := make(map[string][]float64, len(s.Temps))
|
||||
for _, x := range s.Temps {
|
||||
m[x.Chip+"/"+x.Label] = []float64{x.C}
|
||||
m[x.Chip+"/"+x.Label+"/"+strconv.Itoa(x.Pkg)] = []float64{x.C}
|
||||
}
|
||||
return m
|
||||
})
|
||||
out.Temps = append([]Temp(nil), out.Temps...)
|
||||
for i, x := range out.Temps {
|
||||
if v, ok := temps[x.Chip+"/"+x.Label]; ok {
|
||||
if v, ok := temps[x.Chip+"/"+x.Label+"/"+strconv.Itoa(x.Pkg)]; ok {
|
||||
out.Temps[i].C = v[0]
|
||||
}
|
||||
}
|
||||
@@ -122,9 +139,54 @@ func fold(b []Sample) Sample {
|
||||
out.Disks[i].ReadBps, out.Disks[i].WriteBps, out.Disks[i].UtilPct = v[0], v[1], v[2]
|
||||
}
|
||||
}
|
||||
out.ZFSWrite = foldZFSWrites(b, out.ZFSWrite)
|
||||
return out
|
||||
}
|
||||
|
||||
// foldZFSWrites averages the write path's rates and the buffer's fill over a bucket, per pool;
|
||||
// everything else (limits, the mover's state, the last walk) is the newest sample's.
|
||||
func foldZFSWrites(b []Sample, last *ZFSWrites) *ZFSWrites {
|
||||
if last == nil {
|
||||
return nil
|
||||
}
|
||||
w := *last
|
||||
w.Pools = append([]PoolWrites(nil), last.Pools...)
|
||||
var dirty, delayed, over, n float64
|
||||
for _, s := range b {
|
||||
if s.ZFSWrite != nil {
|
||||
dirty, delayed, over, n = dirty+float64(s.ZFSWrite.Dirty), delayed+s.ZFSWrite.DelayedPerSec, over+s.ZFSWrite.OverMaxPerSec, n+1
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
w.Dirty, w.DelayedPerSec, w.OverMaxPerSec = uint64(dirty/n), round1(delayed/n), round1(over/n)
|
||||
}
|
||||
per := avgBy(b, func(s Sample) map[string][]float64 {
|
||||
m := map[string][]float64{}
|
||||
if s.ZFSWrite == nil {
|
||||
return m
|
||||
}
|
||||
for _, p := range s.ZFSWrite.Pools {
|
||||
v := []float64{p.WriteBps, p.LogicalBps, 0, 0}
|
||||
if p.Landing != nil {
|
||||
v[2], v[3] = p.Landing.InBps, p.Landing.DrainBps
|
||||
}
|
||||
m[p.Pool] = v
|
||||
}
|
||||
return m
|
||||
})
|
||||
for i, p := range w.Pools {
|
||||
if v, ok := per[p.Pool]; ok {
|
||||
w.Pools[i].WriteBps, w.Pools[i].LogicalBps = round1(v[0]), round1(v[1])
|
||||
if p.Landing != nil {
|
||||
l := *p.Landing
|
||||
l.InBps, l.DrainBps = round1(v[2]), round1(v[3])
|
||||
w.Pools[i].Landing = &l
|
||||
}
|
||||
}
|
||||
}
|
||||
return &w
|
||||
}
|
||||
|
||||
// avgBy averages each sample's named numbers across the bucket, per name.
|
||||
func avgBy(b []Sample, of func(Sample) map[string][]float64) map[string][]float64 {
|
||||
sums := map[string][]float64{}
|
||||
|
||||
+218
-27
@@ -49,6 +49,7 @@ type GPU struct {
|
||||
PowerW float64 `json:"power_w,omitempty"`
|
||||
PowerCapW float64 `json:"power_cap_w,omitempty"`
|
||||
FanRPM int `json:"fan_rpm,omitempty"`
|
||||
FanPct *float64 `json:"fan_pct,omitempty"` // fan speed as a % of its range: nvidia-smi's fan.speed, or the hwmon's pwm1; nil when the card has no such reading (0 is a stopped fan)
|
||||
MHz float64 `json:"mhz,omitempty"` // actual clock (0 when power-gated)
|
||||
ReqMHz float64 `json:"req_mhz,omitempty"` // what the driver asked for
|
||||
MaxMHz float64 `json:"max_mhz,omitempty"`
|
||||
@@ -56,7 +57,7 @@ type GPU struct {
|
||||
MemTotal uint64 `json:"mem_total,omitempty"`
|
||||
Clients []GPUClient `json:"clients"`
|
||||
ClientsOK bool `json:"clients_ok"` // false when fdinfo of other users' processes could not be read
|
||||
Stalled bool `json:"stalled,omitempty"` // a sysfs read of this card never returned; its readings are paused until restart
|
||||
Stalled bool `json:"stalled,omitempty"` // a read of this card has been out for gpuStallAfter; its readings are paused until it returns
|
||||
}
|
||||
|
||||
// GPUClient is one process with the device open.
|
||||
@@ -70,7 +71,7 @@ type GPUClient struct {
|
||||
|
||||
// gpuDrivers are DRM drivers that drive a GPU, not just a scanout (simpledrm, ast, the
|
||||
// Apple display coprocessor) — those have no engines and nothing to say.
|
||||
var gpuDrivers = map[string]bool{"i915": true, "xe": true, "amdgpu": true, "radeon": true, "nouveau": true, "nvidia-drm": true,
|
||||
var gpuDrivers = map[string]bool{"i915": true, "xe": true, "amdgpu": true, "radeon": true, "nouveau": true, "nvidia-drm": true, "nvidia": true,
|
||||
"asahi": true, "panfrost": true, "panthor": true, "v3d": true, "msm": true, "etnaviv": true, "lima": true, "powervr": true, "virtio_gpu": true}
|
||||
|
||||
type gpuDev struct {
|
||||
@@ -78,14 +79,25 @@ type gpuDev struct {
|
||||
integrated, stalled bool
|
||||
prevIdle, prevEnergy float64
|
||||
prevAt time.Time
|
||||
nv []GPUClient // nvidia: the process table from the last read (pmon), fdinfo says nothing there
|
||||
nvRead []GPUClient // the same, as the read in flight left it: only that read touches it
|
||||
pending chan gpuRead // the read in flight, nil when none is
|
||||
pendingAt time.Time
|
||||
last *GPU // the last read that came back
|
||||
readFn func(*GPU, time.Time) // nil is read (tests stand a slow one in)
|
||||
}
|
||||
|
||||
type gpuRead struct {
|
||||
x GPU
|
||||
nv []GPUClient
|
||||
}
|
||||
|
||||
type gpuState struct {
|
||||
devs []*gpuDev
|
||||
listedAt time.Time
|
||||
clients map[string]clientPrev // device key + client id → engine totals at the previous scan
|
||||
last []drmFD // the newest client walk, reused between walks
|
||||
scanAt time.Time
|
||||
clients map[string]clientPrev // device key + client id → engine totals at the previous scan
|
||||
last []drmFD // the newest client walk
|
||||
scan func() ([]drmFD, bool) // the walk; nil is scanClients (tests stand a fake list in)
|
||||
helper *drmHelper
|
||||
ok bool // the last walk saw every process
|
||||
}
|
||||
@@ -95,8 +107,7 @@ type clientPrev struct {
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const gpuRelist = time.Minute // how often the card list is re-read: hotplug is rare, the readdir is cheap
|
||||
const gpuScanEvery = time.Second // engine shares over less than this are noise
|
||||
const gpuRelist = time.Minute // how often the card list is re-read: hotplug is rare, the readdir is cheap
|
||||
|
||||
// gpus is the GPU section of a sample. cpuTemp stands in for an integrated GPU's
|
||||
// temperature: it is the same die.
|
||||
@@ -111,10 +122,13 @@ func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
|
||||
if g.clients == nil {
|
||||
g.clients = map[string]clientPrev{}
|
||||
}
|
||||
if g.scanAt.IsZero() || now.Sub(g.scanAt) >= gpuScanEvery {
|
||||
g.last, g.ok = c.scanClients()
|
||||
g.scanAt = now
|
||||
// The clients are read on every sample, like everything else in it: their engine shares
|
||||
// move on the same tick as the device's own numbers, at whatever rate is set.
|
||||
scan := c.scanClients
|
||||
if g.scan != nil {
|
||||
scan = g.scan
|
||||
}
|
||||
g.last, g.ok = scan()
|
||||
out := make([]GPU, 0, len(g.devs))
|
||||
live := map[string]bool{}
|
||||
for _, d := range g.devs {
|
||||
@@ -129,7 +143,13 @@ func (c *Collector) gpus(now time.Time, cpuTemp float64) []GPU {
|
||||
var order []string
|
||||
engines := map[string]float64{}
|
||||
var resident uint64
|
||||
if d.driver == "nvidia" { // no engine counters in its fdinfo; nvidia-smi pmon is the client list
|
||||
x.Clients, x.ClientsOK = append(x.Clients, d.nv...), true
|
||||
}
|
||||
for _, f := range g.last {
|
||||
if d.driver == "nvidia" {
|
||||
break
|
||||
}
|
||||
fi := parseFdinfo(f.Info)
|
||||
if fi.pdev != "" && fi.pdev != d.bus || fi.pdev == "" && fi.driver != d.driver {
|
||||
continue
|
||||
@@ -339,28 +359,60 @@ func lspciName(desc string) string {
|
||||
return strings.TrimSpace(vendor + " " + desc)
|
||||
}
|
||||
|
||||
// readWithin is read with a deadline. A sysfs read that does not come back must not take
|
||||
// the whole sampler with it — every graph on every page of this machine stops — so the
|
||||
// device is marked stalled, its goroutine abandoned, and the rest of the sample goes on.
|
||||
const gpuReadWithin = 3 * time.Second
|
||||
// readWithin is read with a deadline. A read that does not come back must not take the
|
||||
// whole sampler with it — every graph on every page of this machine stops — so one still out
|
||||
// after gpuReadWithin is left running, the sample goes on with the card's last readings, and
|
||||
// no second read starts until it returns. Late is normal for nvidia-smi on a busy card; only a
|
||||
// read out for gpuStallAfter marks the card stalled, and that clears the moment it returns. A
|
||||
// sysfs read that never returns keeps it stalled, at the cost of one goroutine.
|
||||
var (
|
||||
gpuReadWithin = 3 * time.Second
|
||||
gpuStallAfter = 15 * time.Second // past both of readNvidia's nvidia-smi calls timing out (runOut, 5 s each)
|
||||
)
|
||||
|
||||
func (d *gpuDev) readWithin(x *GPU, now time.Time) {
|
||||
if d.stalled {
|
||||
x.Stalled = true
|
||||
var r gpuRead
|
||||
back := false
|
||||
if d.pending == nil {
|
||||
done := make(chan gpuRead, 1)
|
||||
y := *x
|
||||
read := d.read
|
||||
if d.readFn != nil {
|
||||
read = d.readFn
|
||||
}
|
||||
go func() {
|
||||
read(&y, now)
|
||||
done <- gpuRead{y, d.nvRead}
|
||||
}()
|
||||
d.pending, d.pendingAt = done, now
|
||||
select {
|
||||
case r, back = <-done:
|
||||
case <-time.After(gpuReadWithin):
|
||||
}
|
||||
} else { // started on an earlier sample: taken if it is back, never waited on again
|
||||
select {
|
||||
case r, back = <-d.pending:
|
||||
default:
|
||||
}
|
||||
}
|
||||
ok := x.ClientsOK
|
||||
defer func() { x.ClientsOK = ok }()
|
||||
if back {
|
||||
if d.stalled {
|
||||
log.Printf("gpu: %s (%s): its read came back after %s; readings resume", d.name, d.card, now.Sub(d.pendingAt).Round(time.Second))
|
||||
}
|
||||
d.pending, d.nv, d.stalled, d.last = nil, r.nv, false, &r.x
|
||||
*x = r.x
|
||||
return
|
||||
}
|
||||
done := make(chan GPU, 1)
|
||||
go func() {
|
||||
y := *x
|
||||
d.read(&y, now)
|
||||
done <- y
|
||||
}()
|
||||
select {
|
||||
case y := <-done:
|
||||
*x = y
|
||||
case <-time.After(gpuReadWithin):
|
||||
if d.last != nil {
|
||||
*x = *d.last
|
||||
}
|
||||
if now.Sub(d.pendingAt) >= gpuStallAfter {
|
||||
if !d.stalled {
|
||||
log.Printf("gpu: %s (%s): a read has not returned in %s; its readings are paused until it does", d.name, d.card, gpuStallAfter)
|
||||
}
|
||||
d.stalled, x.Stalled = true, true
|
||||
log.Printf("gpu: %s (%s): a sysfs read did not return within %s; its readings are paused until restart", d.name, d.card, gpuReadWithin)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +420,8 @@ func (d *gpuDev) readWithin(x *GPU, now time.Time) {
|
||||
func (d *gpuDev) read(x *GPU, now time.Time) {
|
||||
dt := now.Sub(d.prevAt).Seconds()
|
||||
switch d.driver {
|
||||
case "nvidia":
|
||||
d.readNvidia(x)
|
||||
case "i915":
|
||||
x.MHz = readF(d.card + "/gt_act_freq_mhz")
|
||||
x.ReqMHz = readF(d.card + "/gt_cur_freq_mhz")
|
||||
@@ -426,6 +480,14 @@ func (d *gpuDev) read(x *GPU, now time.Time) {
|
||||
x.PowerCapW = p / 1e6
|
||||
}
|
||||
x.FanRPM = int(readF(d.hwmon + "/fan1_input"))
|
||||
if pwm, err := strconv.ParseFloat(strings.TrimSpace(readStr(d.hwmon+"/pwm1")), 64); err == nil {
|
||||
top := readF(d.hwmon + "/pwm1_max")
|
||||
if top <= 0 {
|
||||
top = 255
|
||||
}
|
||||
v := round1(pwm / top * 100)
|
||||
x.FanPct = &v
|
||||
}
|
||||
}
|
||||
d.prevAt = now
|
||||
}
|
||||
@@ -703,3 +765,132 @@ func ueventValue(p, key string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// The proprietary NVIDIA driver exports no busy counter in sysfs and no engine totals in
|
||||
// fdinfo, so the device and its processes come from nvidia-smi — two short invocations per
|
||||
// sample (~20 ms and ~75 ms on a GTX 965M), no root needed. The PCI address selects the
|
||||
// card, so two NVIDIA GPUs stay apart.
|
||||
func (d *gpuDev) readNvidia(x *GPU) {
|
||||
if out, ok := runOut("nvidia-smi", "--id="+d.bus, "--query-gpu=utilization.gpu,utilization.encoder,utilization.decoder,memory.used,memory.total,clocks.sm,clocks.max.sm,temperature.gpu,power.draw,power.limit,fan.speed", "--format=csv,noheader,nounits"); ok {
|
||||
parseNvidiaQuery(x, out)
|
||||
}
|
||||
if out, ok := runOut("nvidia-smi", "pmon", "-c", "1", "-s", "um", "--id="+d.bus); ok {
|
||||
d.nvRead = parseNvidiaPmon(out)
|
||||
}
|
||||
}
|
||||
|
||||
// nvNum is one nvidia-smi CSV field; "[N/A]" (no sensor, no limit) is "nothing".
|
||||
func nvNum(s string) (float64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || strings.Contains(s, "N/A") {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
return v, err == nil
|
||||
}
|
||||
|
||||
// parseNvidiaQuery fills a GPU from one --query-gpu line in the field order readNvidia asks
|
||||
// for: gpu%, encoder%, decoder%, mem used MiB, mem total MiB, SM MHz, max SM MHz, °C, W, W cap,
|
||||
// fan % ("[N/A]" on a card with no fan of its own: a laptop's, a passive datacentre card).
|
||||
func parseNvidiaQuery(x *GPU, line string) {
|
||||
f := strings.Split(strings.TrimSpace(line), ",")
|
||||
if len(f) < 10 {
|
||||
return
|
||||
}
|
||||
if v, ok := nvNum(f[0]); ok {
|
||||
x.Usage, x.UsageSrc = v, "busy"
|
||||
x.Engines = map[string]float64{"render": v}
|
||||
enc, okE := nvNum(f[1])
|
||||
dec, okD := nvNum(f[2])
|
||||
if okE || okD {
|
||||
x.Engines["video"] = max(enc, dec)
|
||||
}
|
||||
}
|
||||
if v, ok := nvNum(f[3]); ok {
|
||||
x.MemUsed = uint64(v) << 20
|
||||
}
|
||||
if v, ok := nvNum(f[4]); ok {
|
||||
x.MemTotal = uint64(v) << 20
|
||||
}
|
||||
if v, ok := nvNum(f[5]); ok {
|
||||
x.MHz = v
|
||||
}
|
||||
if v, ok := nvNum(f[6]); ok {
|
||||
x.MaxMHz = v
|
||||
}
|
||||
if v, ok := nvNum(f[7]); ok && v > 0 {
|
||||
x.Temp, x.TempSrc = v, "gpu"
|
||||
}
|
||||
if v, ok := nvNum(f[8]); ok {
|
||||
x.PowerW = round1(v)
|
||||
}
|
||||
if v, ok := nvNum(f[9]); ok {
|
||||
x.PowerCapW = v
|
||||
}
|
||||
if len(f) > 10 {
|
||||
if v, ok := nvNum(f[10]); ok {
|
||||
x.FanPct = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseNvidiaPmon reads `nvidia-smi pmon -c 1 -s um`: a header naming the columns, then one
|
||||
// row per process — "-" where a process has no share. Columns vary between driver versions,
|
||||
// so they are taken by name. Busiest first, like the fdinfo clients.
|
||||
func parseNvidiaPmon(out string) []GPUClient {
|
||||
col := map[string]int{}
|
||||
var clients []GPUClient
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 0 {
|
||||
continue
|
||||
}
|
||||
if f[0] == "#" {
|
||||
if len(f) > 1 && f[1] == "gpu" { // "# gpu pid type sm mem enc dec jpg ofa fb ccpm command"
|
||||
col = map[string]int{}
|
||||
for i, name := range f[1:] {
|
||||
col[name] = i
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
get := func(name string) (float64, bool) {
|
||||
i, ok := col[name]
|
||||
if !ok || i >= len(f) || f[i] == "-" {
|
||||
return 0, false
|
||||
}
|
||||
v, err := strconv.ParseFloat(f[i], 64)
|
||||
return v, err == nil
|
||||
}
|
||||
pid, ok := get("pid")
|
||||
if !ok || pid <= 0 {
|
||||
continue
|
||||
}
|
||||
cl := GPUClient{PID: int(pid), Engines: map[string]float64{}}
|
||||
if i, ok := col["command"]; ok && i < len(f) {
|
||||
cl.Name = strings.Join(f[i:], " ")
|
||||
}
|
||||
if v, ok := get("sm"); ok {
|
||||
cl.Engines["render"] = v
|
||||
}
|
||||
enc, okE := get("enc")
|
||||
dec, okD := get("dec")
|
||||
if okE || okD {
|
||||
cl.Engines["video"] = max(enc, dec)
|
||||
}
|
||||
for _, v := range cl.Engines {
|
||||
if v > cl.Usage {
|
||||
cl.Usage = v
|
||||
}
|
||||
}
|
||||
if len(cl.Engines) == 0 {
|
||||
cl.Engines = nil
|
||||
}
|
||||
if v, ok := get("fb"); ok {
|
||||
cl.Mem = uint64(v) << 20
|
||||
}
|
||||
clients = append(clients, cl)
|
||||
}
|
||||
sort.SliceStable(clients, func(i, j int) bool { return clients[i].Usage > clients[j].Usage })
|
||||
return clients
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A read that comes back late carries the last readings meanwhile and is used when it lands;
|
||||
// only one out past gpuStallAfter marks the card stalled, and its return clears that.
|
||||
func TestGPUReadLateThenStalledThenBack(t *testing.T) {
|
||||
defer func(w, s time.Duration) { gpuReadWithin, gpuStallAfter = w, s }(gpuReadWithin, gpuStallAfter)
|
||||
gpuReadWithin, gpuStallAfter = 20*time.Millisecond, 10*time.Second
|
||||
release := make(chan struct{})
|
||||
n := 0.0 // only the read goroutine writes it, one read at a time
|
||||
d := &gpuDev{name: "test", card: "card0"}
|
||||
d.readFn = func(x *GPU, _ time.Time) {
|
||||
n++
|
||||
if n == 2 {
|
||||
<-release
|
||||
}
|
||||
x.Usage = n * 10
|
||||
}
|
||||
t0 := time.Now()
|
||||
sample := func(at time.Duration) GPU {
|
||||
x := GPU{Name: "test"}
|
||||
d.readWithin(&x, t0.Add(at))
|
||||
return x
|
||||
}
|
||||
if x := sample(0); x.Usage != 10 || x.Stalled {
|
||||
t.Fatalf("first: %+v", x)
|
||||
}
|
||||
if x := sample(time.Second); x.Usage != 10 || x.Stalled { // late: the last readings
|
||||
t.Fatalf("late: %+v", x)
|
||||
}
|
||||
if x := sample(5 * time.Second); x.Usage != 10 || x.Stalled {
|
||||
t.Fatalf("still late: %+v", x)
|
||||
}
|
||||
if x := sample(12 * time.Second); !x.Stalled {
|
||||
t.Fatalf("out past gpuStallAfter, not stalled: %+v", x)
|
||||
}
|
||||
if x := sample(12*time.Second + 1); !x.Stalled {
|
||||
t.Fatal("stall did not hold")
|
||||
}
|
||||
close(release)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if x := sample(13 * time.Second); x.Usage != 20 || x.Stalled {
|
||||
t.Fatalf("back: %+v", x)
|
||||
}
|
||||
if x := sample(14 * time.Second); x.Usage != 30 || x.Stalled { // 30: no extra read ran while one was out
|
||||
t.Fatalf("next: %+v", x)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestGPUClientsShare(t *testing.T) {
|
||||
c.gpu.clients = map[string]clientPrev{}
|
||||
t0 := time.Now()
|
||||
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: fdinfoA}}
|
||||
c.gpu.scanAt = t0 // no walk: the fake list stands
|
||||
c.gpu.scan = func() ([]drmFD, bool) { return c.gpu.last, true } // no walk: the fake list stands
|
||||
first := c.gpus(t0, 40)
|
||||
if len(first) != 1 || len(first[0].Clients) != 1 || first[0].Clients[0].Usage != 0 {
|
||||
t.Fatalf("first pass: %+v", first)
|
||||
@@ -73,7 +73,6 @@ func TestGPUClientsShare(t *testing.T) {
|
||||
later = replace(later, "drm-engine-render: 1000000000 ns", "drm-engine-render: 1500000000 ns")
|
||||
later = replace(later, "drm-engine-video: 2000000000 ns", "drm-engine-video: 4000000000 ns")
|
||||
c.gpu.last = []drmFD{{PID: 7, Comm: "ffmpeg", Info: later}}
|
||||
c.gpu.scanAt = t0.Add(time.Second)
|
||||
got := c.gpus(t0.Add(time.Second), 40)
|
||||
cl := got[0].Clients[0]
|
||||
if cl.Engines["render"] != 50 || cl.Engines["video"] != 100 || cl.Usage != 100 {
|
||||
@@ -170,3 +169,65 @@ func TestPCIName(t *testing.T) {
|
||||
t.Errorf("an unknown device should be empty, got %q", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNvidiaQuery(t *testing.T) {
|
||||
var g GPU
|
||||
parseNvidiaQuery(&g, "31, 0, 12, 1488, 2048, 949, 1097, 57, 23.41, [N/A]\n")
|
||||
if g.Usage != 31 || g.UsageSrc != "busy" || g.Engines["render"] != 31 || g.Engines["video"] != 12 {
|
||||
t.Errorf("usage: %+v", g)
|
||||
}
|
||||
if g.MemUsed != 1488<<20 || g.MemTotal != 2048<<20 || g.MHz != 949 || g.MaxMHz != 1097 {
|
||||
t.Errorf("mem/clock: %+v", g)
|
||||
}
|
||||
if g.Temp != 57 || g.TempSrc != "gpu" || g.PowerW != 23.4 || g.PowerCapW != 0 {
|
||||
t.Errorf("temp/power (cap must stay 0 for [N/A]): %+v", g)
|
||||
}
|
||||
if g.FanPct != nil {
|
||||
t.Errorf("no fan field must leave the fan unset: %+v", g)
|
||||
}
|
||||
var fan, nofan GPU
|
||||
parseNvidiaQuery(&fan, "99, 0, 0, 20000, 24576, 1695, 2100, 83, 403.99, 420.00, 34\n")
|
||||
parseNvidiaQuery(&nofan, "5, 0, 0, 500, 2048, 300, 1097, 45, 8.0, [N/A], [N/A]\n")
|
||||
if fan.FanPct == nil || *fan.FanPct != 34 || nofan.FanPct != nil {
|
||||
t.Errorf("fan: %v, [N/A]: %v", fan.FanPct, nofan.FanPct)
|
||||
}
|
||||
var stopped GPU
|
||||
parseNvidiaQuery(&stopped, "0, 0, 0, 500, 24576, 210, 2100, 35, 20.0, 420.00, 0\n")
|
||||
if stopped.FanPct == nil || *stopped.FanPct != 0 {
|
||||
t.Errorf("a stopped fan must read 0, not nothing: %v", stopped.FanPct)
|
||||
}
|
||||
var short GPU
|
||||
parseNvidiaQuery(&short, "garbage")
|
||||
if short.UsageSrc != "" {
|
||||
t.Errorf("a short line must leave the GPU untouched: %+v", short)
|
||||
}
|
||||
}
|
||||
|
||||
const pmonA = `# gpu pid type sm mem enc dec jpg ofa fb ccpm command
|
||||
# Idx # C/G % % % % % % MB MB name
|
||||
0 1063 G - - - - - - 701 0 Xorg
|
||||
0 1480 G 27 22 - - - - 270 0 cinnamon
|
||||
0 13318 G 9 7 40 - - - 454 0 firefox-esr
|
||||
`
|
||||
|
||||
func TestParseNvidiaPmon(t *testing.T) {
|
||||
cl := parseNvidiaPmon(pmonA)
|
||||
if len(cl) != 3 {
|
||||
t.Fatalf("want 3 clients, got %+v", cl)
|
||||
}
|
||||
// busiest first: firefox's encoder share (40) beats cinnamon's sm (27)
|
||||
if cl[0].PID != 13318 || cl[0].Name != "firefox-esr" || cl[0].Usage != 40 || cl[0].Engines["render"] != 9 || cl[0].Engines["video"] != 40 || cl[0].Mem != 454<<20 {
|
||||
t.Errorf("firefox: %+v", cl[0])
|
||||
}
|
||||
if cl[1].PID != 1480 || cl[1].Usage != 27 || cl[1].Engines["render"] != 27 {
|
||||
t.Errorf("cinnamon: %+v", cl[1])
|
||||
}
|
||||
if cl[2].PID != 1063 || cl[2].Usage != 0 || cl[2].Engines != nil || cl[2].Mem != 701<<20 {
|
||||
t.Errorf("xorg (no shares, only memory): %+v", cl[2])
|
||||
}
|
||||
// an older driver without the jpg/ofa/ccpm columns
|
||||
old := "# gpu pid type sm mem enc dec fb command\n# Idx # C/G % % % % MB name\n 0 42 C 55 10 - - 100 python3\n"
|
||||
if cl := parseNvidiaPmon(old); len(cl) != 1 || cl[0].PID != 42 || cl[0].Usage != 55 || cl[0].Mem != 100<<20 || cl[0].Name != "python3" {
|
||||
t.Errorf("old layout: %+v", cl)
|
||||
}
|
||||
}
|
||||
|
||||
+109
-44
@@ -60,6 +60,7 @@ type Temp struct {
|
||||
C float64 `json:"c"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Crit float64 `json:"crit,omitempty"`
|
||||
Pkg int `json:"pkg,omitempty"` // coretemp: the CPU package the chip belongs to — a two-socket board has two chips, both with a "Core 0"
|
||||
}
|
||||
|
||||
type Fan struct {
|
||||
@@ -101,6 +102,9 @@ type FS struct {
|
||||
// said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and
|
||||
// a remote mount is on none of them.
|
||||
Disk string `json:"disk,omitempty"`
|
||||
// Source is the device the mount is of ("/dev/nvme0n1p1"). A bind mount has the same
|
||||
// source as the filesystem it shows, so a sum over a drive counts each source once.
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type UPS struct {
|
||||
@@ -135,23 +139,25 @@ type Proc struct {
|
||||
}
|
||||
|
||||
type Sample struct {
|
||||
T int64 `json:"t"` // unix ms
|
||||
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Temps []Temp `json:"temps"`
|
||||
Fans []Fan `json:"fans"`
|
||||
Net []Net `json:"net"`
|
||||
Disks []Disk `json:"disks"`
|
||||
FS []FS `json:"fs"`
|
||||
UPS *UPS `json:"ups"`
|
||||
GPUs []GPU `json:"gpus"`
|
||||
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module
|
||||
Uptime float64 `json:"uptime"`
|
||||
T int64 `json:"t"` // unix ms
|
||||
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading
|
||||
CPUPowerPkgs []float64 `json:"cpu_power_pkgs_w,omitempty"` // each package's share, by socket; only with more than one
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Temps []Temp `json:"temps"`
|
||||
Fans []Fan `json:"fans"`
|
||||
Net []Net `json:"net"`
|
||||
Disks []Disk `json:"disks"`
|
||||
FS []FS `json:"fs"`
|
||||
UPS *UPS `json:"ups"`
|
||||
GPUs []GPU `json:"gpus"`
|
||||
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module
|
||||
ZFSWrite *ZFSWrites `json:"zfs_writes"` // the ZFS write path; nil without the module
|
||||
Uptime float64 `json:"uptime"`
|
||||
|
||||
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
|
||||
}
|
||||
@@ -182,6 +188,7 @@ type Collector struct {
|
||||
gpu gpuState
|
||||
power cpuPowerState
|
||||
prevARC arcPrev
|
||||
prevZW zfsWritePrev
|
||||
|
||||
subMu sync.Mutex
|
||||
subs map[chan struct{}]struct{} // told after every sample; see Subscribe
|
||||
@@ -347,7 +354,7 @@ func (c *Collector) sample() {
|
||||
|
||||
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
|
||||
s.CPU, s.Cores = c.cpu()
|
||||
s.CPUPower = c.cpuPower(now)
|
||||
s.CPUPower, s.CPUPowerPkgs = c.cpuPower(now)
|
||||
s.Load = loadavg()
|
||||
s.Mem, s.Swap = meminfo()
|
||||
s.Uptime = uptime()
|
||||
@@ -368,6 +375,7 @@ func (c *Collector) sample() {
|
||||
s.UPS, s.upsT = c.lastUPS, c.upsAt.UnixMilli()
|
||||
s.GPUs = c.gpus(now, cpuTemp(s.Temps))
|
||||
s.ARC = c.arc(now)
|
||||
s.ZFSWrite = c.zfsWrites(now, s.ARC, s.Disks)
|
||||
|
||||
if first {
|
||||
return // deltas are meaningless on the priming pass
|
||||
@@ -541,6 +549,19 @@ func hwmonDir(d string) (chip string, temps []Temp, fans []Fan) {
|
||||
fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)})
|
||||
}
|
||||
}
|
||||
// coretemp is one chip per CPU package, and only its "Package id N" says which
|
||||
if chip == "coretemp" {
|
||||
for _, t := range temps {
|
||||
if n, ok := strings.CutPrefix(t.Label, "Package id "); ok {
|
||||
if pkg, err := strconv.Atoi(n); err == nil {
|
||||
for i := range temps {
|
||||
temps[i].Pkg = pkg
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -556,7 +577,10 @@ func hwmon() ([]Temp, []Fan) {
|
||||
if temps[i].Chip != temps[j].Chip {
|
||||
return temps[i].Chip < temps[j].Chip
|
||||
}
|
||||
return temps[i].Label < temps[j].Label
|
||||
if temps[i].Label != temps[j].Label {
|
||||
return temps[i].Label < temps[j].Label
|
||||
}
|
||||
return temps[i].Pkg < temps[j].Pkg
|
||||
})
|
||||
return temps, fans
|
||||
}
|
||||
@@ -842,7 +866,7 @@ func filesystems() []FS {
|
||||
return
|
||||
}
|
||||
bs := uint64(st.Bsize)
|
||||
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0])}
|
||||
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0]), Source: fs[0]}
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
@@ -1109,27 +1133,61 @@ func coreIDs(n int) []int {
|
||||
return coreIDsVal[:n]
|
||||
}
|
||||
|
||||
// corePkgs maps logical CPUs to the CPU package (socket) each is on, and is nil on a
|
||||
// one-socket machine: there is nothing to tell apart.
|
||||
var (
|
||||
corePkgsOnce sync.Once
|
||||
corePkgsVal []int
|
||||
)
|
||||
|
||||
func corePkgs(n int) []int {
|
||||
corePkgsOnce.Do(func() {
|
||||
seen := map[int]bool{}
|
||||
var out []int
|
||||
for i := 0; ; i++ {
|
||||
b, err := os.ReadFile("/sys/devices/system/cpu/cpu" + strconv.Itoa(i) + "/topology/physical_package_id")
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
out, seen[id] = append(out, id), true
|
||||
}
|
||||
if len(seen) > 1 {
|
||||
corePkgsVal = out
|
||||
}
|
||||
})
|
||||
if len(corePkgsVal) < n {
|
||||
return nil
|
||||
}
|
||||
return corePkgsVal[:n]
|
||||
}
|
||||
|
||||
// ProcTable is the Processes tab payload.
|
||||
type ProcTable struct {
|
||||
T int64 `json:"t"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first
|
||||
CPUModel string `json:"cpu_model,omitempty"`
|
||||
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
||||
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
||||
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
|
||||
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
||||
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Net []Net `json:"net"`
|
||||
GPUs []GPU `json:"gpus"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Procs []Proc `json:"procs"`
|
||||
Total int `json:"total"`
|
||||
Running int `json:"running"`
|
||||
T int64 `json:"t"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Cores []float64 `json:"cores"`
|
||||
CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first
|
||||
CPUModel string `json:"cpu_model,omitempty"`
|
||||
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
||||
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
||||
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
|
||||
CPUPowerPkgsW []float64 `json:"cpu_power_pkgs_w,omitempty"`
|
||||
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
||||
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
||||
CorePkgs []int `json:"core_pkgs,omitempty"` // for each logical CPU, the socket it is on; only on a machine with more than one
|
||||
Load [3]float64 `json:"load"`
|
||||
Mem Mem `json:"mem"`
|
||||
Swap Mem `json:"swap"`
|
||||
Net []Net `json:"net"`
|
||||
GPUs []GPU `json:"gpus"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Procs []Proc `json:"procs"`
|
||||
Total int `json:"total"`
|
||||
Running int `json:"running"`
|
||||
}
|
||||
|
||||
var cpuModel = func() string {
|
||||
@@ -1179,18 +1237,24 @@ func cpuMHz() float64 {
|
||||
|
||||
// cpuTemp picks the sensor that best represents the CPU package.
|
||||
func cpuTemp(temps []Temp) float64 {
|
||||
best, max := 0.0, 0.0
|
||||
// the hottest package: a two-socket board has two, and the hotter one is the one to watch
|
||||
pkg, max := 0.0, 0.0
|
||||
for _, t := range temps {
|
||||
l := strings.ToLower(t.Label)
|
||||
if t.Chip == "coretemp" && strings.HasPrefix(l, "package") || t.Chip == "k10temp" && (l == "tctl" || l == "tdie") ||
|
||||
t.Chip == "thermal" && strings.Contains(l, "cpu") || t.Chip == "cpu_thermal" || t.Chip == "soc_thermal" {
|
||||
return t.C
|
||||
if t.C > pkg {
|
||||
pkg = t.C
|
||||
}
|
||||
}
|
||||
if (t.Chip == "coretemp" || t.Chip == "k10temp") && t.C > max {
|
||||
max, best = t.C, t.C
|
||||
max = t.C
|
||||
}
|
||||
}
|
||||
return best
|
||||
if pkg > 0 {
|
||||
return pkg
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
var bootTime = func() int64 {
|
||||
@@ -1318,8 +1382,9 @@ func (c *Collector) Processes() ProcTable {
|
||||
t.Mem, t.Swap = meminfo()
|
||||
if n := len(c.ring); n > 0 {
|
||||
last := c.ring[n-1]
|
||||
t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower
|
||||
t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW, t.CPUPowerPkgsW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower, last.CPUPowerPkgs
|
||||
t.CoreIDs = coreIDs(len(last.Cores))
|
||||
t.CorePkgs = corePkgs(len(last.Cores))
|
||||
from := n - 90
|
||||
if from < 0 {
|
||||
from = 0
|
||||
|
||||
@@ -65,6 +65,10 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
if s.CPUPower > 0 {
|
||||
f.add("cpu.power", "CPU power", "power", "W", s.CPUPower, t, none)
|
||||
}
|
||||
for i, w := range s.CPUPowerPkgs {
|
||||
n := strconv.Itoa(i)
|
||||
f.add("cpu.package."+n+".power", "CPU "+strconv.Itoa(i+1)+" power", "power", "W", w, t, none, "package", n)
|
||||
}
|
||||
for i, w := range []string{"1", "5", "15"} {
|
||||
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
|
||||
}
|
||||
@@ -92,7 +96,11 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
if x.Crit > 0 {
|
||||
b.crit = sensors.F(x.Crit)
|
||||
}
|
||||
f.add(sensors.ID("temp", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "temperature", "°C", x.C, t, b, "chip", x.Chip, "label", x.Label)
|
||||
labels := []string{"chip", x.Chip, "label", x.Label}
|
||||
if x.Chip == "coretemp" { // a two-socket board has a coretemp chip per CPU, both with a "Core 0"
|
||||
labels = append(labels, "package", strconv.Itoa(x.Pkg))
|
||||
}
|
||||
f.add(sensors.ID("temp", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "temperature", "°C", x.C, t, b, labels...)
|
||||
}
|
||||
for _, x := range s.Fans {
|
||||
f.add(sensors.ID("fan", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "fan", "rpm", float64(x.RPM), t, none, "chip", x.Chip, "label", x.Label)
|
||||
@@ -129,13 +137,22 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
if x.Disk != "" {
|
||||
lbl = append(lbl, "disk", x.Disk)
|
||||
}
|
||||
if x.Source != "" {
|
||||
lbl = append(lbl, "source", x.Source)
|
||||
}
|
||||
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
|
||||
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
|
||||
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
||||
}
|
||||
|
||||
gpuSeen := map[string]int{}
|
||||
for _, g := range s.GPUs {
|
||||
// two identical cards are gpu.<name> and gpu.<name>_2, every sensor of the second under
|
||||
// its own stem (not add's suffix on the end of each id, which the page can't find)
|
||||
p := "gpu." + sensors.Seg(g.Name)
|
||||
if gpuSeen[p]++; gpuSeen[p] > 1 {
|
||||
p = fmt.Sprintf("%s_%d", p, gpuSeen[p])
|
||||
}
|
||||
lbl := []string{"name", g.Name, "driver", g.Driver}
|
||||
if g.Bus != "" {
|
||||
lbl = append(lbl, "bus", g.Bus)
|
||||
@@ -164,6 +181,9 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
if g.FanRPM > 0 {
|
||||
f.add(p+".fan", g.Name+" fan", "fan", "rpm", float64(g.FanRPM), t, none, lbl...)
|
||||
}
|
||||
if g.FanPct != nil {
|
||||
f.add(p+".fan_pct", g.Name+" fan", "fan", "%", *g.FanPct, t, pct, lbl...)
|
||||
}
|
||||
if g.MaxMHz > 0 {
|
||||
f.add(p+".freq", g.Name+" clock", "frequency", "MHz", g.MHz, t, upTo(g.MaxMHz), lbl...)
|
||||
}
|
||||
@@ -203,6 +223,32 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
}
|
||||
}
|
||||
|
||||
if w := s.ZFSWrite; w != nil {
|
||||
f.add("zfs.write.dirty", "ZFS write buffer", "bytes", "B", float64(w.Dirty), t, upTo(float64(w.Limit)))
|
||||
f.add("zfs.write.limit", "ZFS write buffer limit", "bytes", "B", float64(w.Limit), t, none)
|
||||
f.add("zfs.write.delayed", "ZFS writes throttled", "rate", "/s", w.DelayedPerSec, t, none)
|
||||
for _, p := range w.Pools {
|
||||
pp := "zfs." + sensors.Seg(p.Pool) + "."
|
||||
f.add(pp+"write_bps", p.Pool+" writes to disk", "rate", "B/s", p.WriteBps, t, none, "pool", p.Pool)
|
||||
f.add(pp+"logical_bps", p.Pool+" writes by programs", "rate", "B/s", p.LogicalBps, t, none, "pool", p.Pool)
|
||||
if l := p.Landing; l != nil {
|
||||
lp := pp + "landing."
|
||||
lbl := []string{"pool", p.Pool, "branch", l.Branch}
|
||||
f.add(lp+"pending_bytes", p.Pool+" landing: waiting", "bytes", "B", float64(l.PendingBytes), t, upTo(float64(l.Size)), lbl...)
|
||||
f.add(lp+"pending_files", p.Pool+" landing: files waiting", "count", "", float64(l.PendingFiles), t, none, lbl...)
|
||||
if l.Size > 0 {
|
||||
f.add(lp+"used_pct", p.Pool+" landing: used", "usage", "%", round1(float64(l.Used)/float64(l.Size)*100), t, pct, lbl...)
|
||||
}
|
||||
f.add(lp+"in_bps", p.Pool+" landing: arriving", "rate", "B/s", l.InBps, t, none, lbl...)
|
||||
f.add(lp+"drain_bps", p.Pool+" landing: moving to the pool", "rate", "B/s", l.DrainBps, t, none, lbl...)
|
||||
if m := l.Mover; m != nil {
|
||||
f.add(lp+"mover", p.Pool+" landing: mover", "state", "", m.State, t, none, lbl...)
|
||||
f.add(lp+"queue", p.Pool+" landing: mover queue", "count", "", float64(m.Queue), t, none, lbl...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if u := s.UPS; u != nil {
|
||||
ut := s.upsT
|
||||
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
package metrics
|
||||
|
||||
// The ZFS write path, for the ZFS tab: what is waiting in RAM to be written, how fast each
|
||||
// pool is writing to its disks, and — where a pool has one — the landing tier in front of it.
|
||||
//
|
||||
// A landing tier is a mergerfs pool whose first branches are fast non-ZFS storage and whose
|
||||
// last branch is a ZFS dataset: new files land on the fast branch and a mover drains them onto
|
||||
// the pool. It is detected from mergerfs's own branch list (the user.mergerfs.branches xattr on
|
||||
// the pool's .mergerfs control file), so it shows wherever one exists and nowhere else. A mover
|
||||
// that writes a status file into /run/landing-mover/ (hamlet's scripts/landing-mover.py does)
|
||||
// adds what it is doing: its queue, the file in flight, totals and the last error.
|
||||
//
|
||||
// Sources, all cheap enough for every sample:
|
||||
// - arcstats anon_size: dirty data in RAM, for all pools together (ZFS has no live per-pool
|
||||
// figure; txgs only records ndirty once a txg has synced)
|
||||
// - dmu_tx: dmu_tx_dirty_delay / dmu_tx_dirty_over_max — writers slowed by the write throttle,
|
||||
// or stopped at the hard limit
|
||||
// - <pool>/txgs: nwritten per synced txg — bytes the pool actually wrote to its vdevs
|
||||
// - <pool>/objset-*: nwritten per dataset — bytes programs wrote, before compression
|
||||
// The landing branch's file walk is slower and runs every landingEvery.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
zfsKstatDir = "/proc/spl/kstat/zfs/"
|
||||
zfsParamDir = "/sys/module/zfs/parameters/"
|
||||
moverStateDir = "/run/landing-mover"
|
||||
landingEvery = 10 * time.Second
|
||||
landingWalkMax = 200000 // entries; past this the counts are marked partial
|
||||
)
|
||||
|
||||
// ZFSWrites is the write path as one sample sees it. Nil without the ZFS module.
|
||||
type ZFSWrites struct {
|
||||
Dirty uint64 `json:"dirty"` // bytes in RAM written but not yet on disk, all pools
|
||||
DirtyMax uint64 `json:"dirty_max"` // zfs_dirty_data_max
|
||||
DelayMinPct int `json:"delay_min_pct"` // zfs_delay_min_dirty_percent: the throttle starts here
|
||||
Limit uint64 `json:"limit"` // what the buffer can really hold now: the smaller of dirty_max and half the ARC target
|
||||
DelayedPerSec float64 `json:"delayed_per_sec"` // writes the throttle slowed, per second over the interval
|
||||
OverMaxPerSec float64 `json:"over_max_per_sec"` // writes stopped dead at the limit
|
||||
TxgTimeoutSec int `json:"txg_timeout_sec"` // zfs_txg_timeout
|
||||
Pools []PoolWrites `json:"pools"`
|
||||
}
|
||||
|
||||
// PoolWrites is one pool.
|
||||
type PoolWrites struct {
|
||||
Pool string `json:"pool"`
|
||||
WriteBps float64 `json:"write_bps"` // to its vdevs, after compression, parity included
|
||||
LogicalBps float64 `json:"logical_bps"` // into its datasets, as programs wrote it
|
||||
Landing *Landing `json:"landing,omitempty"`
|
||||
}
|
||||
|
||||
// Landing is a landing tier in front of a pool.
|
||||
type Landing struct {
|
||||
Mount string `json:"mount"` // the mergerfs pool, e.g. /srv/tank
|
||||
Branch string `json:"branch"` // the landing branch, e.g. /srv/landing/tank
|
||||
PoolBranch string `json:"pool_branch"`
|
||||
Device string `json:"device,omitempty"` // block device under the branch, as in Sample.Disks
|
||||
FSType string `json:"fstype,omitempty"`
|
||||
Size uint64 `json:"size"`
|
||||
Used uint64 `json:"used"`
|
||||
PendingFiles int `json:"pending_files"`
|
||||
PendingBytes uint64 `json:"pending_bytes"`
|
||||
PendingPartial bool `json:"pending_partial,omitempty"` // the walk stopped at landingWalkMax
|
||||
OldestMs int64 `json:"oldest_ms,omitempty"` // mtime of the oldest waiting file
|
||||
CountedMs int64 `json:"counted_ms"` // when the walk ran
|
||||
InBps float64 `json:"in_bps"` // the landing device's writes
|
||||
DrainBps float64 `json:"drain_bps"` // the mover's progress onto the pool
|
||||
Mover *Mover `json:"mover,omitempty"`
|
||||
}
|
||||
|
||||
// Mover is what a landing mover says about itself in its status file.
|
||||
type Mover struct {
|
||||
State string `json:"state"` // idle | moving | holding (a mount it needs is missing)
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
PID int `json:"pid"`
|
||||
StartedMs int64 `json:"started_ms"`
|
||||
UpdatedMs int64 `json:"updated_ms"`
|
||||
Stale bool `json:"stale"` // not updated for a while: stopped or hung
|
||||
Queue int `json:"queue"`
|
||||
Settle float64 `json:"settle"`
|
||||
BWLimit string `json:"bwlimit"`
|
||||
BatchFiles int `json:"batch_files"`
|
||||
BatchBytes uint64 `json:"batch_bytes"`
|
||||
BatchStartMs int64 `json:"batch_start_ms"`
|
||||
BatchFirst string `json:"batch_first"`
|
||||
Current string `json:"current"` // the file being copied, when it can tell
|
||||
CurrentSize uint64 `json:"current_size"` // its full size
|
||||
Partial uint64 `json:"partial"` // bytes of it already on the pool
|
||||
MovedFiles uint64 `json:"moved_files"` // since the mover started
|
||||
MovedBytes uint64 `json:"moved_bytes"`
|
||||
LastMove string `json:"last_move"`
|
||||
LastMoveMs int64 `json:"last_move_ms"`
|
||||
LastError string `json:"last_error"`
|
||||
LastErrorMs int64 `json:"last_error_ms"`
|
||||
}
|
||||
|
||||
type zfsWritePrev struct {
|
||||
at time.Time
|
||||
delay uint64
|
||||
overMax uint64
|
||||
txg map[string]uint64 // pool -> highest synced txg counted
|
||||
txgAt map[string]time.Time // pool -> when that txg was first seen
|
||||
txgRate map[string]float64 // pool -> bytes/s over the last gap between syncs
|
||||
logical map[string]uint64 // pool -> sum of objset nwritten
|
||||
drained map[string]uint64 // landing branch -> moved + partial bytes, at the mover's last update
|
||||
drainedAt map[string]int64 // landing branch -> that update's time, unix ms
|
||||
drainRate map[string]float64 // landing branch -> bytes/s between its last two updates
|
||||
landing map[string]Landing // branch -> last walk
|
||||
landingAt time.Time
|
||||
}
|
||||
|
||||
func readParamU(name string) uint64 {
|
||||
v, _ := strconv.ParseUint(strings.TrimSpace(readStr(zfsParamDir+name)), 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// poolsWithKstats lists the imported pools: the directories under the kstat tree.
|
||||
func poolsWithKstats() []string {
|
||||
ents, err := os.ReadDir(zfsKstatDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
if _, err := os.Stat(zfsKstatDir + e.Name() + "/txgs"); err == nil {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// parseTxgs reads a pool's txgs kstat and returns, for every committed txg above `after`, the
|
||||
// bytes it wrote, plus the highest committed txg seen.
|
||||
func parseTxgs(text string, after uint64) (written, top uint64) {
|
||||
top = after
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
// txg birth state ndirty nread nwritten reads writes otime qtime wtime stime
|
||||
if len(f) < 12 || f[2] != "C" {
|
||||
continue
|
||||
}
|
||||
txg, err := strconv.ParseUint(f[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if txg > top {
|
||||
top = txg
|
||||
}
|
||||
if txg > after {
|
||||
n, _ := strconv.ParseUint(f[5], 10, 64)
|
||||
written += n
|
||||
}
|
||||
}
|
||||
return written, top
|
||||
}
|
||||
|
||||
// objsetWritten sums nwritten over a pool's datasets.
|
||||
func objsetWritten(pool string) uint64 {
|
||||
m, _ := filepath.Glob(zfsKstatDir + pool + "/objset-*")
|
||||
var t uint64
|
||||
for _, p := range m {
|
||||
for _, line := range strings.Split(readStr(p), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 3 && f[0] == "nwritten" {
|
||||
v, _ := strconv.ParseUint(f[2], 10, 64)
|
||||
t += v
|
||||
}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// mountEntry is one line of /proc/mounts.
|
||||
type mountEntry struct{ src, point, fstype string }
|
||||
|
||||
func readMounts() []mountEntry {
|
||||
var out []mountEntry
|
||||
for _, line := range strings.Split(readStr("/proc/mounts"), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) >= 3 {
|
||||
out = append(out, mountEntry{src: unescapeMount(f[0]), point: unescapeMount(f[1]), fstype: f[2]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// unescapeMount undoes /proc/mounts' octal escapes (\040 for a space, and so on).
|
||||
func unescapeMount(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) {
|
||||
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
||||
b.WriteByte(byte(v))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mountFor is the mount a path lives on: the longest mount point that is a prefix of it.
|
||||
func mountFor(ms []mountEntry, path string) (mountEntry, bool) {
|
||||
var best mountEntry
|
||||
found := false
|
||||
for _, m := range ms {
|
||||
if m.point == path || m.point == "/" || strings.HasPrefix(path, strings.TrimSuffix(m.point, "/")+"/") {
|
||||
if !found || len(m.point) > len(best.point) {
|
||||
best, found = m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// parseBranches reads mergerfs's branch list: "/srv/landing/tank=RW:/tank=RW,50G".
|
||||
func parseBranches(v string) (paths []string, modes []string) {
|
||||
for _, b := range strings.Split(strings.TrimSpace(v), ":") {
|
||||
if b == "" {
|
||||
continue
|
||||
}
|
||||
p, mode, _ := strings.Cut(b, "=")
|
||||
mode, _, _ = strings.Cut(mode, ",")
|
||||
if mode == "" {
|
||||
mode = "RW"
|
||||
}
|
||||
paths, modes = append(paths, p), append(modes, mode)
|
||||
}
|
||||
return paths, modes
|
||||
}
|
||||
|
||||
func getxattr(path, name string) string {
|
||||
buf := make([]byte, 4096)
|
||||
n, err := syscall.Getxattr(path, name, buf)
|
||||
if err != nil || n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
// findLandings maps pool -> the landing tiers in front of it.
|
||||
func findLandings(ms []mountEntry) map[string][]Landing {
|
||||
out := map[string][]Landing{}
|
||||
for _, m := range ms {
|
||||
if m.fstype != "fuse.mergerfs" {
|
||||
continue
|
||||
}
|
||||
paths, modes := parseBranches(getxattr(filepath.Join(m.point, ".mergerfs"), "user.mergerfs.branches"))
|
||||
// the pool branch is the first ZFS one; landing branches are writable non-ZFS ones before it
|
||||
for i, p := range paths {
|
||||
bm, ok := mountFor(ms, p)
|
||||
if !ok || bm.fstype != "zfs" {
|
||||
continue
|
||||
}
|
||||
pool, _, _ := strings.Cut(bm.src, "/")
|
||||
for j := 0; j < i; j++ {
|
||||
lm, ok := mountFor(ms, paths[j])
|
||||
if !ok || lm.fstype == "zfs" || modes[j] == "RO" {
|
||||
continue
|
||||
}
|
||||
out[pool] = append(out[pool], Landing{Mount: m.point, Branch: paths[j], PoolBranch: p, FSType: lm.fstype, Device: blockDisk(lm.src)})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blockDisk turns a mount source into the name Sample.Disks uses: /dev/nvme0n1p1 -> nvme0,
|
||||
// /dev/sdb2 -> sdb. Empty for anything that is not a plain block device.
|
||||
func blockDisk(src string) string {
|
||||
dev, err := filepath.EvalSymlinks(src)
|
||||
if err != nil || !strings.HasPrefix(dev, "/dev/") {
|
||||
return ""
|
||||
}
|
||||
name := filepath.Base(dev)
|
||||
// a partition's sysfs entry sits inside its disk's
|
||||
if p, err := filepath.EvalSymlinks("/sys/class/block/" + name); err == nil {
|
||||
if _, err := os.Stat(p + "/partition"); err == nil {
|
||||
name = filepath.Base(filepath.Dir(p))
|
||||
}
|
||||
}
|
||||
if m := nsRe.FindStringSubmatch(name); m != nil {
|
||||
return m[1] // nvme0n1 -> nvme0, as foldNVMe names it
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// walkLanding counts what is waiting on a landing branch.
|
||||
func walkLanding(l *Landing, now time.Time) {
|
||||
var st syscall.Statfs_t
|
||||
if syscall.Statfs(l.Branch, &st) == nil {
|
||||
l.Size = st.Blocks * uint64(st.Bsize)
|
||||
l.Used = (st.Blocks - st.Bfree) * uint64(st.Bsize)
|
||||
}
|
||||
seen := 0
|
||||
l.PendingFiles, l.PendingBytes, l.OldestMs, l.PendingPartial = 0, 0, 0, false
|
||||
_ = filepath.WalkDir(l.Branch, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if seen++; seen > landingWalkMax {
|
||||
l.PendingPartial = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if fi, err := d.Info(); err == nil {
|
||||
l.PendingFiles++
|
||||
l.PendingBytes += uint64(fi.Size())
|
||||
if ms := fi.ModTime().UnixMilli(); l.OldestMs == 0 || ms < l.OldestMs {
|
||||
l.OldestMs = ms
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
l.CountedMs = now.UnixMilli()
|
||||
}
|
||||
|
||||
// readMovers loads every mover status file, keyed by the branch it drains.
|
||||
func readMovers(now time.Time) map[string]*Mover {
|
||||
out := map[string]*Mover{}
|
||||
m, _ := filepath.Glob(moverStateDir + "/*.json")
|
||||
for _, p := range m {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var mv Mover
|
||||
if json.Unmarshal(b, &mv) != nil || mv.Src == "" {
|
||||
continue
|
||||
}
|
||||
mv.Stale = now.UnixMilli()-mv.UpdatedMs > 30_000
|
||||
if mv.Stale {
|
||||
mv.State = "stopped"
|
||||
}
|
||||
out[filepath.Clean(mv.Src)] = &mv
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// zfsWrites samples the write path; nil when this machine has no ZFS module loaded.
|
||||
func (c *Collector) zfsWrites(now time.Time, arc *ARC, disks []Disk) *ZFSWrites {
|
||||
if arc == nil {
|
||||
return nil
|
||||
}
|
||||
p := &c.prevZW
|
||||
dt := 0.0
|
||||
if !p.at.IsZero() {
|
||||
dt = now.Sub(p.at).Seconds()
|
||||
}
|
||||
w := &ZFSWrites{Dirty: arc.Anon, DirtyMax: readParamU("zfs_dirty_data_max"),
|
||||
DelayMinPct: int(readParamU("zfs_delay_min_dirty_percent")), TxgTimeoutSec: int(readParamU("zfs_txg_timeout")), Pools: []PoolWrites{}}
|
||||
w.Limit = w.DirtyMax
|
||||
if half := arc.Target / 2; half > 0 && half < w.Limit {
|
||||
w.Limit = half
|
||||
}
|
||||
tx := readKstat(zfsKstatDir + "dmu_tx")
|
||||
if dt > 0 && tx["dmu_tx_dirty_delay"] >= p.delay && tx["dmu_tx_dirty_over_max"] >= p.overMax {
|
||||
w.DelayedPerSec = round1(float64(tx["dmu_tx_dirty_delay"]-p.delay) / dt)
|
||||
w.OverMaxPerSec = round1(float64(tx["dmu_tx_dirty_over_max"]-p.overMax) / dt)
|
||||
}
|
||||
p.delay, p.overMax = tx["dmu_tx_dirty_delay"], tx["dmu_tx_dirty_over_max"]
|
||||
if p.txg == nil {
|
||||
p.txg, p.logical, p.drained, p.landing = map[string]uint64{}, map[string]uint64{}, map[string]uint64{}, map[string]Landing{}
|
||||
p.txgAt, p.txgRate, p.drainedAt, p.drainRate = map[string]time.Time{}, map[string]float64{}, map[string]int64{}, map[string]float64{}
|
||||
}
|
||||
|
||||
ms := readMounts()
|
||||
landings := findLandings(ms)
|
||||
walk := now.Sub(p.landingAt) >= landingEvery
|
||||
if walk {
|
||||
p.landingAt = now
|
||||
}
|
||||
movers := readMovers(now)
|
||||
diskW := map[string]float64{}
|
||||
for _, d := range disks {
|
||||
diskW[d.Name] = d.WriteBps
|
||||
}
|
||||
|
||||
for _, pool := range poolsWithKstats() {
|
||||
pw := PoolWrites{Pool: pool}
|
||||
// A txg records what it wrote only when it has synced — every few seconds, not every
|
||||
// sample — so the rate is taken over the gap between syncs and held until the next one.
|
||||
prevTop, had := p.txg[pool]
|
||||
written, top := parseTxgs(readStr(zfsKstatDir+pool+"/txgs"), prevTop)
|
||||
switch {
|
||||
case !had:
|
||||
p.txgAt[pool] = now
|
||||
case top > prevTop:
|
||||
if gap := now.Sub(p.txgAt[pool]).Seconds(); gap > 0 {
|
||||
p.txgRate[pool] = round1(float64(written) / gap)
|
||||
}
|
||||
p.txgAt[pool] = now
|
||||
case now.Sub(p.txgAt[pool]) > time.Duration(2*max(w.TxgTimeoutSec, 5))*time.Second:
|
||||
p.txgRate[pool] = 0 // no sync for two timeouts: nothing is being written
|
||||
}
|
||||
pw.WriteBps = p.txgRate[pool]
|
||||
p.txg[pool] = top
|
||||
lw := objsetWritten(pool)
|
||||
if prev, ok := p.logical[pool]; ok && dt > 0 && lw >= prev {
|
||||
pw.LogicalBps = round1(float64(lw-prev) / dt)
|
||||
}
|
||||
p.logical[pool] = lw
|
||||
|
||||
if ls := landings[pool]; len(ls) > 0 {
|
||||
l := ls[0] // one landing tier per pool is what the view shows
|
||||
if last, ok := p.landing[l.Branch]; ok && !walk {
|
||||
l.Size, l.Used, l.PendingFiles, l.PendingBytes = last.Size, last.Used, last.PendingFiles, last.PendingBytes
|
||||
l.OldestMs, l.PendingPartial, l.CountedMs = last.OldestMs, last.PendingPartial, last.CountedMs
|
||||
} else {
|
||||
walkLanding(&l, now)
|
||||
p.landing[l.Branch] = l
|
||||
}
|
||||
l.InBps = diskW[l.Device]
|
||||
if mv := movers[filepath.Clean(l.Branch)]; mv != nil {
|
||||
l.Mover = mv
|
||||
// the mover writes its status every couple of seconds: measure between its updates
|
||||
done := mv.MovedBytes + mv.Partial
|
||||
if at, ok := p.drainedAt[l.Branch]; !ok || mv.UpdatedMs != at {
|
||||
if ok && mv.UpdatedMs > at && done >= p.drained[l.Branch] {
|
||||
p.drainRate[l.Branch] = round1(float64(done-p.drained[l.Branch]) / (float64(mv.UpdatedMs-at) / 1000))
|
||||
}
|
||||
p.drained[l.Branch], p.drainedAt[l.Branch] = done, mv.UpdatedMs
|
||||
}
|
||||
if mv.State != "moving" {
|
||||
p.drainRate[l.Branch] = 0
|
||||
}
|
||||
l.DrainBps = p.drainRate[l.Branch]
|
||||
}
|
||||
pw.Landing = &l
|
||||
}
|
||||
w.Pools = append(w.Pools, pw)
|
||||
}
|
||||
p.at = now
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package metrics
|
||||
|
||||
import "testing"
|
||||
|
||||
// real rows from grid's /proc/spl/kstat/zfs/tank/txgs, 2026-10-25
|
||||
const txgsSample = `81 1 0x01 26 7072 20029594359 13953814329135
|
||||
txg birth state ndirty nread nwritten reads writes otime qtime wtime stime
|
||||
15812833 13936408791659 C 0 0 0 0 0 5119980242 2040 58385 309580
|
||||
15812834 13941528771901 C 0 0 0 0 0 5119948815 8471 65826 112612
|
||||
15812835 13946648720716 C 885760 0 2293760 0 367 5120100556 1704 56762 271264374
|
||||
15812836 13951768821272 S 4194304 0 0 0 0 5120000000 1000 50000 0
|
||||
15812837 13956888821272 O 0 0 0 0 0 0 0 0 0
|
||||
`
|
||||
|
||||
func TestParseTxgs(t *testing.T) {
|
||||
w, top := parseTxgs(txgsSample, 0)
|
||||
if w != 2293760 || top != 15812835 {
|
||||
t.Fatalf("from 0: written %d top %d", w, top)
|
||||
}
|
||||
// only txgs synced since the last look count, and an open or syncing txg never does
|
||||
w, top = parseTxgs(txgsSample, 15812834)
|
||||
if w != 2293760 || top != 15812835 {
|
||||
t.Fatalf("after 15812834: written %d top %d", w, top)
|
||||
}
|
||||
w, top = parseTxgs(txgsSample, 15812835)
|
||||
if w != 0 || top != 15812835 {
|
||||
t.Fatalf("nothing new: written %d top %d", w, top)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBranches(t *testing.T) {
|
||||
p, m := parseBranches("/srv/landing/tank=RW:/tank=NC,50G:/mnt/old=RO\n")
|
||||
if len(p) != 3 || p[0] != "/srv/landing/tank" || p[1] != "/tank" || p[2] != "/mnt/old" {
|
||||
t.Fatalf("paths %q", p)
|
||||
}
|
||||
if m[0] != "RW" || m[1] != "NC" || m[2] != "RO" {
|
||||
t.Fatalf("modes %q", m)
|
||||
}
|
||||
if p, _ := parseBranches("/a:/b"); len(p) != 2 {
|
||||
t.Fatalf("bare paths %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountFor(t *testing.T) {
|
||||
ms := []mountEntry{
|
||||
{"/dev/sdj2", "/", "ext4"},
|
||||
{"/dev/nvme0n1p1", "/srv/landing", "ext4"},
|
||||
{"tank", "/tank", "zfs"},
|
||||
{"tank/media", "/tank/media", "zfs"},
|
||||
{"tank-landing", "/srv/tank", "fuse.mergerfs"},
|
||||
}
|
||||
cases := map[string]string{
|
||||
"/srv/landing/tank": "/srv/landing", "/tank": "/tank", "/tank/media/x": "/tank/media",
|
||||
"/tankfoo": "/", "/srv/landingx": "/",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if m, ok := mountFor(ms, path); !ok || m.point != want {
|
||||
t.Errorf("%s: got %q want %q", path, m.point, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnescapeMount(t *testing.T) {
|
||||
if got := unescapeMount(`/mnt/my\040disk`); got != "/mnt/my disk" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldZFSWrites(t *testing.T) {
|
||||
mk := func(dirty uint64, bps, in, drain float64) Sample {
|
||||
return Sample{ZFSWrite: &ZFSWrites{Dirty: dirty, Limit: 100, Pools: []PoolWrites{{Pool: "tank", WriteBps: bps,
|
||||
Landing: &Landing{Branch: "/srv/landing/tank", InBps: in, DrainBps: drain, Mover: &Mover{State: "moving"}}}}}}
|
||||
}
|
||||
b := []Sample{mk(10, 100, 400, 0), mk(30, 300, 0, 60)}
|
||||
got := fold(b).ZFSWrite
|
||||
if got.Dirty != 20 || got.Pools[0].WriteBps != 200 || got.Pools[0].Landing.InBps != 200 || got.Pools[0].Landing.DrainBps != 30 {
|
||||
t.Fatalf("folded %+v / %+v", got, got.Pools[0].Landing)
|
||||
}
|
||||
// the fold must not write through to the newest sample it copied from
|
||||
if b[1].ZFSWrite.Pools[0].Landing.InBps != 0 || b[1].ZFSWrite.Dirty != 30 {
|
||||
t.Fatalf("fold changed its input")
|
||||
}
|
||||
if got.Pools[0].Landing.Mover.State != "moving" {
|
||||
t.Fatalf("mover state lost")
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -383,13 +383,19 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bo
|
||||
// WebSocket origin check (meant for browsers) doesn't reject the hub.
|
||||
// Do makes one request to a managed machine as this hub and returns the whole response.
|
||||
func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, error) {
|
||||
code, b, _, err := m.DoHeaders(ctx, name, method, path, body, hdr)
|
||||
return code, b, err
|
||||
}
|
||||
|
||||
// DoHeaders is Do that also answers the response's headers.
|
||||
func (m *Manager) DoHeaders(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, http.Header, error) {
|
||||
n, ok := m.node(name)
|
||||
if !ok {
|
||||
return 0, nil, errors.New("no such machine")
|
||||
return 0, nil, nil, errors.New("no such machine")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(n.URL, "/")+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
for k, v := range hdr {
|
||||
req.Header[k] = v
|
||||
@@ -397,11 +403,17 @@ func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte
|
||||
req.Header.Set("Authorization", "Bearer "+n.Token)
|
||||
resp, err := m.client(n).Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, errors.New(shortErr(err))
|
||||
return 0, nil, nil, errors.New(shortErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
return resp.StatusCode, b, err
|
||||
return resp.StatusCode, b, resp.Header, err
|
||||
}
|
||||
|
||||
// URL is a managed machine's address, "" for one not managed here.
|
||||
func (m *Manager) URL(name string) string {
|
||||
n, _ := m.node(name)
|
||||
return n.URL
|
||||
}
|
||||
|
||||
// Names lists the managed machines.
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
package storage
|
||||
|
||||
// Verifying a landing tier: did everything that landed really reach the pool, intact?
|
||||
//
|
||||
// A landing tier is fast storage in front of a pool (a mergerfs pool, new files on the fast
|
||||
// branch first) with a mover draining it. The mover keeps a ledger — one JSON line per file it
|
||||
// moved: the path under the pool, its size and mtime, and the SHA-256 of the fast copy read
|
||||
// just before the move (hamlet's scripts/landing-mover.py writes /var/lib/landing-mover/
|
||||
// ledger.jsonl and names it in its status file). A verify pass reads the pool's copy of each
|
||||
// entry back and compares.
|
||||
//
|
||||
// What a pass can say about each file:
|
||||
// - match: same size, same mtime, same SHA-256 — it arrived intact
|
||||
// - mismatch: same size and mtime but different contents — the one real failure
|
||||
// - changed: the pool's copy has a different size or mtime: edited since it moved, so the
|
||||
// old hash says nothing about it (counted, not compared)
|
||||
// - gone: not on the pool and not waiting on the landing branch either — deleted or
|
||||
// renamed since it moved. A pass cannot tell that from a loss, so it lists them
|
||||
// - waiting: back on the landing branch (written again since), the mover will take it
|
||||
// - unreadable: could not be opened as this account
|
||||
// - unhashed: the mover recorded no hash for it (not a regular file, or changed mid-move)
|
||||
//
|
||||
// Scope "new" checks what has moved since the last pass, "all" re-reads the whole ledger. A
|
||||
// path moved more than once is checked against its latest entry only.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- schedules ----
|
||||
|
||||
// VerifyPolicy runs a verify pass on a schedule. The when-fields mean what they do on a
|
||||
// snapshot Policy; Scope is "new" or "all".
|
||||
type VerifyPolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Every string `json:"every"`
|
||||
At string `json:"at"`
|
||||
Weekday int `json:"weekday"`
|
||||
Day int `json:"day"`
|
||||
Cron string `json:"cron,omitempty"`
|
||||
Scope string `json:"scope"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// when borrows the snapshot Policy's schedule logic, so both kinds of schedule are written,
|
||||
// validated and read back by exactly the same code.
|
||||
func (v *VerifyPolicy) when() *Policy {
|
||||
return &Policy{Every: v.Every, At: v.At, Weekday: v.Weekday, Day: v.Day, Cron: v.Cron}
|
||||
}
|
||||
|
||||
func (v *VerifyPolicy) CronExpr() (string, error) { return v.when().CronExpr() }
|
||||
func (v *VerifyPolicy) NextRun(now time.Time) time.Time { return v.when().NextRun(now) }
|
||||
|
||||
func (v *VerifyPolicy) Validate() error {
|
||||
if v.Scope != "new" && v.Scope != "all" {
|
||||
return errors.New("scope: new (what moved since the last pass) or all (the whole ledger)")
|
||||
}
|
||||
switch v.Every {
|
||||
case "hourly", "daily", "weekly", "monthly":
|
||||
case "custom":
|
||||
if strings.TrimSpace(v.Cron) == "" {
|
||||
return errors.New("a custom schedule needs a cron expression")
|
||||
}
|
||||
default:
|
||||
return errors.New("every: hourly, daily, weekly, monthly or custom")
|
||||
}
|
||||
if _, err := v.CronExpr(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := v.when().clock(); err != nil {
|
||||
return err
|
||||
}
|
||||
if v.Every == "weekly" && (v.Weekday < 0 || v.Weekday > 6) {
|
||||
return errors.New("weekday: 0 (Sunday) to 6")
|
||||
}
|
||||
if v.Every == "monthly" && (v.Day < 1 || v.Day > 28) {
|
||||
return errors.New("day: 1 to 28, so every month has one")
|
||||
}
|
||||
if len(v.Name) > 64 {
|
||||
return errors.New("name: 64 characters at most")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyCronFile holds the verify schedules; Ham Lab owns it and rewrites it whole.
|
||||
const VerifyCronFile = "/etc/cron.d/hamlab-landing"
|
||||
|
||||
// VerifyCronText renders the verify schedules as cron lines, run as Ham Lab's own account.
|
||||
func VerifyCronText(ps []VerifyPolicy, user, exe, config string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Ham Lab landing-tier verify schedules — written by the ZFS tab, rewritten whole on every change.\n")
|
||||
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
|
||||
b.WriteString("SHELL=/bin/sh\n")
|
||||
b.WriteString("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||
for i := range ps {
|
||||
p := &ps[i]
|
||||
name := strings.ReplaceAll(p.Name, "\n", " ")
|
||||
if !p.Enabled {
|
||||
b.WriteString("# " + name + " — paused\n")
|
||||
continue
|
||||
}
|
||||
expr, err := p.CronExpr()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", p.Name, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "# %s — verify %s\n", name, p.Scope)
|
||||
fmt.Fprintf(&b, "%s %s %s -config %s landing-verify %s\n\n", expr, user, exe, config, p.ID)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// WriteVerifyCron puts the verify schedules on the machine, or removes the file when none run.
|
||||
func (m *Manager) WriteVerifyCron(ctx context.Context, ps []VerifyPolicy, user, exe, config string) error {
|
||||
live := false
|
||||
for i := range ps {
|
||||
live = live || ps[i].Enabled
|
||||
}
|
||||
if !live {
|
||||
return m.RemoveRootFile(ctx, VerifyCronFile)
|
||||
}
|
||||
text, err := VerifyCronText(ps, user, exe, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.writeRoot(ctx, VerifyCronFile, text)
|
||||
}
|
||||
|
||||
// ---- finding the landing tier ----
|
||||
|
||||
// MoverStatusDir is where a landing mover writes what it is doing.
|
||||
const MoverStatusDir = "/run/landing-mover"
|
||||
|
||||
// DefaultLedger is where hamlet's mover keeps its ledger when no status file names one.
|
||||
const DefaultLedger = "/var/lib/landing-mover/ledger.jsonl"
|
||||
|
||||
// LandingTarget is one landing tier a pass can check: the branch files land on, the pool
|
||||
// directory they are moved to, and the mover's ledger.
|
||||
type LandingTarget struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Ledger string `json:"ledger"`
|
||||
}
|
||||
|
||||
// LandingTargets reads the movers' status files. Each mover names its own source, target and
|
||||
// ledger; with none running, nothing can be verified, and the page says so.
|
||||
func LandingTargets() []LandingTarget {
|
||||
var out []LandingTarget
|
||||
m, _ := filepath.Glob(MoverStatusDir + "/*.json")
|
||||
for _, p := range m {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var st struct{ Src, Dst, Ledger string }
|
||||
if json.Unmarshal(b, &st) != nil || st.Src == "" || st.Dst == "" {
|
||||
continue
|
||||
}
|
||||
if st.Ledger == "" {
|
||||
st.Ledger = DefaultLedger
|
||||
}
|
||||
out = append(out, LandingTarget{Src: filepath.Clean(st.Src), Dst: filepath.Clean(st.Dst), Ledger: st.Ledger})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Src < out[j].Src })
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- stale files ----
|
||||
|
||||
// StaleAfter is how long a file may wait on a landing branch before it counts as stale. A file
|
||||
// older than this is not necessarily lost — a big backlog drains for hours — but it is worth a
|
||||
// look, which is what the ZFS tab offers.
|
||||
const StaleAfter = time.Hour
|
||||
|
||||
// StaleFile is one file that has waited longer than StaleAfter.
|
||||
type StaleFile struct {
|
||||
Path string `json:"path"` // relative to the landing branch, which is also its path on the pool
|
||||
Size int64 `json:"size"`
|
||||
MTime int64 `json:"mtime"` // unix ms
|
||||
}
|
||||
|
||||
// StaleFiles lists what has waited on a landing branch longer than StaleAfter, oldest first,
|
||||
// at most limit of them, with the full count and size.
|
||||
func StaleFiles(branch string, limit int, now time.Time) (files []StaleFile, count int, bytes int64) {
|
||||
cut := now.Add(-StaleAfter)
|
||||
_ = filepath.WalkDir(branch, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
fi, err := d.Info()
|
||||
if err != nil || !fi.ModTime().Before(cut) {
|
||||
return nil
|
||||
}
|
||||
count++
|
||||
bytes += fi.Size()
|
||||
rel, _ := filepath.Rel(branch, p)
|
||||
files = append(files, StaleFile{Path: rel, Size: fi.Size(), MTime: fi.ModTime().UnixMilli()})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].MTime < files[j].MTime })
|
||||
if len(files) > limit {
|
||||
files = files[:limit]
|
||||
}
|
||||
return files, count, bytes
|
||||
}
|
||||
|
||||
// ---- the pass ----
|
||||
|
||||
// LedgerEntry is one line of the mover's ledger.
|
||||
type LedgerEntry struct {
|
||||
T int64 `json:"t"` // when it was moved, unix ms
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
MTimeNs int64 `json:"mtime_ns"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyIssue is one file a pass wants a person to look at.
|
||||
type VerifyIssue struct {
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"` // mismatch | gone | unreadable
|
||||
Moved int64 `json:"moved"`
|
||||
Size int64 `json:"size"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyResult is what one pass found.
|
||||
type VerifyResult struct {
|
||||
ID string `json:"id"` // the schedule that ran it, or "manual"
|
||||
Scope string `json:"scope"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished time.Time `json:"finished"`
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst"`
|
||||
Since int64 `json:"since"` // ledger entries after this (unix ms); 0 for all
|
||||
Entries int `json:"entries"`
|
||||
Checked int `json:"checked"`
|
||||
Matched int `json:"matched"`
|
||||
Mismatched int `json:"mismatched"`
|
||||
Changed int `json:"changed"`
|
||||
Gone int `json:"gone"`
|
||||
Waiting int `json:"waiting"`
|
||||
Unreadable int `json:"unreadable"`
|
||||
Unhashed int `json:"unhashed"`
|
||||
BytesRead int64 `json:"bytes_read"`
|
||||
Issues []VerifyIssue `json:"issues"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Cursor int64 `json:"cursor"` // the newest entry it covered
|
||||
}
|
||||
|
||||
// OK is true when nothing needs a look: no mismatches, nothing gone or unreadable, no error.
|
||||
func (r *VerifyResult) OK() bool {
|
||||
return r.Error == "" && r.Mismatched == 0 && r.Gone == 0 && r.Unreadable == 0
|
||||
}
|
||||
|
||||
// VerifyProgress is published while a pass runs.
|
||||
type VerifyProgress struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
PID int `json:"pid"`
|
||||
Started time.Time `json:"started"`
|
||||
Updated time.Time `json:"updated"`
|
||||
Total int `json:"total"`
|
||||
Done int `json:"done"`
|
||||
BytesRead int64 `json:"bytes_read"`
|
||||
BytesAll int64 `json:"bytes_all"`
|
||||
Current string `json:"current"`
|
||||
}
|
||||
|
||||
const maxIssues = 500
|
||||
|
||||
// readLedger loads the ledger's entries newer than since, the latest per path, oldest first.
|
||||
func readLedger(path string, since int64) ([]LedgerEntry, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
latest := map[string]LedgerEntry{}
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for sc.Scan() {
|
||||
var e LedgerEntry
|
||||
if json.Unmarshal(sc.Bytes(), &e) != nil || e.Path == "" || e.T <= since {
|
||||
continue
|
||||
}
|
||||
if old, ok := latest[e.Path]; !ok || e.T >= old.T {
|
||||
latest[e.Path] = e
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]LedgerEntry, 0, len(latest))
|
||||
for _, e := range latest {
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].T < out[j].T })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inside joins rel under root and refuses anything that would climb out of it.
|
||||
func inside(root, rel string) (string, bool) {
|
||||
p := filepath.Join(root, rel)
|
||||
return p, p == root || strings.HasPrefix(p, root+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func hashFile(p string, buf []byte, onRead func(int)) (string, error) {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if n > 0 {
|
||||
h.Write(buf[:n])
|
||||
onRead(n)
|
||||
}
|
||||
if err == io.EOF {
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify runs one pass over a landing tier's ledger. progress is called about once a second.
|
||||
func Verify(t LandingTarget, id, scope string, since int64, progress func(VerifyProgress)) VerifyResult {
|
||||
r := VerifyResult{ID: id, Scope: scope, Started: time.Now(), Src: t.Src, Dst: t.Dst, Since: since, Issues: []VerifyIssue{}, Cursor: since}
|
||||
if scope == "all" {
|
||||
r.Since = 0
|
||||
}
|
||||
entries, err := readLedger(t.Ledger, r.Since)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
r.Error = "no ledger yet at " + t.Ledger + " — nothing has been moved since the mover started keeping one"
|
||||
} else {
|
||||
r.Error = "reading the ledger: " + err.Error()
|
||||
}
|
||||
r.Finished = time.Now()
|
||||
return r
|
||||
}
|
||||
r.Entries = len(entries)
|
||||
pr := VerifyProgress{ID: id, Scope: scope, PID: os.Getpid(), Started: r.Started, Total: len(entries)}
|
||||
for _, e := range entries {
|
||||
pr.BytesAll += e.Size
|
||||
}
|
||||
last := time.Time{}
|
||||
tick := func(force bool) {
|
||||
if progress != nil && (force || time.Since(last) >= time.Second) {
|
||||
pr.Updated, last = time.Now(), time.Now()
|
||||
progress(pr)
|
||||
}
|
||||
}
|
||||
issue := func(e LedgerEntry, kind, note string) {
|
||||
if len(r.Issues) < maxIssues {
|
||||
r.Issues = append(r.Issues, VerifyIssue{Path: e.Path, Kind: kind, Moved: e.T, Size: e.Size, Note: note})
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 4<<20)
|
||||
for _, e := range entries {
|
||||
pr.Current = e.Path
|
||||
tick(false)
|
||||
if e.T > r.Cursor {
|
||||
r.Cursor = e.T
|
||||
}
|
||||
r.Checked++
|
||||
pr.Done++
|
||||
dst, ok1 := inside(t.Dst, e.Path)
|
||||
src, ok2 := inside(t.Src, e.Path)
|
||||
if !ok1 || !ok2 {
|
||||
r.Unreadable++
|
||||
issue(e, "unreadable", "path climbs out of the pool")
|
||||
continue
|
||||
}
|
||||
st, err := os.Lstat(dst)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if _, err := os.Lstat(src); err == nil {
|
||||
r.Waiting++
|
||||
continue
|
||||
}
|
||||
r.Gone++
|
||||
issue(e, "gone", "not on the pool, not waiting to move: deleted or renamed since it moved?")
|
||||
continue
|
||||
}
|
||||
r.Unreadable++
|
||||
issue(e, "unreadable", err.Error())
|
||||
continue
|
||||
}
|
||||
if e.SHA256 == "" {
|
||||
r.Unhashed++
|
||||
continue
|
||||
}
|
||||
mt := st.ModTime().UnixNano()
|
||||
if st.Size() != e.Size || abs64(mt-e.MTimeNs) >= int64(time.Second) {
|
||||
r.Changed++
|
||||
continue
|
||||
}
|
||||
sum, err := hashFile(dst, buf, func(n int) { r.BytesRead += int64(n); pr.BytesRead += int64(n); tick(false) })
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EACCES) || errors.Is(err, os.ErrPermission) {
|
||||
r.Unreadable++
|
||||
issue(e, "unreadable", "permission denied")
|
||||
} else {
|
||||
r.Unreadable++
|
||||
issue(e, "unreadable", err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if sum != e.SHA256 {
|
||||
r.Mismatched++
|
||||
issue(e, "mismatch", "contents differ from the copy that landed")
|
||||
continue
|
||||
}
|
||||
r.Matched++
|
||||
}
|
||||
tick(true)
|
||||
r.Finished = time.Now()
|
||||
return r
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ---- records ----
|
||||
|
||||
func verifyDir(dataDir string) string { return filepath.Join(dataDir, "landing-verify") }
|
||||
|
||||
func writeJSON(p string, v any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p)
|
||||
}
|
||||
|
||||
func readJSON(p string, v any) bool {
|
||||
b, err := os.ReadFile(p)
|
||||
return err == nil && json.Unmarshal(b, v) == nil
|
||||
}
|
||||
|
||||
// cursorKey names a landing tier's cursor file.
|
||||
func cursorKey(t LandingTarget) string {
|
||||
return strings.Trim(strings.NewReplacer("/", "_").Replace(t.Src), "_")
|
||||
}
|
||||
|
||||
// VerifyCursor is the newest ledger entry a finished pass covered, for scope "new".
|
||||
func VerifyCursor(dataDir string, t LandingTarget) int64 {
|
||||
var c struct{ Cursor int64 }
|
||||
readJSON(filepath.Join(verifyDir(dataDir), "cursor-"+cursorKey(t)+".json"), &c)
|
||||
return c.Cursor
|
||||
}
|
||||
|
||||
// RecordVerify keeps a finished pass: as the run's last result, in the history (newest first,
|
||||
// 30 kept), and — when it read the ledger at all — as the new cursor.
|
||||
func RecordVerify(dataDir string, t LandingTarget, r VerifyResult) error {
|
||||
d := verifyDir(dataDir)
|
||||
if err := writeJSON(filepath.Join(d, "run-"+r.ID+".json"), r); err != nil {
|
||||
return err
|
||||
}
|
||||
var hist []VerifyResult
|
||||
readJSON(filepath.Join(d, "history.json"), &hist)
|
||||
hist = append([]VerifyResult{r}, hist...)
|
||||
if len(hist) > 30 {
|
||||
hist = hist[:30]
|
||||
}
|
||||
if err := writeJSON(filepath.Join(d, "history.json"), hist); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Error == "" && r.Cursor > VerifyCursor(dataDir, t) {
|
||||
return writeJSON(filepath.Join(d, "cursor-"+cursorKey(t)+".json"), map[string]int64{"cursor": r.Cursor})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyHistory is the kept passes, newest first.
|
||||
func VerifyHistory(dataDir string) []VerifyResult {
|
||||
var hist []VerifyResult
|
||||
readJSON(filepath.Join(verifyDir(dataDir), "history.json"), &hist)
|
||||
if hist == nil {
|
||||
hist = []VerifyResult{}
|
||||
}
|
||||
return hist
|
||||
}
|
||||
|
||||
// LastVerify is the last pass a schedule (or "manual") ran.
|
||||
func LastVerify(dataDir, id string) *VerifyResult {
|
||||
var r VerifyResult
|
||||
if !readJSON(filepath.Join(verifyDir(dataDir), "run-"+id+".json"), &r) {
|
||||
return nil
|
||||
}
|
||||
return &r
|
||||
}
|
||||
|
||||
// ForgetVerify drops a removed schedule's last result (the history keeps it).
|
||||
func ForgetVerify(dataDir, id string) {
|
||||
_ = os.Remove(filepath.Join(verifyDir(dataDir), "run-"+id+".json"))
|
||||
}
|
||||
|
||||
func progressPath(dataDir string) string { return filepath.Join(verifyDir(dataDir), "running.json") }
|
||||
|
||||
// WriteVerifyProgress publishes a running pass.
|
||||
func WriteVerifyProgress(dataDir string, p VerifyProgress) { _ = writeJSON(progressPath(dataDir), p) }
|
||||
|
||||
// ClearVerifyProgress removes it once the pass ends.
|
||||
func ClearVerifyProgress(dataDir string) { _ = os.Remove(progressPath(dataDir)) }
|
||||
|
||||
// RunningVerify is the pass in progress, or nil. A progress file whose process is gone, or
|
||||
// that has not moved for two minutes, is a pass that died: it is reported as not running.
|
||||
func RunningVerify(dataDir string) *VerifyProgress {
|
||||
var p VerifyProgress
|
||||
if !readJSON(progressPath(dataDir), &p) {
|
||||
return nil
|
||||
}
|
||||
if p.PID <= 0 || syscall.Kill(p.PID, 0) != nil || time.Since(p.Updated) > 2*time.Minute {
|
||||
return nil
|
||||
}
|
||||
return &p
|
||||
}
|
||||
|
||||
// LockVerify takes the one-pass-at-a-time lock; release it with the returned func.
|
||||
func LockVerify(dataDir string) (func(), error) {
|
||||
if err := os.MkdirAll(verifyDir(dataDir), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(verifyDir(dataDir), "lock"), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
return nil, errors.New("a verify pass is already running")
|
||||
}
|
||||
return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVerifyClassifiesEveryCase(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src, dst := filepath.Join(root, "landing"), filepath.Join(root, "pool")
|
||||
for _, d := range []string{src, dst + "/a", src + "/a"} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mtime := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
|
||||
put := func(p, body string) LedgerEntry {
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chtimes(p, mtime, mtime)
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
return LedgerEntry{Size: int64(len(body)), MTimeNs: mtime.UnixNano(), SHA256: hex.EncodeToString(sum[:])}
|
||||
}
|
||||
var ledger []LedgerEntry
|
||||
add := func(rel string, e LedgerEntry, at int64) { e.Path, e.T = rel, at; ledger = append(ledger, e) }
|
||||
|
||||
add("a/match.bin", put(dst+"/a/match.bin", "intact"), 10)
|
||||
e := put(dst+"/a/mismatch.bin", "garbled") // same size and mtime as what was hashed, other bytes
|
||||
sum := sha256.Sum256([]byte("GARBLED"))
|
||||
e.SHA256 = hex.EncodeToString(sum[:])
|
||||
add("a/mismatch.bin", e, 11)
|
||||
e = put(dst+"/a/changed.bin", "edited since, longer now")
|
||||
e.Size = 3
|
||||
add("a/changed.bin", e, 12)
|
||||
add("a/gone.bin", LedgerEntry{Size: 4, MTimeNs: mtime.UnixNano(), SHA256: "00"}, 13)
|
||||
put(src+"/a/waiting.bin", "back again")
|
||||
add("a/waiting.bin", LedgerEntry{Size: 10, MTimeNs: mtime.UnixNano(), SHA256: "00"}, 14)
|
||||
e = put(dst+"/a/unhashed", "x")
|
||||
e.SHA256, e.Note = "", "not a regular file"
|
||||
add("a/unhashed", e, 15)
|
||||
add("../escape", LedgerEntry{Size: 1, SHA256: "00"}, 16)
|
||||
// moved twice: only the newer entry counts, and it matches
|
||||
add("a/twice.bin", LedgerEntry{Size: 99, SHA256: "stale"}, 5)
|
||||
add("a/twice.bin", put(dst+"/a/twice.bin", "second time"), 17)
|
||||
|
||||
lp := filepath.Join(root, "ledger.jsonl")
|
||||
f, _ := os.Create(lp)
|
||||
for _, e := range ledger {
|
||||
b, _ := json.Marshal(e)
|
||||
f.Write(append(b, '\n'))
|
||||
}
|
||||
f.WriteString("not json\n")
|
||||
f.Close()
|
||||
|
||||
tg := LandingTarget{Src: src, Dst: dst, Ledger: lp}
|
||||
r := Verify(tg, "manual", "all", 0, nil)
|
||||
want := map[string]int{"checked": 8, "matched": 2, "mismatched": 1, "changed": 1, "gone": 1, "waiting": 1, "unhashed": 1, "unreadable": 1}
|
||||
got := map[string]int{"checked": r.Checked, "matched": r.Matched, "mismatched": r.Mismatched, "changed": r.Changed,
|
||||
"gone": r.Gone, "waiting": r.Waiting, "unhashed": r.Unhashed, "unreadable": r.Unreadable}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s: got %d want %d (%+v)", k, got[k], v, r)
|
||||
}
|
||||
}
|
||||
if r.OK() || r.Cursor != 17 || r.Error != "" {
|
||||
t.Errorf("ok=%v cursor=%d err=%q", r.OK(), r.Cursor, r.Error)
|
||||
}
|
||||
kinds := map[string]string{}
|
||||
for _, i := range r.Issues {
|
||||
kinds[i.Path] = i.Kind
|
||||
}
|
||||
if kinds["a/mismatch.bin"] != "mismatch" || kinds["a/gone.bin"] != "gone" || kinds["../escape"] != "unreadable" {
|
||||
t.Errorf("issues %+v", r.Issues)
|
||||
}
|
||||
|
||||
// "new" after a pass at cursor 14 only looks at what moved later
|
||||
r = Verify(tg, "manual", "new", 14, nil)
|
||||
if r.Checked != 3 || r.Matched != 1 || r.Unhashed != 1 || r.Unreadable != 1 {
|
||||
t.Errorf("new since 14: %+v", r)
|
||||
}
|
||||
|
||||
// records: the cursor only advances on a pass that read the ledger
|
||||
dir := t.TempDir()
|
||||
if err := RecordVerify(dir, tg, Verify(tg, "manual", "all", 0, nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := VerifyCursor(dir, tg); c != 17 {
|
||||
t.Errorf("cursor %d", c)
|
||||
}
|
||||
bad := Verify(LandingTarget{Src: src, Dst: dst, Ledger: filepath.Join(root, "missing")}, "manual", "new", 0, nil)
|
||||
if !strings.Contains(bad.Error, "no ledger yet") {
|
||||
t.Errorf("missing ledger: %q", bad.Error)
|
||||
}
|
||||
if len(VerifyHistory(dir)) != 1 || LastVerify(dir, "manual") == nil {
|
||||
t.Errorf("history/last not kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPolicy(t *testing.T) {
|
||||
p := VerifyPolicy{ID: "abc", Name: "nightly", Every: "daily", At: "05:10", Scope: "new", Enabled: true}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if x, _ := p.CronExpr(); x != "10 5 * * *" {
|
||||
t.Fatalf("cron %q", x)
|
||||
}
|
||||
txt, err := VerifyCronText([]VerifyPolicy{p, {ID: "off", Name: "paused one", Every: "hourly", Scope: "all"}}, "core", "/usr/local/bin/hamlab", "/cfg.json")
|
||||
if err != nil || !strings.Contains(txt, "10 5 * * * core /usr/local/bin/hamlab -config /cfg.json landing-verify abc") || !strings.Contains(txt, "# paused one — paused") {
|
||||
t.Fatalf("%v\n%s", err, txt)
|
||||
}
|
||||
for _, b := range []VerifyPolicy{{Every: "daily", Scope: "some"}, {Every: "sometimes", Scope: "new"}, {Every: "custom", Scope: "new"}} {
|
||||
if b.Validate() == nil {
|
||||
t.Errorf("accepted %+v", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleFiles(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
now := time.Now()
|
||||
os.MkdirAll(d+"/x", 0o755)
|
||||
for name, age := range map[string]time.Duration{"x/old.bin": 3 * time.Hour, "x/older.bin": 5 * time.Hour, "fresh.bin": time.Minute} {
|
||||
os.WriteFile(filepath.Join(d, name), []byte("12345"), 0o644)
|
||||
os.Chtimes(filepath.Join(d, name), now.Add(-age), now.Add(-age))
|
||||
}
|
||||
files, n, b := StaleFiles(d, 1, now)
|
||||
if n != 2 || b != 10 || len(files) != 1 || files[0].Path != "x/older.bin" {
|
||||
t.Fatalf("%d %d %+v", n, b, files)
|
||||
}
|
||||
}
|
||||
+13
-7
@@ -68,13 +68,19 @@ type State struct {
|
||||
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
||||
// orchestrator that has since been removed can still be found on disk. See
|
||||
// internal/api/stacks.go.
|
||||
Mounts []RememberedMount `json:"mounts,omitempty"`
|
||||
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so
|
||||
// the next browser — or this one after it has forgotten them — is signed into the device
|
||||
// already. See internal/api/kvm.go.
|
||||
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
|
||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
||||
Mounts []RememberedMount `json:"mounts,omitempty"`
|
||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
||||
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
|
||||
// a machine and a device on it. nil is the machine the file is on, its best device.
|
||||
Transcode *Transcode `json:"transcode,omitempty"`
|
||||
}
|
||||
|
||||
// Transcode names a device on a machine: Node "" is the hub itself; Device is a render node
|
||||
// or "cpu".
|
||||
type Transcode struct {
|
||||
Node string `json:"node"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
|
||||
// UserState returns the prefs of an account, making an empty one if needed.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"icons": "node scripts/icons.mjs",
|
||||
"test": "node scripts/test-graphcols.mjs && node scripts/test-css-imports.mjs && node scripts/test-resolve-path.mjs"
|
||||
"test": "node scripts/test-graphcols.mjs && node scripts/test-sockets.mjs && node scripts/test-css-imports.mjs && node scripts/test-resolve-path.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
|
||||
@@ -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)
|
||||
@@ -8,7 +8,7 @@ import { join } from 'node:path'
|
||||
|
||||
const out = join(mkdtempSync(join(tmpdir(), 'graphcols-')), 'graphcols.mjs')
|
||||
buildSync({ entryPoints: [new URL('../src/graphcols.ts', import.meta.url).pathname], outfile: out, format: 'esm', logLevel: 'error' })
|
||||
const { columnsOf } = await import(pathToFileURL(out).href)
|
||||
const { columnsOf, columnsAt } = await import(pathToFileURL(out).href)
|
||||
|
||||
const fails = []
|
||||
const check = (ok, what, detail) => { if (!ok) fails.push(what + (detail !== undefined ? ' — ' + JSON.stringify(detail).slice(0, 200) : '')) }
|
||||
@@ -38,5 +38,28 @@ const wide = columnsOf(base, CAP - 1, CAP, 390)
|
||||
check(wide.length === 390 && Math.max(...wide) === 900, 'a box wider than the history is filled, nothing invented', wide.length)
|
||||
check(columnsOf([], 0, CAP, COLS).length === 0 && columnsOf([5], 0, 2, 50).length >= 1, 'empty and single-point curves')
|
||||
|
||||
// Sliding with the ticks: four ticks to a point (a 250 ms rate, a 1 s stride). Every tick moves
|
||||
// the graph left by the same fraction of a column, the newest column grows in from the right, and
|
||||
// a column, once finished, keeps its value wherever it has slid to.
|
||||
for (const cols of [COLS, 390]) {
|
||||
let v = base.slice(), adv = CAP - 1, prevAt = null, back = 0, jump = 0, held = 0
|
||||
const was = new Map() // a finished column's value, by its number
|
||||
for (let t = 0; t < 4 * 30; t++) {
|
||||
const now = adv + ((t % 4) + 1) / 4
|
||||
const { cols: seen, edge, last } = columnsAt(v, adv, now, CAP, cols)
|
||||
check(edge > 0 && edge <= 1, `edge in (0, 1] (${cols} cols)`, edge)
|
||||
check(seen.length >= cols - 2 && seen.length <= cols + 1, `the box is filled, give or take the sliver (${cols} cols)`, seen.length)
|
||||
const at = last + edge // how far the graph has slid, in columns
|
||||
if (prevAt !== null) { if (at < prevAt - 1e-9) back++; if (at > prevAt + 1 + 1e-9) jump++; if (Math.abs(at - prevAt) < 1e-9) held++ }
|
||||
prevAt = at
|
||||
seen.forEach((x, i) => { const k = last - (seen.length - 1 - i); if (k < last - 1) { if (was.has(k) && was.get(k) !== x) fails.push(`column ${k} changed after it was finished`); was.set(k, x) } })
|
||||
if ((t % 4) === 3) { v.push(10); v.shift(); adv++ }
|
||||
}
|
||||
check(back === 0 && jump === 0, `the graph only slides left, less than a column a tick (${cols} cols)`, { back, jump })
|
||||
check(held < 4 * 30 / 4, `the graph moves on almost every tick, not once a point (${cols} cols)`, held)
|
||||
}
|
||||
// a point per tick (stride == rate) is the old layout exactly
|
||||
check(JSON.stringify(columnsAt(base, CAP - 1, CAP, CAP, COLS).cols.slice(-COLS)) === JSON.stringify(columnsOf(base, CAP - 1, CAP, COLS)), 'columnsOf is columnsAt at a whole point')
|
||||
|
||||
if (fails.length) { console.error('graphcols: FAIL\n ' + fails.join('\n ')); process.exit(1) }
|
||||
console.log('graphcols: ok')
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// node scripts/test-sockets.mjs — src/sockets.ts: each CPU's threads, counted under it.
|
||||
import { buildSync } from 'esbuild'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const out = join(mkdtempSync(join(tmpdir(), 'sockets-')), 'sockets.mjs')
|
||||
buildSync({ entryPoints: [new URL('../src/sockets.ts', import.meta.url).pathname], outfile: out, format: 'esm', logLevel: 'error' })
|
||||
const { socketThreads } = await import(pathToFileURL(out).href)
|
||||
|
||||
const fails = []
|
||||
const eq = (got, want, what) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||||
const range = (a, b) => Array.from({ length: b - a }, (_, i) => a + i)
|
||||
|
||||
// nexus: 2 × E5-2680 v2, as /sys/devices/system/cpu/cpu*/topology/physical_package_id reads
|
||||
const nexus = [...Array(10).fill(0), ...Array(10).fill(1), ...Array(10).fill(0), ...Array(10).fill(1)]
|
||||
const got = socketThreads(nexus)
|
||||
eq(got.map((s) => s.pkg), [0, 1], 'two sockets, in order')
|
||||
eq(got[0].cpus, [...range(0, 10), ...range(20, 30)], "CPU 1's threads")
|
||||
eq(got[1].cpus, [...range(10, 20), ...range(30, 40)], "CPU 2's threads")
|
||||
eq(got.map((s) => s.cpus.length), [20, 20], 'twenty threads each')
|
||||
// every thread is under exactly one CPU
|
||||
eq(got.flatMap((s) => s.cpus).sort((a, b) => a - b), range(0, 40), 'every thread once')
|
||||
// a thread's number under its CPU runs 0…19 on both
|
||||
for (const s of got) eq(s.cpus.map((_, n) => n), range(0, 20), `CPU ${s.pkg + 1} counts from 0`)
|
||||
|
||||
// a board that numbers each socket's threads together, and one whose socket ids are not 0 and 1
|
||||
eq(socketThreads([0, 0, 0, 0, 1, 1, 1, 1]), [{ pkg: 0, cpus: [0, 1, 2, 3] }, { pkg: 1, cpus: [4, 5, 6, 7] }], 'contiguous numbering')
|
||||
eq(socketThreads([3, 1, 3, 1]), [{ pkg: 1, cpus: [1, 3] }, { pkg: 3, cpus: [0, 2] }], 'socket ids sorted')
|
||||
eq(socketThreads([]), [], 'nothing')
|
||||
|
||||
if (fails.length) { console.error('sockets: FAIL\n ' + fails.join('\n ')); process.exit(1) }
|
||||
console.log('sockets: ok')
|
||||
+11
-9
@@ -402,7 +402,7 @@ export default function Docker() {
|
||||
const rate = useRefreshMs()
|
||||
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, [])
|
||||
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
|
||||
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
|
||||
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: rate })
|
||||
const sensors = live.data?.c ?? {}
|
||||
const byStack = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase(); const out = new Map<string, DockerContainer[]>()
|
||||
@@ -414,8 +414,11 @@ export default function Docker() {
|
||||
const taken = useMemo(() => { const ports: Record<string, string> = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d])
|
||||
// What the registry last said about each image, so a container can be marked without
|
||||
// asking again. Only ever read from the cache here — checking is a deliberate act.
|
||||
// Re-read after a stack task too: an update changes this machine's side of the answer.
|
||||
const [upd, setUpd] = useState<MachineUpdates | null>(null)
|
||||
const readUpd = useCallback(() => { api.machineUpdates().then(setUpd).catch(() => {}) }, [])
|
||||
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
|
||||
const stackChanged = useCallback(() => { load(); readUpd() }, [load, readUpd])
|
||||
const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
|
||||
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
|
||||
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
||||
@@ -492,7 +495,7 @@ export default function Docker() {
|
||||
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
||||
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
||||
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
|
||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={stackChanged} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -522,7 +525,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
}
|
||||
const run = async (j: UpdateJob) => {
|
||||
if (!await askYes(`Run “${j.name}” now? It pulls ${j.stack}'s images${j.apply ? ' and deploys them, which restarts its containers' : ', without deploying them'}.`, 'Run now')) return
|
||||
try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs(); api.machineUpdates().then(onChecked).catch(() => {}) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const remove = async (j: UpdateJob) => {
|
||||
if (!await askYes(`Remove the schedule “${j.name}”? Nothing is updated or rolled back — the schedule simply stops.`, 'Remove')) return
|
||||
@@ -535,7 +538,6 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
// machine at once.
|
||||
const [queue, setQueue] = useState<string[]>([])
|
||||
const [running, setRunning] = useState<{ stack: string; id: string } | null>(null)
|
||||
const [done, setDone] = useState<string[]>([])
|
||||
const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd])
|
||||
const startNext = useCallback(async (rest: string[]) => {
|
||||
const next = rest[0]
|
||||
@@ -548,7 +550,7 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
const uniq = [...new Set(names)]
|
||||
if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return }
|
||||
if (!await askYes(`Update ${what}?\n\nThis pulls the newer image${uniq.length > 1 ? 's' : ''} and recreates ${uniq.length === 1 ? `the ${uniq[0]} stack` : `these stacks, one at a time: ${uniq.join(', ')}`}. Their containers restart.`, 'Update')) return
|
||||
setDone([]); startNext(uniq)
|
||||
startNext(uniq)
|
||||
}
|
||||
const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))]
|
||||
return (
|
||||
@@ -567,9 +569,9 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
{imgs.filter((i) => i.state !== 'current').map((i) => (
|
||||
<div className="row" key={i.image}>
|
||||
<span className="k mono small">{i.image}</span>
|
||||
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{done.some((d) => stacksFor(i.image).includes(d)) ? <span className="good">updated just now</span> : i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
|
||||
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
|
||||
<span className="muted small">{stacksFor(i.image).join(' ')}</span>
|
||||
{i.state === 'update' && !done.some((d) => stacksFor(i.image).includes(d)) && (
|
||||
{i.state === 'update' && (
|
||||
<Btn className="btn" disabled={!!running || stacksFor(i.image).length === 0} title={stacksFor(i.image).length ? `pull it and recreate ${stacksFor(i.image).join(', ')}` : 'no stack here uses this image'}
|
||||
onClick={() => updateStacks(stacksFor(i.image), `${i.image}`)}>Update</Btn>
|
||||
)}
|
||||
@@ -581,8 +583,8 @@ function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="sub-head"><span className="dname">Updating {running.stack}</span><span className="spacer" /><span className="muted small">{queue.length > 0 ? `${queue.length} to follow` : 'last one'}</span></div>
|
||||
<TaskOutput id={running.id} onDone={(t) => {
|
||||
if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack])
|
||||
else toast(`${running.stack}: update failed`, 'err')
|
||||
if (t.exit_code !== 0 || t.error) toast(`${running.stack}: update failed`, 'err')
|
||||
api.machineUpdates().then(onChecked).catch(() => {}) // an image now held drops off the list
|
||||
startNext(queue)
|
||||
}} />
|
||||
</div>
|
||||
|
||||
+63
-8
@@ -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 { fmtBytes } from './fmt'
|
||||
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 isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
||||
const isMedia = (e: FileEntry) => !!e.preview || isImage(e.name)
|
||||
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
||||
const MAX_EDIT = 2 * 1024 * 1024
|
||||
|
||||
@@ -198,7 +199,9 @@ function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: bool
|
||||
}
|
||||
|
||||
// ---- editor / viewer ----
|
||||
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
|
||||
// steps: the files ← and → go through, in the list's order (only the pictures, videos and
|
||||
// sound when one of those is open).
|
||||
function Viewer({ path, sudo, steps, onStep, onClose, onChanged }: { path: string; sudo: boolean; steps: string[]; onStep: (p: string) => void; onClose: () => void; onChanged: () => void }) {
|
||||
const [info, setInfo] = useState<FileInfo | null>(null)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [orig, setOrig] = useState('')
|
||||
@@ -251,10 +254,49 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
}, [dirty, name])
|
||||
const dl = api.fileDownloadURL(path, sudo)
|
||||
|
||||
// Previous and next: the buttons, ← and → (unless typing, or the player has the focus and
|
||||
// seeks with them), and a sideways swipe on a picture.
|
||||
const at = steps.indexOf(path)
|
||||
const prev = at > 0 ? steps[at - 1] : null
|
||||
const next = at >= 0 && at < steps.length - 1 ? steps[at + 1] : null
|
||||
const step = useCallback(async (to: string | null) => {
|
||||
if (!to) return
|
||||
if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return
|
||||
setUnsaved(''); onStep(to)
|
||||
}, [dirty, onStep])
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t?.closest('input, textarea, select, [contenteditable], .vp')) return
|
||||
if (e.key === 'ArrowLeft' && prev) { e.preventDefault(); step(prev) }
|
||||
else if (e.key === 'ArrowRight' && next) { e.preventDefault(); step(next) }
|
||||
}
|
||||
window.addEventListener('keydown', h)
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [prev, next, step])
|
||||
// The next and previous pictures load now, so stepping onto them is instant.
|
||||
useEffect(() => {
|
||||
for (const p of [prev, next]) if (p && isImage(p)) new Image().src = api.fileReadURL(p, sudo)
|
||||
}, [prev, next, sudo])
|
||||
const swipe = useRef<{ x: number; y: number } | null>(null)
|
||||
const swipeEnd = (e: PointerEvent) => {
|
||||
const s = swipe.current
|
||||
swipe.current = null
|
||||
if (!s) return
|
||||
const dx = e.clientX - s.x, dy = e.clientY - s.y
|
||||
if (Math.abs(dx) > 50 && Math.abs(dx) > 1.5 * Math.abs(dy)) step(dx < 0 ? next : prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="viewer">
|
||||
<div className="viewer-bar">
|
||||
<button className="btn" onClick={close}>‹ Back</button>
|
||||
{at >= 0 && steps.length > 1 && <>
|
||||
<button className="btn step" disabled={!prev} title="previous (←)" onClick={() => step(prev)}>←</button>
|
||||
<span className="muted num">{at + 1}/{steps.length}</span>
|
||||
<button className="btn step" disabled={!next} title="next (→)" onClick={() => step(next)}>→</button>
|
||||
</>}
|
||||
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
||||
<span className="spacer" />
|
||||
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
||||
@@ -266,11 +308,17 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
{info && text !== null && (
|
||||
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
||||
)}
|
||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
||||
{/* Video: the browser's own decoder first, and a re-encode on the machine when that fails. Audio as it is. */}
|
||||
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />}
|
||||
{info && text === null && isImage(name) && (
|
||||
<div className="preview pic" onPointerDown={(e) => { if (e.pointerType !== 'mouse' && e.isPrimary) swipe.current = { x: e.clientX, y: e.clientY } }} onPointerUp={swipeEnd} onPointerCancel={() => { swipe.current = null }}>
|
||||
<img src={api.fileReadURL(path, sudo)} alt={name} draggable={false} />
|
||||
{prev && <button className="step-edge prev" title="previous (←)" onClick={() => step(prev)}>‹</button>}
|
||||
{next && <button className="step-edge next" title="next (→)" onClick={() => step(next)}>›</button>}
|
||||
</div>
|
||||
)}
|
||||
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
||||
{info && text === null && !isImage(name) && info.preview === 'video' && <Player key={path} path={path} sudo={sudo} name={name} />}
|
||||
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
||||
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls 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 && (
|
||||
<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 [listEl, setListEl] = useState<HTMLDivElement | null>(null)
|
||||
const near = useMemo(() => (listEl && previews ? new Near(listEl) : null), [listEl, previews])
|
||||
// Video previews are made on the Transcode device (Settings), wherever it is.
|
||||
const [tc, setTc] = useState<string>('')
|
||||
useEffect(() => { api.transcodeSelected().then((t) => setTc(t.selected ? `videos on ${t.host}'s ${t.name} (Settings → Transcode device), images on this machine's CPU` : '')).catch(() => {}) }, [])
|
||||
const readThumbs = useCallback((refresh = false) => { api.thumbStatus(sudo, refresh).then(setThumbs).catch((e) => setThumbs({ ok: false, engine: '', summary: '', gpus: [], error: e instanceof Error ? e.message : 'failed', user: '', cache: { path: '', entries: 0, bytes: 0 } })) }, [sudo])
|
||||
useEffect(() => { try { localStorage.setItem(PREVIEWS_KEY, previews ? '1' : '0') } catch { /* private mode */ } }, [previews])
|
||||
useEffect(() => { if (previews) readThumbs(); else setThumbs(null) }, [previews, readThumbs])
|
||||
@@ -396,7 +447,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
||||
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 (
|
||||
<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">
|
||||
<button className={'btn' + (sudo ? ' on' : '')} title="browse as root" onClick={() => setSudo(!sudo)}>{sudo ? 'root' : 'sudo'}</button>
|
||||
<button className={'btn' + (hidden ? ' on' : '')} title="show hidden files" onClick={() => setHidden(!hidden)}>.hidden</button>
|
||||
<button className={'btn' + (previews ? ' on' : '')} title={'thumbnails for images and videos' + (thumbs ? ` — ${thumbs.summary}` + (thumbs.cache.entries ? ` · ${thumbs.cache.entries} kept (${fmtBytes(thumbs.cache.bytes)})` : '') : '')} onClick={() => setPreviews(!previews)}>previews</button>
|
||||
<button className={'btn' + (previews ? ' on' : '')} title={'thumbnails for images and videos' + (thumbs ? ` — ${tc || thumbs.summary}` + (thumbs.cache.entries ? ` · ${thumbs.cache.entries} kept (${fmtBytes(thumbs.cache.bytes)})` : '') : '')} onClick={() => setPreviews(!previews)}>previews</button>
|
||||
<select className="btn" value={sort} onChange={(e) => setSort(e.target.value as Sort)}><option value="name">name</option><option value="size">size</option><option value="mtime">modified</option></select>
|
||||
<input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn" onClick={mkdir}>+ folder</button>
|
||||
|
||||
+2
-2
@@ -37,8 +37,8 @@ export default function Gallery() {
|
||||
const rate = useRefreshMs()
|
||||
const [find, setFind] = useState('')
|
||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
// Values only, at most once a second: the catalog already holds everything that doesn't change.
|
||||
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: Math.max(1000, rate) })
|
||||
// Values only, at the rate: the catalog already holds everything that doesn't change.
|
||||
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: rate })
|
||||
const now = Date.now()
|
||||
const sensors = useMemo(() => {
|
||||
const v = live.data?.[0]
|
||||
|
||||
+13
-4
@@ -158,6 +158,10 @@ export default function Hardware() {
|
||||
const capacity = drives.reduce((n, d) => n + d.size, 0)
|
||||
const threads = cpu.threads ?? 0
|
||||
const cores = cpu.cores ?? 0
|
||||
// a two-socket board is two processors, not one with twice the cores: count and name each
|
||||
const sockets = Math.max(1, cpu.sockets ?? 1)
|
||||
const multi = sockets > 1
|
||||
const per = (n: number) => Math.round(n / sockets)
|
||||
|
||||
return (
|
||||
<div className="dash">
|
||||
@@ -171,8 +175,11 @@ export default function Hardware() {
|
||||
<div className="kpis">
|
||||
<div className="tile">
|
||||
<div className="label">Processor</div>
|
||||
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{cpu.model}</div>
|
||||
<div className="sub">{[cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : ''].filter(Boolean).join(' · ')}</div>
|
||||
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > (multi ? 16 : 20) ? { fontSize: 16 } : undefined}>{multi ? `${sockets} × ${cpu.model}` : cpu.model}</div>
|
||||
<div className="sub">{(multi
|
||||
? [`${sockets} CPUs`, cores ? `${per(cores)} cores${threads && threads !== cores ? ` / ${per(threads)} threads` : ''} each` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : '']
|
||||
: [cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : '']
|
||||
).filter(Boolean).join(' · ')}</div>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<div className="label">Memory</div>
|
||||
@@ -199,9 +206,11 @@ export default function Hardware() {
|
||||
<div className="props">
|
||||
<P k="Model" v={cpu.model} />
|
||||
<P k="Vendor" v={cpu.vendor} />
|
||||
<P k="Cores" v={cores ? `${cores}${cpu.threads_per_core && cpu.threads_per_core > 1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} />
|
||||
{multi && <P k="CPUs" v={`${sockets}`} />}
|
||||
<P k="Cores" v={cores ? `${multi ? `${per(cores)} per CPU · ${cores} in all` : cores}${cpu.threads_per_core && cpu.threads_per_core > 1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} />
|
||||
{multi && threads > 0 && <P k="Threads" v={`${per(threads)} per CPU · ${threads} in all`} />}
|
||||
<P k="Clock" v={cpu.min_mhz && cpu.max_mhz ? `${ghz(cpu.min_mhz)} – ${ghz(cpu.max_mhz)}` : ghz(cpu.max_mhz)} />
|
||||
<P k="Socket" v={(cpu.socket_names ?? []).join(', ')} />
|
||||
<P k={multi ? 'Sockets' : 'Socket'} v={(cpu.socket_names ?? []).join(', ')} />
|
||||
<P k="Arch" v={cpu.arch} />
|
||||
<P k="Family" v={[cpu.family && `family ${cpu.family}`, cpu.model_id && `model ${cpu.model_id}`, cpu.stepping && `stepping ${cpu.stepping}`].filter(Boolean).join(' · ')} />
|
||||
<P k="Microcode" v={cpu.microcode} mono />
|
||||
|
||||
-187
@@ -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
@@ -81,7 +81,7 @@ export default function Network() {
|
||||
// While a test is running the state is polled hard, because the needle should move; at rest
|
||||
// the page is a read and asks again only now and then.
|
||||
useEffect(() => {
|
||||
const every = running ? Math.max(400, Math.min(rate, 1000)) : Math.max(5000, rate)
|
||||
const every = running ? rate : Math.max(5000, rate)
|
||||
const t = setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (running) api.speedtest().then((r) => setD((old) => (old ? { ...old, run: r } : old))).catch(() => {})
|
||||
@@ -93,7 +93,7 @@ export default function Network() {
|
||||
const phase = run?.phase
|
||||
useEffect(() => { if (phase === 'done') load() }, [phase, load])
|
||||
|
||||
const live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: Math.max(1000, rate) })
|
||||
const live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: rate })
|
||||
const sens = live.data?.n ?? {}
|
||||
const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value
|
||||
|
||||
|
||||
+389
-118
@@ -1,145 +1,416 @@
|
||||
// A video in the Files viewer. First the file as it is, handed to the browser's own
|
||||
// decoder; when that fails — Safari and an MKV, HEVC without a hardware decoder — a
|
||||
// picker: codec and size, and the machine re-encodes it to HLS on its own GPU (or CPU),
|
||||
// which plays natively on Safari and through hls.js everywhere else. The same picker is
|
||||
// one tap away even when the file plays, for a 36 Mbps capture over a slow link.
|
||||
// A video in the Files viewer, YouTube's way: Auto, which reads the connection and picks the
|
||||
// quality (hls.js), any quality by hand, or Original — the file as it is, for a browser that
|
||||
// decodes it. The qualities are made on the fly, from wherever the viewer seeks to, on the device
|
||||
// the Transcode device setting names (Settings), which may be on another machine than the file.
|
||||
// A 360° video is drawn as the view from inside it (vr.ts): drag, pinch or scroll, and on a
|
||||
// phone the motion button. The same player as Ham Share's.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, apiPath, PlayCaps, PlayStatus, PlayStream } from './api'
|
||||
import { Btn, Spinner } from './ui'
|
||||
import Hls from 'hls.js'
|
||||
import { api, nodeAPI, type PlayOpen, type PlayStatus } from './api'
|
||||
import { canMotion, Sphere } from './vr'
|
||||
|
||||
type Mode = 'native' | 'pick' | 'hls'
|
||||
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 } }
|
||||
type Choice = 'auto' | 'original' | number // a number is a width: the rung's name
|
||||
|
||||
const fmtMbps = (bps?: number) => (bps ? `${(bps / 1e6).toFixed(bps >= 10e6 ? 0 : 1)} Mbps` : '')
|
||||
const describe = (s: PlayStream) => {
|
||||
const v = s.source.video
|
||||
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 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`
|
||||
return `${src} → ${out} on ${s.engine}`
|
||||
// The bandwidth hls.js measured last time, so the next video starts near the right quality
|
||||
// rather than at the bottom.
|
||||
const BW_KEY = 'hamlab.play.bw'
|
||||
const savedBW = () => { try { const n = Number(localStorage.getItem(BW_KEY)); return n > 0 ? n : 0 } catch { return 0 } }
|
||||
const saveBW = (n: number) => { try { if (n > 0) localStorage.setItem(BW_KEY, String(Math.round(n))) } catch { /* private mode */ } }
|
||||
|
||||
// hls.js reads the connection from how long each piece takes. Here a piece can be slow to
|
||||
// start because the machine is still making it (a few seconds after a seek or a change of
|
||||
// quality, at 8K), which says nothing about the connection; counted, it drags Auto down to the
|
||||
// bottom, and every drop starts the making over again. So: the wait before the first byte is
|
||||
// not counted against the connection, and hls.js doesn't give up on a piece before its first
|
||||
// byte comes. Once bytes flow, its usual rules apply.
|
||||
type AbrLike = { new(hls: Hls): object }
|
||||
const Base = Hls.DefaultConfig.abrController as unknown as AbrLike
|
||||
type Stats = { loading: { start: number; first: number } }
|
||||
type Proto = { initEstimator: () => { sampleTTFB: (ms: number) => void }; onFragBuffered: (ev: unknown, d: unknown) => void }
|
||||
const proto = Base.prototype as unknown as Proto
|
||||
class PatientAbr extends Base {
|
||||
constructor(hls: Hls) {
|
||||
super(hls)
|
||||
const self = this as unknown as { _abandonRulesCheck: (l?: unknown) => void; fragCurrent: { stats: Stats } | null }
|
||||
const orig = self._abandonRulesCheck
|
||||
self._abandonRulesCheck = (l?: unknown) => {
|
||||
const f = self.fragCurrent
|
||||
if (f && !f.stats.loading.first) return
|
||||
orig(l)
|
||||
}
|
||||
}
|
||||
initEstimator() {
|
||||
const e = proto.initEstimator.call(this)
|
||||
const sample = e.sampleTTFB.bind(e)
|
||||
e.sampleTTFB = (ms: number) => sample(Math.min(ms, 300))
|
||||
return e
|
||||
}
|
||||
onFragBuffered(ev: unknown, d: { frag: { stats: Stats }; part?: { stats: Stats & { loaded: number } } | null }) {
|
||||
const st = d.part?.stats.loaded ? d.part.stats : d.frag.stats
|
||||
const start = st.loading.start
|
||||
if (st.loading.first > start) st.loading.start = st.loading.first
|
||||
try { proto.onFragBuffered.call(this, ev, d) } finally { st.loading.start = start }
|
||||
}
|
||||
}
|
||||
|
||||
export default function Player({ path, sudo, name }: { path: string; sudo: boolean; name: string }) {
|
||||
const [mode, setMode] = useState<Mode>('native')
|
||||
const [why, setWhy] = useState('') // what brought the picker up
|
||||
const [caps, setCaps] = useState<PlayCaps | null>(null)
|
||||
const [codec, setCodec] = useState(() => recall(CODEC_KEY, 'h264'))
|
||||
const [quality, setQuality] = useState(() => recall(QUALITY_KEY, '1080p'))
|
||||
const [stream, setStream] = useState<{ s: PlayStream; url: string } | null>(null)
|
||||
const [status, setStatus] = useState<PlayStatus | null>(null)
|
||||
// Whether the browser lets a page set the volume level (iOS doesn't: only mute).
|
||||
const canLevel = (() => { try { const a = document.createElement('audio'); a.volume = 0.5; return a.volume === 0.5 } catch { return false } })()
|
||||
|
||||
const mbps = (b: number) => `${b >= 10e6 ? Math.round(b / 1e6) : (b / 1e6).toFixed(1)} Mb/s`
|
||||
const clock = (sec: number) => { const t = Math.max(0, Math.floor(sec)), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), x = t % 60; return `${h ? `${h}:${String(m).padStart(2, '0')}` : m}:${String(x).padStart(2, '0')}` }
|
||||
const size = (w?: number, h?: number, vr?: boolean) => {
|
||||
if (!w || !h) return ''
|
||||
if (vr) return `${w >= 7680 ? '8K' : w >= 5760 ? '5.7K' : w >= 3840 ? '4K' : w >= 3072 ? '3K' : `${w}×${h}`} 360°`
|
||||
const s = Math.min(w, h)
|
||||
return s >= 2160 ? '4K' : s >= 1440 ? '1440p' : s >= 1080 ? '1080p' : s >= 720 ? '720p' : `${w}×${h}`
|
||||
}
|
||||
|
||||
const I = ({ d, size: sz = 20, fill }: { d: string; size?: number; fill?: boolean }) => (
|
||||
<svg width={sz} height={sz} viewBox="0 0 24 24" fill={fill ? 'currentColor' : 'none'} stroke={fill ? 'none' : 'currentColor'} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
|
||||
)
|
||||
const PLAY = 'M7 4.5v15l12.5-7.5z', PAUSE = 'M8 5v14M16 5v14', CHECK = 'M5 12.5l4.5 4.5L19 7.5', TUNE = 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4'
|
||||
const FULL = 'M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5', SMALL = 'M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5', PHONE = 'M8 3h8a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM3 9a9 9 0 0 0 0 6M21 9a9 9 0 0 1 0 6'
|
||||
|
||||
// The speaker: waves by the level, a cross when muted.
|
||||
const Speaker = ({ off, low }: { off?: boolean; low?: boolean }) => (
|
||||
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" />
|
||||
{off ? <path d="M16 9.5l5 5M21 9.5l-5 5" /> : <path d={low ? 'M15.5 9.5a3.5 3.5 0 0 1 0 5' : 'M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10'} />}
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Five seconds back or on: a circling arrow with the 5 inside.
|
||||
const Skip = ({ back }: { back?: boolean }) => (
|
||||
<svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d={back ? 'M12 5a7 7 0 1 1-7 7M12 5l2.5-2.5M12 5l2.5 2.5' : 'M12 5a7 7 0 1 0 7 7M12 5L9.5 2.5M12 5l-2.5 2.5'} />
|
||||
<text x="12" y="15" textAnchor="middle" fontSize="7.5" fontWeight="700" fill="currentColor" stroke="none" fontFamily="system-ui, sans-serif">5</text>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Player({ path, sudo }: { path: string; sudo: boolean; name: string }) {
|
||||
const [open, setOpen] = useState<PlayOpen | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
const [choice, setChoice] = useState<Choice>('auto')
|
||||
const [showing, setShowing] = useState(0) // the width playing now
|
||||
const [status, setStatus] = useState<PlayStatus | null>(null)
|
||||
const [paused, setPaused] = useState(true)
|
||||
const [waiting, setWaiting] = useState(true)
|
||||
const [time, setTime] = useState(0)
|
||||
const [scrub, setScrub] = useState<number | null>(null)
|
||||
const [buffered, setBuffered] = useState(0)
|
||||
const [menu, setMenu] = useState(false)
|
||||
const [chrome, setChrome] = useState(true)
|
||||
const [motion, setMotion] = useState(false)
|
||||
const [full, setFull] = useState(false) // the browser's full screen, or (an iPhone) the window
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const video = useRef<HTMLVideoElement>(null)
|
||||
const streamRef = useRef(stream)
|
||||
streamRef.current = stream
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
const hls = useRef<Hls | null>(null)
|
||||
const sphere = useRef<Sphere | null>(null)
|
||||
const hideTimer = useRef<number>()
|
||||
const resume = useRef<{ at: number; play: boolean }>({ at: 0, play: true })
|
||||
const openRef = useRef(open)
|
||||
openRef.current = open
|
||||
const menuRef = useRef(menu)
|
||||
menuRef.current = menu
|
||||
|
||||
useEffect(() => { setMode('native'); setWhy(''); setStream(null); setStatus(null); setErr('') }, [path, sudo])
|
||||
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])
|
||||
// Open the play; let it go when this player does, and when the page goes, by a beacon.
|
||||
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)
|
||||
return () => window.removeEventListener('pagehide', h)
|
||||
}, [])
|
||||
|
||||
const start = async () => {
|
||||
setErr('')
|
||||
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') }
|
||||
}
|
||||
const info = open?.play.info
|
||||
const vr = !!info?.video?.spherical
|
||||
|
||||
// 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(() => {
|
||||
const v = video.current
|
||||
if (mode !== 'hls' || !stream || !v) return
|
||||
const url = apiPath(stream.url)
|
||||
let hls: { destroy: () => void } | null = null
|
||||
let gone = false
|
||||
;(async () => {
|
||||
if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
v.src = url
|
||||
} else {
|
||||
const { default: Hls } = await import('hls.js')
|
||||
if (gone) return
|
||||
if (!Hls.isSupported()) { setErr('this browser has neither HLS nor MediaSource — nothing can play here'); return }
|
||||
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)
|
||||
if (!open || !v) return
|
||||
const { at, play } = resume.current
|
||||
const start = () => { if (at) v.currentTime = at; if (play) v.play().catch(() => setPaused(true)) }
|
||||
if (choice === 'original') {
|
||||
v.src = api.fileReadURL(path, sudo)
|
||||
v.addEventListener('loadedmetadata', start, { once: true })
|
||||
setShowing(info?.video?.width ?? 0)
|
||||
const bad = () => {
|
||||
resume.current = { at: v.currentTime || at, play: true }
|
||||
setNote('This browser can’t play the file as it is. Back on Auto.')
|
||||
setChoice('auto')
|
||||
}
|
||||
v.play().catch(() => { /* needs a tap on some phones */ })
|
||||
})()
|
||||
return () => { gone = true; hls?.destroy() }
|
||||
}, [mode, stream])
|
||||
v.addEventListener('error', bad, { once: true })
|
||||
return () => { v.removeEventListener('error', bad); v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
|
||||
}
|
||||
const master = nodeAPI(open.node, open.master)
|
||||
if (Hls.isSupported()) {
|
||||
const h = new Hls({
|
||||
abrController: PatientAbr as unknown as typeof Hls.DefaultConfig.abrController,
|
||||
abrEwmaDefaultEstimate: savedBW() || 5_000_000,
|
||||
startPosition: at || -1,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
backBufferLength: 60,
|
||||
// A piece being made after a seek can take a while; the machine waits up to 45 s.
|
||||
fragLoadPolicy: { default: { maxTimeToFirstByteMs: 50_000, maxLoadTimeMs: 120_000, timeoutRetry: { maxNumRetry: 2, retryDelayMs: 0, maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 4, retryDelayMs: 1000, maxRetryDelayMs: 8000 } } },
|
||||
})
|
||||
hls.current = h
|
||||
h.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (typeof choice === 'number') {
|
||||
const i = h.levels.findIndex((l) => l.width === choice)
|
||||
if (i >= 0) { h.startLevel = i; h.currentLevel = i }
|
||||
}
|
||||
if (play) v.play().catch(() => setPaused(true))
|
||||
})
|
||||
h.on(Hls.Events.LEVEL_SWITCHED, (_, d) => setShowing(h.levels[d.level]?.width ?? 0))
|
||||
h.on(Hls.Events.FRAG_BUFFERED, () => saveBW(h.bandwidthEstimate))
|
||||
let recovered = 0
|
||||
h.on(Hls.Events.ERROR, (_, d) => {
|
||||
if (!d.fatal) return
|
||||
if (d.type === Hls.ErrorTypes.MEDIA_ERROR && recovered++ < 3) { h.recoverMediaError(); return }
|
||||
if (d.type === Hls.ErrorTypes.NETWORK_ERROR && recovered++ < 3) { h.startLoad(v.currentTime); return }
|
||||
setErr(`The video stopped loading (${d.details}).`)
|
||||
})
|
||||
h.loadSource(master)
|
||||
h.attachMedia(v)
|
||||
return () => { saveBW(h.bandwidthEstimate); h.destroy(); hls.current = null }
|
||||
}
|
||||
// Safari without Media Source (an older iPhone): its own HLS, Auto only.
|
||||
v.src = master
|
||||
v.addEventListener('loadedmetadata', start, { once: true })
|
||||
return () => { v.removeEventListener('loadedmetadata', start); v.removeAttribute('src'); v.load() }
|
||||
}, [open, choice === 'original' ? 'o' : 'h']) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// follow the re-encode while it runs
|
||||
// A hand-picked quality, or back to Auto, while hls.js is already playing.
|
||||
useEffect(() => {
|
||||
if (mode !== 'hls' || !stream) return
|
||||
let stopPolling = false
|
||||
const tick = async () => { try { const st = await api.playStatus(stream.s.id); if (!stopPolling) { setStatus(st); if (st.running) setTimeout(tick, 2000) } } catch { /* ended */ } }
|
||||
tick()
|
||||
return () => { stopPolling = true }
|
||||
}, [mode, stream])
|
||||
const h = hls.current
|
||||
if (!h || choice === 'original' || !h.levels.length) return
|
||||
if (choice === 'auto') { h.currentLevel = -1; return }
|
||||
const i = h.levels.findIndex((l) => l.width === choice)
|
||||
if (i >= 0) h.currentLevel = i
|
||||
}, [choice])
|
||||
|
||||
const pick = (reason: string) => { setWhy(reason); setMode('pick') }
|
||||
const codecs = caps?.codecs ?? []
|
||||
// What the machine is doing, while the menu is open.
|
||||
useEffect(() => {
|
||||
if (!menu || !open) return
|
||||
let live = true
|
||||
const tick = () => api.playStatus(open.node, open.play.id).then((s) => { if (live) setStatus(s) }).catch(() => {})
|
||||
tick()
|
||||
const t = window.setInterval(tick, 2000)
|
||||
return () => { live = false; window.clearInterval(t) }
|
||||
}, [menu, open])
|
||||
|
||||
// Controls fade while it plays and nobody touches anything.
|
||||
const poke = useCallback(() => {
|
||||
setChrome(true)
|
||||
window.clearTimeout(hideTimer.current)
|
||||
hideTimer.current = window.setTimeout(() => { if (!video.current?.paused && !menuRef.current) setChrome(false) }, 3000)
|
||||
}, [])
|
||||
useEffect(() => () => window.clearTimeout(hideTimer.current), [])
|
||||
useEffect(() => { if (!menu) poke() }, [menu, poke])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
if (v.paused) void v.play().catch(() => {})
|
||||
else v.pause()
|
||||
poke()
|
||||
}, [poke])
|
||||
// The seek bar: while it is dragged the ball follows the finger (the input event); the seek
|
||||
// itself is on release (the browser's own change event, which a click, a drag and the arrow
|
||||
// keys all end with). The ball is put where the seek went at once, so it waits there while
|
||||
// the piece is made rather than going back to where the film was.
|
||||
const seekBar = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
const el = seekBar.current
|
||||
if (!el) return
|
||||
const commit = () => {
|
||||
const x = Number(el.value), v = video.current
|
||||
if (v && Math.abs(v.currentTime - x) > 0.05) v.currentTime = x
|
||||
setTime(x)
|
||||
setScrub(null)
|
||||
}
|
||||
el.addEventListener('change', commit)
|
||||
return () => el.removeEventListener('change', commit)
|
||||
})
|
||||
// Volume: a level and mute, remembered for the next video. iOS lets a page mute but not set
|
||||
// the level (it reads back as 1), so there the slider isn't shown.
|
||||
const [vol, setVol] = useState(() => { try { const v = JSON.parse(localStorage.getItem('hamlab.play.volume') ?? ''); return { level: Math.min(1, Math.max(0, Number(v.level) || 0)), muted: !!v.muted } } catch { return { level: 1, muted: false } } })
|
||||
useEffect(() => {
|
||||
const v = video.current
|
||||
if (v) { v.volume = vol.level; v.muted = vol.muted || vol.level === 0 }
|
||||
try { localStorage.setItem('hamlab.play.volume', JSON.stringify(vol)) } catch { /* private mode */ }
|
||||
}, [vol, open])
|
||||
const volBy = useCallback((d: number) => setVol((x) => ({ level: Math.round(Math.min(1, Math.max(0, x.level + d)) * 20) / 20, muted: false })), [])
|
||||
const muteToggle = useCallback(() => setVol((x) => (x.muted || x.level === 0 ? { level: x.level || 0.5, muted: false } : { ...x, muted: true })), [])
|
||||
|
||||
const seekBy = useCallback((d: number) => {
|
||||
const v = video.current
|
||||
if (!v) return
|
||||
const to = Math.max(0, Math.min(info?.duration ?? v.duration, v.currentTime + d))
|
||||
v.currentTime = to
|
||||
setTime(to) // the ball goes there at once, made yet or not
|
||||
poke()
|
||||
}, [info, poke])
|
||||
// Full screen: the browser's where it has one for an element, else (an iPhone) the window.
|
||||
const fullscreen = useCallback(() => {
|
||||
const el = box.current as (HTMLDivElement & { webkitRequestFullscreen?: () => void }) | null
|
||||
if (!el) return
|
||||
if (document.fullscreenElement) { void document.exitFullscreen(); return }
|
||||
if (el.requestFullscreen) { void el.requestFullscreen().catch(() => setFull((f) => !f)); return }
|
||||
if (el.webkitRequestFullscreen) { el.webkitRequestFullscreen(); return }
|
||||
setFull((f) => !f)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const f = () => setFull(!!document.fullscreenElement)
|
||||
document.addEventListener('fullscreenchange', f)
|
||||
return () => document.removeEventListener('fullscreenchange', f)
|
||||
}, [])
|
||||
|
||||
// Keys work while the player has the focus (a click on it gives it).
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' || e.key === 'k') { e.preventDefault(); toggle() }
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'j') { e.preventDefault(); seekBy(e.key === 'j' ? -10 : -5) }
|
||||
else if (e.key === 'ArrowRight' || e.key === 'l') { e.preventDefault(); seekBy(e.key === 'l' ? 10 : 5) }
|
||||
else if (e.key === 'f') fullscreen()
|
||||
else if (e.key === 'm') muteToggle()
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); volBy(0.1) }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); volBy(-0.1) }
|
||||
else if (e.key === 'Escape') { if (menu) setMenu(false); else if (full && !document.fullscreenElement) setFull(false) }
|
||||
}
|
||||
|
||||
// Looking around a 360° video: a drag turns the view, a pinch zooms, a tap shows or hides the
|
||||
// controls.
|
||||
const pointers = useRef(new Map<number, { x: number; y: number }>())
|
||||
const moved = useRef(0)
|
||||
const pinch = useRef(0)
|
||||
const onDown = (e: React.PointerEvent) => {
|
||||
(e.target as Element).setPointerCapture(e.pointerId)
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
moved.current = 0
|
||||
if (pointers.current.size === 2) { const [a, b] = [...pointers.current.values()]; pinch.current = Math.hypot(a.x - b.x, a.y - b.y) }
|
||||
}
|
||||
const onMove = (e: React.PointerEvent) => {
|
||||
const p = pointers.current.get(e.pointerId)
|
||||
if (!p) return
|
||||
const dx = e.clientX - p.x, dy = e.clientY - p.y
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
moved.current += Math.abs(dx) + Math.abs(dy)
|
||||
if (pointers.current.size === 2) {
|
||||
const [a, b] = [...pointers.current.values()]
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
if (pinch.current) sphere.current?.zoom(pinch.current / d)
|
||||
pinch.current = d
|
||||
} else sphere.current?.drag(dx, dy)
|
||||
}
|
||||
const onUp = (e: React.PointerEvent) => {
|
||||
pointers.current.delete(e.pointerId)
|
||||
if (pointers.current.size < 2) pinch.current = 0
|
||||
if (pointers.current.size === 0 && moved.current < 6) {
|
||||
if (e.pointerType === 'mouse') toggle()
|
||||
else if (chrome && !menu) setChrome(false)
|
||||
else poke()
|
||||
setMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const v = video.current
|
||||
const dur = info?.duration ?? 0
|
||||
const shown = scrub ?? time
|
||||
const rungs = open?.play.rungs ?? []
|
||||
const label = (w: number) => rungs.find((r) => r.width === w)?.label ?? (w && w === info?.video?.width ? 'Original' : '')
|
||||
const choiceLabel = choice === 'auto' ? `Auto${showing ? ` · ${label(showing)}` : ''}` : choice === 'original' ? 'Original' : label(choice)
|
||||
const tooBig = vr && sphere.current && (info?.video?.width ?? 0) > sphere.current.maxTexture
|
||||
const fileRate = info?.bit_rate || (info?.size && dur ? (info.size * 8) / dur : 0)
|
||||
const keep = () => { if (v) resume.current = { at: v.currentTime, play: !v.paused } }
|
||||
|
||||
return (
|
||||
<div className="player">
|
||||
{mode === 'native' && (
|
||||
<>
|
||||
<div className="play-bar"><span>as it is</span><span className="spacer" /><button className="btn" onClick={() => pick('')}>Re-encode…</button></div>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{mode === 'pick' && (
|
||||
<div className="play-pick notice">
|
||||
<div>{why || `Re-encode ${name} on this machine and play that instead.`}</div>
|
||||
{caps?.error && <div className="error" style={{ marginTop: 8 }}>{caps.error}</div>}
|
||||
{!caps && !err && <div className="muted small" style={{ marginTop: 8 }}><Spinner /> asking the machine what it can encode…</div>}
|
||||
{caps && !caps.error && (
|
||||
<div className="row">
|
||||
<label>codec
|
||||
<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>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>size
|
||||
<select value={quality} onChange={(e) => setQuality(e.target.value)} disabled={codec === 'copy'}>
|
||||
{caps.qualities.map((q) => <option key={q.id} value={q.id}>{q.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Btn className="btn on" disabled={!codecs.find((c) => c.id === codec)?.engine} onClick={start}>Play</Btn>
|
||||
<a className="btn" href={api.fileDownloadURL(path, sudo)} download={name}>Download instead</a>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||
<div ref={box} tabIndex={0} onKeyDown={onKey} className={`vp${chrome ? '' : ' bare'}${full ? ' max' : ''}`}
|
||||
onPointerMove={(e) => { if (e.pointerType === 'mouse') poke() }}
|
||||
onWheel={vr ? (e) => { if (!(e.target as Element).closest('.vp-menu')) sphere.current?.zoom(e.deltaY > 0 ? 1.08 : 1 / 1.08) } : undefined}>
|
||||
<video ref={video} className={vr ? 'vp-v vp-hidden' : 'vp-v'} playsInline preload="auto"
|
||||
onPlay={() => { setPaused(false); poke() }} onPause={() => { setPaused(true); setChrome(true) }}
|
||||
onWaiting={() => setWaiting(true)} onPlaying={() => setWaiting(false)} onCanPlay={() => setWaiting(false)} onSeeking={() => setWaiting(true)} onSeeked={() => setWaiting(false)}
|
||||
onTimeUpdate={(e) => {
|
||||
const el = e.currentTarget
|
||||
setTime(el.currentTime)
|
||||
resume.current = { at: el.currentTime, play: !el.paused }
|
||||
for (let i = 0; i < el.buffered.length; i++) if (el.buffered.start(i) <= el.currentTime + 0.5) setBuffered(el.buffered.end(i))
|
||||
}}
|
||||
onClick={vr ? undefined : () => { if (matchMedia('(pointer: coarse)').matches) { if (chrome) setChrome(false); else poke() } else toggle() }}
|
||||
onDoubleClick={vr ? undefined : fullscreen} />
|
||||
{vr && <canvas ref={canvas} className="vp-v vp-sphere" onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onDoubleClick={fullscreen} />}
|
||||
|
||||
{(waiting || !open) && !err && <div className="vp-spin" aria-label="Loading" />}
|
||||
{err && <div className="vp-err"><p>{err}</p></div>}
|
||||
{open && paused && !waiting && !err && <button className="vp-big" onClick={toggle} aria-label="Play"><I d={PLAY} size={36} fill /></button>}
|
||||
{note && <div className="vp-note" role="status" onClick={() => setNote('')}>{note}</div>}
|
||||
{vr && open && chrome && !menu && time < 8 && !err && <div className="vp-hint">Drag to look around · scroll or pinch to zoom</div>}
|
||||
|
||||
<div className="vp-bottom">
|
||||
<span className="vp-time above">{clock(shown)} / {clock(dur)}</span>
|
||||
<div className="vp-seek" style={{ ['--p' as string]: dur ? `${(shown / dur) * 100}%` : '0%', ['--b' as string]: dur ? `${(buffered / dur) * 100}%` : '0%' }}>
|
||||
<input ref={seekBar} type="range" min={0} max={dur || 1} step={0.1} value={shown} aria-label="Seek"
|
||||
onChange={(e) => { setScrub(Number(e.target.value)); poke() }} />
|
||||
</div>
|
||||
)}
|
||||
{mode === 'hls' && stream && (
|
||||
<>
|
||||
<div className="play-bar">
|
||||
<span title={describe(stream.s)}>{describe(stream.s)}</span>
|
||||
{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>}
|
||||
{status?.done && <span>· all of it is ready</span>}
|
||||
{status?.error && <span className="error">· stopped early: {status.error}</span>}
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={() => { stop(); setMode('pick'); setWhy('') }}>Change…</button>
|
||||
<div className="vp-row">
|
||||
<button className="vp-icon" onClick={toggle} aria-label={paused ? 'Play' : 'Pause'}>{paused ? <I d={PLAY} fill /> : <I d={PAUSE} />}</button>
|
||||
<button className="vp-icon" onClick={() => seekBy(-5)} aria-label="Back 5 seconds" title="Back 5 seconds"><Skip back /></button>
|
||||
<button className="vp-icon" onClick={() => seekBy(5)} aria-label="On 5 seconds" title="On 5 seconds"><Skip /></button>
|
||||
<span className="vp-vol">
|
||||
<button className="vp-icon" onClick={muteToggle} aria-label={vol.muted || vol.level === 0 ? 'Unmute' : 'Mute'} title={vol.muted || vol.level === 0 ? 'Unmute (m)' : 'Mute (m)'}><Speaker off={vol.muted || vol.level === 0} low={vol.level < 0.5} /></button>
|
||||
{canLevel && <input type="range" min={0} max={1} step={0.05} value={vol.muted ? 0 : vol.level} aria-label="Volume"
|
||||
style={{ ['--v' as string]: `${(vol.muted ? 0 : vol.level) * 100}%` }}
|
||||
onChange={(e) => { const level = Number(e.target.value); setVol({ level, muted: level === 0 }); poke() }} />}
|
||||
</span>
|
||||
<span className="vp-time inrow">{clock(shown)} / {clock(dur)}</span>
|
||||
<span className="spacer" />
|
||||
{vr && canMotion() && (
|
||||
<button className={`vp-icon${motion ? ' on' : ''}`} aria-label="Look around by moving the phone" aria-pressed={motion}
|
||||
onClick={async () => { const s = sphere.current; if (s) setMotion(await s.setGyro(!s.gyroOn)) }}><I d={PHONE} /></button>
|
||||
)}
|
||||
<button className="vp-q" onClick={() => { setMenu((m) => !m); poke() }} aria-label="Quality" aria-expanded={menu}><I d={TUNE} size={16} /><span>{choiceLabel}</span></button>
|
||||
<button className="vp-icon" onClick={fullscreen} aria-label={full ? 'Exit full screen' : 'Full screen'}><I d={full ? SMALL : FULL} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menu && open && (
|
||||
<div className="vp-menu" role="menu">
|
||||
<div className="vp-menu-h">Quality</div>
|
||||
<button role="menuitemradio" aria-checked={choice === 'auto'} onClick={() => { setChoice('auto'); setMenu(false) }}>
|
||||
<span className="ck">{choice === 'auto' && <I d={CHECK} size={16} />}</span><span>Auto</span><small>{choice === 'auto' && showing ? label(showing) : 'fits the connection'}</small>
|
||||
</button>
|
||||
<button role="menuitemradio" aria-checked={choice === 'original'} disabled={!!tooBig} onClick={() => { keep(); setChoice('original'); setMenu(false) }}>
|
||||
<span className="ck">{choice === 'original' && <I d={CHECK} size={16} />}</span><span>Original</span>
|
||||
<small>{tooBig ? 'too big for this device' : [size(info?.video?.width, info?.video?.height, vr), fileRate ? mbps(fileRate) : ''].filter(Boolean).join(' · ')}</small>
|
||||
</button>
|
||||
{rungs.map((r) => (
|
||||
<button key={r.name} role="menuitemradio" aria-checked={choice === r.width} onClick={() => { keep(); setChoice(r.width); setMenu(false) }}>
|
||||
<span className="ck">{choice === r.width && <I d={CHECK} size={16} />}</span><span>{r.label}</span><small>{mbps(r.bitrate)}</small>
|
||||
</button>
|
||||
))}
|
||||
<div className="vp-menu-f">
|
||||
Made on <b>{open.host}</b> · {status?.engine || open.engine}
|
||||
{status?.engine && <> · {status.paused ? 'far enough ahead, waiting' : `from ${clock(status.from)}`}</>}
|
||||
</div>
|
||||
<div className="preview"><video ref={video} controls autoPlay playsInline /></div>
|
||||
{err && <div className="error" style={{ margin: 12 }}>{err}</div>}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+59
-1
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchCatalog } from './sensors'
|
||||
import { api, Me, TokenInfo, UpdateStatus } from './api'
|
||||
import { api, Me, TokenInfo, TranscodeSetting, UpdateStatus } from './api'
|
||||
import { savePrefs } from './accents'
|
||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
||||
@@ -104,6 +104,7 @@ export default function Settings({ me }: { me: Me }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TranscodeDevice />
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||
<section className="card">
|
||||
@@ -248,3 +249,60 @@ function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { n
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Where videos played in Files are made: one device on one machine, for every machine's files.
|
||||
// Every machine's devices are asked for live; one that can't be used says why.
|
||||
function TranscodeDevice() {
|
||||
const [t, setT] = useState<TranscodeSetting | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
useEffect(() => { api.transcode().then(setT).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
const set = async (node: string, device: string) => {
|
||||
setBusy(node + '|' + device); setErr('')
|
||||
try { setT(await api.setTranscode(node, device)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
setBusy('')
|
||||
}
|
||||
const sel = t?.selected
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar"><h2 className="section">Transcode device</h2><span className="spacer" /><span className="muted small">videos in Files, on every machine</span></div>
|
||||
<section className="card">
|
||||
{!t && !err && <div className="muted small"><Spinner /> asking every machine what it can encode on…</div>}
|
||||
{t && (
|
||||
<div className="tc-list">
|
||||
<div className="tc-machine">
|
||||
<div className="tc-host">Anywhere</div>
|
||||
<div className="chips">
|
||||
<button className={'chip' + (!sel ? ' on' : '')} disabled={!!busy} onClick={() => set('', '')}>The machine the file is on, its best device</button>
|
||||
</div>
|
||||
</div>
|
||||
{t.machines.map((m) => (
|
||||
<div key={m.node} className="tc-machine">
|
||||
<div className="tc-host">{m.host}{m.node === '' && <span className="muted small"> · this hub</span>}</div>
|
||||
{m.error && <div className="muted small">{m.error}</div>}
|
||||
<div className="chips">
|
||||
{(m.devices ?? []).map((d) => {
|
||||
const on = !!sel && sel.node === m.node && sel.device === d.id
|
||||
return (
|
||||
<button key={d.id} className={'chip' + (on ? ' on' : '') + (d.ok ? '' : ' off')} disabled={!d.ok || !!busy} title={d.ok ? d.id : d.reason}
|
||||
onClick={() => set(m.node, d.id)}>
|
||||
{busy === m.node + '|' + d.id ? <Spinner size={10} /> : null}{d.name}{d.id !== 'cpu' && d.integrated ? ' (integrated)' : ''}{!d.ok && d.reason ? ` — ${d.reason}` : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||
<div className="muted small" style={{ marginTop: 10 }}>
|
||||
Opening a video in Files plays it YouTube's way — Auto picks the quality the connection takes, any quality can be picked by hand,
|
||||
or the file as it is — and every quality is made on the fly, from wherever you seek to, on this device. Video previews
|
||||
(thumbnails) are made on it too; image previews stay on each machine's CPU. When it is on another
|
||||
machine than the file, that machine reads the file through this hub. If it can't be reached, the file's own machine makes it instead.
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-9
@@ -15,8 +15,6 @@ import Hardware from './Hardware'
|
||||
import Network from './Network'
|
||||
import VPN from './VPN'
|
||||
import ZFS from './ZFS'
|
||||
import Virtualization from './Virtualization'
|
||||
import KVM from './KVM'
|
||||
import Files from './Files'
|
||||
import Top from './Top'
|
||||
import Machines from './Machines'
|
||||
@@ -82,8 +80,6 @@ const GROUPS: Group[] = [
|
||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||
{ id: 'virt', label: 'Virtualization', icon: '▣' },
|
||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||
@@ -93,8 +89,8 @@ const GROUPS: Group[] = [
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'virt' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'virt', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
|
||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||
|
||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||
@@ -326,12 +322,10 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
||||
{view === 'settings' && <Settings me={hubMe} />}
|
||||
{view === 'users' && <Users me={hubMe} />}
|
||||
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
||||
{view === 'usage' && <Usage key={machine ?? ''} />}
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||
{view === 'widgets' && <Gallery />}
|
||||
{view === 'docker' && <Docker />}
|
||||
{view === 'virt' && <Virtualization />}
|
||||
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
||||
{view === 'procs' && <Top />}
|
||||
{view === 'services' && <Services />}
|
||||
@@ -369,7 +363,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'virt' ? 'Virtualization' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
+125
-56
@@ -3,14 +3,15 @@
|
||||
// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and
|
||||
// /api/procs for the table — so this works on any machine the console can see, whether or not
|
||||
// btop is installed on it.
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, GPU, ProcTable, Proc, Sample } from './api'
|
||||
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
|
||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
||||
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
||||
import { Loading, askYes, toast } from './ui'
|
||||
import { Theme, currentThemeId, themeById } from './themes'
|
||||
import { columnsOf } from './graphcols'
|
||||
import { columnsAt } from './graphcols'
|
||||
import { socketThreads } from './sockets'
|
||||
import { savePrefs } from './accents'
|
||||
|
||||
const MAX = 300 // points kept per curve, as the machines keep them
|
||||
@@ -68,7 +69,7 @@ function at(g: readonly string[], t: number): string {
|
||||
|
||||
// ---- the dot graph ----
|
||||
// One dot column per sample, filled from the bottom, each dot coloured by how high it sits.
|
||||
function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
|
||||
function Graph({ values, tick, advance, now, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; now: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const pal = useContext(Pal)
|
||||
@@ -79,7 +80,8 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
|
||||
ro.observe(box.current)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
// drawn before the browser paints, so a graph changes in the same frame as the numbers
|
||||
useLayoutEffect(() => {
|
||||
const c = ref.current
|
||||
if (!c || w < 4 || height < 4) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
@@ -91,14 +93,16 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
|
||||
// The box is the whole window, always: `cap` points wide (what the window holds at this
|
||||
// rate) whatever the box measures, so a full history fills it edge to edge and a machine
|
||||
// that has only just started draws in from the right, as btop does. Which points each
|
||||
// column draws — the peak of its own fixed slice of the history — is graphcols.ts.
|
||||
const seen = columnsOf(values, advance, cap, cols)
|
||||
const top = max ?? Math.max(1, ...seen)
|
||||
// column draws — the peak of its own fixed slice of the history — is graphcols.ts. The
|
||||
// columns slide by a fraction of one on every tick, so the graph moves with the numbers
|
||||
// beside it even when its points are several ticks apart.
|
||||
const { cols: seen, edge } = columnsAt(values, advance, now, cap, cols)
|
||||
const top = max ?? Math.max(1, ...seen.slice(-cols))
|
||||
const stops = pal[grad]
|
||||
for (let i = 0; i < seen.length; i++) {
|
||||
// anything above nothing lights the bottom row: the curve keeps a baseline, as btop's does
|
||||
const lit = seen[i] > 0 ? Math.max(1, Math.round((seen[i] / top) * rows)) : 0
|
||||
const x = w - (seen.length - i) * step
|
||||
const x = Math.round((w - (seen.length - 1 - i + edge) * step) * dpr) / dpr
|
||||
for (let r = 0; r < lit; r++) {
|
||||
const t = rows > 1 ? r / (rows - 1) : 1
|
||||
g.fillStyle = at(stops, t)
|
||||
@@ -106,7 +110,7 @@ function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values
|
||||
g.fillRect(x, y, 2, 2)
|
||||
}
|
||||
}
|
||||
}, [values, tick, advance, cap, w, height, max, grad, flip, pal]) // tick: the curve is appended to in place
|
||||
}, [values, tick, advance, now, cap, w, height, max, grad, flip, pal]) // tick: the curve is appended to in place
|
||||
return <div className="tg" ref={box} data-n={values.length}><canvas ref={ref} style={{ width: '100%', height: '100%' }} /></div>
|
||||
}
|
||||
|
||||
@@ -187,12 +191,17 @@ function braille(values: number[], cells = 7, max = 100): string {
|
||||
|
||||
// ---- the curves this page keeps ----
|
||||
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util,gpu.*.usage'
|
||||
const RECENT = 18 // ticks a per-core strip keeps: its nine braille cells, two dots each
|
||||
|
||||
function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
|
||||
const cur = useRef<Map<string, number[]>>(new Map())
|
||||
const lastT = useRef(0)
|
||||
const binN = useRef(1)
|
||||
const adv = useRef(0) // the number of the newest point, counted since the page opened: what a graph's columns are cut by
|
||||
const now = useRef(1) // how far along the curves are, in points: adv plus the newest point's share of its stride gone
|
||||
// The per-core strips are a few seconds wide: they keep every tick, not the stride's points,
|
||||
// so they move with the numbers beside them whatever the history window is.
|
||||
const recent = useRef<Map<string, number[]>>(new Map())
|
||||
const [ticks, bump] = useState(0)
|
||||
const seed = useCallback(async () => {
|
||||
try {
|
||||
@@ -202,21 +211,30 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
|
||||
// The stride is timed on this clock only. The history's own timestamps are the machine's:
|
||||
// measured against them, a viewer whose clock runs minutes slow would wait those minutes
|
||||
// before its curves gained a single new point.
|
||||
cur.current = m; lastT.current = Date.now(); binN.current = 1
|
||||
cur.current = m; lastT.current = Date.now(); binN.current = 1; now.current = adv.current + 1
|
||||
bump((x) => x + 1)
|
||||
} catch { /* the curves fill in live instead */ }
|
||||
}, [])
|
||||
useEffect(() => { seed() }, [seed, resumed, historySec]) // a new window is a new spacing: start from what the machine now keeps
|
||||
useEffect(() => {
|
||||
// before paint: the curves take the sample in the same frame that shows its numbers
|
||||
useLayoutEffect(() => {
|
||||
if (!live || !t) return
|
||||
const { fresh, start } = nextPoint(t, lastT.current, strideMs(historySec, refreshMs), refreshMs)
|
||||
const stride = strideMs(historySec, refreshMs)
|
||||
const { fresh, start } = nextPoint(t, lastT.current, stride, refreshMs)
|
||||
binN.current = fresh ? 1 : binN.current + 1
|
||||
lastT.current = start
|
||||
if (fresh) adv.current++
|
||||
// this tick's share of the point: one tick in at its start, the whole point by its last
|
||||
now.current = adv.current + Math.max(refreshMs / stride, Math.min(1, (t - start + refreshMs) / stride))
|
||||
const n = binN.current
|
||||
for (const [id, v] of Object.entries(live)) {
|
||||
if (typeof v !== 'number') continue
|
||||
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$|^gpu\..+\.usage$/.test(id)) continue
|
||||
if (id.startsWith('cpu.core.')) {
|
||||
const r = recent.current.get(id) ?? []
|
||||
r.push(v); if (r.length > RECENT) r.shift()
|
||||
recent.current.set(id, r)
|
||||
}
|
||||
const arr = cur.current.get(id) ?? []
|
||||
if (!cur.current.has(id)) cur.current.set(id, arr)
|
||||
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
|
||||
@@ -224,7 +242,7 @@ function useCurves(live: Values | null, t: number, refreshMs: number, historySec
|
||||
}
|
||||
bump((x) => x + 1)
|
||||
}, [live, t, refreshMs, historySec])
|
||||
return [cur.current, ticks, adv.current] as const
|
||||
return [cur.current, ticks, adv.current, now.current, recent.current] as const
|
||||
}
|
||||
|
||||
// ---- the page ----
|
||||
@@ -239,18 +257,51 @@ export default function Top() {
|
||||
const [ready, setReady] = useState(false)
|
||||
const stream = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
|
||||
useEffect(() => { fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])); setReady(true) }).catch(() => setReady(true)) }, [])
|
||||
const values = useMemo(() => (stream.data ? mergeValues(stream.data) : null), [stream.data])
|
||||
|
||||
// One beat for the whole page. Every sample the stream pushes is held until the process
|
||||
// table has been read for it, and the two land together: the curves, the meters, the numbers,
|
||||
// the GPU boxes and the table all change on the same tick, at the rate, never on two clocks
|
||||
// that drift past each other. A sample that arrives while the table is still being read
|
||||
// replaces the held one, so a slow read costs ticks, never order.
|
||||
const [frame, setFrame] = useState<{ data: unknown[] | null; pt: ProcTable | null }>({ data: null, pt: null })
|
||||
const [err, setErr] = useState('')
|
||||
const held = useRef<unknown[] | null>(null)
|
||||
const reading = useRef(false)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
useEffect(() => {
|
||||
if (!stream.data) return
|
||||
held.current = stream.data
|
||||
if (reading.current) return
|
||||
reading.current = true
|
||||
api.procs().then((pt) => ({ pt, e: '' }), (e) => ({ pt: null, e: e instanceof Error ? e.message : 'failed' })).then(({ pt, e }) => {
|
||||
reading.current = false
|
||||
if (!mounted.current) return
|
||||
const data = held.current
|
||||
setFrame((f) => ({ data, pt: pt ?? f.pt }))
|
||||
setErr(e)
|
||||
})
|
||||
}, [stream.data])
|
||||
const pt = frame.pt
|
||||
const values = useMemo(() => (frame.data ? mergeValues(frame.data) : null), [frame.data])
|
||||
useEffect(() => {
|
||||
if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return
|
||||
refetching.current = true
|
||||
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
|
||||
}, [values])
|
||||
const s: Sample | null = useMemo(() => (values && ready ? sampleOf(values, catalog.current) : null), [values, ready])
|
||||
const [curves, tick, advance] = useCurves(values, s?.t ?? 0, refreshMs, historySec, stream.resumed)
|
||||
const [curves, tick, advance, now, recent] = useCurves(values, s?.t ?? 0, refreshMs, historySec, stream.resumed)
|
||||
|
||||
// the process table, and the CPU model, come from the same place the Processes page reads
|
||||
const [pt, setPt] = useState<ProcTable | null>(null)
|
||||
// the process table, and the CPU model, come from the same place the Processes page reads,
|
||||
// on the sample's beat (above)
|
||||
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
|
||||
// which sockets' threads are folded away on a two-socket machine; remembered in this browser
|
||||
const [folded, setFolded] = useState<number[]>(() => { try { return JSON.parse(localStorage.getItem('top.folded-cpus') ?? '[]') } catch { return [] } })
|
||||
const fold = (pk: number) => setFolded((f) => {
|
||||
const n = f.includes(pk) ? f.filter((x) => x !== pk) : [...f, pk]
|
||||
try { localStorage.setItem('top.folded-cpus', JSON.stringify(n)) } catch { /* private window */ }
|
||||
return n
|
||||
})
|
||||
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
|
||||
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
|
||||
const [pools, setPools] = useState<Record<string, string>>({})
|
||||
@@ -264,14 +315,6 @@ export default function Top() {
|
||||
}).catch(() => { /* the rows simply say less */ })
|
||||
return () => { live = false }
|
||||
}, [])
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
const poll = async () => { try { const r = await api.procs(); if (!stop) { setPt(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') } }
|
||||
poll()
|
||||
const i = window.setInterval(poll, refreshMs) // exactly the rate, like every other page: nothing is throttled behind his back
|
||||
return () => { stop = true; window.clearInterval(i) }
|
||||
}, [refreshMs])
|
||||
|
||||
// the same global rate Settings sets: saved on the hub, so it sticks and every machine's sampler follows
|
||||
const rateIdx = (() => { const i = REFRESH_OPTIONS.findIndex((x) => x >= refreshMs); return i < 0 ? REFRESH_OPTIONS.length - 1 : i })()
|
||||
@@ -313,7 +356,7 @@ export default function Top() {
|
||||
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.pid}`) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
if (!s && !pt) return <div className="top" style={pal.vars as React.CSSProperties}><Loading label={err || 'Reading the machine…'} /></div>
|
||||
if (!s && !pt) return <div className="top" style={pal.vars as React.CSSProperties}><Loading label={err || stream.error || 'Reading the machine…'} /></div>
|
||||
|
||||
const cpu = s?.cpu ?? pt?.cpu ?? 0
|
||||
const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? []
|
||||
@@ -325,7 +368,16 @@ export default function Top() {
|
||||
const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').replace(/@.*$/, '').replace(/^\d+th Gen\s*/i, '').replace(/\s+/g, ' ').trim()
|
||||
// the hottest thing that looks like a CPU sensor, and one per core where the chip names them;
|
||||
// a second thread of a core (CPU 8 on an 8-core chip with hyper-threading) wears its core's
|
||||
const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label))?.c }
|
||||
// On a two-socket board each CPU has its own sensor chip, both counting from "Core 0", so a
|
||||
// thread's core is looked up on its own socket's chip.
|
||||
const pkgs = pt?.core_pkgs?.length === cores.length ? pt.core_pkgs : undefined
|
||||
// each CPU's own threads, counted from 0 under it (sockets.ts): the kernel's one numbering
|
||||
// of the whole machine puts cpu 0–9 and 20–29 on the first and 10–19 and 30–39 on the second
|
||||
const socks = pkgs ? socketThreads(pkgs) : []
|
||||
const sockets = socks.map((x) => x.pkg)
|
||||
const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i, pk = pkgs?.[i]; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label) && (pk === undefined || (t.pkg ?? 0) === pk))?.c }
|
||||
const sockTemp = (pk: number) => s?.temps.find((t) => t.chip === 'coretemp' && new RegExp(`^package id ${pk}$`, 'i').test(t.label))?.c
|
||||
const sockUse = (pk: number) => { const v = cores.filter((_, i) => pkgs?.[i] === pk); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0 }
|
||||
const cpuTemp = pt?.cpu_temp ?? s?.temps.find((t) => /package|tctl|tdie|cpu/i.test(t.label))?.c ?? coreTemp(0)
|
||||
// the temperature columns are there for every row once any row has one, so the % column lines up
|
||||
const anyTemp = cpuTemp !== undefined || cores.some((_, i) => coreTemp(i) !== undefined)
|
||||
@@ -365,29 +417,46 @@ export default function Top() {
|
||||
</>}>
|
||||
<div className="t-cpu">
|
||||
<div className="t-cpu-graph">
|
||||
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
|
||||
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
|
||||
<div className="t-up">up {upfor(uptime)}</div>
|
||||
</div>
|
||||
<div className="t-cores">
|
||||
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
|
||||
{pt?.cpu_power_w ? <span title="the CPU's own power reading: cores, built-in graphics and memory controller, not the whole machine">{pt.cpu_mhz ? ' · ' : ''}{pt.cpu_power_w < 10 ? pt.cpu_power_w.toFixed(1) : Math.round(pt.cpu_power_w)} W</span> : null}</div>
|
||||
<div className="t-core">
|
||||
<div className={'t-cores' + (sockets.length > 1 ? ' multi' : '')}>
|
||||
<div className="t-cores-head"><b>{sockets.length > 1 ? `${sockets.length} × ` : ''}{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
|
||||
{pt?.cpu_power_w && sockets.length < 2 ? <span title="the CPU's own power reading: cores, built-in graphics and memory controller, not the whole machine">{pt.cpu_mhz ? ' · ' : ''}{pt.cpu_power_w < 10 ? pt.cpu_power_w.toFixed(1) : Math.round(pt.cpu_power_w)} W</span> : null}</div>
|
||||
{sockets.length < 2 && <div className="t-core">
|
||||
<span className="t-core-n">CPU</span>
|
||||
<Meter pct={cpu} grad="cpu" />
|
||||
<span className="t-core-p">{Math.round(cpu)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{cpuTemp !== undefined && <Meter pct={Math.min(100, (cpuTemp / 100) * 100)} grad="temp" />}</span><span className="t-core-c">{cpuTemp !== undefined ? `${Math.round(cpuTemp)}°C` : ''}</span></>}
|
||||
</div>
|
||||
{cores.map((v, i) => {
|
||||
const t = coreTemp(i)
|
||||
return (
|
||||
<div className="t-core" key={i}>
|
||||
<span className="t-core-n">C{i}</span>
|
||||
<span className="t-core-g" title={pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(curves.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
|
||||
<span className="t-core-p">{Math.round(v)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>}
|
||||
{(sockets.length > 1 ? socks : [{ pkg: undefined, cpus: cores.map((_, i) => i) }]).map(({ pkg: pk, cpus }) => (
|
||||
<Fragment key={pk ?? 'all'}>
|
||||
{pk !== undefined && (() => {
|
||||
const u = sockUse(pk), t = sockTemp(pk), w = pt?.cpu_power_pkgs_w?.[pk], open = !folded.includes(pk)
|
||||
return (
|
||||
<div className="t-core t-sock" role="button" aria-expanded={open} title={open ? 'hide its threads' : 'show its threads'} onClick={() => fold(pk)}>
|
||||
<span className="t-core-n"><span className="t-fold">{open ? '▾' : '▸'}</span>CPU{pk + 1}</span>
|
||||
<span className="t-core-w" title="this CPU's own power reading: its cores, cache and memory controller">{w ? `${w < 10 ? w.toFixed(1) : Math.round(w)} W` : ''}</span>
|
||||
<Meter pct={u} grad="cpu" />
|
||||
<span className="t-core-p">{Math.round(u)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{(pk !== undefined && folded.includes(pk) ? [] : cpus).map((i, n) => {
|
||||
// n is the thread's number under its own CPU, i the kernel's for the whole machine
|
||||
const t = coreTemp(i), v = cores[i] ?? 0
|
||||
return (
|
||||
<div className="t-core" key={i}>
|
||||
<span className="t-core-n">C{n}</span>
|
||||
<span className="t-core-g" title={pk !== undefined ? `CPU ${pk + 1} · thread ${n} · cpu ${i} to the kernel${pt?.core_ids ? ` · a thread of core ${pt.core_ids[i]}` : ''}` : pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(recent.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
|
||||
<span className="t-core-p">{Math.round(v)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="t-load">Load AVG:<span className="spacer" />{load.map((x) => x.toFixed(2)).join(' ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,7 +466,7 @@ export default function Top() {
|
||||
<div className="top-gpus">
|
||||
{gpus.map(({ g, stem }, i) => (
|
||||
<Box key={stem} name={`gpu${i}`} className={'top-gpu' + (unmeasured(g) ? ' slim' : '')} right={<span className="t-gpu-name" title={[g.name, g.driver, g.bus].filter(Boolean).join(' · ')}>{g.name}</span>}>
|
||||
{unmeasured(g) ? <GPULine g={g} /> : <GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} cap={cap} />}
|
||||
{unmeasured(g) ? <GPULine g={g} /> : <GPUBox g={g} values={curves.get(stem + '.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} />}
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
@@ -421,7 +490,9 @@ export default function Top() {
|
||||
// pool or an md array spans several drives, so nothing is attributed and the row
|
||||
// shows what is true of the hardware — how busy it is, and how hot.
|
||||
const on = fs.filter((f) => f.disk === d.name)
|
||||
const used = on.reduce((a, f) => a + f.used, 0)
|
||||
// a bind mount (nexus's /var/log is /data/var-log) is the same filesystem twice:
|
||||
// count each source device once, or the drive reads double
|
||||
const used = [...new Map(on.map((f) => [f.source || f.mount, f.used])).values()].reduce((a, b) => a + b, 0)
|
||||
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
|
||||
})}
|
||||
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
|
||||
@@ -441,10 +512,10 @@ export default function Top() {
|
||||
<div className="t-net-graphs">
|
||||
<div className="t-net-g">
|
||||
<span className="t-scale">{rate(peak(`net.${segOf(iface.iface)}.rx`))}</span>
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} advance={advance} cap={cap} grad="down" />
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} advance={advance} now={now} cap={cap} grad="down" />
|
||||
</div>
|
||||
<div className="t-net-g">
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} cap={cap} grad="up" flip />
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} now={now} cap={cap} grad="up" flip />
|
||||
<span className="t-scale t-scale-b">{rate(peak(`net.${segOf(iface.iface)}.tx`))}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -522,16 +593,14 @@ const Rows = memo(function Rows({ procs, sel, onSel, curves, gpu, empty }: { pro
|
||||
// that have it open with their share. What cannot be measured on this card is left out rather
|
||||
// than shown as zero: an Arc on i915 has no temperature sensor, Asahi has no busy counter.
|
||||
const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'], ['video', 'video'], ['video-enhance', 'enhance'], ['copy', 'copy']]
|
||||
function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; tick: number; advance: number; cap: number }) {
|
||||
function GPUBox({ g, values, tick, advance, now, cap }: { g: GPU; values: number[]; tick: number; advance: number; now: number; cap: number }) {
|
||||
const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
|
||||
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined
|
||||
const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts'
|
||||
: g.usage_src === 'idle' ? 'busy, from idle residency' : g.usage_src === 'busy' ? 'busy, as the driver reports it' : g.usage_src === 'clients' ? 'the busiest engine, summed over its clients' : 'this driver exposes no activity counter'
|
||||
return (
|
||||
<div className="t-gpu">
|
||||
<div className="t-gpu-graph">
|
||||
<Graph values={values} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
|
||||
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
|
||||
<Graph values={values} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
|
||||
{g.stalled && <div className="t-up">a read of this card stopped answering; its readings are paused until it does</div>}
|
||||
</div>
|
||||
<div className="t-gpu-stats">
|
||||
<div className="t-cores-head"><b>{g.name}</b><span className="spacer" />{g.driver}{g.integrated ? ' · integrated' : ''}</div>
|
||||
@@ -555,7 +624,7 @@ function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; t
|
||||
<span className="t-fact" title={g.req_mhz ? `${Math.round(g.req_mhz)} MHz requested${g.max_mhz ? `, ${Math.round(g.max_mhz)} MHz max` : ''}` : undefined}>{Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz</span></div> : null}
|
||||
{(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ? <div className="t-row t-sub"><span>Power</span><span className="spacer" />
|
||||
<span className="t-fact">{(g.power_w ?? 0).toFixed(1)}{g.power_cap_w ? ` / ${Math.round(g.power_cap_w)}` : ''} W</span></div> : null}
|
||||
{g.fan_rpm ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{g.fan_rpm} rpm</span></div> : null}
|
||||
{g.fan_rpm || g.fan_pct !== undefined ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{[g.fan_pct !== undefined ? `${Math.round(g.fan_pct)}%` : '', g.fan_rpm ? `${g.fan_rpm} rpm` : ''].filter(Boolean).join(' · ')}</span></div> : null}
|
||||
{g.mem_total ? <div className="t-row t-sub"><span>Mem</span><span className="t-pct">{Math.round(((g.mem_used ?? 0) / g.mem_total) * 100)}%</span><Meter pct={((g.mem_used ?? 0) / g.mem_total) * 100} grad="used" /><span className="t-amt">{bytes(g.mem_used ?? 0, 1)}</span></div>
|
||||
: g.mem_used ? <div className="t-row t-sub"><span>Mem</span><span className="spacer" /><span className="t-fact" title="resident memory of the processes that have it open">{bytes(g.mem_used, 1)}</span></div> : null}
|
||||
</div>
|
||||
@@ -576,13 +645,13 @@ function GPUBox({ g, values, tick, advance, cap }: { g: GPU; values: number[]; t
|
||||
// Nothing on this GPU can be read — no busy counter, engine, temperature, clock, power, memory,
|
||||
// nor a process with it open: the Mac's Asahi, today. Its box would be a graph that can never
|
||||
// draw beside a column of dashes, so it is one line instead. A client opening it brings the box back.
|
||||
const unmeasured = (g: GPU) => !g.usage_src && !g.engines && !(g.temp && g.temp > 0) && !g.mhz && !g.max_mhz && !g.power_w && !g.power_cap_w && !g.fan_rpm && !g.mem_total && !g.mem_used && g.clients.length === 0
|
||||
const unmeasured = (g: GPU) => !g.usage_src && !g.engines && !(g.temp && g.temp > 0) && !g.mhz && !g.max_mhz && !g.power_w && !g.power_cap_w && !g.fan_rpm && g.fan_pct === undefined && !g.mem_total && !g.mem_used && g.clients.length === 0
|
||||
function GPULine({ g }: { g: GPU }) {
|
||||
return (
|
||||
<div className="t-row t-gpu-line">
|
||||
<span>{g.driver}{g.integrated ? ' · integrated' : ''}</span>
|
||||
{g.stalled
|
||||
? <span className="t-dim" title="a sysfs read of this card stopped answering">readings paused until the console restarts</span>
|
||||
? <span className="t-dim" title="a read of this card stopped answering">readings paused until it answers</span>
|
||||
: <span className="t-dim" title="the driver exposes no busy counter, clock, temperature, power or memory reading">nothing measurable</span>}
|
||||
<span className="t-dim t-gpu-who" title={g.clients_ok ? undefined : 'the console has no root here'}>{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden'}</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -9,8 +9,8 @@
|
||||
// disagree over. An existing cron job is detected and shown, so the page tells the truth
|
||||
// about the machine whether Ham Lab or something else is doing the work.
|
||||
import { FormEvent, Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ARCView, CacheDev, PoolCaching, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { api, ARCView, CacheDev, Landing, LandingCheck, PoolCaching, PoolWrites, VerifyPolicy, VerifyResult, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot, ZFSWrites } from './api'
|
||||
import { fmtBytes, fmtRate } from './fmt'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { fetchHistories } from './sensors'
|
||||
import Sparkline from './Sparkline'
|
||||
@@ -367,22 +367,153 @@ function PoolTiers({ p, v }: { p: PoolCaching; v: ARCView }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the write path: the dirty buffer in RAM, what each pool writes, and a landing tier ----
|
||||
//
|
||||
// A write reaches a pool in up to three steps. If a landing tier fronts the pool (a mergerfs
|
||||
// pool whose first branch is fast storage and whose last is the pool), new files land there
|
||||
// first and a mover drains them. Either way the data then waits in RAM as dirty buffers, and
|
||||
// each txg sync writes it to the disks. Each step's live number is here, with its history.
|
||||
|
||||
const dur = (s: number) => (!Number.isFinite(s) || s <= 0 ? '—' : s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : `${(s / 3600).toFixed(1)} h`)
|
||||
type WHist = Record<string, number[]>
|
||||
const wIds = (w: ZFSWrites) => ['zfs.write.dirty', ...w.pools.flatMap((p) => {
|
||||
const b = `zfs.${p.pool}.`
|
||||
return [b + 'write_bps', b + 'logical_bps', ...(p.landing ? [b + 'landing.pending_bytes', b + 'landing.in_bps', b + 'landing.drain_bps'] : [])]
|
||||
})]
|
||||
|
||||
// rsync's --bwlimit: a bare number is KiB/s, a K/M/G suffix is binary (60M = 60 MiB/s); 0 is none
|
||||
const bwl = (v: string) => {
|
||||
const m = /^(\d+(?:\.\d+)?)([KMG]?)$/i.exec((v ?? '').trim())
|
||||
if (!m || Number(m[1]) === 0) return ''
|
||||
return ` · rsync limited to ${m[1]} ${(m[2] || 'K').toUpperCase()}iB/s`
|
||||
}
|
||||
|
||||
function MoverLine({ l }: { l: Landing }) {
|
||||
const m = l.mover
|
||||
if (!m) return <div className="notice" style={{ marginTop: 6 }}>No mover is reporting for this landing tier (nothing in <span className="mono">/run/landing-mover/</span>). Files that land here stay here until something moves them.</div>
|
||||
const frac = m.current_size > 0 ? m.partial / m.current_size : 0
|
||||
return (
|
||||
<>
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Mover</span><span>
|
||||
<span className={'badge ' + (m.state === 'moving' ? 'on' : m.state === 'idle' ? '' : 'warn')}>{m.state}</span>
|
||||
{' '}{m.state === 'moving' && m.batch_files > 1 ? `batch of ${m.batch_files} files, ${fmtBytes(m.batch_bytes)}` : m.state === 'holding' ? 'a mount it needs is missing — waiting' : m.state === 'stopped' ? `no word since ${ago(m.updated_ms)}` : `queue ${m.queue}`}
|
||||
{m.state === 'moving' && m.queue > 0 ? ` · ${m.queue} more queued` : ''}
|
||||
</span></div>
|
||||
{m.state === 'moving' && m.current && <div><span className="k">Now</span><span className="mono small" title={m.current}>{m.current}{m.current_size ? ` · ${fmtBytes(m.partial)} of ${fmtBytes(m.current_size)}` : ''}</span></div>}
|
||||
<div><span className="k">Since start</span><span>{fmtN(m.moved_files)} files · {fmtBytes(m.moved_bytes)} moved · running {ago(m.started_ms).replace(' ago', '')}</span></div>
|
||||
{m.last_move && <div><span className="k">Last moved</span><span className="mono small" title={m.last_move}>{m.last_move} · {ago(m.last_move_ms)}</span></div>}
|
||||
<div><span className="k">Rules</span><span className="muted">a file moves once it has been closed and left alone for {m.settle} s · oldest first{bwl(m.bwlimit)}</span></div>
|
||||
{m.last_error && <div><span className="k">Last error</span><span className="warn-text" style={{ whiteSpace: 'normal' }}>{m.last_error} · {ago(m.last_error_ms)}</span></div>}
|
||||
</div>
|
||||
{m.state === 'moving' && m.current_size > 0 && <Meter frac={frac} cls="" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingBlockInner({ l, pending, inn, drain }: { l: Landing; pending: number[]; inn: number[]; drain: number[] }) {
|
||||
const eta = l.drain_bps > 0 ? l.pending_bytes / l.drain_bps : NaN
|
||||
const fill = l.size > 0 ? l.used / l.size : 0
|
||||
return (
|
||||
<div className="landing-tier">
|
||||
<div className="arc-tier"><span className="k" title="a mergerfs pool puts new files on this branch first; a mover drains them onto the pool">Landing</span>
|
||||
<span className="arc-tier-v"><span className="mono" title={`${l.branch} → ${l.pool_branch} via ${l.mount}`}>{l.branch}{l.device ? ` · ${l.device}` : ''}{l.fstype ? ` · ${l.fstype}` : ''} · in front of {l.pool_branch} as {l.mount}</span></span>
|
||||
</div>
|
||||
<div className="sub-head" style={{ marginTop: 6 }}>
|
||||
<span className="muted small">{fmtBytes(l.used)} of {fmtBytes(l.size)} used</span><span className="spacer" />
|
||||
<span className="num">{l.pending_files ? `${fmtN(l.pending_files)}${l.pending_partial ? '+' : ''} waiting · ${fmtBytes(l.pending_bytes)}` : 'nothing waiting'}</span>
|
||||
</div>
|
||||
<Meter frac={fill} />
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Waiting</span><span>{l.pending_files ? `${fmtN(l.pending_files)}${l.pending_partial ? '+' : ''} files, ${fmtBytes(l.pending_bytes)} · oldest landed ${ago(l.oldest_ms)}` : 'nothing — everything that landed is on the pool'}<span className="muted small"> · counted {ago(l.counted_ms)}</span></span></div>
|
||||
<div><span className="k">Arriving</span><span>{fmtRate(l.in_bps)}{l.device ? <span className="muted small"> · writes to {l.device}</span> : ''}</span></div>
|
||||
<div><span className="k">To the pool</span><span>{fmtRate(l.drain_bps)}{l.pending_bytes > 0 ? ` · ${Number.isFinite(eta) ? `clear in about ${dur(eta)}` : 'not draining right now'}` : ''}</span></div>
|
||||
</div>
|
||||
<MoverLine l={l} />
|
||||
<div className="arc-sparks">
|
||||
<div>
|
||||
<div className="muted small">Waiting on the landing tier · {fmtBytes(l.pending_bytes)}</div>
|
||||
<Sparkline series={[{ name: 'waiting', values: pending, color: cssVar('--s1') }]} format={fmtBytes} height={40} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="muted small">Arriving {fmtRate(l.in_bps)} · to the pool {fmtRate(l.drain_bps)}</div>
|
||||
<Sparkline series={[
|
||||
{ name: 'arriving', values: inn, color: cssVar('--s1') },
|
||||
{ name: 'to the pool', values: drain, color: cssVar('--ok') },
|
||||
]} format={fmtRate} height={40} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WritePath({ w, p, h }: { w: ZFSWrites; p: PoolWrites; h: WHist }) {
|
||||
const b = `zfs.${p.pool}.`
|
||||
const throttling = w.delayed_per_sec > 0 || w.over_max_per_sec > 0
|
||||
const delayAt = (w.limit * w.delay_min_pct) / 100
|
||||
const ratio = p.write_bps > 0 && p.logical_bps > 0 ? p.logical_bps / p.write_bps : 0
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (w.over_max_per_sec > 0 ? 'bad' : throttling ? 'warn' : 'ok')} />
|
||||
<span className="dname">{p.pool}</span>
|
||||
<span className="badge">write path</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{fmtRate(p.write_bps)} to disk</span>
|
||||
</div>
|
||||
{p.landing
|
||||
? <LandingBlockWithHist l={p.landing} h={h} b={b} />
|
||||
: <div className="arc-tiers"><div className="arc-tier"><span className="k" title="a fast tier new files land on before the pool">Landing</span><span className="arc-tier-v"><span className="none">none — writes go straight into the pool's RAM buffer</span></span></div></div>}
|
||||
<div className="arc-sparks">
|
||||
<div>
|
||||
<div className="muted small">RAM write buffer · {fmtBytes(w.dirty)} of {fmtBytes(w.limit)}</div>
|
||||
<Sparkline series={[{ name: 'waiting in RAM', values: h['zfs.write.dirty'] ?? [], color: cssVar('--s1') }]} max={w.limit || undefined} format={fmtBytes} height={44} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="muted small">Writes · {fmtRate(p.write_bps)} to disk · {fmtRate(p.logical_bps)} from programs</div>
|
||||
<Sparkline series={[
|
||||
{ name: 'to disk', values: h[b + 'write_bps'] ?? [], color: cssVar('--s1') },
|
||||
{ name: 'from programs', values: h[b + 'logical_bps'] ?? [], color: cssVar('--ok') },
|
||||
]} format={fmtRate} height={44} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="props rows" style={{ marginTop: 8 }}>
|
||||
<div><span className="k">In RAM</span><span title="dirty data: written by programs, not yet on the disks. For all pools together — ZFS keeps no live per-pool figure">{fmtBytes(w.dirty)} waiting · holds up to {fmtBytes(w.limit)} {w.limit < w.dirty_max ? `(half the ARC target; zfs_dirty_data_max is ${fmtBytes(w.dirty_max)})` : '(zfs_dirty_data_max)'}</span></div>
|
||||
<div><span className="k">Throttle</span><span title="past zfs_delay_min_dirty_percent of the buffer, ZFS makes each write wait a little so the disks can catch up; at the limit, writes stop until a sync frees room">
|
||||
{w.over_max_per_sec > 0 ? <span className="warn-text">{fmtN(w.over_max_per_sec)}/s writes stopped at the limit</span> : w.delayed_per_sec > 0 ? <span className="warn-text">slowing {fmtN(w.delayed_per_sec)} writes/s — the disks are the limit now</span> : 'not slowing anything'}
|
||||
<span className="muted"> · starts at {w.delay_min_pct}% ({fmtBytes(delayAt)})</span></span></div>
|
||||
<div><span className="k">To disk</span><span>{fmtRate(p.write_bps)} written by syncs{ratio > 0 ? ` · ${fmtRate(p.logical_bps)} from programs (${ratio >= 1 ? `${ratio.toFixed(2)}x compressed` : 'parity and metadata included'})` : ''}<span className="muted"> · a sync at least every {w.txg_timeout_sec} s, sooner as the buffer fills</span></span></div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingBlockWithHist({ l, h, b }: { l: Landing; h: WHist; b: string }) {
|
||||
return <LandingBlockInner l={l} pending={h[b + 'landing.pending_bytes'] ?? []} inn={h[b + 'landing.in_bps'] ?? []} drain={h[b + 'landing.drain_bps'] ?? []} />
|
||||
}
|
||||
|
||||
function ARCSection() {
|
||||
const [v, setV] = useState<ARCView | null>(null)
|
||||
const [hist, setHist] = useState<{ size: number[]; hit: number[] }>({ size: [], hit: [] })
|
||||
const [whist, setWhist] = useState<WHist>({})
|
||||
const [err, setErr] = useState('')
|
||||
const rate = useRefreshMs()
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
const poll = async () => {
|
||||
try { const r = await api.zfsARC(); if (!stop) { setV(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
|
||||
let r: ARCView | null = null
|
||||
try { r = await api.zfsARC(); if (!stop) { setV(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
|
||||
try {
|
||||
const hs = await fetchHistories('zfs.arc.size,zfs.arc.hit_pct')
|
||||
if (!stop) setHist({ size: hs.find((h) => h.id === 'zfs.arc.size')?.v ?? [], hit: hs.find((h) => h.id === 'zfs.arc.hit_pct')?.v ?? [] })
|
||||
const extra = r?.writes ? wIds(r.writes) : []
|
||||
const hs = await fetchHistories(['zfs.arc.size', 'zfs.arc.hit_pct', ...extra].join(','))
|
||||
if (!stop) {
|
||||
setHist({ size: hs.find((h) => h.id === 'zfs.arc.size')?.v ?? [], hit: hs.find((h) => h.id === 'zfs.arc.hit_pct')?.v ?? [] })
|
||||
setWhist(Object.fromEntries(hs.filter((h) => extra.includes(h.id)).map((h) => [h.id, h.v])))
|
||||
}
|
||||
} catch { /* the sparklines stay empty; the numbers still show */ }
|
||||
}
|
||||
poll()
|
||||
const t = setInterval(poll, Math.max(2000, rate))
|
||||
const t = setInterval(poll, rate)
|
||||
return () => { stop = true; clearInterval(t) }
|
||||
}, [rate])
|
||||
|
||||
@@ -444,7 +575,262 @@ function ARCSection() {
|
||||
<span><b>All reads</b></span><span className="num"><b>{fmtPct(hitPct(L.Hits, L.Misses))}</b></span><span className="num">{fmtN(L.Hits)}</span><span className="num">{fmtN(L.Misses)}</span>
|
||||
</div>
|
||||
</section>
|
||||
{d.pools.length > 0 && <div className="cards" style={{ marginTop: 8 }}>{d.pools.map((p) => <PoolTiers key={p.pool} p={p} v={v} />)}</div>}
|
||||
{d.pools.length > 0 && <div className="cards full" style={{ marginTop: 8 }}>{d.pools.map((p) => <PoolTiers key={p.pool} p={p} v={v} />)}</div>}
|
||||
{v.writes && v.writes.pools.length > 0 && (
|
||||
<>
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Writes</h2><span className="spacer" />
|
||||
<span className="muted small">what is waiting to reach each pool, and how fast it is getting there</span>
|
||||
</div>
|
||||
<div className="cards full">{v.writes.pools.map((p) => <WritePath key={p.pool} w={v.writes!} p={p} h={whist} />)}</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- landing-tier checks: what has waited too long, and verify passes ----
|
||||
//
|
||||
// A verify pass reads the pool's copy of every file the mover moved back against the SHA-256
|
||||
// the mover took of the landing copy, from its ledger. "New moves" checks what has moved since
|
||||
// the last pass; "everything" re-reads the whole ledger. Passes run as their own process, the
|
||||
// same one a schedule's cron line starts, so a console restart does not cut one short.
|
||||
|
||||
const vDescribe = (p: VerifyPolicy) => {
|
||||
const when = p.every === 'custom' ? `on ${p.cron}`
|
||||
: p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}`
|
||||
: p.every === 'daily' ? `every day at ${p.at}`
|
||||
: p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}`
|
||||
: `on day ${p.day} of the month at ${p.at}`
|
||||
return `${when} · ${p.scope === 'all' ? 'everything in the ledger' : 'what moved since the last pass'}`
|
||||
}
|
||||
const vOK = (r: VerifyResult) => !r.error && r.mismatched === 0 && r.gone === 0 && r.unreadable === 0
|
||||
const secs = (a: string, b: string) => (new Date(b).getTime() - new Date(a).getTime()) / 1000
|
||||
|
||||
function VerifySummary({ r }: { r: VerifyResult }) {
|
||||
if (r.error) return <span className="warn-text" style={{ whiteSpace: 'normal' }}>{r.error}</span>
|
||||
const extra = [r.changed && `${fmtN(r.changed)} edited since (not compared)`, r.waiting && `${fmtN(r.waiting)} waiting to move again`, r.unhashed && `${fmtN(r.unhashed)} without a hash`].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<span>
|
||||
{vOK(r)
|
||||
? <>{r.checked === 0 ? 'nothing new to check' : <><b>{fmtN(r.matched)}</b> of {fmtN(r.checked)} files match</>}</>
|
||||
: <span className="warn-text">{[r.mismatched && `${fmtN(r.mismatched)} DIFFER`, r.gone && `${fmtN(r.gone)} not on the pool`, r.unreadable && `${fmtN(r.unreadable)} unreadable`].filter(Boolean).join(' · ')} · {fmtN(r.matched)} match</span>}
|
||||
<span className="muted"> · {fmtBytes(r.bytes_read)} read in {dur(secs(r.started, r.finished)) === '—' ? '0 s' : dur(secs(r.started, r.finished))} · {r.scope === 'all' ? 'everything' : 'new moves'} · {ago(r.finished)}</span>
|
||||
{extra && <span className="muted"> · {extra}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Issues({ r }: { r: VerifyResult }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!r.issues?.length) return null
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button className="btn" onClick={() => setOpen(!open)}>{open ? 'Hide' : 'Show'} the {r.issues.length} file{r.issues.length === 1 ? '' : 's'} to look at</button>
|
||||
{open && <div className="snap-list" style={{ marginTop: 6 }}>{r.issues.map((i) => (
|
||||
<div key={i.path + i.kind} className="snap">
|
||||
<span className={'badge ' + (i.kind === 'mismatch' ? 'bad' : 'warn')}>{i.kind === 'mismatch' ? 'differs' : i.kind === 'gone' ? 'not on pool' : 'unreadable'}</span>
|
||||
<span className="mono snap-name" title={i.path}>{i.path}</span>
|
||||
<span className="muted snap-when">moved {new Date(i.moved).toLocaleString()}</span>
|
||||
<span className="num muted">{fmtBytes(i.size)}</span>
|
||||
</div>
|
||||
))}</div>}
|
||||
{open && r.gone > 0 && <div className="muted small" style={{ marginTop: 4 }}>"Not on the pool" is a file that was moved and is no longer there — deleted or renamed since, most often. The pass cannot tell that from a loss, so it lists them.</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifyScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: VerifyPolicy; cron: string; next?: string; last: VerifyResult | null; onChanged: () => void; onEdit: () => void }) {
|
||||
const run = async () => {
|
||||
try { await api.runVerifySchedule(p.id); toast('verify pass started'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!await askYes(`Remove the verify schedule "${p.name}"? Passes it already ran stay in the history.`, 'Remove')) return
|
||||
try { await api.deleteVerifySchedule(p.id); toast('schedule removed'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const toggle = async () => {
|
||||
try { await api.saveVerifySchedule({ ...p, enabled: !p.enabled }); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (last && !vOK(last) ? 'bad' : p.enabled ? 'ok' : '')} />
|
||||
<span className="dname">{p.name || 'verify'}</span>
|
||||
{!p.enabled && <span className="badge">paused</span>}
|
||||
<span className="spacer" />
|
||||
<span className="muted small">{p.enabled ? soon(next) : 'not scheduled'}</span>
|
||||
</div>
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">When</span><span>{vDescribe(p)}</span></div>
|
||||
<div><span className="k">Cron</span><span className="mono small">{cron}</span></div>
|
||||
<div><span className="k">Last pass</span>{last ? <VerifySummary r={last} /> : <span>never run</span>}</div>
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" onClick={run}>Run now</Btn>
|
||||
<button className="btn" onClick={onEdit}>Edit</button>
|
||||
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const VBLANK: Partial<VerifyPolicy> = { name: '', every: 'daily', at: '05:10', weekday: 0, day: 1, scope: 'new', enabled: true }
|
||||
|
||||
function VerifyDialog({ p, onClose, onSaved }: { p: Partial<VerifyPolicy>; onClose: () => void; onSaved: () => void }) {
|
||||
const [f, setF] = useState<Partial<VerifyPolicy>>({ ...VBLANK, ...p })
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
const set = (k: keyof VerifyPolicy, v: unknown) => setF((x) => ({ ...x, [k]: v }))
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr('')
|
||||
try { await api.saveVerifySchedule({ ...f, name: (f.name || '').trim() || (f.scope === 'all' ? 'verify everything' : 'verify new moves') }); onSaved() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||
<h3>{f.id ? 'Edit verify schedule' : 'New verify schedule'}</h3>
|
||||
<label className="muted">Name — what this schedule is for
|
||||
<input value={f.name} onChange={(e) => set('name', e.target.value)} placeholder="nightly verify" maxLength={64} />
|
||||
</label>
|
||||
<label className="muted">What to check
|
||||
<span className="chips">
|
||||
<button type="button" className={'chip' + (f.scope === 'new' ? ' on' : '')} onClick={() => set('scope', 'new')}>new moves</button>
|
||||
<button type="button" className={'chip' + (f.scope === 'all' ? ' on' : '')} onClick={() => set('scope', 'all')}>everything</button>
|
||||
</span>
|
||||
<span className="small">{f.scope === 'all' ? 'Re-reads every file the mover has ever moved from the pool. Thorough, and as slow as the ledger is large.' : 'Reads back what has moved since the last pass — every file checked once, soon after it lands.'}</span>
|
||||
</label>
|
||||
<label className="muted">How often
|
||||
<span className="chips">
|
||||
{(['hourly', 'daily', 'weekly', 'monthly', 'custom'] as const).map((k) => (
|
||||
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
|
||||
))}
|
||||
</span>
|
||||
</label>
|
||||
{f.every === 'custom' ? (
|
||||
<label className="muted">Cron expression — minute hour day month weekday
|
||||
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 */6 * * *" spellCheck={false} required />
|
||||
</label>
|
||||
) : (
|
||||
<label className="muted">At
|
||||
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
{f.every === 'weekly' && (
|
||||
<label className="muted">On
|
||||
<select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}>
|
||||
{WEEKDAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{f.every === 'monthly' && (
|
||||
<label className="muted">Day of the month — 1 to 28, so every month has one
|
||||
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
|
||||
</label>
|
||||
)}
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
|
||||
Enabled
|
||||
</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on">Save</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingChecks() {
|
||||
const [c, setC] = useState<LandingCheck | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [showStale, setShowStale] = useState(false)
|
||||
const [editing, setEditing] = useState<Partial<VerifyPolicy> | null>(null)
|
||||
const rate = useRefreshMs()
|
||||
const load = useCallback(async () => {
|
||||
try { setC(await api.zfsLanding()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const running = !!c?.running
|
||||
useEffect(() => { const t = setInterval(load, running ? 2000 : Math.max(10000, rate * 5)); return () => clearInterval(t) }, [load, rate, running])
|
||||
|
||||
if (err && !c) return <div className="error">{err}</div>
|
||||
if (!c || (c.tiers.length === 0 && c.policies.length === 0 && c.history.length === 0)) return null
|
||||
const verify = async (scope: 'new' | 'all') => {
|
||||
if (scope === 'all' && !await askYes('Re-read every file the mover has ever moved, from the pool, and check each against its hash? It reads as much as the ledger holds.', 'Verify everything')) return
|
||||
try { await api.landingVerify(scope); toast('verify pass started'); setTimeout(load, 800) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const last = c.history[0]
|
||||
const hours = Math.round(c.stale_after_sec / 3600)
|
||||
const r = c.running
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Landing tier checks</h2><span className="spacer" />
|
||||
<span className="muted small">did everything that landed reach the pool, intact?</span>
|
||||
</div>
|
||||
<div className="cards full">
|
||||
{c.tiers.length === 0 && <div className="notice">No landing mover is reporting right now (nothing in <span className="mono">/run/landing-mover/</span>), so there is nothing to check. Past passes are below.</div>}
|
||||
{c.tiers.map((t) => (
|
||||
<section className="card" key={t.src}>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (last && !vOK(last) ? 'bad' : t.stale_count ? 'warn' : 'ok')} />
|
||||
<span className="dname">{t.dst}</span>
|
||||
<span className="badge">landing checks</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted small mono">{t.src} → {t.dst}</span>
|
||||
</div>
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Stale</span><span>
|
||||
{t.stale_count
|
||||
? <><span className="warn-text">{fmtN(t.stale_count)} file{t.stale_count === 1 ? '' : 's'}, {fmtBytes(t.stale_bytes)}, waiting over {hours} h</span>{' '}<button className="btn" onClick={() => setShowStale(!showStale)}>{showStale ? 'Hide' : 'Show'}</button></>
|
||||
: `nothing has waited on the landing tier over ${hours} h`}
|
||||
</span></div>
|
||||
<div><span className="k">Last pass</span>{last ? <VerifySummary r={last} /> : <span>never verified</span>}</div>
|
||||
<div><span className="k">Covered</span><span>{t.cursor ? `every file moved up to ${new Date(t.cursor).toLocaleString()} has been checked at least once` : 'nothing checked yet'}<span className="muted"> · ledger {t.ledger}</span></span></div>
|
||||
</div>
|
||||
{showStale && t.stale.length > 0 && (
|
||||
<div className="snap-list" style={{ marginTop: 6 }}>{t.stale.map((f) => (
|
||||
<div key={f.path} className="snap">
|
||||
<span className="mono snap-name" title={f.path}>{f.path}</span>
|
||||
<span className="muted snap-when">landed {new Date(f.mtime).toLocaleString()}</span>
|
||||
<span className="muted snap-exp">waiting {dur((Date.now() - f.mtime) / 1000)}</span>
|
||||
<span className="num muted">{fmtBytes(f.size)}</span>
|
||||
</div>
|
||||
))}{t.stale_count > t.stale.length && <div className="muted small">and {fmtN(t.stale_count - t.stale.length)} more</div>}</div>
|
||||
)}
|
||||
{r && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="sub-head"><span className="muted small">Verifying {r.scope === 'all' ? 'everything' : 'new moves'} · {fmtN(r.done)} of {fmtN(r.total)} files · {fmtBytes(r.bytes_read)} of {fmtBytes(r.bytes_all)}</span><span className="spacer" /><span className="mono small muted" title={r.current}>{r.current}</span></div>
|
||||
<Meter frac={r.bytes_all > 0 ? r.bytes_read / r.bytes_all : r.total > 0 ? r.done / r.total : 0} cls="" />
|
||||
</div>
|
||||
)}
|
||||
{last && <Issues r={last} />}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={running} onClick={() => verify('new')}>{running ? 'Verifying…' : 'Verify new moves'}</Btn>
|
||||
<Btn className="btn" disabled={running} onClick={() => verify('all')}>Verify everything</Btn>
|
||||
</div>
|
||||
{c.history.length > 1 && (
|
||||
<details style={{ marginTop: 6 }}>
|
||||
<summary className="muted small">Earlier passes</summary>
|
||||
<div className="props rows" style={{ marginTop: 4 }}>{c.history.slice(1).map((h, i) => (
|
||||
<div key={i}><span className="k">{new Date(h.finished).toLocaleString()}</span><VerifySummary r={h} /></div>
|
||||
))}</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Verify schedules</h2><span className="spacer" />
|
||||
<Btn className="btn on" onClick={() => setEditing({ ...VBLANK })}>+ New schedule</Btn>
|
||||
</div>
|
||||
{c.policies.length === 0 && <div className="notice">No verify runs on a schedule. A daily pass of new moves checks every file once, soon after it lands.</div>}
|
||||
<div className="cards full">{c.policies.map(({ policy, cron, next, last }) => (
|
||||
<VerifyScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
))}</div>
|
||||
{editing && <VerifyDialog p={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -475,19 +861,20 @@ export default function ZFS() {
|
||||
<div className="dash">
|
||||
<div className="dash-bar"><h2 className="section">Pools</h2></div>
|
||||
{zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
||||
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
||||
<div className="cards full">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Cache</h2><span className="spacer" />
|
||||
<span className="muted small">the ARC in RAM, and what each pool has in front of its disks</span>
|
||||
</div>
|
||||
<ARCSection />
|
||||
<LandingChecks />
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
|
||||
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
|
||||
</div>
|
||||
{ov.external && <ExternalNotice x={ov.external} mine={mine} onAdopt={() => setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />}
|
||||
{ov.policies.length === 0 && !ov.external && <div className="notice">Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.</div>}
|
||||
<div className="cards">{ov.policies.map(({ policy, cron, next, last }) => (
|
||||
<div className="cards full">{ov.policies.map(({ policy, cron, next, last }) => (
|
||||
<ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
))}</div>
|
||||
|
||||
|
||||
+66
-69
@@ -4,7 +4,12 @@ export class Unauthenticated extends Error { constructor(msg = 'unauthenticated'
|
||||
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
|
||||
// the machine list itself) pass local=true and are never rewritten.
|
||||
let base = ''
|
||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
|
||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : ''; machine = name ?? '' }
|
||||
let machine = ''
|
||||
// The machine the console is looking at: '' is this host.
|
||||
export const currentMachine = () => machine
|
||||
// A path under another machine's /api, whichever one the console is looking at.
|
||||
export const nodeAPI = (node: string, p: string) => (node && p.startsWith('/api/') ? `/api/nodes/${node}${p.slice(4)}` : p)
|
||||
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
|
||||
|
||||
// A machine that just failed to answer is left alone for a few seconds instead of
|
||||
@@ -66,11 +71,11 @@ export const titleOf = (name: string | null, machines: MachineStatus[], hubHost:
|
||||
export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string }
|
||||
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
|
||||
|
||||
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
|
||||
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number; pkg?: number }
|
||||
export interface Fan { chip: string; label: string; rpm: number }
|
||||
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
|
||||
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||
export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string }
|
||||
export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string; source?: string }
|
||||
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; gpu?: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
||||
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
|
||||
@@ -81,11 +86,11 @@ export interface GPUClient { pid: number; name: string; usage: number; engines?:
|
||||
export interface GPU {
|
||||
name: string; driver: string; bus?: string; integrated?: boolean
|
||||
usage: number; usage_src?: 'idle' | 'busy' | 'clients'; engines?: Record<string, number>
|
||||
temp?: number; temp_src?: 'gpu' | 'package'; power_w?: number; power_cap_w?: number; fan_rpm?: number
|
||||
temp?: number; temp_src?: 'gpu' | 'package'; power_w?: number; power_cap_w?: number; fan_rpm?: number; fan_pct?: number
|
||||
mhz?: number; req_mhz?: number; max_mhz?: number; mem_used?: number; mem_total?: number
|
||||
clients: GPUClient[]; clients_ok: boolean; stalled?: boolean
|
||||
}
|
||||
export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number }
|
||||
export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_power_pkgs_w?: number[]; cpu_temp?: number; core_ids?: number[]; core_pkgs?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number }
|
||||
export interface Sample {
|
||||
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
|
||||
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
||||
@@ -180,7 +185,6 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
|
||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
||||
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||
|
||||
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
||||
|
||||
// ---- the Network tab ----
|
||||
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
||||
@@ -247,13 +251,51 @@ export interface ARCDetail {
|
||||
dbuf: { Size: number; Target: number; Hits: number; Misses: number }
|
||||
pools: PoolCaching[]
|
||||
}
|
||||
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
|
||||
export interface LandingMover {
|
||||
state: 'idle' | 'moving' | 'holding' | 'stopped' | string; src: string; dst: string; pid: number; started_ms: number; updated_ms: number; stale: boolean
|
||||
queue: number; settle: number; bwlimit: string; batch_files: number; batch_bytes: number; batch_start_ms: number; batch_first: string
|
||||
current: string; current_size: number; partial: number; moved_files: number; moved_bytes: number
|
||||
last_move: string; last_move_ms: number; last_error: string; last_error_ms: number
|
||||
}
|
||||
export interface Landing {
|
||||
mount: string; branch: string; pool_branch: string; device?: string; fstype?: string; size: number; used: number
|
||||
pending_files: number; pending_bytes: number; pending_partial?: boolean; oldest_ms?: number; counted_ms: number
|
||||
in_bps: number; drain_bps: number; mover?: LandingMover
|
||||
}
|
||||
export interface PoolWrites { pool: string; write_bps: number; logical_bps: number; landing?: Landing }
|
||||
export interface ZFSWrites {
|
||||
dirty: number; dirty_max: number; delay_min_pct: number; limit: number; delayed_per_sec: number; over_max_per_sec: number; txg_timeout_sec: number
|
||||
pools: PoolWrites[]
|
||||
}
|
||||
export interface VerifyPolicy { id: string; name: string; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; scope: 'new' | 'all'; enabled: boolean }
|
||||
export interface VerifyIssue { path: string; kind: 'mismatch' | 'gone' | 'unreadable'; moved: number; size: number; note?: string }
|
||||
export interface VerifyResult {
|
||||
id: string; scope: 'new' | 'all'; started: string; finished: string; src: string; dst: string; since: number
|
||||
entries: number; checked: number; matched: number; mismatched: number; changed: number; gone: number; waiting: number; unreadable: number; unhashed: number
|
||||
bytes_read: number; issues: VerifyIssue[]; error?: string; cursor: number
|
||||
}
|
||||
export interface VerifyProgress { id: string; scope: string; pid: number; started: string; updated: string; total: number; done: number; bytes_read: number; bytes_all: number; current: string }
|
||||
export interface StaleFile { path: string; size: number; mtime: number }
|
||||
export interface LandingCheck {
|
||||
tiers: { src: string; dst: string; ledger: string; cursor: number; stale_count: number; stale_bytes: number; stale: StaleFile[] }[]
|
||||
stale_after_sec: number; running: VerifyProgress | null; history: VerifyResult[]; cron_file: string
|
||||
policies: { policy: VerifyPolicy; cron: string; next?: string; last: VerifyResult | null }[]
|
||||
}
|
||||
export interface ARCView { detail: ARCDetail; live: ARCLive | null; writes: ZFSWrites | null }
|
||||
|
||||
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' | '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 }
|
||||
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 { running: boolean; done: boolean; error?: string; progress: number; speed: number; segments: number }
|
||||
export interface PlayTrack { codec: string; profile?: string; pix_fmt?: string; width?: number; height?: number; fps?: number; channels?: number; spherical?: boolean }
|
||||
export interface PlayRung { name: string; label: string; width: number; height: number; fps: number; bitrate: number; peak: number }
|
||||
export interface PlayStatus { engine?: string; quality?: string; from: number; ahead: number; paused?: boolean }
|
||||
// A play: where it is made (node, '' the hub), its qualities best first, and the playlist under
|
||||
// that machine's /api.
|
||||
export interface PlayOpen {
|
||||
node: string; master: string; engine: string; host: string; note?: string; status: PlayStatus
|
||||
play: { id: string; name: string; device: string; remote: boolean; rungs: PlayRung[]; info: { container: string; duration: number; bit_rate?: number; size?: number; video?: PlayTrack; audio?: PlayTrack } }
|
||||
}
|
||||
export interface TranscodeDevice { id: string; name: string; integrated?: boolean; ok: boolean; reason?: string }
|
||||
export interface TranscodeMachine { node: string; host: string; devices?: TranscodeDevice[]; error?: string }
|
||||
export interface TranscodeSetting { selected: { node: string; device: string } | null; machines: TranscodeMachine[] }
|
||||
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
|
||||
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
|
||||
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }
|
||||
@@ -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}`
|
||||
|
||||
// ---- Virtualization: Incus, per machine ----
|
||||
export interface VirtLayout { driver: string; pool_source: string; pool_size_gib?: number; bridge: string; subnet: string; docker_rules: boolean; arc_max_gib?: number; attempted: string; hamlab_version?: string }
|
||||
export interface VirtStatus { state: 'ready' | 'repair' | 'installable' | 'unavailable'; reason?: string; kvm: boolean; arch: string; version?: string; pm?: string; drivers: string[]; docker: boolean; zfs_host: boolean; arc_max_gib?: number; mem_total: number; mem_avail: number; disk_free: number; dir: string; subnet: string; pool_size_gib?: number; layout?: VirtLayout; user: string; root: boolean; unit?: string; btrfs_tools: boolean }
|
||||
export interface VirtSnapshot { name: string; created: string; expires?: string; size?: number }
|
||||
export interface VirtInstance { name: string; type: 'container' | 'virtual-machine'; status: string; arch: string; description?: string; image: string; os?: string; release?: string; cpu?: string; memory?: string; created: string; last_used: string; ipv4: string[]; snapshots: VirtSnapshot[]; profiles: string[]; ephemeral: boolean; cpu_pct: number; mem_usage: number; mem_peak: number; disk_usage: number; disk_total: number; pid: number; processes: number; config: Record<string, string>; devices: Record<string, Record<string, string>>; schedule?: string; expiry?: string }
|
||||
export interface VirtImage { fingerprint: string; aliases: string[]; arch: string; type: string; size: number; created: string; last_used: string; description: string; os?: string; release?: string; variant?: string; cached: boolean; source?: string; public: boolean }
|
||||
export interface VirtPool { name: string; driver: string; source: string; where: string; used: number; total: number; used_by: number }
|
||||
export interface VirtNetwork { name: string; type: string; managed: boolean; address?: string; nat: boolean; used_by: number }
|
||||
export interface LibraryFile { name: string; size: number; modified: string; attached_to?: string[] }
|
||||
export interface ExportConfig { enabled: boolean; hour: number; minute: number; keep: number; instance_only: boolean; instances?: string[]; updated?: string }
|
||||
export interface ExportRun { at: string; exported: string[]; pruned: string[]; errors?: string[]; seconds: number }
|
||||
export interface ExportFile { instance: string; name: string; size: number; modified: string }
|
||||
export interface ExportsView { config: ExportConfig; cron: string; next?: string; last: ExportRun | null; files: ExportFile[]; dir: string; cron_file: string }
|
||||
export interface FromISO { name: string; iso: string; cpu: string; memory: string; disk: string; pool: string; network: string; secureboot: boolean; start: boolean }
|
||||
export interface VirtOverview { status: VirtStatus; error?: string; instances?: VirtInstance[]; images?: VirtImage[]; pools?: VirtPool[]; default_pool?: string; networks?: VirtNetwork[]; library: { images: LibraryFile[]; isos: LibraryFile[] } }
|
||||
export interface VirtInstallOpts { driver: string; pool_size_gib: number; subnet: string; docker_rules: boolean; arc_max_gib: number }
|
||||
export interface VirtCreate { name: string; image: string; vm: boolean; cpu: string; memory: string; disk: string; pool: string; network: string; cloud_init: string; profiles: string[]; description: string; start: boolean }
|
||||
|
||||
export const api = {
|
||||
procs: () => req<ProcTable>('GET', '/api/procs'),
|
||||
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
|
||||
@@ -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) }
|
||||
return r.text()
|
||||
},
|
||||
// Playing: opened on the hub, which picks the machine (the Transcode device setting); the
|
||||
// rest goes to that machine.
|
||||
play: (path: string, sudo: boolean) => req<PlayOpen>('POST', '/api/play', { node: currentMachine(), path, sudo }, true),
|
||||
playStatus: (node: string, id: string) => req<PlayStatus>('GET', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||
playStop: (node: string, id: string) => req<void>('DELETE', nodeAPI(node, `/api/files/play/${id}`), undefined, true),
|
||||
transcode: () => req<TranscodeSetting>('GET', '/api/transcode', undefined, true),
|
||||
transcodeSelected: () => req<{ selected: { node: string; device: string } | null; host?: string; name?: string }>('GET', '/api/transcode?only=selected', undefined, true),
|
||||
setTranscode: (node: string, device: string) => req<TranscodeSetting>('PUT', '/api/transcode', device ? { node, device } : {}, true),
|
||||
fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`),
|
||||
fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${q(path, sudo)}`),
|
||||
// v carries the file's mtime and size, so a changed file is a new URL and an unchanged one a cached one
|
||||
@@ -304,11 +336,6 @@ export const api = {
|
||||
thumbStatus: (sudo: boolean, refresh = false) => req<ThumbStatus>('GET', `/api/files/thumbs?${sudo ? 'sudo=1&' : ''}${refresh ? 'refresh=1' : ''}`),
|
||||
thumbInstall: () => req<TaskInfo>('POST', '/api/files/thumbs/install'),
|
||||
thumbGPUAccess: () => req<TaskInfo>('POST', '/api/files/thumbs/gpu-access'),
|
||||
// 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) => {
|
||||
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()
|
||||
@@ -332,12 +359,13 @@ export const api = {
|
||||
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
||||
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
||||
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
||||
// Always the hub's: it holds every machine's KVM, so the tab works when the machine does not.
|
||||
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
|
||||
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
|
||||
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
||||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||||
zfsLanding: () => req<LandingCheck>('GET', '/api/zfs/landing'),
|
||||
landingVerify: (scope: 'new' | 'all') => req<{ started: string }>('POST', '/api/zfs/landing/verify', { scope }),
|
||||
saveVerifySchedule: (p: Partial<VerifyPolicy>) => req<VerifyPolicy>('PUT', '/api/zfs/landing/schedules', p),
|
||||
deleteVerifySchedule: (id: string) => req<void>('DELETE', `/api/zfs/landing/schedules/${encodeURIComponent(id)}`),
|
||||
runVerifySchedule: (id: string) => req<{ started: string }>('POST', `/api/zfs/landing/schedules/${encodeURIComponent(id)}/run`),
|
||||
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||||
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
||||
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
|
||||
@@ -446,37 +474,6 @@ export const api = {
|
||||
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||||
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
|
||||
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
|
||||
virt: () => req<VirtOverview>('GET', '/api/virt'),
|
||||
virtInstall: (o: VirtInstallOpts) => req<TaskInfo>('POST', '/api/virt/install', o),
|
||||
virtRemote: (type: string, refresh = false) => req<{ images: VirtImage[] }>('GET', `/api/virt/images/remote?type=${type}${refresh ? '&refresh=1' : ''}`),
|
||||
virtImageDownload: (alias: string) => req<TaskInfo>('POST', '/api/virt/images/download', { alias }),
|
||||
virtImageImport: (path: string, alias: string, type: string) => req<TaskInfo>('POST', '/api/virt/images/import', { path, alias, type }),
|
||||
virtImagePublish: (instance: string, alias: string) => req<TaskInfo>('POST', '/api/virt/images/publish', { instance, alias }),
|
||||
virtImageDelete: (ref: string) => req<void>('POST', '/api/virt/images/delete', { ref }),
|
||||
virtCreate: (c: VirtCreate) => req<TaskInfo>('POST', '/api/virt/instances', c),
|
||||
virtAction: (name: string, action: 'start' | 'stop' | 'restart' | 'kill') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${action}`),
|
||||
virtDelete: (name: string, force: boolean) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/delete`, { confirm: name, force }),
|
||||
virtTerminal: (name: string, kind: 'console' | 'exec') => req<{ id: string; name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/${kind}`),
|
||||
virtRename: (name: string, to: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/rename`, { name: to }),
|
||||
virtConfig: (name: string, cfg: Record<string, string>) => req<void>('PUT', `/api/virt/instances/${encodeURIComponent(name)}/config`, cfg),
|
||||
virtSnapshot: (name: string, snap: string) => req<{ name: string }>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots`, { name: snap }),
|
||||
virtSnapshotAction: (name: string, snap: string, action: 'restore' | 'delete') => req<void>('POST', `/api/virt/instances/${encodeURIComponent(name)}/snapshots/${encodeURIComponent(snap)}/${action}`),
|
||||
virtISOFetch: (url: string, sha256: string, name: string) => req<TaskInfo>('POST', '/api/virt/isos/fetch', { url, sha256, name }),
|
||||
virtISOImport: (path: string) => req<TaskInfo>('POST', '/api/virt/isos/import', { path }),
|
||||
virtISODelete: (name: string) => req<void>('POST', '/api/virt/isos/delete', { name }),
|
||||
virtISORename: (name: string, to: string) => req<void>('POST', '/api/virt/isos/rename', { name, to }),
|
||||
virtISOAttach: (inst: string, iso: string, bootFirst: boolean) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso`, { iso, boot_first: bootFirst }),
|
||||
virtISODetach: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/iso/detach`, { device }),
|
||||
virtFromISO: (f: FromISO) => req<TaskInfo>('POST', '/api/virt/instances/from-iso', f),
|
||||
virtMountAdd: (inst: string, m: { device: string; source: string; path: string; readonly: boolean; shift: boolean }) => req<{ device: string }>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts`, m),
|
||||
virtMountRemove: (inst: string, device: string) => req<void>('POST', `/api/virt/instances/${encodeURIComponent(inst)}/mounts/remove`, { device }),
|
||||
virtExports: () => req<ExportsView>('GET', '/api/virt/exports'),
|
||||
virtExportsPut: (c: ExportConfig) => req<void>('PUT', '/api/virt/exports', c),
|
||||
virtExportsRun: (instance = '') => req<TaskInfo>('POST', '/api/virt/exports/run', { instance }),
|
||||
virtExportDelete: (instance: string, name: string) => req<void>('POST', '/api/virt/exports/delete', { instance, name }),
|
||||
virtSSHKeys: () => req<{ keys: string[]; user: string }>('GET', '/api/virt/sshkeys'),
|
||||
virtPoolCreate: (name: string, path: string) => req<{ name: string; path: string }>('POST', '/api/virt/pools', { name, path }),
|
||||
virtPoolDelete: (name: string) => req<void>('POST', '/api/virt/pools/delete', { name }),
|
||||
}
|
||||
|
||||
export function wsURLPath(path: string): string {
|
||||
|
||||
+20
-6
@@ -16,26 +16,40 @@
|
||||
// Returns one value per column, oldest first, right-aligned: a young curve has fewer columns
|
||||
// than the box and draws in from the right.
|
||||
export function columnsOf(values: number[], advance: number, cap: number, cols: number): number[] {
|
||||
return columnsAt(values, advance, advance + 1, cap, cols).cols.slice(-cols)
|
||||
}
|
||||
|
||||
// The same columns, placed in time. `now` is how far the curve has got in points: `advance`
|
||||
// plus the share of the newest point's stride already gone (0–1], so a curve whose points are
|
||||
// several ticks apart still moves on every tick instead of jumping once a point. `edge` is how
|
||||
// much of the newest column is showing (0–1]: the box draws it that far in from the right and
|
||||
// everything else behind it, so the whole graph slides a fraction of a column each tick. One
|
||||
// more column than the box holds comes back, to fill the sliver the slide opens on the left.
|
||||
// `last` is the newest column's number, for a caller that follows a column as it slides.
|
||||
export function columnsAt(values: number[], advance: number, now: number, cap: number, cols: number): { cols: number[]; edge: number; last: number } {
|
||||
const n = Math.min(values.length, cap)
|
||||
if (n === 0 || cols < 1 || cap < 1) return []
|
||||
if (n === 0 || cols < 1 || cap < 1) return { cols: [], edge: 1, last: 0 }
|
||||
const at = (a: number) => values[values.length - 1 - (advance - a)] // the point numbered a
|
||||
const oldest = advance - n + 1
|
||||
const r = cols / cap
|
||||
const out: number[] = []
|
||||
if (cols >= cap) {
|
||||
const kLast = Math.ceil(((advance + 1) * cols) / cap) - 1
|
||||
for (let k = kLast - cols + 1; k <= kLast; k++) {
|
||||
const kLast = Math.ceil(now * r) - 1
|
||||
for (let k = kLast - cols; k <= kLast; k++) {
|
||||
const a = Math.floor((k * cap) / cols)
|
||||
if (a >= oldest && a <= advance) out.push(at(a))
|
||||
}
|
||||
return out
|
||||
return { cols: out, edge: Math.min(1, Math.max(0, now * r - kLast)) || 1, last: kLast }
|
||||
}
|
||||
// the newest column is the newest point's; until the next point lands in the next column the
|
||||
// slide waits at its edge, rather than show a column with nothing in it
|
||||
const kLast = Math.floor((advance * cols) / cap)
|
||||
for (let k = kLast - cols + 1; k <= kLast; k++) {
|
||||
for (let k = kLast - cols; k <= kLast; k++) {
|
||||
const a0 = Math.max(oldest, Math.ceil((k * cap) / cols)), a1 = Math.min(advance, Math.ceil(((k + 1) * cap) / cols) - 1)
|
||||
if (a1 < a0) continue
|
||||
let peak = at(a0)
|
||||
for (let a = a0 + 1; a <= a1; a++) peak = Math.max(peak, at(a))
|
||||
out.push(peak)
|
||||
}
|
||||
return out
|
||||
return { cols: out, edge: Math.min(1, Math.max(0, now * r - kLast)) || 1, last: kLast }
|
||||
}
|
||||
|
||||
@@ -47,11 +47,11 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
|
||||
t: Date.now(), interval_ms: num(v, 'hamlab.sample_interval'),
|
||||
cpu: num(v, 'cpu.usage'), cores, load: [num(v, 'load.1'), num(v, 'load.5'), num(v, 'load.15')],
|
||||
mem: mem('mem'), swap: mem('swap'), uptime: num(v, 'uptime'),
|
||||
temps: hw('temp.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, c: num(v, id), max: s.warn, crit: s.crit })),
|
||||
temps: hw('temp.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, c: num(v, id), max: s.warn, crit: s.crit, pkg: s.labels!.package !== undefined ? Number(s.labels!.package) : undefined })),
|
||||
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
|
||||
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
|
||||
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
|
||||
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk })),
|
||||
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk, source: cat.get(p + '.size')?.labels?.source })),
|
||||
ups: v['ups.battery'] === undefined ? null : {
|
||||
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
|
||||
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Which threads belong to which CPU, on a machine with more than one.
|
||||
//
|
||||
// The kernel numbers every thread of the machine in one run, and on a two-socket board with
|
||||
// hyper-threading it deals them out a socket at a time and then goes round again: cpu 0–9 are
|
||||
// CPU 1's cores, 10–19 CPU 2's, 20–29 the second threads of CPU 1's, 30–39 of CPU 2's. Listed
|
||||
// under its CPU by that number, a 20-thread processor reads "C0…C9, C20…C29" and the one
|
||||
// beside it "C10…C19, C30…C39" — neither looks like twenty of anything. So each CPU's threads
|
||||
// are counted from 0 under it, in the kernel's order, the way a one-socket machine's are: the
|
||||
// first half are its cores, the second half their second threads.
|
||||
//
|
||||
// `pkgs[i]` is the socket logical CPU `i` is on. The answer is, for each socket (ascending),
|
||||
// the logical CPUs on it (ascending); a thread's number under its CPU is its place in that list.
|
||||
export function socketThreads(pkgs: number[]): { pkg: number; cpus: number[] }[] {
|
||||
const by = new Map<number, number[]>()
|
||||
pkgs.forEach((pk, i) => { const l = by.get(pk); if (l) l.push(i); else by.set(pk, [i]) })
|
||||
return [...by.entries()].sort((a, b) => a[0] - b[0]).map(([pkg, cpus]) => ({ pkg, cpus }))
|
||||
}
|
||||
+96
-46
@@ -5,6 +5,10 @@
|
||||
--s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18);
|
||||
--btn-fg: #111; --term-bg: var(--term-bg); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--header-h: 44px; --footer-h: 28px; --rail-w: 220px; --tabs-h: 34px;
|
||||
/* iOS 26+ draws a soft blur under the status bar of a Home Screen app that reaches ~16pt
|
||||
below it (hamlet docs/gotchas.md). Whatever starts at the safe area starts below that band
|
||||
too. Zero where there is no status bar to clear: desktop, a Safari tab. */
|
||||
--blur: min(env(safe-area-inset-top), 16px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
@@ -30,7 +34,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.svc-list > .error, .cron-list > .error { margin: 8px 12px; }
|
||||
|
||||
/* layout */
|
||||
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
|
||||
.app { height: 100%; display: flex; flex-direction: column; padding-top: calc(env(safe-area-inset-top) + var(--blur)); }
|
||||
.header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); }
|
||||
.brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
|
||||
.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
@@ -95,6 +99,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.dash > * { flex-shrink: 0; }
|
||||
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; align-items: start; }
|
||||
.cards.full { grid-template-columns: minmax(0, 1fr); } /* one card per row, the width of the page */
|
||||
.tile, .card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; }
|
||||
.tile .label, .card h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
.tile .value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
||||
@@ -145,6 +150,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.svc-tools { padding: 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||
.svc-tools input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||
.chip.on { color: var(--fg); border-color: var(--accent); } .chip.bad { color: var(--bad); }
|
||||
.chip.off { opacity: .55; cursor: default; } .chip.off:hover { color: var(--muted); border-color: var(--border); }
|
||||
.tc-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.tc-host { font-weight: 600; margin-bottom: 6px; }
|
||||
.svc-rows { flex: 1; overflow-y: auto; }
|
||||
.svc-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||
.svc-row:hover { background: var(--bg3); } .svc-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
@@ -189,6 +197,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
/* One property per row, label above its value. For panes like Inspect, whose values are
|
||||
long paths and lists — side by side they were half truncated and read as columns. */
|
||||
/* One property per row, label beside its value, the value wrapping rather than cut off: for
|
||||
sentences, like the ZFS tab's write path, that columns would truncate. */
|
||||
.props.rows { grid-template-columns: minmax(0, 1fr); gap: 3px 0; }
|
||||
.props.rows .k { width: 96px; }
|
||||
.props.rows span:not(.k) { overflow: visible; white-space: normal; overflow-wrap: anywhere; }
|
||||
.props.stacked { grid-template-columns: 1fr; gap: 10px 0; }
|
||||
.props.stacked > div { flex-direction: column; gap: 2px; }
|
||||
.props.stacked .k { width: auto; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
@@ -261,7 +274,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
than what is left, and scrolls inside itself. Its parts keep their height: in a column that
|
||||
may shrink, anything with its own scrollbar (a preview, a list) is squeezed to a sliver
|
||||
instead, and the dialog never scrolls at all. */
|
||||
.modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
|
||||
.modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: max(16px, calc(env(safe-area-inset-top) + var(--blur))) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
|
||||
.modal { --modal-bg: var(--bg2); width: min(420px, 100%); max-height: 100%; overflow-y: auto; overscroll-behavior: contain; background: var(--modal-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.modal > * { flex-shrink: 0; }
|
||||
/* a long dialog's buttons (and what went wrong) stay in reach while the rest scrolls */
|
||||
@@ -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 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; }
|
||||
.player { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.player .preview { flex: 1; min-height: 0; }
|
||||
.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); }
|
||||
.play-bar select { padding: 4px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 12px; }
|
||||
.play-pick { margin: 12px; }
|
||||
.play-pick .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
.play-pick label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.preview.pic { position: relative; touch-action: pan-y pinch-zoom; }
|
||||
.step-edge { position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 72px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, .35); color: #fff; font-size: 34px; line-height: 1; cursor: pointer; opacity: .55; }
|
||||
.step-edge:hover { opacity: 1; } .step-edge.prev { left: 8px; } .step-edge.next { right: 8px; }
|
||||
.btn.step:disabled { opacity: .4; }
|
||||
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
||||
on an iPhone the whole window */
|
||||
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
||||
.vp.max { position: fixed; inset: 0; z-index: 1000; }
|
||||
.vp.max .vp-note { top: calc(env(safe-area-inset-top) + var(--blur) + 14px); }
|
||||
.vp-v { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
|
||||
.vp-hidden { opacity: 0; pointer-events: none; }
|
||||
.vp-sphere { touch-action: none; cursor: grab; }
|
||||
.vp-sphere:active { cursor: grabbing; }
|
||||
.vp-bottom { position: absolute; left: 0; right: 0; bottom: 0; padding: 26px 8px calc(env(safe-area-inset-bottom) + 4px); background: linear-gradient(transparent, rgba(0, 0, 0, .75)); transition: opacity .25s; z-index: 2; }
|
||||
.vp.bare .vp-bottom, .vp.bare .vp-hint { opacity: 0; pointer-events: none; }
|
||||
.vp.bare { cursor: none; }
|
||||
.vp-row { display: flex; align-items: center; gap: 4px; }
|
||||
.vp-icon { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 19px; color: #fff; }
|
||||
.vp-icon:hover { background: rgba(255, 255, 255, .14); }
|
||||
.vp-icon.on { color: var(--accent); }
|
||||
.vp-time { font-size: 13px; font-variant-numeric: tabular-nums; padding: 0 6px; white-space: nowrap; }
|
||||
.vp-q { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 17px; font-size: 13px; font-weight: 600; white-space: nowrap; color: #fff; }
|
||||
.vp-q:hover { background: rgba(255, 255, 255, .14); }
|
||||
.vp-seek { position: relative; height: 24px; margin: 0 8px; display: flex; align-items: center; }
|
||||
.vp-seek::before { content: ''; position: absolute; left: 0; right: 0; height: 4px; border-radius: 2px;
|
||||
background: linear-gradient(to right, var(--accent) var(--p), rgba(255, 255, 255, .45) var(--p) var(--b), rgba(255, 255, 255, .2) var(--b)); }
|
||||
.vp-seek input { position: relative; width: 100%; margin: 0; background: transparent; border: 0; padding: 0; height: 24px; -webkit-appearance: none; appearance: none; cursor: pointer; }
|
||||
.vp-seek input::-webkit-slider-runnable-track { height: 4px; background: transparent; }
|
||||
.vp-seek input::-moz-range-track { height: 4px; background: transparent; }
|
||||
.vp-seek input::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; margin-top: -5px; border-radius: 50%; background: var(--accent); border: 0; }
|
||||
.vp-seek input::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 0; }
|
||||
.vp-big { position: absolute; left: 50%; top: 50%; width: 68px; height: 68px; margin: -34px 0 0 -34px; border-radius: 50%; display: grid; place-items: center; background: rgba(0, 0, 0, .55); color: #fff; z-index: 2; padding-left: 4px; }
|
||||
.vp-spin { position: absolute; left: 50%; top: 50%; width: 46px; height: 46px; margin: -23px 0 0 -23px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .2); border-top-color: var(--accent); animation: vp-spin .8s linear infinite; z-index: 1; pointer-events: none; }
|
||||
@keyframes vp-spin { to { transform: rotate(360deg); } }
|
||||
.vp-err { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; text-align: center; z-index: 1; }
|
||||
.vp-err p { margin: 0; max-width: 460px; line-height: 1.45; }
|
||||
.vp-note { position: absolute; left: 50%; top: 14px; transform: translateX(-50%); max-width: min(92%, 480px); padding: 9px 13px; border-radius: 8px; background: rgba(20, 20, 20, .92); font-size: 13px; line-height: 1.4; z-index: 3; cursor: pointer; }
|
||||
.vp-hint { position: absolute; left: 50%; bottom: 86px; transform: translateX(-50%); padding: 5px 11px; border-radius: 14px; background: rgba(0, 0, 0, .55); font-size: 12px; pointer-events: none; transition: opacity .25s; z-index: 1; white-space: nowrap; }
|
||||
.vp-menu { position: absolute; right: 8px; bottom: 60px; width: min(290px, calc(100% - 16px)); max-height: calc(100% - 76px); overflow-y: auto; padding: 6px; border-radius: 10px; background: rgba(24, 24, 24, .96); box-shadow: 0 8px 30px rgba(0, 0, 0, .5); z-index: 4; }
|
||||
.vp-menu-h { font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: rgba(255, 255, 255, .55); padding: 6px 10px 4px; }
|
||||
.vp-menu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; border-radius: 6px; text-align: left; font-size: 14px; color: #fff; }
|
||||
.vp-menu button:hover:not(:disabled) { background: rgba(255, 255, 255, .1); }
|
||||
.vp-menu button:disabled { opacity: .45; }
|
||||
.vp-menu button small { margin-left: auto; color: rgba(255, 255, 255, .6); font-size: 12px; white-space: nowrap; }
|
||||
.vp-menu .ck { width: 16px; flex: none; color: var(--accent); display: grid; place-items: center; }
|
||||
.vp-menu-f { border-top: 1px solid rgba(255, 255, 255, .12); margin-top: 6px; padding: 8px 10px 4px; font-size: 12px; color: rgba(255, 255, 255, .6); line-height: 1.4; }
|
||||
.vp-menu-f b { color: #fff; font-weight: 600; }
|
||||
.preview.audio { align-items: flex-start; }
|
||||
.audio-box { display: flex; flex-direction: column; align-items: center; gap: 10px; width: min(560px, 100%); margin-top: 8vh; }
|
||||
.audio-box .ficon { font-size: 48px; }
|
||||
@@ -741,6 +794,11 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.t-cores-head b { color: var(--fg); font-weight: 400; overflow: hidden; text-overflow: ellipsis; }
|
||||
.t-core { display: flex; align-items: center; gap: 6px; }
|
||||
.t-core-n { width: 30px; } .t-core-p { width: 34px; text-align: right; }
|
||||
/* a two-socket board: a row per socket — arrow, name, watts — with its threads indented under it */
|
||||
.t-cores.multi .t-core-n { width: 50px; } .t-cores.multi .t-core:not(.t-sock) .t-core-n { padding-left: 14px; box-sizing: border-box; }
|
||||
.t-sock { color: var(--fg); margin-top: 2px; cursor: pointer; user-select: none; }
|
||||
.t-fold { display: inline-block; width: 12px; color: var(--muted); }
|
||||
.t-core-w { width: 40px; flex-shrink: 0; text-align: right; color: var(--muted); }
|
||||
.t-core-g { flex: 1; color: var(--ok); letter-spacing: 0; overflow: hidden; white-space: nowrap; }
|
||||
.t-core .tm { flex: 1; } .t-core-t { flex: 0 0 62px; display: flex; } .t-core-c { width: 42px; text-align: right; color: var(--t-blue); }
|
||||
.t-load { display: flex; margin-top: 2px; }
|
||||
@@ -841,6 +899,8 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.arc-tier { display: flex; gap: 8px; min-width: 0; } .arc-tier .k { color: var(--muted); width: 80px; flex-shrink: 0; cursor: help; }
|
||||
.arc-tier-v { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .arc-tier-v > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.arc-tier-v .none { color: var(--muted); white-space: normal; }
|
||||
.landing-tier { border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 4px 0 6px; font-size: 13px; }
|
||||
.landing-tier .props .badge { margin-right: 2px; }
|
||||
|
||||
/* ---- Space: what is using a disk ---- */
|
||||
.space { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||
@@ -908,43 +968,33 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
||||
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||
.modal .chips { margin-top: 2px; }
|
||||
|
||||
/* ---- KVM: a device's own web page, framed ---- */
|
||||
.kvm { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||
.kvm-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-wrap: wrap; }
|
||||
.kvm-bar .kvm-url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 28ch; }
|
||||
.kvm-frame { flex: 1; min-height: 0; background: #000; position: relative; }
|
||||
.kvm-frame iframe { width: 100%; height: 100%; border: 0; display: block; background: #000; }
|
||||
.kvm-frame.full { background: #000; }
|
||||
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
||||
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
||||
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
|
||||
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
|
||||
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; }
|
||||
.kvm-frame.expanded iframe { height: 100%; }
|
||||
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
|
||||
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
||||
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
||||
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
||||
|
||||
/* ---- Virtualization ---- */
|
||||
.virt .svc-tools .task { max-height: 45vh; }
|
||||
.virt-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.virt-table th, .virt-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.virt-table th { font-weight: 500; opacity: .7; }
|
||||
.virt-table td.num, .virt-table th.num { text-align: right; white-space: nowrap; }
|
||||
.virt-pick { max-height: 240px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.virt-pick .svc-row { padding: 6px 10px; }
|
||||
.virt-kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; font-size: 12px; word-break: break-all; }
|
||||
.virt-form-row { display: flex; gap: 8px; }
|
||||
.virt-form-row > label { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.virt-form-row > label > input, .virt-form-row > label > select { width: 100%; box-sizing: border-box; }
|
||||
.virt .modal { max-width: 640px; }
|
||||
.vga-back { align-items: stretch; padding: 10px; }
|
||||
.vga { display: flex; flex-direction: column; gap: 6px; width: 100%; height: 100%; max-width: none; max-height: none; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
|
||||
.vga-bar { flex-shrink: 0; }
|
||||
.vga-area { flex: 1; min-height: 0; overflow: auto; background: #000; border-radius: 6px; display: flex; align-items: flex-start; justify-content: flex-start; padding: 4px; }
|
||||
.vga-area #spice-area { display: inline-block; }
|
||||
.vga-area #spice-screen { display: inline-block; line-height: 0; }
|
||||
.vga-area canvas { outline: none; }
|
||||
.vga-msg { display: none; }
|
||||
.vga-hint { flex-shrink: 0; }
|
||||
/* a phone: the time goes above the seek bar, so the buttons fit in one row */
|
||||
.vp-time.above { display: none; }
|
||||
@media (max-width: 560px) {
|
||||
.vp-time.above { display: block; padding: 0 10px 2px; }
|
||||
.vp-time.inrow { display: none; }
|
||||
}
|
||||
|
||||
|
||||
/* No theme touches playback, whatever it draws. A theme's full-window overlays (the Retro
|
||||
scanlines today, any other tomorrow) are off while a player is open; the picture is never
|
||||
filtered or blended; the player's text is plain, not the theme's lettering. Only the accent
|
||||
colour of its controls follows the theme. */
|
||||
html:has(.vp)::before, html:has(.vp)::after, body:has(.vp)::before, body:has(.vp)::after,
|
||||
.app:has(.vp)::before, .app:has(.vp)::after, #root:has(.vp)::before, #root:has(.vp)::after { display: none !important; }
|
||||
.vp { isolation: isolate; filter: none !important; mix-blend-mode: normal !important; }
|
||||
.vp video, .vp canvas { filter: none !important; mix-blend-mode: normal !important; }
|
||||
.vp { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-weight: 400; letter-spacing: normal; text-shadow: none; }
|
||||
.vp button, .vp input, .vp select { font-family: inherit !important; }
|
||||
|
||||
/* volume: the speaker mutes; the slider beside it (not on iOS, which only mutes) sets the level */
|
||||
.vp-vol { display: inline-flex; align-items: center; }
|
||||
.vp-vol input { width: 72px; height: 24px; margin: 0 6px 0 0; padding: 0; background: transparent; border: 0; -webkit-appearance: none; appearance: none; cursor: pointer; }
|
||||
.vp-vol input::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: linear-gradient(to right, #fff var(--v), rgba(255, 255, 255, .3) var(--v)); }
|
||||
.vp-vol input::-moz-range-track { height: 4px; border-radius: 2px; background: linear-gradient(to right, #fff var(--v), rgba(255, 255, 255, .3) var(--v)); }
|
||||
.vp-vol input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; margin-top: -4px; border-radius: 50%; background: #fff; border: 0; }
|
||||
.vp-vol input::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #fff; border: 0; }
|
||||
@media (max-width: 560px) { .vp-vol input { display: none; } }
|
||||
|
||||
Vendored
-674
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
-1691
File diff suppressed because it is too large
Load Diff
-384
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
Vendored
-181
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
-1345
File diff suppressed because it is too large
Load Diff
-87
@@ -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
@@ -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 };
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
-1508
File diff suppressed because it is too large
Load Diff
-497
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user