Compare commits
24
Commits
@@ -18,6 +18,8 @@ stub tab or a roadmap item.
|
|||||||
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
||||||
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
||||||
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
||||||
|
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
|
||||||
|
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -176,6 +178,44 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms
|
the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms
|
||||||
and 10 min at 2 s. Still to do: persistent
|
and 10 min at 2 s. Still to do: persistent
|
||||||
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
|
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
|
||||||
|
- **Updating from the Updates tab** — **shipped 2026-09-26**: the "what is out of date" list
|
||||||
|
was read-only, so seeing a newer image and doing something about it were different places.
|
||||||
|
Each row now has **Update**, and the header has **Update all**. Updating is per stack —
|
||||||
|
that is the unit compose deals in — so a row acts on whichever stacks use that image, and
|
||||||
|
several stacks run through a queue one at a time rather than recreating half the machine at
|
||||||
|
once, each with its task output on screen. A row that succeeds says "updated just now"
|
||||||
|
instead of re-checking, since a check is a registry request.
|
||||||
|
- **Recreate, and the Updates tab at full width** (2026-09-26): a stack's actions gained
|
||||||
|
**Recreate** (`up -d --force-recreate`), the only way out of a container Docker refuses to
|
||||||
|
start — one pinned to a network that has since been recreated answers "network … not
|
||||||
|
found" to Deploy and Update alike, because as far as compose is concerned nothing about it
|
||||||
|
changed. Found the hard way when a SiYuan update landed in exactly that state. The Updates
|
||||||
|
tab also stopped rendering as a list-and-detail pair: it is a page, not a list, so on a
|
||||||
|
wide window it now sits under the tab strip at full width instead of being squeezed into
|
||||||
|
the right-hand half beside an empty column.
|
||||||
|
- **Cron in English** (`crontext.ts`, 2026-09-26): schedules read "daily at 3:15am" rather
|
||||||
|
than `15 3 * * *`, with the expression kept beside it. Handles lists, ranges, steps, day
|
||||||
|
and month names, `@daily` and friends, weekdays/weekends, midnight and noon — and declines
|
||||||
|
to describe anything it is not sure of, because a confident wrong sentence is worse than
|
||||||
|
five fields. The Jobs tab had its own smaller version of this; it now uses this one.
|
||||||
|
|
||||||
|
- **Hardware tab** — **shipped 2026-09-25** (`internal/hardware`, `internal/api/hardware.go`,
|
||||||
|
`Hardware.tsx`): what the machine is physically made of, per machine like every other page.
|
||||||
|
Four headline tiles (processor, memory, storage, graphics) over cards for each: the
|
||||||
|
processor with its cores, clock range, socket, caches, microcode and the handful of feature
|
||||||
|
flags anyone asks about; **every memory slot** with size, type, form factor, rated and
|
||||||
|
running speed, maker, part number, serial, rank and voltage, and the empty ones marked
|
||||||
|
empty; each drive with model, size, bus, firmware and serial; each display adapter with its
|
||||||
|
driver, PCI address and video memory; and the board itself — model, firmware and date,
|
||||||
|
serial, kernel. Read from what Debian already has (dmidecode for what only the firmware
|
||||||
|
knows, lscpu, lspci, sysfs) and cached for ten minutes, since none of it changes under a
|
||||||
|
running kernel; "Re-read" is there for the one thing that does, a drive plugged in.
|
||||||
|
Every reader may come back empty and the page says what it could not read and why: a
|
||||||
|
machine with no dmidecode, no root, or no PCI bus still gets a page. Verified on `core`
|
||||||
|
(SMBIOS, Intel graphics) and on the Mac mini (Asahi: no SMBIOS memory table, a
|
||||||
|
device-tree GPU, heterogeneous cores — where lscpu's "cores per socket" counts one
|
||||||
|
cluster, so without SMT the thread count is the honest core count).
|
||||||
|
|
||||||
- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
|
- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
|
||||||
`internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`;
|
`internal/api/status_sensors.go`, `web/src/sensors.ts`, `widgets.tsx`, `Embed.tsx`;
|
||||||
reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID;
|
reference in `SENSORS.md`): everything measured is a sensor with a stable dotted ID;
|
||||||
@@ -398,9 +438,16 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
replace, exec in place. Config default moved to `~/.config/hamlab` (existing
|
replace, exec in place. Config default moved to `~/.config/hamlab` (existing
|
||||||
`~/apps/hamlab` still found by the installer and the CLI). Tested in
|
`~/apps/hamlab` still found by the installer and the CLI). Tested in
|
||||||
containers: Debian 12, Ubuntu 22.04, Fedora 40, Rocky 8, openSUSE Leap 15.5, Alpine
|
containers: Debian 12, Ubuntu 22.04, Fedora 40, Rocky 8, openSUSE Leap 15.5, Alpine
|
||||||
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. Not done: an OpenRC
|
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. **procd (OpenWrt) added
|
||||||
backend for the Services tab; runit/s6/sysvinit service files; public hosting for
|
2026-09-26** (`internal/setup/hamlab.procd`): detected on `/sbin/procd` + `/etc/rc.common`
|
||||||
`install.sh` (the Gitea repo is private, so the one-liner needs `HAMLAB_TOKEN`).
|
ahead of the OpenRC branch, installed to the same `/etc/init.d/hamlab` path and told apart
|
||||||
|
from OpenRC at uninstall by `USE_PROCD=1` in the file. Verified on the new router
|
||||||
|
(`gate`, OpenWrt 25.12.5, running as root): `S95`/`K10` symlinks, procd supervising the
|
||||||
|
instance, Docker tab and stacks working through the hub. The Updates tab needed no change
|
||||||
|
— apk-tools 3 answers `apk version -l '<'` the same way Alpine's does. Not done: an
|
||||||
|
OpenRC or procd backend for the Services tab (it is systemd-only and says so cleanly);
|
||||||
|
runit/s6/sysvinit service files; public hosting for `install.sh` (the Gitea repo is
|
||||||
|
private, so the one-liner needs `HAMLAB_TOKEN`).
|
||||||
- **Users, sessions and settings that follow the account** — **shipped 2026-09-22**
|
- **Users, sessions and settings that follow the account** — **shipped 2026-09-22**
|
||||||
(`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`,
|
(`internal/api/users.go`, `config.User`, `store.Session`/`UserState`, `Login.tsx`,
|
||||||
`Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's
|
`Users.tsx` — a hub-only **Users** tab in the rail, added the same day at Mitchell's
|
||||||
@@ -540,6 +587,72 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
`POST /api/password`, current password required), Access tokens (list/revoke the tokens
|
`POST /api/password`, current password required), Access tokens (list/revoke the tokens
|
||||||
hubs hold), About (version, host, arch, uptime, certificate).
|
hubs hold), About (version, host, arch, uptime, certificate).
|
||||||
|
|
||||||
|
## Network tab (2026-09-27)
|
||||||
|
|
||||||
|
A **Network** tab, per machine like every other: the interfaces as the kernel has them
|
||||||
|
(addresses, MAC, MTU, negotiated link speed, carrier, byte totals, live rates from the
|
||||||
|
sampler's own sensors), the default route and resolvers, what the internet sees of this
|
||||||
|
connection, and an **internet speed test**.
|
||||||
|
|
||||||
|
Decisions worth keeping:
|
||||||
|
|
||||||
|
- **Measured by the daemon, not the browser.** The test runs on the machine in the switcher,
|
||||||
|
so the figure is that machine's line — the router's, if you switch to `gate` — and it
|
||||||
|
carries on if the phone locks or the tab closes. The page polls a run it is not driving.
|
||||||
|
- **No tool to install.** It measures against Cloudflare's public speed endpoints
|
||||||
|
(`__down`, `__up`, `cdn-cgi/trace`), which is what makes it work identically on Debian and
|
||||||
|
on OpenWrt, where `opkg install` costs flash. That is the feature's one external
|
||||||
|
dependency, and it is named in `internal/network/speedtest.go`.
|
||||||
|
- **Several streams, HTTP/2 off, and the ramp thrown away.** One stream over one connection
|
||||||
|
measures a congestion window; HTTP/2 would put every stream back on one connection and do
|
||||||
|
the same. The first second of each phase is measured and discarded.
|
||||||
|
- **Latency is the round trip less the far end's own Server-Timing.** A zero-byte response
|
||||||
|
is mostly Cloudflare's worker running — 20 to 45 ms of it against a few ms of wire. Left
|
||||||
|
in, it reported 31 ms for a 7 ms line. Jitter is the mean change between consecutive
|
||||||
|
probes, not a standard deviation, so one slow probe doesn't run away with it.
|
||||||
|
- **A refused phase is not a number.** The far end rate-limits by volume, and a test moves a
|
||||||
|
few hundred megabytes; several in a row earn 429s, and the block observed while building this
|
||||||
|
lasted over twenty minutes. So: a 20-second cooldown counted from the last *attempt* (a
|
||||||
|
refused test still used the line), a stream that retries twice before giving up, the rate
|
||||||
|
computed over the window in which bytes were actually still arriving, and a plain sentence
|
||||||
|
when it was cut short. Coasting to the end of the clock with nothing arriving reported a
|
||||||
|
third of the truth.
|
||||||
|
- **A second download source, for when the first refuses.** A plain 100 MB file on Linode's
|
||||||
|
Seattle mirror, which has no opinion about volume. It is only reached after a refusal, and
|
||||||
|
every result names the source it used — that mirror is in one place, so on a machine far from
|
||||||
|
Seattle it would read low, and a number that quietly came from somewhere else would be a lie.
|
||||||
|
Upload stays on Cloudflare, which has never been the one to refuse.
|
||||||
|
- **The last result is published as sensors** (`net.internet.*`), so the line is a widget, a
|
||||||
|
graph, an alert or a Prometheus series like anything else. Each carries the time the test
|
||||||
|
ran, not the time it was asked for.
|
||||||
|
- **Not scheduled.** Nothing takes a speed test on its own. A nightly one recorded to the
|
||||||
|
history is the obvious next step and deliberately not built yet — the cron shape for it
|
||||||
|
already exists twice over (ZFS snapshots, Docker image updates).
|
||||||
|
|
||||||
|
## VPN tab (2026-09-27)
|
||||||
|
|
||||||
|
A **VPN** tab beside Network, per machine: every WireGuard tunnel on the machine in the
|
||||||
|
switcher, and the devices on it — name, address, last handshake, where it connects from, and
|
||||||
|
what it has moved.
|
||||||
|
|
||||||
|
- **Per machine is the point, not a limitation.** The tunnel people dial into and a tunnel a
|
||||||
|
machine dials out through are on different boxes here (`gate` and `hamtv`), and a combined
|
||||||
|
view would invite exactly the question "which box is this on".
|
||||||
|
- **The kernel is the truth, the config only supplies names.** `wg show all dump` has the
|
||||||
|
handshakes and counters and knows nothing but public keys; names come from the `# comment`
|
||||||
|
above a peer in a wg-quick file, or a UCI section's `description` on OpenWrt. Both are read
|
||||||
|
only to label what the kernel already said.
|
||||||
|
- **Server or client is worked out, not declared.** A peer allowed `0.0.0.0/0` that has an
|
||||||
|
endpoint is a way out, so a tunnel holding one is this machine dialling out; a tunnel with a
|
||||||
|
listen port and peers is one being dialled. Anything else says neither rather than guessing.
|
||||||
|
- **"Online" is a handshake in the last three minutes**, and the tab says so. WireGuard is
|
||||||
|
silent when idle; there is no connection to be in, and a tab claiming otherwise would be
|
||||||
|
lying twice a day.
|
||||||
|
- **Read-only, and no secrets.** Private keys are in the second column of the dump and are
|
||||||
|
read past; pre-shared keys are noted as present and never read. Adding or editing peers is
|
||||||
|
not here: it means generating keys and writing the machine's network configuration, which is
|
||||||
|
a bigger thing than this tab, and a bad thing to get half right.
|
||||||
|
|
||||||
## To do
|
## To do
|
||||||
|
|
||||||
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
|
- **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files
|
||||||
@@ -571,41 +684,54 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
2026-09-24 to take in Docker tab polish and feature work, on the same reasoning as the
|
2026-09-24 to take in Docker tab polish and feature work, on the same reasoning as the
|
||||||
Files item: it is one tab, and the gaps that block retiring the old tool and the things
|
Files item: it is one tab, and the gaps that block retiring the old tool and the things
|
||||||
that would simply make it better are the same body of work. The *homelab* half —
|
that would simply make it better are the same body of work. The *homelab* half —
|
||||||
migrating every stack off Dockhand's data dir, then removing the container — is `hamlet`'s
|
the cutover itself, then moving every stack off Dockhand's data dir — is `hamlet`'s
|
||||||
`docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", agreed
|
`docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator", agreed
|
||||||
2026-09-20. This entry is the *app* half.
|
2026-09-20. This entry is the *app* half.
|
||||||
Where it stands: Stacks already creates, edits, deploys, updates, restarts, stops, downs
|
|
||||||
and deletes stacks on every machine, and Containers does start/stop/restart, logs that
|
**Done 2026-09-24 — the eight Mitchell picked from the inventory**, all in the Docker tab:
|
||||||
follow, inspect, and the images/volumes/networks tables. So this is not a rewrite.
|
per-container **Stats**, **Health** (with the last few probe outputs), **Events** (Ham Lab
|
||||||
- **What Dockhand still does that Ham Lab does not.**
|
keeps its own 4000-entry ring, since `docker events` forgets after about a minute),
|
||||||
|
**Shell** (`docker exec`, handed to the Terminal tab) and **Limits** (`docker update` for
|
||||||
|
memory, CPU and restart policy); an **updates** tab giving the whole machine at once with
|
||||||
|
a per-container indicator in the list; and **schedules**, which write
|
||||||
|
`/etc/cron.d/hamlab-docker` and call
|
||||||
|
`hamlab docker-update <id>` — the shape the ZFS schedules already use.
|
||||||
|
Also done: Ham Lab keeps working when `dockhand` is removed. It remembers where a
|
||||||
|
container's bind mounts pointed (`mounts` in `state.json`) and resolves a stack through
|
||||||
|
that record when no running container explains its path, running compose on the host
|
||||||
|
instead; `repoint` does the same for a schedule at run time. Without this, removing the
|
||||||
|
container would have made 17 stacks on `core` and 5 on `grid` unfindable at once.
|
||||||
|
|
||||||
|
- **Left from the Dockhand inventory, both to decide rather than build.**
|
||||||
- *Git-sourced stacks.* Dockhand's `stack_sources` table points a stack at a repo and
|
- *Git-sourced stacks.* Dockhand's `stack_sources` table points a stack at a repo and
|
||||||
pulls it. Ham Lab has no equivalent. Decide whether to build it or drop it — most
|
pulls it. Its `git_repositories` and `git_stacks` tables are **empty**, so nothing
|
||||||
stacks here have no row in that table anyway, so dropping it may be the honest answer.
|
here uses it — dropping it is the honest answer unless Mitchell says otherwise.
|
||||||
- *Scheduled image updates.* Ham Lab checks for new images only when asked, since each
|
- *Webhooks, OIDC, LDAP, vulnerability scans.* All four tables empty. Nothing to move.
|
||||||
check is a registry request Docker Hub counts, and never updates by itself. If
|
|
||||||
anything relies on Dockhand updating on a schedule, that must move here or be dropped
|
**Done 2026-09-25 — the two gaps in the tab itself.**
|
||||||
on purpose. Note the Jobs tab and the ZFS schedules already show the shape this would
|
- *Logs are followed over a held connection*, the way the Services tab follows a journal
|
||||||
take: a cron line Ham Lab owns, not a ticker in the daemon.
|
(`internal/api/docker_logs.go`, `/api/docker/containers/{id}/logs/tail`): one
|
||||||
- *Webhooks and per-host environments*, if either is in use. Inventory before deciding.
|
`docker logs -f` per viewer, stdout and stderr both, stderr coloured as an error. A
|
||||||
- **Known gaps in the tab itself**, worth closing whether or not Dockhand goes:
|
reconnect resumes from the last line's timestamp, so a container that restarts or a
|
||||||
- *Removing networks* — volumes and images can be pruned and removed, networks cannot.
|
phone that slept doesn't replay what is already on screen; the client dedupes by line
|
||||||
- *Container detail is thin next to the stack view.* No per-container resource limits,
|
rather than by "later than", because `--since` is inclusive and two lines can share a
|
||||||
restart-policy editing, or exec-into-container from the tab (the Terminal tab is the
|
timestamp. Docker replays the opening backlog down two pipes in no particular order, so
|
||||||
workaround). Decide which of these belong here at all.
|
the first 300 ms is held and sorted before it is sent. The polled `?since=` endpoint
|
||||||
- *Logs are polled with `--since` rather than followed over a held connection.* That was
|
stays — it is what the resume is built on. Reconnects back off to 15 s, since a
|
||||||
deliberate and works, but a long tail is choppier than the Services tab's live tail,
|
container that has stopped ends the stream immediately.
|
||||||
which does hold a connection. Worth reconciling the two.
|
- *Networks can be removed and pruned* — `POST /api/docker/networks/{remove,prune}`, the
|
||||||
- *Image update checks are remembered six hours and asked for per stack*, so there is no
|
shape images and volumes already had. The overview says who is attached to each one, so
|
||||||
one view of "what is out of date across the fleet".
|
the ✕ is only offered on a network nothing uses and never on Docker's own three. Prune
|
||||||
- **The `*:2375` question, which this is the moment to settle.** Ham Lab runs compose
|
is deliberately untested against this host: `core` has several networks belonging to
|
||||||
inside `dockhand` because that is how the stacks were deployed; once they move to a
|
stacks that are merely down (`pihole_default`, `transmission_default`, `gluetun_default`
|
||||||
neutral home that path is unused for them, but a stack deployed to `grid` or the Mac
|
among them), and pruning would take them and hand them new subnets on the next deploy.
|
||||||
still goes over `tcp://<node>:2375` from the hub. Decide whether the hub should instead
|
|
||||||
ask the node's own Ham Lab to deploy locally, which would end the unauthenticated
|
- **The `*:2375` question, which this is the moment to settle.** A stack deployed to
|
||||||
Docker socket exposure — its own item in `docs/pending.md`, currently filed under
|
`grid` or the Mac still goes over `tcp://<node>:2375` from the hub. Decide whether the
|
||||||
"decided, no action", and this is the change that would make closing it free.
|
hub should instead ask the node's own Ham Lab to deploy locally, which would end the
|
||||||
- *Not app work, but the trigger:* nothing above blocks starting the migration except the
|
unauthenticated Docker socket exposure — its own item in `docs/pending.md`, currently
|
||||||
inventory. Do that first.
|
filed under "decided, no action", and this is the change that would make closing it
|
||||||
|
free.
|
||||||
|
|
||||||
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
|
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
|
||||||
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
|
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ reset a password; your own password confirms the last two). From the shell:
|
|||||||
users` lists them, `hamlab user-rm <user>` removes one. Machine settings — refresh rate,
|
users` lists them, `hamlab user-rm <user>` removes one. Machine settings — refresh rate,
|
||||||
graph history, alert rules — stay the machine's and are shared by everyone.
|
graph history, alert rules — stay the machine's and are shared by everyone.
|
||||||
|
|
||||||
|
**Guest logins**, for showing somebody the console: Users → Guest logins makes a
|
||||||
|
throwaway user name and password, good for half an hour by default (up to 24 hours), and
|
||||||
|
hands you the pair to paste into a message. A guest is an ordinary account with a clock on
|
||||||
|
it — the whole console, every machine, the terminal included — that stops working when the
|
||||||
|
time is up, wherever it is signed in, or the moment you revoke it. There is no reduced
|
||||||
|
guest: give one to somebody you would hand the password to, and revoke it if you change
|
||||||
|
your mind.
|
||||||
|
|
||||||
What adapts to the host, rather than assuming Debian:
|
What adapts to the host, rather than assuming Debian:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -62,6 +70,8 @@ What adapts to the host, rather than assuming Debian:
|
|||||||
| Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) |
|
| Updates tab | apt, dnf, zypper, pacman, apk: list, refresh, upgrade, install (pacman: all or nothing) |
|
||||||
| Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd |
|
| Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd |
|
||||||
| Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there |
|
| Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there |
|
||||||
|
| Network | `/proc` and `/sys` for the interfaces, addresses, link speed and default route. The speed test needs no tool installed: it measures against Cloudflare's public speed endpoints, so it works the same on Debian and on the OpenWrt router |
|
||||||
|
| VPN | WireGuard tunnels and their peers, from `wg show` where wireguard-tools is installed; peer names from wg-quick `.conf` comments or OpenWrt UCI descriptions. Read-only, and it never reads a private or pre-shared key |
|
||||||
| Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed |
|
| Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed |
|
||||||
| Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start |
|
| Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start |
|
||||||
| Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept |
|
| Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept |
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ cpu.usage cpu.core.<n>.usage cpu.temp load.1|5|15 uptime
|
|||||||
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
mem.total|used|avail|free|cached|buffers|used_pct swap.total|used|avail|used_pct
|
||||||
temp.<chip>.<label> fan.<chip>.<label>
|
temp.<chip>.<label> fan.<chip>.<label>
|
||||||
net.<iface>.rx|tx|rx_total|tx_total|up
|
net.<iface>.rx|tx|rx_total|tx_total|up
|
||||||
|
net.internet.download|upload|latency|jitter|tested|ip|edge
|
||||||
disk.<dev>.read|write|util|size|temp
|
disk.<dev>.read|write|util|size|temp
|
||||||
fs.<mount>.size|used|used_pct
|
fs.<mount>.size|used|used_pct
|
||||||
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
||||||
@@ -26,6 +27,12 @@ ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
|||||||
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between
|
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between
|
||||||
boots). Two identical chips collide; the second gets `_2`.
|
boots). Two identical chips collide; the second gets `_2`.
|
||||||
- Which sensors exist differs per machine. That is the point of the catalog.
|
- Which sensors exist differs per machine. That is the point of the catalog.
|
||||||
|
- `net.internet.*` is the Network tab's speed test: `download` and `upload` in Mbps,
|
||||||
|
`latency` and `jitter` in ms, `tested` the age of the reading in seconds, and `ip` and
|
||||||
|
`edge` the public address and the Cloudflare edge as strings. They appear only once a test
|
||||||
|
has run on that machine, and each one's `t` is when it ran — so a widget or an alert on
|
||||||
|
them is reading a measurement from that moment, not a live value. Nothing takes a speed
|
||||||
|
test on its own; something has to press the button.
|
||||||
|
|
||||||
## A sensor
|
## A sensor
|
||||||
|
|
||||||
|
|||||||
+56
-2
@@ -32,8 +32,8 @@ import (
|
|||||||
"hamlab/internal/files"
|
"hamlab/internal/files"
|
||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
"hamlab/internal/nodes"
|
"hamlab/internal/nodes"
|
||||||
"hamlab/internal/services"
|
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
|
"hamlab/internal/services"
|
||||||
"hamlab/internal/setup"
|
"hamlab/internal/setup"
|
||||||
"hamlab/internal/space"
|
"hamlab/internal/space"
|
||||||
"hamlab/internal/storage"
|
"hamlab/internal/storage"
|
||||||
@@ -62,6 +62,7 @@ func main() {
|
|||||||
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
|
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
|
||||||
users list the accounts
|
users list the accounts
|
||||||
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
|
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
|
||||||
|
docker-update <id> run one image-update schedule (what its cron line calls)
|
||||||
user-rm <user> remove an account and sign it out everywhere
|
user-rm <user> remove an account and sign it out everywhere
|
||||||
check verify the config and state load, then exit
|
check verify the config and state load, then exit
|
||||||
version
|
version
|
||||||
@@ -132,6 +133,10 @@ func main() {
|
|||||||
if err := passwd(cfg, flag.Arg(1)); err != nil {
|
if err := passwd(cfg, flag.Arg(1)); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
case "docker-update":
|
||||||
|
if err := dockerUpdateJob(cfg, flag.Arg(1)); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
case "zfs-snapshot":
|
case "zfs-snapshot":
|
||||||
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
|
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -146,7 +151,14 @@ func main() {
|
|||||||
if !u.LastSeen.IsZero() {
|
if !u.LastSeen.IsZero() {
|
||||||
seen = u.LastSeen.Local().Format("2006-01-02 15:04")
|
seen = u.LastSeen.Local().Format("2006-01-02 15:04")
|
||||||
}
|
}
|
||||||
fmt.Printf("%-20s created %s last seen %s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen)
|
note := ""
|
||||||
|
if u.Guest { // a temporary look-only login, not one of the machine's accounts
|
||||||
|
note = " guest, until " + u.Expires.Local().Format("15:04")
|
||||||
|
if u.Expired(time.Now()) {
|
||||||
|
note = " guest, expired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("%-20s created %s last seen %s%s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen, note)
|
||||||
}
|
}
|
||||||
case "user-rm":
|
case "user-rm":
|
||||||
if err := userRm(cfg, flag.Arg(1)); err != nil {
|
if err := userRm(cfg, flag.Arg(1)); err != nil {
|
||||||
@@ -185,6 +197,48 @@ func main() {
|
|||||||
|
|
||||||
// migrateUsers gives a pre-users config its first account, named after this OS user, so
|
// migrateUsers gives a pre-users config its first account, named after this OS user, so
|
||||||
// the CLI sees the same accounts the daemon will.
|
// the CLI sees the same accounts the daemon will.
|
||||||
|
// dockerUpdateJob pulls a stack's images, and deploys them when the schedule says to. This
|
||||||
|
// is what the cron line Ham Lab writes actually calls, so image updates keep happening
|
||||||
|
// whether or not the console is up.
|
||||||
|
func dockerUpdateJob(cfg *config.Config, id string) error {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return errors.New("usage: hamlab docker-update <schedule-id>")
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var raw json.RawMessage
|
||||||
|
st.View(func(s *store.State) { raw = s.DockerJobs })
|
||||||
|
var d struct {
|
||||||
|
Jobs []api.UpdateJob `json:"jobs"`
|
||||||
|
}
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &d); err != nil {
|
||||||
|
return fmt.Errorf("reading the schedules: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, j := range d.Jobs {
|
||||||
|
if j.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A pull that never finishes must not leave a cron job resident until the next reboot.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
out, runErr := api.RunUpdate(ctx, st, j, api.DockerTool())
|
||||||
|
rec := storage.RunRecord{At: time.Now(), Taken: j.Stack}
|
||||||
|
if runErr != nil {
|
||||||
|
rec.Error = runErr.Error()
|
||||||
|
}
|
||||||
|
if err := storage.WriteRun(cfg.DataDir(), "docker-"+id, rec); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "recording the run:", err)
|
||||||
|
}
|
||||||
|
fmt.Print(out)
|
||||||
|
return runErr
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no image-update schedule %q — it may have been removed; this cron line is stale", id)
|
||||||
|
}
|
||||||
|
|
||||||
// zfsSnapshot runs one snapshot schedule: take it, then expire what has aged out. This is
|
// zfsSnapshot runs one snapshot schedule: take it, then expire what has aged out. This is
|
||||||
// what the cron line Ham Lab writes actually calls, so a schedule keeps running whether or
|
// what the cron line Ham Lab writes actually calls, so a schedule keeps running whether or
|
||||||
// not the console is up — and keeps running if Ham Lab is never opened again.
|
// not the console is up — and keeps running if Ham Lab is never opened again.
|
||||||
|
|||||||
+245
-10
@@ -3,8 +3,8 @@ package api
|
|||||||
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
|
// The Docker tab: what is running, grouped the way it was deployed (compose stacks), with
|
||||||
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
|
// start / stop / restart, logs and inspect — plus images, volumes and networks to look at.
|
||||||
// It drives the docker CLI (or podman), as the user first and as root if that is refused.
|
// It drives the docker CLI (or podman), as the user first and as root if that is refused.
|
||||||
// Creating, removing and pruning are deliberately absent: whatever deployed the stacks
|
// What can be removed here is what nothing uses: an untagged image, a volume no container
|
||||||
// (Dockhand, compose) stays the one place that changes what exists.
|
// holds, a network nothing is attached to. Compose stays the one place a stack is defined.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -49,6 +49,10 @@ func dockerTool() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dockerRun runs the tool as this user, and again as root if the socket said no.
|
// dockerRun runs the tool as this user, and again as root if the socket said no.
|
||||||
|
// DockerTool is whichever of docker or podman this machine has, for callers outside the
|
||||||
|
// package — the scheduled updates the CLI runs.
|
||||||
|
func DockerTool() string { return dockerTool() }
|
||||||
|
|
||||||
func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
tool := dockerTool()
|
tool := dockerTool()
|
||||||
if tool == "" {
|
if tool == "" {
|
||||||
@@ -72,6 +76,18 @@ func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dockerWhy picks the line worth showing out of a failed command. Some subcommands —
|
||||||
|
// `network rm` among them — print the real reason first and then an aggregated "exit
|
||||||
|
// status 1", which is what dockerRun's last-line rule would otherwise hand back.
|
||||||
|
func dockerWhy(out []byte, err error) string {
|
||||||
|
for _, l := range strings.Split(string(out), "\n") {
|
||||||
|
if l = strings.TrimSpace(l); strings.HasPrefix(l, "Error") || strings.Contains(l, "daemon") {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
func jsonLines(out []byte, each func(map[string]any)) {
|
func jsonLines(out []byte, each func(map[string]any)) {
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
@@ -135,23 +151,30 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
// Who uses what, from one inspect of every container: an image or a volume nothing uses is
|
// Who uses what, from one inspect of every container: an image, a volume or a network
|
||||||
// the only kind this page offers to remove.
|
// nothing uses is the only kind this page offers to remove.
|
||||||
imageUsed, volumeUsers := map[string]bool{}, map[string][]string{}
|
imageUsed := map[string]bool{}
|
||||||
|
volumeUsers, networkUsers := map[string][]string{}, map[string][]string{}
|
||||||
if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 {
|
if ids, err := dockerRun(ctx, "ps", "-aq"); err == nil && len(strings.Fields(string(ids))) > 0 {
|
||||||
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}`
|
const f = `{{.Name}}|{{.Image}}|{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}},{{end}}{{end}}|{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}`
|
||||||
if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil {
|
if out, err := dockerRun(ctx, append([]string{"inspect", "-f", f}, strings.Fields(string(ids))...)...); err == nil {
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
p := strings.SplitN(line, "|", 3)
|
p := strings.SplitN(line, "|", 4)
|
||||||
if len(p) < 3 {
|
if len(p) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
name := strings.TrimPrefix(p[0], "/")
|
||||||
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
|
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
|
||||||
imageUsed[id[:12]] = true
|
imageUsed[id[:12]] = true
|
||||||
}
|
}
|
||||||
for _, v := range strings.Split(p[2], ",") {
|
for _, v := range strings.Split(p[2], ",") {
|
||||||
if v != "" {
|
if v != "" {
|
||||||
volumeUsers[v] = append(volumeUsers[v], strings.TrimPrefix(p[0], "/"))
|
volumeUsers[v] = append(volumeUsers[v], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range strings.Split(p[3], ",") {
|
||||||
|
if n != "" {
|
||||||
|
networkUsers[n] = append(networkUsers[n], name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,8 +194,18 @@ func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
v["used_by"] = users
|
v["used_by"] = users
|
||||||
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
|
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
|
||||||
}
|
}
|
||||||
|
networks := list("network", "ls", "--format", "{{json .}}")
|
||||||
|
for _, n := range networks {
|
||||||
|
users := networkUsers[dstr(n, "Name")]
|
||||||
|
if users == nil {
|
||||||
|
users = []string{}
|
||||||
|
}
|
||||||
|
sort.Strings(users)
|
||||||
|
n["used_by"] = users
|
||||||
|
n["predefined"] = predefinedNetwork[dstr(n, "Name")]
|
||||||
|
}
|
||||||
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
|
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
|
||||||
"images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")})
|
"images": images, "volumes": volumes, "networks": networks})
|
||||||
}
|
}
|
||||||
|
|
||||||
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
||||||
@@ -394,3 +427,205 @@ func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("docker: volume prune (from %s)", clientIP(r))
|
log.Printf("docker: volume prune (from %s)", clientIP(r))
|
||||||
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The three networks Docker makes itself. They hold nothing, cannot be removed, and
|
||||||
|
// their absence would break every container that asks for one by name.
|
||||||
|
var predefinedNetwork = map[string]bool{"bridge": true, "host": true, "none": true}
|
||||||
|
|
||||||
|
// networkRemove deletes one network. Unlike a volume there is no data in it, and unlike an
|
||||||
|
// image nothing has to be downloaded again — a stack that wants it back gets it recreated
|
||||||
|
// on the next deploy. Docker refuses one a container is still attached to, which is the
|
||||||
|
// check that matters, so this asks for no second confirmation.
|
||||||
|
func (s *Server) networkRemove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct{ Name string }
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) {
|
||||||
|
jsonErr(w, 400, "which network?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if predefinedNetwork[body.Name] {
|
||||||
|
jsonErr(w, 400, body.Name+" is one of Docker's own networks and can't be removed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := dockerRun(ctx, "network", "rm", body.Name); err != nil {
|
||||||
|
jsonErr(w, 409, dockerWhy(out, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker: network rm %s (from %s)", body.Name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkPrune removes every network no container is attached to. A stack that is merely
|
||||||
|
// down counts as unattached, so its network goes too — and comes back on the next deploy,
|
||||||
|
// with a new subnet. That is the whole cost, and the caller is told it.
|
||||||
|
func (s *Server) networkPrune(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
out, err := dockerRun(ctx, "network", "prune", "-f")
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
log.Printf("docker: network prune (from %s)", clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- a container's health, its recent life, its console, and its limits ----
|
||||||
|
|
||||||
|
// dockerHealth is the health check's verdict and the last few probe results. Docker keeps
|
||||||
|
// the last five itself, which is all anyone wants: is it failing, and what did it say.
|
||||||
|
func (s *Server) dockerHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := dockerRun(ctx, "inspect", "--format", "{{json .State}}", id)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var st struct {
|
||||||
|
Status string `json:"Status"`
|
||||||
|
Health *struct {
|
||||||
|
Status string `json:"Status"`
|
||||||
|
FailingStreak int `json:"FailingStreak"`
|
||||||
|
Log []struct {
|
||||||
|
Start time.Time `json:"Start"`
|
||||||
|
End time.Time `json:"End"`
|
||||||
|
ExitCode int `json:"ExitCode"`
|
||||||
|
Output string `json:"Output"`
|
||||||
|
} `json:"Log"`
|
||||||
|
} `json:"Health"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(out, &st) != nil {
|
||||||
|
jsonErr(w, 500, "could not read the container's state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.Health == nil {
|
||||||
|
jsonOK(w, 200, map[string]any{"configured": false, "state": st.Status})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{
|
||||||
|
"configured": true, "state": st.Status, "status": st.Health.Status,
|
||||||
|
"failing_streak": st.Health.FailingStreak, "log": st.Health.Log,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerExec opens a shell inside a container, as a terminal session like any other — so
|
||||||
|
// it reattaches, resizes and scrolls back the same way, and works on a managed machine
|
||||||
|
// through the same proxy.
|
||||||
|
func (s *Server) dockerExec(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tool := dockerTool()
|
||||||
|
if tool == "" {
|
||||||
|
jsonErr(w, 500, "neither docker nor podman is installed here")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Shell string `json:"shell"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||||
|
shell := strings.TrimSpace(body.Shell)
|
||||||
|
if shell == "" {
|
||||||
|
// most images have sh; bash is nicer where it exists, so try it first and fall back
|
||||||
|
shell = "if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi"
|
||||||
|
} else if strings.ContainsAny(shell, "\n\r") {
|
||||||
|
jsonErr(w, 400, "bad shell")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
argv := []string{tool, "exec", "-it", id, "sh", "-c", shell}
|
||||||
|
if priv.Available() && !dockerReachable() {
|
||||||
|
argv = priv.Wrap(argv...)
|
||||||
|
}
|
||||||
|
sess, err := s.mgr.CreateCmd("", "sh "+shortName(id), argv)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker exec into %s as terminal %s by %s", id, sess.ID, clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"id": sess.ID, "name": "sh " + shortName(id)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortName(id string) string {
|
||||||
|
if len(id) > 20 {
|
||||||
|
return id[:20]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerReachable reports whether docker answers without help.
|
||||||
|
func dockerReachable() bool {
|
||||||
|
tool := dockerTool()
|
||||||
|
if tool == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return exec.CommandContext(ctx, tool, "version", "--format", "{{.Server.Version}}").Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerUpdate changes a running container's limits and restart policy. `docker update`
|
||||||
|
// does this live, without recreating it and without touching the compose file — which is
|
||||||
|
// also the catch, and the caller is told: a deploy from compose puts back whatever compose
|
||||||
|
// says.
|
||||||
|
func (s *Server) dockerUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Memory string `json:"memory"` // "512m", "2g", or "" to leave alone; "0" removes the limit
|
||||||
|
CPUs string `json:"cpus"` // "1.5", or "0" for no limit
|
||||||
|
Restart string `json:"restart"` // no | on-failure | unless-stopped | always
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := []string{"update"}
|
||||||
|
if m := strings.TrimSpace(body.Memory); m != "" {
|
||||||
|
if !regexp.MustCompile(`^[0-9]+([bkmgBKMG])?$`).MatchString(m) {
|
||||||
|
jsonErr(w, 400, "memory: a number, optionally with b, k, m or g — 0 removes the limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, "--memory", m, "--memory-swap", m)
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(body.CPUs); c != "" {
|
||||||
|
if !regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`).MatchString(c) {
|
||||||
|
jsonErr(w, 400, "cpus: a number like 1.5 — 0 removes the limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, "--cpus", c)
|
||||||
|
}
|
||||||
|
if p := strings.TrimSpace(body.Restart); p != "" {
|
||||||
|
switch p {
|
||||||
|
case "no", "on-failure", "unless-stopped", "always":
|
||||||
|
default:
|
||||||
|
jsonErr(w, 400, "restart: no, on-failure, unless-stopped or always")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, "--restart", p)
|
||||||
|
}
|
||||||
|
if len(args) == 1 {
|
||||||
|
jsonErr(w, 400, "nothing to change")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := dockerRun(ctx, append(args, id)...); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker update %s %v by %s", id, args[1:], clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// A container's history.
|
||||||
|
//
|
||||||
|
// `docker events` is a live stream with only a short in-memory backlog — ask it for the
|
||||||
|
// last thirty days and you get the last few minutes, because the daemon keeps events in a
|
||||||
|
// small ring and loses them all when it restarts. That is why Dockhand kept its own copy,
|
||||||
|
// and why this does too: a watcher follows the stream and appends the lifecycle events to a
|
||||||
|
// ring kept beside the config, so "what happened to this container last Tuesday" has an
|
||||||
|
// answer.
|
||||||
|
//
|
||||||
|
// Only lifecycle events are kept. A health check produces three exec events every probe,
|
||||||
|
// which is thousands a day and tells you nothing that the health panel does not.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/priv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
eventsKept = 4000
|
||||||
|
eventsSaveEvery = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContainerEvent is one thing that happened to one container.
|
||||||
|
type ContainerEvent struct {
|
||||||
|
T int64 `json:"t"` // unix ms
|
||||||
|
Name string `json:"name"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
ExitCode string `json:"exit_code,omitempty"`
|
||||||
|
Signal string `json:"signal,omitempty"`
|
||||||
|
Health string `json:"health,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventLog struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ring []ContainerEvent
|
||||||
|
dirty bool
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventLog) load(path string) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.path = path
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list []ContainerEvent
|
||||||
|
if json.Unmarshal(b, &list) == nil {
|
||||||
|
e.ring = list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventLog) add(ev ContainerEvent) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.ring = append(e.ring, ev)
|
||||||
|
if len(e.ring) > eventsKept {
|
||||||
|
e.ring = append([]ContainerEvent(nil), e.ring[len(e.ring)-eventsKept:]...)
|
||||||
|
}
|
||||||
|
e.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// forName is one container's events, newest first.
|
||||||
|
func (e *eventLog) forName(name string, since time.Time, limit int) []ContainerEvent {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
out := []ContainerEvent{}
|
||||||
|
for _, ev := range e.ring {
|
||||||
|
if ev.Name == name && ev.T >= since.UnixMilli() {
|
||||||
|
out = append(out, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].T > out[j].T })
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *eventLog) save() {
|
||||||
|
e.mu.Lock()
|
||||||
|
if !e.dirty || e.path == "" {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(e.ring)
|
||||||
|
e.dirty = false
|
||||||
|
path := e.path
|
||||||
|
e.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(filepath.Dir(path), 0o700)
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if os.WriteFile(tmp, b, 0o600) == nil {
|
||||||
|
_ = os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchDockerEvents follows the stream, restarting if Docker goes away. It is the only
|
||||||
|
// thing that writes the log, so nothing contends with it.
|
||||||
|
func (s *Server) watchDockerEvents(ctx context.Context) {
|
||||||
|
s.events.load(filepath.Join(s.cfg.DataDir(), "docker-events.json"))
|
||||||
|
go func() { // a periodic flush, so a crash loses at most half a minute of history
|
||||||
|
t := time.NewTicker(eventsSaveEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.events.save()
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
s.events.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dockerTool() == "" {
|
||||||
|
time.Sleep(time.Minute) // no Docker here; look again later in case it is installed
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.followEvents(ctx)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(10 * time.Second): // the daemon restarted, or the pipe broke
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) followEvents(ctx context.Context) {
|
||||||
|
args := []string{"events", "--format", "{{json .}}", "--filter", "type=container"}
|
||||||
|
for _, e := range []string{"create", "start", "restart", "stop", "kill", "die", "destroy", "oom", "pause", "unpause", "rename", "update", "health_status"} {
|
||||||
|
args = append(args, "--filter", "event="+e)
|
||||||
|
}
|
||||||
|
tool := dockerTool()
|
||||||
|
full := append([]string{tool}, args...)
|
||||||
|
if !dockerReachable() && priv.Available() {
|
||||||
|
full = priv.Wrap(full...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, full[0], full[1:]...)
|
||||||
|
out, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(out)
|
||||||
|
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
||||||
|
n := 0
|
||||||
|
for sc.Scan() {
|
||||||
|
var e struct {
|
||||||
|
Action string `json:"Action"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
TimeNano int64 `json:"timeNano"`
|
||||||
|
Actor struct {
|
||||||
|
Attributes map[string]string `json:"Attributes"`
|
||||||
|
} `json:"Actor"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(sc.Bytes(), &e) != nil || e.Action == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := e.Actor.Attributes
|
||||||
|
ev := ContainerEvent{
|
||||||
|
T: e.Time * 1000, Name: at["name"], Image: at["image"],
|
||||||
|
Action: e.Action, ExitCode: at["exitCode"], Signal: at["signal"],
|
||||||
|
}
|
||||||
|
if e.TimeNano > 0 {
|
||||||
|
ev.T = e.TimeNano / 1e6
|
||||||
|
}
|
||||||
|
// health_status comes through as "health_status: healthy"
|
||||||
|
if a, h, ok := strings.Cut(e.Action, ": "); ok {
|
||||||
|
ev.Action, ev.Health = a, h
|
||||||
|
}
|
||||||
|
if ev.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.events.add(ev)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
_ = cmd.Wait()
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("docker events: stream ended after %d events; reconnecting", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerEvents answers from Ham Lab's own history, not Docker's short memory.
|
||||||
|
func (s *Server) dockerEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
since := r.URL.Query().Get("since")
|
||||||
|
d := 7 * 24 * time.Hour
|
||||||
|
switch since {
|
||||||
|
case "24h":
|
||||||
|
d = 24 * time.Hour
|
||||||
|
case "30d":
|
||||||
|
d = 30 * 24 * time.Hour
|
||||||
|
default:
|
||||||
|
since = "7d"
|
||||||
|
}
|
||||||
|
ev := s.events.forName(id, time.Now().Add(-d), 500)
|
||||||
|
s.events.mu.Lock()
|
||||||
|
total := len(s.events.ring)
|
||||||
|
s.events.mu.Unlock()
|
||||||
|
jsonOK(w, 200, map[string]any{"events": ev, "since": since, "kept": total, "capacity": eventsKept})
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Following a container's log over a held connection, the way the Services tab follows a
|
||||||
|
// journal. The polled `?since=` endpoint next door still exists — it is what a reconnect
|
||||||
|
// hands back to pick up from — but the live view holds one `docker logs -f` per viewer
|
||||||
|
// instead of asking again every two seconds.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
|
"hamlab/internal/priv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logLine is one line of a container's output: the time Docker stamped it with, the text,
|
||||||
|
// and which of the two streams it came out of — stderr is where a container says what went
|
||||||
|
// wrong, and the page colours it accordingly.
|
||||||
|
type logLine struct {
|
||||||
|
T string `json:"t"`
|
||||||
|
M string `json:"m"`
|
||||||
|
Err bool `json:"err,omitempty"`
|
||||||
|
at string // when to sort it by: its own stamp, or the last one its stream carried
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitStamp pulls Docker's RFC 3339 prefix off a line. A line without one (a container
|
||||||
|
// that writes bare bytes, or a continuation) keeps the whole text and no time — but is
|
||||||
|
// still sorted where its stream put it, behind the last line that did have one.
|
||||||
|
func splitStamp(l string, carry *string) logLine {
|
||||||
|
t, m, ok := strings.Cut(l, " ")
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil {
|
||||||
|
return logLine{M: l, at: *carry}
|
||||||
|
}
|
||||||
|
*carry = t
|
||||||
|
return logLine{T: t, M: m, at: t}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerTail streams a container's output as JSON text frames until the client goes away.
|
||||||
|
// `?since=<RFC3339>` resumes where a dropped connection left off; otherwise it starts with
|
||||||
|
// the last `?n=` lines.
|
||||||
|
func (s *Server) dockerTail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !dockerRef.MatchString(id) {
|
||||||
|
jsonErr(w, 400, "bad container")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tool := dockerTool()
|
||||||
|
if tool == "" {
|
||||||
|
jsonErr(w, 500, "neither docker nor podman is installed here")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := []string{tool, "logs", "--timestamps", "--follow"}
|
||||||
|
if since := r.URL.Query().Get("since"); since != "" {
|
||||||
|
if _, err := time.Parse(time.RFC3339Nano, since); err != nil {
|
||||||
|
jsonErr(w, 400, "since is an RFC 3339 time")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args = append(args, "--since", since)
|
||||||
|
} else {
|
||||||
|
args = append(args, "--tail", strconv.Itoa(logCount(r)))
|
||||||
|
}
|
||||||
|
args = append(args, id)
|
||||||
|
if priv.Available() && !dockerReachable() {
|
||||||
|
args = priv.Wrap(args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||||
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||||
|
// Own process group, so the kill reaches the docker client itself and not just a sudo
|
||||||
|
// wrapper — the same reason the journal tail does it.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
pg := "-" + strconv.Itoa(cmd.Process.Pid)
|
||||||
|
if args[0] != tool { // running under sudo: only sudo can signal root's children
|
||||||
|
k := priv.Wrap("kill", "-9", "--", pg)
|
||||||
|
return exec.Command(k[0], k[1:]...).Run()
|
||||||
|
}
|
||||||
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = conn.Write(ctx, websocket.MessageText, mustJSON(logLine{M: err.Error(), Err: true}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both pipes funnel into one writer: a websocket takes one writer at a time.
|
||||||
|
lines := make(chan logLine, 256)
|
||||||
|
read := func(p io.Reader, isErr bool) {
|
||||||
|
sc := bufio.NewScanner(p)
|
||||||
|
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
|
||||||
|
carry := ""
|
||||||
|
for sc.Scan() {
|
||||||
|
l := splitStamp(sc.Text(), &carry)
|
||||||
|
l.Err = isErr
|
||||||
|
select {
|
||||||
|
case lines <- l:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done := make(chan struct{}, 2)
|
||||||
|
go func() { read(stdout, false); done <- struct{}{} }()
|
||||||
|
go func() { read(stderr, true); done <- struct{}{} }()
|
||||||
|
go func() { // the command ends when the container does; closing says so, having said why
|
||||||
|
<-done
|
||||||
|
<-done
|
||||||
|
_ = cmd.Wait()
|
||||||
|
close(lines)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() { // reader: only to notice the close
|
||||||
|
defer cancel()
|
||||||
|
for {
|
||||||
|
if _, _, err := conn.Read(ctx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ping := time.NewTicker(25 * time.Second)
|
||||||
|
defer ping.Stop()
|
||||||
|
// stdout and stderr are two pipes, and what Docker replays down them arrives in no
|
||||||
|
// particular order. So the opening burst is held for a moment and sorted by the time
|
||||||
|
// Docker stamped each line with; after that the log is live and arrives in order anyway.
|
||||||
|
const burstFor = 300 * time.Millisecond
|
||||||
|
warm := time.NewTimer(burstFor)
|
||||||
|
defer warm.Stop()
|
||||||
|
warmC := warm.C // nil once the burst has been flushed: a nil channel never fires
|
||||||
|
var burst []logLine
|
||||||
|
write := func(l logLine) bool {
|
||||||
|
return conn.Write(ctx, websocket.MessageText, mustJSON(l)) == nil
|
||||||
|
}
|
||||||
|
flush := func() bool {
|
||||||
|
sort.SliceStable(burst, func(i, j int) bool { return burst[i].at < burst[j].at })
|
||||||
|
for _, l := range burst {
|
||||||
|
if !write(l) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
burst = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case l, ok := <-lines:
|
||||||
|
if !ok { // the container stopped, or never existed: say the last of it and go
|
||||||
|
flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if warmC != nil {
|
||||||
|
burst = append(burst, l)
|
||||||
|
if len(burst) < 10000 { // a container that never stops talking still gets read
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !flush() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !write(l) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-warmC:
|
||||||
|
warmC = nil
|
||||||
|
if !flush() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ping.C:
|
||||||
|
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := conn.Ping(pctx)
|
||||||
|
pc()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Scheduled image updates.
|
||||||
|
//
|
||||||
|
// Dockhand kept three containers up to date nightly — unbound, nginx and pihole, which is
|
||||||
|
// DNS and ingress — and that is the one thing it does that Ham Lab did not. This replaces
|
||||||
|
// it, and deliberately in the same shape as the ZFS snapshot schedules: a real line in a
|
||||||
|
// cron file Ham Lab owns, calling the binary, rather than a ticker inside the daemon.
|
||||||
|
// Updating an image is not something that should stop happening because a web console is
|
||||||
|
// down, and a cron line is visible to anyone looking at the machine the normal way.
|
||||||
|
//
|
||||||
|
// A schedule stores everything the run needs — project, compose files, how compose has to
|
||||||
|
// be invoked for that stack — so the cron job is self-contained and does not have to
|
||||||
|
// rediscover the world before it can act.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
|
"hamlab/internal/cron"
|
||||||
|
"hamlab/internal/storage"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateJob is a standing instruction to pull a stack's images, and optionally deploy them.
|
||||||
|
type UpdateJob struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Stack string `json:"stack"`
|
||||||
|
Machine string `json:"machine,omitempty"` // for display; the job runs where it was made
|
||||||
|
// Services narrows it to some of the stack's services; empty is all of them.
|
||||||
|
Services []string `json:"services,omitempty"`
|
||||||
|
Cron string `json:"cron"`
|
||||||
|
// Apply says whether a newer image is deployed or only fetched. Check-only still pulls,
|
||||||
|
// so the Docker tab shows what is behind without a registry round trip.
|
||||||
|
Apply bool `json:"apply"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// How to run compose for this stack, captured when the schedule was made.
|
||||||
|
Spec composeSpec `json:"spec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type composeSpec struct {
|
||||||
|
Project string `json:"project"`
|
||||||
|
Files []string `json:"files"`
|
||||||
|
Via string `json:"via,omitempty"`
|
||||||
|
Remote string `json:"remote,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c composeSpec) toSpec(tool string) compose.Spec {
|
||||||
|
return compose.Spec{Project: c.Project, Files: c.Files, Via: c.Via, Remote: c.Remote, Tool: tool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dockerCronFile = "/etc/cron.d/hamlab-docker"
|
||||||
|
|
||||||
|
type dockerSchedState struct {
|
||||||
|
Jobs []UpdateJob `json:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsLoad() dockerSchedState {
|
||||||
|
var raw json.RawMessage
|
||||||
|
s.store.View(func(st *store.State) { raw = st.DockerJobs })
|
||||||
|
var d dockerSchedState
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &d)
|
||||||
|
}
|
||||||
|
if d.Jobs == nil {
|
||||||
|
d.Jobs = []UpdateJob{}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsSave(d dockerSchedState) error {
|
||||||
|
b, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.Update(func(st *store.State) { st.DockerJobs = b })
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsCronText renders the schedules as cron lines. Same shape as the ZFS file: Ham Lab owns
|
||||||
|
// it whole and rewrites it on every change.
|
||||||
|
func dsCronText(jobs []UpdateJob, user, exe, config string) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# Ham Lab image-update schedules — written by the Docker tab, rewritten whole on every change.\n")
|
||||||
|
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
|
||||||
|
b.WriteString("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||||
|
for i := range jobs {
|
||||||
|
j := &jobs[i]
|
||||||
|
if !j.Enabled {
|
||||||
|
b.WriteString("# " + j.Name + " — paused\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expr := strings.Join(strings.Fields(j.Cron), " ")
|
||||||
|
if err := cron.ValidSchedule(expr); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
what := "pull only"
|
||||||
|
if j.Apply {
|
||||||
|
what = "pull and deploy"
|
||||||
|
}
|
||||||
|
b.WriteString("# " + j.Name + " — " + j.Stack + ", " + what + "\n")
|
||||||
|
b.WriteString(expr + " " + user + " " + exe + " -config " + config + " docker-update " + j.ID + "\n\n")
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsSyncCron(ctx context.Context, d dockerSchedState) error {
|
||||||
|
live := false
|
||||||
|
for i := range d.Jobs {
|
||||||
|
if d.Jobs[i].Enabled {
|
||||||
|
live = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !live {
|
||||||
|
return s.storage.RemoveRootFile(ctx, dockerCronFile)
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exe, _ = filepath.EvalSymlinks(exe)
|
||||||
|
text, err := dsCronText(d.Jobs, s.user, exe, s.cfg.Path())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.storage.WriteRootFile(ctx, dockerCronFile, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoint keeps a schedule working when what deployed its stack has been removed.
|
||||||
|
//
|
||||||
|
// A schedule captures how to invoke compose at the moment it is made, so the cron job never
|
||||||
|
// has to rediscover the world at 3am. But "run compose inside this container" stops being
|
||||||
|
// true the day that container is deleted — which is exactly what happens when an
|
||||||
|
// orchestrator is retired, and would otherwise break every schedule made before it went.
|
||||||
|
// So: if the container named is not running, the files are looked up again, through live
|
||||||
|
// mounts and remembered ones both, and compose runs on the host instead.
|
||||||
|
func repoint(ctx context.Context, db *store.Store, spec compose.Spec) compose.Spec {
|
||||||
|
if spec.Via == "" || db == nil {
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
if out, err := dockerRun(ctx, "inspect", "-f", "{{.State.Running}}", spec.Via); err == nil && strings.TrimSpace(string(out)) == "true" {
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
st := stack{Name: spec.Project, File: spec.Files[0], Files: spec.Files}
|
||||||
|
st.resolve(mountsSeen(ctx, db)) // read-only: this also runs from cron, where the daemon owns state.json
|
||||||
|
if !st.Editable || st.Via != "" {
|
||||||
|
return spec // still only reachable through a container, or not reachable at all
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %s: %s is gone, running compose here against %s", spec.Project, spec.Via, st.HostFiles[0])
|
||||||
|
spec.Files, spec.Via = st.HostFiles, ""
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUpdate pulls, and deploys when the schedule says to. Shared with the CLI so the button
|
||||||
|
// and the cron line cannot drift apart.
|
||||||
|
func RunUpdate(ctx context.Context, db *store.Store, j UpdateJob, tool string) (string, error) {
|
||||||
|
if len(j.Spec.Files) == 0 {
|
||||||
|
return "", errors.New("this schedule has no compose file recorded — open it in the Docker tab and save it again")
|
||||||
|
}
|
||||||
|
spec := repoint(ctx, db, j.Spec.toSpec(tool))
|
||||||
|
var log strings.Builder
|
||||||
|
run := func(args ...string) error {
|
||||||
|
cmd := compose.Cmd(ctx, spec, args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
log.Write(out)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
args := append([]string{"pull"}, j.Services...)
|
||||||
|
if err := run(args...); err != nil {
|
||||||
|
return log.String(), err
|
||||||
|
}
|
||||||
|
if !j.Apply {
|
||||||
|
return log.String(), nil
|
||||||
|
}
|
||||||
|
up := append([]string{"up", "-d"}, j.Services...)
|
||||||
|
if err := run(up...); err != nil {
|
||||||
|
return log.String(), err
|
||||||
|
}
|
||||||
|
return log.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- endpoints ----
|
||||||
|
|
||||||
|
func (s *Server) dsList(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
d := s.dsLoad()
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]map[string]any, 0, len(d.Jobs))
|
||||||
|
for _, j := range d.Jobs {
|
||||||
|
m := map[string]any{"job": j, "last": storage.ReadRun(s.cfg.DataDir(), "docker-"+j.ID)}
|
||||||
|
if j.Enabled {
|
||||||
|
if n, ok := cron.NextRun(strings.Join(strings.Fields(j.Cron), " "), now); ok {
|
||||||
|
m["next"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
jsonOK(w, 200, map[string]any{"jobs": out, "file": dockerCronFile})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsPut(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var j UpdateJob
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 32<<10)).Decode(&j); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.Cron = strings.Join(strings.Fields(j.Cron), " ")
|
||||||
|
if err := cron.ValidSchedule(j.Cron); err != nil {
|
||||||
|
jsonErr(w, 400, "schedule: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(j.Stack) == "" {
|
||||||
|
jsonErr(w, 400, "which stack?")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// resolve the stack now, so the cron job never has to
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
stacks, err := s.findStacks(ctx)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, st := range stacks {
|
||||||
|
if st.Name == j.Stack {
|
||||||
|
// seenFiles is the list as compose itself sees it: host paths normally, the
|
||||||
|
// orchestrator container's own paths when compose runs inside one. Getting this
|
||||||
|
// backwards gives a cron line that works from the console and fails at 3am.
|
||||||
|
j.Spec = composeSpec{Project: st.Name, Files: seenFiles(st, -1, ""), Via: st.Via, Remote: st.Remote}
|
||||||
|
if len(j.Spec.Files) == 0 {
|
||||||
|
jsonErr(w, 409, "that stack's compose file isn't anywhere this console can reach")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no stack called "+j.Stack+" on this machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(j.Name) == "" {
|
||||||
|
j.Name = j.Stack
|
||||||
|
}
|
||||||
|
d := s.dsLoad()
|
||||||
|
replaced := false
|
||||||
|
for i := range d.Jobs {
|
||||||
|
if d.Jobs[i].ID == j.ID && j.ID != "" {
|
||||||
|
d.Jobs[i] = j
|
||||||
|
replaced = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.ID = hex.EncodeToString(b)
|
||||||
|
d.Jobs = append(d.Jobs, j)
|
||||||
|
}
|
||||||
|
if err := s.dsSave(d); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.dsSyncCron(r.Context(), d); err != nil {
|
||||||
|
jsonErr(w, 500, "saved, but writing "+dockerCronFile+" failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %q saved by %s (%s, %q, %s)", j.Name, clientIP(r), j.Stack, j.Cron, map[bool]string{true: "pull and deploy", false: "pull only"}[j.Apply])
|
||||||
|
jsonOK(w, 200, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
d := s.dsLoad()
|
||||||
|
kept := d.Jobs[:0]
|
||||||
|
found := false
|
||||||
|
for _, j := range d.Jobs {
|
||||||
|
if j.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, j)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Jobs = kept
|
||||||
|
if err := s.dsSave(d); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.dsSyncCron(r.Context(), d); err != nil {
|
||||||
|
jsonErr(w, 500, "removed, but writing "+dockerCronFile+" failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storage.ForgetRun(s.cfg.DataDir(), "docker-"+id)
|
||||||
|
log.Printf("docker schedule %s removed by %s", id, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dsRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
var job UpdateJob
|
||||||
|
found := false
|
||||||
|
for _, j := range s.dsLoad().Jobs {
|
||||||
|
if j.ID == id {
|
||||||
|
job, found = j, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such schedule")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Outliving the request: a pull the browser gave up on still finishes, but not forever.
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
out, err := RunUpdate(ctx, s.store, job, dockerTool())
|
||||||
|
rec := storage.RunRecord{At: time.Now(), Taken: job.Stack}
|
||||||
|
if err != nil {
|
||||||
|
rec.Error = err.Error()
|
||||||
|
}
|
||||||
|
_ = storage.WriteRun(s.cfg.DataDir(), "docker-"+id, rec)
|
||||||
|
stackUpdatesForget("/" + job.Stack)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error()+"\n"+tail(out, 2000))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("docker schedule %q run by %s", job.Name, clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]string{"output": tail(out, 4000)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Guest logins: a temporary account for handing to somebody you want to show around. It is
|
||||||
|
// an ordinary account with a clock on it — every tab, every action, on this machine and on
|
||||||
|
// every machine this hub manages — that stops working at its expiry (half an hour by
|
||||||
|
// default) or the moment it is revoked.
|
||||||
|
//
|
||||||
|
// Deliberately not a reduced account: Mitchell, 2026-09-27, replacing a read-only version
|
||||||
|
// built the same day. He gives these to people he trusts and revokes them otherwise, and a
|
||||||
|
// console with half its buttons missing is not the console he wanted to show off. What that
|
||||||
|
// means in practice: a guest has the Terminal tab, which is a shell as the user the daemon
|
||||||
|
// runs as — root on every host here — and can make an account or a token of its own that
|
||||||
|
// outlives the guest login.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/auth"
|
||||||
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
guestPrefix = "guest-"
|
||||||
|
guestTTLDefault = 30 * time.Minute
|
||||||
|
guestTTLMax = 24 * time.Hour
|
||||||
|
guestReapEvery = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- the accounts themselves ----
|
||||||
|
|
||||||
|
func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
|
||||||
|
now := time.Now()
|
||||||
|
counts := s.sessions.Count()
|
||||||
|
out := []map[string]any{}
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if !u.Guest || u.Expired(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"name": u.Name, "created": u.Created, "expires": u.Expires,
|
||||||
|
"issued_by": u.IssuedBy, "sessions": counts[u.Name], "used": !u.LastSeen.IsZero(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jsonOK(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addGuest makes a guest login and hands back its password — the only time it is ever
|
||||||
|
// readable, here as everywhere else a password is generated.
|
||||||
|
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := actorOf(r)
|
||||||
|
var body struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||||
|
ttl := guestTTLDefault
|
||||||
|
if body.Minutes > 0 {
|
||||||
|
ttl = time.Duration(body.Minutes) * time.Minute
|
||||||
|
}
|
||||||
|
if ttl > guestTTLMax {
|
||||||
|
jsonErr(w, 400, "a guest login lasts at most 24 hours")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pw, err := auth.GuestPassword()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(pw)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
for try := 0; try < 8 && name == ""; try++ {
|
||||||
|
suffix, err := auth.GuestSuffix()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidate := guestPrefix + suffix
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
if _, taken := c.User(candidate); !taken {
|
||||||
|
name = candidate
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
jsonErr(w, 500, "could not find a free guest name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expires := now.Add(ttl)
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
c.Users = append(c.Users, config.User{Name: name, Hash: hash, Created: now, Guest: true, Expires: expires, IssuedBy: me.User})
|
||||||
|
}); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("guest login %q issued until %s (by %s)", name, expires.Format(time.RFC3339), clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]any{"name": name, "password": pw, "expires": expires, "minutes": int(ttl.Minutes())})
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeGuest ends a guest login now. No password: it only ever takes access away, and the
|
||||||
|
// whole point of the thing is that ending it is one tap.
|
||||||
|
func (s *Server) revokeGuest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
found := false
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
kept := c.Users[:0]
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if u.Name == name && u.Guest {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, u)
|
||||||
|
}
|
||||||
|
c.Users = kept
|
||||||
|
}); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such guest login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.forgetGuest(name)
|
||||||
|
log.Printf("guest login %q revoked (by %s)", name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetGuest signs a guest out everywhere and drops the look they left behind.
|
||||||
|
func (s *Server) forgetGuest(name string) {
|
||||||
|
s.sessions.RevokeUser(name)
|
||||||
|
_ = s.store.Update(func(st *store.State) { delete(st.Users, name) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchGuests clears out guest logins whose time is up. Expiry is enforced on every request
|
||||||
|
// as well — this is what keeps the config file from collecting them, and what signs a guest
|
||||||
|
// out of a console they left open rather than waiting for their next click.
|
||||||
|
func (s *Server) watchGuests() {
|
||||||
|
for {
|
||||||
|
s.reapGuests()
|
||||||
|
time.Sleep(guestReapEvery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) reapGuests() {
|
||||||
|
now := time.Now()
|
||||||
|
var gone []string
|
||||||
|
var have bool
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if u.Guest {
|
||||||
|
have = true
|
||||||
|
if u.Expired(now) {
|
||||||
|
gone = append(gone, u.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !have || len(gone) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expired := map[string]bool{}
|
||||||
|
for _, n := range gone {
|
||||||
|
expired[n] = true
|
||||||
|
}
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
kept := c.Users[:0]
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if expired[u.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, u)
|
||||||
|
}
|
||||||
|
c.Users = kept
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("guests: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, n := range gone {
|
||||||
|
s.forgetGuest(n)
|
||||||
|
}
|
||||||
|
log.Printf("guest login(s) expired: %s", strings.Join(gone, ", "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/auth"
|
||||||
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A guest login is an ordinary account with a clock on it, so the clock is the thing worth
|
||||||
|
// testing: one whose time is up is stopped at the door, whether or not the minute-by-minute
|
||||||
|
// sweep has reached their account yet, and their session is dropped on the way — so the
|
||||||
|
// console they left open goes back to the login form rather than carrying on.
|
||||||
|
func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
expires time.Time
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"still has time", time.Now().Add(10 * time.Minute), true},
|
||||||
|
{"time is up", time.Now().Add(-time.Second), false},
|
||||||
|
{"no expiry at all", time.Time{}, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Default(filepath.Join(dir, "config.json"))
|
||||||
|
if err := cfg.Update(func(c *config.Config) {
|
||||||
|
c.Users = []config.User{{Name: "guest-abcd", Hash: "x", Guest: true, Expires: tc.expires}}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(dir, "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)}
|
||||||
|
tok, err := s.sessions.Create("guest-abcd", "10.0.0.5", "curl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("GET", "/api/me", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a, ok, answered := s.cookieActor(w, r)
|
||||||
|
if ok != tc.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||||
|
}
|
||||||
|
if tc.wantOK {
|
||||||
|
if !a.Guest || a.User != "guest-abcd" {
|
||||||
|
t.Errorf("actor = %+v, want the guest account marked as one", a)
|
||||||
|
}
|
||||||
|
if answered {
|
||||||
|
t.Error("answered the caller for a guest that still has time")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !answered || w.Code != 401 {
|
||||||
|
t.Errorf("answered = %v, code = %d, want true and 401", answered, w.Code)
|
||||||
|
}
|
||||||
|
if _, live := s.sessions.Lookup(tok, "10.0.0.5"); live {
|
||||||
|
t.Error("the session of an expired guest is still live")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// The Hardware tab: what the machine is made of. Read once and remembered — none of it
|
||||||
|
// changes while the kernel runs — with ?refresh=1 for the one thing that can, a drive
|
||||||
|
// plugged in or pulled out.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) hardwareReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jsonOK(w, 200, s.hardware.Report(r.Context(), r.URL.Query().Get("refresh") == "1"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// The Network tab: the machine's interfaces, how the internet looks from here, and the speed
|
||||||
|
// test. One machine at a time, like every other tab — the test runs on the machine in the
|
||||||
|
// switcher, so the number is that machine's line and not the hub's.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/network"
|
||||||
|
"hamlab/internal/sensors"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// networkOverview is everything the tab draws at once: the interfaces, the outside view, the
|
||||||
|
// last test and the ones before it.
|
||||||
|
func (s *Server) networkOverview(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
run := s.net.Test.State()
|
||||||
|
jsonOK(w, 200, map[string]any{
|
||||||
|
"report": network.Look(s.hostName()),
|
||||||
|
"outside": s.net.Outside(),
|
||||||
|
"run": run,
|
||||||
|
"history": s.net.Test.History(),
|
||||||
|
"method": network.Method,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) speedtestState(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
jsonOK(w, 200, s.net.Test.State())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) speedtestStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
run, err := s.net.Test.Start()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 409, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("speed test started on %s (by %s)", s.hostName(), clientIP(r))
|
||||||
|
jsonOK(w, 200, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) speedtestCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.net.Test.Cancel() {
|
||||||
|
jsonErr(w, 404, "no speed test is running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("speed test cancelled on %s (by %s)", s.hostName(), clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keeping the results ----
|
||||||
|
|
||||||
|
// savedNetwork is what survives a restart: the recent results, so the history is a history
|
||||||
|
// rather than whatever has happened since the daemon last started.
|
||||||
|
type savedNetwork struct {
|
||||||
|
Results []network.Result `json:"results,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loadNetwork() []network.Result {
|
||||||
|
var saved savedNetwork
|
||||||
|
var raw json.RawMessage
|
||||||
|
s.store.View(func(st *store.State) { raw = st.Network })
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &saved); err != nil {
|
||||||
|
log.Printf("network: kept results unreadable (%v); starting a fresh history", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saved.Results
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepResult is called when a test finishes: append it to what is on disk.
|
||||||
|
func (s *Server) keepResult(r network.Result) {
|
||||||
|
if r.Error != "" {
|
||||||
|
log.Printf("speed test on %s failed: %s", s.hostName(), r.Error)
|
||||||
|
} else {
|
||||||
|
log.Printf("speed test on %s: down %s, up %s, latency %.1f ms via %s",
|
||||||
|
s.hostName(), network.Mbits(r.DownMbps), network.Mbits(r.UpMbps), r.LatencyMs, r.Colo)
|
||||||
|
}
|
||||||
|
results := s.net.Test.History()
|
||||||
|
b, err := json.Marshal(savedNetwork{Results: results})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.Update(func(st *store.State) { st.Network = b }); err != nil {
|
||||||
|
log.Printf("network: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the speed test as sensors ----
|
||||||
|
|
||||||
|
// netSensors publishes the last speed test as ordinary sensors, so the line can be a widget,
|
||||||
|
// a graph, an alert or a Prometheus series like anything else Ham Lab measures. Each one's
|
||||||
|
// timestamp is when the test ran, which is how a stale reading says so.
|
||||||
|
type netSensors struct{ w *network.Watcher }
|
||||||
|
|
||||||
|
func (n netSensors) Sensors() []sensors.Sensor {
|
||||||
|
var out []sensors.Sensor
|
||||||
|
if o := n.w.Outside(); o.IP != "" {
|
||||||
|
at := o.At.UnixMilli()
|
||||||
|
out = append(out, sensors.Sensor{ID: "net.internet.ip", Name: "Public address", Kind: "state", Value: o.IP, T: at})
|
||||||
|
if o.Colo != "" {
|
||||||
|
out = append(out, sensors.Sensor{ID: "net.internet.edge", Name: "Internet edge", Kind: "state", Value: o.Colo, T: at})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run := n.w.Test.State()
|
||||||
|
if run.Result == nil || run.Result.Error != "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
r := run.Result
|
||||||
|
at := r.At.UnixMilli()
|
||||||
|
return append(out,
|
||||||
|
sensors.Sensor{ID: "net.internet.download", Name: "Download", Kind: "rate", Unit: "Mbps", Value: r.DownMbps, T: at, Min: sensors.F(0)},
|
||||||
|
sensors.Sensor{ID: "net.internet.upload", Name: "Upload", Kind: "rate", Unit: "Mbps", Value: r.UpMbps, T: at, Min: sensors.F(0)},
|
||||||
|
sensors.Sensor{ID: "net.internet.latency", Name: "Latency", Kind: "duration", Unit: "ms", Value: r.LatencyMs, T: at, Min: sensors.F(0)},
|
||||||
|
sensors.Sensor{ID: "net.internet.jitter", Name: "Jitter", Kind: "duration", Unit: "ms", Value: r.JitterMs, T: at, Min: sensors.F(0)},
|
||||||
|
sensors.Sensor{ID: "net.internet.tested", Name: "Speed test age", Kind: "duration", Unit: "s", Value: round1(time.Since(r.At).Seconds()), T: at, Min: sensors.F(0)},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func round1(v float64) float64 { return float64(int64(v*10+0.5)) / 10 }
|
||||||
+96
-18
@@ -33,7 +33,9 @@ import (
|
|||||||
"hamlab/internal/config"
|
"hamlab/internal/config"
|
||||||
"hamlab/internal/cron"
|
"hamlab/internal/cron"
|
||||||
"hamlab/internal/files"
|
"hamlab/internal/files"
|
||||||
|
"hamlab/internal/hardware"
|
||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
|
"hamlab/internal/network"
|
||||||
"hamlab/internal/nodes"
|
"hamlab/internal/nodes"
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
"hamlab/internal/sensors"
|
"hamlab/internal/sensors"
|
||||||
@@ -67,8 +69,11 @@ type Server struct {
|
|||||||
tasks *tasks.Manager
|
tasks *tasks.Manager
|
||||||
updates *updates.Manager
|
updates *updates.Manager
|
||||||
storage *storage.Manager
|
storage *storage.Manager
|
||||||
|
hardware *hardware.Manager
|
||||||
space *space.Manager
|
space *space.Manager
|
||||||
|
net *network.Watcher
|
||||||
update updateState
|
update updateState
|
||||||
|
events eventLog
|
||||||
kvms kvmProxies
|
kvms kvmProxies
|
||||||
fsLocal files.FS
|
fsLocal files.FS
|
||||||
fsSudo files.FS // nil without passwordless sudo
|
fsSudo files.FS // nil without passwordless sudo
|
||||||
@@ -86,7 +91,7 @@ type Server struct {
|
|||||||
|
|
||||||
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
|
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
|
||||||
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
|
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
|
||||||
started: time.Now(), user: os.Getenv("USER")}
|
hardware: hardware.New(), started: time.Now(), user: os.Getenv("USER")}
|
||||||
self, err := os.Executable()
|
self, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
self = "hamlab"
|
self = "hamlab"
|
||||||
@@ -100,6 +105,11 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
s.user = "?"
|
s.user = "?"
|
||||||
}
|
}
|
||||||
s.migrateUsers()
|
s.migrateUsers()
|
||||||
|
// The speed test and the outside view. Its history outlives a restart, and its last result
|
||||||
|
// is published as sensors, so the line is graphable and alertable like anything else.
|
||||||
|
s.net = network.NewWatcher(s.keepResult)
|
||||||
|
s.net.Test.Load(s.loadNetwork())
|
||||||
|
s.sensors.Add(netSensors{s.net})
|
||||||
s.sensors.Add(newStatusSensors(s))
|
s.sensors.Add(newStatusSensors(s))
|
||||||
s.alerts = newAlertEngine(s)
|
s.alerts = newAlertEngine(s)
|
||||||
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
||||||
@@ -137,8 +147,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
}
|
}
|
||||||
go s.keepFleetInStep()
|
go s.keepFleetInStep()
|
||||||
}
|
}
|
||||||
|
go s.watchGuests()
|
||||||
go s.watchForUpdates(context.Background())
|
go s.watchForUpdates(context.Background())
|
||||||
|
go s.watchDockerEvents(context.Background())
|
||||||
|
|
||||||
|
s.mux = s.routes()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes is the whole HTTP surface. A method of its own, rather than inline in New, so a
|
||||||
|
// test can build the real router without the machinery behind it — which is what the guest
|
||||||
|
// gate is checked against, since it decides a request by asking this router where it would go.
|
||||||
|
func (s *Server) routes() *http.ServeMux {
|
||||||
m := http.NewServeMux()
|
m := http.NewServeMux()
|
||||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
||||||
m.HandleFunc("POST /api/login", s.login)
|
m.HandleFunc("POST /api/login", s.login)
|
||||||
@@ -185,11 +205,19 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
|
m.HandleFunc("POST /api/docker/images/prune", s.auth(s.imagePrune))
|
||||||
m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove))
|
m.HandleFunc("POST /api/docker/volumes/remove", s.auth(s.volumeRemove))
|
||||||
m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune))
|
m.HandleFunc("POST /api/docker/volumes/prune", s.auth(s.volumePrune))
|
||||||
|
m.HandleFunc("POST /api/docker/networks/remove", s.auth(s.networkRemove))
|
||||||
|
m.HandleFunc("POST /api/docker/networks/prune", s.auth(s.networkPrune))
|
||||||
m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
|
m.HandleFunc("GET /api/docker/stacks/{name}", s.auth(s.stackFile))
|
||||||
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
|
m.HandleFunc("PUT /api/docker/stacks/{name}", s.auth(s.stackSave))
|
||||||
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
m.HandleFunc("POST /api/docker/stacks/{name}/{action}", s.auth(s.stackAction))
|
||||||
m.HandleFunc("GET /api/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
m.HandleFunc("GET /api/docker/stacks/{name}/diff", s.auth(s.stackDiff))
|
||||||
m.HandleFunc("GET /api/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
m.HandleFunc("GET /api/docker/stacks/{name}/updates", s.auth(s.stackUpdates))
|
||||||
|
m.HandleFunc("GET /api/docker/updates", s.auth(s.machineUpdates))
|
||||||
|
m.HandleFunc("GET /api/docker/schedules", s.auth(s.dsList))
|
||||||
|
m.HandleFunc("PUT /api/docker/schedules", s.auth(s.dsPut))
|
||||||
|
m.HandleFunc("DELETE /api/docker/schedules/{id}", s.auth(s.dsDelete))
|
||||||
|
m.HandleFunc("POST /api/docker/schedules/{id}/run", s.auth(s.dsRun))
|
||||||
|
m.HandleFunc("GET /api/nodes/{node}/docker/updates", s.auth(s.machineUpdates))
|
||||||
// The same, asked of a node through this hub: the hub answers for the stacks whose files
|
// The same, asked of a node through this hub: the hub answers for the stacks whose files
|
||||||
// it holds (deployed to the node over tcp) and passes everything else to the node.
|
// it holds (deployed to the node over tcp) and passes everything else to the node.
|
||||||
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
m.HandleFunc("GET /api/nodes/{node}/docker/stacks", s.auth(s.stacksList))
|
||||||
@@ -205,7 +233,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
m.HandleFunc("GET /api/nodes/{node}/tasks/{id}/stream", s.auth(s.taskOnHub(s.streamTask)))
|
||||||
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
|
m.HandleFunc("POST /api/docker/containers/{id}/{action}", s.auth(s.dockerAction))
|
||||||
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
|
m.HandleFunc("GET /api/docker/containers/{id}/logs", s.auth(s.dockerLogs))
|
||||||
|
m.HandleFunc("GET /api/docker/containers/{id}/logs/tail", s.auth(s.dockerTail))
|
||||||
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
|
m.HandleFunc("GET /api/docker/containers/{id}/inspect", s.auth(s.dockerInspect))
|
||||||
|
m.HandleFunc("GET /api/docker/containers/{id}/health", s.auth(s.dockerHealth))
|
||||||
|
m.HandleFunc("GET /api/docker/containers/{id}/events", s.auth(s.dockerEvents))
|
||||||
|
m.HandleFunc("POST /api/docker/containers/{id}/exec", s.auth(s.dockerExec))
|
||||||
|
m.HandleFunc("POST /api/docker/containers/{id}/limits", s.auth(s.dockerUpdate))
|
||||||
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
|
m.HandleFunc("GET /api/alerts", s.auth(s.alertsGet))
|
||||||
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
|
m.HandleFunc("PUT /api/alerts", s.auth(s.alertsPut))
|
||||||
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
|
m.HandleFunc("POST /api/alerts/test", s.auth(s.alertsTest))
|
||||||
@@ -246,6 +279,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
|
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
|
||||||
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
||||||
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
||||||
|
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
||||||
|
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
|
||||||
|
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
|
||||||
|
m.HandleFunc("GET /api/network/speedtest", s.auth(s.speedtestState))
|
||||||
|
m.HandleFunc("POST /api/network/speedtest", s.auth(s.speedtestStart))
|
||||||
|
m.HandleFunc("POST /api/network/speedtest/cancel", s.auth(s.speedtestCancel))
|
||||||
m.HandleFunc("GET /api/storage", s.auth(s.storageOverview))
|
m.HandleFunc("GET /api/storage", s.auth(s.storageOverview))
|
||||||
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
|
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
|
||||||
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
|
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
|
||||||
@@ -271,6 +310,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/users", s.auth(s.addUser))
|
m.HandleFunc("POST /api/users", s.auth(s.addUser))
|
||||||
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
||||||
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
|
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
|
||||||
|
m.HandleFunc("GET /api/guests", s.auth(s.listGuests))
|
||||||
|
m.HandleFunc("POST /api/guests", s.auth(s.addGuest))
|
||||||
|
m.HandleFunc("DELETE /api/guests/{name}", s.auth(s.revokeGuest))
|
||||||
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
||||||
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
||||||
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
||||||
@@ -300,8 +342,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
jsonErr(w, 404, "not found")
|
jsonErr(w, 404, "not found")
|
||||||
}))
|
}))
|
||||||
m.HandleFunc("/", s.static)
|
m.HandleFunc("/", s.static)
|
||||||
s.mux = m
|
return m
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sensorPath reports whether a path belongs to the read-only sensor API.
|
// sensorPath reports whether a path belongs to the read-only sensor API.
|
||||||
@@ -387,14 +428,45 @@ func (s *Server) signedIn(r *http.Request) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cookieActor resolves the session cookie to who is calling. answered means the caller has
|
||||||
|
// already been sent an error and the handler must not run: the account behind a live session
|
||||||
|
// has gone away, or a guest login's time is up — so a console left open on a visitor's phone
|
||||||
|
// stops at the expiry, not at whatever they click next.
|
||||||
|
func (s *Server) cookieActor(w http.ResponseWriter, r *http.Request) (a actor, ok, answered bool) {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return actor{}, false, false
|
||||||
|
}
|
||||||
|
ses, live := s.sessions.Lookup(c.Value, clientKey(r))
|
||||||
|
if !live {
|
||||||
|
return actor{}, false, false
|
||||||
|
}
|
||||||
|
var u config.User
|
||||||
|
var found bool
|
||||||
|
s.cfg.View(func(cf *config.Config) { u, found = cf.User(ses.User) })
|
||||||
|
if !found || u.Expired(time.Now()) {
|
||||||
|
s.sessions.Delete(c.Value)
|
||||||
|
s.setCookie(w, r, "", -1)
|
||||||
|
if found {
|
||||||
|
jsonErr(w, 401, "this guest login has expired")
|
||||||
|
} else {
|
||||||
|
jsonErr(w, 401, "that account no longer exists")
|
||||||
|
}
|
||||||
|
return actor{}, false, true
|
||||||
|
}
|
||||||
|
return actor{User: ses.User, Session: ses.ID, token: c.Value, Guest: u.Guest, Expires: u.Expires}, true, false
|
||||||
|
}
|
||||||
|
|
||||||
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
||||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if c, err := r.Cookie(cookieName); err == nil {
|
a, ok, answered := s.cookieActor(w, r)
|
||||||
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok {
|
if answered {
|
||||||
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
|
return
|
||||||
return
|
}
|
||||||
}
|
if ok {
|
||||||
|
next(w, withActor(r, a))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||||
var hashes, names []string
|
var hashes, names []string
|
||||||
@@ -428,11 +500,11 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ok, trusted := false, false
|
ok, trusted := false, false
|
||||||
var patterns []string
|
var patterns []string
|
||||||
if c, err := r.Cookie(cookieName); err == nil {
|
if a, live, answered := s.cookieActor(w, r); answered {
|
||||||
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
|
return
|
||||||
ok, trusted = true, true
|
} else if live {
|
||||||
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
|
ok, trusted = true, true
|
||||||
}
|
r = withActor(r, a)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
||||||
@@ -901,11 +973,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
// name is allowed only while there is exactly one account — what a client from before
|
// name is allowed only while there is exactly one account — what a client from before
|
||||||
// user names sends, and what a hub pairing with a one-user node may leave out.
|
// user names sends, and what a hub pairing with a one-user node may leave out.
|
||||||
func (s *Server) checkLogin(username, password string) (string, bool) {
|
func (s *Server) checkLogin(username, password string) (string, bool) {
|
||||||
var users []config.User
|
var users, real []config.User
|
||||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
s.cfg.View(func(c *config.Config) { users, real = append(users, c.Users...), c.Accounts() })
|
||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
if username == "" && len(users) == 1 {
|
if username == "" && len(real) == 1 { // guest logins are not the account an empty name means
|
||||||
username = users[0].Name
|
username = real[0].Name
|
||||||
}
|
}
|
||||||
if username == "" {
|
if username == "" {
|
||||||
auth.CheckUser(nil, "", password) // take as long as a real check
|
auth.CheckUser(nil, "", password) // take as long as a real check
|
||||||
@@ -1040,8 +1112,14 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
addr = h // the address this console listens on, for the machine menu
|
addr = h // the address this console listens on, for the machine menu
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
me := actorOf(r)
|
||||||
|
var guestUntil any
|
||||||
|
if me.Guest {
|
||||||
|
guestUntil = me.Expires
|
||||||
|
}
|
||||||
jsonOK(w, 200, map[string]any{
|
jsonOK(w, 200, map[string]any{
|
||||||
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
|
"host": s.hostName(), "user": s.user, "account": me.User, "version": s.version,
|
||||||
|
"guest": me.Guest, "guest_until": guestUntil,
|
||||||
"load": load, "uptime": uptime, "service_started": s.started,
|
"load": load, "uptime": uptime, "service_started": s.started,
|
||||||
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
||||||
"update_available": updateVersion,
|
"update_available": updateVersion,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type spaceTarget struct {
|
|||||||
Layout string `json:"layout,omitempty"` // pool: raidz2, mirror, …
|
Layout string `json:"layout,omitempty"` // pool: raidz2, mirror, …
|
||||||
Health string `json:"health,omitempty"` // pool: ONLINE, DEGRADED, …
|
Health string `json:"health,omitempty"` // pool: ONLINE, DEGRADED, …
|
||||||
Size uint64 `json:"size"`
|
Size uint64 `json:"size"`
|
||||||
Used uint64 `json:"used,omitempty"` // pool: allocated
|
Used uint64 `json:"used,omitempty"` // pool: allocated
|
||||||
Disks []string `json:"disks,omitempty"` // pool: its member disks
|
Disks []string `json:"disks,omitempty"` // pool: its member disks
|
||||||
Roots []string `json:"roots"` // the mountpoints a scan walks
|
Roots []string `json:"roots"` // the mountpoints a scan walks
|
||||||
Scan *space.Scan `json:"scan,omitempty"` // the last scan, if any
|
Scan *space.Scan `json:"scan,omitempty"` // the last scan, if any
|
||||||
|
|||||||
+216
-58
@@ -12,6 +12,12 @@ package api
|
|||||||
// relative paths, the .env beside them and the labels all stay exactly as they were
|
// relative paths, the .env beside them and the labels all stay exactly as they were
|
||||||
// deployed — else on the host.
|
// deployed — else on the host.
|
||||||
//
|
//
|
||||||
|
// Those mounts are also written down. An orchestrator can be removed, and when it is, the
|
||||||
|
// containers it left behind still carry labels naming paths inside a filesystem that no
|
||||||
|
// longer exists; without a record of where that filesystem was mounted, every stack on the
|
||||||
|
// machine would become unfindable at once. A remembered mount resolves with no container to
|
||||||
|
// run in, which is right: compose runs on the host against the host's copy of the file.
|
||||||
|
//
|
||||||
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
|
// A stack can also be deployed *from another machine*: Dockhand on the hub drives a node's
|
||||||
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
|
// Docker over tcp, so the node runs the containers and the hub holds the files. The node
|
||||||
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
// alone can only list such a stack read-only. The hub, asked about one of its nodes, fills
|
||||||
@@ -39,7 +45,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
"hamlab/internal/priv"
|
"hamlab/internal/priv"
|
||||||
|
"hamlab/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stack struct {
|
type stack struct {
|
||||||
@@ -89,6 +97,100 @@ func stackMounts(ctx context.Context) []mountMap {
|
|||||||
return maps
|
return maps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mountsFor is stackMounts plus memory. An orchestrator that deployed everything here and
|
||||||
|
// has since been removed takes the only record of where its files really are with it: the
|
||||||
|
// containers it left behind are labelled with paths inside *its* filesystem. So every map
|
||||||
|
// seen is written down, and a path that no live container explains is looked for in what
|
||||||
|
// was seen before. A remembered map resolves with no `via`, which is correct — the
|
||||||
|
// container is gone, so compose runs on the host, against the host's copy of the file.
|
||||||
|
func mountsFor(ctx context.Context, db *store.Store) []mountMap {
|
||||||
|
live := stackMounts(ctx)
|
||||||
|
rememberMounts(db, live)
|
||||||
|
return append(live, recallMounts(db, live)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountsSeen is the same without writing anything down. The daemon owns state.json; a cron
|
||||||
|
// job that wrote to it would be clobbered by the next thing the daemon saved, so out there
|
||||||
|
// the record is read and never added to.
|
||||||
|
func mountsSeen(ctx context.Context, db *store.Store) []mountMap {
|
||||||
|
live := stackMounts(ctx)
|
||||||
|
return append(live, recallMounts(db, live)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rememberMounts folds what is mounted now into what was mounted before, and forgets a map
|
||||||
|
// whose source has gone from disk — that one is not coming back, and keeping it would make
|
||||||
|
// a stack look editable when its file no longer exists.
|
||||||
|
//
|
||||||
|
// This is on the path of every stack listing, which the Docker tab polls, so it reads first
|
||||||
|
// and only writes when the set actually changed.
|
||||||
|
func rememberMounts(db *store.Store, live []mountMap) {
|
||||||
|
if len(live) == 0 {
|
||||||
|
return // docker is down or answered nothing; that is not evidence of anything
|
||||||
|
}
|
||||||
|
key := func(c, d string) string { return c + "\x00" + d }
|
||||||
|
var have []store.RememberedMount
|
||||||
|
db.View(func(st *store.State) { have = append([]store.RememberedMount(nil), st.Mounts...) })
|
||||||
|
at := map[string]int{}
|
||||||
|
for i, m := range have {
|
||||||
|
at[key(m.Container, m.Dest)] = i
|
||||||
|
}
|
||||||
|
now, add, moved := time.Now(), 0, false
|
||||||
|
for _, m := range live {
|
||||||
|
i, ok := at[key(m.container, m.dest)]
|
||||||
|
if !ok {
|
||||||
|
add++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if have[i].Src != m.src {
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pruning is the expensive half — a stat, or a sudo test(1), per remembered map — so it
|
||||||
|
// only happens when something else has already made a write necessary.
|
||||||
|
if add == 0 && !moved {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = db.Update(func(st *store.State) {
|
||||||
|
at := map[string]int{}
|
||||||
|
for i, m := range st.Mounts {
|
||||||
|
at[key(m.Container, m.Dest)] = i
|
||||||
|
}
|
||||||
|
for _, m := range live {
|
||||||
|
if i, ok := at[key(m.container, m.dest)]; ok {
|
||||||
|
st.Mounts[i].Src, st.Mounts[i].Seen = m.src, now
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st.Mounts = append(st.Mounts, store.RememberedMount{Container: m.container, Dest: m.dest, Src: m.src, Seen: now})
|
||||||
|
}
|
||||||
|
kept := st.Mounts[:0]
|
||||||
|
for _, m := range st.Mounts {
|
||||||
|
if readable(m.Src) {
|
||||||
|
kept = append(kept, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Mounts = kept
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recallMounts is what was seen before and is not running now.
|
||||||
|
func recallMounts(db *store.Store, live []mountMap) []mountMap {
|
||||||
|
var remembered []store.RememberedMount
|
||||||
|
db.View(func(st *store.State) { remembered = append([]store.RememberedMount(nil), st.Mounts...) })
|
||||||
|
now := map[string]bool{}
|
||||||
|
for _, m := range live {
|
||||||
|
now[m.container+"\x00"+m.dest] = true
|
||||||
|
}
|
||||||
|
var out []mountMap
|
||||||
|
for _, m := range remembered {
|
||||||
|
if now[m.Container+"\x00"+m.Dest] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, mountMap{"", filepath.Clean(m.Dest), filepath.Clean(m.Src)}) // no container: run compose here
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return len(out[i].dest) > len(out[j].dest) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func readable(p string) bool {
|
func readable(p string) bool {
|
||||||
if _, err := os.Stat(p); err == nil {
|
if _, err := os.Stat(p); err == nil {
|
||||||
return true
|
return true
|
||||||
@@ -172,7 +274,7 @@ func (s *Server) findStacks(ctx context.Context) ([]stack, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maps := stackMounts(ctx)
|
maps := mountsFor(ctx, s.store)
|
||||||
stacks := []stack{}
|
stacks := []stack{}
|
||||||
for _, st := range by {
|
for _, st := range by {
|
||||||
sort.Strings(st.Containers)
|
sort.Strings(st.Containers)
|
||||||
@@ -353,7 +455,7 @@ func (s *Server) hubStacks(ctx context.Context, node string) ([]stack, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if remote == "" {
|
if remote == "" {
|
||||||
remote, maps = s.nodeDocker(ctx, node), stackMounts(ctx)
|
remote, maps = s.nodeDocker(ctx, node), mountsFor(ctx, s.store)
|
||||||
}
|
}
|
||||||
if remote == "-" {
|
if remote == "-" {
|
||||||
continue
|
continue
|
||||||
@@ -511,24 +613,13 @@ func (s *Server) stackFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
// composeCmd is `docker compose …` for this stack, run where its files make sense and
|
// composeCmd is `docker compose …` for this stack, run where its files make sense and
|
||||||
// pointed at the Docker that runs its containers. files are given as compose will see them.
|
// pointed at the Docker that runs its containers. files are given as compose will see them.
|
||||||
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
|
func composeCmd(ctx context.Context, st stack, files []string, args ...string) *exec.Cmd {
|
||||||
tool := dockerTool()
|
return compose.Cmd(ctx, specOf(st, files), args...)
|
||||||
base := []string{"compose", "-p", st.Name}
|
}
|
||||||
for _, f := range files {
|
|
||||||
base = append(base, "-f", f)
|
// specOf is a stack as the compose runner needs it — and as a scheduled update stores it,
|
||||||
}
|
// so the cron job and the console invoke compose identically.
|
||||||
if st.Via != "" {
|
func specOf(st stack, files []string) compose.Spec {
|
||||||
pre := []string{"exec"}
|
return compose.Spec{Project: st.Name, Files: files, Via: st.Via, Remote: st.Remote, Tool: dockerTool()}
|
||||||
if st.Remote != "" {
|
|
||||||
pre = append(pre, "-e", "DOCKER_HOST="+st.Remote)
|
|
||||||
}
|
|
||||||
return exec.CommandContext(ctx, tool, append(append(append(pre, st.Via, "docker"), base...), args...)...)
|
|
||||||
}
|
|
||||||
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
|
|
||||||
cmd.Dir = filepath.Dir(files[0])
|
|
||||||
if st.Remote != "" {
|
|
||||||
cmd.Env = append(os.Environ(), "DOCKER_HOST="+st.Remote)
|
|
||||||
}
|
|
||||||
return cmd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate.
|
// seenFiles is the stack's file list as compose runs it, with file i replaced by a candidate.
|
||||||
@@ -734,6 +825,12 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
script = base("up -d --remove-orphans") + remember
|
script = base("up -d --remove-orphans") + remember
|
||||||
case "update":
|
case "update":
|
||||||
script = base("pull") + " && " + base("up -d --remove-orphans") + remember
|
script = base("pull") + " && " + base("up -d --remove-orphans") + remember
|
||||||
|
case "recreate":
|
||||||
|
// The way out of a container Docker will no longer start — most often one pinned to a
|
||||||
|
// network that has since been recreated, which answers "network … not found" to every
|
||||||
|
// ordinary deploy. `up -d` leaves such a container alone because its definition has
|
||||||
|
// not changed; only --force-recreate builds it again.
|
||||||
|
script = base("up -d --force-recreate --remove-orphans") + remember
|
||||||
case "restart":
|
case "restart":
|
||||||
script = base("restart")
|
script = base("restart")
|
||||||
case "stop":
|
case "stop":
|
||||||
@@ -741,7 +838,7 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
|
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
|
||||||
script = base("down --remove-orphans")
|
script = base("down --remove-orphans")
|
||||||
default:
|
default:
|
||||||
jsonErr(w, 400, "deploy, update, restart, stop or down")
|
jsonErr(w, 400, "deploy, update, recreate, restart, stop or down")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
|
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
|
||||||
@@ -788,6 +885,103 @@ var (
|
|||||||
|
|
||||||
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
func stackUpdatesForget(key string) { updMu.Lock(); delete(updCache, key); updMu.Unlock() }
|
||||||
|
|
||||||
|
// checkImages asks the registry whether each image is still the one we hold. remote is a
|
||||||
|
// DOCKER_HOST for a machine whose images live elsewhere, "" for this one. Each image is one
|
||||||
|
// registry request, which Docker Hub counts — so this is only ever called when asked for.
|
||||||
|
func checkImages(ctx context.Context, images []string, remote string) []imageCheck {
|
||||||
|
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
||||||
|
if remote != "" {
|
||||||
|
args = append([]string{"-H", remote}, args...)
|
||||||
|
}
|
||||||
|
return dockerRun(ctx, args...)
|
||||||
|
}
|
||||||
|
res := []imageCheck{}
|
||||||
|
for _, img := range images {
|
||||||
|
ic := imageCheck{Image: img, State: "unknown"}
|
||||||
|
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
||||||
|
var have []string
|
||||||
|
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
||||||
|
ic.Detail = "built or loaded here, not pulled from a registry"
|
||||||
|
res = append(res, ic)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rem, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
|
||||||
|
digest := strings.TrimSpace(string(rem))
|
||||||
|
if i := strings.LastIndex(digest, "\n"); i >= 0 {
|
||||||
|
digest = digest[i+1:]
|
||||||
|
}
|
||||||
|
if err != nil || !strings.HasPrefix(digest, "sha256:") {
|
||||||
|
ic.Detail = "the registry didn't answer"
|
||||||
|
if err != nil {
|
||||||
|
ic.Detail += ": " + err.Error()
|
||||||
|
}
|
||||||
|
res = append(res, ic)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ic.State = "update"
|
||||||
|
for _, h := range have {
|
||||||
|
if strings.HasSuffix(h, "@"+digest) {
|
||||||
|
ic.State = "current"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res = append(res, ic)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// machineUpdates checks every image on this machine in one pass, so the container list can
|
||||||
|
// be coloured and the fleet can be asked "what is behind?" without walking stack by stack.
|
||||||
|
func (s *Server) machineUpdates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
node := r.PathValue("node")
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
stacks, err := s.findStacks(ctx)
|
||||||
|
if node != "" {
|
||||||
|
stacks, err = s.hubStacks(ctx, node)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// one entry per image, however many stacks use it
|
||||||
|
type want struct{ remote string }
|
||||||
|
imgs := map[string]want{}
|
||||||
|
byImage := map[string][]string{} // image -> stacks
|
||||||
|
for _, st := range stacks {
|
||||||
|
for _, img := range st.Images {
|
||||||
|
imgs[img] = want{remote: st.Remote}
|
||||||
|
byImage[img] = append(byImage[img], st.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh := r.URL.Query().Get("refresh") == "1"
|
||||||
|
key := "machine:" + node
|
||||||
|
updMu.Lock()
|
||||||
|
c, ok := updCache[key]
|
||||||
|
updMu.Unlock()
|
||||||
|
if ok && time.Since(c.at) < 6*time.Hour && !refresh {
|
||||||
|
jsonOK(w, 200, map[string]any{"images": c.res, "checked": c.at.UnixMilli(), "stacks": byImage})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !refresh {
|
||||||
|
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0, "stacks": byImage})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
remote := ""
|
||||||
|
for img, wnt := range imgs {
|
||||||
|
list = append(list, img)
|
||||||
|
if wnt.remote != "" {
|
||||||
|
remote = wnt.remote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(list)
|
||||||
|
res := checkImages(ctx, list, remote)
|
||||||
|
updMu.Lock()
|
||||||
|
updCache[key] = updAnswer{time.Now(), res}
|
||||||
|
updMu.Unlock()
|
||||||
|
jsonOK(w, 200, map[string]any{"images": res, "checked": time.Now().UnixMilli(), "stacks": byImage})
|
||||||
|
}
|
||||||
|
|
||||||
// stackUpdates asks each image's registry for the digest its tag points at now and compares
|
// stackUpdates asks each image's registry for the digest its tag points at now and compares
|
||||||
// it with the digest that was pulled. It is asked for, never polled: every answer is a
|
// it with the digest that was pulled. It is asked for, never polled: every answer is a
|
||||||
// registry request, and Docker Hub counts those. Answers are kept for six hours.
|
// registry request, and Docker Hub counts those. Answers are kept for six hours.
|
||||||
@@ -816,43 +1010,7 @@ func (s *Server) stackUpdates(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
jsonOK(w, 200, map[string]any{"images": []imageCheck{}, "checked": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
local := func(args ...string) ([]byte, error) { // the Docker that holds the images
|
res := checkImages(ctx, st.Images, st.Remote)
|
||||||
if st.Remote != "" {
|
|
||||||
args = append([]string{"-H", st.Remote}, args...)
|
|
||||||
}
|
|
||||||
return dockerRun(ctx, args...)
|
|
||||||
}
|
|
||||||
res := []imageCheck{}
|
|
||||||
for _, img := range st.Images {
|
|
||||||
ic := imageCheck{Image: img, State: "unknown"}
|
|
||||||
out, err := local("image", "inspect", "-f", "{{json .RepoDigests}}", img)
|
|
||||||
var have []string
|
|
||||||
if err != nil || json.Unmarshal(bytes.TrimSpace(out), &have) != nil || len(have) == 0 {
|
|
||||||
ic.Detail = "built or loaded here, not pulled from a registry"
|
|
||||||
res = append(res, ic)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remote, err := dockerRun(ctx, "buildx", "imagetools", "inspect", img, "--format", "{{.Manifest.Digest}}")
|
|
||||||
digest := strings.TrimSpace(string(remote))
|
|
||||||
if i := strings.LastIndex(digest, "\n"); i >= 0 {
|
|
||||||
digest = digest[i+1:]
|
|
||||||
}
|
|
||||||
if err != nil || !strings.HasPrefix(digest, "sha256:") {
|
|
||||||
ic.Detail = "the registry didn't answer"
|
|
||||||
if err != nil {
|
|
||||||
ic.Detail += ": " + err.Error()
|
|
||||||
}
|
|
||||||
res = append(res, ic)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ic.State = "update"
|
|
||||||
for _, h := range have {
|
|
||||||
if strings.HasSuffix(h, "@"+digest) {
|
|
||||||
ic.State = "current"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res = append(res, ic)
|
|
||||||
}
|
|
||||||
updMu.Lock()
|
updMu.Lock()
|
||||||
updCache[key] = updAnswer{time.Now(), res}
|
updCache[key] = updAnswer{time.Now(), res}
|
||||||
updMu.Unlock()
|
updMu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlab/internal/compose"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A stack deployed by an orchestrator running in a container records the path its compose
|
||||||
|
// file had inside that container. Resolving it has to work both while the orchestrator is
|
||||||
|
// running — compose is then run inside it — and after it has been removed, where the same
|
||||||
|
// mount is only remembered and compose has to run on the host instead.
|
||||||
|
func TestResolveThroughMounts(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(dir, "compose.yaml")
|
||||||
|
if err := os.WriteFile(file, []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inner := "/app/data/stacks/app/compose.yaml"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, container, wantVia string
|
||||||
|
}{
|
||||||
|
{"orchestrator running", "orch", "orch"},
|
||||||
|
{"orchestrator removed", "", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
st := stack{Name: "app", File: inner, Files: []string{inner}}
|
||||||
|
st.resolve([]mountMap{{tc.container, "/app/data", filepath.Join(root, "orch")}})
|
||||||
|
if !st.Editable {
|
||||||
|
t.Fatalf("not editable: %s", st.Note)
|
||||||
|
}
|
||||||
|
if st.HostFile != file {
|
||||||
|
t.Errorf("host file = %q, want %q", st.HostFile, file)
|
||||||
|
}
|
||||||
|
if st.Via != tc.wantVia {
|
||||||
|
t.Errorf("via = %q, want %q", st.Via, tc.wantVia)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing explains the path: the stack is listed, but read-only and says why.
|
||||||
|
st := stack{Name: "app", File: inner, Files: []string{inner}}
|
||||||
|
st.resolve(nil)
|
||||||
|
if st.Editable || st.Note == "" {
|
||||||
|
t.Errorf("unresolvable stack should be read-only with a note, got editable=%v note=%q", st.Editable, st.Note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every file of a multi-file stack must resolve through the same place. Redeploying from
|
||||||
|
// the first file alone would silently drop whatever the override set.
|
||||||
|
func TestResolveRefusesSplitStack(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st := stack{Name: "app", File: "/app/data/stacks/app/compose.yaml", Files: []string{
|
||||||
|
"/app/data/stacks/app/compose.yaml",
|
||||||
|
"/app/data/stacks/app/override.yaml", // never written: not on disk
|
||||||
|
}}
|
||||||
|
st.resolve([]mountMap{{"orch", "/app/data", filepath.Join(root, "orch")}})
|
||||||
|
if st.Editable {
|
||||||
|
t.Error("a stack with a file that cannot be found should not be editable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A schedule made while an orchestrator was running says "run compose inside that
|
||||||
|
// container". When the container is gone the schedule must still work, against the same
|
||||||
|
// files on the host — otherwise retiring the orchestrator silently breaks every scheduled
|
||||||
|
// update made before it went.
|
||||||
|
func TestRepointSurvivesAMissingContainer(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "orch", "stacks", "app")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
host := filepath.Join(dir, "compose.yaml")
|
||||||
|
if err := os.WriteFile(host, []byte("services: {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
db, err := store.Open(filepath.Join(root, "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Update(func(st *store.State) {
|
||||||
|
st.Mounts = []store.RememberedMount{{Container: "gone-orchestrator", Dest: "/app/data", Src: filepath.Join(root, "orch")}}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
in := compose.Spec{Project: "app", Files: []string{"/app/data/stacks/app/compose.yaml"}, Via: "gone-orchestrator", Tool: "docker"}
|
||||||
|
got := repoint(context.Background(), db, in)
|
||||||
|
if got.Via != "" {
|
||||||
|
t.Errorf("via = %q, want it dropped", got.Via)
|
||||||
|
}
|
||||||
|
if len(got.Files) != 1 || got.Files[0] != host {
|
||||||
|
t.Errorf("files = %v, want [%s]", got.Files, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing remembered: the schedule is left exactly as it was rather than pointed at a
|
||||||
|
// guess. It will fail loudly, which is better than deploying the wrong file.
|
||||||
|
empty, err := store.Open(filepath.Join(root, "empty.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := repoint(context.Background(), empty, in); got.Via != in.Via || len(got.Files) != 1 || got.Files[0] != in.Files[0] {
|
||||||
|
t.Errorf("unresolvable spec was changed: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -26,6 +26,10 @@ type actor struct {
|
|||||||
Session string // session ID, "" for a token
|
Session string // session ID, "" for a token
|
||||||
token string // the raw cookie token, so a handler can spare the current session
|
token string // the raw cookie token, so a handler can spare the current session
|
||||||
Token string // token name, "" for a session
|
Token string // token name, "" for a session
|
||||||
|
// Guest marks a temporary look-around login, and Expires is when it stops working. What
|
||||||
|
// a guest may reach is decided in the middleware, not here; see internal/api/guests.go.
|
||||||
|
Guest bool
|
||||||
|
Expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
@@ -44,6 +48,8 @@ func actorOf(r *http.Request) actor {
|
|||||||
// who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub.
|
// who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub.
|
||||||
func (a actor) String() string {
|
func (a actor) String() string {
|
||||||
switch {
|
switch {
|
||||||
|
case a.User != "" && a.Guest:
|
||||||
|
return "guest:" + a.User
|
||||||
case a.User != "":
|
case a.User != "":
|
||||||
return a.User
|
return a.User
|
||||||
case a.Token != "":
|
case a.Token != "":
|
||||||
@@ -59,7 +65,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
counts := s.sessions.Count()
|
counts := s.sessions.Count()
|
||||||
var out []map[string]any
|
var out []map[string]any
|
||||||
s.cfg.View(func(c *config.Config) {
|
s.cfg.View(func(c *config.Config) {
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Accounts() { // guest logins are listed on their own, see listGuests
|
||||||
var seen any
|
var seen any
|
||||||
if !u.LastSeen.IsZero() {
|
if !u.LastSeen.IsZero() {
|
||||||
seen = u.LastSeen
|
seen = u.LastSeen
|
||||||
@@ -84,6 +90,10 @@ func (s *Server) addUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
|
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(name, guestPrefix) {
|
||||||
|
jsonErr(w, 400, "names starting with "+guestPrefix+" belong to guest logins")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(body.Password) < auth.MinPasswordLen {
|
if len(body.Password) < auth.MinPasswordLen {
|
||||||
jsonErr(w, 400, "password must be at least 12 characters")
|
jsonErr(w, 400, "password must be at least 12 characters")
|
||||||
return
|
return
|
||||||
@@ -141,7 +151,7 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
found, last := false, false
|
found, last := false, false
|
||||||
if err := s.cfg.Update(func(c *config.Config) {
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
if len(c.Users) <= 1 {
|
if len(c.Accounts()) <= 1 { // guest logins don't keep the last real account alive
|
||||||
last = true
|
last = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// The VPN tab: this machine's WireGuard tunnels and who is on them. Read-only, and per
|
||||||
|
// machine like every other tab — the tunnels shown are the ones on the machine in the
|
||||||
|
// switcher, which is the only honest way to show them: the inbound server and an outbound
|
||||||
|
// tunnel are different machines here.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"hamlab/internal/vpn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) vpnReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jsonOK(w, 200, vpn.Look(r.Context()))
|
||||||
|
}
|
||||||
+57
-6
@@ -113,11 +113,14 @@ var dummyHash = func() string {
|
|||||||
return h
|
return h
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// CheckUser verifies a password for one of users. A missing user fails in the same time
|
// CheckUser verifies a password for one of users. A missing user — or a guest login whose
|
||||||
// as a wrong password.
|
// time is up — fails in the same time as a wrong password.
|
||||||
func CheckUser(users []config.User, name, pw string) bool {
|
func CheckUser(users []config.User, name, pw string) bool {
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if u.Name == name {
|
if u.Name == name {
|
||||||
|
if u.Expired(time.Now()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
return VerifyPassword(u.Hash, pw)
|
return VerifyPassword(u.Hash, pw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,51 @@ func CheckUser(users []config.User, name, pw string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- guest logins ----
|
||||||
|
|
||||||
|
// guestAlphabet leaves out every letter and digit that can be misread in a screenshot or
|
||||||
|
// over the phone: i, l, o, 0 and 1. A guest login is read aloud and typed by hand.
|
||||||
|
const guestAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||||
|
|
||||||
|
// randomFrom returns n characters of guestAlphabet, chosen without modulo bias.
|
||||||
|
func randomFrom(n int) (string, error) {
|
||||||
|
out := make([]byte, 0, n)
|
||||||
|
buf := make([]byte, n)
|
||||||
|
limit := byte(256 - 256%len(guestAlphabet)) // the top of the last whole run of the alphabet
|
||||||
|
for len(out) < n {
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, b := range buf {
|
||||||
|
if b >= limit {
|
||||||
|
continue // would favour the first few letters; ask for another byte instead
|
||||||
|
}
|
||||||
|
out = append(out, guestAlphabet[int(b)%len(guestAlphabet)])
|
||||||
|
if len(out) == n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuestPassword is a password for a guest login: four groups of four, ~78 bits, in an
|
||||||
|
// alphabet with nothing ambiguous in it.
|
||||||
|
func GuestPassword() (string, error) {
|
||||||
|
var groups []string
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
g, err := randomFrom(4)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
return strings.Join(groups, "-"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuestSuffix is the tail of a generated guest name.
|
||||||
|
func GuestSuffix() (string, error) { return randomFrom(4) }
|
||||||
|
|
||||||
// ---- sessions ----
|
// ---- sessions ----
|
||||||
|
|
||||||
// Sessions is a token store backed by the state file. Expiry is sliding.
|
// Sessions is a token store backed by the state file. Expiry is sliding.
|
||||||
@@ -270,11 +318,10 @@ func (s *Sessions) RevokeOthers(user, keep string) int {
|
|||||||
// RevokeUser ends every session of user (the account is going away).
|
// RevokeUser ends every session of user (the account is going away).
|
||||||
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
|
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
|
||||||
|
|
||||||
// Limiter is a single global backoff: after 5 failures, each further failure
|
|
||||||
// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
|
|
||||||
// Limiter locks out a client after repeated failed logins, per client address, with a
|
// Limiter locks out a client after repeated failed logins, per client address, with a
|
||||||
// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one
|
// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one
|
||||||
// address never blocks another.
|
// address never blocks another. Past perClientLock failures, each further failure doubles a
|
||||||
|
// lockout starting at 30s, capped at 10 minutes.
|
||||||
type Limiter struct {
|
type Limiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
clients map[string]*bucket
|
clients map[string]*bucket
|
||||||
@@ -290,7 +337,11 @@ type bucket struct {
|
|||||||
var ErrLocked = errors.New("too many failed logins")
|
var ErrLocked = errors.New("too many failed logins")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
perClientLock = 5 // failures before a client is locked
|
// perClientLock is how many failures one address gets before it is made to wait. Set high
|
||||||
|
// on purpose: a guest login is a generated password read off a message and typed by hand
|
||||||
|
// on a phone, and being locked out for fumbling it is worse than the brute-force risk —
|
||||||
|
// which the doubling backoff past this point, and the global backstop, still cover.
|
||||||
|
perClientLock = 20
|
||||||
globalLock = 100 // failures (all clients) in the window before everyone waits a minute
|
globalLock = 100 // failures (all clients) in the window before everyone waits a minute
|
||||||
globalWindow = 5 * time.Minute
|
globalWindow = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Package compose runs docker compose the way a stack was deployed — which is not always
|
||||||
|
// simply "on this host". A stack deployed by an orchestrator running inside its own
|
||||||
|
// container records paths in that container's filesystem, so compose has to run in there;
|
||||||
|
// a stack deployed to another machine over tcp needs DOCKER_HOST pointed at it. Both the
|
||||||
|
// console and the scheduled updates it writes must invoke it identically, or a redeploy
|
||||||
|
// from one would not match the other's labels.
|
||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spec is everything needed to run compose for one stack.
|
||||||
|
type Spec struct {
|
||||||
|
Project string // -p
|
||||||
|
Files []string // -f, in order
|
||||||
|
Via string // a container to run compose inside; "" is this host
|
||||||
|
Remote string // a DOCKER_HOST; "" is this machine's Docker
|
||||||
|
Tool string // "docker" or "podman"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd builds the command. args are compose's own, e.g. "pull" or "up", "-d".
|
||||||
|
func Cmd(ctx context.Context, s Spec, args ...string) *exec.Cmd {
|
||||||
|
tool := s.Tool
|
||||||
|
if tool == "" {
|
||||||
|
tool = "docker"
|
||||||
|
}
|
||||||
|
base := []string{"compose", "-p", s.Project}
|
||||||
|
for _, f := range s.Files {
|
||||||
|
base = append(base, "-f", f)
|
||||||
|
}
|
||||||
|
if s.Via != "" {
|
||||||
|
pre := []string{"exec"}
|
||||||
|
if s.Remote != "" {
|
||||||
|
pre = append(pre, "-e", "DOCKER_HOST="+s.Remote)
|
||||||
|
}
|
||||||
|
return exec.CommandContext(ctx, tool, append(append(append(pre, s.Via, "docker"), base...), args...)...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, tool, append(base, args...)...)
|
||||||
|
if len(s.Files) > 0 {
|
||||||
|
cmd.Dir = filepath.Dir(s.Files[0])
|
||||||
|
}
|
||||||
|
if s.Remote != "" {
|
||||||
|
cmd.Env = append(os.Environ(), "DOCKER_HOST="+s.Remote)
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -46,6 +46,22 @@ type User struct {
|
|||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||||
|
// Guest marks a temporary login made to show somebody the console: it may only read,
|
||||||
|
// it expires by itself at Expires, and it is not one of the machine's accounts — it is
|
||||||
|
// left out of the account list, does not count towards "the last account", and cannot
|
||||||
|
// be the one an empty user name means. See internal/api/guests.go.
|
||||||
|
Guest bool `json:"guest,omitempty"`
|
||||||
|
// Expires is when a guest login stops working. A guest without one has already
|
||||||
|
// expired, which is the safe way to read a half-written file.
|
||||||
|
Expires time.Time `json:"expires,omitempty"`
|
||||||
|
// IssuedBy is the account that made this guest login.
|
||||||
|
IssuedBy string `json:"issued_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired reports whether a guest login's time is up. A real account never expires; a
|
||||||
|
// guest with no expiry at all counts as expired rather than as forever.
|
||||||
|
func (u User) Expired(now time.Time) bool {
|
||||||
|
return u.Guest && (u.Expires.IsZero() || now.After(u.Expires))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
|
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
|
||||||
@@ -82,8 +98,8 @@ type Config struct {
|
|||||||
Listen string `json:"listen"`
|
Listen string `json:"listen"`
|
||||||
// PasswordHash is the single login of installs before users existed. On load it becomes
|
// PasswordHash is the single login of installs before users existed. On load it becomes
|
||||||
// the first user (see MigrateUsers) and is then dropped from the file.
|
// the first user (see MigrateUsers) and is then dropped from the file.
|
||||||
PasswordHash string `json:"password_hash,omitempty"`
|
PasswordHash string `json:"password_hash,omitempty"`
|
||||||
Users []User `json:"users,omitempty"`
|
Users []User `json:"users,omitempty"`
|
||||||
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
||||||
Tokens []Token `json:"tokens,omitempty"`
|
Tokens []Token `json:"tokens,omitempty"`
|
||||||
Nodes []Node `json:"nodes,omitempty"`
|
Nodes []Node `json:"nodes,omitempty"`
|
||||||
@@ -145,6 +161,18 @@ func (c *Config) MigrateUsers(name string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts is every real account: the machine's users, without the guest logins.
|
||||||
|
// Call under View/Update or on a config nobody else holds.
|
||||||
|
func (c *Config) Accounts() []User {
|
||||||
|
out := make([]User, 0, len(c.Users))
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if !u.Guest {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// User finds an account by name. Call under View/Update or on a config nobody else holds.
|
// User finds an account by name. Call under View/Update or on a config nobody else holds.
|
||||||
func (c *Config) User(name string) (User, bool) {
|
func (c *Config) User(name string) (User, bool) {
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Users {
|
||||||
|
|||||||
@@ -0,0 +1,843 @@
|
|||||||
|
// Package hardware is what is physically in the machine: the board it is, its processor,
|
||||||
|
// the memory modules in its slots, the drives attached to it and the graphics in it.
|
||||||
|
//
|
||||||
|
// None of this changes while the machine is running, so it is read once and remembered.
|
||||||
|
// Everything here is read from what Debian and its kin already have — dmidecode for the
|
||||||
|
// parts only the firmware knows, lscpu, lspci, sysfs — and every reader is allowed to
|
||||||
|
// come back empty: a machine with no dmidecode, no PCI bus or no root still gets a page,
|
||||||
|
// with a note saying what could not be read and why.
|
||||||
|
package hardware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/priv"
|
||||||
|
"hamlab/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type System struct {
|
||||||
|
Model string `json:"model,omitempty"` // "GMKtec NucBox G3"
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
Family string `json:"family,omitempty"`
|
||||||
|
Kernel string `json:"kernel,omitempty"`
|
||||||
|
Arch string `json:"arch,omitempty"`
|
||||||
|
Virt string `json:"virt,omitempty"` // "kvm", "lxc", … when this is not real hardware
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slot is an expansion slot as the firmware describes it. Worth treating with suspicion on
|
||||||
|
// a mini PC, where the table is often copied from a reference design and lists slots the
|
||||||
|
// case has no room for.
|
||||||
|
type Slot struct {
|
||||||
|
Designation string `json:"designation"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Usage string `json:"usage,omitempty"` // Available | In Use | Unknown
|
||||||
|
Length string `json:"length,omitempty"`
|
||||||
|
Width string `json:"width,omitempty"`
|
||||||
|
Bus string `json:"bus,omitempty"` // PCI address of what is in it
|
||||||
|
Device string `json:"device,omitempty"` // and what that turns out to be
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board is the motherboard and the firmware on it.
|
||||||
|
type Board struct {
|
||||||
|
Maker string `json:"maker,omitempty"`
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
AssetTag string `json:"asset_tag,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"` // "Motherboard", "Processor Module", …
|
||||||
|
Location string `json:"location,omitempty"` // where it sits in the chassis
|
||||||
|
Chassis string `json:"chassis,omitempty"`
|
||||||
|
ChassisMaker string `json:"chassis_maker,omitempty"`
|
||||||
|
BIOS string `json:"bios,omitempty"` // vendor and version
|
||||||
|
BIOSDate string `json:"bios_date,omitempty"`
|
||||||
|
BIOSRevision string `json:"bios_revision,omitempty"`
|
||||||
|
ROMSize string `json:"rom_size,omitempty"`
|
||||||
|
UEFI bool `json:"uefi,omitempty"`
|
||||||
|
Slots []Slot `json:"slots,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
Level string `json:"level"` // L1d, L1i, L2, L3
|
||||||
|
Size string `json:"size"` // as lscpu puts it: "6 MiB (1 instance)"
|
||||||
|
}
|
||||||
|
|
||||||
|
type CPU struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Arch string `json:"arch,omitempty"`
|
||||||
|
Sockets int `json:"sockets,omitempty"`
|
||||||
|
SocketNames []string `json:"socket_names,omitempty"` // what the firmware calls each socket
|
||||||
|
Cores int `json:"cores,omitempty"`
|
||||||
|
Threads int `json:"threads,omitempty"`
|
||||||
|
ThreadsPerCore int `json:"threads_per_core,omitempty"`
|
||||||
|
MinMHz float64 `json:"min_mhz,omitempty"`
|
||||||
|
MaxMHz float64 `json:"max_mhz,omitempty"`
|
||||||
|
Family string `json:"family,omitempty"`
|
||||||
|
ModelID string `json:"model_id,omitempty"`
|
||||||
|
Stepping string `json:"stepping,omitempty"`
|
||||||
|
Microcode string `json:"microcode,omitempty"`
|
||||||
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
|
Caches []Cache `json:"caches,omitempty"`
|
||||||
|
Flags []string `json:"flags,omitempty"` // the handful anyone looks for
|
||||||
|
}
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
Locator string `json:"locator"` // the slot, as the board labels it
|
||||||
|
Bank string `json:"bank,omitempty"`
|
||||||
|
Empty bool `json:"empty,omitempty"`
|
||||||
|
Size uint64 `json:"size,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"` // DDR4
|
||||||
|
FormFactor string `json:"form_factor,omitempty"` // SODIMM
|
||||||
|
SpeedMTs int `json:"speed_mts,omitempty"` // what the module can do
|
||||||
|
RunningMTs int `json:"running_mts,omitempty"` // what the board is running it at
|
||||||
|
Maker string `json:"maker,omitempty"`
|
||||||
|
Part string `json:"part,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
Rank int `json:"rank,omitempty"`
|
||||||
|
Voltage string `json:"voltage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Memory struct {
|
||||||
|
Total uint64 `json:"total"` // what the kernel can use
|
||||||
|
Installed uint64 `json:"installed,omitempty"` // what the modules add up to
|
||||||
|
MaxCapacity uint64 `json:"max_capacity,omitempty"` // what the board will take
|
||||||
|
Slots int `json:"slots,omitempty"`
|
||||||
|
Filled int `json:"filled,omitempty"`
|
||||||
|
ECC string `json:"ecc,omitempty"`
|
||||||
|
Modules []Module `json:"modules,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Drive struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // nvme | ssd | hdd | usb | emmc | sd | other
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Serial string `json:"serial,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
Tran string `json:"tran,omitempty"`
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
Rotational bool `json:"rotational,omitempty"`
|
||||||
|
Removable bool `json:"removable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GPU struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
Slot string `json:"slot,omitempty"` // PCI address, when it is on a bus
|
||||||
|
Driver string `json:"driver,omitempty"`
|
||||||
|
Node string `json:"node,omitempty"` // card0
|
||||||
|
Memory uint64 `json:"memory,omitempty"`
|
||||||
|
Class string `json:"class,omitempty"` // VGA | 3D | Display
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
System System `json:"system"`
|
||||||
|
Board Board `json:"board"`
|
||||||
|
CPU CPU `json:"cpu"`
|
||||||
|
Memory Memory `json:"memory"`
|
||||||
|
Drives []Drive `json:"drives"`
|
||||||
|
GPUs []GPU `json:"gpus"`
|
||||||
|
Notes []string `json:"notes,omitempty"` // what could not be read, and why
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager remembers the last report. Hardware does not change under a running kernel —
|
||||||
|
// the one thing that would, a drive plugged in, is what Refresh is for.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
last *Report
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Manager { return &Manager{} }
|
||||||
|
|
||||||
|
const keepFor = 10 * time.Minute
|
||||||
|
|
||||||
|
func (m *Manager) Report(ctx context.Context, refresh bool) *Report {
|
||||||
|
m.mu.Lock()
|
||||||
|
if !refresh && m.last != nil && time.Since(m.at) < keepFor {
|
||||||
|
r := m.last
|
||||||
|
m.mu.Unlock()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
r := collect(ctx)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.last, m.at = r, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func collect(ctx context.Context) *Report {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r := &Report{At: time.Now()}
|
||||||
|
dmi, note := readDMI(ctx)
|
||||||
|
if note != "" {
|
||||||
|
r.Notes = append(r.Notes, note)
|
||||||
|
}
|
||||||
|
r.System = system(ctx, dmi)
|
||||||
|
r.Board = board(dmi, pciNames(ctx))
|
||||||
|
r.CPU = cpu(ctx, dmi)
|
||||||
|
r.Memory = memory(dmi)
|
||||||
|
if len(r.Memory.Modules) == 0 && note == "" {
|
||||||
|
r.Notes = append(r.Notes, "memory modules: the firmware's table says nothing about this machine's slots")
|
||||||
|
}
|
||||||
|
r.Drives = drives(ctx)
|
||||||
|
r.GPUs = gpus(ctx)
|
||||||
|
if len(r.GPUs) == 0 {
|
||||||
|
r.Notes = append(r.Notes, "graphics: nothing on the PCI bus and no kernel display device")
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- dmidecode ----
|
||||||
|
|
||||||
|
// record is one entry of the firmware's table: its DMI type and its key/value lines.
|
||||||
|
type record struct {
|
||||||
|
Type int
|
||||||
|
V map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// listKey holds every doubly-indented line of a record — dmidecode's lists, which have no
|
||||||
|
// key of their own: BIOS characteristics, board features, slot characteristics.
|
||||||
|
const listKey = "\x00list"
|
||||||
|
|
||||||
|
var handleRe = regexp.MustCompile(`^Handle 0x[0-9A-Fa-f]+, DMI type (\d+)`)
|
||||||
|
|
||||||
|
// readDMI runs dmidecode once for every type the page wants and parses the lot. It needs
|
||||||
|
// root; without it the page loses the board, the memory modules and the socket names, and
|
||||||
|
// says so rather than pretending the machine has none.
|
||||||
|
func readDMI(ctx context.Context) ([]record, string) {
|
||||||
|
tool, err := exec.LookPath("dmidecode")
|
||||||
|
if err != nil {
|
||||||
|
if _, e := os.Stat("/usr/sbin/dmidecode"); e != nil {
|
||||||
|
return nil, "the board and the memory modules need dmidecode, which isn't installed here"
|
||||||
|
}
|
||||||
|
tool = "/usr/sbin/dmidecode"
|
||||||
|
}
|
||||||
|
args := []string{tool, "-t", "bios", "-t", "system", "-t", "baseboard", "-t", "chassis", "-t", "slot", "-t", "processor", "-t", "memory"}
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
if !priv.Available() {
|
||||||
|
return nil, "the board and the memory modules come from the firmware's table, which only root may read"
|
||||||
|
}
|
||||||
|
args = priv.Wrap(args...)
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "the firmware's table could not be read: " + strings.TrimSpace(err.Error())
|
||||||
|
}
|
||||||
|
return parse(string(out)), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse turns dmidecode's output into records: a handle line starts one, and the single
|
||||||
|
// indented "Key: Value" lines under it are its fields. Doubly-indented lines are the
|
||||||
|
// continuation of a list (a processor's flags), which nothing here reads.
|
||||||
|
func parse(text string) []record {
|
||||||
|
var recs []record
|
||||||
|
var cur *record
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(text))
|
||||||
|
sc.Buffer(make([]byte, 0, 64<<10), 2<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if m := handleRe.FindStringSubmatch(line); m != nil {
|
||||||
|
t, _ := strconv.Atoi(m[1])
|
||||||
|
recs = append(recs, record{Type: t, V: map[string]string{}})
|
||||||
|
cur = &recs[len(recs)-1]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == nil || !strings.HasPrefix(line, "\t") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
cur = nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "\t\t") { // a list under the field above it, e.g. BIOS characteristics
|
||||||
|
cur.V[listKey] += strings.TrimSpace(line) + "\n"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(strings.TrimSpace(line), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur.V[k] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return recs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r record) get(keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := r.V[k]; v != "" && !unset(v) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// unset is every way the firmware has of saying "nobody filled this in".
|
||||||
|
func unset(v string) bool {
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "", "unknown", "not specified", "not provided", "none", "default string", "to be filled by o.e.m.",
|
||||||
|
"system manufacturer", "system product name", "to be filled by oem", "n/a", "empty", "no module installed",
|
||||||
|
"<out of spec>", "other", "not applicable":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func first(recs []record, t int) record {
|
||||||
|
for _, r := range recs {
|
||||||
|
if r.Type == t {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record{Type: t, V: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func all(recs []record, t int) []record {
|
||||||
|
var out []record
|
||||||
|
for _, r := range recs {
|
||||||
|
if r.Type == t {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- system ----
|
||||||
|
|
||||||
|
func system(ctx context.Context, dmi []record) System {
|
||||||
|
sys := first(dmi, 1)
|
||||||
|
s := System{
|
||||||
|
Model: join(sys.get("Manufacturer"), sys.get("Product Name")),
|
||||||
|
Serial: sys.get("Serial Number"),
|
||||||
|
Family: sys.get("Family"),
|
||||||
|
Arch: runtimeArch(),
|
||||||
|
}
|
||||||
|
s.Kernel = readFile("/proc/sys/kernel/osrelease")
|
||||||
|
if s.Model == "" { // no SMBIOS: a Pi, a Mac, anything with a device tree
|
||||||
|
s.Model = strings.TrimRight(readFile("/sys/firmware/devicetree/base/model"), "\x00")
|
||||||
|
}
|
||||||
|
if out, err := exec.CommandContext(ctx, "systemd-detect-virt").Output(); err == nil {
|
||||||
|
if v := strings.TrimSpace(string(out)); v != "" && v != "none" {
|
||||||
|
s.Virt = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// board reads the motherboard, the chassis it sits in, the firmware on it and the
|
||||||
|
// expansion slots the firmware claims. All of it is SMBIOS, so a machine without one — a
|
||||||
|
// Mac, a Pi — gets an empty board, and the page says so rather than inventing one.
|
||||||
|
func board(dmi []record, pci map[string]string) Board {
|
||||||
|
b, ch, bios := first(dmi, 2), first(dmi, 3), first(dmi, 0)
|
||||||
|
out := Board{
|
||||||
|
Maker: b.get("Manufacturer"),
|
||||||
|
Product: b.get("Product Name"),
|
||||||
|
Version: b.get("Version"),
|
||||||
|
Serial: b.get("Serial Number"),
|
||||||
|
AssetTag: b.get("Asset Tag"),
|
||||||
|
Type: b.get("Type"),
|
||||||
|
Location: b.get("Location In Chassis"),
|
||||||
|
Chassis: ch.get("Type"),
|
||||||
|
ChassisMaker: ch.get("Manufacturer"),
|
||||||
|
BIOS: join(bios.get("Vendor"), bios.get("Version")),
|
||||||
|
BIOSDate: bios.get("Release Date"),
|
||||||
|
BIOSRevision: bios.get("BIOS Revision"),
|
||||||
|
}
|
||||||
|
// "ROM Size: 0 MB" is a board that never filled the field in, not a board with no ROM
|
||||||
|
if rom := bios.get("ROM Size"); rom != "" && !strings.HasPrefix(rom, "0 ") {
|
||||||
|
out.ROMSize = rom
|
||||||
|
}
|
||||||
|
if out.Product == out.Maker {
|
||||||
|
out.Product = "" // a board named after its maker says nothing twice
|
||||||
|
}
|
||||||
|
out.UEFI = strings.Contains(bios.V[listKey], "UEFI") // a characteristics line, not a field
|
||||||
|
for _, sl := range all(dmi, 9) {
|
||||||
|
s := Slot{
|
||||||
|
Designation: sl.get("Designation"),
|
||||||
|
Type: sl.get("Type"),
|
||||||
|
Usage: sl.get("Current Usage"),
|
||||||
|
Length: sl.get("Length"),
|
||||||
|
Width: sl.get("Data Bus Width"),
|
||||||
|
}
|
||||||
|
// every slot on some boards claims 0000:00:00.0, which is the host bridge and
|
||||||
|
// certainly not what is plugged into that slot
|
||||||
|
if bus := sl.get("Bus Address"); bus != "" && bus != "0000:00:00.0" {
|
||||||
|
s.Bus, s.Device = bus, pci[bus]
|
||||||
|
}
|
||||||
|
if s.Designation != "" {
|
||||||
|
out.Slots = append(out.Slots, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func join(parts ...string) string {
|
||||||
|
var keep []string
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" && !unset(p) {
|
||||||
|
keep = append(keep, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "GMKtec GMKtec" reads badly: drop a maker the product already names
|
||||||
|
if len(keep) == 2 && strings.HasPrefix(strings.ToLower(keep[1]), strings.ToLower(keep[0])) {
|
||||||
|
return keep[1]
|
||||||
|
}
|
||||||
|
return strings.Join(keep, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(p string) string {
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeArch() string {
|
||||||
|
if out, err := exec.Command("uname", "-m").Output(); err == nil {
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- processor ----
|
||||||
|
|
||||||
|
// worthShowing is the short list of processor features anyone actually asks about; the
|
||||||
|
// full flag list runs to hundreds and belongs in a terminal.
|
||||||
|
var worthShowing = []string{"aes", "avx", "avx2", "avx512f", "sha_ni", "vmx", "svm", "sse4_2", "rdrand", "hypervisor"}
|
||||||
|
|
||||||
|
func cpu(ctx context.Context, dmi []record) CPU {
|
||||||
|
c := CPU{}
|
||||||
|
f := lscpu(ctx)
|
||||||
|
c.Model = f["Model name"]
|
||||||
|
c.Vendor = f["Vendor ID"]
|
||||||
|
c.Arch = f["Architecture"]
|
||||||
|
c.Sockets = atoi(f["Socket(s)"])
|
||||||
|
c.ThreadsPerCore = atoi(f["Thread(s) per core"])
|
||||||
|
c.Threads = atoi(f["CPU(s)"])
|
||||||
|
c.Cores = atoi(f["Core(s) per socket"]) * maxInt(1, c.Sockets)
|
||||||
|
c.MinMHz = atof(f["CPU min MHz"])
|
||||||
|
c.MaxMHz = atof(f["CPU max MHz"])
|
||||||
|
c.Family, c.ModelID, c.Stepping = f["CPU family"], f["Model"], f["Stepping"]
|
||||||
|
c.Virtualization = f["Virtualization"]
|
||||||
|
for _, l := range []string{"L1d", "L1i", "L2", "L3"} {
|
||||||
|
if v := f[l+" cache"]; v != "" {
|
||||||
|
c.Caches = append(c.Caches, Cache{Level: l, Size: v})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, fl := range strings.Fields(f["Flags"]) {
|
||||||
|
have[fl] = true
|
||||||
|
}
|
||||||
|
for _, fl := range worthShowing {
|
||||||
|
if have[fl] {
|
||||||
|
c.Flags = append(c.Flags, fl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range all(dmi, 4) {
|
||||||
|
if s := p.get("Socket Designation"); s != "" {
|
||||||
|
c.SocketNames = append(c.SocketNames, s)
|
||||||
|
}
|
||||||
|
if c.Model == "" {
|
||||||
|
c.Model = p.get("Version", "Family")
|
||||||
|
}
|
||||||
|
if c.MaxMHz == 0 {
|
||||||
|
if mhz := atof(strings.TrimSuffix(p.get("Max Speed"), " MHz")); mhz > 0 {
|
||||||
|
c.MaxMHz = mhz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Sockets == 0 {
|
||||||
|
c.Sockets = len(c.SocketNames)
|
||||||
|
}
|
||||||
|
// A processor whose cores are not all alike — Apple's, Intel's P and E cores — is
|
||||||
|
// reported by lscpu as several groups, and "core(s) per socket" then counts one group
|
||||||
|
// rather than the chip. Without SMT every thread is a core, so the thread count is the
|
||||||
|
// honest answer.
|
||||||
|
if c.ThreadsPerCore == 1 && c.Threads > c.Cores {
|
||||||
|
c.Cores = c.Threads
|
||||||
|
}
|
||||||
|
// /proc/cpuinfo is the only place the running microcode revision is written down
|
||||||
|
if b, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == "microcode" {
|
||||||
|
c.Microcode = strings.TrimSpace(v)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Model == "" {
|
||||||
|
c.Model = "unknown processor"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func lscpu(ctx context.Context) map[string]string {
|
||||||
|
f := map[string]string{}
|
||||||
|
out, err := exec.CommandContext(ctx, "lscpu").Output()
|
||||||
|
if err != nil {
|
||||||
|
// no util-linux: /proc/cpuinfo still names the thing and counts it
|
||||||
|
if b, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
|
n := 0
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, ":")
|
||||||
|
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||||
|
switch {
|
||||||
|
case ok && strings.EqualFold(k, "model name"):
|
||||||
|
f["Model name"] = v
|
||||||
|
case ok && k == "processor":
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f["CPU(s)"] = strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, ":")
|
||||||
|
if !ok || strings.HasPrefix(line, " ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoi(s string) int { n, _ := strconv.Atoi(strings.TrimSpace(s)); return n }
|
||||||
|
|
||||||
|
func atof(s string) float64 {
|
||||||
|
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// leadingInt reads the number off the front of "3200 MT/s".
|
||||||
|
func leadingInt(s string) int {
|
||||||
|
f := strings.Fields(s)
|
||||||
|
if len(f) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return atoi(f[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- memory ----
|
||||||
|
|
||||||
|
var sizeRe = regexp.MustCompile(`^(\d+)\s*(kB|KB|MB|GB|TB)$`)
|
||||||
|
|
||||||
|
// dmiSize turns dmidecode's "8 GB" / "16384 MB" into bytes.
|
||||||
|
func dmiSize(s string) uint64 {
|
||||||
|
m := sizeRe.FindStringSubmatch(strings.TrimSpace(s))
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.ParseUint(m[1], 10, 64)
|
||||||
|
switch strings.ToUpper(m[2]) {
|
||||||
|
case "KB":
|
||||||
|
return n << 10
|
||||||
|
case "MB":
|
||||||
|
return n << 20
|
||||||
|
case "GB":
|
||||||
|
return n << 30
|
||||||
|
case "TB":
|
||||||
|
return n << 40
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func memory(dmi []record) Memory {
|
||||||
|
m := Memory{}
|
||||||
|
if b, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, ":"); ok && k == "MemTotal" {
|
||||||
|
m.Total = uint64(atoi(strings.TrimSuffix(strings.TrimSpace(v), " kB"))) << 10
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range all(dmi, 16) {
|
||||||
|
m.MaxCapacity += dmiSize(a.get("Maximum Capacity"))
|
||||||
|
switch e := a.V["Error Correction Type"]; {
|
||||||
|
case e == "None":
|
||||||
|
m.ECC = "non-ECC" // the array says so outright; worth knowing, not worth hiding
|
||||||
|
case e != "" && !unset(e):
|
||||||
|
m.ECC = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range all(dmi, 17) {
|
||||||
|
mod := Module{
|
||||||
|
Locator: d.get("Locator"),
|
||||||
|
Bank: d.get("Bank Locator"),
|
||||||
|
Size: dmiSize(d.get("Size")),
|
||||||
|
}
|
||||||
|
m.Slots++
|
||||||
|
if mod.Locator == "" {
|
||||||
|
mod.Locator = "slot " + strconv.Itoa(m.Slots)
|
||||||
|
}
|
||||||
|
if mod.Size == 0 {
|
||||||
|
mod.Empty = true
|
||||||
|
m.Modules = append(m.Modules, mod)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Filled++
|
||||||
|
mod.Type = d.get("Type")
|
||||||
|
mod.FormFactor = d.get("Form Factor")
|
||||||
|
mod.SpeedMTs = leadingInt(d.get("Speed"))
|
||||||
|
mod.RunningMTs = leadingInt(d.get("Configured Memory Speed", "Configured Clock Speed"))
|
||||||
|
mod.Maker = makerName(d.get("Manufacturer"), d.V["Module Manufacturer ID"])
|
||||||
|
mod.Part = d.get("Part Number")
|
||||||
|
mod.Serial = d.get("Serial Number")
|
||||||
|
mod.Rank = atoi(d.get("Rank"))
|
||||||
|
mod.Voltage = d.get("Configured Voltage")
|
||||||
|
m.Installed += mod.Size
|
||||||
|
m.Modules = append(m.Modules, mod)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// jedec maps the memory makers who turn up in a homelab to their names: SPD carries a
|
||||||
|
// JEDEC id, and dmidecode prints the raw code when the module's string field is empty.
|
||||||
|
var jedec = map[string]string{
|
||||||
|
"0X2C00": "Micron", "0X802C": "Micron",
|
||||||
|
"0XCE00": "Samsung", "0X80CE": "Samsung", "0X00CE": "Samsung",
|
||||||
|
"0XAD00": "SK hynix", "0X80AD": "SK hynix",
|
||||||
|
"0X9801": "Kingston", "0X7F98": "Kingston", "0X0198": "Kingston",
|
||||||
|
"0X859B": "Crucial", "0X04CB": "A-DATA", "0X04CD": "G.Skill",
|
||||||
|
"0X029E": "Corsair", "0X0B45": "Wilk Elektronik", "0X830B": "Wilk Elektronik",
|
||||||
|
"0X0443": "Ramaxel", "0X0194": "Smart Modular",
|
||||||
|
}
|
||||||
|
|
||||||
|
// makerName prefers what the module says it is; a bare JEDEC id becomes a name when it is
|
||||||
|
// one we know, and stays the id when it is not — a wrong name is worse than a number.
|
||||||
|
func makerName(v, id string) string {
|
||||||
|
if v != "" && !unset(v) && !strings.HasPrefix(v, "0x") {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
code := v
|
||||||
|
if code == "" {
|
||||||
|
code = id
|
||||||
|
}
|
||||||
|
if n, ok := jedec[strings.ToUpper(code)]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
if v != "" && !unset(v) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- drives ----
|
||||||
|
|
||||||
|
func drives(ctx context.Context) []Drive {
|
||||||
|
devs, err := storage.Devices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]Drive, 0, len(devs))
|
||||||
|
for _, d := range devs {
|
||||||
|
out = append(out, Drive{
|
||||||
|
Name: d.Name, Kind: d.Kind, Model: d.Model, Vendor: d.Vendor, Serial: d.Serial,
|
||||||
|
Tran: d.Tran, Size: d.Size, Rotational: d.Rotational, Removable: d.Removable,
|
||||||
|
Firmware: firmwareOf(d.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// firmwareOf reads a drive's firmware revision. An NVMe device is folded to its
|
||||||
|
// controller ("nvme0"), which has no /sys/block entry of its own — the revision is on the
|
||||||
|
// controller in /sys/class/nvme.
|
||||||
|
func firmwareOf(name string) string {
|
||||||
|
return firstNonEmpty(
|
||||||
|
readFile("/sys/block/"+name+"/device/firmware_rev"),
|
||||||
|
readFile("/sys/block/"+name+"/device/rev"),
|
||||||
|
readFile("/sys/class/nvme/"+name+"/firmware_rev"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(v ...string) string {
|
||||||
|
for _, s := range v {
|
||||||
|
if s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- graphics ----
|
||||||
|
|
||||||
|
// lspci -mm quotes every field, so a device name can hold the spaces and brackets it
|
||||||
|
// likes: `"VGA compatible controller [0300]" "Intel Corporation [8086]" "Alder Lake-N …"`.
|
||||||
|
var quoted = regexp.MustCompile(`"([^"]*)"`)
|
||||||
|
|
||||||
|
// stripID drops the trailing PCI id from `Alder Lake-N [UHD Graphics] [46d1]`.
|
||||||
|
func stripID(s string) string {
|
||||||
|
if i := strings.LastIndex(s, " ["); i > 0 && strings.HasSuffix(s, "]") {
|
||||||
|
return strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func gpus(ctx context.Context) []GPU {
|
||||||
|
var out []GPU
|
||||||
|
seen := map[string]bool{} // PCI addresses already accounted for
|
||||||
|
if _, err := exec.LookPath("lspci"); err == nil {
|
||||||
|
drivers := pciDrivers(ctx)
|
||||||
|
if b, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output(); err == nil {
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
slot, _, _ := strings.Cut(line, " ")
|
||||||
|
fields := quoted.FindAllStringSubmatch(line, -1)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind := displayClass(fields[0][1])
|
||||||
|
if kind == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[slot] = true
|
||||||
|
g := GPU{Slot: slot, Class: kind, Vendor: stripID(fields[1][1]), Model: stripID(fields[2][1]), Driver: drivers[slot]}
|
||||||
|
g.Node, g.Memory = drmFor(slot)
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A GPU that is not on a PCI bus at all — an Apple Silicon Mac, a Pi, anything with a
|
||||||
|
// device tree — is only visible as a kernel display device.
|
||||||
|
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
|
||||||
|
for _, c := range cards {
|
||||||
|
dev, err := filepath.EvalSymlinks(c + "/device")
|
||||||
|
if err != nil || seen[filepath.Base(dev)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g := GPU{Node: filepath.Base(c), Class: "Display"}
|
||||||
|
if drv, err := filepath.EvalSymlinks(dev + "/driver"); err == nil {
|
||||||
|
g.Driver = filepath.Base(drv)
|
||||||
|
}
|
||||||
|
g.Model = deviceTreeName(dev)
|
||||||
|
if g.Model == "" && g.Driver != "" {
|
||||||
|
g.Model = g.Driver + " display"
|
||||||
|
}
|
||||||
|
if g.Model == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g.Memory = vram(dev)
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pciNames maps a PCI address to a readable name, so a slot the firmware says is in use
|
||||||
|
// can say what is in it.
|
||||||
|
func pciNames(ctx context.Context) map[string]string {
|
||||||
|
m := map[string]string{}
|
||||||
|
if _, err := exec.LookPath("lspci"); err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output()
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
slot, _, _ := strings.Cut(line, " ")
|
||||||
|
f := quoted.FindAllStringSubmatch(line, -1)
|
||||||
|
if slot == "" || len(f) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[slot] = strings.TrimSpace(stripID(f[1][1]) + " " + stripID(f[2][1]))
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayClass(c string) string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(c, "[0300]"):
|
||||||
|
return "VGA"
|
||||||
|
case strings.Contains(c, "[0302]"):
|
||||||
|
return "3D"
|
||||||
|
case strings.Contains(c, "[0301]"), strings.Contains(c, "[0380]"):
|
||||||
|
return "Display"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// pciDrivers maps a PCI address to the kernel module driving it.
|
||||||
|
func pciDrivers(ctx context.Context) map[string]string {
|
||||||
|
m := map[string]string{}
|
||||||
|
out, err := exec.CommandContext(ctx, "lspci", "-D", "-k").Output()
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
slot := ""
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") {
|
||||||
|
slot, _, _ = strings.Cut(line, " ")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "Kernel driver in use:"); ok && slot != "" {
|
||||||
|
m[slot] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// drmFor finds the kernel display device for a PCI address, and its video memory when the
|
||||||
|
// driver publishes it (amdgpu does; Intel's integrated graphics have none of their own).
|
||||||
|
func drmFor(slot string) (string, uint64) {
|
||||||
|
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
|
||||||
|
for _, c := range cards {
|
||||||
|
dev, err := filepath.EvalSymlinks(c + "/device")
|
||||||
|
if err != nil || filepath.Base(dev) != slot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return filepath.Base(c), vram(dev)
|
||||||
|
}
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func vram(dev string) uint64 {
|
||||||
|
for _, f := range []string{"mem_info_vram_total", "mem_info_vis_vram_total"} {
|
||||||
|
if v := readFile(dev + "/" + f); v != "" {
|
||||||
|
n, _ := strconv.ParseUint(v, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceTreeName reads what a device-tree machine calls its graphics: the compatible
|
||||||
|
// string, tidied — "apple,agx-g13g" becomes "Apple agx-g13g".
|
||||||
|
func deviceTreeName(dev string) string {
|
||||||
|
c := readFile(dev + "/of_node/compatible")
|
||||||
|
if c == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
c = strings.TrimRight(strings.Split(c, "\x00")[0], "\x00")
|
||||||
|
maker, rest, ok := strings.Cut(c, ",")
|
||||||
|
if !ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if maker != "" {
|
||||||
|
maker = strings.ToUpper(maker[:1]) + maker[1:]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(maker + " " + rest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package hardware
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDMISize(t *testing.T) {
|
||||||
|
for in, want := range map[string]uint64{
|
||||||
|
"8 GB": 8 << 30, "16384 MB": 16384 << 20, "512 kB": 512 << 10, "1 TB": 1 << 40,
|
||||||
|
"No Module Installed": 0, "": 0, "8 GiB": 0,
|
||||||
|
} {
|
||||||
|
if got := dmiSize(in); got != want {
|
||||||
|
t.Errorf("dmiSize(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakerName(t *testing.T) {
|
||||||
|
cases := []struct{ v, id, want string }{
|
||||||
|
{"Samsung", "", "Samsung"}, // the module says who it is
|
||||||
|
{"0x0B45", "Bank 12, Hex 0x45", "Wilk Elektronik"}, // only a JEDEC id, and one we know
|
||||||
|
{"0xFFFF", "", "0xFFFF"}, // one we don't: the number, not a guess
|
||||||
|
{"Unknown", "", ""},
|
||||||
|
{"", "0xCE00", "Samsung"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := makerName(c.v, c.id); got != c.want {
|
||||||
|
t.Errorf("makerName(%q, %q) = %q, want %q", c.v, c.id, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripIDAndDisplayClass(t *testing.T) {
|
||||||
|
if got := stripID("Alder Lake-N [UHD Graphics] [46d1]"); got != "Alder Lake-N [UHD Graphics]" {
|
||||||
|
t.Errorf("stripID = %q", got)
|
||||||
|
}
|
||||||
|
if got := stripID("no brackets here"); got != "no brackets here" {
|
||||||
|
t.Errorf("stripID left a plain name alone wrong: %q", got)
|
||||||
|
}
|
||||||
|
if displayClass(`VGA compatible controller [0300]`) != "VGA" {
|
||||||
|
t.Error("a VGA controller should be graphics")
|
||||||
|
}
|
||||||
|
if displayClass(`Ethernet controller [0200]`) != "" {
|
||||||
|
t.Error("a network card is not graphics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeadingIntAndJoin(t *testing.T) {
|
||||||
|
if leadingInt("3200 MT/s") != 3200 || leadingInt("") != 0 || leadingInt("Unknown") != 0 {
|
||||||
|
t.Error("leadingInt")
|
||||||
|
}
|
||||||
|
if got := join("GMKtec", "GMKtec NucBox G3"); got != "GMKtec NucBox G3" {
|
||||||
|
t.Errorf("a maker the product already names should not be said twice: %q", got)
|
||||||
|
}
|
||||||
|
if got := join("Dell Inc.", "OptiPlex 7040"); got != "Dell Inc. OptiPlex 7040" {
|
||||||
|
t.Errorf("join = %q", got)
|
||||||
|
}
|
||||||
|
if got := join("To Be Filled By O.E.M.", "System Product Name"); got != "" {
|
||||||
|
t.Errorf("firmware boilerplate is not a model: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = `# dmidecode 3.6
|
||||||
|
Handle 0x0028, DMI type 17, 92 bytes
|
||||||
|
Memory Device
|
||||||
|
Array Handle: 0x0027
|
||||||
|
Size: 8 GB
|
||||||
|
Form Factor: SODIMM
|
||||||
|
Locator: Controller0-ChannelA-DIMM0
|
||||||
|
Type: DDR4
|
||||||
|
Speed: 3200 MT/s
|
||||||
|
Manufacturer: 0x0B45
|
||||||
|
Part Number: WPBH32D408SWD-8G
|
||||||
|
Configured Memory Speed: 2933 MT/s
|
||||||
|
Rank: 1
|
||||||
|
|
||||||
|
Handle 0x0029, DMI type 17, 92 bytes
|
||||||
|
Memory Device
|
||||||
|
Size: No Module Installed
|
||||||
|
Locator: Controller1-ChannelA-DIMM0
|
||||||
|
Type: Unknown
|
||||||
|
`
|
||||||
|
|
||||||
|
// parseRecords is what readDMI does once dmidecode has answered; this exercises it
|
||||||
|
// without needing root or the tool.
|
||||||
|
func TestMemoryFromRecords(t *testing.T) {
|
||||||
|
recs := parse(sample)
|
||||||
|
m := memory(recs)
|
||||||
|
if m.Slots != 2 || m.Filled != 1 {
|
||||||
|
t.Fatalf("slots %d filled %d, want 2 and 1", m.Slots, m.Filled)
|
||||||
|
}
|
||||||
|
mod := m.Modules[0]
|
||||||
|
if mod.Size != 8<<30 || mod.Type != "DDR4" || mod.Maker != "Wilk Elektronik" || mod.Part != "WPBH32D408SWD-8G" {
|
||||||
|
t.Errorf("first module read wrong: %+v", mod)
|
||||||
|
}
|
||||||
|
if mod.SpeedMTs != 3200 || mod.RunningMTs != 2933 {
|
||||||
|
t.Errorf("a module running below its rating should say both: %d / %d", mod.RunningMTs, mod.SpeedMTs)
|
||||||
|
}
|
||||||
|
if !m.Modules[1].Empty || m.Modules[1].Locator != "Controller1-ChannelA-DIMM0" {
|
||||||
|
t.Errorf("empty slot read wrong: %+v", m.Modules[1])
|
||||||
|
}
|
||||||
|
if m.Installed != 8<<30 {
|
||||||
|
t.Errorf("installed = %d", m.Installed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Package network is what the Network tab shows: the machine's interfaces as the kernel has
|
||||||
|
// them, how the internet looks from out there, and a speed test that measures the line.
|
||||||
|
//
|
||||||
|
// Everything here is read from /proc and /sys, or measured against Cloudflare's public speed
|
||||||
|
// endpoints — no tool to install, on any of the machines. That matters for the router, where
|
||||||
|
// flash is small and `opkg install` is a decision.
|
||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Addr is one address on an interface.
|
||||||
|
type Addr struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Prefix int `json:"prefix"` // network bits, as in /24
|
||||||
|
V6 bool `json:"v6,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iface is one network interface: what it is, whether it is up, and what it has moved.
|
||||||
|
type Iface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // ethernet | wifi | bridge | tunnel | loopback | virtual
|
||||||
|
MAC string `json:"mac,omitempty"`
|
||||||
|
MTU int `json:"mtu"`
|
||||||
|
// Up is the administrative state, Carrier whether a cable (or an association) is actually
|
||||||
|
// there. Up without Carrier is the usual "it's plugged into nothing".
|
||||||
|
Up bool `json:"up"`
|
||||||
|
Carrier bool `json:"carrier"`
|
||||||
|
// SpeedMbps is what the link negotiated, where the driver says: 0 for wireless, virtual
|
||||||
|
// and tunnel devices, which have no such number.
|
||||||
|
SpeedMbps int `json:"speed_mbps,omitempty"`
|
||||||
|
Duplex string `json:"duplex,omitempty"`
|
||||||
|
Driver string `json:"driver,omitempty"`
|
||||||
|
SignalDbm *float64 `json:"signal_dbm,omitempty"` // wireless only, from /proc/net/wireless
|
||||||
|
Addrs []Addr `json:"addrs"`
|
||||||
|
RxTotal uint64 `json:"rx_total"`
|
||||||
|
TxTotal uint64 `json:"tx_total"`
|
||||||
|
// Default marks the interface the default route leaves by — the one that matters when
|
||||||
|
// the question is about the internet.
|
||||||
|
Default bool `json:"default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is the machine's network as it stands.
|
||||||
|
type Report struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Ifaces []Iface `json:"ifaces"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
GatewayVia string `json:"gateway_via,omitempty"` // the interface it is reached through
|
||||||
|
DNS []string `json:"dns,omitempty"`
|
||||||
|
Search []string `json:"search,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStr(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInt(path string) int {
|
||||||
|
n, err := strconv.Atoi(readStr(path))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindOf names what sort of device this is, from what the kernel exposes about it rather
|
||||||
|
// than from its name — a bridge is a bridge whatever it is called.
|
||||||
|
func kindOf(name string, flags net.Flags) string {
|
||||||
|
sys := "/sys/class/net/" + name + "/"
|
||||||
|
switch {
|
||||||
|
case flags&net.FlagLoopback != 0:
|
||||||
|
return "loopback"
|
||||||
|
case dirExists(sys + "wireless"), dirExists(sys + "phy80211"):
|
||||||
|
return "wifi"
|
||||||
|
case dirExists(sys + "bridge"):
|
||||||
|
return "bridge"
|
||||||
|
case dirExists(sys + "bonding"):
|
||||||
|
return "bond"
|
||||||
|
}
|
||||||
|
// DEVTYPE says vlan, bridge, wlan… for the devices that have one
|
||||||
|
for _, line := range strings.Split(readStr(sys+"uevent"), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "DEVTYPE="); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dirExists(sys + "device") { // no bus device behind it: veth, tun, wireguard, docker's own
|
||||||
|
return "virtual"
|
||||||
|
}
|
||||||
|
return "ethernet"
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirExists(p string) bool {
|
||||||
|
st, err := os.Stat(p)
|
||||||
|
return err == nil && st.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverOf is the module behind an interface, which is what names the hardware in one word.
|
||||||
|
func driverOf(name string) string {
|
||||||
|
l, err := os.Readlink("/sys/class/net/" + name + "/device/driver")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l[strings.LastIndexByte(l, '/')+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// wirelessSignal is the signal level of each wireless interface, in dBm.
|
||||||
|
func wirelessSignal() map[string]float64 {
|
||||||
|
out := map[string]float64{}
|
||||||
|
f, err := os.Open("/proc/net/wireless")
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
name, rest, ok := strings.Cut(sc.Text(), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs := strings.Fields(rest)
|
||||||
|
if len(fs) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSuffix(fs[2], "."), 64); err == nil {
|
||||||
|
out[strings.TrimSpace(name)] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// counters is every interface's byte totals, from the same file the sampler reads.
|
||||||
|
func counters() map[string][2]uint64 {
|
||||||
|
out := map[string][2]uint64{}
|
||||||
|
f, err := os.Open("/proc/net/dev")
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
name, rest, ok := strings.Cut(sc.Text(), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs := strings.Fields(rest)
|
||||||
|
if len(fs) < 16 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rx, _ := strconv.ParseUint(fs[0], 10, 64)
|
||||||
|
tx, _ := strconv.ParseUint(fs[8], 10, 64)
|
||||||
|
out[strings.TrimSpace(name)] = [2]uint64{rx, tx}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultRoute is the gateway the machine sends the internet to, and the interface it
|
||||||
|
// leaves by. Read from /proc/net/route, whose addresses are little-endian hex.
|
||||||
|
func defaultRoute() (gw, via string) {
|
||||||
|
f, err := os.Open("/proc/net/route")
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
best := -1
|
||||||
|
for sc.Scan() {
|
||||||
|
fs := strings.Fields(sc.Text())
|
||||||
|
if len(fs) < 8 || fs[0] == "Iface" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fs[1] != "00000000" { // not a default route
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metric, _ := strconv.Atoi(fs[6])
|
||||||
|
if best >= 0 && metric >= best {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ip, err := hexIP(fs[2])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
best, gw, via = metric, ip, fs[0]
|
||||||
|
}
|
||||||
|
return gw, via
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexIP reads one address out of /proc/net/route, where each is printed as a 32-bit word in
|
||||||
|
// the host's byte order: 10.0.0.1 appears as "0100000A" on a little-endian machine. So the
|
||||||
|
// text is parsed as a plain number, then written back out in native order to recover the four
|
||||||
|
// bytes — which is right on either sort of machine, without asking which this is.
|
||||||
|
func hexIP(h string) (string, error) {
|
||||||
|
v, err := strconv.ParseUint(h, 16, 32)
|
||||||
|
if err != nil || len(h) != 8 {
|
||||||
|
return "", fmt.Errorf("not an address: %q", h)
|
||||||
|
}
|
||||||
|
var b [4]byte
|
||||||
|
binary.NativeEndian.PutUint32(b[:], uint32(v))
|
||||||
|
return net.IPv4(b[0], b[1], b[2], b[3]).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvers is what /etc/resolv.conf points at.
|
||||||
|
func resolvers() (servers, search []string) {
|
||||||
|
f, err := os.Open("/etc/resolv.conf")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
fs := strings.Fields(sc.Text())
|
||||||
|
if len(fs) < 2 || strings.HasPrefix(fs[0], "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch fs[0] {
|
||||||
|
case "nameserver":
|
||||||
|
servers = append(servers, fs[1])
|
||||||
|
case "search", "domain":
|
||||||
|
search = append(search, fs[1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return servers, search
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces is every interface the kernel has, with its addresses and totals.
|
||||||
|
func Interfaces() []Iface {
|
||||||
|
list, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, via := defaultRoute()
|
||||||
|
sig, cnt := wirelessSignal(), counters()
|
||||||
|
out := make([]Iface, 0, len(list))
|
||||||
|
for _, in := range list {
|
||||||
|
sys := "/sys/class/net/" + in.Name + "/"
|
||||||
|
f := Iface{
|
||||||
|
Name: in.Name, Kind: kindOf(in.Name, in.Flags), MTU: in.MTU,
|
||||||
|
MAC: in.HardwareAddr.String(), Up: in.Flags&net.FlagUp != 0,
|
||||||
|
Driver: driverOf(in.Name), Default: in.Name == via, Addrs: []Addr{},
|
||||||
|
}
|
||||||
|
f.Carrier = readStr(sys+"carrier") == "1"
|
||||||
|
if f.Kind == "loopback" {
|
||||||
|
f.Carrier = f.Up
|
||||||
|
}
|
||||||
|
if sp := readInt(sys + "speed"); sp > 0 { // -1 when the driver doesn't know
|
||||||
|
f.SpeedMbps = sp
|
||||||
|
}
|
||||||
|
if d := readStr(sys + "duplex"); d != "" && d != "unknown" {
|
||||||
|
f.Duplex = d
|
||||||
|
}
|
||||||
|
if v, ok := sig[in.Name]; ok {
|
||||||
|
f.SignalDbm = &v
|
||||||
|
}
|
||||||
|
if c, ok := cnt[in.Name]; ok {
|
||||||
|
f.RxTotal, f.TxTotal = c[0], c[1]
|
||||||
|
}
|
||||||
|
addrs, _ := in.Addrs()
|
||||||
|
for _, a := range addrs {
|
||||||
|
n, ok := a.(*net.IPNet)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ones, _ := n.Mask.Size()
|
||||||
|
f.Addrs = append(f.Addrs, Addr{IP: n.IP.String(), Prefix: ones, V6: n.IP.To4() == nil})
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
// the interface the internet leaves by first, then the ones carrying addresses, then the rest
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
a, b := out[i], out[j]
|
||||||
|
if a.Default != b.Default {
|
||||||
|
return a.Default
|
||||||
|
}
|
||||||
|
ai, bi := len(a.Addrs) > 0 && a.Kind != "loopback", len(b.Addrs) > 0 && b.Kind != "loopback"
|
||||||
|
if ai != bi {
|
||||||
|
return ai
|
||||||
|
}
|
||||||
|
return a.Name < b.Name
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look is the machine's network now. Cheap: a handful of small files.
|
||||||
|
func Look(host string) Report {
|
||||||
|
gw, via := defaultRoute()
|
||||||
|
dns, search := resolvers()
|
||||||
|
return Report{At: time.Now(), Host: host, Ifaces: Interfaces(), Gateway: gw, GatewayVia: via, DNS: dns, Search: search}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- how the internet sees this machine ----
|
||||||
|
|
||||||
|
// Outside is what the far end of the line says about this connection: the address the
|
||||||
|
// internet sees, and where it came ashore. Refreshed on its own clock, because the public
|
||||||
|
// address here is a DHCP lease and moves without warning.
|
||||||
|
type Outside struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
Colo string `json:"colo,omitempty"` // the Cloudflare edge that answered — "SEA"
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher keeps the outside view fresh and holds the speed tester.
|
||||||
|
type Watcher struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
out Outside
|
||||||
|
Test *Tester
|
||||||
|
}
|
||||||
|
|
||||||
|
const outsideEvery = 15 * time.Minute
|
||||||
|
|
||||||
|
func NewWatcher(onResult func(Result)) *Watcher {
|
||||||
|
w := &Watcher{Test: NewTester(onResult)}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
w.refresh()
|
||||||
|
time.Sleep(outsideEvery)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Watcher) refresh() {
|
||||||
|
o := probeOutside()
|
||||||
|
w.mu.Lock()
|
||||||
|
// a failed probe does not throw away a good answer; it is noted beside it
|
||||||
|
if o.IP == "" && w.out.IP != "" {
|
||||||
|
w.out.Error, w.out.At = o.Error, o.At
|
||||||
|
} else {
|
||||||
|
w.out = o
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside is the last known view from outside, asking again if it has gone stale.
|
||||||
|
func (w *Watcher) Outside() Outside {
|
||||||
|
w.mu.RLock()
|
||||||
|
o := w.out
|
||||||
|
w.mu.RUnlock()
|
||||||
|
if time.Since(o.At) > outsideEvery {
|
||||||
|
w.refresh()
|
||||||
|
w.mu.RLock()
|
||||||
|
o = w.out
|
||||||
|
w.mu.RUnlock()
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHexIP(t *testing.T) {
|
||||||
|
// /proc/net/route prints each address as a host-order word: on a little-endian machine
|
||||||
|
// 10.0.0.1 is written "0100000A". These cases are that machine, which is all of ours.
|
||||||
|
for hex, want := range map[string]string{
|
||||||
|
"0100000A": "10.0.0.1",
|
||||||
|
"00000000": "0.0.0.0",
|
||||||
|
"0101A8C0": "192.168.1.1",
|
||||||
|
} {
|
||||||
|
got, err := hexIP(hex)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("hexIP(%q) = %q, %v; want %q", hex, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := hexIP("nonsense"); err == nil {
|
||||||
|
t.Error("hexIP accepted nonsense")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerTimeIsNotLatency(t *testing.T) {
|
||||||
|
h := http.Header{}
|
||||||
|
h.Add("Server-Timing", "cfSpeedEdge;dur=7, cfSpeedWorker;dur=36")
|
||||||
|
h.Add("Server-Timing", `cfL4;desc="?proto=TCP&rtt=3505&min_rtt=2856"`)
|
||||||
|
if got := serverMs(h); got != 43 {
|
||||||
|
t.Errorf("serverMs = %v, want 43 (7+36, and nothing from the cfL4 description)", got)
|
||||||
|
}
|
||||||
|
if got := serverMs(http.Header{}); got != 0 {
|
||||||
|
t.Errorf("serverMs with no header = %v, want 0", got)
|
||||||
|
}
|
||||||
|
h2 := http.Header{"Cf-Ray": {"a41f6720f966a36c-SEA"}, "Cf-Meta-Ip": {"50.47.252.221"}}
|
||||||
|
if m := readMeta(h2); m.Colo != "SEA" || m.IP != "50.47.252.221" {
|
||||||
|
t.Errorf("readMeta = %+v, want the edge SEA and the address", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRates(t *testing.T) {
|
||||||
|
// a gibibyte in eight seconds is 1073.7 Mbps
|
||||||
|
if got := mbps(1<<30, 8); math.Abs(got-1073.7) > 0.1 {
|
||||||
|
t.Errorf("mbps(1GiB, 8s) = %v, want ~1073.7", got)
|
||||||
|
}
|
||||||
|
if got := best([]float64{12.5, 9.2, 40, 9.9}); got != 9.2 {
|
||||||
|
t.Errorf("best = %v, want 9.2 (the least of them)", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{10, 10, 10}); got != 0 {
|
||||||
|
t.Errorf("jitter of a steady line = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{10}); got != 0 {
|
||||||
|
t.Errorf("jitter of one sample = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := jitter([]float64{5, 50, 5, 50}); got != 45 {
|
||||||
|
t.Errorf("jitter of a line alternating by 45 = %v, want 45", got)
|
||||||
|
}
|
||||||
|
// one slow probe among steady ones moves it a little, not enormously
|
||||||
|
if got := jitter([]float64{10, 10, 10, 60, 10, 10}); got > 25 {
|
||||||
|
t.Errorf("jitter with a single outlier = %v, want it not to run away", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body an upload sends is a fixed size, counts itself, and costs nothing to make.
|
||||||
|
func TestZerosBodyCountsItself(t *testing.T) {
|
||||||
|
var n atomic.Int64
|
||||||
|
z := &zeros{left: 100, n: &n}
|
||||||
|
buf := make([]byte, 30)
|
||||||
|
total := 0
|
||||||
|
for {
|
||||||
|
got, err := z.Read(buf)
|
||||||
|
total += got
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total != 100 || n.Load() != 100 {
|
||||||
|
t.Errorf("read %d bytes and counted %d, want 100 and 100", total, n.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces has to work on whatever machine this runs on, and always name a loopback.
|
||||||
|
func TestInterfacesHasLoopback(t *testing.T) {
|
||||||
|
ifs := Interfaces()
|
||||||
|
if len(ifs) == 0 {
|
||||||
|
t.Fatal("no interfaces at all")
|
||||||
|
}
|
||||||
|
lo := false
|
||||||
|
for _, f := range ifs {
|
||||||
|
if f.Kind == "loopback" {
|
||||||
|
lo = true
|
||||||
|
if !f.Up || len(f.Addrs) == 0 {
|
||||||
|
t.Errorf("loopback %s looks wrong: up=%v addrs=%v", f.Name, f.Up, f.Addrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Name == "" {
|
||||||
|
t.Error("an interface with no name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !lo {
|
||||||
|
t.Error("no loopback among the interfaces")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real thing, against the real endpoint: skipped by default, because `make test` must
|
||||||
|
// not need the internet. HAMLAB_NET_TEST=1 go test ./internal/network/ -run Live -v
|
||||||
|
func TestLiveSpeedTest(t *testing.T) {
|
||||||
|
if os.Getenv("HAMLAB_NET_TEST") == "" {
|
||||||
|
t.Skip("set HAMLAB_NET_TEST=1 to measure the actual line")
|
||||||
|
}
|
||||||
|
tr := NewTester(nil)
|
||||||
|
if _, err := tr.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tr.Start(); err == nil {
|
||||||
|
t.Error("a second test started while the first was running")
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(90 * time.Second)
|
||||||
|
for tr.Running() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
}
|
||||||
|
st := tr.State()
|
||||||
|
if st.Result == nil {
|
||||||
|
t.Fatal("no result")
|
||||||
|
}
|
||||||
|
r := *st.Result
|
||||||
|
t.Logf("down %s up %s latency %.1f ms jitter %.1f ms via %s ip %s %.0f MB moved in %.1fs",
|
||||||
|
Mbits(r.DownMbps), Mbits(r.UpMbps), r.LatencyMs, r.JitterMs, r.Colo, r.IP,
|
||||||
|
float64(r.DownBytes+r.UpBytes)/1e6, r.TookSec)
|
||||||
|
if r.Error != "" {
|
||||||
|
t.Fatalf("test failed: %s", r.Error)
|
||||||
|
}
|
||||||
|
if r.DownMbps <= 0 || r.UpMbps <= 0 || r.LatencyMs <= 0 {
|
||||||
|
t.Errorf("a figure came back zero: %+v", r)
|
||||||
|
}
|
||||||
|
if r.IP == "" {
|
||||||
|
t.Error("no public address reported")
|
||||||
|
}
|
||||||
|
if len(tr.History()) != 1 {
|
||||||
|
t.Errorf("history has %d results, want 1", len(tr.History()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phase measured against a local server, at a rate the test controls. This is the part of
|
||||||
|
// the speed test with arithmetic in it — the window after the ramp, the byte cap that ends it
|
||||||
|
// early, and the rate that comes out — and none of it needs the internet to check.
|
||||||
|
func TestPhaseMeasuresTheWindow(t *testing.T) {
|
||||||
|
const perTick = 1 << 20 // a mebibyte every 5 ms ≈ 1678 Mbps, whatever the machine is doing
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/up" {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
w.WriteHeader(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, perTick)
|
||||||
|
fl, _ := w.(http.Flusher)
|
||||||
|
for {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fl != nil {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(5 * time.Millisecond):
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
defer func(up string, w, d, mw time.Duration, m int64) {
|
||||||
|
upURL, warmUp, downFor, minWindow, measureBytes = up, w, d, mw, m
|
||||||
|
}(upURL, warmUp, downFor, minWindow, measureBytes)
|
||||||
|
upURL = srv.URL + "/up"
|
||||||
|
warmUp, downFor, minWindow, measureBytes = 100*time.Millisecond, 3*time.Second, 200*time.Millisecond, 8<<20
|
||||||
|
here := Source{"local", func(int) string { return srv.URL + "/down" }}
|
||||||
|
|
||||||
|
tr := NewTester(nil)
|
||||||
|
began := time.Now()
|
||||||
|
mbps, moved, note, err := tr.phase(context.Background(), srv.Client(), "download", 2, downFor, 0, 1, here)
|
||||||
|
took := time.Since(began)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("phase: %v", err)
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
t.Errorf("note = %q, want none — nothing went wrong", note)
|
||||||
|
}
|
||||||
|
// The cap must have ended it early rather than the clock: the phase was allowed three
|
||||||
|
// seconds and should be done in well under one, having moved at least the cap.
|
||||||
|
if moved < measureBytes {
|
||||||
|
t.Errorf("moved %d bytes, want at least the %d-byte cap", moved, measureBytes)
|
||||||
|
}
|
||||||
|
if took > time.Second {
|
||||||
|
t.Errorf("phase took %v of the %v it was allowed; the byte cap should have ended it", took.Round(time.Millisecond), downFor)
|
||||||
|
}
|
||||||
|
// The server hands out a mebibyte per 5 ms per stream, so the rate is in the hundreds of
|
||||||
|
// Mbps. The point is that it is a real quotient of what moved over how long, not that it
|
||||||
|
// hits a particular figure on a loaded test machine.
|
||||||
|
if mbps < 50 || mbps > 20000 {
|
||||||
|
t.Errorf("mbps = %v, which is not a believable quotient", mbps)
|
||||||
|
}
|
||||||
|
t.Logf("measured %.0f Mbps over %.1f MiB", mbps, float64(moved)/(1<<20))
|
||||||
|
|
||||||
|
// A phase whose server refuses outright fails, and says what it was told.
|
||||||
|
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
|
||||||
|
defer bad.Close()
|
||||||
|
refuser := Source{"refuser", func(int) string { return bad.URL + "/down" }}
|
||||||
|
_, _, _, err = tr.phase(context.Background(), bad.Client(), "download", 2, 300*time.Millisecond, 0, 1, refuser)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("a phase against a server that refuses everything should fail")
|
||||||
|
} else if !refused(err) {
|
||||||
|
t.Errorf("error = %q, want the refusal that makes the fallback kick in", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping a test mid-flight is reported as cancelled, not as whatever the streams said on
|
||||||
|
// their way out.
|
||||||
|
func TestCancelIsReportedAsCancelled(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
buf := make([]byte, 1<<16)
|
||||||
|
for {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
defer func(w, d time.Duration) { warmUp, downFor = w, d }(warmUp, downFor)
|
||||||
|
warmUp, downFor = 10*time.Millisecond, 10*time.Second
|
||||||
|
here := Source{"local", func(int) string { return srv.URL + "/down" }}
|
||||||
|
|
||||||
|
tr := NewTester(nil)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() { time.Sleep(200 * time.Millisecond); cancel() }()
|
||||||
|
_, _, _, err := tr.phase(ctx, srv.Client(), "download", 1, downFor, 0, 1, here)
|
||||||
|
// Cancelled after bytes moved: the figure stands, and the phase does not claim an error.
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("err = %v, want nil or a cancellation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the first source refuses, the download is taken from the next one and the result says
|
||||||
|
// so. This is the whole reason there is more than one.
|
||||||
|
func TestDownloadFallsBackWhenRefused(t *testing.T) {
|
||||||
|
serving := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/up" {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
w.WriteHeader(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// paced, so the phase is governed by its window and not by a loopback moving a
|
||||||
|
// gibibyte before the first tick
|
||||||
|
buf := make([]byte, 1<<20)
|
||||||
|
fl, _ := w.(http.Flusher)
|
||||||
|
for i := 0; i < 64; i++ {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fl != nil {
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(2 * time.Millisecond):
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer serving.Close()
|
||||||
|
refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) }))
|
||||||
|
defer refusing.Close()
|
||||||
|
|
||||||
|
defer func(srcs []Source, up string, w, d, mw time.Duration, m int64) {
|
||||||
|
downSources, upURL, warmUp, downFor, minWindow, measureBytes = srcs, up, w, d, mw, m
|
||||||
|
}(downSources, upURL, warmUp, downFor, minWindow, measureBytes)
|
||||||
|
downSources = []Source{
|
||||||
|
{"refuser", func(int) string { return refusing.URL + "/down" }},
|
||||||
|
{"the-other-one", func(int) string { return serving.URL + "/down" }},
|
||||||
|
}
|
||||||
|
upURL = serving.URL + "/up"
|
||||||
|
warmUp, downFor, upFor, minWindow, measureBytes = 50*time.Millisecond, time.Second, 300*time.Millisecond, 100*time.Millisecond, 4<<20
|
||||||
|
|
||||||
|
tr := NewTester(nil)
|
||||||
|
if _, err := tr.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(30 * time.Second)
|
||||||
|
for tr.Running() && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
res := tr.State().Result
|
||||||
|
if res == nil {
|
||||||
|
t.Fatal("no result")
|
||||||
|
}
|
||||||
|
if res.Source != "the-other-one" {
|
||||||
|
t.Errorf("source = %q, want the second one — the first refused", res.Source)
|
||||||
|
}
|
||||||
|
if res.DownMbps <= 0 {
|
||||||
|
t.Errorf("download = %v, want a figure from the source that did serve: %+v", res.DownMbps, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
package network
|
||||||
|
|
||||||
|
// The speed test. It measures against Cloudflare's public speed endpoints, the same ones
|
||||||
|
// speed.cloudflare.com uses: a plain GET of N bytes down, a plain POST of N bytes up, and a
|
||||||
|
// zero-byte GET for the round trip. No account, no API key, nothing to install, and an edge
|
||||||
|
// close to every one of these machines.
|
||||||
|
//
|
||||||
|
// The method, since a number without one means little — it is stated in the UI too:
|
||||||
|
//
|
||||||
|
// - Latency is the round trip of a zero-byte response on a connection that is already open,
|
||||||
|
// less the time the far end says it spent on the request (its Server-Timing header). That
|
||||||
|
// subtraction is the difference between measuring the line and measuring Cloudflare's
|
||||||
|
// thinking time: a zero-byte response is almost entirely the latter — tens of
|
||||||
|
// milliseconds of worker, against a few of actual wire. The figure reported is the best of
|
||||||
|
// several probes; jitter is the average change from one to the next.
|
||||||
|
// - Download and upload each run several streams at once for a fixed few seconds. One
|
||||||
|
// stream measures one TCP connection's congestion window as much as the line, which is
|
||||||
|
// why every speed test ever made uses more than one.
|
||||||
|
// - The first second of each phase is thrown away. That is the ramp: counting it reports a
|
||||||
|
// number nobody's connection ever actually did.
|
||||||
|
// - Each phase is capped in bytes as well as seconds, so a very fast line cannot run away
|
||||||
|
// with the meter.
|
||||||
|
//
|
||||||
|
// The one thing to know about the far end: it rate-limits by volume, and a test moves a few
|
||||||
|
// hundred megabytes. Several in a row will start earning 429s, which is why there is a
|
||||||
|
// cooldown between tests and why a phase that gets refused says so instead of reporting the
|
||||||
|
// decayed figure it would otherwise compute.
|
||||||
|
//
|
||||||
|
// HTTP/2 is deliberately off: over one HTTP/2 connection the streams would share a single
|
||||||
|
// congestion window, which measures the window, not the line.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The endpoints, and the shape of a phase. Variables rather than constants only so the tests
|
||||||
|
// can point them at a local server and shrink the windows; nothing else rebinds them.
|
||||||
|
// Source is somewhere to pull bytes from. Cloudflare's endpoint is the first choice because
|
||||||
|
// it is anycast — near wherever Ham Lab is running — and because the same host answers for
|
||||||
|
// upload and for the trace. The fallback exists because that endpoint rate-limits by volume
|
||||||
|
// and will refuse a connection that has been tested hard: a plain 100 MB file on a public
|
||||||
|
// mirror has no such opinion. It is in Seattle, so on a machine far from there it will read
|
||||||
|
// low, which is exactly why every result names the source it used.
|
||||||
|
type Source struct {
|
||||||
|
Name string
|
||||||
|
// URL takes the number of bytes wanted, where the source can be asked; a fixed file
|
||||||
|
// ignores it and is fetched again each time round.
|
||||||
|
URL func(bytes int) string
|
||||||
|
}
|
||||||
|
|
||||||
|
var downSources = []Source{
|
||||||
|
{"cloudflare", func(b int) string { return fmt.Sprintf(cfDownURL, b) }},
|
||||||
|
{"linode-seattle", func(int) string { return "https://speedtest.seattle.linode.com/100MB-seattle.bin" }},
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfDownURL = "https://speed.cloudflare.com/__down?bytes=%d"
|
||||||
|
upURL = "https://speed.cloudflare.com/__up"
|
||||||
|
// traceURL is Cloudflare's standard trace: a few lines of key=value naming the address it
|
||||||
|
// sees, the edge that answered and the country. A few hundred bytes.
|
||||||
|
traceURL = "https://speed.cloudflare.com/cdn-cgi/trace"
|
||||||
|
|
||||||
|
warmUp = time.Second
|
||||||
|
downFor = 4 * time.Second
|
||||||
|
upFor = 4 * time.Second
|
||||||
|
// measureBytes ends a phase early once this much has moved inside the window. A fast line
|
||||||
|
// says what it is in well under the time allowed, and stopping there is what keeps a test
|
||||||
|
// down to a couple of hundred megabytes — which is the difference between the far end
|
||||||
|
// serving this happily and rate-limiting it. A slow line never reaches it and is timed out
|
||||||
|
// by downFor/upFor instead, which is the honest way round.
|
||||||
|
measureBytes = int64(64) << 20
|
||||||
|
// minWindow is how long the window must last whatever the byte cap says. Without it a very
|
||||||
|
// fast line fills the cap in a few hundredths of a second and the phase would be over
|
||||||
|
// before there was anything to divide by.
|
||||||
|
minWindow = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
latencyProbes = 8
|
||||||
|
downStreams = 4
|
||||||
|
upStreams = 3
|
||||||
|
downChunk = 25 << 20 // bytes per download request; the far end refuses much more
|
||||||
|
upChunk = 25 << 20 // and per upload request
|
||||||
|
// phaseBudget is the runaway guard, counting the ramp too: nothing measures forever.
|
||||||
|
phaseBudget = int64(512) << 20
|
||||||
|
httpTimeout = 90 * time.Second
|
||||||
|
// Cooldown is how long the line is left alone between tests. The far end rate-limits by
|
||||||
|
// volume, and two back-to-back tests are the surest way to be refused by it.
|
||||||
|
Cooldown = 20 * time.Second
|
||||||
|
|
||||||
|
// Method is what the UI tells the reader about the numbers it is showing.
|
||||||
|
Method = "Cloudflare's speed endpoints · 4 streams down, 3 up · a 1 s ramp, then up to 4 s or 64 MiB each way"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is one finished speed test.
|
||||||
|
type Result struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
DownMbps float64 `json:"down_mbps"`
|
||||||
|
UpMbps float64 `json:"up_mbps"`
|
||||||
|
LatencyMs float64 `json:"latency_ms"`
|
||||||
|
JitterMs float64 `json:"jitter_ms"`
|
||||||
|
DownBytes int64 `json:"down_bytes"`
|
||||||
|
UpBytes int64 `json:"up_bytes"`
|
||||||
|
TookSec float64 `json:"took_sec"`
|
||||||
|
// Colo is the Cloudflare edge that answered — "SEA" — and IP is this connection's public
|
||||||
|
// address as that edge saw it.
|
||||||
|
Colo string `json:"colo,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
// Source is where the download came from, which matters because the fallback is not in the
|
||||||
|
// same place as the anycast one. Empty means the usual.
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
// Note is set when the figures are real but something went wrong along the way — a stream
|
||||||
|
// that was refused part-way, most often. The number is what was actually seen; the note is
|
||||||
|
// why it may be low.
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run is a test in progress, or the last one to have finished.
|
||||||
|
type Run struct {
|
||||||
|
Started time.Time `json:"started"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
// Phase is where it has got to: latency, download, upload, done.
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
// Done is how far through the whole test, 0–1, so one bar can show all of it.
|
||||||
|
Done float64 `json:"done"`
|
||||||
|
// Mbps is the live figure for the phase under way — what the needle shows.
|
||||||
|
Mbps float64 `json:"mbps"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Result *Result `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tester runs one speed test at a time and remembers what the last ones said.
|
||||||
|
type Tester struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
run Run
|
||||||
|
hist []Result
|
||||||
|
// lastTry is when a test was last *started*, which is what the cooldown counts from — a
|
||||||
|
// test that was refused still used the line, and hammering retries at an endpoint that has
|
||||||
|
// just rate-limited us is the one thing guaranteed not to help.
|
||||||
|
lastTry time.Time
|
||||||
|
cancel context.CancelFunc
|
||||||
|
onDone func(Result)
|
||||||
|
maxHist int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTester(onDone func(Result)) *Tester {
|
||||||
|
return &Tester{onDone: onDone, maxHist: 50}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load takes the results kept from before a restart, oldest first.
|
||||||
|
func (t *Tester) Load(rs []Result) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.hist = append(t.hist[:0], rs...)
|
||||||
|
if n := len(t.hist); n > 0 {
|
||||||
|
last := t.hist[n-1]
|
||||||
|
t.run = Run{Started: last.At, Phase: "done", Done: 1, Result: &last}
|
||||||
|
t.lastTry = last.At
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) State() Run {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return t.run
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) History() []Result {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return append([]Result{}, t.hist...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) Running() bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return t.run.Running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel stops a test in progress. Reports whether there was one.
|
||||||
|
func (t *Tester) Cancel() bool {
|
||||||
|
t.mu.Lock()
|
||||||
|
c := t.cancel
|
||||||
|
t.mu.Unlock()
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrBusy = errors.New("a speed test is already running")
|
||||||
|
|
||||||
|
// Start begins a test in the background and returns its opening state.
|
||||||
|
func (t *Tester) Start() (Run, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.run.Running {
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
return r, ErrBusy
|
||||||
|
}
|
||||||
|
if since := time.Since(t.lastTry); since < Cooldown && !t.lastTry.IsZero() {
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
return r, fmt.Errorf("give the line %d more seconds — testing back to back is what gets this connection rate-limited", int((Cooldown-since).Seconds())+1)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
t.cancel = cancel
|
||||||
|
t.lastTry = time.Now()
|
||||||
|
t.run = Run{Started: t.lastTry, Running: true, Phase: "latency"}
|
||||||
|
r := t.run
|
||||||
|
t.mu.Unlock()
|
||||||
|
go t.measure(ctx, cancel)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) set(fn func(*Run)) {
|
||||||
|
t.mu.Lock()
|
||||||
|
fn(&t.run)
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tester) measure(ctx context.Context, cancel context.CancelFunc) {
|
||||||
|
defer cancel()
|
||||||
|
started := time.Now()
|
||||||
|
res := Result{At: started}
|
||||||
|
|
||||||
|
tr := newTransport(downStreams)
|
||||||
|
defer tr.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: tr, Timeout: httpTimeout}
|
||||||
|
|
||||||
|
// latency, and the meta the same responses carry
|
||||||
|
lat, meta, err := t.latency(ctx, client)
|
||||||
|
if err == nil {
|
||||||
|
res.LatencyMs, res.JitterMs = best(lat), jitter(lat)
|
||||||
|
res.Colo, res.IP = meta.Colo, meta.IP
|
||||||
|
}
|
||||||
|
var note string
|
||||||
|
if err == nil {
|
||||||
|
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "download", 0.1, 0 })
|
||||||
|
// Each source in turn: a refusal from the first is exactly what the second is for, and
|
||||||
|
// anything else (no route, no DNS) would fail the same way twice, so it stops there.
|
||||||
|
for i, src := range downSources {
|
||||||
|
res.Source = src.Name
|
||||||
|
res.DownMbps, res.DownBytes, note, err = t.phase(ctx, client, "download", downStreams, downFor, 0.1, 0.6, src)
|
||||||
|
if err == nil || !refused(err) || i == len(downSources)-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf("speed test: %s refused the download, trying %s", src.Name, downSources[i+1].Name)
|
||||||
|
}
|
||||||
|
res.Note = join(res.Note, note)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "upload", 0.7, 0 })
|
||||||
|
res.UpMbps, res.UpBytes, note, err = t.phase(ctx, client, "upload", upStreams, upFor, 0.7, 1.0, Source{Name: "cloudflare"})
|
||||||
|
res.Note = join(res.Note, note)
|
||||||
|
}
|
||||||
|
res.TookSec = round(time.Since(started).Seconds(), 1)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
res.Error = "cancelled"
|
||||||
|
} else {
|
||||||
|
res.Error = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.cancel = nil
|
||||||
|
t.run = Run{Started: started, Phase: "done", Done: 1, Result: &res, Bytes: res.DownBytes + res.UpBytes}
|
||||||
|
if res.Error == "" || res.DownMbps > 0 { // a cancelled test that got a number is still a reading
|
||||||
|
t.hist = append(t.hist, res)
|
||||||
|
if len(t.hist) > t.maxHist {
|
||||||
|
t.hist = t.hist[len(t.hist)-t.maxHist:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done, out := t.onDone, res
|
||||||
|
t.mu.Unlock()
|
||||||
|
if done != nil {
|
||||||
|
done(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta is what the edge says about the connection. The address comes from a header of the
|
||||||
|
// speed endpoint's own; the edge's name is the tail of the ray ID, which every Cloudflare
|
||||||
|
// response carries ("a41f6720f966a36c-SEA").
|
||||||
|
type meta struct{ IP, Colo string }
|
||||||
|
|
||||||
|
func readMeta(h http.Header) meta {
|
||||||
|
m := meta{IP: h.Get("cf-meta-ip")}
|
||||||
|
if ray := h.Get("cf-ray"); ray != "" {
|
||||||
|
if i := strings.LastIndexByte(ray, '-'); i >= 0 {
|
||||||
|
m.Colo = ray[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverMs is how long the far end says it spent on this request, from Server-Timing. The
|
||||||
|
// cfL4 entry carries no dur= and contributes nothing, which is what we want — it is a
|
||||||
|
// description of the connection, not time spent.
|
||||||
|
func serverMs(h http.Header) float64 {
|
||||||
|
var total float64
|
||||||
|
for _, v := range h.Values("Server-Timing") {
|
||||||
|
for _, part := range strings.Split(v, ",") {
|
||||||
|
_, after, ok := strings.Cut(part, "dur=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := 0
|
||||||
|
for end < len(after) && (after[end] == '.' || (after[end] >= '0' && after[end] <= '9')) {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(after[:end], 64); err == nil {
|
||||||
|
total += f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// latency times several zero-byte responses on an already-open connection.
|
||||||
|
func (t *Tester) latency(ctx context.Context, client *http.Client) ([]float64, meta, error) {
|
||||||
|
var m meta
|
||||||
|
var out []float64
|
||||||
|
for i := 0; i <= latencyProbes; i++ { // the first is the handshake, and is not counted
|
||||||
|
start := time.Now()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(cfDownURL, 0), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, m, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, m, err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, m, fmt.Errorf("speed endpoint answered %s", resp.Status)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
m = readMeta(resp.Header)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rtt := float64(time.Since(start).Microseconds())/1000 - serverMs(resp.Header)
|
||||||
|
if rtt < 0.01 { // the far end claimed more time than the whole request took
|
||||||
|
rtt = 0.01
|
||||||
|
}
|
||||||
|
out = append(out, rtt)
|
||||||
|
t.set(func(r *Run) { r.Done = 0.02 + 0.08*float64(i)/latencyProbes })
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, m, errors.New("no reply from the speed endpoint")
|
||||||
|
}
|
||||||
|
return out, m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase pushes traffic with several streams for a fixed spell and reports the rate over the
|
||||||
|
// window after the ramp. from and to are where this phase sits in the whole test's progress.
|
||||||
|
//
|
||||||
|
// The window ends when the traffic does, not when the clock runs out: if the far end starts
|
||||||
|
// refusing part-way, the figure is what was actually moved while it was moving, and the note
|
||||||
|
// says what happened. Coasting to the end of the clock with no bytes arriving would report a
|
||||||
|
// number a third of the truth.
|
||||||
|
func (t *Tester) phase(parent context.Context, client *http.Client, dir string, streams int, forHow time.Duration, from, to float64, src Source) (float64, int64, string, error) {
|
||||||
|
// parent is the test's own context, which is cancelled when somebody presses Stop. ctx is
|
||||||
|
// this phase's, which *this function* cancels to shut the streams down — so the two are
|
||||||
|
// kept apart: asking ctx whether the test was cancelled would always say yes by then, and
|
||||||
|
// every failure would be reported as "context canceled" whatever really happened.
|
||||||
|
ctx, stop := context.WithCancel(parent)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
var moved atomic.Int64
|
||||||
|
var firstErr error
|
||||||
|
var errOnce sync.Once
|
||||||
|
var alive atomic.Int32
|
||||||
|
alive.Store(int32(streams))
|
||||||
|
dead := make(chan struct{})
|
||||||
|
var deadOnce sync.Once
|
||||||
|
|
||||||
|
for i := 0; i < streams; i++ {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if alive.Add(-1) == 0 {
|
||||||
|
deadOnce.Do(func() { close(dead) })
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
tries := 0
|
||||||
|
for ctx.Err() == nil && moved.Load() < phaseBudget {
|
||||||
|
var err error
|
||||||
|
if dir == "download" {
|
||||||
|
err = stream(ctx, client, src, &moved)
|
||||||
|
} else {
|
||||||
|
err = upstream(ctx, client, &moved)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
tries = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errOnce.Do(func() { firstErr = err })
|
||||||
|
if tries++; tries > 2 { // refused three times: this stream is finished
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(400 * time.Millisecond):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ramp, measured and thrown away
|
||||||
|
select {
|
||||||
|
case <-time.After(warmUp):
|
||||||
|
case <-dead:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
mark, at := moved.Load(), time.Now()
|
||||||
|
last, lastAt := mark, at // the last time bytes were still arriving
|
||||||
|
deadline := at.Add(forHow)
|
||||||
|
tick := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
select {
|
||||||
|
case <-tick.C:
|
||||||
|
case <-dead:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
if parent.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
now := moved.Load()
|
||||||
|
if now > last {
|
||||||
|
last, lastAt = now, time.Now()
|
||||||
|
}
|
||||||
|
// enough has moved to know, and it has been long enough to mean it
|
||||||
|
if (now-mark >= measureBytes && time.Since(at) >= minWindow) || now >= phaseBudget {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if secs := time.Since(at).Seconds(); secs > 0 {
|
||||||
|
live := mbps(now-mark, secs)
|
||||||
|
part := math.Min(1, secs/forHow.Seconds())
|
||||||
|
t.set(func(r *Run) { r.Mbps, r.Bytes, r.Done = live, now, from+(to-from)*part })
|
||||||
|
}
|
||||||
|
// every stream has given up and nothing more is coming
|
||||||
|
if alive.Load() == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total, secs := last-mark, lastAt.Sub(at).Seconds()
|
||||||
|
stop()
|
||||||
|
|
||||||
|
if total <= 0 {
|
||||||
|
switch {
|
||||||
|
case firstErr != nil: // what the far end said explains it better than anything else
|
||||||
|
return 0, moved.Load(), "", firstErr
|
||||||
|
case parent.Err() != nil:
|
||||||
|
return 0, moved.Load(), "", parent.Err()
|
||||||
|
}
|
||||||
|
return 0, moved.Load(), "", fmt.Errorf("nothing moved in the %s phase", dir)
|
||||||
|
}
|
||||||
|
if secs < 0.02 { // too short a window to divide by; the guard is against dividing by zero,
|
||||||
|
// not a judgement about how long a good measurement takes
|
||||||
|
return 0, moved.Load(), "", fmt.Errorf("the %s phase was over before it could be measured", dir)
|
||||||
|
}
|
||||||
|
note := ""
|
||||||
|
if firstErr != nil {
|
||||||
|
note = fmt.Sprintf("the %s phase was cut short: %s", dir, firstErr)
|
||||||
|
}
|
||||||
|
return mbps(total, secs), moved.Load(), note, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// join puts two notes together without leaving an empty one dangling.
|
||||||
|
func join(a, b string) string {
|
||||||
|
switch {
|
||||||
|
case a == "":
|
||||||
|
return b
|
||||||
|
case b == "":
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return a + "; " + b
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream fetches one chunk from src, counting bytes as they arrive.
|
||||||
|
func stream(ctx context.Context, client *http.Client, src Source, n *atomic.Int64) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", src.URL(downChunk), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return answered(resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 64<<10)
|
||||||
|
for {
|
||||||
|
got, err := resp.Body.Read(buf)
|
||||||
|
if got > 0 {
|
||||||
|
n.Add(int64(got))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// upstream posts one chunk of nothing in particular, counting bytes as they go out.
|
||||||
|
func upstream(ctx context.Context, client *http.Client, n *atomic.Int64) error {
|
||||||
|
body := &zeros{left: upChunk, n: n}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", upURL, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.ContentLength = upChunk
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 && resp.StatusCode != 204 {
|
||||||
|
return answered(resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// answered turns the far end's refusal into something worth reading. It rate-limits by
|
||||||
|
// volume: enough testing in a short while and it stops serving the big transfers, which is
|
||||||
|
// the only failure of this test anyone here is likely to meet.
|
||||||
|
func answered(code int, status string) error {
|
||||||
|
if code == 429 || code == 403 {
|
||||||
|
return errRefused
|
||||||
|
}
|
||||||
|
return fmt.Errorf("the speed endpoint answered %s", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errRefused is the far end rate-limiting this connection, which is the only failure of this
|
||||||
|
// test anyone here is likely to meet: it limits by volume, and a test moves a few hundred
|
||||||
|
// megabytes. It is recognisable so the download phase can try its other source.
|
||||||
|
var errRefused = errors.New("the speed endpoint is rate-limiting this connection — it does that after a lot of testing; leave it a few minutes")
|
||||||
|
|
||||||
|
func refused(err error) bool { return errors.Is(err, errRefused) }
|
||||||
|
|
||||||
|
// zeros is a request body of a fixed size that costs nothing to produce, counting itself as
|
||||||
|
// it is read out onto the wire.
|
||||||
|
type zeros struct {
|
||||||
|
left int
|
||||||
|
n *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *zeros) Read(p []byte) (int, error) {
|
||||||
|
if z.left <= 0 {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
if len(p) > z.left {
|
||||||
|
p = p[:z.left]
|
||||||
|
}
|
||||||
|
for i := range p {
|
||||||
|
p[i] = 0
|
||||||
|
}
|
||||||
|
z.left -= len(p)
|
||||||
|
z.n.Add(int64(len(p)))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTransport(streams int) *http.Transport {
|
||||||
|
return &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
MaxIdleConns: streams * 2,
|
||||||
|
MaxIdleConnsPerHost: streams * 2,
|
||||||
|
// An empty map turns HTTP/2 off: see the note at the top of this file.
|
||||||
|
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeOutside asks the far end who it thinks we are. One small request.
|
||||||
|
func probeOutside() Outside {
|
||||||
|
tr := newTransport(1)
|
||||||
|
defer tr.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", traceURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Outside{At: time.Now(), Error: err.Error()}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Outside{At: time.Now(), Error: shortErr(err)}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return Outside{At: time.Now(), Error: "the trace endpoint answered " + resp.Status}
|
||||||
|
}
|
||||||
|
o := Outside{At: time.Now()}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "ip":
|
||||||
|
o.IP = v
|
||||||
|
case "colo":
|
||||||
|
o.Colo = v
|
||||||
|
case "loc":
|
||||||
|
o.Country = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.IP == "" {
|
||||||
|
o.Error = "the trace endpoint said nothing about this connection"
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortErr keeps the part of a network error a person can act on.
|
||||||
|
func shortErr(err error) string {
|
||||||
|
s := err.Error()
|
||||||
|
if i := strings.LastIndex(s, ": "); i > 0 && len(s)-i < 60 {
|
||||||
|
return s[i+2:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func mbps(bytes int64, secs float64) float64 {
|
||||||
|
return round(float64(bytes)*8/secs/1e6, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func best(v []float64) float64 {
|
||||||
|
m := v[0]
|
||||||
|
for _, x := range v {
|
||||||
|
if x < m {
|
||||||
|
m = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return round(m, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jitter is how much the line wobbles: the average change from one round trip to the next,
|
||||||
|
// which is the usual definition and, unlike a standard deviation, is not run away with by a
|
||||||
|
// single slow probe.
|
||||||
|
func jitter(v []float64) float64 {
|
||||||
|
if len(v) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var sum float64
|
||||||
|
for i := 1; i < len(v); i++ {
|
||||||
|
sum += math.Abs(v[i] - v[i-1])
|
||||||
|
}
|
||||||
|
return round(sum/float64(len(v)-1), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func round(v float64, places int) float64 {
|
||||||
|
p := math.Pow(10, float64(places))
|
||||||
|
return math.Round(v*p) / p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mbits renders a rate the way the line is sold, for a log line.
|
||||||
|
func Mbits(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) + " Mbps" }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh /etc/rc.common
|
||||||
|
# Ham Lab web console (written by hamlab install)
|
||||||
|
|
||||||
|
USE_PROCD=1
|
||||||
|
# after network and firewall, before the tail of the boot
|
||||||
|
START=95
|
||||||
|
STOP=10
|
||||||
|
|
||||||
|
start_service() {
|
||||||
|
procd_open_instance hamlab
|
||||||
|
procd_set_param command "@BIN@" serve
|
||||||
|
procd_set_param env HAMLAB_CONFIG="@CONFIG@" HOME="@HOME@" USER="@USER@"
|
||||||
|
procd_set_param user "@USER@"
|
||||||
|
procd_set_param group "@GROUP@"
|
||||||
|
procd_set_param pidfile /var/run/hamlab.pid
|
||||||
|
# respawn: settle 3s, 5 retries inside that window, then give up counting
|
||||||
|
procd_set_param respawn 3600 3 0
|
||||||
|
procd_set_param stdout 1
|
||||||
|
procd_set_param stderr 1
|
||||||
|
procd_close_instance
|
||||||
|
}
|
||||||
|
|
||||||
|
service_triggers() {
|
||||||
|
procd_add_reload_trigger network
|
||||||
|
}
|
||||||
+30
-6
@@ -37,9 +37,14 @@ var systemdUnit string
|
|||||||
//go:embed hamlab.openrc
|
//go:embed hamlab.openrc
|
||||||
var openrcScript string
|
var openrcScript string
|
||||||
|
|
||||||
|
//go:embed hamlab.procd
|
||||||
|
var procdScript string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
unitPath = "/etc/systemd/system/hamlab.service"
|
unitPath = "/etc/systemd/system/hamlab.service"
|
||||||
openrcPath = "/etc/init.d/hamlab"
|
openrcPath = "/etc/init.d/hamlab"
|
||||||
|
// procd (OpenWrt) uses the same path as OpenRC; the two are told apart by content.
|
||||||
|
procdPath = "/etc/init.d/hamlab"
|
||||||
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -228,6 +233,19 @@ func Install(args []string, version string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case exists("/sbin/procd") && exists("/etc/rc.common"):
|
||||||
|
initSys = "procd"
|
||||||
|
if err := os.WriteFile(procdPath, []byte(fill(procdScript, vars)), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := run(procdPath, "enable"); err != nil {
|
||||||
|
say("warning: %s enable: %v", procdPath, err)
|
||||||
|
}
|
||||||
|
if !o.noStart {
|
||||||
|
if err := run(procdPath, "restart"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
||||||
initSys = "OpenRC"
|
initSys = "OpenRC"
|
||||||
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
||||||
@@ -258,7 +276,7 @@ func Install(args []string, version string) error {
|
|||||||
url := scheme + "://" + browseAddr(cfg.Listen)
|
url := scheme + "://" + browseAddr(cfg.Listen)
|
||||||
switch {
|
switch {
|
||||||
case initSys == "":
|
case initSys == "":
|
||||||
say("\nNo systemd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
|
say("\nNo systemd, procd or OpenRC here, so nothing starts it at boot.\nRun it under your own supervisor, as %s:\n HAMLAB_CONFIG=%s %s serve", acct.name, cfgPath, dest)
|
||||||
case o.noStart:
|
case o.noStart:
|
||||||
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
||||||
say("\nHam Lab is up: %s", url)
|
say("\nHam Lab is up: %s", url)
|
||||||
@@ -294,9 +312,15 @@ func Uninstall(args []string) error {
|
|||||||
_ = run("systemctl", "daemon-reload")
|
_ = run("systemctl", "daemon-reload")
|
||||||
}
|
}
|
||||||
if b, err := os.ReadFile(openrcPath); err == nil {
|
if b, err := os.ReadFile(openrcPath); err == nil {
|
||||||
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
|
if strings.Contains(string(b), "USE_PROCD=1") {
|
||||||
_ = run("rc-service", "hamlab", "stop")
|
dataDir = configDirFrom(string(b), "HAMLAB_CONFIG=")
|
||||||
_ = run("rc-update", "del", "hamlab", "default")
|
_ = run(procdPath, "stop")
|
||||||
|
_ = run(procdPath, "disable")
|
||||||
|
} else {
|
||||||
|
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
|
||||||
|
_ = run("rc-service", "hamlab", "stop")
|
||||||
|
_ = run("rc-update", "del", "hamlab", "default")
|
||||||
|
}
|
||||||
os.Remove(openrcPath)
|
os.Remove(openrcPath)
|
||||||
}
|
}
|
||||||
os.Remove(sudoersPath)
|
os.Remove(sudoersPath)
|
||||||
|
|||||||
@@ -452,8 +452,25 @@ func (m *Manager) WriteCronFile(ctx context.Context, policies []Policy, user, ex
|
|||||||
return m.writeRoot(ctx, CronFile, text)
|
return m.writeRoot(ctx, CronFile, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRoot puts text at a root-owned path, through the same privilege escalation every
|
// WriteRootFile puts text at a root-owned path, through the same privilege escalation every
|
||||||
// other write here uses.
|
// other write here uses. Shared by the snapshot schedules and the image-update schedules,
|
||||||
|
// which both own a file in /etc/cron.d.
|
||||||
|
func (m *Manager) WriteRootFile(ctx context.Context, path, text string) error {
|
||||||
|
return m.writeRoot(ctx, path, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRootFile deletes one, for when the last schedule of a kind goes away.
|
||||||
|
func (m *Manager) RemoveRootFile(ctx context.Context, path string) error {
|
||||||
|
out, code, err := m.run(ctx, "rm", "-f", path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
return errors.New(strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) writeRoot(ctx context.Context, path, text string) error {
|
func (m *Manager) writeRoot(ctx context.Context, path, text string) error {
|
||||||
args := []string{"tee", path}
|
args := []string{"tee", path}
|
||||||
if m.sudo {
|
if m.sudo {
|
||||||
|
|||||||
+20
-4
@@ -44,6 +44,16 @@ type UserState struct {
|
|||||||
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RememberedMount is one bind mount of a container that was running when Ham Lab last
|
||||||
|
// looked. A compose stack records the path its file had *inside* whatever deployed it, so
|
||||||
|
// when that container is gone the path is the only way back to the file on disk.
|
||||||
|
type RememberedMount struct {
|
||||||
|
Container string `json:"container"`
|
||||||
|
Dest string `json:"dest"` // the path inside the container
|
||||||
|
Src string `json:"src"` // the path on the machine
|
||||||
|
Seen time.Time `json:"seen"`
|
||||||
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
Terminals []Terminal `json:"terminals"`
|
Terminals []Terminal `json:"terminals"`
|
||||||
Sessions map[string]Session `json:"sessions"` // token -> session
|
Sessions map[string]Session `json:"sessions"` // token -> session
|
||||||
@@ -51,10 +61,16 @@ type State struct {
|
|||||||
// UI is the pre-users machine-wide look; on first boot with users it moves to the first
|
// UI is the pre-users machine-wide look; on first boot with users it moves to the first
|
||||||
// account. Kept so an old state file still reads.
|
// account. Kept so an old state file still reads.
|
||||||
UI json.RawMessage `json:"ui,omitempty"`
|
UI json.RawMessage `json:"ui,omitempty"`
|
||||||
Alerts json.RawMessage `json:"alerts,omitempty"`
|
Alerts json.RawMessage `json:"alerts,omitempty"` // rules, channels and recent events (internal/api/alerts.go)
|
||||||
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go) // rules, channels and recent events (internal/api/alerts.go)
|
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go)
|
||||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
DockerJobs json.RawMessage `json:"docker_jobs,omitempty"` // image-update schedules (internal/api/dockersched.go)
|
||||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
Network json.RawMessage `json:"network,omitempty"` // speed test results (internal/api/network.go)
|
||||||
|
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
||||||
|
// orchestrator that has since been removed can still be found on disk. See
|
||||||
|
// internal/api/stacks.go.
|
||||||
|
Mounts []RememberedMount `json:"mounts,omitempty"`
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserState returns the prefs of an account, making an empty one if needed.
|
// UserState returns the prefs of an account, making an empty one if needed.
|
||||||
|
|||||||
@@ -84,10 +84,21 @@ func newID() string {
|
|||||||
|
|
||||||
// Create starts a new login shell. id may be empty.
|
// Create starts a new login shell. id may be empty.
|
||||||
func (m *Manager) Create(id, name string) (*Session, error) {
|
func (m *Manager) Create(id, name string) (*Session, error) {
|
||||||
|
return m.CreateCmd(id, name, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCmd is Create with something other than a login shell: argv nil is the shell, and
|
||||||
|
// anything else runs that instead — `docker exec -it …` for a container's console. Every
|
||||||
|
// session is the same thing afterwards, so attach, resize, the scrollback ring and the tab
|
||||||
|
// strip work without knowing which it is.
|
||||||
|
func (m *Manager) CreateCmd(id, name string, argv []string) (*Session, error) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
id = newID()
|
id = newID()
|
||||||
}
|
}
|
||||||
cmd := exec.Command(m.shell, "-l")
|
cmd := exec.Command(m.shell, "-l")
|
||||||
|
if len(argv) > 0 {
|
||||||
|
cmd = exec.Command(argv[0], argv[1:]...)
|
||||||
|
}
|
||||||
cmd.Dir = m.home
|
cmd.Dir = m.home
|
||||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1")
|
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=1")
|
||||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// Package vpn reads this machine's WireGuard tunnels: the interfaces, and the peers on them
|
||||||
|
// with names put back on.
|
||||||
|
//
|
||||||
|
// The live state comes from `wg show all dump`, which is the kernel's own answer and the only
|
||||||
|
// place handshakes and byte counts exist. The kernel knows nothing about names, though — a
|
||||||
|
// peer there is a public key — so the configuration is read alongside it purely to label
|
||||||
|
// them: `# comments` in a wg-quick .conf, or the `description` of a UCI peer section on
|
||||||
|
// OpenWrt. Everything here is read-only, and no private or pre-shared key is ever read, let
|
||||||
|
// alone returned.
|
||||||
|
package vpn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/priv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Peer is one end of a tunnel: a device that dials in, or the server this machine dials out
|
||||||
|
// to. Which it is depends on the tunnel; see Tunnel.Kind.
|
||||||
|
type Peer struct {
|
||||||
|
// Name is what the configuration calls it, where it says at all. The kernel has no idea.
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
// Endpoint is where its packets come from, empty until it has been heard from.
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
AllowedIPs []string `json:"allowed_ips"`
|
||||||
|
// Handshake is the last completed handshake, absent when there has never been one — a peer
|
||||||
|
// that is configured but has not connected since this tunnel came up. A pointer because
|
||||||
|
// omitempty does nothing for a zero time.Time, and a peer that has never been heard from
|
||||||
|
// would otherwise be reported as having last spoken in the year one.
|
||||||
|
Handshake *time.Time `json:"handshake,omitempty"`
|
||||||
|
// Online is a handshake inside the last three minutes. WireGuard is silent when idle, so
|
||||||
|
// this means "was talking recently", not "is connected" — there is no such thing here.
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Rx int64 `json:"rx"`
|
||||||
|
Tx int64 `json:"tx"`
|
||||||
|
Keepalive int `json:"keepalive,omitempty"`
|
||||||
|
// PSK notes that a pre-shared key is in use, without the key itself going anywhere.
|
||||||
|
PSK bool `json:"psk,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunnel is one WireGuard interface.
|
||||||
|
type Tunnel struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
// Kind is "server" when peers dial in to this machine, "client" when this machine dials
|
||||||
|
// out through a peer, and "" when it is too new or too odd to tell. It is worked out from
|
||||||
|
// the shape of the peers, not from anything WireGuard states: a peer that is routed
|
||||||
|
// everything and has an endpoint of its own is an exit, and a tunnel with one of those is
|
||||||
|
// this machine going out through it.
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ListenPort int `json:"listen_port,omitempty"`
|
||||||
|
FwMark string `json:"fwmark,omitempty"`
|
||||||
|
Addrs []string `json:"addrs"`
|
||||||
|
MTU int `json:"mtu,omitempty"`
|
||||||
|
Peers []Peer `json:"peers"`
|
||||||
|
// Exit is the peer this machine goes out through, for a client tunnel.
|
||||||
|
Exit string `json:"exit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is every tunnel on this machine, and why there are none when there are none.
|
||||||
|
type Report struct {
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Tunnels []Tunnel `json:"tunnels"`
|
||||||
|
// Tool is false when wireguard-tools is not installed, Root false when the daemon cannot
|
||||||
|
// reach root — either one means the peers cannot be read, and the tab should say which.
|
||||||
|
Tool bool `json:"tool"`
|
||||||
|
Root bool `json:"root"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineWithin = 3 * time.Minute
|
||||||
|
|
||||||
|
// Look reads the tunnels on this machine.
|
||||||
|
func Look(ctx context.Context) Report {
|
||||||
|
r := Report{At: time.Now(), Tunnels: []Tunnel{}, Root: priv.Available()}
|
||||||
|
if _, err := exec.LookPath("wg"); err != nil {
|
||||||
|
r.Note = "wireguard-tools is not installed here, so there is nothing to read"
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
r.Tool = true
|
||||||
|
args := []string{"wg", "show", "all", "dump"}
|
||||||
|
if priv.Available() {
|
||||||
|
args = priv.Wrap(args...)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if !r.Root {
|
||||||
|
r.Note = "reading WireGuard needs root, and this daemon has no way to it"
|
||||||
|
} else {
|
||||||
|
r.Note = "wg show failed: " + strings.TrimSpace(err.Error())
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
r.Tunnels = parseDump(string(out))
|
||||||
|
names := peerNames()
|
||||||
|
for i := range r.Tunnels {
|
||||||
|
t := &r.Tunnels[i]
|
||||||
|
t.Addrs, t.MTU = ifaceAddrs(t.Name)
|
||||||
|
for j := range t.Peers {
|
||||||
|
p := &t.Peers[j]
|
||||||
|
p.Name = names[p.PublicKey]
|
||||||
|
p.Online = p.Handshake != nil && time.Since(*p.Handshake) < onlineWithin
|
||||||
|
}
|
||||||
|
t.Kind, t.Exit = classify(*t)
|
||||||
|
sort.SliceStable(t.Peers, func(a, b int) bool {
|
||||||
|
pa, pb := t.Peers[a], t.Peers[b]
|
||||||
|
if pa.Online != pb.Online {
|
||||||
|
return pa.Online
|
||||||
|
}
|
||||||
|
if ha, hb := shookAt(pa), shookAt(pb); !ha.Equal(hb) {
|
||||||
|
return ha.After(hb)
|
||||||
|
}
|
||||||
|
return pa.Name < pb.Name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(r.Tunnels) == 0 {
|
||||||
|
r.Note = "no WireGuard interfaces are up on this machine"
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDump reads `wg show all dump`. The first line of each interface is the interface
|
||||||
|
// itself, the rest are its peers; every line is tab-separated and positional.
|
||||||
|
//
|
||||||
|
// interface: name private-key public-key listen-port fwmark
|
||||||
|
// peer: name public-key psk endpoint allowed-ips handshake rx tx keepalive
|
||||||
|
//
|
||||||
|
// The private key is in the second column and is read past without being kept.
|
||||||
|
func parseDump(out string) []Tunnel {
|
||||||
|
// An empty list, never nil: this is JSON to a browser that will take its length, and a
|
||||||
|
// null there is a blank page rather than "no tunnels".
|
||||||
|
tunnels := []Tunnel{}
|
||||||
|
byName := map[string]int{}
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
f := strings.Split(sc.Text(), "\t")
|
||||||
|
if len(f) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := f[0]
|
||||||
|
if len(f) == 5 { // an interface line
|
||||||
|
port, _ := strconv.Atoi(f[3])
|
||||||
|
byName[name] = len(tunnels)
|
||||||
|
tunnels = append(tunnels, Tunnel{Name: name, PublicKey: f[2], ListenPort: port, FwMark: off(f[4]), Peers: []Peer{}})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(f) < 9 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i, ok := byName[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hs, _ := strconv.ParseInt(f[5], 10, 64)
|
||||||
|
rx, _ := strconv.ParseInt(f[6], 10, 64)
|
||||||
|
tx, _ := strconv.ParseInt(f[7], 10, 64)
|
||||||
|
ka, _ := strconv.Atoi(f[8])
|
||||||
|
p := Peer{PublicKey: f[1], PSK: f[2] != "(none)", Endpoint: off(f[3]), Rx: rx, Tx: tx, Keepalive: ka}
|
||||||
|
if f[4] != "(none)" {
|
||||||
|
p.AllowedIPs = strings.Split(f[4], ",")
|
||||||
|
}
|
||||||
|
if hs > 0 {
|
||||||
|
at := time.Unix(hs, 0)
|
||||||
|
p.Handshake = &at
|
||||||
|
}
|
||||||
|
tunnels[i].Peers = append(tunnels[i].Peers, p)
|
||||||
|
}
|
||||||
|
return tunnels
|
||||||
|
}
|
||||||
|
|
||||||
|
// shookAt is a peer's last handshake, or the zero time when there has never been one.
|
||||||
|
func shookAt(p Peer) time.Time {
|
||||||
|
if p.Handshake == nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return *p.Handshake
|
||||||
|
}
|
||||||
|
|
||||||
|
// off turns wg's placeholders into an empty string.
|
||||||
|
func off(v string) string {
|
||||||
|
if v == "(none)" || v == "off" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// classify decides whether this machine is the one being dialled or the one dialling. A peer
|
||||||
|
// that is allowed every address and has an endpoint is a way out to the internet; a tunnel
|
||||||
|
// with one of those is this machine using it. Otherwise, if anything can reach us here, it is
|
||||||
|
// a server.
|
||||||
|
func classify(t Tunnel) (kind, exit string) {
|
||||||
|
for _, p := range t.Peers {
|
||||||
|
for _, a := range p.AllowedIPs {
|
||||||
|
if a == "0.0.0.0/0" || a == "::/0" {
|
||||||
|
if p.Endpoint != "" {
|
||||||
|
name := p.Name
|
||||||
|
if name == "" {
|
||||||
|
name = p.Endpoint
|
||||||
|
}
|
||||||
|
return "client", name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.ListenPort > 0 && len(t.Peers) > 0 {
|
||||||
|
return "server", ""
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ifaceAddrs is what the interface carries, and its MTU.
|
||||||
|
func ifaceAddrs(name string) ([]string, int) {
|
||||||
|
addrs := []string{}
|
||||||
|
mtu := 0
|
||||||
|
ifi, err := net.InterfaceByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return addrs, mtu
|
||||||
|
}
|
||||||
|
mtu = ifi.MTU
|
||||||
|
got, err := ifi.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
return addrs, mtu
|
||||||
|
}
|
||||||
|
for _, a := range got {
|
||||||
|
addrs = append(addrs, a.String())
|
||||||
|
}
|
||||||
|
return addrs, mtu
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- names ----
|
||||||
|
|
||||||
|
// peerNames maps a public key to what the configuration calls that peer. Two shapes are
|
||||||
|
// understood, which between them cover every machine here: wg-quick's .conf files, where the
|
||||||
|
// name is a comment, and OpenWrt's UCI, where it is the peer section's description.
|
||||||
|
func peerNames() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range confNames() {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range uciNames() { // UCI wins: on OpenWrt it is the live configuration
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// confNames reads /etc/wireguard/*.conf. In a wg-quick file a peer is named by the comment
|
||||||
|
// that introduces it, which is the convention every tool that writes these files follows:
|
||||||
|
//
|
||||||
|
// # phone
|
||||||
|
// [Peer]
|
||||||
|
// PublicKey = …
|
||||||
|
//
|
||||||
|
// confDir is where wg-quick keeps its files; a variable so the test can point it elsewhere.
|
||||||
|
var confDir = "/etc/wireguard"
|
||||||
|
|
||||||
|
func confNames() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
files, _ := filepath.Glob(filepath.Join(confDir, "*.conf"))
|
||||||
|
for _, path := range files {
|
||||||
|
text, err := readMaybeRoot(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(text))
|
||||||
|
// last is the most recent comment line; when a [Peer] block opens, that comment becomes
|
||||||
|
// its pending name. Keeping the two apart is what lets each block take the comment that
|
||||||
|
// introduces it and ignore any comment written inside it.
|
||||||
|
last, pending := "", ""
|
||||||
|
inPeer := false
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(line, "[Peer]"):
|
||||||
|
inPeer, pending, last = true, last, ""
|
||||||
|
case strings.HasPrefix(line, "["):
|
||||||
|
inPeer, pending, last = false, "", ""
|
||||||
|
case strings.HasPrefix(line, "#"):
|
||||||
|
if c := strings.TrimSpace(strings.TrimLeft(line, "#")); c != "" {
|
||||||
|
last = c
|
||||||
|
}
|
||||||
|
case inPeer && strings.HasPrefix(line, "PublicKey"):
|
||||||
|
// The separator is the first '='; the key's own base64 padding is the rest of
|
||||||
|
// the line, so everything after that first one is the key.
|
||||||
|
if _, v, ok := strings.Cut(line, "="); ok && pending != "" {
|
||||||
|
out[strings.TrimSpace(v)] = pending
|
||||||
|
}
|
||||||
|
pending = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMaybeRoot reads a file, falling back to root when it is not ours to read — which
|
||||||
|
// /etc/wireguard always is, since it holds private keys. Only the comments and public keys in
|
||||||
|
// it are looked at; nothing else in the file is kept or returned.
|
||||||
|
func readMaybeRoot(path string) (string, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
if !os.IsPermission(err) || !priv.Available() {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
args := priv.Wrap("cat", path)
|
||||||
|
out, cerr := exec.CommandContext(ctx, args[0], args[1:]...).Output()
|
||||||
|
if cerr != nil {
|
||||||
|
return "", cerr
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uciNames reads OpenWrt's configuration: each peer is a section with a description and a
|
||||||
|
// public key. `uci show` is used rather than the file, so includes and overrides are applied.
|
||||||
|
func uciNames() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
if _, err := exec.LookPath("uci"); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
b, err := exec.CommandContext(ctx, "uci", "-q", "show", "network").Output()
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
desc, key := map[string]string{}, map[string]string{}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
lhs, rhs, ok := strings.Cut(line, "=")
|
||||||
|
if !ok || !strings.HasPrefix(lhs, "network.") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimPrefix(lhs, "network."), ".")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
section, option := parts[0], parts[1]
|
||||||
|
v := strings.Trim(rhs, "'")
|
||||||
|
switch option {
|
||||||
|
case "description":
|
||||||
|
desc[section] = v
|
||||||
|
case "public_key":
|
||||||
|
key[section] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for section, k := range key {
|
||||||
|
if d := desc[section]; d != "" {
|
||||||
|
out[k] = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package vpn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Real output of `wg show all dump`, from the two machines here: gate, where devices dial in,
|
||||||
|
// and hamtv, which dials out through Mullvad. Keys are the real public ones, which are public.
|
||||||
|
const gateDump = "wg0\tPRIVATEKEYWOULDBEHERE=\t4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=\t51820\toff\n" +
|
||||||
|
"wg0\tNeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=\t3KWsu0mdVb2l5B/j0qHE03yE9LXi3FJ1WAjWMuxaLAw=\t10.0.0.33:63573\t172.21.46.3/32\t1790565744\t134192164\t415977404\toff\n" +
|
||||||
|
"wg0\twUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=\t4Pd5u2t6U9EdHlFlneg+hMr0G2v+IvoE/EqGfFdDPI8=\t(none)\t172.21.46.2/32\t0\t0\t0\toff\n"
|
||||||
|
|
||||||
|
const hamtvDump = "wg0\tPRIVATEKEYWOULDBEHERE=\tF/rgcHi+ZiaWPoSQ9iPcldIUbbMotE1hwSB/74Y9Qi4=\t39281\toff\n" +
|
||||||
|
"wg0\tzqsfGglzJPY657WMRxf/S4omG7+ZkSDIpDq+ggbc9yo=\t(none)\t23.234.72.2:51820\t0.0.0.0/0,::/0\t1790571246\t18206675520\t2687837240\t25\n"
|
||||||
|
|
||||||
|
func TestParseServerDump(t *testing.T) {
|
||||||
|
ts := parseDump(gateDump)
|
||||||
|
if len(ts) != 1 {
|
||||||
|
t.Fatalf("got %d tunnels, want 1", len(ts))
|
||||||
|
}
|
||||||
|
w := ts[0]
|
||||||
|
if w.Name != "wg0" || w.ListenPort != 51820 {
|
||||||
|
t.Errorf("interface = %s:%d, want wg0:51820", w.Name, w.ListenPort)
|
||||||
|
}
|
||||||
|
if w.PublicKey != "4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=" {
|
||||||
|
t.Errorf("public key = %q; the third column is the public one", w.PublicKey)
|
||||||
|
}
|
||||||
|
// The private key is column two of the interface line and must not appear anywhere.
|
||||||
|
if w.PublicKey == "PRIVATEKEYWOULDBEHERE=" || w.FwMark == "PRIVATEKEYWOULDBEHERE=" {
|
||||||
|
t.Error("the private key was kept")
|
||||||
|
}
|
||||||
|
if len(w.Peers) != 2 {
|
||||||
|
t.Fatalf("got %d peers, want 2", len(w.Peers))
|
||||||
|
}
|
||||||
|
p := w.Peers[0]
|
||||||
|
if p.PublicKey != "NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=" || p.Endpoint != "10.0.0.33:63573" {
|
||||||
|
t.Errorf("first peer = %+v", p)
|
||||||
|
}
|
||||||
|
if !p.PSK {
|
||||||
|
t.Error("first peer has a pre-shared key and should say so")
|
||||||
|
}
|
||||||
|
if got := p.AllowedIPs; len(got) != 1 || got[0] != "172.21.46.3/32" {
|
||||||
|
t.Errorf("allowed ips = %v", got)
|
||||||
|
}
|
||||||
|
if p.Rx != 134192164 || p.Tx != 415977404 {
|
||||||
|
t.Errorf("counters = %d/%d", p.Rx, p.Tx)
|
||||||
|
}
|
||||||
|
if p.Handshake == nil || p.Handshake.Unix() != 1790565744 {
|
||||||
|
t.Errorf("handshake = %v", p.Handshake)
|
||||||
|
}
|
||||||
|
// The second peer has never connected: no endpoint, no handshake, nothing moved.
|
||||||
|
if q := w.Peers[1]; q.Endpoint != "" || q.Handshake != nil || q.Rx != 0 {
|
||||||
|
t.Errorf("second peer = %+v, want one that has never been heard from", q)
|
||||||
|
}
|
||||||
|
if kind, exit := classify(w); kind != "server" || exit != "" {
|
||||||
|
t.Errorf("classify = %q/%q, want server", kind, exit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseClientDump(t *testing.T) {
|
||||||
|
ts := parseDump(hamtvDump)
|
||||||
|
if len(ts) != 1 || len(ts[0].Peers) != 1 {
|
||||||
|
t.Fatalf("got %d tunnels", len(ts))
|
||||||
|
}
|
||||||
|
w := ts[0]
|
||||||
|
p := w.Peers[0]
|
||||||
|
if len(p.AllowedIPs) != 2 || p.AllowedIPs[0] != "0.0.0.0/0" {
|
||||||
|
t.Errorf("allowed ips = %v, want everything", p.AllowedIPs)
|
||||||
|
}
|
||||||
|
if p.Keepalive != 25 {
|
||||||
|
t.Errorf("keepalive = %d, want 25", p.Keepalive)
|
||||||
|
}
|
||||||
|
if p.PSK {
|
||||||
|
t.Error("this peer has no pre-shared key")
|
||||||
|
}
|
||||||
|
// It routes everything and has an endpoint, so this machine goes out through it.
|
||||||
|
kind, exit := classify(w)
|
||||||
|
if kind != "client" || exit != "23.234.72.2:51820" {
|
||||||
|
t.Errorf("classify = %q/%q, want client through the endpoint", kind, exit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tunnel that is up but has no peers at all is neither, and must not be guessed at.
|
||||||
|
func TestClassifyUndecided(t *testing.T) {
|
||||||
|
if kind, _ := classify(Tunnel{Name: "wg0", ListenPort: 51820}); kind != "" {
|
||||||
|
t.Errorf("classify with no peers = %q, want no opinion", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peers are named by the comment above them in a wg-quick file. The public key's own base64
|
||||||
|
// padding is an '=' too, which is the thing to get wrong.
|
||||||
|
func TestConfNames(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
conf := `[Interface]
|
||||||
|
Address = 172.21.46.1/24
|
||||||
|
PrivateKey = SECRETSECRETSECRETSECRETSECRETSECRETSECRET=
|
||||||
|
ListenPort = 51820
|
||||||
|
|
||||||
|
# phone
|
||||||
|
[Peer]
|
||||||
|
PublicKey = NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=
|
||||||
|
AllowedIPs = 172.21.46.3/32
|
||||||
|
|
||||||
|
# the laptop
|
||||||
|
[Peer]
|
||||||
|
PublicKey = wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=
|
||||||
|
AllowedIPs = 172.21.46.2/32
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "wg0.conf"), []byte(conf), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old := confDir
|
||||||
|
confDir = dir
|
||||||
|
defer func() { confDir = old }()
|
||||||
|
|
||||||
|
names := confNames()
|
||||||
|
if got := names["NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU="]; got != "phone" {
|
||||||
|
t.Errorf("first peer named %q, want phone", got)
|
||||||
|
}
|
||||||
|
if got := names["wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg="]; got != "the laptop" {
|
||||||
|
t.Errorf("second peer named %q, want the laptop", got)
|
||||||
|
}
|
||||||
|
for k := range names {
|
||||||
|
if k == "SECRETSECRETSECRETSECRETSECRETSECRETSECRET=" {
|
||||||
|
t.Error("the private key was read as if it were a peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) != 2 {
|
||||||
|
t.Errorf("%d names, want 2: %v", len(names), names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineIsRecentHandshake(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
hs time.Time
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"just now", time.Now().Add(-30 * time.Second), true},
|
||||||
|
{"a while back", time.Now().Add(-10 * time.Minute), false},
|
||||||
|
{"never", time.Time{}, false},
|
||||||
|
} {
|
||||||
|
got := !tc.hs.IsZero() && time.Since(tc.hs) < onlineWithin
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%s: online = %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer that has never handshaken must carry no time at all, not the zero one. omitempty
|
||||||
|
// does nothing for a time.Time, so this is the difference between the page saying "never
|
||||||
|
// connected" and saying the device was last seen in the year one.
|
||||||
|
func TestNeverConnectedHasNoTimestamp(t *testing.T) {
|
||||||
|
w := parseDump(gateDump)[0]
|
||||||
|
b, err := json.Marshal(w.Peers[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), "handshake") {
|
||||||
|
t.Errorf("json = %s, want no handshake field at all", b)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), "0001-01-01") {
|
||||||
|
t.Errorf("json = %s, want no year-one timestamp", b)
|
||||||
|
}
|
||||||
|
// and the one that has connected still carries its time
|
||||||
|
if b, _ := json.Marshal(w.Peers[0]); !strings.Contains(string(b), "handshake") {
|
||||||
|
t.Errorf("json = %s, want the handshake of a peer that has one", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine with wireguard-tools installed but no tunnel up produces an empty dump. That has
|
||||||
|
// to marshal as [] and not as null: the page takes the length of it, and null blanks the
|
||||||
|
// whole console rather than saying "no tunnels".
|
||||||
|
func TestEmptyDumpIsAnEmptyList(t *testing.T) {
|
||||||
|
for _, dump := range []string{"", "\n", " \n\n"} {
|
||||||
|
got := parseDump(dump)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("parseDump(%q) = nil, want an empty list", dump)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(b) != "[]" {
|
||||||
|
t.Errorf("parseDump(%q) marshals to %s, want []", dump, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+448
-41
@@ -1,67 +1,119 @@
|
|||||||
// Docker: containers grouped by the stack that deployed them, with start / stop / restart,
|
// Docker: containers grouped by the stack that deployed them, with start / stop / restart,
|
||||||
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
|
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
|
||||||
// come from the same container sensors everything else uses.
|
// come from the same container sensors everything else uses.
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
|
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
|
||||||
import TaskOutput from './TaskOutput'
|
import TaskOutput from './TaskOutput'
|
||||||
import StackNew, { DupFrom } from './StackNew'
|
import StackNew, { DupFrom } from './StackNew'
|
||||||
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
||||||
import { useHashSel } from './hashsel'
|
import { useHashSel } from './hashsel'
|
||||||
import { useRefreshMs } from './prefs'
|
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
|
||||||
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } from './ui'
|
import Sparkline from './Sparkline'
|
||||||
|
import { fmtBytes } from './fmt'
|
||||||
|
import { describeCron } from './crontext'
|
||||||
|
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast, useEscape } from './ui'
|
||||||
|
|
||||||
type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks'
|
type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'networks'
|
||||||
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
|
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
|
||||||
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
|
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
|
||||||
|
|
||||||
|
// A container's output, followed over a held connection the way the Services tab follows a
|
||||||
|
// journal. A reconnect resumes from the last line's timestamp, so a container that restarts
|
||||||
|
// — or a phone that slept — doesn't replay what is already on screen.
|
||||||
function LogView({ id }: { id: string }) {
|
function LogView({ id }: { id: string }) {
|
||||||
const [lines, setLines] = useState<{ t: string; m: string }[]>([])
|
const [lines, setLines] = useState<DockerLogLine[]>([])
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
const [live, setLive] = useState(false)
|
||||||
const [follow, setFollow] = useState(true)
|
const [follow, setFollow] = useState(true)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
const box = useRef<HTMLDivElement>(null)
|
const box = useRef<HTMLDivElement>(null)
|
||||||
const last = useRef('')
|
const last = useRef('')
|
||||||
|
const pausedRef = useRef(false)
|
||||||
|
pausedRef.current = paused
|
||||||
|
const pending = useRef<DockerLogLine[]>([])
|
||||||
|
// What a resume has already shown: `--since` is inclusive, and two lines can share a
|
||||||
|
// timestamp (a container writing to both streams at once), so the boundary is a set of
|
||||||
|
// lines rather than a moment. Bounded — only the lines around a reconnect can repeat.
|
||||||
|
const seen = useRef<{ set: Set<string>; q: string[] }>({ set: new Set(), q: [] })
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let stop = false; setLines([]); last.current = ''
|
setLines([]); setErr(''); setLive(false); last.current = ''; pending.current = []
|
||||||
const tick = async () => {
|
seen.current = { set: new Set(), q: [] }
|
||||||
if (stop) return
|
let closed = false
|
||||||
if (document.visibilityState === 'visible') {
|
let sock: WebSocket | null = null
|
||||||
try {
|
let retry: number | undefined
|
||||||
const r = await api.dockerLogs(id, last.current)
|
let attempt = 0
|
||||||
const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines
|
const connect = () => {
|
||||||
if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) }
|
if (closed) return
|
||||||
setErr('')
|
const q = last.current ? `since=${encodeURIComponent(last.current)}` : 'n=300'
|
||||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
// fixed when the log opens: a reconnect must not follow the console to another machine
|
||||||
|
const s = new WebSocket(wsURLPath(`/api/docker/containers/${encodeURIComponent(id)}/logs/tail?${q}`))
|
||||||
|
sock = s
|
||||||
|
s.onopen = () => { attempt = 0; setLive(true); setErr('') }
|
||||||
|
s.onmessage = (ev) => {
|
||||||
|
const l = JSON.parse(ev.data) as DockerLogLine
|
||||||
|
if (l.t) {
|
||||||
|
const k = l.t + '|' + (l.err ? 'e' : 'o') + '|' + l.m
|
||||||
|
const { set, q } = seen.current
|
||||||
|
if (set.has(k)) return
|
||||||
|
set.add(k); q.push(k)
|
||||||
|
if (q.length > 1000) for (const old of q.splice(0, 500)) set.delete(old)
|
||||||
|
if (l.t > last.current) last.current = l.t
|
||||||
|
}
|
||||||
|
if (pausedRef.current) { pending.current.push(l); return }
|
||||||
|
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), l] : [...prev, l]))
|
||||||
}
|
}
|
||||||
window.setTimeout(tick, 2000)
|
// a container that has stopped — or was never there — ends the stream at once, so
|
||||||
|
// back off rather than asking Docker the same question twice a second forever
|
||||||
|
s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, Math.min(15000, 1000 * 2 ** attempt++)) }
|
||||||
|
s.onerror = () => { setErr('log stream disconnected'); s.close() }
|
||||||
}
|
}
|
||||||
tick()
|
connect()
|
||||||
return () => { stop = true }
|
return () => { closed = true; clearTimeout(retry); sock?.close() }
|
||||||
}, [id])
|
}, [id])
|
||||||
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow])
|
// Resume: flush what arrived while paused.
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || pending.current.length === 0) return
|
||||||
|
const p = pending.current; pending.current = []
|
||||||
|
setLines((prev) => [...prev, ...p].slice(-2000))
|
||||||
|
}, [paused])
|
||||||
|
useEffect(() => { if (follow && !paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow, paused])
|
||||||
return (
|
return (
|
||||||
<div className="logs">
|
<div className="logs">
|
||||||
<div className="logs-bar"><span className="muted">{lines.length} lines</span><span className="spacer" /><button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button><button className="chip" onClick={() => setLines([])}>Clear</button></div>
|
<div className="logs-bar">
|
||||||
{err && <div className="error">{err}</div>}
|
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'} · {lines.length} lines{err && ` · ${err}`}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button>
|
||||||
|
<button className="chip" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
|
||||||
|
<button className="chip" onClick={() => setLines([])}>Clear</button>
|
||||||
|
</div>
|
||||||
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
||||||
{lines.map((l, i) => <div className="log" key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
{lines.map((l, i) => <div className={'log' + (l.err ? ' err' : '')} key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
||||||
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a container is doing, and what can be done to it: its numbers over time, its health
|
||||||
|
// checks, what has happened to it lately, a shell inside it, and its limits.
|
||||||
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
|
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
|
||||||
const [tab, setTab] = useState<'logs' | 'inspect'>('logs')
|
const [tab, setTab] = useState<'logs' | 'stats' | 'health' | 'events' | 'inspect'>('logs')
|
||||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
||||||
useEffect(() => { setInfo(null); if (tab === 'inspect') api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, c.name])
|
const [limits, setLimits] = useState(false)
|
||||||
|
useEffect(() => { setInfo(null); if (tab === 'inspect' || limits) api.dockerInspect(c.name).then(setInfo).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [tab, limits, c.name])
|
||||||
const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
|
const act = (a: 'start' | 'stop' | 'restart' | 'remove') => async () => { try { await api.dockerAction(c.name, a); toast(`${c.name}: ${a} ok`); onChanged() } catch (e) { toast(`${c.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } }
|
||||||
const running = c.state === 'running'
|
const running = c.state === 'running'
|
||||||
const s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
const seg = c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')
|
||||||
|
const s = (k: string) => live[`container.${seg}.${k}`]
|
||||||
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
|
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
|
||||||
|
const shell = async () => {
|
||||||
|
try { const t = await api.dockerExec(c.name); toast(`shell open as “${t.name}” on the Terminal tab`); window.dispatchEvent(new CustomEvent('hamlab:terminal', { detail: t.id })) }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="svc-detail">
|
<div className="svc-detail">
|
||||||
<CloseBtn onClick={onBack} />
|
<CloseBtn onClick={onBack} />
|
||||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div>
|
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span>{c.health && c.health !== 'none' && <span className={'badge' + (c.health === 'healthy' ? '' : ' bad')}>{c.health}</span>}</div>
|
||||||
<div className="props">
|
<div className="props">
|
||||||
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
|
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
|
||||||
<div><span className="k">Status</span><span>{c.status}</span></div>
|
<div><span className="k">Status</span><span>{c.status}</span></div>
|
||||||
@@ -72,27 +124,181 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
|
|||||||
<div className="svc-actions">
|
<div className="svc-actions">
|
||||||
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
|
{!running && <Btn className="btn on" onClick={act('start')}>Start</Btn>}
|
||||||
{!running && <Btn className="btn danger" confirm={`Remove the container ${c.name}? Its image, volumes and bind-mounted data stay; anything written inside the container itself is lost.${c.stack ? ` Deploying the ${c.stack} stack makes it again.` : ''}`} onClick={async () => { await act('remove')(); onBack() }}>Remove</Btn>}
|
{!running && <Btn className="btn danger" confirm={`Remove the container ${c.name}? Its image, volumes and bind-mounted data stay; anything written inside the container itself is lost.${c.stack ? ` Deploying the ${c.stack} stack makes it again.` : ''}`} onClick={async () => { await act('remove')(); onBack() }}>Remove</Btn>}
|
||||||
|
{running && <Btn className="btn" onClick={shell}>Shell</Btn>}
|
||||||
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
|
{running && <Btn className="btn" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
|
||||||
|
<Btn className="btn" onClick={() => setLimits(true)}>Limits</Btn>
|
||||||
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
|
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
|
||||||
</div>
|
</div>
|
||||||
<div className="tabs"><button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button><button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button></div>
|
<div className="tabs">
|
||||||
|
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
|
||||||
|
<button className={tab === 'stats' ? 'on' : ''} onClick={() => setTab('stats')}>Stats</button>
|
||||||
|
<button className={tab === 'health' ? 'on' : ''} onClick={() => setTab('health')}>Health</button>
|
||||||
|
<button className={tab === 'events' ? 'on' : ''} onClick={() => setTab('events')}>Events</button>
|
||||||
|
<button className={tab === 'inspect' ? 'on' : ''} onClick={() => setTab('inspect')}>Inspect</button>
|
||||||
|
</div>
|
||||||
{tab === 'logs' && <LogView id={c.name} />}
|
{tab === 'logs' && <LogView id={c.name} />}
|
||||||
|
{tab === 'stats' && <Stats seg={seg} live={live} />}
|
||||||
|
{tab === 'health' && <Health id={c.name} />}
|
||||||
|
{tab === 'events' && <Events id={c.name} />}
|
||||||
{tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : (
|
{tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : (
|
||||||
<div className="props">
|
<div className="props stacked">
|
||||||
<div><span className="k">Restarts</span><span>{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×</span></div>
|
<div><span className="k">Restarts</span><span>{String(info.restart || 'no')} policy · restarted {String(info.restarts ?? 0)}×</span></div>
|
||||||
<div><span className="k">Network</span><span className="mono small">{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}</span></div>
|
<div><span className="k">Network</span><span className="mono small">{Object.keys((info.networks as object) ?? {}).join(', ') || String(info.network_mode)}</span></div>
|
||||||
{Number(info.memory_limit) > 0 && <div><span className="k">Mem limit</span><span>{fmtValue(Number(info.memory_limit), 'B')}</span></div>}
|
{Number(info.memory_limit) > 0 && <div><span className="k">Mem limit</span><span>{fmtValue(Number(info.memory_limit), 'B')}</span></div>}
|
||||||
{!!info.error && <div><span className="k">Error</span><span className="warn-text">{String(info.error)}</span></div>}
|
{!!info.error && <div><span className="k">Error</span><span className="warn-text">{String(info.error)}</span></div>}
|
||||||
{!!info.compose_file && <div><span className="k">Compose</span><span className="mono small">{String(info.compose_file)}</span></div>}
|
{!!info.compose_file && <div><span className="k">Compose</span><span className="mono small">{String(info.compose_file)}</span></div>}
|
||||||
{mounts.map((m) => <div key={m.Destination}><span className="k">Mount</span><span className="mono small">{m.Source} → {m.Destination}{m.RW ? '' : ' (ro)'}</span></div>)}
|
{mounts.length > 0 && <div><span className="k">Mounts</span><span className="mono small">{mounts.map((m) => <div key={m.Destination}>{m.Source} → {m.Destination}{m.RW ? '' : ' (read only)'}</div>)}</span></div>}
|
||||||
<div><span className="k">Env</span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ')} <span className="muted">(names only)</span></span></div>
|
<div><span className="k">Env <span className="muted">names only</span></span><span className="mono small muted">{((info.env_names as string[]) ?? []).join(' ') || '—'}</span></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{limits && <LimitsDialog c={c} info={info} onClose={() => setLimits(false)} onSaved={() => { setLimits(false); onChanged() }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU and memory over time, from the container sensors the sampler already keeps.
|
||||||
|
function Stats({ seg, live }: { seg: string; live: Record<string, Sensor> }) {
|
||||||
|
const [hist, setHist] = useState<{ id: string; t: number[]; v: number[] }[] | null>(null)
|
||||||
|
const historySec = useHistorySec()
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
fetchHistories(`container.${seg}.cpu,container.${seg}.mem`)
|
||||||
|
.then((h) => { if (alive) setHist(h) })
|
||||||
|
.catch(() => { if (alive) setHist([]) })
|
||||||
|
return () => { alive = false }
|
||||||
|
}, [seg])
|
||||||
|
const css = (n: string) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || '#3987e5'
|
||||||
|
const of = (k: string) => hist?.find((h) => h.id === `container.${seg}.${k}`)?.v ?? []
|
||||||
|
const cpu = of('cpu'), mem = of('mem')
|
||||||
|
if (hist === null) return <Loading label="Reading the history…" />
|
||||||
|
if (cpu.length < 2 && mem.length < 2) return <div className="notice">No history yet for this container — it is sampled while it runs, so give it a moment.</div>
|
||||||
|
return (
|
||||||
|
<div className="cards">
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">CPU</span><span className="spacer" /><span className="num">{fmtValue(live[`container.${seg}.cpu`]?.value, '%')} of a core</span></div>
|
||||||
|
<Sparkline series={[{ name: 'cpu', values: cpu, color: css('--s1') }]} format={(v) => `${v.toFixed(1)}%`} height={64} />
|
||||||
|
<div className="muted small">the last {fmtWindowSec(historySec)}</div>
|
||||||
|
</section>
|
||||||
|
<section className="card">
|
||||||
|
<div className="sub-head"><span className="dname">Memory</span><span className="spacer" /><span className="num">{fmtValue(live[`container.${seg}.mem`]?.value, 'B')}</span></div>
|
||||||
|
<Sparkline series={[{ name: 'mem', values: mem, color: css('--s2') }]} format={(v) => fmtBytes(v)} height={64} />
|
||||||
|
<div className="muted small">the last {fmtWindowSec(historySec)}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The health check's verdict, and what its last few probes actually said.
|
||||||
|
function Health({ id }: { id: string }) {
|
||||||
|
const [h, setH] = useState<DockerHealth | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
useEffect(() => { setH(null); api.dockerHealth(id).then(setH).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id])
|
||||||
|
if (err) return <div className="error">{err}</div>
|
||||||
|
if (!h) return <Loading label="Asking…" />
|
||||||
|
if (!h.configured) return <div className="notice">This image defines no health check, so Docker only knows whether the process is running — which it is ({h.state}). A health check is a <span className="mono">healthcheck:</span> block in the compose file.</div>
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">Health</span><span className={h.status === 'healthy' ? '' : 'warn-text'}>{h.status}</span></div>
|
||||||
|
<div><span className="k">State</span><span>{h.state}</span></div>
|
||||||
|
{!!h.failing_streak && <div><span className="k">Failing</span><span className="warn-text">{h.failing_streak} probe{h.failing_streak === 1 ? '' : 's'} in a row</span></div>}
|
||||||
|
</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 8 }}>The last {h.log?.length ?? 0} probes — Docker keeps five.</div>
|
||||||
|
{(h.log ?? []).slice().reverse().map((p, i) => (
|
||||||
|
<section className="card" key={i} style={{ marginTop: 6 }}>
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className={'sdot ' + (p.ExitCode === 0 ? 'ok' : 'bad')} />
|
||||||
|
<span>{new Date(p.Start).toLocaleString()}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small">exit {p.ExitCode} · {Math.max(0, new Date(p.End).getTime() - new Date(p.Start).getTime())} ms</span>
|
||||||
|
</div>
|
||||||
|
{p.Output.trim() && <pre className="run-out" style={{ maxHeight: 140 }}>{p.Output.trim()}</pre>}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What has happened to this container lately, asked of Docker rather than kept here.
|
||||||
|
function Events({ id }: { id: string }) {
|
||||||
|
const [ev, setEv] = useState<DockerEvent[] | null>(null)
|
||||||
|
const [kept, setKept] = useState(0)
|
||||||
|
const [since, setSince] = useState('7d')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
useEffect(() => { setEv(null); setErr(''); api.dockerEvents(id, since).then((r) => { setEv(r.events); setKept(r.kept) }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [id, since])
|
||||||
|
const bad = (a: string, h?: string) => /die|kill|oom|destroy/.test(a) || (h !== undefined && h !== 'healthy')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="chips" style={{ marginBottom: 8 }}>
|
||||||
|
{['24h', '7d', '30d'].map((x) => <button key={x} className={'chip' + (since === x ? ' on' : '')} onClick={() => setSince(x)}>{x}</button>)}
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
{!ev && !err && <Loading label="Asking Docker…" />}
|
||||||
|
{ev && ev.length === 0 && <div className="notice">Nothing for this container in the last {since}. Ham Lab keeps its own record — Docker's own only reaches back a few minutes — so history starts from when this version was installed. {kept} events kept across every container.</div>}
|
||||||
|
{ev && ev.length > 0 && (
|
||||||
|
<div className="snap-list">
|
||||||
|
{ev.map((e, i) => (
|
||||||
|
<div className="snap" key={i}>
|
||||||
|
<span className={bad(e.action, e.health) ? 'warn-text' : ''}>{e.action}{e.health ? `: ${e.health}` : ''}</span>
|
||||||
|
<span className="muted">{new Date(e.t).toLocaleString()}</span>
|
||||||
|
<span className="muted small">{e.exit_code ? `exit ${e.exit_code}` : ''}{e.signal ? ` signal ${e.signal}` : ''}</span>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory, CPU and restart policy, changed on the running container. Docker does this live,
|
||||||
|
// which is the point — and also the catch, said plainly below.
|
||||||
|
function LimitsDialog({ c, info, onClose, onSaved }: { c: DockerContainer; info: Record<string, unknown> | null; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const curMem = Number(info?.memory_limit ?? 0)
|
||||||
|
const [mem, setMem] = useState(curMem > 0 ? String(Math.round(curMem / (1 << 20))) + 'm' : '')
|
||||||
|
const [cpus, setCpus] = useState('')
|
||||||
|
const [restart, setRestart] = useState(String(info?.restart ?? ''))
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
useEscape(onClose)
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr(''); setBusy(true)
|
||||||
|
try { await api.dockerLimits(c.name, { memory: mem, cpus, restart }); toast(`${c.name} updated`); onSaved() }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||||
|
<h3>Limits for {c.name}</h3>
|
||||||
|
<label className="muted">Memory — 512m, 2g, or 0 for no limit{curMem > 0 ? ` (now ${fmtBytes(curMem)})` : ' (no limit now)'}
|
||||||
|
<input className="mono" value={mem} onChange={(e) => setMem(e.target.value)} placeholder="leave blank to keep" spellCheck={false} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">CPUs — 1.5 means one and a half cores, 0 for no limit
|
||||||
|
<input className="mono" value={cpus} onChange={(e) => setCpus(e.target.value)} placeholder="leave blank to keep" spellCheck={false} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">Restart policy
|
||||||
|
<select value={restart} onChange={(e) => setRestart(e.target.value)}>
|
||||||
|
<option value="">leave as it is</option>
|
||||||
|
{['no', 'on-failure', 'unless-stopped', 'always'].map((p) => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="notice attn">
|
||||||
|
This changes the running container now, without recreating it. It does <b>not</b> edit the
|
||||||
|
compose file{c.stack ? <> — so deploying <b>{c.stack}</b> puts back whatever the file says</> : ''}.
|
||||||
|
Edit the stack to make it stick.
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={busy || (!mem && !cpus && !restart)}>Apply</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A plain line diff (longest common subsequence): compose files are short, so the simple way is fine.
|
|
||||||
function lineDiff(a: string, b: string): { s: ' ' | '+' | '-'; t: string }[] {
|
function lineDiff(a: string, b: string): { s: ' ' | '+' | '-'; t: string }[] {
|
||||||
const x = a.split('\n'), y = b.split('\n'); const n = x.length, m = y.length
|
const x = a.split('\n'), y = b.split('\n'); const n = x.length, m = y.length
|
||||||
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
|
const L = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
|
||||||
@@ -120,6 +326,7 @@ const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.inde
|
|||||||
|
|
||||||
// One stack: its compose file to edit, and the four things to do with it. Saving validates
|
// One stack: its compose file to edit, and the four things to do with it. Saving validates
|
||||||
// with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act.
|
// with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act.
|
||||||
|
|
||||||
function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void; onDuplicate: (from: DupFrom) => void }) {
|
function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void; onDuplicate: (from: DupFrom) => void }) {
|
||||||
const [st, setSt] = useState<Stack | null>(null)
|
const [st, setSt] = useState<Stack | null>(null)
|
||||||
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
||||||
@@ -140,7 +347,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
|
|||||||
try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||||
const run = (a: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
const run = (a: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||||
if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
|
if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
|
||||||
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
|
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
|
||||||
return (
|
return (
|
||||||
@@ -159,6 +366,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
|
|||||||
<Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn>
|
<Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn>
|
||||||
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>}
|
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>}
|
||||||
{st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
|
{st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
|
||||||
|
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="build the containers again from scratch: docker compose up -d --force-recreate" confirm={`Recreate every container in ${st.name}?\n\nThis is the way out of a container Docker refuses to start — one pinned to a network that no longer exists, say, which answers "network … not found" to an ordinary Deploy. Nothing in a volume or a bind mount is touched.`} onClick={run('recreate')}>Recreate</Btn>}
|
||||||
{st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>}
|
{st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>}
|
||||||
</div>
|
</div>
|
||||||
<div className="svc-actions">
|
<div className="svc-actions">
|
||||||
@@ -203,6 +411,12 @@ export default function Docker() {
|
|||||||
// what a new stack could join, and what it could collide with
|
// what a new stack could join, and what it could collide with
|
||||||
const nets = useMemo(() => (d?.networks ?? []).map((n) => String(n.Name)).filter((n) => !['bridge', 'host', 'none'].includes(n)).sort(), [d])
|
const nets = useMemo(() => (d?.networks ?? []).map((n) => String(n.Name)).filter((n) => !['bridge', 'host', 'none'].includes(n)).sort(), [d])
|
||||||
const taken = useMemo(() => { const ports: Record<string, string> = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d])
|
const taken = useMemo(() => { const ports: Record<string, string> = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d])
|
||||||
|
// What the registry last said about each image, so a container can be marked without
|
||||||
|
// asking again. Only ever read from the cache here — checking is a deliberate act.
|
||||||
|
const [upd, setUpd] = useState<MachineUpdates | null>(null)
|
||||||
|
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
|
||||||
|
const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
|
||||||
|
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
|
||||||
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
||||||
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
|
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
|
||||||
const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
|
const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
|
||||||
@@ -210,12 +424,12 @@ export default function Docker() {
|
|||||||
const shownTab: Tab = selStack ? 'stacks' : tab
|
const shownTab: Tab = selStack ? 'stacks' : tab
|
||||||
const up = d.containers.filter((c) => c.state === 'running').length
|
const up = d.containers.filter((c) => c.state === 'running').length
|
||||||
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
||||||
const cols: Record<Exclude<Tab, 'containers' | 'stacks'>, [string, string][]> = { images: [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']], volumes: [['Name', 'Name'], ['Driver', 'Driver'], ['Mountpoint', 'Mountpoint']], networks: [['Name', 'Name'], ['Driver', 'Driver'], ['Scope', 'Scope']] }
|
const imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']]
|
||||||
return (
|
return (
|
||||||
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
|
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
|
||||||
<div className="svc-list">
|
<div className="svc-list">
|
||||||
<div className="svc-tools">
|
<div className="svc-tools">
|
||||||
<div className="chips">{(['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t} · {t === 'containers' ? `${up}/${d.containers.length}` : t === 'stacks' ? (stacks?.length ?? '…') : d[t].length}</button>)}</div>
|
<div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
|
||||||
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
|
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
|
||||||
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
|
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
|
||||||
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||||
@@ -224,6 +438,7 @@ export default function Docker() {
|
|||||||
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
|
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
|
||||||
</form>}
|
</form>}
|
||||||
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
||||||
|
{shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
|
||||||
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
||||||
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +457,7 @@ export default function Docker() {
|
|||||||
{list.map((c) => (
|
{list.map((c) => (
|
||||||
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
|
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
|
||||||
<span className={'sdot ' + dot(c)} />
|
<span className={'sdot ' + dot(c)} />
|
||||||
<div className="svc-name"><div className="n">{c.name}</div><div className="d">{c.image}</div></div>
|
<div className="svc-name"><div className="n">{c.name}<UpdateDot state={imgState[c.image]} /></div><div className="d">{c.image}</div></div>
|
||||||
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
|
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -260,16 +475,208 @@ export default function Docker() {
|
|||||||
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
||||||
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
||||||
)}
|
)}
|
||||||
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && (
|
{shownTab === 'networks' && (
|
||||||
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <th />}</tr></thead>
|
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
|
||||||
<tbody>{d[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map((row, i) => <tr key={i}>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}{shownTab === 'images' && <td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td>}</tr>)}</tbody></table>
|
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
|
||||||
|
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
|
||||||
|
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
|
||||||
|
)}
|
||||||
|
{shownTab === 'images' && (
|
||||||
|
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
|
||||||
|
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr>)}</tbody></table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
|
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
|
||||||
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
||||||
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
||||||
|
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
|
||||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A container's image against the registry, as a single mark: nothing when nobody has
|
||||||
|
// asked, so an uncoloured list means "not checked" rather than "all current".
|
||||||
|
function UpdateDot({ state }: { state?: string }) {
|
||||||
|
if (!state || state === 'current') return null
|
||||||
|
const known = state === 'update'
|
||||||
|
return <span className={'updot' + (known ? ' behind' : ' unknown')} title={known ? 'a newer image has been published' : 'the registry could not say'}>{known ? '↑' : '?'}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is out of date here, and the schedules that keep it from happening again.
|
||||||
|
function Updates({ upd, onChecked }: { upd: MachineUpdates | null; onChecked: (u: MachineUpdates) => void }) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [jobs, setJobs] = useState<UpdateJobRow[] | null>(null)
|
||||||
|
const [file, setFile] = useState('')
|
||||||
|
const [editing, setEditing] = useState<Partial<UpdateJob> | null>(null)
|
||||||
|
const [stacks, setStacks] = useState<string[]>([])
|
||||||
|
const loadJobs = useCallback(async () => {
|
||||||
|
try { const r = await api.updateJobs(); setJobs(r.jobs); setFile(r.file) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setJobs([]) }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { loadJobs(); api.stacks().then((r) => setStacks(r.stacks.map((s) => s.name))).catch(() => {}) }, [loadJobs])
|
||||||
|
const check = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try { onChecked(await api.machineUpdates(true)); toast('checked') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const run = async (j: UpdateJob) => {
|
||||||
|
if (!await askYes(`Run “${j.name}” now? It pulls ${j.stack}'s images${j.apply ? ' and deploys them, which restarts its containers' : ', without deploying them'}.`, 'Run now')) return
|
||||||
|
try { await api.runUpdateJob(j.id); toast(`${j.name}: done`); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
const remove = async (j: UpdateJob) => {
|
||||||
|
if (!await askYes(`Remove the schedule “${j.name}”? Nothing is updated or rolled back — the schedule simply stops.`, 'Remove')) return
|
||||||
|
try { await api.deleteUpdateJob(j.id); toast('removed'); loadJobs() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
const imgs = upd?.images ?? []
|
||||||
|
const behind = imgs.filter((i) => i.state === 'update')
|
||||||
|
// Updating is per stack — that is what compose can do — so an image row acts on whichever
|
||||||
|
// stacks use it, and a queue runs them one at a time rather than recreating half the
|
||||||
|
// machine at once.
|
||||||
|
const [queue, setQueue] = useState<string[]>([])
|
||||||
|
const [running, setRunning] = useState<{ stack: string; id: string } | null>(null)
|
||||||
|
const [done, setDone] = useState<string[]>([])
|
||||||
|
const stacksFor = useCallback((img: string) => upd?.stacks?.[img] ?? [], [upd])
|
||||||
|
const startNext = useCallback(async (rest: string[]) => {
|
||||||
|
const next = rest[0]
|
||||||
|
if (!next) { setRunning(null); setQueue([]); return }
|
||||||
|
setQueue(rest.slice(1))
|
||||||
|
try { setRunning({ stack: next, id: (await api.stackAction(next, 'update')).id }) }
|
||||||
|
catch (e) { toast(`${next}: ${e instanceof Error ? e.message : 'failed'}`, 'err'); setRunning(null); startNext(rest.slice(1)) }
|
||||||
|
}, [])
|
||||||
|
const updateStacks = async (names: string[], what: string) => {
|
||||||
|
const uniq = [...new Set(names)]
|
||||||
|
if (uniq.length === 0) { toast('nothing here names a stack this console can deploy', 'info'); return }
|
||||||
|
if (!await askYes(`Update ${what}?\n\nThis pulls the newer image${uniq.length > 1 ? 's' : ''} and recreates ${uniq.length === 1 ? `the ${uniq[0]} stack` : `these stacks, one at a time: ${uniq.join(', ')}`}. Their containers restart.`, 'Update')) return
|
||||||
|
setDone([]); startNext(uniq)
|
||||||
|
}
|
||||||
|
const behindStacks = [...new Set(behind.flatMap((i) => stacksFor(i.image)))]
|
||||||
|
return (
|
||||||
|
<div className="svc-detail">
|
||||||
|
<div className="dash-bar"><h2 className="section">What is out of date</h2><span className="spacer" />
|
||||||
|
<span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked yet'}</span>
|
||||||
|
{behindStacks.length > 0 && <Btn className="btn on" disabled={!!running} onClick={() => updateStacks(behindStacks, `everything out of date — ${behind.length} image${behind.length === 1 ? '' : 's'} across ${behindStacks.length} stack${behindStacks.length === 1 ? '' : 's'}`)}>Update all · {behind.length}</Btn>}
|
||||||
|
<Btn className="btn" busy={busy} onClick={check}>Check now</Btn>
|
||||||
|
</div>
|
||||||
|
<div className="muted small">Each check is one request per image, which registries count — so it happens when asked, and the answer is kept for six hours.</div>
|
||||||
|
{imgs.length === 0 && <div className="notice" style={{ marginTop: 8 }}>Nothing checked yet on this machine.</div>}
|
||||||
|
{imgs.length > 0 && (
|
||||||
|
<section className="card" style={{ marginTop: 8 }}>
|
||||||
|
{behind.length === 0 && <div className="muted">Every image that could be checked is current.</div>}
|
||||||
|
<div className="upd-list">
|
||||||
|
{imgs.filter((i) => i.state !== 'current').map((i) => (
|
||||||
|
<div className="row" key={i.image}>
|
||||||
|
<span className="k mono small">{i.image}</span>
|
||||||
|
<span className={i.state === 'update' ? 'warn-text' : 'muted small'}>{done.some((d) => stacksFor(i.image).includes(d)) ? <span className="good">updated just now</span> : i.state === 'update' ? 'a newer image is published' : i.detail || 'unknown'}</span>
|
||||||
|
<span className="muted small">{stacksFor(i.image).join(' ')}</span>
|
||||||
|
{i.state === 'update' && !done.some((d) => stacksFor(i.image).includes(d)) && (
|
||||||
|
<Btn className="btn" disabled={!!running || stacksFor(i.image).length === 0} title={stacksFor(i.image).length ? `pull it and recreate ${stacksFor(i.image).join(', ')}` : 'no stack here uses this image'}
|
||||||
|
onClick={() => updateStacks(stacksFor(i.image), `${i.image}`)}>Update</Btn>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{behind.length === 0 && imgs.some((i) => i.state === 'current') && <div className="muted small" style={{ marginTop: 6 }}>{imgs.filter((i) => i.state === 'current').length} current.</div>}
|
||||||
|
{running && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div className="sub-head"><span className="dname">Updating {running.stack}</span><span className="spacer" /><span className="muted small">{queue.length > 0 ? `${queue.length} to follow` : 'last one'}</span></div>
|
||||||
|
<TaskOutput id={running.id} onDone={(t) => {
|
||||||
|
if (t.exit_code === 0 && !t.error) setDone((d) => [...d, running.stack])
|
||||||
|
else toast(`${running.stack}: update failed`, 'err')
|
||||||
|
startNext(queue)
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dash-bar" style={{ marginTop: 14 }}><h2 className="section">Schedules</h2><span className="spacer" />
|
||||||
|
<Btn className="btn on" onClick={() => setEditing({ cron: '0 3 * * *', apply: true, enabled: true })}>+ New schedule</Btn>
|
||||||
|
</div>
|
||||||
|
<div className="muted small">Written to <span className="mono">{file || '/etc/cron.d/hamlab-docker'}</span>, which Ham Lab owns — so they keep running whether or not this console is up.</div>
|
||||||
|
{jobs === null && <Loading label="Reading…" />}
|
||||||
|
{jobs?.length === 0 && <div className="notice" style={{ marginTop: 8 }}>No schedule yet. Nothing on this machine updates itself.</div>}
|
||||||
|
<div className="cards" style={{ marginTop: 8 }}>
|
||||||
|
{(jobs ?? []).map(({ job, next, last }) => (
|
||||||
|
<section className="card" key={job.id}>
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className={'dot ' + (last?.error ? 'bad' : job.enabled ? 'ok' : '')} />
|
||||||
|
<span className="dname">{job.name}</span>
|
||||||
|
{!job.enabled && <span className="badge">paused</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small">{job.enabled && next ? new Date(next).toLocaleString() : 'not scheduled'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="props" style={{ marginTop: 6 }}>
|
||||||
|
<div><span className="k">Stack</span><span>{job.stack}{job.services?.length ? ` · ${job.services.join(' ')}` : ''}</span></div>
|
||||||
|
<div><span className="k">Runs</span><span>{describeCron(job.cron) || <span className="mono small">{job.cron}</span>}{describeCron(job.cron) && <span className="muted mono small"> · {job.cron}</span>}</span></div>
|
||||||
|
<div><span className="k">Does</span><span>{job.apply ? 'pulls and deploys' : 'pulls only — you deploy'}</span></div>
|
||||||
|
<div><span className="k">Last run</span><span>{last?.at ? new Date(last.at).toLocaleString() : 'never'}</span></div>
|
||||||
|
</div>
|
||||||
|
{last?.error && <div className="error" style={{ marginTop: 6 }}>{last.error}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<Btn className="btn on" onClick={() => run(job)}>Run now</Btn>
|
||||||
|
<button className="btn" onClick={() => setEditing(job)}>Edit</button>
|
||||||
|
<Btn className="btn" onClick={async () => { await api.saveUpdateJob({ ...job, enabled: !job.enabled }); loadJobs() }}>{job.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||||
|
<Btn className="btn danger" onClick={() => remove(job)}>Remove</Btn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{editing && <JobDialog j={editing} stacks={stacks} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); loadJobs() }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobDialog({ j, stacks, onClose, onSaved }: { j: Partial<UpdateJob>; stacks: string[]; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const [f, setF] = useState<Partial<UpdateJob>>(j)
|
||||||
|
const [svc, setSvc] = useState((j.services ?? []).join(' '))
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
useEscape(onClose)
|
||||||
|
const set = (k: keyof UpdateJob, v: unknown) => setF((x) => ({ ...x, [k]: v }))
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr(''); setBusy(true)
|
||||||
|
try { await api.saveUpdateJob({ ...f, services: svc.split(/\s+/).filter(Boolean) }); onSaved() }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||||
|
<h3>{f.id ? 'Edit schedule' : 'Keep a stack up to date'}</h3>
|
||||||
|
<label className="muted">Stack
|
||||||
|
<select value={f.stack ?? ''} onChange={(e) => set('stack', e.target.value)} required>
|
||||||
|
<option value="">choose…</option>
|
||||||
|
{stacks.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="muted">Name — optional
|
||||||
|
<input value={f.name ?? ''} onChange={(e) => set('name', e.target.value)} placeholder={f.stack ?? 'nightly'} maxLength={64} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">Only these services — optional, space separated; blank means the whole stack
|
||||||
|
<input className="mono" value={svc} onChange={(e) => setSvc(e.target.value)} placeholder="all of them" spellCheck={false} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">When — a cron expression
|
||||||
|
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 3 * * *" spellCheck={false} required />
|
||||||
|
</label>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={!!f.apply} onChange={(e) => set('apply', e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Deploy the new image, not just fetch it
|
||||||
|
</label>
|
||||||
|
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div className="notice">
|
||||||
|
{f.apply
|
||||||
|
? 'Deploying restarts the stack’s containers. On something that carries DNS or ingress, pick an hour you are asleep.'
|
||||||
|
: 'Pulling only means the new image is on the machine and the list shows it as available — nothing restarts until you deploy.'}
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={busy || !f.stack || !f.cron}>Save</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// Hardware: what this machine is made of — the board, the processor, the memory modules in
|
||||||
|
// their slots, the drives and the graphics. None of it moves, so the page is a read rather
|
||||||
|
// than a monitor: what is live (temperature, usage, SMART) belongs to Dashboard and Disks.
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api, Hardware as HW, HWBoard, HWDrive, HWGPU, HWModule } from './api'
|
||||||
|
import { fmtBytes } from './fmt'
|
||||||
|
import { Btn, Loading } from './ui'
|
||||||
|
|
||||||
|
const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', emmc: 'eMMC', sd: 'SD', other: 'disk' }
|
||||||
|
const ghz = (mhz?: number) => (mhz ? `${(mhz / 1000).toFixed(mhz >= 1000 ? 2 : 1)} GHz` : '')
|
||||||
|
|
||||||
|
// A row of the "key: value" kind, dropped entirely when there is no value — a machine that
|
||||||
|
// doesn't report its serial should show one fewer line, not an empty one.
|
||||||
|
function P({ k, v, mono }: { k: string; v?: string | number | null | false; mono?: boolean }) {
|
||||||
|
if (v === undefined || v === null || v === false || v === '' || v === 0) return null
|
||||||
|
return <div><span className="k">{k}</span><span className={mono ? 'mono' : undefined} title={String(v)}>{v}</span></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Module({ m }: { m: HWModule }) {
|
||||||
|
if (m.empty) {
|
||||||
|
return (
|
||||||
|
<div className="sub-card">
|
||||||
|
<div className="sub-head"><span className="dname muted">{m.locator}</span><span className="spacer" /><span className="badge">empty</span></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const speed = m.running_mts && m.speed_mts && m.running_mts !== m.speed_mts
|
||||||
|
? `${m.running_mts} MT/s (rated ${m.speed_mts})` // the board is running it slower than it could
|
||||||
|
: m.running_mts || m.speed_mts ? `${m.running_mts || m.speed_mts} MT/s` : ''
|
||||||
|
return (
|
||||||
|
<div className="sub-card">
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className="dname">{fmtBytes(m.size ?? 0)} {m.type}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="model mono">{m.locator}</span>
|
||||||
|
</div>
|
||||||
|
<div className="props">
|
||||||
|
<P k="Maker" v={m.maker} />
|
||||||
|
<P k="Form" v={m.form_factor} />
|
||||||
|
<P k="Part" v={m.part} mono />
|
||||||
|
<P k="Speed" v={speed} />
|
||||||
|
<P k="Rank" v={m.rank ? `${m.rank}R` : ''} />
|
||||||
|
<P k="Voltage" v={m.voltage} />
|
||||||
|
<P k="Serial" v={m.serial} mono />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Drive({ d }: { d: HWDrive }) {
|
||||||
|
return (
|
||||||
|
<div className="sub-card">
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className="dname">{d.model || d.name}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="badge">{KIND[d.kind] ?? d.kind}</span>
|
||||||
|
</div>
|
||||||
|
<div className="props">
|
||||||
|
<P k="Size" v={fmtBytes(d.size)} />
|
||||||
|
<P k="Device" v={`/dev/${d.name}`} mono />
|
||||||
|
<P k="Bus" v={d.tran ? d.tran.toUpperCase() : ''} />
|
||||||
|
<P k="Spins" v={d.rotational ? 'yes — mechanical' : 'no — solid state'} />
|
||||||
|
<P k="Firmware" v={d.firmware} mono />
|
||||||
|
<P k="Serial" v={d.serial} mono />
|
||||||
|
<P k="Vendor" v={d.vendor} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Gpu({ g }: { g: HWGPU }) {
|
||||||
|
return (
|
||||||
|
<div className="sub-card">
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className="dname">{g.model}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
{g.class && <span className="badge">{g.class}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="props">
|
||||||
|
<P k="Vendor" v={g.vendor} />
|
||||||
|
<P k="Driver" v={g.driver} mono />
|
||||||
|
<P k="Memory" v={g.memory ? fmtBytes(g.memory) : ''} />
|
||||||
|
<P k="Slot" v={g.slot} mono />
|
||||||
|
<P k="Node" v={g.node} mono />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The board and the firmware on it. Everything here is SMBIOS, so a machine without one
|
||||||
|
// — a Mac, a Pi — gets a card that says as much instead of a column of blanks.
|
||||||
|
function Motherboard({ b }: { b: HWBoard }) {
|
||||||
|
const named = b.maker || b.product || b.bios
|
||||||
|
// A board that reports every slot "Available" is not telling us anything — mini PCs
|
||||||
|
// copy the slot table out of a reference design and list slots the case has no room
|
||||||
|
// for. Only worth showing when the firmware actually tracks what is plugged in.
|
||||||
|
const slots = (b.slots ?? []).some((s) => s.usage === 'In Use') ? (b.slots ?? []) : []
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Motherboard</h2>
|
||||||
|
{!named && <div className="empty">This machine has no SMBIOS table, so its firmware doesn't say what board it is.</div>}
|
||||||
|
{named && (
|
||||||
|
<div className="props">
|
||||||
|
<P k="Maker" v={b.maker} />
|
||||||
|
<P k="Model" v={b.product} />
|
||||||
|
<P k="Version" v={b.version} />
|
||||||
|
<P k="Kind" v={b.type} />
|
||||||
|
<P k="Chassis" v={[b.chassis_maker, b.chassis].filter(Boolean).join(' ')} />
|
||||||
|
<P k="In chassis" v={b.location} />
|
||||||
|
<P k="Serial" v={b.serial} mono />
|
||||||
|
<P k="Asset tag" v={b.asset_tag} mono />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(b.bios || b.bios_date) && (
|
||||||
|
<div className="props" style={{ marginTop: 8 }}>
|
||||||
|
<P k="Firmware" v={b.bios} />
|
||||||
|
<P k="Revision" v={b.bios_revision} />
|
||||||
|
<P k="Dated" v={b.bios_date} />
|
||||||
|
<P k="ROM" v={b.rom_size} />
|
||||||
|
<P k="Boots" v={b.uefi ? 'UEFI' : ''} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slots.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div className="sub-head"><span className="dname">Expansion slots</span><span className="spacer" /><span className="muted small">as the firmware lists them</span></div>
|
||||||
|
<table className="dtable">
|
||||||
|
<thead><tr><th>Slot</th><th>Type</th><th>What is in it</th></tr></thead>
|
||||||
|
<tbody>{slots.map((s, i) => (
|
||||||
|
<tr key={s.designation + i}>
|
||||||
|
<td className="mono small">{s.designation}</td>
|
||||||
|
<td className="mono small">{[s.type, s.length && s.length !== 'Other' ? s.length.toLowerCase() : ''].filter(Boolean).join(' · ')}</td>
|
||||||
|
<td className="small" title={s.bus}>{s.usage === 'In Use' ? (s.device ?? s.bus ?? 'in use') : <span className="muted">{(s.usage ?? '').toLowerCase() || '—'}</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hardware() {
|
||||||
|
const [hw, setHw] = useState<HW | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const load = useCallback(async (refresh = false) => {
|
||||||
|
if (refresh) setBusy(true)
|
||||||
|
try { setHw(await api.hardware(refresh)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy(false) }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (err) return <div className="dash"><div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Hardware tab. Update it from Machines." : err}</div></div>
|
||||||
|
if (!hw) return <div className="dash"><Loading label="Reading the machine…" /></div>
|
||||||
|
|
||||||
|
const { system: sys, board, cpu, memory: mem } = hw
|
||||||
|
const drives = hw.drives ?? []
|
||||||
|
const gpus = hw.gpus ?? []
|
||||||
|
const capacity = drives.reduce((n, d) => n + d.size, 0)
|
||||||
|
const threads = cpu.threads ?? 0
|
||||||
|
const cores = cpu.cores ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dash">
|
||||||
|
<div className="dash-bar">
|
||||||
|
<span className="status"><b>{sys.model || 'this machine'}</b></span>
|
||||||
|
{sys.virt && <span className="badge" title="this is a virtual machine, so the parts below are what the host handed it">{sys.virt}</span>}
|
||||||
|
<span className="spacer" style={{ flex: 1 }} />
|
||||||
|
<Btn className="btn" busy={busy} onClick={() => load(true)}>Re-read</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kpis">
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">Processor</div>
|
||||||
|
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{cpu.model}</div>
|
||||||
|
<div className="sub">{cores ? `${cores} core${cores === 1 ? '' : 's'}` : ''}{threads && threads !== cores ? ` · ${threads} threads` : ''}{cpu.max_mhz ? ` · up to ${ghz(cpu.max_mhz)}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">Memory</div>
|
||||||
|
<div className="value">{fmtBytes(mem.installed || mem.total)}</div>
|
||||||
|
<div className="sub">{mem.slots ? `${mem.filled ?? 0} of ${mem.slots} slot${mem.slots === 1 ? '' : 's'} filled` : 'slots unknown'}{mem.ecc ? ` · ${mem.ecc}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">Storage</div>
|
||||||
|
<div className="value">{fmtBytes(capacity)}</div>
|
||||||
|
<div className="sub">{drives.length} drive{drives.length === 1 ? '' : 's'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">Graphics</div>
|
||||||
|
<div className="value" title={gpus[0]?.model} style={(gpus[0]?.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{gpus[0]?.model ?? 'none found'}</div>
|
||||||
|
<div className="sub">{gpus.length > 1 ? `${gpus.length} adapters` : gpus[0]?.driver ? `${gpus[0].driver} driver` : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(hw.notes ?? []).map((n, i) => <div className="notice" key={i}>{n}</div>)}
|
||||||
|
|
||||||
|
<div className="cards">
|
||||||
|
<section className="card">
|
||||||
|
<h2>Processor</h2>
|
||||||
|
<div className="props">
|
||||||
|
<P k="Model" v={cpu.model} />
|
||||||
|
<P k="Vendor" v={cpu.vendor} />
|
||||||
|
<P k="Cores" v={cores ? `${cores}${cpu.threads_per_core && cpu.threads_per_core > 1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} />
|
||||||
|
<P k="Clock" v={cpu.min_mhz && cpu.max_mhz ? `${ghz(cpu.min_mhz)} – ${ghz(cpu.max_mhz)}` : ghz(cpu.max_mhz)} />
|
||||||
|
<P k="Socket" v={(cpu.socket_names ?? []).join(', ')} />
|
||||||
|
<P k="Arch" v={cpu.arch} />
|
||||||
|
<P k="Family" v={[cpu.family && `family ${cpu.family}`, cpu.model_id && `model ${cpu.model_id}`, cpu.stepping && `stepping ${cpu.stepping}`].filter(Boolean).join(' · ')} />
|
||||||
|
<P k="Microcode" v={cpu.microcode} mono />
|
||||||
|
<P k="Virt" v={cpu.virtualization} />
|
||||||
|
</div>
|
||||||
|
{(cpu.caches ?? []).length > 0 && (
|
||||||
|
<div className="props" style={{ marginTop: 8 }}>
|
||||||
|
{(cpu.caches ?? []).map((c) => <P key={c.level} k={c.level} v={c.size} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(cpu.flags ?? []).length > 0 && (
|
||||||
|
<div className="chips" style={{ marginTop: 8 }}>{(cpu.flags ?? []).map((f) => <span className="badge" key={f}>{f}</span>)}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Memory</h2>
|
||||||
|
<div className="props">
|
||||||
|
<P k="Installed" v={mem.installed ? fmtBytes(mem.installed) : ''} />
|
||||||
|
<P k="Usable" v={fmtBytes(mem.total)} />
|
||||||
|
<P k="Board takes" v={mem.max_capacity ? fmtBytes(mem.max_capacity) : ''} />
|
||||||
|
<P k="ECC" v={mem.ecc} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{(mem.modules ?? []).map((m, i) => <Module key={m.locator + i} m={m} />)}
|
||||||
|
{(mem.modules ?? []).length === 0 && <div className="empty">The firmware doesn't say what is in the slots.</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Drives</h2>
|
||||||
|
{drives.map((d) => <Drive key={d.name} d={d} />)}
|
||||||
|
{drives.length === 0 && <div className="empty">No drives found.</div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>Graphics</h2>
|
||||||
|
{gpus.map((g, i) => <Gpu key={(g.slot ?? '') + i} g={g} />)}
|
||||||
|
{gpus.length === 0 && <div className="empty">No display adapter found.</div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Motherboard b={board} />
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2>System</h2>
|
||||||
|
<div className="props">
|
||||||
|
<P k="Model" v={sys.model} />
|
||||||
|
<P k="Family" v={sys.family} />
|
||||||
|
<P k="Serial" v={sys.serial} mono />
|
||||||
|
<P k="Kernel" v={sys.kernel} mono />
|
||||||
|
<P k="Arch" v={sys.arch} />
|
||||||
|
<P k="Virtual" v={sys.virt} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-16
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { describeCron } from './crontext'
|
||||||
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
||||||
import { Logs } from './Services'
|
import { Logs } from './Services'
|
||||||
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
||||||
@@ -13,22 +14,7 @@ function rel(ms: number): string {
|
|||||||
return s >= 0 ? `in ${txt}` : `${txt} ago`
|
return s >= 0 ? `in ${txt}` : `${txt} ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain-English reading of the common five-field shapes; anything else shows the raw expression.
|
const describe = (expr: string) => describeCron(expr) || expr
|
||||||
function describe(expr: string): string {
|
|
||||||
const alias: Record<string, string> = { '@reboot': 'at boot', '@hourly': 'every hour', '@daily': 'every day at 00:00', '@midnight': 'every day at 00:00', '@weekly': 'every Sunday at 00:00', '@monthly': 'on the 1st at 00:00', '@yearly': 'every 1 Jan', '@annually': 'every 1 Jan' }
|
|
||||||
if (alias[expr]) return alias[expr]
|
|
||||||
const f = expr.split(/\s+/)
|
|
||||||
if (f.length !== 5) return expr
|
|
||||||
const [mi, h, dom, mon, dow] = f
|
|
||||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
|
||||||
const at = /^\d+$/.test(mi) && /^\d+$/.test(h) ? `at ${h.padStart(2, '0')}:${mi.padStart(2, '0')}` : /^\d+$/.test(mi) && h === '*' ? `every hour at :${mi.padStart(2, '0')}` : mi.startsWith('*/') && h === '*' ? `every ${mi.slice(2)} min` : null
|
|
||||||
if (!at) return expr
|
|
||||||
const dowTxt = dow === '*' ? '' : /^\d$/.test(dow) ? `every ${days[+dow]}` : `on days ${dow}`
|
|
||||||
const domTxt = dom === '*' ? '' : /^\d+$/.test(dom) ? `on the ${dom}${['st', 'nd', 'rd'][+dom - 1] ?? 'th'}` : `on days ${dom}`
|
|
||||||
const monTxt = mon === '*' ? '' : `in month ${mon}`
|
|
||||||
const when = [dowTxt, domTxt, monTxt].filter(Boolean).join(', ')
|
|
||||||
return (when ? `${when} ` : dow === '*' && dom === '*' && !at.startsWith('every') ? 'every day ' : '') + at
|
|
||||||
}
|
|
||||||
|
|
||||||
const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s)
|
const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s)
|
||||||
|
|
||||||
|
|||||||
+27
-2
@@ -2,6 +2,19 @@ import { FormEvent, useState } from 'react'
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import { Spinner } from './ui'
|
import { Spinner } from './ui'
|
||||||
|
|
||||||
|
// Drawn, not typed: 👁 is an emoji on phones, full colour and its own size. Outline strokes
|
||||||
|
// match the glyphs in the rail and the power button.
|
||||||
|
const EyeIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M1.2 8S3.9 3.6 8 3.6 14.8 8 14.8 8 12.1 12.4 8 12.4 1.2 8 1.2 8z" /><circle cx="8" cy="8" r="2.1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const EyeOffIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M1.2 8S3.9 3.6 8 3.6 14.8 8 14.8 8 12.1 12.4 8 12.4 1.2 8 1.2 8z" /><circle cx="8" cy="8" r="2.1" /><path d="M2.4 13.6 13.6 2.4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again
|
const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again
|
||||||
|
|
||||||
export default function Login({ onLogin }: { onLogin: () => void }) {
|
export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||||
@@ -9,6 +22,9 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
|||||||
const [pw, setPw] = useState('')
|
const [pw, setPw] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
// Shown on request: a generated password is typed by hand off a message, and a wrong
|
||||||
|
// character looks exactly like a wrong password from here.
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
async function submit(e: FormEvent) {
|
async function submit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -26,8 +42,17 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
|||||||
<img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} />
|
<img className="login-logo" src="/icons/logo.png" alt="Ham Lab" width={176} height={176} />
|
||||||
<input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off"
|
<input type="text" autoFocus={!user} autoComplete="username" placeholder="user" spellCheck={false} autoCapitalize="off" autoCorrect="off"
|
||||||
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
|
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
|
||||||
<input type="password" autoFocus={!!user} autoComplete="current-password" placeholder="password"
|
<div className="pwfield">
|
||||||
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
|
{/* revealed, the field is a text field, so the keyboard would otherwise offer to
|
||||||
|
capitalise and correct what is typed into it */}
|
||||||
|
<input type={show ? 'text' : 'password'} autoFocus={!!user} autoComplete="current-password" placeholder="password"
|
||||||
|
spellCheck={false} autoCapitalize="off" autoCorrect="off"
|
||||||
|
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
|
||||||
|
<button type="button" className="pweye" onClick={() => setShow(!show)} disabled={busy}
|
||||||
|
aria-label={show ? 'hide the password' : 'show the password'} aria-pressed={show} title={show ? 'hide' : 'show'}>
|
||||||
|
{show ? EyeOffIcon : EyeIcon}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
|
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||||
{err && <div className="error">{err}</div>}
|
{err && <div className="error">{err}</div>}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Network: how this machine reaches the internet, and how fast the line actually is.
|
||||||
|
//
|
||||||
|
// The speed test runs on the machine in the switcher, not in this browser — so it measures
|
||||||
|
// that machine's line, and it keeps running if the phone is locked or the tab is closed. What
|
||||||
|
// is drawn here is what the daemon reports about a test it is running itself.
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { api, Iface, NetworkInfo, SpeedResult, SpeedRun } from './api'
|
||||||
|
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
||||||
|
import { fmtBytes } from './fmt'
|
||||||
|
import { Btn, Loading, toast } from './ui'
|
||||||
|
import { useRefreshMs } from './prefs'
|
||||||
|
import Sparkline from './Sparkline'
|
||||||
|
|
||||||
|
// Docker's per-network bridges, and the veth ends inside them, are real interfaces and
|
||||||
|
// nobody wants to read nineteen of them. They fold away unless asked for.
|
||||||
|
const scenery = (f: Iface) =>
|
||||||
|
!f.default && (/^br-[0-9a-f]{12}$/.test(f.name) || /^(veth|docker)/.test(f.name) || f.kind === 'loopback')
|
||||||
|
|
||||||
|
const KIND: Record<string, string> = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi', wireguard: 'VPN tunnel', none: 'tunnel' }
|
||||||
|
|
||||||
|
// A WireGuard interface is listed here because it is an interface and carries traffic, but
|
||||||
|
// everything about it that is worth knowing — who is on it, when they last spoke — belongs to
|
||||||
|
// the VPN tab, and is only there.
|
||||||
|
const isTunnel = (f: Iface) => f.kind === 'wireguard' || /^(wg|tun|tap)[0-9]/.test(f.name)
|
||||||
|
const goVPN = () => window.dispatchEvent(new CustomEvent('hamlab:view', { detail: 'vpn' }))
|
||||||
|
|
||||||
|
// Where the bytes came from. The usual source is anycast and its edge is the interesting
|
||||||
|
// part; a fallback source is somewhere fixed, and then *which* one is the interesting part,
|
||||||
|
// because a mirror far away reads low and the figure should not pretend otherwise.
|
||||||
|
const where = (r: SpeedResult) =>
|
||||||
|
r.source && r.source !== 'cloudflare' ? ` from ${r.source}` : r.colo ? ` via ${r.colo}` : ''
|
||||||
|
|
||||||
|
// A round number of megabits, which is how a line is sold and how it should be read.
|
||||||
|
const mbps = (v: number) => (v >= 100 ? v.toFixed(0) : v.toFixed(1))
|
||||||
|
const ago = (iso: string) => {
|
||||||
|
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
|
if (s < 60) return 'just now'
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)} min ago`
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
|
||||||
|
return `${Math.floor(s / 86400)} d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four figures a speed test produces, big enough to read from across the room.
|
||||||
|
function Figures({ r, live }: { r?: SpeedResult; live?: SpeedRun }) {
|
||||||
|
const running = !!live?.running
|
||||||
|
const phase = live?.phase
|
||||||
|
const downNow = running && phase === 'download' ? live!.mbps : undefined
|
||||||
|
const upNow = running && phase === 'upload' ? live!.mbps : undefined
|
||||||
|
return (
|
||||||
|
<div className="speedgrid">
|
||||||
|
<Figure label="Download" unit="Mbps" value={downNow ?? r?.down_mbps} live={downNow !== undefined} waiting={running && phase === 'latency'} />
|
||||||
|
<Figure label="Upload" unit="Mbps" value={upNow ?? r?.up_mbps} live={upNow !== undefined} waiting={running && phase !== 'upload'} />
|
||||||
|
<Figure label="Latency" unit="ms" value={r?.latency_ms} waiting={running && phase === 'latency'} />
|
||||||
|
<Figure label="Jitter" unit="ms" value={r?.jitter_ms} waiting={running && phase === 'latency'} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Figure({ label, unit, value, live, waiting }: { label: string; unit: string; value?: number; live?: boolean; waiting?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={'speedfig' + (live ? ' live' : '')}>
|
||||||
|
<div className="label">{label}</div>
|
||||||
|
<div className="big">{value === undefined || value === null ? (waiting ? '·' : '—') : mbps(value)}<i>{unit}</i></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Network() {
|
||||||
|
const [d, setD] = useState<NetworkInfo | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [all, setAll] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const rate = useRefreshMs()
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setD(await api.network()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
const run = d?.run
|
||||||
|
const running = !!run?.running
|
||||||
|
// While a test is running the state is polled hard, because the needle should move; at rest
|
||||||
|
// the page is a read and asks again only now and then.
|
||||||
|
useEffect(() => {
|
||||||
|
const every = running ? Math.max(400, Math.min(rate, 1000)) : Math.max(5000, rate)
|
||||||
|
const t = setInterval(() => {
|
||||||
|
if (document.visibilityState !== 'visible') return
|
||||||
|
if (running) api.speedtest().then((r) => setD((old) => (old ? { ...old, run: r } : old))).catch(() => {})
|
||||||
|
else load()
|
||||||
|
}, every)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [running, rate, load])
|
||||||
|
// The run that just ended brings a result and a history entry with it.
|
||||||
|
const phase = run?.phase
|
||||||
|
useEffect(() => { if (phase === 'done') load() }, [phase, load])
|
||||||
|
|
||||||
|
const live = useSensorStream<{ n: Record<string, Sensor> }>({ n: 'net.*' }, { everyMs: Math.max(1000, rate) })
|
||||||
|
const sens = live.data?.n ?? {}
|
||||||
|
const rateOf = (name: string, dir: 'rx' | 'tx') => sens[`net.${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '_')}.${dir}`]?.value
|
||||||
|
|
||||||
|
const ifaces = d?.report.ifaces ?? []
|
||||||
|
const shown = all ? ifaces : ifaces.filter((f) => !scenery(f))
|
||||||
|
const hidden = ifaces.length - shown.length
|
||||||
|
const hist = useMemo(() => (d?.history ?? []).filter((r) => !r.error), [d])
|
||||||
|
const last = run?.result && !run.result.error ? run.result : hist[hist.length - 1]
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try { const r = await api.startSpeedtest(); setD((old) => (old ? { ...old, run: r } : old)) }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const cancel = async () => {
|
||||||
|
try { await api.cancelSpeedtest(); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Network tab. Update it from Machines." : err}</div> : <Loading label="Reading the network…" />}</div>
|
||||||
|
const o = d.outside
|
||||||
|
const failed = run?.result?.error
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dash net">
|
||||||
|
<div className="dash-bar"><h2 className="section">Internet speed</h2><span className="spacer" />
|
||||||
|
<span className="muted small">{last ? `measured ${ago(last.at)}` : 'not measured yet'}</span></div>
|
||||||
|
<section className="card speedtest">
|
||||||
|
<Figures r={last} live={run} />
|
||||||
|
{running && (
|
||||||
|
<div className="speedbar">
|
||||||
|
<div className="meter"><div className="fill" style={{ width: `${Math.round((run!.done || 0) * 100)}%` }} /></div>
|
||||||
|
<span className="muted small">{run!.phase === 'latency' ? 'measuring the round trip' : run!.phase === 'download' ? 'pulling down' : 'pushing up'} · {fmtBytes(run!.bytes || 0)} moved</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="svc-actions">
|
||||||
|
{running
|
||||||
|
? <><Btn className="btn danger" onClick={cancel}>Stop</Btn><span className="muted small">running on {d.report.host} — it carries on if you close this</span></>
|
||||||
|
: <><Btn className="btn on" busy={busy} onClick={start}>Run speed test</Btn>
|
||||||
|
<span className="muted small">{last ? `${fmtBytes((last.down_bytes ?? 0) + (last.up_bytes ?? 0))} moved in ${last.took_sec}s${where(last)}` : 'about ten seconds'}</span></>}
|
||||||
|
</div>
|
||||||
|
{failed && <div className="error">{failed === 'cancelled' ? 'Stopped.' : failed}</div>}
|
||||||
|
{!failed && last?.note && <div className="notice">{last.note}</div>}
|
||||||
|
<div className="muted small method">{d.method}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{hist.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar"><h2 className="section">Past tests</h2><span className="spacer" /><span className="muted small">{hist.length} kept</span></div>
|
||||||
|
<section className="card">
|
||||||
|
<Sparkline height={60} format={(v) => `${mbps(v)} Mbps`}
|
||||||
|
series={[{ name: 'down', values: hist.map((r) => r.down_mbps), color: 'var(--s1)' },
|
||||||
|
{ name: 'up', values: hist.map((r) => r.up_mbps), color: 'var(--s2)' }]} />
|
||||||
|
<div className="props" style={{ marginTop: 8 }}>
|
||||||
|
{[...hist].reverse().slice(0, 8).map((r) => (
|
||||||
|
<div key={r.at}><span className="k">{new Date(r.at).toLocaleString()}</span>
|
||||||
|
<span className="mono">↓ {mbps(r.down_mbps)} · ↑ {mbps(r.up_mbps)} Mbps · {r.latency_ms} ms</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dash-bar"><h2 className="section">Out there</h2></div>
|
||||||
|
<section className="card">
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">Public address</span><span className="mono">{o.ip || '—'}{o.error ? ` · ${o.error}` : ''}</span></div>
|
||||||
|
{o.colo && <div><span className="k">Reached via</span><span>{o.colo}{o.country ? ` · ${o.country}` : ''}</span></div>}
|
||||||
|
{d.report.gateway && <div><span className="k">Gateway</span><span className="mono">{d.report.gateway}{d.report.gateway_via ? ` via ${d.report.gateway_via}` : ''}</span></div>}
|
||||||
|
{!!d.report.dns?.length && <div><span className="k">DNS</span><span className="mono">{d.report.dns.join(', ')}</span></div>}
|
||||||
|
{!!d.report.search?.length && <div><span className="k">Search</span><span className="mono">{d.report.search.join(' ')}</span></div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="dash-bar"><h2 className="section">Interfaces</h2><span className="spacer" />
|
||||||
|
{hidden > 0 && <button className="link" onClick={() => setAll(true)}>show {hidden} more</button>}
|
||||||
|
{all && <button className="link" onClick={() => setAll(false)}>hide the scenery</button>}</div>
|
||||||
|
{shown.map((f) => {
|
||||||
|
const rx = rateOf(f.name, 'rx'), tx = rateOf(f.name, 'tx')
|
||||||
|
return (
|
||||||
|
<section className="card iface" key={f.name}>
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className="dname mono">{f.name}</span>
|
||||||
|
{f.default && <span className="badge ok" title="the default route leaves by this one">internet</span>}
|
||||||
|
<span className="badge">{KIND[f.kind] ?? f.kind}</span>
|
||||||
|
{isTunnel(f) && <button className="link" onClick={goVPN} title="who is on this tunnel">peers →</button>}
|
||||||
|
{!f.up ? <span className="badge bad">down</span> : !f.carrier && <span className="badge bad">no link</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small mono">{typeof rx === 'number' ? `↓ ${fmtValue(rx, 'B/s')}` : ''} {typeof tx === 'number' ? `↑ ${fmtValue(tx, 'B/s')}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="props">
|
||||||
|
{f.addrs.length > 0 && <div><span className="k">Address</span><span className="mono">{f.addrs.filter((a) => !a.v6).map((a) => `${a.ip}/${a.prefix}`).join(', ') || '—'}</span></div>}
|
||||||
|
{f.addrs.some((a) => a.v6) && <div><span className="k">IPv6</span><span className="mono" title={f.addrs.filter((a) => a.v6).map((a) => `${a.ip}/${a.prefix}`).join('\n')}>{f.addrs.filter((a) => a.v6).length} addresses</span></div>}
|
||||||
|
{!!f.speed_mbps && <div><span className="k">Link</span><span>{f.speed_mbps >= 1000 ? `${f.speed_mbps / 1000} Gbps` : `${f.speed_mbps} Mbps`}{f.duplex ? ` ${f.duplex} duplex` : ''}</span></div>}
|
||||||
|
{f.signal_dbm !== undefined && <div><span className="k">Signal</span><span>{f.signal_dbm} dBm</span></div>}
|
||||||
|
{f.mac && <div><span className="k">MAC</span><span className="mono">{f.mac}</span></div>}
|
||||||
|
<div><span className="k">MTU</span><span>{f.mtu}{f.driver ? ` · ${f.driver}` : ''}</span></div>
|
||||||
|
<div><span className="k">Moved</span><span className="mono">↓ {fmtBytes(f.rx_total)} · ↑ {fmtBytes(f.tx_total)}</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+60
-4
@@ -11,6 +11,9 @@ import Services from './Services'
|
|||||||
import Jobs from './Jobs'
|
import Jobs from './Jobs'
|
||||||
import Updates from './Updates'
|
import Updates from './Updates'
|
||||||
import Disks from './Disks'
|
import Disks from './Disks'
|
||||||
|
import Hardware from './Hardware'
|
||||||
|
import Network from './Network'
|
||||||
|
import VPN from './VPN'
|
||||||
import ZFS from './ZFS'
|
import ZFS from './ZFS'
|
||||||
import KVM from './KVM'
|
import KVM from './KVM'
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
@@ -24,7 +27,8 @@ import { MachineStatus, setMachine } from './api'
|
|||||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||||
import Power from './Power'
|
import Power from './Power'
|
||||||
import { applyAccent } from './accents'
|
import { applyAccent } from './accents'
|
||||||
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
||||||
|
import { fmtLeft, useTimeLeft } from './guest'
|
||||||
|
|
||||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||||
const FolderIcon = (
|
const FolderIcon = (
|
||||||
@@ -51,6 +55,20 @@ const BellIcon = (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const GlobeIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<circle cx="8" cy="8" r="6.2" /><path d="M1.9 8h12.2M8 1.8c1.7 1.8 2.6 3.9 2.6 6.2S9.7 12.4 8 14.2C6.3 12.4 5.4 10.3 5.4 8S6.3 3.6 8 1.8z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// A padlock-ish shield: the tab is about who may come in, and a keyhole reads at 14px where
|
||||||
|
// a padlock's shackle does not.
|
||||||
|
const ShieldIcon = (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M8 1.6 13.2 3.5v4.2c0 3-2.1 5.4-5.2 6.7-3.1-1.3-5.2-3.7-5.2-6.7V3.5z" /><circle cx="8" cy="7.2" r="1.3" /><path d="M8 8.5v2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'procs', label: 'Processes', icon: '▚' },
|
{ id: 'procs', label: 'Processes', icon: '▚' },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
@@ -58,10 +76,13 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
||||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||||
|
{ id: 'network', label: 'Network', icon: GlobeIcon },
|
||||||
|
{ id: 'vpn', label: 'VPN', icon: ShieldIcon },
|
||||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||||
|
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
||||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||||
@@ -70,8 +91,8 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||||
|
|
||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
@@ -102,6 +123,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||||
const [remoteErr, setRemoteErr] = useState('')
|
const [remoteErr, setRemoteErr] = useState('')
|
||||||
|
// A guest login is an account with a clock on it: nothing is hidden from it, but the header
|
||||||
|
// says how long it has, for whoever is holding the phone.
|
||||||
|
const leftMs = useTimeLeft(hubMe.guest_until)
|
||||||
// A machine is titled by its own hostname; the name it was added under stays its identity
|
// A machine is titled by its own hostname; the name it was added under stays its identity
|
||||||
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
||||||
const title = titleOf(machine, machines, hubMe.host)
|
const title = titleOf(machine, machines, hubMe.host)
|
||||||
@@ -125,6 +149,32 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
|
|
||||||
useEffect(() => { reload() }, [reload])
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
|
// One page sending you to another — the Network tab handing a tunnel over to the VPN tab.
|
||||||
|
// A custom event rather than a hash assignment, because assigning location.hash does not
|
||||||
|
// reliably raise popstate, which is what this shell navigates by.
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: Event) => {
|
||||||
|
const id = (e as CustomEvent<string>).detail
|
||||||
|
if (VIEWS.includes(id as View)) { setView(id as View); setDrawer(false) }
|
||||||
|
}
|
||||||
|
window.addEventListener('hamlab:view', h)
|
||||||
|
return () => window.removeEventListener('hamlab:view', h)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// A shell opened inside a container from the Docker tab is a terminal like any other:
|
||||||
|
// pick it up, show the Terminal tab, and make it the active one.
|
||||||
|
useEffect(() => {
|
||||||
|
const h = async (e: Event) => {
|
||||||
|
const id = (e as CustomEvent<string>).detail
|
||||||
|
if (!id) return
|
||||||
|
await reload()
|
||||||
|
setActive(id)
|
||||||
|
setView('terminal')
|
||||||
|
}
|
||||||
|
window.addEventListener('hamlab:terminal', h)
|
||||||
|
return () => window.removeEventListener('hamlab:terminal', h)
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
// Machine list for the switcher (hub-local), refreshed while the console is open.
|
// Machine list for the switcher (hub-local), refreshed while the console is open.
|
||||||
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
|
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
|
||||||
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
||||||
@@ -258,6 +308,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
|
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
|
||||||
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
||||||
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
|
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
|
||||||
|
{hubMe.guest && <span className="badge guest" title={`this guest login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
|
||||||
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
||||||
<Btn className="link" onClick={logout}>logout</Btn>
|
<Btn className="link" onClick={logout}>logout</Btn>
|
||||||
</header>
|
</header>
|
||||||
@@ -269,6 +320,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||||
|
|
||||||
<main className="main" key={machine ?? 'hub'}>
|
<main className="main" key={machine ?? 'hub'}>
|
||||||
|
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
||||||
{view === 'settings' && <Settings me={hubMe} />}
|
{view === 'settings' && <Settings me={hubMe} />}
|
||||||
{view === 'users' && <Users me={hubMe} />}
|
{view === 'users' && <Users me={hubMe} />}
|
||||||
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
||||||
@@ -282,8 +334,12 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
{view === 'jobs' && <Jobs />}
|
{view === 'jobs' && <Jobs />}
|
||||||
{view === 'updates' && <Updates />}
|
{view === 'updates' && <Updates />}
|
||||||
{view === 'disks' && <Disks />}
|
{view === 'disks' && <Disks />}
|
||||||
|
{view === 'hardware' && <Hardware />}
|
||||||
|
{view === 'network' && <Network />}
|
||||||
|
{view === 'vpn' && <VPN />}
|
||||||
{view === 'zfs' && <ZFS />}
|
{view === 'zfs' && <ZFS />}
|
||||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||||
|
</PageBoundary>
|
||||||
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||||
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
onSelect={setActive} onNew={create} onRename={rename} onClose={close} onMove={moveTab}
|
||||||
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
|
onFocusTerm={() => window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />}
|
||||||
@@ -309,7 +365,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+80
-1
@@ -3,19 +3,98 @@
|
|||||||
// a token rather than a login, so a node's accounts are managed on that node's console.
|
// a token rather than a login, so a node's accounts are managed on that node's console.
|
||||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { api, Me, Session, User } from './api'
|
import { api, FreshGuest, Guest, Me, Session, User } from './api'
|
||||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||||
|
import { fmtLeft } from './guest'
|
||||||
|
|
||||||
export default function Users({ me }: { me: Me }) {
|
export default function Users({ me }: { me: Me }) {
|
||||||
return (
|
return (
|
||||||
<div className="dash settings">
|
<div className="dash settings">
|
||||||
<Account me={me} />
|
<Account me={me} />
|
||||||
<Sessions />
|
<Sessions />
|
||||||
|
<Guests />
|
||||||
<Accounts me={me} />
|
<Accounts me={me} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GUEST_MINUTES = [15, 30, 60, 120, 480] as const
|
||||||
|
|
||||||
|
// A login to hand somebody you are showing the console to: an ordinary account with a clock
|
||||||
|
// on it, which expires on its own and can be ended here at any moment. Making one is the only
|
||||||
|
// time its password is readable, so it is shown until dismissed rather than in a toast.
|
||||||
|
function Guests() {
|
||||||
|
const [list, setList] = useState<Guest[] | null>(null)
|
||||||
|
const [minutes, setMinutes] = useState<number>(30)
|
||||||
|
const [fresh, setFresh] = useState<FreshGuest | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const load = useCallback(async () => { try { setList(await api.guests()) } catch { setList([]) } }, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
// The clock is the point of the thing, so it keeps up without a reload.
|
||||||
|
useEffect(() => { const t = setInterval(load, 30000); return () => clearInterval(t) }, [load])
|
||||||
|
const create = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try { setFresh(await api.createGuest(minutes)); load() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const revoke = async (g: Guest) => {
|
||||||
|
if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once, everywhere.`, 'Revoke')) return
|
||||||
|
try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can be revoked</span></div>
|
||||||
|
<section className="card">
|
||||||
|
{list === null && <Loading label="Loading…" />}
|
||||||
|
{list?.length === 0 && <div className="empty">None. One lets somebody in for a while without giving them an account.</div>}
|
||||||
|
{list?.map((g) => {
|
||||||
|
const left = new Date(g.expires).getTime() - Date.now()
|
||||||
|
return (
|
||||||
|
<div className="row" key={g.name}>
|
||||||
|
<span className="k mono">{g.name}</span>
|
||||||
|
<span className="muted small">{left > 0 ? `expires in ${fmtLeft(left)}` : 'expiring'} · {g.sessions > 0 ? `${g.sessions} signed in` : g.used ? 'signed in earlier' : 'not used yet'}{g.issued_by ? ` · by ${g.issued_by}` : ''}</span>
|
||||||
|
<Btn className="btn danger" onClick={() => revoke(g)}>Revoke</Btn>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
{fresh && <FreshCard g={fresh} onClose={() => setFresh(null)} />}
|
||||||
|
<div className="card addform" style={{ marginTop: 8 }}>
|
||||||
|
<label>Lasts
|
||||||
|
<select value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
|
||||||
|
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A guest can do everything you can, on every machine — including the terminal — until the time is up or you revoke it. Give one out accordingly.</span></div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The credentials, once. Copy takes the whole invitation, because that is what gets pasted
|
||||||
|
// into a message.
|
||||||
|
function FreshCard({ g, onClose }: { g: FreshGuest; onClose: () => void }) {
|
||||||
|
const invite = `${location.origin}\nuser: ${g.name}\npassword: ${g.password}\nexpires ${new Date(g.expires).toLocaleTimeString()}`
|
||||||
|
const copy = async (text: string, what: string) => {
|
||||||
|
try { await navigator.clipboard.writeText(text); toast(`${what} copied`) }
|
||||||
|
catch { toast('select and copy it by hand', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="card fresh" style={{ marginTop: 8 }}>
|
||||||
|
<div className="row"><span className="k">User</span><span className="mono">{g.name}</span><Btn className="btn" onClick={() => copy(g.name, 'user')}>Copy</Btn></div>
|
||||||
|
<div className="row"><span className="k">Password</span><span className="mono">{g.password}</span><Btn className="btn" onClick={() => copy(g.password, 'password')}>Copy</Btn></div>
|
||||||
|
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||||
|
<Btn className="btn on" onClick={() => copy(invite, 'invitation')}>Copy invitation</Btn>
|
||||||
|
<span className="muted small">Shown once. Expires {new Date(g.expires).toLocaleTimeString()}.</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn" onClick={onClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Account({ me }: { me: Me }) {
|
function Account({ me }: { me: Me }) {
|
||||||
const [cur, setCur] = useState('')
|
const [cur, setCur] = useState('')
|
||||||
const [pw1, setPw1] = useState('')
|
const [pw1, setPw1] = useState('')
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
// VPN: this machine's WireGuard tunnels, and the devices on them.
|
||||||
|
//
|
||||||
|
// Per machine, like every other tab, which here is the whole point: the tunnel people dial
|
||||||
|
// into and a tunnel this machine dials out through are different machines, and showing them
|
||||||
|
// together would invite the question "which box is this actually on".
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { api, VPNReport, VPNPeer, VPNTunnel } from './api'
|
||||||
|
import { fmtBytes } from './fmt'
|
||||||
|
import { Loading } from './ui'
|
||||||
|
import { useRefreshMs } from './prefs'
|
||||||
|
|
||||||
|
// WireGuard says nothing when it has nothing to say, so "online" is a handshake in the last
|
||||||
|
// few minutes rather than a connection — there is no connection to have.
|
||||||
|
const ago = (iso?: string) => {
|
||||||
|
if (!iso) return 'never'
|
||||||
|
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
|
||||||
|
if (s < 60) return `${Math.floor(s)}s ago`
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)} min ago`
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)} h ago`
|
||||||
|
return `${Math.floor(s / 86400)} d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A public key is long and nobody reads the middle of it; the ends are what you compare.
|
||||||
|
const shortKey = (k: string) => (k.length > 16 ? `${k.slice(0, 8)}…${k.slice(-6)}` : k)
|
||||||
|
|
||||||
|
function Peer({ p, server }: { p: VPNPeer; server: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="card iface vpn-peer">
|
||||||
|
<div className="sub-head">
|
||||||
|
<span className={'sdot ' + (p.online ? 'ok' : p.handshake ? '' : 'bad')} />
|
||||||
|
<span className="dname">{p.name || <span className="muted">unnamed</span>}</span>
|
||||||
|
{p.online && <span className="badge ok">online</span>}
|
||||||
|
{!p.handshake && <span className="badge">never connected</span>}
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small mono">{(p.allowed_ips ?? []).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="props">
|
||||||
|
<div><span className="k">Last handshake</span><span>{ago(p.handshake)}</span></div>
|
||||||
|
{p.endpoint && <div><span className="k">{server ? 'Comes from' : 'Endpoint'}</span><span className="mono">{p.endpoint}</span></div>}
|
||||||
|
<div><span className="k">Moved</span><span className="mono">↓ {fmtBytes(p.rx)} · ↑ {fmtBytes(p.tx)}</span></div>
|
||||||
|
{!!p.keepalive && <div><span className="k">Keepalive</span><span>every {p.keepalive}s</span></div>}
|
||||||
|
<div><span className="k">Public key</span><span className="mono" title={p.public_key}>{shortKey(p.public_key)}</span></div>
|
||||||
|
{p.psk && <div><span className="k">Pre-shared key</span><span>in use</span></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tunnel({ t }: { t: VPNTunnel }) {
|
||||||
|
const server = t.kind === 'server'
|
||||||
|
const peers = t.peers ?? []
|
||||||
|
const online = peers.filter((p) => p.online).length
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar">
|
||||||
|
<h2 className="section">{t.name}</h2>
|
||||||
|
<span className="badge">{server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="muted small">{server ? `${online} of ${peers.length} online` : t.exit ? `via ${t.exit}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<section className="card">
|
||||||
|
<div className="props">
|
||||||
|
{!!t.addrs?.length && <div><span className="k">Address</span><span className="mono">{t.addrs.join(', ')}</span></div>}
|
||||||
|
{!!t.listen_port && <div><span className="k">Listening on</span><span className="mono">{t.listen_port}/udp</span></div>}
|
||||||
|
{!!t.mtu && <div><span className="k">MTU</span><span>{t.mtu}</span></div>}
|
||||||
|
<div><span className="k">Public key</span><span className="mono" title={t.public_key}>{shortKey(t.public_key)}</span></div>
|
||||||
|
{t.fwmark && <div><span className="k">Firewall mark</span><span className="mono">{t.fwmark}</span></div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{peers.map((p) => <Peer key={p.public_key} p={p} server={server} />)}
|
||||||
|
{peers.length === 0 && <section className="card"><div className="empty">No peers are configured on this tunnel.</div></section>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VPN() {
|
||||||
|
const [d, setD] = useState<VPNReport | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const rate = useRefreshMs()
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { setD(await api.vpn()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
// Handshakes and counters move; nothing here is expensive to read.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate))
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [load, rate])
|
||||||
|
|
||||||
|
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the VPN tab. Update it from Machines." : err}</div> : <Loading label="Reading the tunnels…" />}</div>
|
||||||
|
|
||||||
|
// A machine running an older build answers with null rather than an empty list, and taking
|
||||||
|
// the length of that is what a blank page is made of.
|
||||||
|
const tunnels = d.tunnels ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dash net">
|
||||||
|
{tunnels.length === 0 && (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar"><h2 className="section">VPN</h2></div>
|
||||||
|
<section className="card"><div className="empty">{d.note || 'Nothing here.'}</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 6 }}>
|
||||||
|
Tunnels are the machine's own, so look on the one that carries them — the switcher at the top of the rail.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tunnels.map((t) => <Tunnel key={t.name} t={t} />)}
|
||||||
|
{tunnels.length > 0 && (
|
||||||
|
<div className="muted small method" style={{ marginTop: 12 }}>
|
||||||
|
Read from the kernel with <span className="mono">wg show</span>; names come from the configuration beside it.
|
||||||
|
Online means a handshake in the last three minutes — WireGuard is silent when idle, so there is no connection to be in.
|
||||||
|
Private and pre-shared keys are never read.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+90
-3
@@ -55,7 +55,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
||||||
export interface Me { addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
|
export interface Me { addr?: string; host: string; user: string; account?: string; guest?: boolean; guest_until?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
|
||||||
export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
|
export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
|
||||||
// What a machine is called on screen: the hostname it reports, which follows a rename within
|
// What a machine is called on screen: the hostname it reports, which follows a rename within
|
||||||
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
|
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
|
||||||
@@ -109,6 +109,39 @@ export interface StorageDevice { name: string; kind: string; model?: string; ven
|
|||||||
export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean }
|
export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean }
|
||||||
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number }
|
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number }
|
||||||
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
|
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
|
||||||
|
export interface UpdateJob { id: string; name: string; stack: string; machine?: string; services?: string[]; cron: string; apply: boolean; enabled: boolean }
|
||||||
|
export interface UpdateJobRow { job: UpdateJob; next?: string; last?: { at?: string; taken?: string; error?: string } }
|
||||||
|
export interface MachineUpdates { images: ImageCheck[]; checked: number; stacks: Record<string, string[]> }
|
||||||
|
|
||||||
|
export interface DockerHealthProbe { Start: string; End: string; ExitCode: number; Output: string }
|
||||||
|
export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] }
|
||||||
|
export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string }
|
||||||
|
|
||||||
|
// ---- hardware (the Hardware tab: what the machine is made of) ----
|
||||||
|
export interface HWSystem { model?: string; serial?: string; family?: string; kernel?: string; arch?: string; virt?: string }
|
||||||
|
export interface HWSlot { designation: string; type?: string; usage?: string; length?: string; width?: string; bus?: string; device?: string }
|
||||||
|
export interface HWBoard {
|
||||||
|
maker?: string; product?: string; version?: string; serial?: string; asset_tag?: string; type?: string; location?: string
|
||||||
|
chassis?: string; chassis_maker?: string; bios?: string; bios_date?: string; bios_revision?: string; rom_size?: string
|
||||||
|
uefi?: boolean; slots?: HWSlot[]
|
||||||
|
}
|
||||||
|
export interface HWCache { level: string; size: string }
|
||||||
|
export interface HWCPU {
|
||||||
|
model?: string; vendor?: string; arch?: string; sockets?: number; socket_names?: string[]
|
||||||
|
cores?: number; threads?: number; threads_per_core?: number; min_mhz?: number; max_mhz?: number
|
||||||
|
family?: string; model_id?: string; stepping?: string; microcode?: string; virtualization?: string
|
||||||
|
caches?: HWCache[]; flags?: string[]
|
||||||
|
}
|
||||||
|
export interface HWModule {
|
||||||
|
locator: string; bank?: string; empty?: boolean; size?: number; type?: string; form_factor?: string
|
||||||
|
speed_mts?: number; running_mts?: number; maker?: string; part?: string; serial?: string; rank?: number; voltage?: string
|
||||||
|
}
|
||||||
|
export interface HWMemory { total: number; installed?: number; max_capacity?: number; slots?: number; filled?: number; ecc?: string; modules?: HWModule[] }
|
||||||
|
export interface HWDrive { name: string; kind: string; model?: string; vendor?: string; serial?: string; firmware?: string; tran?: string; size: number; rotational?: boolean; removable?: boolean }
|
||||||
|
export interface HWGPU { model?: string; vendor?: string; slot?: string; driver?: string; node?: string; memory?: number; class?: string }
|
||||||
|
export interface Hardware { at: string; system: HWSystem; board: HWBoard; cpu: HWCPU; memory: HWMemory; drives: HWDrive[] | null; gpus: HWGPU[] | null; notes?: string[] }
|
||||||
|
|
||||||
|
export interface DockerLogLine { t: string; m: string; err?: boolean }
|
||||||
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
|
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
|
||||||
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
|
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
|
||||||
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
|
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
|
||||||
@@ -125,6 +158,9 @@ export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record<
|
|||||||
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
|
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
|
||||||
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
|
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
|
||||||
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
|
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
|
||||||
|
// A guest login: a full account with a clock on it, gone at `expires` or when revoked.
|
||||||
|
export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean }
|
||||||
|
export interface FreshGuest { name: string; password: string; expires: string; minutes: number }
|
||||||
|
|
||||||
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
|
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
|
||||||
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
|
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
|
||||||
@@ -135,6 +171,37 @@ export interface SpaceFile { path: string; size: number; apparent: number; mtime
|
|||||||
|
|
||||||
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
||||||
|
|
||||||
|
// ---- the Network tab ----
|
||||||
|
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
||||||
|
export interface Iface {
|
||||||
|
name: string; kind: string; mac?: string; mtu: number; up: boolean; carrier: boolean
|
||||||
|
speed_mbps?: number; duplex?: string; driver?: string; signal_dbm?: number
|
||||||
|
addrs: IfAddr[]; rx_total: number; tx_total: number; default?: boolean
|
||||||
|
}
|
||||||
|
export interface NetReport { at: string; host?: string; ifaces: Iface[]; gateway?: string; gateway_via?: string; dns?: string[]; search?: string[] }
|
||||||
|
export interface Outside { at: string; ip?: string; colo?: string; country?: string; error?: string }
|
||||||
|
export interface SpeedResult {
|
||||||
|
at: string; down_mbps: number; up_mbps: number; latency_ms: number; jitter_ms: number
|
||||||
|
down_bytes: number; up_bytes: number; took_sec: number; colo?: string; ip?: string; source?: string; note?: string; error?: string
|
||||||
|
}
|
||||||
|
// A test in progress, or the last one to have finished. `done` is 0–1 across the whole test
|
||||||
|
// and `mbps` is the live figure for the phase under way.
|
||||||
|
export interface SpeedRun { started: string; running: boolean; phase: string; done: number; mbps: number; bytes: number; result?: SpeedResult }
|
||||||
|
export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedRun; history: SpeedResult[]; method: string }
|
||||||
|
|
||||||
|
// ---- the VPN tab ----
|
||||||
|
// A peer is a device that dials in (on a server tunnel) or the way out (on a client one).
|
||||||
|
// `online` is a handshake in the last few minutes, not a connection: WireGuard has none.
|
||||||
|
export interface VPNPeer {
|
||||||
|
name?: string; public_key: string; endpoint?: string; allowed_ips: string[]
|
||||||
|
handshake?: string; online: boolean; rx: number; tx: number; keepalive?: number; psk?: boolean
|
||||||
|
}
|
||||||
|
export interface VPNTunnel {
|
||||||
|
name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number
|
||||||
|
fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: string
|
||||||
|
}
|
||||||
|
export interface VPNReport { at: string; tunnels: VPNTunnel[]; tool: boolean; root: boolean; note?: string }
|
||||||
|
|
||||||
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
|
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
|
||||||
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
||||||
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||||
@@ -228,6 +295,9 @@ export const api = {
|
|||||||
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
||||||
removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
|
removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
|
||||||
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
|
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
|
||||||
|
guests: () => req<Guest[]>('GET', '/api/guests', undefined, true),
|
||||||
|
createGuest: (minutes: number) => req<FreshGuest>('POST', '/api/guests', { minutes }, true),
|
||||||
|
revokeGuest: (name: string) => req<void>('DELETE', `/api/guests/${encodeURIComponent(name)}`, undefined, true),
|
||||||
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
||||||
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
||||||
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
||||||
@@ -244,10 +314,25 @@ export const api = {
|
|||||||
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
|
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
|
||||||
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
|
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
|
||||||
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
||||||
|
hardware: (refresh = false) => req<Hardware>('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`),
|
||||||
|
network: () => req<NetworkInfo>('GET', '/api/network'),
|
||||||
|
vpn: () => req<VPNReport>('GET', '/api/vpn'),
|
||||||
|
speedtest: () => req<SpeedRun>('GET', '/api/network/speedtest'),
|
||||||
|
startSpeedtest: () => req<SpeedRun>('POST', '/api/network/speedtest'),
|
||||||
|
cancelSpeedtest: () => req<void>('POST', '/api/network/speedtest/cancel'),
|
||||||
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
||||||
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
|
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
|
||||||
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
|
dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
|
||||||
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
|
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
|
||||||
|
machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`),
|
||||||
|
updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'),
|
||||||
|
saveUpdateJob: (j: Partial<UpdateJob>) => req<UpdateJob>('PUT', '/api/docker/schedules', j),
|
||||||
|
deleteUpdateJob: (id: string) => req<void>('DELETE', `/api/docker/schedules/${encodeURIComponent(id)}`),
|
||||||
|
runUpdateJob: (id: string) => req<{ output: string }>('POST', `/api/docker/schedules/${encodeURIComponent(id)}/run`),
|
||||||
|
dockerHealth: (id: string) => req<DockerHealth>('GET', `/api/docker/containers/${encodeURIComponent(id)}/health`),
|
||||||
|
dockerEvents: (id: string, since = '7d') => req<{ events: DockerEvent[]; since: string; kept: number; capacity: number }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/events?since=${since}`),
|
||||||
|
dockerExec: (id: string) => req<{ id: string; name: string }>('POST', `/api/docker/containers/${encodeURIComponent(id)}/exec`, {}),
|
||||||
|
dockerLimits: (id: string, p: { memory?: string; cpus?: string; restart?: string }) => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/limits`, p),
|
||||||
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
|
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
|
||||||
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'),
|
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'),
|
||||||
createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
|
createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
|
||||||
@@ -257,11 +342,13 @@ export const api = {
|
|||||||
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
|
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
|
||||||
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
|
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
|
||||||
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
|
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
|
||||||
|
removeNetwork: (name: string) => req<void>('POST', '/api/docker/networks/remove', { name }),
|
||||||
|
pruneNetworks: () => req<{ result: string }>('POST', '/api/docker/networks/prune'),
|
||||||
stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
|
stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
|
||||||
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
|
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
|
||||||
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
||||||
saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
|
saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
|
||||||
stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
|
stackAction: (name: string, action: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
|
||||||
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
|
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
|
||||||
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
||||||
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
|
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// A cron expression in English. Not a parser for running jobs — internal/cron does that,
|
||||||
|
// and computes the next run — just a sentence for the person reading the schedule, who
|
||||||
|
// should not have to decode "15 3 * * 1" in their head.
|
||||||
|
//
|
||||||
|
// Anything it can't say plainly it declines to describe, and the caller shows the
|
||||||
|
// expression instead. A confident wrong sentence is worse than five fields.
|
||||||
|
|
||||||
|
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||||
|
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||||
|
const DAY_ALIAS: Record<string, number> = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
|
||||||
|
const MONTH_ALIAS: Record<string, number> = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }
|
||||||
|
|
||||||
|
// "@daily" and friends, which crontab(5) accepts in place of the five fields.
|
||||||
|
const ALIASES: Record<string, string> = {
|
||||||
|
'@yearly': 'once a year, on 1 January at midnight',
|
||||||
|
'@annually': 'once a year, on 1 January at midnight',
|
||||||
|
'@monthly': 'monthly, on the 1st at midnight',
|
||||||
|
'@weekly': 'weekly, on Sunday at midnight',
|
||||||
|
'@daily': 'daily at midnight',
|
||||||
|
'@midnight': 'daily at midnight',
|
||||||
|
'@hourly': 'hourly, on the hour',
|
||||||
|
'@reboot': 'at every boot',
|
||||||
|
}
|
||||||
|
|
||||||
|
const clock = (h: number, m: number) => {
|
||||||
|
if (m === 0 && h === 0) return 'midnight'
|
||||||
|
if (m === 0 && h === 12) return 'noon'
|
||||||
|
const ampm = h < 12 ? 'am' : 'pm'
|
||||||
|
const h12 = h % 12 === 0 ? 12 : h % 12
|
||||||
|
return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2, '0')}${ampm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monday-to-Friday and Saturday-Sunday are worth naming; the rest get listed.
|
||||||
|
function dayNames(d: number[]): string {
|
||||||
|
const set = d.join(',')
|
||||||
|
if (set === '1,2,3,4,5') return 'weekdays'
|
||||||
|
if (set === '0,6') return 'weekends'
|
||||||
|
return list(d.map((x) => DAYS[x]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (parts: string[]) =>
|
||||||
|
parts.length <= 1 ? (parts[0] ?? '') : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`
|
||||||
|
|
||||||
|
const ordinal = (n: number) => {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'][(n % 100 - 20) % 10] ?? ['th', 'st', 'nd', 'rd'][n % 100] ?? 'th'
|
||||||
|
return `${n}${s}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// values of one field, or null when it is a form this doesn't describe
|
||||||
|
function values(field: string, min: number, max: number, alias: Record<string, number> = {}): number[] | null {
|
||||||
|
const out = new Set<number>()
|
||||||
|
for (const part of field.split(',')) {
|
||||||
|
const [range, stepText] = part.split('/')
|
||||||
|
const step = stepText === undefined ? 1 : Number(stepText)
|
||||||
|
if (!Number.isInteger(step) || step < 1) return null
|
||||||
|
let lo: number, hi: number
|
||||||
|
if (range === '*') {
|
||||||
|
lo = min; hi = max
|
||||||
|
} else {
|
||||||
|
const ends = range.split('-').map((v) => {
|
||||||
|
const n = alias[v.toLowerCase()] ?? Number(v)
|
||||||
|
return Number.isInteger(n) ? n : NaN
|
||||||
|
})
|
||||||
|
if (ends.some(Number.isNaN) || ends.length > 2) return null
|
||||||
|
lo = ends[0]
|
||||||
|
hi = ends.length === 2 ? ends[1] : (stepText === undefined ? ends[0] : max)
|
||||||
|
}
|
||||||
|
if (lo < min || hi > max || lo > hi) return null
|
||||||
|
for (let v = lo; v <= hi; v += step) out.add(v)
|
||||||
|
}
|
||||||
|
return [...out].sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
const everyValue = (v: number[], min: number, max: number) => v.length === max - min + 1
|
||||||
|
|
||||||
|
// stepOf reports the interval when a field is a plain "every n" over its whole range.
|
||||||
|
function stepOf(v: number[], min: number, max: number): number | null {
|
||||||
|
if (v.length < 2 || v[0] !== min) return null
|
||||||
|
const step = v[1] - v[0]
|
||||||
|
for (let i = 1; i < v.length; i++) if (v[i] - v[i - 1] !== step) return null
|
||||||
|
return v[v.length - 1] + step > max ? step : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** describeCron turns "15 3 * * *" into "daily at 3:15am", or returns "" if it can't. */
|
||||||
|
export function describeCron(expr: string): string {
|
||||||
|
const raw = (expr ?? '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (raw.startsWith('@')) return ALIASES[raw.toLowerCase()] ?? ''
|
||||||
|
const f = raw.split(/\s+/)
|
||||||
|
if (f.length !== 5) return '' // 6-field (seconds) and Quartz forms aren't ours to guess at
|
||||||
|
const [minF, hourF, domF, monF, dowF] = f
|
||||||
|
const mins = values(minF, 0, 59)
|
||||||
|
const hours = values(hourF, 0, 23)
|
||||||
|
const doms = values(domF, 1, 31)
|
||||||
|
const mons = values(monF, 1, 12, MONTH_ALIAS)
|
||||||
|
const dows = values(dowF.replace(/7/g, '0'), 0, 6, DAY_ALIAS)
|
||||||
|
if (!mins || !hours || !doms || !mons || !dows) return ''
|
||||||
|
|
||||||
|
// --- when in the day ---
|
||||||
|
let time: string
|
||||||
|
const everyMin = everyValue(mins, 0, 59)
|
||||||
|
const everyHour = everyValue(hours, 0, 23)
|
||||||
|
const minStep = stepOf(mins, 0, 59)
|
||||||
|
const hourStep = stepOf(hours, 0, 23)
|
||||||
|
if (everyMin && everyHour) time = 'every minute'
|
||||||
|
else if (everyMin) time = `every minute of ${list(hours.map((h) => clock(h, 0).replace(/(am|pm)/, '$1')))}`
|
||||||
|
else if (minStep && everyHour) time = `every ${minStep} minutes`
|
||||||
|
else if (minStep) time = `every ${minStep} minutes, ${hours.length === 1 ? `during the ${clock(hours[0], 0)} hour` : `during ${list(hours.map((h) => clock(h, 0)))}`}`
|
||||||
|
else if (everyHour && mins.length === 1) time = mins[0] === 0 ? 'hourly, on the hour' : `hourly, at ${mins[0]} past`
|
||||||
|
else if (hourStep && mins.length === 1) time = `every ${hourStep} hours` + (mins[0] === 0 ? ', on the hour' : `, at ${mins[0]} past`)
|
||||||
|
else if (mins.length === 1 && hours.length >= 1) time = `at ${list(hours.map((h) => clock(h, mins[0])))}`
|
||||||
|
else {
|
||||||
|
const times: string[] = []
|
||||||
|
for (const h of hours) for (const m of mins) times.push(clock(h, m))
|
||||||
|
if (times.length > 6) return '' // a wall of times is not a translation
|
||||||
|
time = `at ${list(times)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- which days ---
|
||||||
|
const allDom = everyValue(doms, 1, 31)
|
||||||
|
const allDow = everyValue(dows, 0, 6)
|
||||||
|
const allMon = everyValue(mons, 1, 12)
|
||||||
|
let days = ''
|
||||||
|
if (allDom && allDow) days = ''
|
||||||
|
else if (allDom) days = `on ${dayNames(dows)}`
|
||||||
|
else if (allDow) days = `on the ${list(doms.map(ordinal))}`
|
||||||
|
// crontab(5): with both set, the job runs when *either* matches — worth saying out loud
|
||||||
|
else days = `on the ${list(doms.map(ordinal))}, and on ${dayNames(dows)}`
|
||||||
|
const months = allMon ? '' : `in ${list(mons.map((m) => MONTHS[m - 1]))}`
|
||||||
|
|
||||||
|
const daily = !days && !months
|
||||||
|
const head = daily ? (time.startsWith('every') || time.startsWith('hourly') ? '' : 'daily ') : ''
|
||||||
|
return [head + time, days, months].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// A guest login's clock. That is the whole difference between a guest and an account: it
|
||||||
|
// can do everything, until it can't, so what the console has to show is how long is left.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// fmtLeft says how long is left, in the roundest terms that are still honest.
|
||||||
|
export function fmtLeft(ms: number): string {
|
||||||
|
const min = Math.floor(ms / 60000)
|
||||||
|
if (min >= 120) return `${Math.floor(min / 60)} h ${min % 60} min`
|
||||||
|
if (min >= 1) return `${min} min`
|
||||||
|
return 'under a minute'
|
||||||
|
}
|
||||||
|
|
||||||
|
// useTimeLeft is the milliseconds until `until`, re-rendering the caller every 15 seconds so
|
||||||
|
// the number keeps up. 0 when there is no expiry, which is every normal account.
|
||||||
|
export function useTimeLeft(until?: string): number {
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
const active = !!until
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const t = setInterval(() => tick((n) => n + 1), 15000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [active])
|
||||||
|
return until ? Math.max(0, new Date(until).getTime() - Date.now()) : 0
|
||||||
|
}
|
||||||
+49
-1
@@ -20,6 +20,12 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|||||||
.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||||
.login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; }
|
.login button, .primary { padding: 10px 12px; background: var(--accent); color: var(--btn-fg); border-radius: 6px; font-weight: 600; }
|
||||||
.login button:disabled { opacity: .5; }
|
.login button:disabled { opacity: .5; }
|
||||||
|
/* the password field and its show/hide eye, which sits inside the field's right edge */
|
||||||
|
.login .pwfield { position: relative; display: flex; }
|
||||||
|
.login .pwfield input { flex: 1; min-width: 0; padding-right: 44px; }
|
||||||
|
.login button.pweye { position: absolute; right: 1px; top: 1px; bottom: 1px; width: 42px; padding: 0; background: none; color: var(--muted); font-weight: 400; border-radius: 0 6px 6px 0; }
|
||||||
|
.login button.pweye:hover, .login button.pweye[aria-pressed="true"] { color: var(--fg); }
|
||||||
|
.login button.pweye:disabled { opacity: .4; }
|
||||||
.error { color: var(--bad); font-size: 13px; }
|
.error { color: var(--bad); font-size: 13px; }
|
||||||
|
|
||||||
/* layout */
|
/* layout */
|
||||||
@@ -94,6 +100,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|||||||
.tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
|
.tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||||
.tile .spark { margin-top: 8px; }
|
.tile .spark { margin-top: 8px; }
|
||||||
.card .empty { color: var(--muted); font-size: 13px; }
|
.card .empty { color: var(--muted); font-size: 13px; }
|
||||||
|
/* the out-of-date list: image, what the registry said, which stacks, and the button */
|
||||||
|
.upd-list .row { grid-template-columns: minmax(130px, 1.3fr) minmax(110px, 1.5fr) auto auto; }
|
||||||
|
.upd-list .row .btn { padding: 2px 10px; }
|
||||||
|
@media (max-width: 560px) { .upd-list .row { grid-template-columns: 1fr auto; } .upd-list .row > :nth-child(2) { grid-column: 1 / -1; } }
|
||||||
.row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; }
|
.row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; }
|
||||||
.row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; }
|
.row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; }
|
||||||
.num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }
|
.num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }
|
||||||
@@ -163,11 +173,28 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
|||||||
.svc:not(.has-sel) .svc-list, .cron:not(.has-sel) .cron-list { width: auto; flex: 1 1 auto; border-right: 0; }
|
.svc:not(.has-sel) .svc-list, .cron:not(.has-sel) .cron-list { width: auto; flex: 1 1 auto; border-right: 0; }
|
||||||
.svc:not(.has-sel) .svc-empty, .cron:not(.has-sel) .svc-empty { display: none; }
|
.svc:not(.has-sel) .svc-empty, .cron:not(.has-sel) .svc-empty { display: none; }
|
||||||
}
|
}
|
||||||
|
/* A tab that is a page rather than a list-and-detail — Docker's Updates — stacks: the tab
|
||||||
|
strip on top, the page below at full width. Otherwise the empty list column takes half
|
||||||
|
the window and the page is squeezed into the other half, which looks like a phone. */
|
||||||
|
.svc.wide { flex-direction: column; }
|
||||||
|
/* :not(.has-sel) to outrank the wide-window rule above, which grows the list to fill */
|
||||||
|
.svc.wide:not(.has-sel) .svc-list { width: auto; flex: 0 0 auto; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.svc.wide .svc-rows { display: none; }
|
||||||
|
.svc.wide > .svc-detail { flex: 1 1 auto; min-height: 0; }
|
||||||
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
|
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
|
||||||
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; }
|
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; }
|
||||||
.props > div { display: flex; gap: 8px; min-width: 0; } .props .k { color: var(--muted); width: 80px; flex-shrink: 0; }
|
.props > div { display: flex; gap: 8px; min-width: 0; } .props .k { color: var(--muted); width: 80px; flex-shrink: 0; }
|
||||||
.props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
.props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
|
/* One property per row, label above its value. For panes like Inspect, whose values are
|
||||||
|
long paths and lists — side by side they were half truncated and read as columns. */
|
||||||
|
.props.stacked { grid-template-columns: 1fr; gap: 10px 0; }
|
||||||
|
.props.stacked > div { flex-direction: column; gap: 2px; }
|
||||||
|
.props.stacked .k { width: auto; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
.props.stacked span:not(.k) { overflow: visible; white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
.props.stacked .k { text-transform: none; } .props.stacked > div > .k { text-transform: uppercase; }
|
||||||
|
.props.stacked .k .muted { text-transform: none; letter-spacing: 0; }
|
||||||
|
.props.stacked span:not(.k) > div + div { margin-top: 3px; }
|
||||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
|
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
|
||||||
.tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
.tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||||
.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
|
.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
|
||||||
@@ -395,10 +422,29 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
|
.mitem { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 12px; text-align: left; }
|
||||||
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
.mitem:hover { background: var(--bg3); } .mitem.on { box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
||||||
|
/* Network tab: the speed test's four figures, large enough to read at arm's length, and the
|
||||||
|
bar that fills while one is running. */
|
||||||
|
.net .speedgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||||
|
@media (max-width: 560px) { .net .speedgrid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
.net .speedfig { min-width: 0; }
|
||||||
|
.net .speedfig .label { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.net .speedfig .big { font-size: 28px; font-weight: 600; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
||||||
|
.net .speedfig .big i { font-size: 12px; font-weight: 400; font-style: normal; color: var(--muted); margin-left: 4px; }
|
||||||
|
.net .speedfig.live .big { color: var(--accent); }
|
||||||
|
.net .speedbar { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
|
||||||
|
.net .card.speedtest .svc-actions { margin-top: 10px; }
|
||||||
|
.net .card.speedtest .method { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
|
||||||
|
.net .card.iface { margin-top: 8px; }
|
||||||
|
.net .card + .dash-bar { margin-top: 14px; }
|
||||||
|
|
||||||
.addform { display: flex; flex-direction: column; gap: 10px; }
|
.addform { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||||
.addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
.addform input, .addform select { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
||||||
.header .badge.bad { color: var(--bad); border-color: var(--bad); }
|
.header .badge.bad { color: var(--bad); border-color: var(--bad); }
|
||||||
|
/* a guest login says so, and how long it has, where the power button would be */
|
||||||
|
.badge.guest { color: var(--accent2); border-color: var(--accent2); text-transform: none; margin-left: 0; white-space: nowrap; }
|
||||||
|
.card.fresh { border-color: var(--accent2); }
|
||||||
|
.card.fresh .mono { user-select: all; }
|
||||||
|
|
||||||
/* settings + themes */
|
/* settings + themes */
|
||||||
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||||
@@ -756,3 +802,5 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.kvm-frame.expanded iframe { height: 100%; }
|
.kvm-frame.expanded iframe { height: 100%; }
|
||||||
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
|
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
|
||||||
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
||||||
|
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
||||||
|
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
||||||
|
|||||||
+32
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
import { Component, ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
// ---- spinners ----
|
// ---- spinners ----
|
||||||
export function Spinner({ size = 14 }: { size?: number }) {
|
export function Spinner({ size = 14 }: { size?: number }) {
|
||||||
@@ -155,3 +155,34 @@ export async function okToLeave(): Promise<boolean> {
|
|||||||
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
|
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A page that throws takes the whole console down with it: React unmounts the tree, and what
|
||||||
|
// is left is a blank window with no clue in it. The VPN tab did exactly that on a machine
|
||||||
|
// whose tunnel list came back null. So each page is rendered inside one of these, and a fault
|
||||||
|
// costs that page rather than the session — the rail, the switcher and every other tab still
|
||||||
|
// work, and the error says which page and what it said.
|
||||||
|
export class PageBoundary extends Component<{ page: string; children: ReactNode }, { err: Error | null }> {
|
||||||
|
constructor(props: { page: string; children: ReactNode }) {
|
||||||
|
super(props)
|
||||||
|
this.state = { err: null }
|
||||||
|
}
|
||||||
|
static getDerivedStateFromError(err: Error) { return { err } }
|
||||||
|
componentDidCatch(err: Error) { console.error('page failed:', err) }
|
||||||
|
// A different page is a different mount; clear the fault when the page changes.
|
||||||
|
componentDidUpdate(prev: { page: string }) { if (prev.page !== this.props.page && this.state.err) this.setState({ err: null }) }
|
||||||
|
render() {
|
||||||
|
if (!this.state.err) return this.props.children
|
||||||
|
return (
|
||||||
|
<div className="dash">
|
||||||
|
<div className="dash-bar"><h2 className="section">{this.props.page}</h2></div>
|
||||||
|
<section className="card">
|
||||||
|
<div className="error">This page hit a fault and stopped drawing. The rest of the console is unaffected.</div>
|
||||||
|
<div className="muted small mono" style={{ marginTop: 8 }}>{String(this.state.err)}</div>
|
||||||
|
<div className="svc-actions" style={{ marginTop: 10 }}>
|
||||||
|
<button className="btn" onClick={() => this.setState({ err: null })}>Try again</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user