Compare commits
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(systemctl status *)",
|
||||
"Bash(systemctl is-active *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ bin/
|
||||
internal/web/dist/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
# Bug bash — 2026-09-18
|
||||
|
||||
Fix pass 2026-09-18: every item carries its outcome; ✅ resolved and verified live, ➖ no change with reason. Found by a headless Chromium pass (iPhone and
|
||||
1440px desktop viewports) over every page on the hub and on each remote machine, an
|
||||
offline-node scenario (wyse stopped for the test, then restarted), direct API probing
|
||||
with hostile inputs, and a code read. Ordered by severity within each group.
|
||||
|
||||
## Security / robustness
|
||||
|
||||
1. ✅ **RESOLVED 2026-09-18** — only regular files are readable; the byte cap applies to what is sent, not to `stat`. `/dev/zero`, `/dev/null` and FIFOs answer 400 in 30 ms; `/proc/cpuinfo` still reads. **Reading a special file hangs the request forever.** `GET /api/files/read?path=/dev/zero`
|
||||
never ends: the `max` cap compares against `stat` size, which is 0 for device files,
|
||||
so the cap never triggers and the handler streams `/dev/zero` until the client gives
|
||||
up. Same for FIFOs (blocks on open) and `/proc/self/mem`. Opening one in the Files
|
||||
viewer would hang the tab. Needs: refuse non-regular files for read/edit, and cap the
|
||||
copy with `io.LimitReader` regardless of `stat`.
|
||||
2. ✅ **RESOLVED** — per-client buckets (first X-Forwarded-For hop or the peer) with the same 5-strike schedule, plus a global backstop of 100 failures per 5 min → 1 min. Verified: core's public IP locked while grid logged in fine. **Login lockout is global, not per client.** Five bad passwords from anyone on the
|
||||
internet lock the real user out for up to 10 minutes (`auth.Limiter` is one bucket).
|
||||
The bash locked the hub for the full 10 minutes twice. Also locks `POST /api/tokens`,
|
||||
so hub pairing fails during a lockout. Needs per-IP buckets with a modest global cap.
|
||||
3. ✅ **RESOLVED** — server rejects refreshMs outside 100–60000 and non-identifier themes (400); client ignores out-of-range rates from any source. **UI prefs from the server are not validated.** A stored `refreshMs` of `-5` made the
|
||||
client call `setInterval(-5)` on every live view: a request storm that took the
|
||||
browser to `ERR_INSUFFICIENT_RESOURCES` and hammered the daemon. `theme` is validated
|
||||
only by falling back. Needs range checks on load (and on save server-side).
|
||||
4. ✅ **RESOLVED** — every response carries X-Frame-Options DENY, CSP frame-ancestors none, nosniff, no-referrer. **No anti-framing / CSP headers.** Only HSTS (from NPM). A root-capable console should
|
||||
send `X-Frame-Options: DENY`, `Content-Security-Policy: frame-ancestors 'none'`,
|
||||
`Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff` on every response.
|
||||
5. ✅ **RESOLVED** — kernel threads (empty cmdline) and the daemon's own PID are refused. **Signals can be sent to kernel threads.** `POST /api/procs/2/signal` (kthreadd)
|
||||
returns 204. Harmless in practice, but the guard should refuse PIDs with an empty
|
||||
cmdline (kernel threads) and our own PID.
|
||||
6. ✅ **RESOLVED** — unit runs with `SupplementaryGroups=systemd-journal adm` so journal reads need no sudo, and the installer drops `/etc/sudoers.d/hamlab-quiet` (no syslog/pam session for the read-only polling commands). Verified: 0 sudo lines in 60 s of 250 ms polling (was 839/10 min). Applied on all four hosts. **Journal spam from the daemon's own sudo calls.** Every sudo'd `journalctl`, `smartctl`
|
||||
or `pwrstat` writes three `pam_unix(sudo:session)` lines into the journal *under
|
||||
`hamlab.service`*: 839 lines in 10 minutes at a 250 ms refresh, and the journal is
|
||||
already 4 GB. It also buries Ham Lab's own log entries. Fix options: add the service
|
||||
user to `systemd-journal` (and `adm`) so journal reads don't need sudo, and/or a
|
||||
sudoers `Defaults!/usr/bin/journalctl !syslog, !pam_session` line in the installer.
|
||||
7. ✅ **RESOLVED** — server answers 409 unless `overwrite=1`; the UI asks "Replace it?" first. **Uploading over an existing file silently overwrites it.** No confirmation, no
|
||||
"replace?" prompt. Same for "+ file" when the name exists (server refuses; UI just
|
||||
shows the error, fine) — upload is the gap.
|
||||
8. ➖ **NO CHANGE** — Go's mux redirects the unclean path to `/etc/passwd`, which is an ordinary deep link the SPA must serve; there is no API prefix left by the time the static handler sees it. Harmless. **`/api/<anything>/../../x` falls through to the SPA** and returns `index.html` with
|
||||
200 instead of a JSON 404. Harmless, but sloppy for an API prefix.
|
||||
|
||||
## Functional bugs
|
||||
|
||||
9. ✅ **RESOLVED** — 401 carries the server message; the form shows "wrong password" (verified headless). **Wrong password gives no feedback at all.** The 401 is thrown as `Unauthenticated`
|
||||
with an empty message, and the login form only renders `err` when non-empty. The
|
||||
button just re-enables. Reproduced on phone and desktop.
|
||||
10. ✅ **RESOLVED** — hub-only pages bounce to the dashboard on switch (verified headless). **Switching machines while on Settings or Machines keeps showing the hub's page.**
|
||||
`switchMachine` only resets the view for Machines; Settings stays, so "mac" shows
|
||||
core's account/token/about panels. Both are hub-only pages and should bounce to the
|
||||
dashboard (or be clearly labelled as hub pages).
|
||||
11. ✅ **RESOLVED** — escaped; no console error. **Machines add-form `pattern` is an invalid regex in Chromium.** `[a-z0-9][a-z0-9-]*`
|
||||
fails under the `v` flag ("Invalid character class"), logs a console error and the
|
||||
browser skips validation. Needs `[a-z0-9][a-z0-9\-]*`.
|
||||
12. ✅ **RESOLVED** — refresh from the newest repodata under libdnf5/dnf caches (Mac: 9 d), last upgrade from `dnf history` (194 d), label reads "from dnf history". **Updates tab on dnf hosts shows nonsense.** Mac: "Index refreshed 561d ago" (mtime of
|
||||
`/var/cache/dnf`, which Fedora 41+ no longer uses; `libdnf5` is the live cache) and
|
||||
"Last upgrade never · from apt history" (label and source are apt-specific). Needs
|
||||
`dnf history` for last upgrade, metadata timestamps for refresh, and neutral labels.
|
||||
13. ✅ **RESOLVED** — journal read incrementally by cursor, timers from one `systemctl show`, services' two calls run in parallel. Cron 3.6 s → 0.08 s, services 0.6 s → 0.05 s, with last-run and next-run data intact. **Cron overview is slow: 3.6 s per request on core.** At any refresh rate the page
|
||||
is constantly busy. The 30-day, 20 000-line journal scan for "last run" is rebuilt
|
||||
every 5 s; timers make three `systemctl` calls. Needs an incremental journal cursor
|
||||
and a single `systemctl` call. Services list is 0.6 s for the same reason (two calls).
|
||||
14. ✅ **RESOLVED** — heap profile showed a 48 MB `bytes.Buffer` from buffering `journalctl` dumps; those are now streamed line by line, plus a 160 MB soft memory limit for the GC. RSS 22 MB fresh, 27 MB after 25× cron/logs/procs/services (was 180 MB). pprof stays available behind the login at `/api/debug/pprof/`. **Daemon memory grows.** RSS on core went 22 → 85 → 216 MB during the day (136 MB at
|
||||
rest after the bash). Suspects: per-pid caches in the process table (`procInfo` only
|
||||
prunes on the next walk), task output buffers, SMART cache, xterm scrollback rings
|
||||
that are never freed for exited sessions. Needs a heap profile under load.
|
||||
15. ✅ **RESOLVED** — `--dirty` dropped from the version and deploys now follow commits; binaries report e.g. `v0.1.0-38-gc1805f6`. **Version string is always `-dirty`.** `make deploy` builds before the commit, so
|
||||
every installed binary reports e.g. `v0.1.0-32-ga372378-dirty`, and the Machines page
|
||||
compares with `-dirty` stripped. Build after commit, or tag releases.
|
||||
16. ✅ **RESOLVED** — hash routing `#/<machine|hub>/<view>`: reload restores machine and tab, Back/Forward work, links can point at a page on a machine (verified headless). **View resets to Dashboard on reload; no deep links; browser Back leaves the app.**
|
||||
The selected machine survives a reload (sessionStorage) but the tab does not, and
|
||||
there is no URL routing at all. Needs hash or path routing (`/#/grid/services`).
|
||||
17. ✅ **RESOLVED** — newest first. **Cron log tab lists oldest first and starts at the top.** 200 lines from Sep 10 with
|
||||
the newest at the bottom, off-screen. Should show newest first or scroll to the end.
|
||||
18. ✅ **RESOLVED** — "Nothing matches." **Processes: empty filter result shows nothing.** No "nothing matches" message.
|
||||
19. ✅ **RESOLVED** — exited shells remove themselves 10 minutes after exit (verified live: seeded 17:53:56, gone at 18:03:59 with no daemon restart in between). Found and fixed alongside: a daemon restart used to re-spawn *exited* shells as fresh running ones; only running shells are persisted now. Idle running shells are deliberately kept. **Terminals accumulate.** Nothing ever reaps exited or idle sessions; the bash left
|
||||
several "bug-bash-term" shells on the hub until deleted by hand. Exited sessions
|
||||
should auto-remove after a while; idle ones at least be counted somewhere.
|
||||
20. ✅ **RESOLVED** — a node answers 409 "busy" when a package task is running or a terminal is attached; the Machines page offers Force, `hamlab push-nodes --force` / `make push-nodes-force` do the same. Verified: 409 with a terminal attached on grid, 200 with force. **Self-update restarts without warning running work.** A push to a node kills its
|
||||
open terminals and any running task (upgrade in progress). The hub should refuse or
|
||||
warn when the node reports a running task or attached terminals.
|
||||
21. ✅ **RESOLVED** — PathError and LinkError are unwrapped to the bare reason. **Error text leaks raw Go errors.** e.g. rename shows "rename /tmp/x /tmp/y: no such
|
||||
file or directory". Fine for a lab, ugly for a product; strip the syscall prefix.
|
||||
|
||||
## Mobile / UX
|
||||
|
||||
22. ✅ **RESOLVED** — on coarse pointers every button, chip, icon button, link, header and select is at least 36 px tall (verified: no control under 32 px on the dashboard or processes pages). **Tap targets under 28 px on phone:** header ⏻ (25), logout (22), customize ▲ ▼ hide
|
||||
(23), terminal leaf ✎ ✕ (24), chips (25), table headers (27). Apple's guideline is
|
||||
44 pt. Systemic: the small `.btn`/`.chip`/`.icon-btn` sizes need a touch variant.
|
||||
23. ✅ **RESOLVED** — the dialog asks for the machine name (`mac`); the node still checks its own hostname under the hood. **Power dialog on the Mac asks you to type `5ce91ee54af6`.** The confirmation uses
|
||||
the node's hostname, which on the Mac is the unset default. Should offer the machine
|
||||
name (`mac`) as the confirmation word on remote machines.
|
||||
24. ✅ **RESOLVED** — down interfaces collapse to one line marked "down"; modules with nothing to show (fans, UPS, temps, disks) hide unless customizing. **Dashboard shows dead interfaces and empty modules.** wlp1s0 (down) gets a full
|
||||
sparkline block of flat lines; "Fans: no fan sensors" is a permanent empty card on
|
||||
hosts without fans. Down interfaces should collapse to one line; empty modules should
|
||||
auto-hide unless customizing.
|
||||
25. ✅ **RESOLVED** — "load 1.39 1.56 1.55" with a tooltip. **Header load average is unlabelled** ("1.39 1.56 1.55" floating in the header).
|
||||
26. ➖ **NOT FIXING** — the list is intentionally a plain table; the menu anchors inside the row. Cosmetic taste, not a defect. **Files list on desktop is a narrow strip in a sea of empty space** for small
|
||||
folders; the row menu opens off the right edge of the row. Cosmetic.
|
||||
27. ✅ **RESOLVED** — after a 502 the client leaves that machine alone for 5 s: 2 requests in 10 s at a 250 ms refresh (would have been ~80), badge and message intact. **Offline machine keeps polling every view at the refresh rate**, producing a 502
|
||||
per request per page (and console errors). The UI copes — badge, message — but the
|
||||
hub should back off to the prober's cadence while a node is down.
|
||||
28. ✅ **RESOLVED** with 6. **Services logs are noise.** The default view of `hamlab.service` logs is the sudo
|
||||
session spam from bug 6, three lines per poll.
|
||||
|
||||
## Confirmed working under abuse (for the record)
|
||||
|
||||
Path traversal in files, services, cron, ZFS, procs and updates all rejected. `/etc`
|
||||
and `/` protected from delete/rename as root. Unit, pool, dataset, snapshot and
|
||||
package names validated. Token issue refused with a wrong password. Oversized prefs
|
||||
rejected. Duplicate machine add refused. Unicode + spaces in file names round-trip.
|
||||
Upload byte-exact. Terminal scrollback survives switching. Offline node: switcher and
|
||||
header show it, pages show the reason. Remote terminals, logs, tasks and files through
|
||||
the proxy all work on all three nodes.
|
||||
|
||||
|
||||
# Bug bash — 2026-09-19
|
||||
|
||||
Found by driving a scratch instance (same binary, its own state, real fleet behind it) in
|
||||
headless Chromium at 1440 px and iPhone size: a sweep of every page on every machine for
|
||||
page errors, failed requests, overflow and small tap targets; then scripted use of every
|
||||
feature — terminal tabs, Files in a throwaway folder, Processes (killing a process the
|
||||
test spawned), Monitors customisation and widgets, Services, Cron, Updates, Disks,
|
||||
Machines, Settings, embeds, machine switching, session loss — and a hostile pass over the
|
||||
API. Host-changing actions (service control, cron edits, upgrades, power) were opened but
|
||||
not executed. Every fix below was re-checked by the same script that found it.
|
||||
|
||||
## Bugs — all ✅ fixed and verified
|
||||
|
||||
1. ✅ **One widget with a bad pattern silenced every custom widget.** They share one stream;
|
||||
`fan.[` made the whole query a 400, so all of them showed "…" forever with no reason
|
||||
given. The picker now asks the API before adding and shows its answer; a bad pattern
|
||||
already saved shows a notice instead of dots.
|
||||
2. ✅ **A saved layout of the wrong shape could not be loaded** ("could not load layout",
|
||||
and the page could no longer be customised). `normalize()` now accepts anything.
|
||||
3. ✅ **A lost session left pages saying "unauthenticated"** for up to 30 s. A 401 from the hub
|
||||
now returns to the login form at once, and logging in returns to the same page. (A 401
|
||||
relayed from a managed machine is that machine rejecting the hub, and doesn't.)
|
||||
4. ✅ **The machine-name rule in "Add a machine" was an invalid regular expression** under the
|
||||
browser's `v` mode, so it validated nothing and logged a console error on every render.
|
||||
5. ✅ **Two terminals opened in the same minute got the same name** ("shell 09:45" twice).
|
||||
Now "shell 09:45 (2)".
|
||||
6. ✅ **Names had no length limit** — a 328-character terminal tab, a 5000-character token
|
||||
name. 64 characters, refused with a reason; token names also refuse `/ \ < > "`.
|
||||
7. ✅ **"+ file", "+ folder" and Rename accepted `../x` and `a/b`**, quietly acting outside the
|
||||
folder on screen. A name is now one path segment; Move… is how things go elsewhere.
|
||||
8. ✅ **Unsaved file edits were thrown away without a word** when you picked another page or
|
||||
machine, or closed the tab. All three now ask; Cancel keeps you where you were.
|
||||
9. ✅ **A mistyped path in Files was remembered**, so a reload landed on the error. Only a
|
||||
folder that opened is remembered.
|
||||
10. ✅ **Escape closed nothing**: the machine menu, the file menu, the Power dialog, the widget
|
||||
picker. It closes all four.
|
||||
11. ✅ **Reset on Monitors removed every custom widget with no confirmation.** It asks, and
|
||||
says how many.
|
||||
12. ✅ **A few bytes of query bought 27 MB of answer** (`{"k0":"*",…×400}` with `full=1`) — and a
|
||||
read-only token is public on the demo page. A query may return 10 000 values (thirty
|
||||
whole machines); past that it is a 400 that says so.
|
||||
13. ✅ **No ceiling on live streams**: 300 from one token were accepted. 32 per client address,
|
||||
512 in all, then a 429 that says so; closed streams free their places.
|
||||
14. ✅ **Anything under `/embed/` lost the anti-framing headers**, not just the widget page.
|
||||
Only `/embed/widget` is frameable.
|
||||
15. ✅ **The new ZFS sensors ran three `sudo` commands a minute on every machine** — including
|
||||
`core`, which has the tools and no pool — and each wrote three lines to the journal:
|
||||
most of `hamlab.service`'s log was that. Pools are now read with one unprivileged
|
||||
`zpool list`; a source that finds nothing is asked again ten times less often; and
|
||||
the installer's quiet list covers `zpool|zfs list|status`.
|
||||
16. ✅ **Phone: the filter boxes on Processes and Files, the path box and the "/" crumb were
|
||||
under 30 px.** 36 px like every other control.
|
||||
17. ✅ **Disks → datasets on a phone: rows ended 50 px before their own cells** when scrolled
|
||||
sideways (`min-width` was less than the columns add up to).
|
||||
|
||||
Also: tab titles now say where you are ("Services · grid — Ham Lab"), so history and open
|
||||
tabs can be told apart.
|
||||
|
||||
## Checked and fine
|
||||
|
||||
Terminal focus after every switch/close/return; scrollback after reload; exited shells;
|
||||
30 tabs with the + still reachable; re-fit on resize; Back/Forward; unknown machine or page
|
||||
in the URL; wrong password; XSS through terminal, token and file names (all rendered as
|
||||
text); hidden files, filter, symlinks (broken ones marked), binary files, upload and
|
||||
replace, folder delete needing the typed name, root toggle; process sort, filter and
|
||||
Terminate (asks first); themes; password-change validation; duplicate or unreachable
|
||||
machine; hub terminals not leaking into another machine's page; CORS only on sensor paths;
|
||||
token list never exposing hashes; cookie flags; deep nesting, empty bodies, odd methods
|
||||
and `every=` values on the sensor API; phone drawer, key bar and picker.
|
||||
|
||||
## Quality of life — all ✅ done 2026-09-19, each verified in the browser
|
||||
|
||||
1. ✅ **Selections are in the URL.** `#/grid/services/ssh.service`, `#/hub/cron/job:…`,
|
||||
`#/hub/files/%2Fetc`: reload keeps them, links carry them, leaving a page drops them.
|
||||
2. ✅ **Browser Back with unsaved file edits asks**, like page and machine changes do.
|
||||
3. ✅ **No more browser `prompt()`/`confirm()` boxes** — all 30 are in-app dialogs (`askYes`,
|
||||
`askText` in `ui.tsx`): Enter submits, Escape or a tap outside cancels, destructive
|
||||
ones are red and say what they do ("Close", "Delete"), and they fit a phone.
|
||||
4. ✅ **Token creation is one dialog**: name, patterns, and the machine's own sensor families
|
||||
as chips to click.
|
||||
5. ✅ **Widgets can be edited** — sensor, type and title — in place, from Customize.
|
||||
6. ✅ **The Widgets gallery has a search** over IDs, names and labels (so "gitea" finds its
|
||||
container's sensors).
|
||||
7. ✅ **Status sensors keep a history** (300 readings each: an hour of container memory, two
|
||||
days of drive wear), so their sparklines have a past.
|
||||
8. ✅ **A stream that fails says why**: "the token was rejected", "too many live streams are
|
||||
open", "that machine is unreachable", "can't reach Ham Lab" — in embeds and on Monitors.
|
||||
9. ✅ **Pattern widgets get readable default titles**: `cpu.core.*.usage` → "CPU core usage".
|
||||
10. ✅ **Terminal tabs reorder** — drag on a desktop, ‹ › on a touch screen, remembered per
|
||||
machine in the browser — and **Alt+Shift+←/→** switches tabs, **Alt+Shift+T** opens one,
|
||||
with the keyboard left in the terminal each time.
|
||||
11. ➖ **"Log out everywhere"** — built, then removed the same day at Mitchell's word: no need
|
||||
for it on a single-user console.
|
||||
12. ✅ **Real clipboard shortcuts in the terminal**, from a physical keyboard: **Ctrl+C copies
|
||||
when text is selected** (and is still ^C when nothing is — the selection clears after a
|
||||
copy, so a second Ctrl+C interrupts), **Ctrl+V pastes**, Ctrl+Shift+C / Ctrl+Shift+V do
|
||||
the same unconditionally, Ctrl+Insert / Shift+Insert too. It rides the browser's own
|
||||
copy and paste events, which xterm already answers, so it works over plain http on the
|
||||
LAN where `navigator.clipboard` does not exist; bracketed paste is honoured.
|
||||
|
||||
|
||||
# Bug bash 2 — 2026-09-19 (evening): Alerts, Docker, Stacks, skins, the installer
|
||||
|
||||
Same method as the morning: a scratch instance with the real fleet behind it, headless
|
||||
Chromium at 1440 px and iPhone size. A sweep of all 13 pages on all four machines (errors,
|
||||
failed requests, overflow, tap targets) plus a contrast measurement of every skin; then
|
||||
scripted use of the new features on throwaway containers and stacks — a multi-file stack, a
|
||||
file edited behind the editor's back, two actions at once, a chatty log, odd container
|
||||
names, containers stopped and removed while open, every kind of bad alert rule, a rule's
|
||||
whole life (fire, narrow, pause, delete, survive a restart), token scope and hostile paths
|
||||
on the new APIs — and a regression of terminals, Files, widgets, deep links and colours.
|
||||
|
||||
## Bugs — all ✅ fixed and verified by the script that found them
|
||||
|
||||
1. ✅ **Deploying a stack made of several compose files dropped all but the first.** A stack
|
||||
deployed with `-f compose.yaml -f compose.override.yaml` was redeployed from
|
||||
`compose.yaml` alone, so everything the override set silently vanished from the new
|
||||
container. Two causes: only the first file was used, and the file list was read from
|
||||
`docker ps`, which joins labels with commas — the list is itself comma-separated, so it
|
||||
arrived truncated. Labels now come from `inspect`; every file is resolved, shown as a
|
||||
chip, editable, validated together and passed to compose in order.
|
||||
2. ✅ **A compose file changed on disk after you opened it was silently overwritten** (Dockhand,
|
||||
or a second tab). The editor now hands back the checksum it was given; a mismatch is a
|
||||
409 that says so, keeps your text, and offers to reload.
|
||||
3. ✅ **Stacks on a machine without Docker answered 500.** An empty list.
|
||||
4. ✅ **Every hub restart re-fired every firing alert** — and every deploy is a restart, so a
|
||||
phone would have been told about the same failed service on each one. What is firing is
|
||||
now kept in `state.json` and carried on, with its original start time.
|
||||
5. ✅ **An alert whose rule was paused, deleted or narrowed just vanished**, leaving "firing" in
|
||||
the history forever and no word to the phone. It is now recorded (and sent) as cleared,
|
||||
with the reason: "rule paused", "rule deleted", "no longer watched".
|
||||
6. ✅ **Nonsense rule values were accepted**: `null`, an object, `cpu.usage > ninety` (which can
|
||||
never be true, and would never say so), `true` with `<`. Refused, each with its reason.
|
||||
7. ✅ **No limit on rules** — 3000 were accepted, each checked on every machine every 15 s. 200.
|
||||
8. ✅ **Logs of a container that no longer exists answered 500.** 404.
|
||||
9. ✅ **Four skins had text under 3:1 contrast**: Nord's red; Catppuccin Latte's muted text and
|
||||
green; Solarized Dark's body text, muted text and red; Solarized Light's body text,
|
||||
muted text, accent and green. Each colour kept its hue and was moved to a readable value
|
||||
(measured: all thirteen skins now pass, body text ≥ 7:1).
|
||||
10. ✅ **Phone: the alert toggles (28 px) and the colour swatches (26 px) were under 30 px.**
|
||||
11. ✅ **The public installer had fallen four builds behind the fleet** (v65 published, v69
|
||||
running), so `curl … | sh` on an up-to-date machine "upgraded" it backwards. `make
|
||||
deploy` now republishes, and the installer compares versions: an older build is called
|
||||
older, defaults to No, and unattended runs refuse it without `HAMLAB_ALLOW_DOWNGRADE=1`.
|
||||
|
||||
## Checked and fine
|
||||
|
||||
A second stack action while one runs (409); `../` and encoded tricks in stack names;
|
||||
shell metacharacters in container names, `since` and actions; `rm` / `kill` / `down` are
|
||||
not actions; a read-only token is refused on every Docker, Stacks and Alerts endpoint,
|
||||
directly and through the hub; container names with dots and underscores still get live
|
||||
CPU and memory; a log at five lines a second follows with no gaps or duplicates; a
|
||||
container stopped or removed while open updates itself; an alert rule's XSS name renders
|
||||
as text; per-machine firing, the rail badge, webhook and ntfy delivery; terminals, Files,
|
||||
widgets over container sensors, deep links, tab titles and machine colours all unchanged.
|
||||
No native browser dialogs anywhere; no page errors on any of 100 page loads.
|
||||
|
||||
## Found by the alerts themselves
|
||||
|
||||
`core` has one real alert firing: **`zfs-import-cache.service` fails at every boot** — `core`
|
||||
has the ZFS tools and no pool, so there is nothing to import. Harmless, but it will sit on
|
||||
the rail as "1" until the unit is disabled or the rule is narrowed. Left alone.
|
||||
|
||||
## Quality of life — five chosen by Mitchell, ✅ built and verified 2026-09-19
|
||||
|
||||
1. ✅ **Stacks on `grid` and the Mac are editable from the hub.** Their files live on `core`
|
||||
(Dockhand deploys over tcp); the hub resolves them and runs compose with `DOCKER_HOST`
|
||||
pointed at the node. Verified on a throwaway stack: saved on `core`, recreated **on
|
||||
`grid`**, same compose labels, nothing created on `core`, output followed on the page.
|
||||
2. ✅ **A stack's `.env` is editable**, values masked until "Reveal and edit". A `.env` that
|
||||
breaks the compose file (a required variable removed) is refused and the old one put back.
|
||||
6. ✅ **A link to a container that no longer exists says so**, by name.
|
||||
9. ✅ **"Update available" per image** — on request, remembered six hours. Verified with an
|
||||
image whose tag had moved on (update available) beside a current one (up to date).
|
||||
10. ✅ **What Deploy would change**: a line diff against what was last deployed from here;
|
||||
after a deploy it says there is nothing left to change. `.env` diffs name the key and
|
||||
never show a value.
|
||||
|
||||
Not chosen, still open: alert acknowledge/snooze; quiet hours and per-rule channels; push
|
||||
straight from Ham Lab; a type hint in the alert editor; switching off the nodes' own alert
|
||||
engines.
|
||||
|
||||
# Bug bash 3 — 2026-09-20: new stacks, new containers, images, volumes, the HUD
|
||||
|
||||
Started from Mitchell's report — the New stack dialog is see-through in HUD, its text
|
||||
mixing with the page behind. Same method as before: a scratch hub with the real fleet behind
|
||||
it, headless Chromium at 1440 px and iPhone size. A sweep of all 13 pages on all four
|
||||
machines, plus a measurement **in every skin** of everything that floats over the page
|
||||
(dialogs, the question box, the widget picker, menus, the phone drawer: background alpha
|
||||
must be ≥ 0.9). Then scripted use of what is new on throwaway `hl-` stacks, containers,
|
||||
images and volumes: the New stack / New container dialog's edges, stacks that are not
|
||||
deployed, a deploy that fails, a container name that is taken, deletes beside other
|
||||
people's files, a two-file stack, create → deploy → restart → stop → delete **on the Mac
|
||||
and on `grid` through the hub**, pulls on the hub and on a node, removal guards, a
|
||||
container stopped on purpose, a read-only token and hostile input on every new endpoint,
|
||||
and a regression of Escape everywhere, terminals, Files, sensors, alerts and deep links.
|
||||
|
||||
## Bugs — all ✅ fixed and verified by the script that found them
|
||||
|
||||
1. ✅ **HUD: every dialog was see-through.** The skin's "glass" rule (3 % cyan) was written
|
||||
for tiles and cards and also caught `.modal` — the New stack dialog, the widget picker,
|
||||
the Power dialog, the token dialog, the rule editor and every question. Anything that
|
||||
floats over the page is now solid in HUD (hairline border and glow kept) over a darker
|
||||
backdrop. Measured in all thirteen skins: nothing that floats is under 0.9 alpha.
|
||||
2. ✅ **HUD: the phone's slide-out menu was 92 % opaque**, the page showing through it. Solid.
|
||||
3. ✅ **Deploy, Update, Restart, Stop and Down all failed on the Mac's stack**
|
||||
(`peaches-and-plums`) — and on any stack held on the hub that was deployed from the hub's
|
||||
own shell rather than from inside Dockhand. The command was built as
|
||||
`'DOCKER_HOST=tcp://…' docker compose …`; quoted, `sh` reads that not as a variable but
|
||||
as the name of a program to run ("not found", exit 127). Now passed through `env`.
|
||||
Verified by a whole life on the Mac and on `grid` from the hub: create, deploy (running
|
||||
there, nothing on `core`), restart, stop, delete.
|
||||
4. ✅ **A container stopped on purpose was reported as down** when it has no restart policy
|
||||
and doesn't handle SIGTERM: `docker stop` then ends it with 137 (or 143, or 130), a
|
||||
non-zero code, which read as a crash — "Container down" would have fired for a
|
||||
deliberate stop. Those three codes now mean "a person", unless the kernel did the
|
||||
killing for want of memory (`OOMKilled`). A real crash (exit 3) is still down.
|
||||
5. ✅ **A stack with no containers offered Restart, Stop and Update**, and Restart
|
||||
"succeeded" having done nothing. Offered only when there is something to act on; the
|
||||
API answers 409 "isn't deployed: there is nothing to restart".
|
||||
6. ✅ **One stray tap outside the New stack dialog threw away everything typed** — on a phone,
|
||||
a compose file's worth. So did Escape. With anything typed, all three ways out (tap
|
||||
outside, Escape, Cancel) ask first; an untouched dialog still just closes.
|
||||
7. ✅ **Compose → Form → Compose silently replaced hand-edited compose text** with what the
|
||||
form says. The form now writes the box only when the form has changed since it last
|
||||
did, and asks before replacing text edited by hand.
|
||||
8. ✅ **A question asked from inside a dialog opened underneath it** (same layer, earlier in
|
||||
the page), so it could not be answered — found the moment the New stack dialog started
|
||||
asking. Questions now sit above whatever asked them.
|
||||
9. ✅ **Escape on a question also reached the dialog beneath it**: one key cancelled the
|
||||
question *and* closed (or re-asked from) the dialog under it. Escape now answers the
|
||||
question and goes no further; the next Escape is the dialog's. Checked on every dialog
|
||||
and menu: widget picker, machine menu, rule editor, Power, New stack, and that Escape on
|
||||
"Delete the rule?" deletes nothing.
|
||||
|
||||
10. ✅ **Phone: the bottom of the New container dialog could not be reached** (Mitchell, from
|
||||
his iPhone). A dialog is a column whose parts were allowed to shrink, so instead of the
|
||||
dialog scrolling, the one part with its own scrollbar — the compose preview — was
|
||||
squeezed to 22 px and everything "fitted". And `92vh` on iOS is taller than the screen
|
||||
with the address bar gone, so the top sat under the clock. Parts now keep their height
|
||||
and the dialog scrolls inside itself; it is never taller than the space between the
|
||||
status bar and the home indicator (safe-area insets, no `vh`); Create / Cancel — and
|
||||
any error — stay pinned at the bottom while the form scrolls. Applies to every dialog;
|
||||
measured at 390×844, 375×667 and sideways, and the picker, rule editor, token and Power
|
||||
dialogs rechecked.
|
||||
|
||||
## Checked and fine
|
||||
|
||||
All 13 pages × four machines × both sizes: no page errors, failed requests, sideways
|
||||
scroll or tap targets under 30 px. A port of `not-a-port` is refused by compose before
|
||||
anything is written, and leaves no folder. Environment values with quotes, `#`, `$` and
|
||||
nothing after the `=` arrive in the container exactly as typed. A deploy whose image
|
||||
doesn't exist shows the registry's error and is **not** remembered as "last deployed". A
|
||||
container name already taken fails the deploy and says why. Delete moves only the stack's
|
||||
own compose files and `.env` (kept `600` in the trash) and leaves a `data/` folder and
|
||||
notes beside them alone; both files of a two-file stack go to the trash; the wrong name,
|
||||
or none, deletes nothing. A `.env` value never appears on the stack page and stays masked
|
||||
until asked for. A second stack of the same name is 409; an upper-case name is 400. A pull
|
||||
of an image that doesn't exist ends as a failed task; a pull on `grid` from the hub lands
|
||||
on `grid` and its output is followed. A volume or image in use can't be removed; a running
|
||||
container can't be removed (409); a volume needs its name twice. A read-only token gets
|
||||
401 from all fourteen new write paths, through the hub as well. `--force`, `-f`,
|
||||
`--all-tags`, `..%2F..` and a home outside the known ones are refused; a 1.2 MB compose
|
||||
file is refused. Shaped, fleet and streamed sensor queries, `/metrics`, the ten alert
|
||||
rules, terminal typing, the file editor's discard question and service deep links are
|
||||
unchanged. No native browser dialogs anywhere.
|
||||
|
||||
## Quality of life — four chosen by Mitchell, ✅ built and verified 2026-09-20
|
||||
|
||||
1. ✅ **"Where" fits on a phone**: `…/stacks/core · 12 stacks` instead of the whole path (which
|
||||
is still written out underneath). Two homes whose last two folders match fall back to the
|
||||
full path. Measured at 390 px: no option is wider than the box.
|
||||
2. ✅ **How a task ended is a badge**: green `finished`, red `failed · exit 1` with the box
|
||||
outlined in red, grey `cancelled`. Verified on a deploy of an image that doesn't exist.
|
||||
5. ✅ **Duplicate…** on a stack: the New stack dialog opened on a copy of its compose file,
|
||||
named `<name>-copy`. The original's `.env` is copied **on the machine** (`env_from`), mode
|
||||
600, its values never sent to the browser — checked: the secret is nowhere in the page.
|
||||
Anything that would collide with what is running is named before you press Create
|
||||
("a container called X already exists; port 18222 is already published by X") — for
|
||||
every new stack, not only duplicates — and clears as you fix it. Disabled while the
|
||||
original has unsaved edits. Verified: copy deployed beside the original, which never
|
||||
stopped.
|
||||
6. ✅ **The form has Labels and Network.** Labels one per line (`$`, quotes and spaces arrive
|
||||
intact). Network: its own, the machine's (`host`, which turns Ports off), or **join** one
|
||||
that already exists — written as an `external` network so compose doesn't make a second
|
||||
one. Verified on the container: both labels, attached to the chosen network only.
|
||||
|
||||
Found while building them, ✅ fixed:
|
||||
|
||||
- **Cancel on a stack task cancelled nothing.** It killed the `sh` that ran compose, not
|
||||
compose; and as compose still held the output pipe, the task stayed "running" and the
|
||||
stack "busy" until compose finished by itself — for a deploy waiting on a health check,
|
||||
minutes. Tasks now run in their own process group and the whole group is stopped (TERM,
|
||||
then KILL after 4 s); for stacks deployed inside Dockhand, where the process is not the
|
||||
task's child at all, the cancel goes into the container after it. Verified both ways:
|
||||
ended in ~100 ms, compose gone, the stack free for the next action.
|
||||
- **Phone: a stack's task output was squeezed to a line** under the buttons (the same fault
|
||||
as the dialog: a scrolling column whose parts were allowed to shrink), which also put
|
||||
Cancel out of reach. Detail panes keep their parts' heights; services, containers, stacks
|
||||
and cron rechecked on both sizes.
|
||||
- The Duplicate dialog and the stack page under it shared a React key, so the dialog
|
||||
multiplied. An undeployed stack's dot was green ("0 of 0 running"); now neutral.
|
||||
|
||||
# Bug bash 4 — 2026-09-21: the Top tab, and the refresh rate it didn't follow
|
||||
|
||||
Started from Mitchell's first look at Top on his own machine (Firefox on Windows, a window
|
||||
1392 CSS px wide — none of which the tab had been drawn for): "Command" printed over
|
||||
"Threads", the rate in Settings ignored, the symbol beside *logout* a box of hex digits;
|
||||
and make it follow the skins. Method as before, plus two things new: **Firefox** (Playwright's
|
||||
build; the GTK libraries it wants are unpacked from their `.deb`s into
|
||||
`~/.cache/ms-playwright/fflibs` and given to it as `LD_LIBRARY_PATH`, with
|
||||
`PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1` — nothing is installed on `core`), and a layout
|
||||
checker that measures instead of looking — every heading against its neighbour and its own
|
||||
text, every row against its box, every border label, at eight window sizes from 900 to 1920.
|
||||
Run first against the live v83 it reproduced the screenshot to the pixel ("the Command column
|
||||
is 11px wide" at 1392); run after, nothing, in both browsers.
|
||||
|
||||
## Bugs — all ✅ fixed and verified by the script that found them
|
||||
|
||||
1. ✅ **Between about 1280 and 1500 px the Command column collapsed to a letter** and its
|
||||
heading ran under "Threads:" (itself 4 px too narrow for its own text at every size). The
|
||||
table's fixed columns were sized for one window. The process box now measures *itself*
|
||||
(a container query — it is a third of a wide window and all of a narrow one, so the
|
||||
window's width says nothing) and drops the curve, then Threads, then Command, as it
|
||||
narrows; the box also gets half the width rather than 44 %, and no heading can spill
|
||||
into the next. Stacked layout now starts at 1280, not 1100.
|
||||
2. ✅ **The refresh rate reached a machine only through its Dashboard page.** The sampler on
|
||||
each machine was told the rate by an open Dashboard and nothing else — so with the rate
|
||||
at 250 ms, `grid`, `wyse` and the Mac were still sampling at 1 s (measured, live), and
|
||||
Top, Processes, Disks and every widget of theirs "ignored" the setting until somebody
|
||||
opened that machine's Dashboard. The rate is now the fleet's: saved with the UI prefs on
|
||||
the hub, which sets its own sampler and passes it to every machine (`PUT
|
||||
/api/prefs/sampler`, remembered in each `state.json`), and says it again at start-up for
|
||||
a node that restarted or joined since. Verified: Settings 2 s → 250 ms moves the sampler
|
||||
with no Dashboard visit.
|
||||
3. ✅ **Top read the process table at most once a second whatever the rate** — a clamp of
|
||||
my own, exactly what `REFRESH` promises not to do. It reads at the rate: 20 reads in 5 s
|
||||
at 250 ms, 10 at 500 ms. (59 frames a second with 390 rows at 250 ms, no stalls, both
|
||||
browsers — the rows are only redrawn when the table is read.)
|
||||
4. ✅ **Top's `− rate +` changed the rate for half a minute.** It set the browser's copy
|
||||
and never saved it, so the next sync with the hub put it back. It saves — which is also
|
||||
what moves the samplers — survives a reload, and each end is disabled at the limit.
|
||||
5. ✅ **A viewer whose clock is wrong froze the curves.** New points were timed against the
|
||||
machine's timestamps: a browser clock 7 minutes slow waited 7 minutes for its next point
|
||||
(Top *and* the Dashboard); 7 fast, and it added one per tick. Timed on the browser's own
|
||||
clock now. Verified with the page's clock shifted both ways: 5 points in 10 s at a 2 s
|
||||
stride, both pages, both browsers.
|
||||
6. ✅ **Every other point was being skipped.** "Has a stride passed?" was asked with `>=`
|
||||
of a timer that fires a few ms early half the time — so at a 2 s rate and a 2 s stride a
|
||||
curve gained 3 points in 10 s, not 5, and its time axis was quietly wrong. The same
|
||||
question on the server kept every bucket one sample long (14 s points at a 12 s stride).
|
||||
Both now allow half a tick of slack and keep to the stride's own beat; the server times
|
||||
from the last point kept, so a stalled or sleeping machine just keeps the next sample it
|
||||
gets. Unit-tested with jittered samples: 240 samples → exactly 40 points, 12 000 ± 100 ms
|
||||
apart, each the mean of its own six.
|
||||
7. ✅ **The power symbol was a typed `⏻`** (U+23FB, Unicode 9) — not in Windows' fonts, so
|
||||
Firefox there drew the missing-glyph box. Drawn as an SVG like the other icons. Every
|
||||
other symbol the console types was checked against the same risk: all older.
|
||||
8. ✅ **A handful of samples was stretched into a bar chart.** A machine ten seconds into
|
||||
its history filled the whole CPU box with five fat columns. The box is now always the
|
||||
whole history window — a young curve draws in from the right, as btop's does, and a full
|
||||
one fills it edge to edge, at any window (checked at 10 min and at 1 min).
|
||||
9. ✅ **Idle curves vanished**: a core at 10 % rounded to no dots at all in its mini graph.
|
||||
Anything above zero lights the bottom row, which is also btop's rule.
|
||||
|
||||
10. ✅ **Found by the deploy of fix 2 itself: telling the machines once is not enough.** The hub
|
||||
restarted first and told three nodes that did not yet have the endpoint (404); they then
|
||||
updated and carried on at 1 s. The same would happen to any node that is off or
|
||||
restarting when a setting changes. The hub now *keeps* the fleet in step: fifteen seconds
|
||||
after it starts and every two minutes after, it asks each machine for its sampling rate
|
||||
and history window and corrects only what differs (logged as `fleet: grid had ms=1000,
|
||||
now 250`).
|
||||
|
||||
11. ✅ **The net graph redrew its own past every second** (Mitchell, two screenshots a second
|
||||
apart: one tall spike in the first, a comb of medium ones in the second). That box is
|
||||
about 75 dot-columns wide and holds 300 points, and each column drew *the nearest point*
|
||||
— one in four, and a different one in four each time the curve advanced, so spikes came
|
||||
and went and the whole shape changed while the scale stayed put. The CPU graph did the
|
||||
same on a phone. Now a column draws the **peak** of all the points in it, and columns are
|
||||
cut from the data, not from the box (`web/src/graphcols.ts`): a finished column never
|
||||
changes, the graph only slides left by whole columns. Measured by reading every column's
|
||||
height off the canvas once a second under bursty traffic: on the live build the past
|
||||
changed shape in 18 of 47 frame pairs; after, 0 of 46 — and 0 of 45 on a young history.
|
||||
`npm test` (now part of `make test`) scrolls a spiky curve through both rules: nearest-
|
||||
point shows its three spikes as "900,500", then "700", then none; the new rule shows all
|
||||
three, at their heights, on every frame. The scale label now covers what is drawn (the
|
||||
window), not everything kept.
|
||||
|
||||
## Skins
|
||||
|
||||
✅ Top now wears the skin. The ground is the skin's terminal background and every curve and
|
||||
meter is built from its sixteen terminal colours (chosen to be read on exactly that ground),
|
||||
the rest from the usual variables — so the dark skins look like btop and the light ones like
|
||||
btop on paper, and a skin arriving from another device repaints the page in place. Measured
|
||||
in all thirteen: text ≥ 4.5 : 1, everything else ≥ 3 : 1, dots ≥ 2.2 : 1. Two failed —
|
||||
process names in Latte (2.75) and Solarized Light (2.62), where a terminal green is too
|
||||
pale for small type — and use `--ok`, the green each skin was already measured readable
|
||||
with.
|
||||
|
||||
## Checked and fine
|
||||
|
||||
Sort by pid, name, mem and cpu, and reverse; filter by name, command, user and pid, with the
|
||||
count showing matched/total and an empty result saying so; a selection that holds while the
|
||||
table is re-read under it; terminate, on a throwaway `sleep`, asks by name and pid, ends it,
|
||||
and the footer stops offering a process that is gone. Sampler values outside 100 ms–60 s are
|
||||
refused, and a rate slower than the collector's 10 s samples at 10 s. All 14 pages at his
|
||||
window size and on a phone, in both browsers: no errors, no overflow.
|
||||
|
||||
|
||||
# Bug bash 4, later — 2026-09-21: the SMART table outside its card
|
||||
|
||||
Mitchell, from the Disks page of `hamtv` with SMART open: the attribute table's rows ran out
|
||||
through the right-hand side of the card they belong to, the rules and the Thresh column
|
||||
drawn over the page behind.
|
||||
|
||||
12. ✅ **A table cannot be narrower than its widest unbreakable cell, and one of `hamtv`'s
|
||||
was 130px wide.** `.num` sets `white-space: nowrap`, and a SMART raw value is not always a
|
||||
number — the temperature attribute reads `34 (Min/Max 28/39)`. That one cell set the
|
||||
table's minimum width above the card's, so it spilled. Three changes, in this order,
|
||||
because the first two each looked worse than the bug in their own way:
|
||||
- the raw column wraps — but `overflow-wrap: anywhere` broke `4295163906` across two
|
||||
lines as "429516 / 3906", which reads as two numbers. It wraps at spaces only now;
|
||||
- a disk card is given room for six columns of SMART (`minmax(420px, 1fr)` rather than
|
||||
the shared 320px), and the cells' side padding goes 6px → 4px, which together is the
|
||||
~40px it was short by;
|
||||
- if some drive ever reports a raw value long enough to still not fit, the table scrolls
|
||||
inside its card rather than out of it.
|
||||
Verified on all four machines at six widths from 1721px down to a phone, in Chromium and
|
||||
Firefox: nothing leaves its card, no number is broken across lines, and the fallback
|
||||
scrollbar is never reached. The same measurement was run over every other page's cards
|
||||
and boxes — Dashboard, Top, Docker, Alerts, Machines and the rest — and found nothing.
|
||||
|
||||
# Bug bash 5 — 2026-09-21: navigation, and the fleet setting nobody had set
|
||||
|
||||
A sweep of the whole console after a week of changes, not of any one page: every page on every
|
||||
machine at four widths in **Chromium and Firefox**, then the everyday flows, then the URL.
|
||||
Measured rather than looked at — a heading against its neighbour and its own text, a box
|
||||
against what it holds, a request against the page that should have made it.
|
||||
|
||||
## Bugs — all ✅ fixed and verified by the script that found them
|
||||
|
||||
1. ✅ **A link to a page of the hub reloaded onto whichever machine you last had open.** The hub
|
||||
is `null` internally, so `parseHash()?.machine ?? sessionStorage…` read straight past it to
|
||||
the remembered machine: open `#/hub/docker/stack:gitea` after looking at `grid`, reload, and
|
||||
you were on `#/grid/docker`. The URL wins when it has an opinion, and "hub" is an opinion;
|
||||
the remembered machine is for a bare address only. Every link shape now survives a reload,
|
||||
and a bare address still lands where you left off.
|
||||
2. ✅ **From Settings, Machines or Alerts, a link to a machine's page arrived at its Dashboard.**
|
||||
Those three pages only exist on the hub, so moving to a machine bounces off them — but the
|
||||
bounce was decided from inside the machine change, which read the page being *left*, and
|
||||
landed after the URL's own page had been set. It is judged now once the machine and the page
|
||||
have both settled. `#/hub/settings` → `#/grid/disks` arrives at Disks; `#/grid/settings`
|
||||
still falls back to that machine's Dashboard.
|
||||
3. ✅ **A page being navigated away from briefly adopted the next page's selection**, and asked
|
||||
about it: leaving `#/grid/services/ssh.service` for `#/hub/docker/stack:gitea`, Services —
|
||||
still mounted for a moment — opened a log socket for a *service* called `stack:gitea`, on
|
||||
`grid`. Firefox says so in the console; Chromium swallows it. A page now reads the URL's
|
||||
selection only while the URL still points at that page. Checked over every hop between the
|
||||
five pages that keep a selection in the URL, in both browsers.
|
||||
4. ✅ **The hub only held its machines to a sampling rate if somebody had been into Settings.**
|
||||
The re-assert loop read the rate out of the saved UI prefs, which are written on change and
|
||||
not before — so on a hub where the rate had never been touched, a node left on a different
|
||||
rate was never pulled back. It compares against what the hub itself is running, which always
|
||||
exists. Verified by setting a node behind the hub's back: corrected within seconds.
|
||||
5. ✅ **"Everything is up to date." sat in a 20px sliver of a card**, flush to its border, where
|
||||
the package list it shares the card with is meant to be.
|
||||
|
||||
6. ✅ **A terminal of the hub's was asked for on a node.** Found in Firefox against the live
|
||||
console, which says out loud what Chromium swallows: `wss://…/api/nodes/grid/terminals/
|
||||
adfdeecb/attach`, where `adfdeecb` was one of the hub's — `grid` had no terminals at all. A
|
||||
pane builds its address when it connects, from whichever machine the console is pointed at
|
||||
*now*; switch machine while one is reconnecting and the reconnect goes to the new one. The
|
||||
address is fixed when the pane opens. The same was true of a service's log socket, and is
|
||||
fixed with it. Verified by walking the console around the fleet with a terminal open and
|
||||
checking every socket against the machine that actually owns what it asked for.
|
||||
|
||||
## Checked and fine
|
||||
|
||||
Machine titles on every page and against an offline or never-reached machine; the colour a
|
||||
machine wears stays with the name it was added under, not its hostname. Files: create, open,
|
||||
edit, save, rename, delete. Services with its unit and log, cron jobs, containers and stacks,
|
||||
alert rules edited and put back, the terminal, the widget gallery. Login, a wrong password, a
|
||||
session cleared under an open page, logging back in onto the page you were on, and logout
|
||||
leaving nothing behind. The sampler, window and UI prefs endpoints under nonsense and hostile
|
||||
input, and a read-only token refused by all of them. The public demo page and installer. The
|
||||
Top tab on all four machines — including `grid`'s sixteen cores and thirteen filesystems, which
|
||||
scroll inside their boxes — its rate control, its filter, and three skins repainting it.
|
||||
`hamtv`'s eMMC really is called `hB8aP>`; that is what the device reports.
|
||||
|
||||
Firefox earns its place in this: three of the six above were invisible in Chromium, which
|
||||
swallows a failed WebSocket where Firefox writes it to the console. One thing it reports is
|
||||
*not* a fault — a page torn down by navigation while a terminal's socket is still shaking
|
||||
hands logs "can't establish a connection". Sitting still on a page produces none, the address
|
||||
is right, and the terminal belongs to the machine asked; `sockets.cjs` checks that ownership
|
||||
rather than the noise.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Ham Lab (`hamlab`) — the web management console app
|
||||
|
||||
This repo is the **app** only: Go daemon + React/xterm UI, deployed as `hamlab.service`
|
||||
on `core`. Product scope, decisions and roadmap are in `PLAN.md`.
|
||||
|
||||
Everything about *how to work* — persona, response style, write-back rule, hosts,
|
||||
credentials, pending items — lives in the homelab repo **Hamlet** at
|
||||
`/home/core/projects/hamlet`, and applies here unchanged:
|
||||
|
||||
@/home/core/projects/hamlet/CLAUDE.md
|
||||
|
||||
Ham Lab facts that belong to the lab (where it runs, ingress, vault item) go in
|
||||
`hamlet/docs/hosts.md` and `hamlet/docs/pending.md`, not here.
|
||||
@@ -1,26 +1,74 @@
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
# Commit first, then deploy: the version is the commit the binary was built from.
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
GO ?= go
|
||||
PREFIX ?= /usr/local/bin
|
||||
|
||||
.PHONY: all web build dev deploy clean
|
||||
# every architecture a release carries; arm is ARMv6 hard-float, so it covers every Raspberry Pi
|
||||
ARCHES ?= amd64 arm64 arm 386 riscv64
|
||||
LDFLAGS = -s -w -X main.version=$(VERSION)
|
||||
# static, no cgo: the same file runs on glibc, musl (Alpine) and anything else with a Linux kernel
|
||||
GOBUILD = CGO_ENABLED=0 GOOS=linux GOARM=6 $(GO) build -trimpath -ldflags "$(LDFLAGS)"
|
||||
|
||||
.PHONY: all web build go dev cross release gitea-release deploy push-nodes push-nodes-force test clean
|
||||
|
||||
all: build
|
||||
|
||||
web:
|
||||
cd web && npm ci --no-audit --no-fund && npm run build
|
||||
|
||||
build: web
|
||||
$(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab
|
||||
build: web go
|
||||
|
||||
# backend only (assumes web/dist already built)
|
||||
go:
|
||||
$(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlab ./cmd/hamlab
|
||||
$(GOBUILD) -o bin/hamlab ./cmd/hamlab
|
||||
|
||||
# one other architecture: make build-arm64, build-arm, build-386, build-riscv64, build-amd64
|
||||
build-%: web
|
||||
GOARCH=$* $(GOBUILD) -o bin/hamlab-linux-$* ./cmd/hamlab
|
||||
|
||||
dev:
|
||||
cd web && npm run dev
|
||||
|
||||
deploy: build
|
||||
./deploy/install.sh
|
||||
# siblings for every other architecture; installed beside the binary, and the hub pushes
|
||||
# each managed machine the one that matches it
|
||||
cross:
|
||||
@for a in $(filter-out $(shell $(GO) env GOARCH),$(ARCHES)); do \
|
||||
echo "build linux/$$a"; GOARCH=$$a $(GOBUILD) -o bin/hamlab-linux-$$a ./cmd/hamlab || exit 1; done
|
||||
|
||||
# dist/: one static binary per architecture, install.sh, SHA256SUMS. Serve the directory
|
||||
# anywhere (or attach it to a release) and install.sh does the rest.
|
||||
release: web
|
||||
rm -rf dist && mkdir -p dist
|
||||
@for a in $(ARCHES); do \
|
||||
echo "build linux/$$a"; GOARCH=$$a $(GOBUILD) -o dist/hamlab-linux-$$a ./cmd/hamlab || exit 1; done
|
||||
sed 's|@VERSION@|$(VERSION)|' install.sh > dist/install.sh && chmod +x dist/install.sh
|
||||
echo $(VERSION) > dist/VERSION
|
||||
cd dist && sha256sum hamlab-linux-* install.sh > SHA256SUMS
|
||||
|
||||
test:
|
||||
$(GO) vet ./... && $(GO) test ./...
|
||||
sh -n install.sh
|
||||
cd web && npm test --silent
|
||||
|
||||
# installs here (keeping the config, user and password), then updates every managed machine
|
||||
# Offer this build to every install, wherever it is: a release on Gitea, which is what
|
||||
# Settings -> Updates reads. Deliberate, and not part of deploy — deploy pushes to the
|
||||
# machines this hub owns, publishing offers it to everyone else.
|
||||
gitea-release: release
|
||||
./scripts/gitea-release.sh $(RELEASE_ARGS)
|
||||
|
||||
deploy: build cross
|
||||
./bin/hamlab install --prefix $(PREFIX)
|
||||
@sleep 1; $(PREFIX)/hamlab push-nodes || true
|
||||
@echo "deployed $(VERSION); 'make gitea-release' publishes it for everyone else"
|
||||
|
||||
# push the installed binary to every managed machine (what deploy does last)
|
||||
push-nodes:
|
||||
$(PREFIX)/hamlab push-nodes
|
||||
|
||||
# same, interrupting running tasks and attached terminals on the nodes
|
||||
push-nodes-force:
|
||||
$(PREFIX)/hamlab push-nodes --force
|
||||
|
||||
clean:
|
||||
rm -rf bin internal/web/dist web/dist
|
||||
rm -rf bin dist internal/web/dist web/dist
|
||||
|
||||
@@ -25,22 +25,22 @@ stub tab or a roadmap item.
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ header: Ham Lab · core · load 0.42 · ● connected · [logout] │
|
||||
├──────────────┬───────────────────────────────────────────────┤
|
||||
│ ▾ Terminal │ │
|
||||
│ shell 1 ● │ │
|
||||
│ backups │ main content (xterm) │
|
||||
│ + New │ │
|
||||
│ ▸ Services │ │
|
||||
│ ▸ Cron │ │
|
||||
│ ▸ ZFS │ │
|
||||
│ ▸ Dashboard │ │
|
||||
│ Dashboard │ shell 1 ● │ backups │ + │
|
||||
│ Processes ├───────────────────────────────────────────────┤
|
||||
│ Terminal │ │
|
||||
│ Services │ main content (xterm) │
|
||||
│ Cron │ │
|
||||
│ … │ │
|
||||
├──────────────┴───────────────────────────────────────────────┤
|
||||
│ footer: v0.1.0 · up 12d · hamlab.bigham.dev │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Left rail: collapsible groups, one expanded at a time. Active leaf highlighted.
|
||||
- Terminal group: one leaf per session, `● ` when a process is running, "+ New
|
||||
Terminal" pinned at the bottom. Right-click / long-press → rename, close.
|
||||
- Left rail: flat list of pages, active one highlighted. No nested leaves.
|
||||
- Terminal page: tab strip across the top of the pane, one tab per session, `● ` when
|
||||
a process is running, `+` pinned at the right edge. Active tab carries rename and
|
||||
close; double-click renames, middle-click closes. (Until 2026-09-18 the sessions
|
||||
were leaves under a collapsible Terminal group in the rail.)
|
||||
- Non-MVP groups render as disabled leaves with "coming soon" — the rail shape is
|
||||
the product; stubs keep it honest about scope.
|
||||
- **Mobile**: rail becomes a slide-in drawer; xterm fits the viewport; a sticky key
|
||||
@@ -119,31 +119,470 @@ something long in one, switch away and back, refresh — it's all still there.
|
||||
|
||||
## Roadmap (not MVP — notes only)
|
||||
|
||||
- **Updates tab** — **shipped 2026-09-17** (`internal/updates`, `internal/tasks`,
|
||||
`Updates.tsx`): upgradable packages with current → candidate, suite, security badge
|
||||
and description; index age, last upgrade (from `/var/log/apt/history.log`),
|
||||
unattended-upgrades on/off, reboot-required banner with the packages that caused it;
|
||||
Refresh index, Upgrade all (dist-upgrade), Upgrade selected; changelog per package on
|
||||
demand. Runs go through the new **tasks** package: background command, output kept,
|
||||
followed live over `/api/tasks/{id}/stream`, cancellable, listed at `/api/tasks` (the
|
||||
seed of a Task History tab). apt is fully supported; dnf/pacman list only.
|
||||
- **Power** — header ⏻ button (amber when a reboot is pending), dialog that requires the
|
||||
hostname typed back, then `systemctl reboot` / `poweroff`. `/api/me` carries
|
||||
`reboot_needed` so the header knows.
|
||||
|
||||
- **tmux-backed sessions** so shells survive a daemon restart / redeploy. Would spawn
|
||||
`tmux new -A -s hamlab-<id>`; needs `mouse on` and status-bar handling in xterm.
|
||||
- **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal
|
||||
lines (live-tail via WS), start/stop/restart/enable buttons, unit file viewer.
|
||||
- **Cron tab** — parse `/etc/crontab`, `/etc/cron.d/*`, user crontabs, and systemd
|
||||
timers into one table with next-run times; edit via `crontab -` round-trip.
|
||||
- **ZFS tab** — pools, datasets (`used/avail/refer/usedbysnapshots`), snapshot list,
|
||||
scrub status, and the `freeing` counter. `grid` is the target, which implies
|
||||
multi-host.
|
||||
- **Dashboard** — CPU/mem/load/net, disks and SMART (`smartctl -j`), temps and fans
|
||||
(`sensors -j`), UPS from `pwrstat`. Ham Lab collects on a timer into a SQLite
|
||||
ring (e.g. 10 s resolution, 7 days) and draws sparklines. This is where the
|
||||
"collects its own data" requirement lands; no Prometheus dependency.
|
||||
- **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini /
|
||||
`wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a
|
||||
host level at the top, exactly like Proxmox's datacenter → node tree. Design the
|
||||
MVP URL space so `host` can be inserted later without breaking clients.
|
||||
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session
|
||||
list + revoke, optional NPM access list restricting to WireGuard/LAN.
|
||||
- **Files** — browse/upload/download/edit under `/`, with a sudo toggle.
|
||||
- **Cron tab** — **shipped 2026-09-17** (`internal/cron`, `Cron.tsx`): one list built
|
||||
from `/etc/crontab`, `/etc/cron.d/*`, every user crontab (via sudo), the scripts in
|
||||
the `run-parts` directories those drive, plus systemd timers. Next run computed by an
|
||||
in-house cron-expression evaluator (tested: ranges, steps, names, dom-OR-dow rule,
|
||||
`@` aliases); last run from the journal's `(user) CMD (command)` lines (cron logs via
|
||||
syslog to journald; reading needs sudo/adm). Commented-out jobs show as disabled;
|
||||
job-shaped lines inside comment blocks (Debian's header examples) are ignored. Per
|
||||
job: Run now (background, as the job's user, `sh -c` with crontab `%` rules, output
|
||||
captured), Enable/Disable (comments the line in place, refuses if the file changed
|
||||
underneath), Edit source (textarea; validated line-by-line; user crontabs installed
|
||||
with `crontab -`, system files written via `sudo tee`), Cron log, and Output file
|
||||
when the command redirects to one. Timers: Run now (starts the activated service),
|
||||
start/stop/enable/disable, live logs of the service via the Services tab's tail.
|
||||
- **Disks tab** — **shipped 2026-09-17** (`internal/storage`, `Disks.tsx`), replacing
|
||||
the planned ZFS tab: block devices from `lsblk -J` (kind, model, size, temperature
|
||||
from the device's hwmon node, partitions with filesystem usage, live read/write/busy
|
||||
from the metrics sampler); SMART via `smartctl -j` when smartmontools is present (one
|
||||
button installs it as a task otherwise): health, temperature, power-on time, self-test
|
||||
status, ATA attribute table with the failure-predicting IDs flagged, NVMe endurance and
|
||||
error counters, short/long self-tests. Spinning disks are read with `-n standby` so
|
||||
the tab never wakes a sleeping drive; "Wake and read" is explicit. ZFS section when
|
||||
the tools and module are there: pools with health, capacity, fragmentation, last scan
|
||||
and vdev tree; scrub start/pause/stop; datasets tree with used/refer/in-snapshots/
|
||||
snapshot count/compressratio/mount; per-dataset snapshot list and "Snapshot now".
|
||||
Verified against `grid` (raidz2 `tank`, 10 datasets, 515 snapshots, 8 WD40EZAZ + 970
|
||||
EVO Plus SMART). Snapshot destroy and dataset property editing are deliberately not
|
||||
there yet.
|
||||
- **Dashboard** — **first cut shipped 2026-09-17** (`internal/metrics`, `Dashboard.tsx`):
|
||||
CPU (total + per core), memory/swap, load, uptime, hwmon temps and fans read straight
|
||||
from `/sys` (no lm-sensors), per-interface network rates, per-disk read/write/busy
|
||||
from `/proc/diskstats`, filesystem usage, UPS via `sudo -n pwrstat -status`. Sampled
|
||||
every 2 s into an in-memory ring of 10 min; the UI (the **Dashboard** tab
|
||||
since 2026-09-19) reads it all through the sensor API — see Sensors below. **Customizable**: a
|
||||
Customize mode reorders and hides modules (CPU, Memory, Load, Uptime, Temperatures,
|
||||
Fans, Network, Disks, Filesystems, UPS) and hides individual rows (an interface, a
|
||||
mount, a disk, a sensor); the layout is stored server-side in `state.json` via
|
||||
`GET/PUT /api/prefs/dashboard` so every device sees the same dashboard. The update
|
||||
interval (100 ms – 2 s) is part of the layout and drives both the browser's polling and
|
||||
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
|
||||
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
|
||||
- **Sensors** — **shipped 2026-09-19** (`internal/sensors`, `internal/metrics/sensors.go`,
|
||||
`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;
|
||||
catalog, single value, history, a **shaped query** (the client sends the object it wants
|
||||
and gets it back filled in — GraphQL's idea without GraphQL, since sensors differ per
|
||||
machine and a static schema would collapse to `sensor(id:)`), the same query as a live
|
||||
SSE stream, Prometheus `/metrics`, read-only `sensors:read` tokens (never full tokens in
|
||||
a URL), status sensors (services, updates, ZFS, SMART), a widget library, custom widgets
|
||||
on the Dashboard tab (briefly renamed "Monitors" 2026-09-19, back to "Dashboard"
|
||||
2026-09-20 at Mitchell's request; the view id and URL were always `dashboard`), and a
|
||||
frameable `/embed/widget` for Glance and friends. (An external demo page was deployed at
|
||||
`hamlab-demo.bigham.dev` and linked from the Widgets tab 2026-09-19; **both removed
|
||||
2026-09-23** at Mitchell's request — the Widgets tab shows the same thing against the
|
||||
machine you are signed in to, so the second copy earned nothing and cost a public
|
||||
read-only token, which was revoked with it. `examples/external-demo/` stays as an example
|
||||
to read.) The stock Dashboard modules and the Disks
|
||||
tab read the sensor API too (`sampleAdapter.ts`); `/api/metrics` is retired.
|
||||
Also per-service and per-container sensors (memory and CPU from cgroups), tokens limited
|
||||
to ID patterns, and `/api/fleet/sensors/query` for the whole fleet in one request.
|
||||
- **Alerts** — **shipped 2026-09-19** (`internal/api/alerts.go`, `Alerts.tsx`): rules over
|
||||
sensors (`container.*.running == false for 120 s`), evaluated by the hub every 15 s for
|
||||
itself and every managed machine in one shaped query each; `hamlab.reachable` is supplied
|
||||
by the hub so "a machine stopped answering" is a rule like any other. Ten defaults
|
||||
(machine offline, container down/unhealthy, failed service, disk full, pool unhealthy,
|
||||
SMART failing, UPS on battery, CPU hot, memory). Firing and resolved are events: kept
|
||||
(last 200, in `state.json`), shown on the hub-only Alerts tab with a count on the rail,
|
||||
and POSTed to channels — **ntfy** (title/priority headers) or a **webhook** (JSON; Home
|
||||
Assistant, Slack). The rule editor shows what a selector matches across the fleet as
|
||||
you type. Not done: quiet hours, per-rule channels, Web Push straight to the PWA.
|
||||
- **Docker tab** — **shipped 2026-09-19** (`internal/api/docker.go`, `Docker.tsx`):
|
||||
containers grouped by compose stack with live memory and CPU from the container
|
||||
sensors, start / stop / restart (asked first), logs that follow (polled with `--since`,
|
||||
no held connection), inspect (env *names* only), and images / volumes / networks
|
||||
tables. Works on every machine through the hub; `#/grid/docker/immich_server` links to
|
||||
one. **Stacks** (`internal/api/stacks.go`): every compose project, found from its
|
||||
containers' labels, with its file to edit and Deploy (`up -d`), Update (pull, then
|
||||
deploy), Restart and Stop as followed tasks. The file is looked for where the label says
|
||||
*and through every container's mounts*, because an orchestrator that runs compose inside
|
||||
its own container (Dockhand here; Dockge, Portainer) records a path in its own
|
||||
filesystem; compose is then run **inside that container**, so `.env`, relative paths
|
||||
and the compose labels stay exactly as deployed (verified: labels identical after a
|
||||
redeploy). Save validates with `compose config` first, keeps the previous file as
|
||||
`<file>.hamlab-bak`, and never deploys by itself. A stack deployed from another host
|
||||
(Dockhand on `core` driving `grid` over tcp) has no file on that machine, so the node
|
||||
alone lists it read-only — but **the hub fills that in** (`hubStacks`): asked about a
|
||||
node, it resolves the files on itself and runs compose here with `DOCKER_HOST` pointed
|
||||
at the node's `tcp://…:2375`, the way it was deployed; the task runs on the hub and the
|
||||
node's page follows it. Also per stack: every file it was deployed from (chips), its
|
||||
**`.env`** (values masked until "Reveal and edit"; validated in place, put back if
|
||||
compose objects), **what Deploy would change** (a line diff against the copy kept at the
|
||||
last deploy from here, `<file>.hamlab-deployed`, else the copy the last save replaced;
|
||||
`.env` values never shown), and **image update checks** — asked for, never polled, since
|
||||
each is a registry request Docker Hub counts; remembered six hours. A save refuses a
|
||||
file changed on disk meanwhile.
|
||||
**Making and unmaking (2026-09-20):** "+ New stack" — a name, a folder, compose text and
|
||||
an optional `.env` (written root-only), checked by compose before it is kept, then
|
||||
"Create and deploy" or "Create only". "+ New container" is the same dialog opened on a
|
||||
form (image, ports, volumes, environment, restart, command, host networking) that writes
|
||||
the compose for you: a container made here is a one-service stack, so it stays editable
|
||||
and redeployable, where a bare `docker run` could only be thrown away and typed again.
|
||||
New stacks go in a **home** — a folder where two or more stacks are already deployed from
|
||||
(Dockhand's `stacks/core` here; `~/stacks` on a machine with none) — and are deployed the
|
||||
way their neighbours were (inside `dockhand`, or over tcp to a node from the hub). Homes
|
||||
are also scanned, so a stack with no containers ("not deployed": never deployed, or taken
|
||||
down) is still listed. **Down** removes a stack's containers; **Delete…** (type its name)
|
||||
takes it down and moves its own files to `<home>/.hamlab-trash/<name>-<time>/` — moved,
|
||||
not erased; volumes and bind-mounted data are never touched. A stopped container can be
|
||||
**removed** (a running one must be stopped first, on purpose). Images: **pull** (a followed
|
||||
task), **remove** (unforced: Docker refuses one still in use) and **prune** (dangling only).
|
||||
**Volumes (2026-09-20):** each says which containers use it; one that nothing uses can be
|
||||
deleted — a named one by typing its name (it may be the data of a stack that is only
|
||||
down), an anonymous one by confirming its full name — and "Prune anonymous" clears the
|
||||
nameless leftovers of recreated containers, never named volumes. Images are marked "in
|
||||
use", and "Remove unused" takes every image no container uses. Nothing is ever forced:
|
||||
Docker refuses what is still in use, and that refusal is the answer. Still absent:
|
||||
removing networks.
|
||||
- **Top merged into Processes** — **2026-09-23.** The two tabs answered the same question
|
||||
from the same data (Top already read `/api/procs`, the endpoint the Processes table was
|
||||
built on), so at Mitchell's instruction there is now one tab, **Processes**, showing what
|
||||
Top showed: the btop-style whole-machine screen. `Procs.tsx` is deleted; the `top` view
|
||||
id and its route are gone. A rail order saved before this keeps its shape — `railorder.ts`
|
||||
maps the retired `top` to `procs` and drops the duplicate, so the merged page sits where
|
||||
Top sat rather than falling to the bottom.
|
||||
- **Dashboard retired** — **2026-09-23**, at Mitchell's instruction, the same reasoning that
|
||||
merged Top into Processes: the app repeated itself. The customisable module list is gone
|
||||
(`Dashboard.tsx` deleted), and with it `/api/prefs/dashboard`, `applyPrefs`, and the
|
||||
per-user `dashboard` blob in `state.json`. The Processes tab already shows those numbers
|
||||
on one screen. `fmtBytes`/`fmtRate` moved to `fmt.ts` (Disks, Files and Space use them);
|
||||
`MONITOR_HISTORY` and `seriesOf` went with the page. The default view after login, after
|
||||
switching machines, and when a hub-only page is left behind, is now **Processes**. A saved
|
||||
rail order naming `dashboard` degrades quietly — `orderBy` drops ids with no page.
|
||||
**Widgets stays and says what it is for**: a notice at the top explains these are for
|
||||
dashboards *outside* Ham Lab — Glance, Home Assistant, a wall display — served from
|
||||
`/embed/widget` with a read-only sensor token, not a second copy of the console's screens.
|
||||
**It also closed a real bug.** `applyPrefs` set the machine's sampling rate from
|
||||
`intervalMs` inside the dashboard layout, and its `else` branch reset the sampler to the
|
||||
default whenever that field was absent. Since layouts became per-user, one account saving
|
||||
a dashboard could silently reset the sampling rate for the whole machine, overriding the
|
||||
fleet refresh rate set in Settings. The sampler now has one source: `refreshMs` in the UI
|
||||
prefs, which the hub fans out, remembered per machine as `sampler_ms`.
|
||||
- **ZFS tab, with scheduled snapshots** — **shipped 2026-09-24** (`internal/storage/snapshots.go`,
|
||||
`internal/api/zfs.go`, `ZFS.tsx`). Pools, datasets and snapshots moved out of Disks —
|
||||
Disks is the hardware, ZFS is the filesystem on top of it — and the tab gained what it was
|
||||
split out for: **snapshot schedules**. Hourly, daily, weekly or monthly, per dataset,
|
||||
optionally recursive, with a **retention period** and a floor that keeps the newest few
|
||||
whatever their age. A missed window (the machine was off) runs once on return rather than
|
||||
being skipped.
|
||||
**The naming is zfSnap's, deliberately.** `grid` has run zfSnap v1.11.1 from root cron at
|
||||
02:30 with a 6-week TTL since before this repo existed, and it works. So rather than
|
||||
invent a second convention for two tools to disagree over, this writes and reads exactly
|
||||
what zfSnap does — `tank@2026-09-24_02.30.01--6w`, **retention encoded in the name**. Three
|
||||
consequences: Ham Lab shows and expires the snapshots zfSnap already took; the two can run
|
||||
side by side without fighting, since both only remove what has passed the expiry written
|
||||
into its own name; and changing a schedule never re-dates old snapshots, because each
|
||||
carries the rule it was taken under. The page **detects the existing cron job**
|
||||
(zfSnap, zfs-auto-snapshot, sanoid, or a bare `zfs snapshot`) and says so, warning if a Ham
|
||||
Lab schedule is running as well.
|
||||
**Nothing is destroyed unseen.** Every schedule answers *what would it delete?* with the
|
||||
list, sizes and dates, before Run now touches anything; only names that parse as a stamp
|
||||
and a TTL are candidates, so a snapshot taken by hand is invisible to it; and a snapshot
|
||||
deleted by hand needs its full name typed back. The scheduler is a 30 s tick in the daemon,
|
||||
and `Run now` calls the same code, so the button and the timer cannot drift apart.
|
||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||
each repaints the terminal too. **Each machine wears a colour** (`accents.ts`): a stripe
|
||||
across the header and its name in the switcher — picked from ten swatches or any colour
|
||||
on the Machines page, else derived from its name — saved with the theme on the hub.
|
||||
- **Multi-host** — **Phase 1 shipped 2026-09-18** (`internal/nodes`, `internal/tlsauto`,
|
||||
`MachineSwitcher.tsx`, `Machines.tsx`). One binary, hub and spoke: a host whose
|
||||
`config.json` lists `nodes` is a hub; any other is a standalone node with its full UI.
|
||||
Node side: `"tls": "auto"` makes a self-signed cert in the data dir and serves HTTPS;
|
||||
`POST /api/tokens {password,name}` trades the login password for a long-lived bearer
|
||||
token (SHA-256 stored in `tokens`), accepted by the auth middleware alongside the
|
||||
cookie; `DELETE /api/tokens/{name}` revokes. Hub side: `POST /api/nodes {name,url,
|
||||
password}` peeks the node's certificate (trust on first use, fingerprint pinned),
|
||||
obtains a token named `hub:<hostname>`, stores `{name,url,fingerprint,token}`; a
|
||||
15 s prober keeps each node's status; `/api/nodes/{name}/…` is a reverse proxy to the
|
||||
node's `/api/…` (bearer swapped in, cookie and Origin stripped, streaming flush) that
|
||||
carries WebSockets, so terminals, log tails and task output work remotely.
|
||||
Frontend: `setMachine()` rewrites every `/api/…` path; the rail has a machine
|
||||
switcher at the top and a Machines page to add/remove/open. Pages remount on switch;
|
||||
terminals live on the node. Nodes: `grid`, `wyse`. **Phase 3 (update from hub)
|
||||
shipped 2026-09-18**: node `POST /api/self-update` takes a binary, checks it answers
|
||||
`version`, keeps `hamlab.prev` in the data dir, installs over the executable via
|
||||
`sudo install`, replies, then restarts the service; the hub pushes its own executable
|
||||
with `POST /api/machines/{name}/update` or `/api/machines-update-all` (architecture
|
||||
checked first via `/api/me.arch`), the Machines page shows an **outdated** badge and
|
||||
Update buttons, and `hamlab push-nodes` does the same from the CLI — `make deploy` runs
|
||||
it last, so one deploy on the hub updates every machine. First install on a new host
|
||||
is still by hand (scp binary + `install.sh`, `hamlab passwd`, `tls auto`). Still to
|
||||
do: Phase 2 Datacenter overview; optional firewall rule limiting a node's port to
|
||||
the hub. **Cross-architecture** (2026-09-18): `make deploy` also builds
|
||||
`bin/hamlab-linux-arm64`, `install.sh` installs it beside the binary as
|
||||
`hamlab-arm64`, and the hub picks the sibling matching a node's `/api/me.arch` when
|
||||
pushing — the Mac mini (Fedora Asahi, aarch64, node `mac`) updates like the rest.
|
||||
- **Portability + install** — **shipped 2026-09-18** (`internal/setup`, `internal/priv`,
|
||||
`install.sh`, `make release`): the binary installs itself (`hamlab install` /
|
||||
`uninstall`; systemd or OpenRC; root, sudo or doas), all builds static on amd64, arm64,
|
||||
arm, 386 and riscv64, `install.sh` as the curl-able bootstrap with checksums. Every
|
||||
privileged call goes through `priv` instead of a literal `sudo -n`. Updates tab acts on
|
||||
dnf, zypper, pacman and apk as well as apt. Services list falls back to the plain
|
||||
table on systemd < 246; Disks falls back to `/sys/block` without `lsblk`. Listener
|
||||
uses `IP_FREEBIND`. Self-update: pre-flight `check` against the live config, atomic
|
||||
replace, exec in place. Config default moved to `~/.config/hamlab` (existing
|
||||
`~/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
|
||||
3.18/3.20 (OpenRC + doas, non-root), plus the four lab hosts. Not done: an OpenRC
|
||||
backend for the Services tab; 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**
|
||||
(`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
|
||||
request, holds everything about accounts: your own password, your sessions, the
|
||||
accounts; Settings keeps appearance, rates, tokens and About). **Rail order is per account** too (2026-09-22,
|
||||
`railorder.ts`, `Rail.tsx`): "⇅ Arrange" at the foot of the rail puts a handle on every
|
||||
page; drag one up or down (pointer events, so a finger works), let go and the order is
|
||||
saved in the UI prefs with the theme; "Reset" restores the built-in order; pages added
|
||||
in a later build go after the ones the saved order names. The login is a user name and a password. `config.json` holds `users`
|
||||
(name, argon2id hash, created, last seen); the old single `password_hash` becomes the
|
||||
account named after the OS user the console runs as on first boot, its signed-in
|
||||
sessions become that account's, and the machine-wide theme/accents/dashboard become
|
||||
theirs — nothing to redo after the upgrade. A session carries its user, an ID, when it
|
||||
was made and last seen, the IP and the browser; **Users → Sessions** lists yours
|
||||
("Safari on iPhone · 10.0.0.5 · seen 2 min ago"), revokes one, or signs out everywhere
|
||||
else. **Users → Users** lists accounts with their session counts, adds one, removes
|
||||
one (their sessions and settings go too; never yourself, never the last), and resets
|
||||
another's password (they are signed out); removing and resetting take your own
|
||||
password first. Everyone signed in can do everything — a household console, not a
|
||||
tenancy — and each machine keeps its own accounts: a hub reaches a node with its token,
|
||||
so pairing (Machines → Add) now takes a user and password on the node, the user
|
||||
optional while it has one account. Per account: `ui` (theme, accents) and `dashboard`
|
||||
in `state.json` under `users.<name>`. Still the machine's: refresh rate (it is how
|
||||
often every machine samples — it rides in the UI prefs object for the client but is
|
||||
stored once, machine-wide), graph history, alert rules, terminals (the shells run as
|
||||
one OS user anyway). A login attempt for a missing user takes as long as a wrong
|
||||
password, so the form can't list accounts. Every write's log line now names the
|
||||
account or token (`core@10.0.0.5 via …`), and Access tokens say who issued them. CLI:
|
||||
`hamlab passwd [user]` (makes the account if new; reads stdin when piped), `hamlab
|
||||
users`, `hamlab user-rm <user>`; `hamlab install` names the first account after the
|
||||
service user. Not done: a read-only viewer role.
|
||||
- **Space tab** — **shipped 2026-09-22** (`internal/space`, `internal/api/space.go`,
|
||||
`Space.tsx`), asked for as "exactly WizTree": what is using a disk. The unit is a whole
|
||||
volume, picked from a dropdown at the top: a **disk on its own** (every filesystem its
|
||||
partitions back) or a **ZFS pool as a whole** (every mounted dataset) — a pool's member
|
||||
disks are listed under it with the layout (raidz2, 8 disks: sda …) and never offered
|
||||
apart, since one disk's share of a pool means nothing (Mitchell's call, same day, after a
|
||||
first cut that listed the disks as chips). The walk runs as a helper process, `hamlab space-walk <roots>`,
|
||||
as root through `priv` when the host allows (so `/var/lib/docker` and `/root` count), 32
|
||||
workers reading directories depth-first, never crossing a filesystem boundary by itself;
|
||||
each root is grafted into one tree at its path. Files under 128 KiB are not kept one by one
|
||||
but summed per folder as "N smaller files" — what keeps a scan of millions of files to tens
|
||||
of MB in the daemon; the tree crosses back as a varint stream (`codec.go`), and the helper's
|
||||
memory goes with it. core's root: 646k files in ~1.2 s. Progress (files, bytes, current
|
||||
folder) every 250 ms; Cancel sends SIGTERM. The page: picker → Scan/Rescan → **Folders**
|
||||
(a tree largest-first: size, share of parent as a bar, files, folders, modified; expand in
|
||||
place) over a **treemap** of the selected folder — squarified, nested to the file, tiles
|
||||
coloured by kind (video, images, audio, archives & disk images, documents, code & config,
|
||||
databases & logs, other; the validated categorical set, dark and light) with folders as
|
||||
labelled boxes; hover for path, size and share; click a folder to drill, breadcrumbs and ↑
|
||||
to come back. The map asks the server for the folder pruned to what a tile could show
|
||||
(`min` = 1/6000 of the folder, 24 deep; deeper folders come back as one lump), the list
|
||||
one level at a time. **Largest files**: the 1,000 biggest with a path filter. Works on
|
||||
every machine through the hub.
|
||||
**Smarter and faster (2026-09-23, at Mitchell's request):** a scan is *kept* — in memory
|
||||
and in `<data>/space/<target>.scan` (the same varint stream) + `.json`, read back after a
|
||||
restart — and the next scan *starts from it*: the helper is handed the last tree on stdin
|
||||
and reads a directory again only when something says it changed. On a ZFS pool with
|
||||
snapshots, the scan records the newest snapshot of each dataset at start (`bases`), and the
|
||||
next one runs `zfs diff -H -F <base>` per dataset: only the directories it names are
|
||||
read, their ancestors kept but looked into, everything else kept wholesale without a
|
||||
stat. The diffs run side by side and one is given three minutes — `timeout -s KILL`
|
||||
inside sudo, since Debian's sudo gives the command its own session and a process-group
|
||||
kill from outside never reached zfs — after which that dataset is walked by mtime
|
||||
instead. On `tank` a diff is metadata-bound like the walk: 8 s to 3 min per dataset for
|
||||
a handful of changes, and `immich`, which changes constantly, ran past twenty minutes
|
||||
the first time. A refresh of `tank` took 4 min 19 s against 7 min for the full scan. Elsewhere (ext4, or a dataset with no snapshot) a directory whose mtime — to the
|
||||
nanosecond — is unchanged keeps its list and only its kept files are stat'ed again, since
|
||||
a file can grow without its directory noticing. A **cancelled** scan keeps what it walked,
|
||||
marks the folders it never got to (`partial`, "not yet read" in the tree) and **Resume**
|
||||
reads just those. core: full 1.2 s → refresh 0.3 s (2 folders read, 84,755 kept). The
|
||||
status line says which (`method`, `by_root`, read/kept counts); **Forget** drops the saved
|
||||
result so the next scan is full. The old result stays browsable while a refresh runs.
|
||||
**Treemap rethought** the same day: every file a tile grouped by folder — folders are only
|
||||
a thin line and a name strip on the first level — instead of nested boxes with headers,
|
||||
which on Linux's single-child folder chains was a staircase of headers around nothing.
|
||||
Not done: delete from here, a size-over-time history, per-user column choices.
|
||||
- **Updater** — **shipped 2026-09-23** (`internal/selfupdate`, `internal/api/update.go`,
|
||||
the Updates card in `Settings.tsx`, `scripts/gitea-release.sh`). A Ham Lab asks one URL
|
||||
what the newest build is and installs it on request. `update.url` in `config.json` is
|
||||
either a Gitea/Forgejo repo API base (`…/api/v1/repos/<owner>/<repo>`, whose newest
|
||||
release names the version and carries the binaries as assets) or any static directory
|
||||
holding `VERSION`, `SHA256SUMS` and `hamlab-linux-<arch>` — which is exactly what
|
||||
`make release` writes and what `install.sh` already downloads, so the demo site, a
|
||||
folder on a NAS and a Gitea release are all the same thing to it. `"off"` never looks.
|
||||
`update.token` is sent when the source is behind a login, so a **private** Gitea serves
|
||||
its own fleet without the instance being opened to the world. The binary is verified
|
||||
against `SHA256SUMS` before it goes anywhere near the running one, then takes the same
|
||||
path `POST /api/self-update` always did (runs `version`, runs `check` against this
|
||||
machine's config, atomic replace keeping `hamlab.prev`, re-exec in place) — that path is
|
||||
now one shared method, `installBinary`. Version ordering is `install.sh`'s `vnum()` rule
|
||||
ported to Go and tested against it, so the daemon and the installer never disagree about
|
||||
which of two builds is newer; the commit count since the tag counts, the hash does not.
|
||||
Checked ~90 s after boot and daily (`update.check: "never"` switches that off), never
|
||||
installed unasked — an update restarts the daemon and drops open terminals, and the
|
||||
dialog says so. Settings → **Updates**: installed, published, when it last looked, the
|
||||
source, the release notes, Check for updates, Install; the rail badges Settings when a
|
||||
newer build is out (`update_available` rides on `/api/me`). Publishing is
|
||||
`make gitea-release` → `scripts/gitea-release.sh`, which creates the release and uploads
|
||||
the five architectures, `SHA256SUMS`, `install.sh` and `VERSION`; it is deliberately
|
||||
**not** part of `make deploy`, because deploy pushes this build to the machines this hub
|
||||
owns while publishing offers it to every install anywhere, and one tag per deploy would
|
||||
be noise. Re-publishing a version needs `--force`.
|
||||
**Public since 2026-09-23**, at Mitchell's instruction and after the trade was put to him
|
||||
twice: Gitea's instance-wide `REQUIRE_SIGNIN_VIEW` is off and `mitchell/hamlab` is marked
|
||||
public, so anyone can browse it, clone it and download a release with no account. The
|
||||
other five repos there are each marked private and are invisible — an anonymous request
|
||||
for one is a 404, not a login prompt — registration is still disabled, and new repos
|
||||
still default to private. No token is needed by anything any more (the read-only one was
|
||||
revoked; only `hamlab-publish`, which creates releases, remains). Gitea resolves
|
||||
`releases/download/latest/<asset>`, so `install.sh` and `HAMLAB_URL` now point straight
|
||||
at the newest release and never need changing. What is public is the source, the history,
|
||||
`BUGS.md` and this file — which describes the lab and names the console's hostname.
|
||||
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, optional NPM
|
||||
access list restricting to WireGuard/LAN.
|
||||
- **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from
|
||||
`/` with breadcrumbs, an editable path box, sort, hidden-file toggle and a filter;
|
||||
rows show type icon, size, modified, perms and owner, link targets and broken links.
|
||||
Text files (≤ 2 MiB, sniffed) open in an editor with Ctrl+S / Save; images preview;
|
||||
everything else downloads. Upload via drag-and-drop or picker with per-file progress
|
||||
(streamed, no size limit). New folder / file, rename, move, chmod, copy path, delete
|
||||
(folders need the name typed back), and "open in terminal", which `cd`s the active
|
||||
shell (or a fresh one) to the folder. **Sudo toggle**: root access re-executes the
|
||||
same binary as `hamlab fs <op>` under `sudo -n`, streaming bodies over stdin/stdout,
|
||||
so both modes share one implementation and one set of rules (absolute paths only;
|
||||
`/`, `/etc`, `/usr`, … are never deleted or moved whole). Every write is logged with
|
||||
the caller's IP.
|
||||
- **Docker read-only view** — `docker ps`/logs; dockhand stays the source of truth.
|
||||
- **Notifications** — push on service failure / SMART warning / UPS event.
|
||||
- **Terminal extras** — search, copy-mode, font/theme, split panes, upload via
|
||||
drag-drop into cwd.
|
||||
|
||||
- **Settings + themes** — **shipped 2026-09-18** (`Settings.tsx`, `themes.ts`): Settings
|
||||
page with Appearance (Dark, Light, Cyberpunk, Retro — CSS variables on `<html>` plus a
|
||||
matching xterm palette, applied before first paint from localStorage and synced hub-side
|
||||
via `GET/PUT /api/prefs/ui` so the choice follows the login), Account (change password,
|
||||
`POST /api/password`, current password required), Access tokens (list/revoke the tokens
|
||||
hubs hold), About (version, host, arch, uptime, certificate).
|
||||
|
||||
## To do
|
||||
|
||||
- **Deprecate copyparty** — asked for 2026-09-23, not started. `copyparty` on `grid`
|
||||
(`copyparty.service`, `:3923`, `/etc/copyparty.conf`, serving all of `/tank` as user
|
||||
`core` with one account behind NPM) overlaps the **Files** tab almost entirely. The aim
|
||||
is to stop the service and keep only Ham Lab. What Files must gain first, in the order
|
||||
that matters:
|
||||
- **Multi-select and bulk actions.** copyparty selects many rows and moves, deletes or
|
||||
downloads them together; Files acts on one row at a time.
|
||||
- **Download a folder**, and a multi-file download, as a streamed zip or tar — the thing
|
||||
copyparty is reached for most.
|
||||
- **Resumable uploads.** copyparty's up2k chunks a file, survives a dropped connection
|
||||
and skips what is already there; Files streams one PUT, so a phone upload that drops at
|
||||
90% starts over.
|
||||
- **A gallery.** Thumbnails for a folder of images and video, and play audio/video in
|
||||
place — `/tank/media` and `/tank/immich` are most of what is browsed.
|
||||
- **Search** below the current folder by name, since `find` in a terminal is the fallback
|
||||
today.
|
||||
- *Decide when built:* whether share links (copyparty's public per-path links) are wanted
|
||||
at all — they are the one feature that hands out access without a login, and Ham Lab has
|
||||
no notion of that; if not, say so in the docs rather than leaving a gap unexplained.
|
||||
- *After:* `systemctl disable --now copyparty`, remove its NPM host, keep
|
||||
`/etc/copyparty.conf` until a rollback is clearly not needed, then update
|
||||
`hamlet`'s `docs/hosts.md` bare-metal list, `docs/gotchas.md` (the "`docker ps` is not
|
||||
the inventory" note names it) and the `grid` row in `CLAUDE.md`.
|
||||
|
||||
- **Deprecate Dockhand** — asked for 2026-09-23, not started; the *homelab* half of this
|
||||
(migrating every stack off Dockhand's data dir, then removing the container) is
|
||||
`hamlet`'s `docs/pending.md` → "Retire Dockhand; Ham Lab becomes the orchestrator",
|
||||
agreed 2026-09-20. This entry is the *app* half only: what Ham Lab must do before that
|
||||
migration can start. Its Stacks page already creates, edits, deploys, updates, restarts,
|
||||
stops, downs and deletes stacks on every machine, so the gaps are Dockhand's extras:
|
||||
- **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 to drop it — most
|
||||
stacks here have no row in that table anyway, so dropping it may be honest.
|
||||
- **Scheduled image updates.** Ham Lab checks for new images only when asked (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 on purpose.
|
||||
- **Webhooks / per-host environments**, if either is in use — inventory before deciding.
|
||||
- **Deploying from a host path without an orchestrator container.** Ham Lab already runs
|
||||
compose inside `dockhand` because that is how the stacks were deployed; once they move
|
||||
to a neutral home the "via" path is unused for them, but a stack deployed to `grid` or
|
||||
the Mac still goes over `tcp://<node>:2375` from the hub. Decide whether Ham Lab should
|
||||
instead ask the node's own Ham Lab to deploy locally, which would end the `*:2375`
|
||||
exposure (its own item in `docs/pending.md`).
|
||||
- *Not app work, but the trigger:* nothing here blocks starting the migration except the
|
||||
inventory. Do that first.
|
||||
|
||||
- **A Virtualization tab** — asked for 2026-09-23, not started, and **large**. Run and
|
||||
manage **LXC containers and virtual machines** from the console, the way Proxmox does —
|
||||
which is the comparison Ham Lab has invited since its first line. Nothing exists yet.
|
||||
What a future session has to settle before writing code: which stack (libvirt/KVM +
|
||||
`virsh` is the obvious one for VMs; LXC or Incus for containers, and Incus covers both);
|
||||
whether it manages what is already installed or installs it; where disk images live
|
||||
(`tank` on `grid`, local NVMe elsewhere); console access (SPICE/VNC proxied through the
|
||||
daemon, or a serial console over the existing terminal machinery, which would reuse
|
||||
`internal/term`); snapshots and clones; and what it does on a host with no virtualisation
|
||||
at all, which is every host here today. Worth scoping as its own plan before starting.
|
||||
|
||||
- **Files explorer: cleanup and ease of use** — asked for 2026-09-23, not started.
|
||||
General polish and ease of use. The **Deprecate copyparty**
|
||||
item names the heavier pieces (multi-select and bulk actions, folder download, resumable
|
||||
uploads, a gallery, search) — worth doing the two together, since they are the same tab.
|
||||
|
||||
- **A KVM tab** — asked for 2026-09-24, not started. **Dead simple is the brief**: paste one
|
||||
HTTP link, the tab shows whatever UI that KVM serves, and the keyboard goes to it. **This
|
||||
is KVM the keyboard-video-mouse switch, not KVM the hypervisor** — the Virtualization item
|
||||
above means the other one.
|
||||
- **The device**: a **Sipeed NanoKVM** attached to `grid` at **`10.0.0.217`** (MAC
|
||||
`48:da:35:6f:d0:31`), web UI on port 80. The only one so far, so store a list, not one URL.
|
||||
- **Framing works.** Probed 2026-09-24: it sends **no `X-Frame-Options` and no
|
||||
`frame-ancestors`**, so it does not refuse to be embedded. No fallback needed.
|
||||
- **Proxy it through the daemon — that is what makes the simple version possible.** The
|
||||
KVM is **HTTP only**, and Ham Lab is served over TLS, so a bare `http://…` iframe is
|
||||
blocked as mixed content and the "just paste a link" idea dies there. Serving it from Ham
|
||||
Lab's own origin (`/api/kvm/<id>/…`, on the `proxyNode` machinery, which already carries
|
||||
WebSockets — exactly what the video and input streams are) fixes three things at once:
|
||||
no mixed content, the KVM sits behind **Ham Lab's** login rather than a second one
|
||||
(`/api/vm/info` answers 401, so it has its own, which the hub can hold once), and the
|
||||
iframe becomes **same-origin**, which matters for the keyboard below.
|
||||
- **The keyboard needs less than it sounds.** A parent page *cannot* capture keys and
|
||||
forward them into a **cross-origin** iframe — the browser forbids synthetic events across
|
||||
origins — but it does not need to: an iframe with focus receives keystrokes directly, and
|
||||
the NanoKVM's own page already handles them. So "capture and give it to the iframe" is
|
||||
really: click-to-focus, keep focus there, and stop the console's own shortcuts from
|
||||
eating keys while it is focused. What is genuinely worth adding is **fullscreen** plus
|
||||
`navigator.keyboard.lock()`, which is what stops the *browser* swallowing Escape, Tab and
|
||||
Ctrl-W so they reach the guest — note it is Chromium-only, so Firefox and iOS Safari get
|
||||
the plain version. Proxying makes the iframe same-origin anyway, so real forwarding stays
|
||||
available if some KVM ever needs it.
|
||||
- **Where the list lives is the one real design decision.** A KVM earns its keep when the
|
||||
machine is *down* — no OS, a bad kernel, a BIOS screen — which is when that host's Ham Lab
|
||||
cannot answer. So it is **hub-side** and must work for machines running no Ham Lab at all.
|
||||
A `kvm_url` in each node's own `config.json` would fail in exactly the case it is for.
|
||||
- **Do not give it an NPM host** without deciding to: `*.bigham.dev` is a wildcard to the
|
||||
public IP, so that would put BIOS-level control of `grid` on the internet behind only the
|
||||
KVM's own login. Behind Ham Lab's proxy it inherits Ham Lab's auth instead.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Idle-session reaping policy (never / after N days) — leaning never; it's a
|
||||
|
||||
@@ -1,10 +1,91 @@
|
||||
# Ham Lab
|
||||
|
||||
Web-based Linux management console for bare-metal Debian, in the style of the Proxmox
|
||||
Web-based Linux management console for bare-metal Linux, in the style of the Proxmox
|
||||
web UI. Named after Ham the cat (see also HamTV).
|
||||
|
||||
Status: **planning**. See [`PLAN.md`](PLAN.md) for the MVP scope and roadmap. No code yet.
|
||||
Status: **v0.1** — persistent web terminals and a live Dashboard tab. Scope and roadmap in
|
||||
[`PLAN.md`](PLAN.md).
|
||||
|
||||
- Runs as a systemd service on the host it manages, as user `core`.
|
||||
- Public at `https://hamlab.bigham.dev` (behind Nginx Proxy Manager on `core`).
|
||||
## Runs on any Linux box
|
||||
|
||||
One static Go binary with the UI embedded: no cgo, no libc, no Node at runtime, no agents.
|
||||
The same file runs on Debian, Ubuntu, Fedora, RHEL/Rocky/Alma, openSUSE, Arch and Alpine
|
||||
(musl), on bare metal, VMs and containers. Builds: `amd64`, `arm64`, `arm` (ARMv6
|
||||
hard-float and up, so every Raspberry Pi), `386`, `riscv64`.
|
||||
|
||||
```sh
|
||||
sudo ./hamlab-linux-amd64 install # that's the whole install
|
||||
```
|
||||
|
||||
or, from wherever `make release` output is served:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.bigham.dev/mitchell/hamlab/releases/download/latest/install.sh | sh # asks: user, address, HTTPS, firewall, password
|
||||
curl -fsSL https://git.bigham.dev/mitchell/hamlab/releases/download/latest/install.sh | sh -s -- --yes # asks nothing, all defaults
|
||||
curl -fsSL https://git.bigham.dev/mitchell/hamlab/releases/download/latest/install.sh | sh -s -- --listen 0.0.0.0:8100 --tls auto # flags go to `hamlab install`
|
||||
```
|
||||
|
||||
`install.sh` is a wizard when there is a terminal (it reads answers from `/dev/tty`, so it
|
||||
works piped from curl) and silent otherwise. Either way it picks the build for `uname -m`,
|
||||
checks it against `SHA256SUMS` and runs `hamlab install`; re-running it upgrades in place.
|
||||
`latest` is an alias Gitea resolves to the newest release, so that URL never changes.
|
||||
`make gitea-release` publishes a release there (`HAMLAB_URL` points the installer elsewhere); run the script beside the `hamlab-linux-*` files and it installs offline.
|
||||
`hamlab install`:
|
||||
|
||||
- copies the binary to `/usr/local/bin` (`--prefix`), atomically;
|
||||
- gets root through `sudo` or `doas` if you aren't root already, and runs the console as
|
||||
**you** (`--user`), so terminals are your login shell with your rights;
|
||||
- writes the config to `~/.config/hamlab/` (`--data`; an existing `~/apps/hamlab/` is kept)
|
||||
and asks for the first account's password (the account is named after the user the
|
||||
console runs as), or generates one and prints it once (`--password-stdin` for scripts);
|
||||
- registers a boot-time service with **systemd** or **OpenRC**, naming only groups that
|
||||
exist on the host, and waits for `/healthz`; with neither init it prints the one line
|
||||
to put under your own supervisor;
|
||||
- says so when firewalld or ufw will block the port (`--open-firewall` opens it).
|
||||
|
||||
Re-running it upgrades in place and keeps config, user and accounts. `hamlab uninstall
|
||||
[--purge]` reverses it.
|
||||
|
||||
Accounts: every person has their own user name and password, and the theme, accent
|
||||
colours and dashboard layout follow the account. The **Users** tab has it all: your own
|
||||
password, where you are signed in (sign out any device), and the accounts (add, remove,
|
||||
reset a password; your own password confirms the last two). From the shell:
|
||||
`hamlab passwd [user]` sets a password and makes the account if it is new, `hamlab
|
||||
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.
|
||||
|
||||
What adapts to the host, rather than assuming Debian:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Root | already root, else passwordless `sudo`, else passwordless `doas`; without any, the console is read-only where root is needed and says so |
|
||||
| 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 |
|
||||
| Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Building: `make build` (web + Go), `make release` (every architecture, `install.sh` and
|
||||
`SHA256SUMS` in `dist/`), `make test`.
|
||||
|
||||
Terminals run as the user the service runs as, with that user's login shell.
|
||||
|
||||
## Managing several machines
|
||||
|
||||
Install Ham Lab on each host; on the ones the hub should reach:
|
||||
`hamlab install --listen <lan-ip>:8100 --tls auto` (a self-signed certificate is generated
|
||||
on first start). Then on the hub, open **Machines**, enter a name, the address
|
||||
(`https://host:8100`) and a user and password on that machine once (the user may be
|
||||
left out when it has only one). The hub pins the
|
||||
certificate, keeps an access token, and every tab works against the chosen machine
|
||||
through the switcher at the top of the rail.
|
||||
|
||||
Updating: `make deploy` on the hub installs the new build there and then pushes it to
|
||||
every managed machine (`hamlab push-nodes`), which install it and restart. The Machines
|
||||
page flags machines running an older version and offers the same push per machine.
|
||||
Mixed architectures are fine: `make deploy` also cross-compiles every other architecture,
|
||||
and the hub sends each machine the build for its own.
|
||||
|
||||
- On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager.
|
||||
- Installable as a PWA.
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Sensors — the standard read interface
|
||||
|
||||
Everything Ham Lab measures is a **sensor**: a stable dotted ID, a little metadata, a
|
||||
current value. Any consumer — Ham Lab's own dashboard, Glance, Home Assistant, a shell
|
||||
script — discovers them from the catalog and asks for exactly the ones it wants.
|
||||
|
||||
Code: `internal/sensors` (types, registry, selectors, shaped query) and
|
||||
`internal/metrics/sensors.go` (the collector as a provider). The registry measures
|
||||
nothing; providers do. A new source of sensors implements `sensors.Provider` and is
|
||||
added to the registry in `api.New`.
|
||||
|
||||
## IDs
|
||||
|
||||
```
|
||||
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
|
||||
temp.<chip>.<label> fan.<chip>.<label>
|
||||
net.<iface>.rx|tx|rx_total|tx_total|up
|
||||
disk.<dev>.read|write|util|size|temp
|
||||
fs.<mount>.size|used|used_pct
|
||||
ups.battery|runtime|load|load_pct|in_v|out_v|on_battery|state|source
|
||||
```
|
||||
|
||||
- Segments are lower case `[a-z0-9_-]`: `Package id 0` → `package_id_0`, `/mnt/tank` →
|
||||
`mnt_tank`, `/` → `root`. The real names are in the sensor's `labels`.
|
||||
- Built from names the hardware reports, never enumeration order (`hwmon3` moves between
|
||||
boots). Two identical chips collide; the second gets `_2`.
|
||||
- Which sensors exist differs per machine. That is the point of the catalog.
|
||||
|
||||
## A sensor
|
||||
|
||||
```json
|
||||
{ "id": "temp.coretemp.package_id_0", "name": "coretemp Package id 0",
|
||||
"kind": "temperature", "unit": "°C", "value": 51, "t": 1789826600878,
|
||||
"warn": 105, "crit": 105, "labels": { "chip": "coretemp", "label": "Package id 0" } }
|
||||
```
|
||||
|
||||
`value` is a number, bool or string. `t` is when it was really read, in unix ms — slow
|
||||
sections (filesystems 30 s, UPS 10 s) are older than the tick. `min`/`max` are the scale
|
||||
(0–100 for percentages, 0–total for used bytes); `warn`/`crit` are the hardware's own
|
||||
thresholds where it states them.
|
||||
|
||||
## Endpoints
|
||||
|
||||
All behind the same auth as the rest of `/api` (session cookie or `Authorization: Bearer`).
|
||||
On a hub, every one is also at `/api/nodes/<machine>/sensors…` for each managed machine.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /api/sensors` | Catalog, with current values. `?ids=fan.*,cpu.usage` narrows it. |
|
||||
| `GET /api/sensors/{id}` | One sensor. `?format=text` → the bare value as text/plain. |
|
||||
| `GET /api/sensors/{id}/history` | `{id, unit, t[], v[]}` — what the ring holds (300 points, spaced by the window below). Numbers only. |
|
||||
| `GET /api/sensors/history?ids=cpu.usage,net.*.rx` | Many histories in one request: `{series: [{id, unit, t[], v[]}]}`. A sensor younger than the ring has a shorter series. |
|
||||
| `GET /api/prefs/history` | How far back the history reaches: `{seconds, points, stride_sec, min_seconds, max_seconds}`. |
|
||||
| `PUT /api/prefs/history` | `{seconds}` — 60 to 86400. Set on the hub it is passed to every machine, so the fleet's graphs reach equally far back. |
|
||||
| `POST /api/sensors/query` | Shaped query, below. |
|
||||
| `GET /api/sensors/query?k=selector&…` | The same, flat, for consumers that can only fetch a URL. |
|
||||
|
||||
A selector is an exact ID or a glob. `*` crosses dots: `fan.*` is every fan,
|
||||
`cpu.core.*.usage` every core.
|
||||
|
||||
## Shaped query
|
||||
|
||||
Send the object you want, with selectors where the values go. Get it back filled in.
|
||||
|
||||
```
|
||||
POST /api/sensors/query
|
||||
{ "cpu": "cpu.usage", "fans": "fan.*", "box": { "temp": "cpu.temp", "up": "uptime" } }
|
||||
|
||||
→ { "cpu": 12.4,
|
||||
"fans": { "fan.nct6775.fan1": 820, "fan.nct6775.fan2": 1140 },
|
||||
"box": { "temp": 41, "up": 80520 } }
|
||||
```
|
||||
|
||||
- Exact ID → its value; `null` if this machine has no such sensor (so one query works
|
||||
across machines that differ).
|
||||
- Glob → an object keyed by ID; `{}` if nothing matches.
|
||||
- Objects and arrays are walked; anything else in a query is a 400.
|
||||
- `?full=1` → every value is the whole sensor object instead of the bare value. (In the
|
||||
GET form `full` is therefore not usable as a key.)
|
||||
|
||||
## Live stream
|
||||
|
||||
`GET /api/sensors/stream` is a shaped query that keeps answering: the same object, pushed
|
||||
as a server-sent event after every sample. The query is `?q=<URL-encoded JSON shape>` or
|
||||
the flat form; `?full=1` as above; `?every=<ms>` spaces events out for a consumer that
|
||||
doesn't want them at the sampling rate. A keepalive comment goes out every 25 s, and
|
||||
`X-Accel-Buffering: no` keeps nginx from holding events back.
|
||||
|
||||
```js
|
||||
new EventSource('/api/sensors/stream?token=…&q=' + encodeURIComponent(JSON.stringify({ cpu: 'cpu.usage', fans: 'fan.*' })))
|
||||
.onmessage = (e) => render(JSON.parse(e.data))
|
||||
```
|
||||
|
||||
## Auth: read-only tokens
|
||||
|
||||
Settings → Access tokens → **+ Read-only sensor token** (or `POST /api/tokens/read
|
||||
{"name"}` with a session). Scope `sensors:read`: it opens every endpoint in this file,
|
||||
here and on each managed machine through the hub (`/api/nodes/<machine>/sensors…`; the
|
||||
hub talks to the node as itself, the caller's token stops at the hub), and nothing else.
|
||||
|
||||
- Send it as `Authorization: Bearer <token>`, or as `?token=<token>` where headers are
|
||||
impossible (an iframe, a browser `EventSource`).
|
||||
- A read-only token can be **limited to ID patterns** (`{"name": "glance", "sensors":
|
||||
["cpu.*", "zfs.tank.*"]}`, or the second prompt in Settings). Everything outside them
|
||||
does not exist for that token — catalog, query, history, stream, Prometheus, and on
|
||||
managed machines through the hub, which passes the restriction along.
|
||||
- A **full-control token is never accepted from a URL**, and a read-only token is never
|
||||
accepted anywhere outside this file's endpoints.
|
||||
|
||||
## Prometheus
|
||||
|
||||
`GET /metrics` (or `/api/sensors/prometheus`, which also works through the hub as
|
||||
`/api/nodes/<machine>/sensors/prometheus`). One gauge family per kind of measurement,
|
||||
base units, the sensor ID and its real-world names as labels:
|
||||
|
||||
```
|
||||
hamlab_temperature_celsius{id="temp.coretemp.core_0",chip="coretemp",label="Core 0"} 50
|
||||
hamlab_usage_percent{id="fs.root.used_pct",fstype="ext4",mount="/"} 10.1
|
||||
hamlab_state{id="ups.state",model="CPS CP1500PFCRM2U",value="Normal"} 1
|
||||
hamlab_updates_pending{id="updates.pending",manager="apt"} 3
|
||||
```
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: hamlab
|
||||
authorization: { credentials: <read-only token> }
|
||||
static_configs: [{ targets: ['10.0.0.234:8100'] }]
|
||||
```
|
||||
|
||||
## Status sensors
|
||||
|
||||
Not everything is a /proc number. These are read in the background on their own clocks
|
||||
(each sensor's `t` is its real age) by `internal/api/status_sensors.go`:
|
||||
|
||||
```
|
||||
services.failed|failed_names|running every 30 s
|
||||
service.<unit>.active|state one pair per service that is enabled, up or failed
|
||||
containers.total|running|unhealthy every 15 s (Docker or Podman, if present)
|
||||
container.<name>.running|state|health|mem|cpu mem and cpu read from the container's cgroup
|
||||
container.<name>.down should be up and isn't: Docker couldn't start it, it is
|
||||
stuck restarting, or it died non-zero with no policy to
|
||||
restart it — *not* a container someone stopped
|
||||
updates.pending|security|reboot_required every 5 min
|
||||
zfs.<pool>.health|healthy|size|used|used_pct|frag every 1 min
|
||||
smart.<dev>.passed|warnings|power_on|wear every 10 min, never waking a sleeping disk
|
||||
```
|
||||
|
||||
## Widgets
|
||||
|
||||
A widget is a spec — `{type, sensor, title?}` — over this API and nothing else
|
||||
(`web/src/widgets.tsx`, fed by `useSensorStream` in `web/src/sensors.ts`). Types: `stat`,
|
||||
`gauge`, `bar`, `spark`, and `list` for a pattern. Scale and colour come from the
|
||||
sensor's own `min`/`max`/`warn`/`crit`.
|
||||
|
||||
- **In Ham Lab:** Dashboard → Customize → **+ Add widget**. Saved with the layout,
|
||||
per machine; all of a page's widgets share one stream.
|
||||
- **Widgets tab:** every widget type, then every sensor on the machine drawn once, live,
|
||||
grouped by family (`web/src/Gallery.tsx`). Tap a tile to copy its sensor ID — the
|
||||
quickest way to find what to put in a widget, a query or a Glance tile.
|
||||
- **Anywhere else:** `/embed/widget` is one widget alone on a page, live, in its own small
|
||||
bundle (the console never loads), and the only path Ham Lab allows to be framed.
|
||||
|
||||
```
|
||||
/embed/widget?type=gauge&sensor=cpu.usage&token=<read-only token>
|
||||
&machine=grid &title=… &theme=dark|light|cyberpunk|retro &every=<ms> &bare=1
|
||||
```
|
||||
|
||||
### Glance
|
||||
|
||||
Glance's own `custom-api` widget refetches on page load / cache expiry — fine for slow
|
||||
facts, never live. An `iframe` of the embed is live.
|
||||
|
||||
```yaml
|
||||
- type: iframe
|
||||
title: core CPU
|
||||
source: https://hamlab.bigham.dev/embed/widget?type=gauge&sensor=cpu.usage&bare=1&token=<read-only token>
|
||||
height: 130
|
||||
|
||||
- type: custom-api
|
||||
title: grid
|
||||
cache: 1m
|
||||
url: https://hamlab.bigham.dev/api/nodes/grid/sensors/query?pool=zfs.tank.used_pct&failed=services.failed&updates=updates.pending
|
||||
headers: { Authorization: Bearer <read-only token> }
|
||||
template: |
|
||||
<div>tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates</div>
|
||||
```
|
||||
|
||||
## The whole fleet in one request
|
||||
|
||||
On a hub, `/api/fleet/sensors/query` asks every machine (or `machines`) the same shaped
|
||||
query and answers `{machine: result}`; this machine answers under its own hostname, and
|
||||
one that can't be reached answers `{"error": …}` while the rest still do.
|
||||
|
||||
```
|
||||
POST /api/fleet/sensors/query {"machines": "*", "query": {"cpu": "cpu.usage", "up": "uptime"}}
|
||||
GET /api/fleet/sensors/query?machines=grid,wyse&cpu=cpu.usage&failed=services.failed
|
||||
|
||||
→ {"core": {"cpu": 4.8, "up": 50211}, "grid": {"cpu": 0.4, "up": 437177}, "mac": {"error": "timeout"}}
|
||||
```
|
||||
|
||||
## What counts as a disk
|
||||
|
||||
`disk.*` names **drives**, not the block devices the kernel lists. An NVMe controller can
|
||||
present several namespaces — an Apple SSD shows `nvme0n1`, `nvme0n2` and `nvme0n3`, the last
|
||||
two a few MiB of firmware — and each one arrives from `/proc/diskstats` looking like a whole
|
||||
disk. They are folded into the controller (`disk.nvme0.*`), with the rates added and busy
|
||||
taken as the longest of them rather than the sum, since they share one controller. This is
|
||||
what the Disks page has always shown; since 2026-09-22 the dashboard, the Top tab and these
|
||||
sensors agree with it.
|
||||
|
||||
## The Top tab
|
||||
|
||||
Ham Lab → **Top** is a whole machine on one screen in the style of btop: black ground, boxes
|
||||
with their name set into the border, curves drawn as dots that climb green → yellow → red.
|
||||
It reads nothing of its own — the sensor stream for everything measured, `/api/procs` for the
|
||||
table — so it works on any machine the console can reach, whether or not btop is installed
|
||||
there, and it follows the same refresh rate and history window as the rest of the console.
|
||||
Its colours are the active skin's: the terminal background for the ground, the skin's sixteen
|
||||
terminal colours for the curves and meters. (The look is btop's, `web/src/Top.tsx` is ours;
|
||||
btop itself is Apache-2.0.)
|
||||
|
||||
## How often a machine samples
|
||||
|
||||
The console's refresh rate (Settings, or `− rate +` on Top) is also the fleet's sampling rate.
|
||||
It is saved with the UI prefs on the hub, which sets its own sampler and passes the rate to
|
||||
every machine it manages — `PUT /api/prefs/sampler {"ms": …}`, 100 ms to 60 s, sampled no
|
||||
slower than the collector's 10 s — and repeats it at start-up. `GET` answers `{ms, min_ms,
|
||||
max_ms}`. Before 2026-09-21 only an open Dashboard page told a machine its rate.
|
||||
|
||||
## How far back a history reaches
|
||||
|
||||
Every machine keeps **300 points per curve**, whatever the window — so the window sets how
|
||||
far apart they are, not how much memory monitoring costs. At the default ten minutes each
|
||||
point is one sample (2 s apart at the default rate); at a day each is the **average of the
|
||||
five minutes behind it**, not whichever instant landed on the mark, so a long graph shows
|
||||
the load that really happened. Ham Lab → Settings → **Graph history** is the one control,
|
||||
1 minute to 24 hours; the hub passes it to every machine it manages.
|
||||
|
||||
The window can't be finer than the sampling interval — 300 points of a 1-minute window at a
|
||||
2-second rate is 30 points, and that is what you get. It is also all in memory: a machine
|
||||
that restarts starts its graphs again.
|
||||
|
||||
## What Ham Lab itself runs on
|
||||
|
||||
Since 2026-09-19 the Dashboard tab (stock modules and custom widgets) and the Disks tab's
|
||||
live rates read only this API: the catalog once, one batch of histories, one stream —
|
||||
closed while the page is hidden, refilled when it returns. `web/src/sampleAdapter.ts` is
|
||||
the seam that hands the stock modules the object they were written against. The old
|
||||
`/api/metrics` and `/api/metrics/history` are gone. `hamlab.sample_interval` is the
|
||||
sampler's own rate, as a sensor.
|
||||
|
||||
## From another origin
|
||||
|
||||
The sensor endpoints (and only they) answer any origin: `Access-Control-Allow-Origin: *`,
|
||||
preflight included. `*` is the one value browsers won't combine with cookies, so a foreign
|
||||
page can reach the API with a token and can never ride a logged-in console's session.
|
||||
`examples/external-demo/` is a complete outside consumer in one HTML file.
|
||||
@@ -2,16 +2,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -20,8 +28,20 @@ import (
|
||||
"hamlab/internal/api"
|
||||
"hamlab/internal/auth"
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/files"
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/nodes"
|
||||
"hamlab/internal/services"
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/setup"
|
||||
"hamlab/internal/space"
|
||||
"hamlab/internal/storage"
|
||||
"hamlab/internal/store"
|
||||
"hamlab/internal/tasks"
|
||||
hterm "hamlab/internal/term"
|
||||
"hamlab/internal/tlsauto"
|
||||
"hamlab/internal/updates"
|
||||
"hamlab/internal/web"
|
||||
)
|
||||
|
||||
@@ -30,12 +50,24 @@ var version = "dev"
|
||||
func main() {
|
||||
defaultCfg := os.Getenv("HAMLAB_CONFIG")
|
||||
if defaultCfg == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
defaultCfg = filepath.Join(home, "apps", "hamlab", "config.json")
|
||||
defaultCfg = config.DefaultPath()
|
||||
}
|
||||
cfgPath := flag.String("config", defaultCfg, "path to config.json")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version]\n")
|
||||
fmt.Fprint(os.Stderr, `usage: hamlab [-config path] <command>
|
||||
|
||||
install [flags] install this binary as a service on this machine (install -h for flags)
|
||||
uninstall [--purge] remove the service and the binary
|
||||
serve run the console in the foreground (what the service runs)
|
||||
passwd [user] set a user's password (makes the account if it is new; reads stdin when piped)
|
||||
users list the accounts
|
||||
zfs-snapshot <id> run one ZFS snapshot schedule (what its cron line calls)
|
||||
user-rm <user> remove an account and sign it out everywhere
|
||||
check verify the config and state load, then exit
|
||||
version
|
||||
push-nodes [--force] hub: send this build to every managed machine
|
||||
|
||||
`)
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
@@ -44,6 +76,43 @@ func main() {
|
||||
if flag.NArg() > 0 {
|
||||
cmd = flag.Arg(0)
|
||||
}
|
||||
switch cmd { // commands that don't read the config
|
||||
case "fs": // privileged helper for the Files tab (run as root by the daemon)
|
||||
os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
case "space-walk": // helper for the Space tab: measure mountpoints, stream the tree back
|
||||
os.Exit(space.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
case "install", "uninstall":
|
||||
var err error
|
||||
if cmd == "install" {
|
||||
err = setup.Install(flag.Args()[1:], version)
|
||||
} else {
|
||||
err = setup.Uninstall(flag.Args()[1:])
|
||||
}
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
os.Exit(0)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "hamlab "+cmd+":", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "replace": // self-update helper (run as root by the daemon): put this binary at a path, atomically
|
||||
// Only ever over a hamlab binary: this runs as root, and must not be a way to
|
||||
// overwrite arbitrary files for whoever is allowed to run hamlab as root.
|
||||
if flag.NArg() != 2 || !filepath.IsAbs(flag.Arg(1)) || !strings.HasPrefix(filepath.Base(flag.Arg(1)), "hamlab") {
|
||||
fmt.Fprintln(os.Stderr, "hamlab replace: destination must be an absolute path to a hamlab binary")
|
||||
os.Exit(2)
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err == nil {
|
||||
err = setup.CopyAtomic(self, flag.Arg(1))
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "hamlab replace:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -51,10 +120,59 @@ func main() {
|
||||
switch cmd {
|
||||
case "version":
|
||||
fmt.Println("hamlab", version)
|
||||
case "passwd":
|
||||
if err := passwd(cfg); err != nil {
|
||||
case "check": // a new build runs this against the live config before it replaces the old one
|
||||
if _, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if _, err := fs.Stat(web.FS(), "index.html"); err != nil {
|
||||
log.Fatal("embedded UI missing: ", err)
|
||||
}
|
||||
fmt.Println("ok")
|
||||
case "passwd":
|
||||
if err := passwd(cfg, flag.Arg(1)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case "zfs-snapshot":
|
||||
if err := zfsSnapshot(cfg, flag.Arg(1)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case "users":
|
||||
migrateUsers(cfg)
|
||||
if len(cfg.Users) == 0 {
|
||||
fmt.Println("no accounts in", cfg.Path())
|
||||
}
|
||||
for _, u := range cfg.Users {
|
||||
seen := "never"
|
||||
if !u.LastSeen.IsZero() {
|
||||
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)
|
||||
}
|
||||
case "user-rm":
|
||||
if err := userRm(cfg, flag.Arg(1)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case "push-nodes": // hub CLI: send this binary to every managed machine
|
||||
exe, _ := os.Executable()
|
||||
host, _ := os.Hostname()
|
||||
nm := nodes.New(cfg, host)
|
||||
force := flag.NArg() > 1 && flag.Arg(1) == "--force"
|
||||
results := nm.PushAll(context.Background(), exe, runtime.GOARCH, force)
|
||||
if len(results) == 0 {
|
||||
fmt.Println("no managed machines in", cfg.Path())
|
||||
}
|
||||
failed := 0
|
||||
for _, r := range results {
|
||||
if r.OK {
|
||||
fmt.Printf("%-12s updated to %s\n", r.Name, r.Version)
|
||||
} else {
|
||||
failed++
|
||||
fmt.Printf("%-12s FAILED: %s\n", r.Name, r.Error)
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
case "serve":
|
||||
if err := serve(cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -65,9 +183,128 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func passwd(cfg *config.Config) error {
|
||||
fmt.Fprint(os.Stderr, "New password: ")
|
||||
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
// migrateUsers gives a pre-users config its first account, named after this OS user, so
|
||||
// the CLI sees the same accounts the daemon will.
|
||||
// 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
|
||||
// not the console is up — and keeps running if Ham Lab is never opened again.
|
||||
//
|
||||
// It deliberately does not write state.json. The daemon owns that file and would clobber
|
||||
// anything written from out here; the outcome goes beside it instead, in zfs-runs/.
|
||||
func zfsSnapshot(cfg *config.Config, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return errors.New("usage: hamlab zfs-snapshot <schedule-id>")
|
||||
}
|
||||
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
st.View(func(s *store.State) { raw = s.ZFS })
|
||||
var z struct {
|
||||
Policies []storage.Policy `json:"policies"`
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &z); err != nil {
|
||||
return fmt.Errorf("reading the schedules: %w", err)
|
||||
}
|
||||
}
|
||||
var p *storage.Policy
|
||||
for i := range z.Policies {
|
||||
if z.Policies[i].ID == id {
|
||||
p = &z.Policies[i]
|
||||
}
|
||||
}
|
||||
if p == nil {
|
||||
return fmt.Errorf("no snapshot schedule %q — it may have been removed; this cron line is stale", id)
|
||||
}
|
||||
res, runErr := storage.New(priv.Available()).Run(context.Background(), p)
|
||||
rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned}
|
||||
if runErr != nil {
|
||||
rec.Error = runErr.Error()
|
||||
}
|
||||
if err := storage.WriteRun(cfg.DataDir(), id, rec); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "recording the run:", err)
|
||||
}
|
||||
if runErr != nil {
|
||||
return runErr
|
||||
}
|
||||
fmt.Printf("%s: took %s", p.Name, res.Taken)
|
||||
if len(res.Pruned) > 0 {
|
||||
fmt.Printf(", expired %d", len(res.Pruned))
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(cfg *config.Config) {
|
||||
name := os.Getenv("USER")
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
name = u.Username
|
||||
}
|
||||
if cfg.MigrateUsers(name) {
|
||||
_ = cfg.Save()
|
||||
}
|
||||
}
|
||||
|
||||
func userRm(cfg *config.Config, name string) error {
|
||||
migrateUsers(cfg)
|
||||
if name == "" {
|
||||
return errors.New("usage: hamlab user-rm <user>")
|
||||
}
|
||||
if len(cfg.Users) <= 1 {
|
||||
return errors.New("the last account can't be removed")
|
||||
}
|
||||
kept := cfg.Users[:0]
|
||||
found := false
|
||||
for _, u := range cfg.Users {
|
||||
if u.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, u)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("no account %q", name)
|
||||
}
|
||||
cfg.Users = kept
|
||||
if err := cfg.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
if st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json")); err == nil {
|
||||
auth.NewSessions(st).RevokeUser(name)
|
||||
_ = st.Update(func(s *store.State) { delete(s.Users, name) })
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "removed", name, "(the running service picks it up at its next config save; restart it to be sure)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func passwd(cfg *config.Config, name string) error {
|
||||
migrateUsers(cfg)
|
||||
if name == "" {
|
||||
switch len(cfg.Users) {
|
||||
case 0:
|
||||
name = os.Getenv("USER")
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
name = u.Username
|
||||
}
|
||||
case 1:
|
||||
name = cfg.Users[0].Name
|
||||
default:
|
||||
return errors.New("several accounts: hamlab passwd <user>")
|
||||
}
|
||||
}
|
||||
if !auth.UserNameRe.MatchString(name) {
|
||||
return fmt.Errorf("%q: a user name is 1–32 lower-case letters, digits, . _ or -", name)
|
||||
}
|
||||
var p1 []byte
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) { // scripted: one line on stdin
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
p1 = []byte(strings.TrimRight(line, "\r\n"))
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "New password for %s: ", name)
|
||||
var err error
|
||||
p1, err = term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -81,24 +318,39 @@ func passwd(cfg *config.Config) error {
|
||||
if string(p1) != string(p2) {
|
||||
return errors.New("passwords do not match")
|
||||
}
|
||||
if len(p1) < 12 {
|
||||
}
|
||||
if len(p1) < auth.MinPasswordLen {
|
||||
return errors.New("password must be at least 12 characters")
|
||||
}
|
||||
h, err := auth.HashPassword(string(p1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.PasswordHash = h
|
||||
made := true
|
||||
for i := range cfg.Users {
|
||||
if cfg.Users[i].Name == name {
|
||||
cfg.Users[i].Hash = h
|
||||
made = false
|
||||
}
|
||||
}
|
||||
if made {
|
||||
cfg.Users = append(cfg.Users, config.User{Name: name, Hash: h, Created: time.Now()})
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "password set in", cfg.Path())
|
||||
if made {
|
||||
fmt.Fprintln(os.Stderr, "account", name, "made in", cfg.Path())
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "password of", name, "set in", cfg.Path())
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "the running service reads the config at its next save; restart it to be sure")
|
||||
return nil
|
||||
}
|
||||
|
||||
func serve(cfg *config.Config) error {
|
||||
if cfg.PasswordHash == "" {
|
||||
return fmt.Errorf("no password set — run: hamlab -config %s passwd", cfg.Path())
|
||||
if cfg.PasswordHash == "" && len(cfg.Users) == 0 {
|
||||
return fmt.Errorf("no account set — run: hamlab -config %s passwd", cfg.Path())
|
||||
}
|
||||
if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil {
|
||||
return err
|
||||
@@ -115,15 +367,17 @@ func serve(cfg *config.Config) error {
|
||||
}
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/bash"
|
||||
shell = loginShell(u.Username)
|
||||
}
|
||||
mgr := hterm.NewManager(shell, u.HomeDir)
|
||||
mgr.OnChange = func(list []hterm.Info) {
|
||||
_ = st.Update(func(s *store.State) {
|
||||
s.Terminals = s.Terminals[:0]
|
||||
for _, t := range list {
|
||||
if t.Running { // an exited shell must not come back from the dead on restart
|
||||
s.Terminals = append(s.Terminals, store.Terminal{ID: t.ID, Name: t.Name})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// Re-open the shells that existed before the last restart (fresh processes,
|
||||
@@ -136,12 +390,57 @@ func serve(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
srv := api.New(cfg, sessions, mgr, web.FS(), version)
|
||||
// Keep the heap tidy: the daemon's live data is tens of MB; without a limit the GC
|
||||
// happily lets RSS coast at twice that. (Soft limit: exceeding it only means more GC.)
|
||||
debug.SetMemoryLimit(160 << 20)
|
||||
|
||||
col := metrics.NewCollector()
|
||||
go col.Run(context.Background())
|
||||
|
||||
svc := services.New()
|
||||
cr := cron.New(svc.Sudo(), svc.JournalSudo())
|
||||
tm := tasks.New()
|
||||
up := updates.New(svc.Sudo(), tm)
|
||||
sto := storage.New(svc.Sudo())
|
||||
self, _ := os.Executable()
|
||||
var sudoFS files.FS
|
||||
if svc.Sudo() && self != "" {
|
||||
sudoFS = files.Sudo{Self: self}
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
nm := nodes.New(cfg, host)
|
||||
go nm.Run(context.Background())
|
||||
|
||||
certPath, keyPath, fingerprint := "", "", ""
|
||||
if cfg.TLS == "auto" {
|
||||
var err error
|
||||
certPath, keyPath, fingerprint, err = tlsauto.Ensure(cfg.DataDir())
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls: %w", err)
|
||||
}
|
||||
}
|
||||
ln, err := listen(cfg.Listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, nm, fingerprint, web.FS(), version)
|
||||
hs := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
// After a self-update: become the new binary in place. Same PID, so it works the same
|
||||
// under systemd, OpenRC, or no supervisor at all. It returns only if the exec failed, by
|
||||
// which point the listener and the shells are gone; the caller puts the old binary back
|
||||
// and exits so the supervisor starts it.
|
||||
var restarting atomic.Bool
|
||||
srv.Restart = func() error {
|
||||
restarting.Store(true) // Serve returns once Shutdown is called; serve() must not exit under us
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = hs.Shutdown(ctx)
|
||||
mgr.Shutdown()
|
||||
return syscall.Exec(self, os.Args, os.Environ())
|
||||
}
|
||||
go func() {
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -152,9 +451,76 @@ func serve(cfg *config.Config) error {
|
||||
_ = hs.Shutdown(ctx)
|
||||
mgr.Shutdown()
|
||||
}()
|
||||
log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
|
||||
if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
if certPath != "" {
|
||||
log.Printf("hamlab %s listening on https://%s as %s (shell %s) cert %s", version, cfg.Listen, u.Username, shell, fingerprint)
|
||||
if err := hs.ServeTLS(ln, certPath, keyPath); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return waitIf(&restarting)
|
||||
}
|
||||
log.Printf("hamlab %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
|
||||
if err := hs.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return waitIf(&restarting)
|
||||
}
|
||||
|
||||
// waitIf parks the caller while a restart is replacing the process.
|
||||
func waitIf(restarting *atomic.Bool) error {
|
||||
if restarting.Load() {
|
||||
select {}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listen binds the configured address with IP_FREEBIND, so a LAN address that isn't up yet
|
||||
// (boot, DHCP still negotiating, cable out) doesn't stop the daemon: it binds now and
|
||||
// answers as soon as the address appears.
|
||||
func listen(addr string) (net.Listener, error) {
|
||||
lc := net.ListenConfig{Control: func(network, _ string, c syscall.RawConn) error {
|
||||
return c.Control(func(fd uintptr) {
|
||||
_ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_IP, syscall.IP_FREEBIND, 1)
|
||||
})
|
||||
}}
|
||||
ln, err := lc.Listen(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen %s: %w", addr, err)
|
||||
}
|
||||
if host, _, _ := net.SplitHostPort(addr); host != "" {
|
||||
if ip := net.ParseIP(host); ip != nil && !ip.IsUnspecified() && !ip.IsLoopback() && !onInterface(ip) {
|
||||
log.Printf("listen address %s isn't on any interface yet; bound anyway and waiting for it", host)
|
||||
}
|
||||
}
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
func onInterface(ip net.IP) bool {
|
||||
addrs, _ := net.InterfaceAddrs()
|
||||
for _, a := range addrs {
|
||||
if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loginShell returns the user's shell from /etc/passwd, falling back to bash if
|
||||
// present and /bin/sh otherwise. Systemd units don't export $SHELL.
|
||||
func loginShell(username string) string {
|
||||
if b, err := os.ReadFile("/etc/passwd"); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Split(line, ":")
|
||||
if len(f) >= 7 && f[0] == username && f[6] != "" {
|
||||
if _, err := os.Stat(f[6]); err == nil {
|
||||
return f[6]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, sh := range []string{"/bin/bash", "/usr/bin/bash", "/bin/zsh", "/bin/sh"} {
|
||||
if _, err := os.Stat(sh); err == nil {
|
||||
return sh
|
||||
}
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=Ham Lab web console
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=core
|
||||
Group=core
|
||||
WorkingDirectory=/home/core
|
||||
Environment=HAMLAB_CONFIG=/home/core/apps/hamlab/config.json
|
||||
ExecStart=/usr/local/bin/hamlab serve
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Install the built binary and unit on this host, then (re)start the service.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
sudo install -m 0755 bin/hamlab /usr/local/bin/hamlab
|
||||
sudo install -m 0644 deploy/hamlab.service /etc/systemd/system/hamlab.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now hamlab.service >/dev/null
|
||||
sudo systemctl restart hamlab.service
|
||||
sleep 1
|
||||
systemctl is-active hamlab.service && /usr/local/bin/hamlab version
|
||||
@@ -0,0 +1,21 @@
|
||||
# External demo
|
||||
|
||||
`index.html` is a stranger's page: one file, no build, no framework, served from a
|
||||
different origin than Ham Lab. It knows a base URL and a read-only sensor token
|
||||
(`config.js`, not in git) and nothing else. Each tab is one shaped query POSTed to
|
||||
`/api/sensors/query?full=1` on an interval; sparklines are seeded from
|
||||
`/api/sensors/history`; the Fleet tab is one call to `/api/fleet/sensors/query`.
|
||||
|
||||
```js
|
||||
// config.js
|
||||
window.HAMLAB = { base: 'http://10.0.0.234:8100', token: '<read-only sensor token>' }
|
||||
```
|
||||
|
||||
Anyone who can load the page can read the token. If the page is public, limit the token to
|
||||
patterns that name nothing you'd mind the world reading — hardware numbers and counts,
|
||||
not container or service names.
|
||||
|
||||
This is an example to read and copy, not something that runs anywhere. It was deployed on
|
||||
`core` as the `hamlab-demo` stack and linked from the Widgets tab until 2026-09-23, when
|
||||
both were removed: the Widgets tab shows the same widgets against the machine you are
|
||||
already signed in to, so a second public copy earned nothing and cost a public token.
|
||||
@@ -0,0 +1,179 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Ham Lab sensor API — external demo.
|
||||
|
||||
One file, no build, no framework, not part of Ham Lab: it is a stranger's page on another
|
||||
origin that knows only a base URL and a read-only token (config.js, beside this file):
|
||||
|
||||
window.HAMLAB = { base: 'http://10.0.0.234:8100', token: '<read-only sensor token>' }
|
||||
|
||||
Every tab is one shaped query — the object it wants, with sensor IDs where the values go —
|
||||
POSTed to /api/sensors/query?full=1 on an interval. Sparklines are seeded once from
|
||||
/api/sensors/history. The Fleet tab uses /api/fleet/sensors/query. See SENSORS.md.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Ham Lab sensors — external demo</title>
|
||||
<script src="config.js"></script>
|
||||
<style>
|
||||
:root { --bg:#0f1216; --card:#171b21; --line:#2a313b; --fg:#d5dbe2; --mut:#7d8590; --acc:#e3a33b; --s1:#3987e5; --s2:#c98500; --bad:#ff7b72; --ok:#3fb950; --track:rgba(57,135,229,.18) }
|
||||
* { box-sizing: border-box }
|
||||
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif }
|
||||
header { display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding:10px 14px; background:var(--card); border-bottom:1px solid var(--line) }
|
||||
header h1 { margin:0; font-size:15px; color:var(--acc); letter-spacing:.4px }
|
||||
header .sp { flex:1 }
|
||||
.mut { color:var(--mut) } .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace }
|
||||
select { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:5px; padding:4px 6px; font:inherit }
|
||||
.dot { width:8px; height:8px; border-radius:50%; background:var(--mut); display:inline-block } .dot.ok { background:var(--ok) } .dot.bad { background:var(--bad) }
|
||||
nav { display:flex; gap:2px; overflow-x:auto; padding:0 8px; background:var(--card); border-bottom:1px solid var(--line); scrollbar-width:none }
|
||||
nav button { flex:0 0 auto; background:none; border:0; border-bottom:2px solid transparent; color:var(--mut); padding:10px 12px; font:inherit; cursor:pointer }
|
||||
nav button.on { color:var(--fg); border-bottom-color:var(--acc) }
|
||||
main { padding:12px; max-width:1100px; margin:0 auto }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); gap:10px }
|
||||
.tile { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:12px; min-width:0; overflow:hidden }
|
||||
.tile.wide { grid-column:1/-1 }
|
||||
.label { font-size:11px; font-weight:600; color:var(--mut); text-transform:uppercase; letter-spacing:.5px; margin-bottom:6px; overflow-wrap:anywhere }
|
||||
.value { font-size:22px; font-weight:600; font-variant-numeric:tabular-nums } .value.warn { color:var(--s2) } .value.bad { color:var(--bad) }
|
||||
.id { margin-top:8px; font-size:10.5px; color:var(--mut); overflow-wrap:anywhere }
|
||||
.meter { height:6px; background:var(--track); border-radius:3px; overflow:hidden; margin-top:8px } .meter i { display:block; height:100%; background:var(--s1); border-radius:3px; transition:width .3s } .meter i.warn { background:var(--s2) } .meter i.bad { background:var(--bad) }
|
||||
svg.gauge { display:block; width:100%; max-width:180px; margin:0 auto } svg.gauge text { fill:var(--fg); font-size:13px; font-weight:600 }
|
||||
svg.spark { display:block; width:100%; height:44px; margin-top:6px }
|
||||
.rows { display:flex; flex-direction:column; gap:7px } .row { display:grid; grid-template-columns:1fr auto; gap:2px 10px; font-size:13px; align-items:baseline }
|
||||
.row .k { overflow:hidden; text-overflow:ellipsis; white-space:nowrap } .row .n { font-variant-numeric:tabular-nums } .row .meter { grid-column:1/3; margin-top:0 }
|
||||
.pill { font-size:11px; padding:1px 7px; border-radius:9px; border:1px solid var(--line) } .pill.ok { color:var(--ok); border-color:var(--ok) } .pill.bad { color:var(--bad); border-color:var(--bad) }
|
||||
details { margin-top:14px; background:var(--card); border:1px solid var(--line); border-radius:8px; padding:10px 12px } summary { cursor:pointer; color:var(--mut) }
|
||||
pre { margin:8px 0 0; font:12px/1.5 ui-monospace,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; max-height:320px; overflow:auto }
|
||||
.err { color:var(--bad); padding:12px }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px } th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums } th { color:var(--mut); font-weight:600; font-size:11px; text-transform:uppercase }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Ham Lab sensors</h1><span class="mut">external demo · <span id="base" class="mono"></span></span>
|
||||
<span class="sp"></span>
|
||||
<span id="status" class="mut"></span><span id="dot" class="dot"></span>
|
||||
<label class="mut">every <select id="every"><option value="1000">1 s</option><option value="2000" selected>2 s</option><option value="5000">5 s</option><option value="10000">10 s</option><option value="30000">30 s</option></select></label>
|
||||
</header>
|
||||
<nav id="tabs"></nav>
|
||||
<main>
|
||||
<div id="view"></div>
|
||||
<details><summary>What this tab asked, and what came back</summary><pre id="wire"></pre></details>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const CFG = window.HAMLAB || {}
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const h = (tag, attrs = {}, ...kids) => { const e = document.createElementNS(tag === 'svg' || attrs.svg ? 'http://www.w3.org/2000/svg' : 'http://www.w3.org/1999/xhtml', tag); for (const [k, v] of Object.entries(attrs)) if (k !== 'svg' && v != null) k === 'class' ? e.setAttribute('class', v) : k === 'style' ? e.setAttribute('style', v) : e.setAttribute(k, v); for (const kid of kids.flat()) if (kid != null) e.append(kid); return e }
|
||||
|
||||
// ---- the API: two calls ----
|
||||
async function api(path, body) {
|
||||
const r = await fetch(CFG.base + path, { method: body ? 'POST' : 'GET', headers: { Authorization: 'Bearer ' + CFG.token, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })
|
||||
if (!r.ok) throw new Error(r.status === 401 ? 'token rejected (401)' : `${r.status} ${r.statusText}`)
|
||||
return r.json()
|
||||
}
|
||||
|
||||
// ---- formatting from the sensor's own unit ----
|
||||
const U = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
const bytes = (v) => { let i = 0; while (v >= 1024 && i < U.length - 1) { v /= 1024; i++ } return `${v >= 100 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${U[i]}` }
|
||||
const dur = (s) => { const d = Math.floor(s / 86400), hh = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); return d ? `${d}d ${hh}h` : hh ? `${hh}h ${m}m` : `${m}m` }
|
||||
function fmt(s) {
|
||||
if (!s || s.value == null) return '—'
|
||||
const v = s.value
|
||||
if (typeof v === 'boolean') return v ? 'yes' : 'no'
|
||||
if (typeof v === 'string') return v || '—'
|
||||
switch (s.unit) { case 'B': return bytes(v); case 'B/s': return bytes(v) + '/s'; case 's': return dur(v); case 'min': return dur(v * 60); case '%': return v.toFixed(v < 10 ? 1 : 0) + '%'; case '°C': return v.toFixed(0) + '°C'; case 'rpm': return Math.round(v) + ' rpm'; case undefined: case '': return String(Math.round(v * 100) / 100); default: return `${Math.round(v * 10) / 10} ${s.unit}` }
|
||||
}
|
||||
const frac = (s) => { if (!s || typeof s.value !== 'number') return null; const max = s.max ?? s.crit ?? s.warn, min = s.min ?? 0; return max === undefined || max <= min ? null : Math.min(1, Math.max(0, (s.value - min) / (max - min))) }
|
||||
const sev = (s) => !s || typeof s.value !== 'number' ? '' : s.crit !== undefined && s.value >= s.crit ? 'bad' : s.warn !== undefined && s.value >= s.warn ? 'warn' : s.kind === 'usage' && s.id !== 'ups.battery' && s.value >= 95 ? 'bad' : s.kind === 'usage' && s.id !== 'ups.battery' && s.value >= 85 ? 'warn' : ''
|
||||
|
||||
// ---- the widgets ----
|
||||
const tile = (label, id, ...body) => h('div', { class: 'tile' }, h('div', { class: 'label' }, label), ...body, id ? h('div', { class: 'id mono' }, id) : null)
|
||||
const missing = (label, id) => tile(label, id, h('div', { class: 'mut' }, 'not on this machine'))
|
||||
const W = {
|
||||
stat: (s, label, id) => s ? tile(label || s.name, s.id, h('div', { class: 'value ' + sev(s) }, fmt(s))) : missing(label, id),
|
||||
bar: (s, label, id) => { if (!s) return missing(label, id); const f = frac(s); return tile(label || s.name, s.id, h('div', { class: 'value ' + sev(s) }, fmt(s)), f === null ? null : h('div', { class: 'meter' }, h('i', { class: sev(s), style: `width:${f * 100}%` }))) },
|
||||
gauge: (s, label, id) => {
|
||||
if (!s) return missing(label, id)
|
||||
const f = frac(s) ?? 0, C = Math.PI * 40, col = { bad: 'var(--bad)', warn: 'var(--s2)' }[sev(s)] || 'var(--s1)', arc = 'M10 50 A40 40 0 0 1 90 50'
|
||||
return tile(label || s.name, s.id, h('svg', { class: 'gauge', viewBox: '0 0 100 56' },
|
||||
h('path', { svg: 1, d: arc, fill: 'none', stroke: 'var(--track)', 'stroke-width': 9, 'stroke-linecap': 'round' }),
|
||||
h('path', { svg: 1, d: arc, fill: 'none', stroke: col, 'stroke-width': 9, 'stroke-linecap': 'round', 'stroke-dasharray': `${f * C} ${C}` }),
|
||||
h('text', { svg: 1, x: 50, y: 47, 'text-anchor': 'middle' }, fmt(s))))
|
||||
},
|
||||
spark: (s, label, id) => {
|
||||
if (!s) return missing(label, id)
|
||||
const pts = (hist[s.id] ||= []); if (typeof s.value === 'number') { pts.push(s.value); if (pts.length > 300) pts.shift() }
|
||||
const max = s.unit === '%' ? 100 : Math.max(...pts, 1e-9) * 1.1, n = pts.length
|
||||
const line = n < 2 ? '' : pts.map((v, i) => `${(i / (n - 1) * 300).toFixed(1)},${(42 - v / max * 40).toFixed(1)}`).join(' ')
|
||||
return tile(label || s.name, s.id, h('div', { class: 'value' }, fmt(s)), h('svg', { class: 'spark', viewBox: '0 0 300 44', preserveAspectRatio: 'none' }, h('polyline', { svg: 1, points: line, fill: 'none', stroke: 'var(--s1)', 'stroke-width': 2, 'vector-effect': 'non-scaling-stroke' })))
|
||||
},
|
||||
// a glob comes back as {id: sensor}: a list draws them all
|
||||
list: (map, label, pattern, opts = {}) => {
|
||||
const rows = Object.values(map || {}).filter((s) => !opts.filter || opts.filter(s)).sort((a, b) => a.id.localeCompare(b.id))
|
||||
return h('div', { class: 'tile wide' }, h('div', { class: 'label' }, `${label} · ${rows.length}`),
|
||||
rows.length ? h('div', { class: 'rows' }, rows.map((s) => { const f = frac(s); return h('div', { class: 'row' }, h('span', { class: 'k', title: s.id }, opts.name ? opts.name(s) : s.name), typeof s.value === 'boolean' ? h('span', { class: 'pill ' + (s.value ? 'ok' : 'bad') }, s.value ? 'up' : 'down') : h('span', { class: 'n' }, fmt(s)), f === null ? null : h('div', { class: 'meter' }, h('i', { class: sev(s), style: `width:${f * 100}%` }))) })) : h('div', { class: 'mut' }, 'nothing here that this token may see'),
|
||||
h('div', { class: 'id mono' }, pattern))
|
||||
},
|
||||
}
|
||||
|
||||
// ---- the tabs: a query, the histories to seed, and how to draw the answer ----
|
||||
const TABS = [
|
||||
{ name: 'Overview', query: { cpu: 'cpu.usage', mem: 'mem.used_pct', memB: 'mem.used', root: 'fs.root.used_pct', ups: 'ups.battery', temp: 'cpu.temp', load: 'load.1', up: 'uptime', failed: 'services.failed', upd: 'updates.pending', cont: 'containers.running' },
|
||||
seed: 'cpu.usage,load.1',
|
||||
draw: (d) => [W.gauge(d.cpu, 'CPU'), W.gauge(d.mem, 'Memory'), W.gauge(d.root, 'Root disk'), W.gauge(d.ups, 'UPS battery', 'ups.battery'), W.spark(d.cpu, 'CPU, recent'), W.spark(d.load, 'Load 1 min'), W.bar(d.memB, 'Memory used'), W.stat(d.temp, 'CPU temp', 'cpu.temp'), W.stat(d.up, 'Uptime'), W.stat(d.cont, 'Containers up', 'containers.running'), W.stat(d.failed, 'Failed services'), W.stat(d.upd, 'Pending updates')] },
|
||||
{ name: 'Widget types', query: { s: 'cpu.usage', cores: 'cpu.core.*.usage' }, seed: 'cpu.usage',
|
||||
draw: (d) => [W.stat(d.s, 'stat — a number'), W.gauge(d.s, 'gauge — has a scale'), W.bar(d.s, 'bar — has a scale'), W.spark(d.s, 'spark — has a past'), W.list(d.cores, 'list — a pattern', 'cpu.core.*.usage')] },
|
||||
{ name: 'Hardware', query: { cores: 'cpu.core.*.usage', temps: 'temp.*', fans: 'fan.*', smart: 'smart.*.passed', wear: 'smart.*.wear' },
|
||||
draw: (d) => [W.list(d.cores, 'CPU cores', 'cpu.core.*.usage'), W.list(d.temps, 'Temperatures', 'temp.*'), W.list(d.fans, 'Fans', 'fan.*'), W.list(d.smart, 'Drive health', 'smart.*.passed', { name: (s) => s.labels?.device + ' · ' + (s.labels?.model || '') }), W.list(d.wear, 'SSD endurance used', 'smart.*.wear')] },
|
||||
{ name: 'Network & disks', query: { rx: 'net.*.rx', tx: 'net.*.tx', rd: 'disk.*.read', wr: 'disk.*.write', fs: 'fs.*.used_pct', fsb: 'fs.*.used' }, seed: 'net.*.rx,net.*.tx,disk.*.read,disk.*.write',
|
||||
draw: (d) => [...Object.values(d.rx).map((s) => W.spark(s)), ...Object.values(d.tx).map((s) => W.spark(s)), ...Object.values(d.rd).map((s) => W.spark(s)), ...Object.values(d.wr).map((s) => W.spark(s)), W.list(d.fs, 'Filesystems', 'fs.*.used_pct', { name: (s) => s.labels?.mount }), W.list(d.fsb, 'Filesystems, bytes', 'fs.*.used', { name: (s) => s.labels?.mount })] },
|
||||
{ name: 'Containers', query: { total: 'containers.total', run: 'containers.running', bad: 'containers.unhealthy', up: 'container.*.running', mem: 'container.*.mem', cpu: 'container.*.cpu' },
|
||||
draw: (d) => [W.stat(d.run, 'Running'), W.stat(d.total, 'Total'), W.stat(d.bad, 'Unhealthy'), W.list(d.up, 'Containers', 'container.*.running', { name: (s) => s.labels?.container }), W.list(d.mem, 'Memory', 'container.*.mem', { name: (s) => s.labels?.container }), W.list(d.cpu, 'CPU, % of one core', 'container.*.cpu', { name: (s) => s.labels?.container })] },
|
||||
{ name: 'Services', query: { failed: 'services.failed', names: 'services.failed_names', running: 'services.running', reboot: 'updates.reboot_required', each: 'service.*.active' },
|
||||
draw: (d) => [W.stat(d.failed, 'Failed'), W.stat(d.running, 'Running'), W.stat(d.names, 'Which failed'), W.stat(d.reboot, 'Reboot required'), W.list(d.each, 'Every service', 'service.*.active')] },
|
||||
{ name: 'Fleet', fleet: true, query: { cpu: 'cpu.usage', mem: 'mem.used_pct', temp: 'cpu.temp', up: 'uptime', failed: 'services.failed', cont: 'containers.running', pool: 'zfs.*.health' },
|
||||
draw: (d) => [h('div', { class: 'tile wide' }, h('div', { class: 'label' }, 'one request, every machine'), h('table', {}, h('tr', {}, ['Machine', 'CPU', 'Memory', 'CPU temp', 'Uptime', 'Failed', 'Containers', 'Pools'].map((t) => h('th', {}, t))),
|
||||
Object.entries(d).sort().map(([m, r]) => r.error ? h('tr', {}, h('td', {}, m), h('td', { colspan: 7, class: 'mut' }, r.error)) : h('tr', {}, h('td', {}, m), ...[r.cpu, r.mem, r.temp, r.up, r.failed, r.cont].map((s) => h('td', {}, fmt(s))), h('td', {}, Object.values(r.pool || {}).map((s) => `${s.labels?.pool} ${s.value}`).join(', ') || '—')))), h('div', { class: 'id mono' }, 'POST /api/fleet/sensors/query'))] },
|
||||
]
|
||||
|
||||
// ---- the loop ----
|
||||
const hist = {}
|
||||
let tab = 0, timer = 0, seq = 0
|
||||
async function show(i) {
|
||||
tab = i; const my = ++seq; clearTimeout(timer)
|
||||
;[...$('tabs').children].forEach((b, j) => b.classList.toggle('on', j === i))
|
||||
$('view').replaceChildren(h('div', { class: 'mut' }, 'asking…'))
|
||||
const t = TABS[i]
|
||||
if (t.seed) try { for (const s of (await api('/api/sensors/history?ids=' + encodeURIComponent(t.seed))).series) hist[s.id] = s.v.slice(-300) } catch { /* sparklines just start empty */ }
|
||||
if (my === seq) tick(my)
|
||||
}
|
||||
async function tick(my) {
|
||||
if (my !== seq) return
|
||||
const t = TABS[tab], t0 = performance.now()
|
||||
try {
|
||||
const path = (t.fleet ? '/api/fleet/sensors/query' : '/api/sensors/query') + '?full=1', body = t.fleet ? { machines: '*', query: t.query } : t.query
|
||||
const data = await api(path, body)
|
||||
if (my !== seq) return
|
||||
$('view').replaceChildren(h('div', { class: 'grid' }, t.draw(data)))
|
||||
$('wire').textContent = `POST ${path}\n${JSON.stringify(body, null, 1)}\n\n→ ${JSON.stringify(data, (k, v) => (v && typeof v === 'object' && 'id' in v && 'value' in v ? { id: v.id, value: v.value, unit: v.unit } : v), 1).slice(0, 6000)}`
|
||||
$('dot').className = 'dot ok'; $('status').textContent = `${Math.round(performance.now() - t0)} ms · ${new Date().toLocaleTimeString()}`
|
||||
} catch (e) {
|
||||
if (my !== seq) return
|
||||
$('dot').className = 'dot bad'; $('status').textContent = e.message
|
||||
if (!$('view').querySelector('.grid')) $('view').replaceChildren(h('div', { class: 'err' }, e.message))
|
||||
}
|
||||
timer = setTimeout(() => tick(my), document.hidden ? 5000 : Number($('every').value))
|
||||
}
|
||||
|
||||
if (!CFG.base || !CFG.token) $('view').replaceChildren(h('div', { class: 'err' }, 'config.js is missing: window.HAMLAB = { base, token }'))
|
||||
else {
|
||||
$('base').textContent = CFG.base
|
||||
TABS.forEach((t, i) => $('tabs').append(h('button', {}, t.name))); [...$('tabs').children].forEach((b, i) => b.onclick = () => show(i))
|
||||
$('every').onchange = () => { clearTimeout(timer); tick(seq) }
|
||||
show(0)
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/bin/sh
|
||||
# Ham Lab installer.
|
||||
#
|
||||
# curl -fsSL https://git.bigham.dev/mitchell/hamlab/releases/download/latest/install.sh | sh
|
||||
#
|
||||
# With a terminal it walks you through it: who the console runs as, where it listens,
|
||||
# HTTPS, the firewall, the password — then downloads the build for this machine, checks
|
||||
# it against SHA256SUMS and hands over to `hamlab install`, which sets up the binary,
|
||||
# the config and a service that starts at boot. Re-running it upgrades in place.
|
||||
#
|
||||
# Without a terminal, with --yes, or with flags after `--`, it asks nothing:
|
||||
#
|
||||
# curl -fsSL …/install.sh | sh -s -- --yes
|
||||
# curl -fsSL …/install.sh | sh -s -- --listen 0.0.0.0:8100 --tls auto --password-stdin <pw
|
||||
# ./install.sh # beside the hamlab-linux-* files: installs offline
|
||||
#
|
||||
# HAMLAB_URL where hamlab-linux-<arch>, SHA256SUMS and VERSION are served from —
|
||||
# by default the newest release on Gitea; "latest" is an alias Gitea
|
||||
# resolves to the most recent release, so the URL never needs changing —
|
||||
# any static file host, or a Gitea/Forgejo release's download URL
|
||||
# HAMLAB_TOKEN access token, when that place is a private Gitea/Forgejo repo
|
||||
# NO_COLOR plain output
|
||||
#
|
||||
# POSIX sh: runs under dash, busybox ash and bash alike.
|
||||
set -eu
|
||||
|
||||
HAMLAB_URL="${HAMLAB_URL:-https://git.bigham.dev/mitchell/hamlab/releases/download/latest}"
|
||||
BUILT_FOR="@VERSION@" # filled in by `make release`
|
||||
|
||||
# ─────────────────────────────────────────────────────────────── looks ──
|
||||
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then
|
||||
B=$(printf '\033[1m'); D=$(printf '\033[2m'); R=$(printf '\033[0m')
|
||||
AMBER=$(printf '\033[38;5;214m'); GREEN=$(printf '\033[32m'); RED=$(printf '\033[31m'); CYAN=$(printf '\033[36m')
|
||||
else
|
||||
B=; D=; R=; AMBER=; GREEN=; RED=; CYAN=
|
||||
fi
|
||||
case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in
|
||||
*UTF-8*|*utf8*|*UTF8*|*utf-8*) OK='✓'; NO='✗'; DOT='•'; ARROW='›'; HR='────────────────────────────────────────────────────────────' ;;
|
||||
*) OK='ok'; NO='x'; DOT='*'; ARROW='>'; HR='------------------------------------------------------------' ;;
|
||||
esac
|
||||
|
||||
out() { printf '%s\n' "$*" >&2; }
|
||||
blank() { printf '\n' >&2; }
|
||||
good() { out " ${GREEN}${OK}${R} $*"; }
|
||||
note() { out " ${D}$*${R}"; }
|
||||
warn() { out " ${AMBER}!${R} $*"; }
|
||||
die() { blank; out " ${RED}${NO}${R} ${B}$1${R}"; shift; for more in "$@"; do out " $more"; done; blank; exit 1; }
|
||||
rule() { out "${D}${HR}${R}"; }
|
||||
step() { blank; out "${AMBER}${B}$1${R} ${B}$2${R}"; [ -z "${3:-}" ] || note "$3"; blank; }
|
||||
|
||||
banner() {
|
||||
blank
|
||||
out " ${AMBER}${B}Ham Lab${R} ${D}a web console for the machines you run${R}"
|
||||
out " ${D}terminals ${DOT} monitors ${DOT} services ${DOT} files ${DOT} updates ${DOT} disks — in one tab${R}"
|
||||
rule
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────── questions ──
|
||||
# Piped from curl, stdin is this script, so every answer comes from the terminal itself.
|
||||
TTY=/dev/tty
|
||||
have_tty() { [ -r "$TTY" ] && [ -w "$TTY" ] && (: <"$TTY") 2>/dev/null; }
|
||||
|
||||
ask() { # ask "Question" "default" → $REPLY
|
||||
if [ -n "${2:-}" ]; then printf ' %s %s[%s]%s ' "$1" "$D" "$2" "$R" >&2; else printf ' %s ' "$1" >&2; fi
|
||||
IFS= read -r REPLY <"$TTY" || REPLY=
|
||||
[ -n "$REPLY" ] || REPLY="${2:-}"
|
||||
}
|
||||
confirm() { # confirm "Question" y|n
|
||||
if [ "$2" = y ]; then hint="Y/n"; else hint="y/N"; fi
|
||||
while :; do
|
||||
ask "$1" "$hint"
|
||||
case "$REPLY" in
|
||||
"$hint") [ "$2" = y ]; return ;;
|
||||
y|Y|yes|Yes|YES) return 0 ;;
|
||||
n|N|no|No|NO) return 1 ;;
|
||||
*) warn "y or n, please" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
secret() { # secret "Prompt" → $REPLY, typed unseen
|
||||
printf ' %s ' "$1" >&2
|
||||
saved=$(stty -g <"$TTY" 2>/dev/null || true)
|
||||
stty -echo <"$TTY" 2>/dev/null || true
|
||||
IFS= read -r REPLY <"$TTY" || REPLY=
|
||||
[ -z "$saved" ] || stty "$saved" <"$TTY" 2>/dev/null || true
|
||||
printf '\n' >&2
|
||||
}
|
||||
choose() { # choose "default-number" "label 1" "label 2" … → $REPLY = the number picked
|
||||
def=$1; shift; n=0
|
||||
for label in "$@"; do n=$((n + 1)); if [ "$n" = "$def" ]; then out " ${AMBER}${B}$n${R} $label"; else out " ${B}$n${R} $label"; fi; done
|
||||
blank
|
||||
while :; do
|
||||
ask "Choose 1-$n" "$def"
|
||||
case "$REPLY" in *[!0-9]*|'') ;; *) if [ "$REPLY" -ge 1 ] && [ "$REPLY" -le "$n" ]; then return; fi ;; esac
|
||||
warn "a number from 1 to $n, please"
|
||||
done
|
||||
}
|
||||
restore_tty() { [ -z "${saved:-}" ] || stty "$saved" <"$TTY" 2>/dev/null || true; }
|
||||
|
||||
# ──────────────────────────────────────────────────────────── plumbing ──
|
||||
fetch() { # url dest
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL ${HAMLAB_TOKEN:+-H "Authorization: token $HAMLAB_TOKEN"} -o "$2" "$1"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q ${HAMLAB_TOKEN:+--header="Authorization: token $HAMLAB_TOKEN"} -O "$2" "$1"
|
||||
else
|
||||
die "This needs curl or wget, and the machine has neither."
|
||||
fi
|
||||
}
|
||||
port_busy() { # is something already listening on TCP port $1?
|
||||
hex=$(printf '%04X' "$1")
|
||||
cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | awk -v p=":$hex" '$4 == "0A" && substr($2, length($2) - 4) == p { found = 1 } END { exit !found }'
|
||||
}
|
||||
lan_addresses() { # this machine's IPv4 addresses, one per line, most useful first
|
||||
if command -v ip >/dev/null 2>&1; then
|
||||
ip -4 -o addr show scope global 2>/dev/null | awk '$2 !~ /^(docker|br-|veth|virbr|cni|flannel|tailscale|wg|tun|tap)/ { split($4, a, "/"); print a[1] " " $2 }'
|
||||
else
|
||||
hostname -I 2>/dev/null | tr ' ' '\n' | awk 'NF && $1 !~ /:/ { print $1 " -" }'
|
||||
fi
|
||||
}
|
||||
|
||||
# ────────────────────────────────────────────────────────── arguments ──
|
||||
YES=0; PASS=
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-y|--yes) YES=1; shift ;;
|
||||
-h|--help) sed -n '2,20p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' >&2; exit 0 ;;
|
||||
--) shift; PASS="$*"; break ;;
|
||||
*) PASS="$*"; break ;; # flags given straight: they belong to `hamlab install`
|
||||
esac
|
||||
done
|
||||
INTERACTIVE=1
|
||||
{ [ "$YES" = 1 ] || [ -n "$PASS" ] || ! have_tty; } && INTERACTIVE=0
|
||||
|
||||
# ─────────────────────────────────────────────────────────── preflight ──
|
||||
banner
|
||||
[ "$(uname -s)" = Linux ] || die "Ham Lab runs on Linux; this is $(uname -s)."
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch=amd64 ;;
|
||||
aarch64|arm64) arch=arm64 ;;
|
||||
armv6l|armv7l|armv8l|arm) arch=arm ;;
|
||||
i386|i486|i586|i686) arch=386 ;;
|
||||
riscv64) arch=riscv64 ;;
|
||||
*) die "There is no build for $(uname -m). (There are: amd64, arm64, arm, 386, riscv64.)" ;;
|
||||
esac
|
||||
file="hamlab-linux-$arch"
|
||||
|
||||
me=$(id -un 2>/dev/null || echo root)
|
||||
if [ "$(id -u)" = 0 ]; then how=root
|
||||
elif command -v sudo >/dev/null 2>&1; then how=sudo
|
||||
elif command -v doas >/dev/null 2>&1; then how=doas
|
||||
else die "Installing a service needs root, and there is no sudo or doas here. Run this as root."
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then init=systemd
|
||||
elif command -v rc-service >/dev/null 2>&1; then init=OpenRC
|
||||
else init=none
|
||||
fi
|
||||
os=$(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}"); [ -n "$os" ] || os=Linux
|
||||
existing=$(command -v hamlab 2>/dev/null || true); [ -n "$existing" ] || { [ -x /usr/local/bin/hamlab ] && existing=/usr/local/bin/hamlab; } || true
|
||||
have=; [ -z "$existing" ] || have=$("$existing" version 2>/dev/null | awk '{ print $2 }') || true
|
||||
|
||||
good "$os ${D}${DOT}${R} $(uname -m) ${D}${DOT}${R} $init"
|
||||
case "$how" in root) good "running as root" ;; *) good "running as ${B}$me${R}; $how is asked for root when it is needed" ;; esac
|
||||
[ "$init" != none ] || warn "no systemd or OpenRC: it will be installed, but nothing starts it at boot"
|
||||
|
||||
# ───────────────────────────────────────────────────── which version ──
|
||||
tmp=$(mktemp -d 2>/dev/null || mktemp -d -t hamlab)
|
||||
alt=
|
||||
cleanup() { restore_tty; rm -rf "$tmp"; [ -z "$alt" ] || rm -f "$alt"; }
|
||||
trap cleanup EXIT
|
||||
trap 'blank; out " cancelled — nothing was changed"; exit 130' INT TERM
|
||||
|
||||
here=$(cd "$(dirname "$0")" 2>/dev/null && pwd || echo .)
|
||||
offline=0; [ -f "$here/$file" ] && offline=1
|
||||
if [ "$offline" = 1 ]; then
|
||||
ver=$BUILT_FOR; case "$ver" in @*) ver="(local files)" ;; esac
|
||||
else
|
||||
# What is published right now — which may be newer than the copy of this script being run.
|
||||
ver=$(fetch "$HAMLAB_URL/VERSION" "$tmp/VERSION" 2>/dev/null && head -n 1 "$tmp/VERSION" | tr -cd 'A-Za-z0-9._+-' || true)
|
||||
[ -n "$ver" ] || { ver=$BUILT_FOR; case "$ver" in @*) ver=latest ;; esac; }
|
||||
fi
|
||||
# v0.1.0-69-g9c4f9df → 0001000069: versions compare as numbers, builds since the tag included.
|
||||
vnum() { printf '%s' "$1" | sed 's/^v//; s/-g[0-9a-f]*$//' | awk -F'[.-]' '{ printf "%d%03d%03d%05d", $1, $2, $3, $4 }'; }
|
||||
older=0
|
||||
if [ -n "$have" ] && [ "$have" != "$ver" ]; then
|
||||
a=$(vnum "$have" 2>/dev/null || echo 0); b=$(vnum "$ver" 2>/dev/null || echo 0)
|
||||
case "$a$b" in *[!0-9]*) ;; *) [ "$b" -ge "$a" ] || older=1 ;; esac
|
||||
fi
|
||||
if [ "$older" = 1 ]; then
|
||||
warn "Ham Lab ${B}$have${R} is installed, and what is published is ${B}older${R}: $ver"
|
||||
note " installing it would take this machine backwards"
|
||||
if [ "$INTERACTIVE" = 1 ]; then
|
||||
blank; confirm "Go back to $ver anyway?" n || { blank; out " nothing was changed"; exit 0; }
|
||||
elif [ -z "${HAMLAB_ALLOW_DOWNGRADE:-}" ]; then
|
||||
die "Not downgrading $have to $ver." "Set HAMLAB_ALLOW_DOWNGRADE=1 if that is really what you want."
|
||||
fi
|
||||
have_msg_done=1
|
||||
fi
|
||||
if [ -n "${have_msg_done:-}" ]; then :
|
||||
elif [ -n "$have" ] && [ "$have" = "$ver" ]; then good "Ham Lab ${B}$have${R} is already installed ${D}${ARROW}${R} this reinstalls it"; note " its config, user and password are kept"
|
||||
elif [ -n "$have" ]; then good "Ham Lab ${B}$have${R} is installed ${D}${ARROW}${R} upgrading to ${B}$ver${R}"; note " its config, user and password are kept"
|
||||
else good "installing Ham Lab ${B}$ver${R}"; fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────── the wizard ──
|
||||
flags=; password=; listen=; url_host=
|
||||
if [ "$INTERACTIVE" = 1 ] && [ -n "$have" ] && [ "$older" = 1 ]; then :
|
||||
elif [ "$INTERACTIVE" = 1 ] && [ -n "$have" ]; then
|
||||
blank
|
||||
confirm "$([ "$have" = "$ver" ] && echo Reinstall || echo Upgrade) now?" y || { blank; out " nothing was changed"; exit 0; }
|
||||
elif [ "$INTERACTIVE" = 1 ]; then
|
||||
# 1 ─ who
|
||||
default_user=${SUDO_USER:-${DOAS_USER:-$me}}
|
||||
step "1/5" "Who should it run as?" "Its terminals are that user's shell; its files are that user's files."
|
||||
while :; do
|
||||
ask "User" "$default_user"; run_as=$REPLY
|
||||
id "$run_as" >/dev/null 2>&1 && break
|
||||
warn "there is no user called \"$run_as\" on this machine"
|
||||
done
|
||||
[ "$run_as" != root ] || warn "as root, every terminal it opens is a root shell"
|
||||
|
||||
# 2 ─ where
|
||||
step "2/5" "Where should it listen?" "Who can reach the console at all. The password guards it either way."
|
||||
addrs=$(lan_addresses || true); first=$(printf '%s\n' "$addrs" | awk 'NR == 1 { print $1 }')
|
||||
if [ -n "$first" ]; then
|
||||
first_if=$(printf '%s\n' "$addrs" | awk 'NR == 1 { print $2 }')
|
||||
choose 2 "This machine only ${D}127.0.0.1 — for an SSH tunnel or a reverse proxy${R}" \
|
||||
"This network ${D}$first ($first_if) — reachable from the LAN${R}" \
|
||||
"Every interface ${D}0.0.0.0 — every address it has or gets${R}" \
|
||||
"Another address"
|
||||
else
|
||||
choose 1 "This machine only ${D}127.0.0.1${R}" "Every interface ${D}0.0.0.0${R}" "Another address"
|
||||
[ "$REPLY" = 1 ] || REPLY=$((REPLY + 1))
|
||||
fi
|
||||
case "$REPLY" in
|
||||
1) host=127.0.0.1 ;;
|
||||
2) host=$first ;;
|
||||
3) host=0.0.0.0 ;;
|
||||
4) others=$(printf '%s\n' "$addrs" | awk 'NR > 1 { printf "%s%s (%s)", sep, $1, $2; sep = ", " }'); [ -z "$others" ] || note "also on this machine: $others"
|
||||
while :; do ask "Address" ""; host=$REPLY; case "$host" in ''|*[!0-9a-fA-F.:]*) warn "an IPv4 or IPv6 address, please" ;; *) break ;; esac; done ;;
|
||||
esac
|
||||
while :; do
|
||||
ask "Port" "8100"; port=$REPLY
|
||||
case "$port" in ''|*[!0-9]*) warn "a number, please"; continue ;; esac
|
||||
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { warn "a port is between 1 and 65535"; continue; }
|
||||
[ "$port" -ge 1024 ] || [ "$run_as" = root ] || { warn "ports under 1024 need root, and it will run as $run_as"; continue; }
|
||||
if port_busy "$port"; then warn "something is already listening on port $port"; continue; fi
|
||||
break
|
||||
done
|
||||
case "$host" in *:*) listen="[$host]:$port" ;; *) listen="$host:$port" ;; esac
|
||||
|
||||
# 3 ─ https
|
||||
step "3/5" "HTTPS?" "With a certificate made here: browsers warn once, a hub pins it."
|
||||
if [ "$host" = 127.0.0.1 ]; then
|
||||
choose 2 "HTTPS, self-signed" "Plain HTTP ${D}— fine here: nothing leaves this machine${R}"
|
||||
else
|
||||
choose 1 "HTTPS, self-signed ${D}— recommended: the password crosses the network${R}" "Plain HTTP ${D}— only behind a reverse proxy that adds HTTPS${R}"
|
||||
fi
|
||||
if [ "$REPLY" = 1 ]; then tls=auto; scheme=https; else tls=off; scheme=http; fi
|
||||
|
||||
# 4 ─ firewall
|
||||
open_fw=0; fw=
|
||||
if [ "$host" != 127.0.0.1 ]; then
|
||||
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then fw=firewalld
|
||||
elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "^Status: active"; then fw=ufw
|
||||
elif [ "$how" != root ] && command -v ufw >/dev/null 2>&1 && systemctl is-active -q ufw 2>/dev/null; then fw=ufw
|
||||
fi
|
||||
fi
|
||||
step "4/5" "Firewall"
|
||||
if [ -n "$fw" ]; then
|
||||
note "$fw is active and will block port $port until it is opened."
|
||||
blank
|
||||
if confirm "Open $port/tcp in $fw?" y; then open_fw=1; fi
|
||||
elif [ "$host" = 127.0.0.1 ]; then good "nothing to open: it only listens on this machine"
|
||||
else good "no active firewalld or ufw found; nothing to open"
|
||||
fi
|
||||
|
||||
# 5 ─ password
|
||||
step "5/5" "The password" "One password opens the console, and the console is a shell here."
|
||||
choose 1 "Choose one now" "Generate a strong one ${D}— shown once at the end${R}"
|
||||
if [ "$REPLY" = 1 ]; then
|
||||
while :; do
|
||||
secret "Password:"; password=$REPLY
|
||||
[ ${#password} -ge 12 ] || { warn "at least 12 characters, please (it guards a shell)"; continue; }
|
||||
secret "Once more:"
|
||||
[ "$REPLY" = "$password" ] && break
|
||||
warn "those didn't match; try again"
|
||||
done
|
||||
fi
|
||||
|
||||
# summary
|
||||
if [ "$host" = 0.0.0.0 ] || [ "$host" = "::" ]; then url_host=${first:-this-machine}; else url_host=$host; fi
|
||||
blank; rule
|
||||
out " ${B}Ready to install${R}"
|
||||
blank
|
||||
out " version ${B}$ver${R} ${D}($file)${R}"
|
||||
out " runs as ${B}$run_as${R}"
|
||||
out " address ${B}$scheme://$listen${R}"
|
||||
if [ "$open_fw" = 1 ]; then out " firewall open $port/tcp in $fw"; fi
|
||||
if [ -n "$password" ]; then out " password the one you typed"; else out " password generated, shown once"; fi
|
||||
case "$init" in none) out " service ${AMBER}none — no systemd or OpenRC${R}" ;; *) out " service $init, starts at boot" ;; esac
|
||||
rule; blank
|
||||
confirm "Install?" y || { blank; out " nothing was changed"; exit 0; }
|
||||
|
||||
flags="--user $run_as --listen $listen --tls $tls"
|
||||
[ "$open_fw" = 0 ] || flags="$flags --open-firewall"
|
||||
[ -z "$password" ] || flags="$flags --password-stdin"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────── download and verify ──
|
||||
blank
|
||||
if [ "$offline" = 1 ]; then
|
||||
cp "$here/$file" "$tmp/hamlab"; [ ! -f "$here/SHA256SUMS" ] || cp "$here/SHA256SUMS" "$tmp/SHA256SUMS"
|
||||
good "using $file from $here"
|
||||
else
|
||||
printf ' %s downloading %s …' "$DOT" "$file" >&2
|
||||
fetch "$HAMLAB_URL/$file" "$tmp/hamlab" || { printf '\n' >&2; die "Download failed: $HAMLAB_URL/$file"; }
|
||||
size=$(wc -c <"$tmp/hamlab" | awk '{ printf "%.1f MB", $1 / 1048576 }')
|
||||
if [ -t 2 ]; then printf '\r\033[K' >&2; else printf '\n' >&2; fi
|
||||
good "downloaded $file ${D}($size)${R}"
|
||||
fetch "$HAMLAB_URL/SHA256SUMS" "$tmp/SHA256SUMS" 2>/dev/null || rm -f "$tmp/SHA256SUMS"
|
||||
fi
|
||||
if [ -f "$tmp/SHA256SUMS" ] && command -v sha256sum >/dev/null 2>&1; then
|
||||
want=$(awk -v f="$file" '$2 == f || $2 == "*" f { print $1 }' "$tmp/SHA256SUMS")
|
||||
got=$(sha256sum "$tmp/hamlab" | awk '{ print $1 }')
|
||||
[ -n "$want" ] || die "SHA256SUMS has no entry for $file."
|
||||
[ "$want" = "$got" ] || die "Checksum mismatch for $file." "This is not the file that was published. Nothing was installed."
|
||||
good "checksum matches SHA256SUMS"
|
||||
else
|
||||
warn "no SHA256SUMS (or no sha256sum here): the download was not verified"
|
||||
fi
|
||||
|
||||
chmod 0755 "$tmp/hamlab"; bin="$tmp/hamlab"
|
||||
if ! "$bin" version >/dev/null 2>&1; then
|
||||
alt="${HOME:-/root}/.hamlab-install.$$" # a noexec /tmp is the usual reason: try from the home directory
|
||||
cp "$bin" "$alt" 2>/dev/null && chmod 0755 "$alt" || true
|
||||
"$alt" version >/dev/null 2>&1 || die "The $arch build doesn't run on this machine."
|
||||
bin="$alt"
|
||||
fi
|
||||
|
||||
# ───────────────────────────────────────────────────────────── install ──
|
||||
blank; out " ${B}Installing${R}"; blank
|
||||
log="$tmp/install.log"
|
||||
set +e
|
||||
# shellcheck disable=SC2086 # $flags and $PASS are lists of words on purpose
|
||||
if [ -n "$password" ]; then
|
||||
printf '%s\n' "$password" | "$bin" install $flags >"$log" 2>&1; rc=$?
|
||||
password=
|
||||
elif [ "$INTERACTIVE" = 0 ] && [ -n "$PASS" ]; then
|
||||
log=
|
||||
case " $PASS " in
|
||||
*password-stdin*) "$bin" install $PASS; rc=$? ;;
|
||||
*) if [ ! -t 0 ] && have_tty; then "$bin" install $PASS <"$TTY"; rc=$?; else "$bin" install $PASS; rc=$?; fi ;;
|
||||
esac
|
||||
else
|
||||
"$bin" install $flags </dev/null >"$log" 2>&1; rc=$? # no typed password and no terminal on stdin: it generates one and prints it
|
||||
fi
|
||||
set -e
|
||||
[ -z "$log" ] || sed 's/^/ /' "$log" >&2
|
||||
hamlab_bin=$(command -v hamlab 2>/dev/null || echo /usr/local/bin/hamlab)
|
||||
[ "$rc" = 0 ] && [ -x "$hamlab_bin" ] || die "The install did not finish. Nothing above this line is hidden: the reason is in it."
|
||||
|
||||
# ──────────────────────────────────────────────────────────────── done ──
|
||||
blank; rule
|
||||
out " ${GREEN}${B}${OK} Ham Lab $("$hamlab_bin" version 2>/dev/null | awk '{ print $2 }') is installed${R}"
|
||||
blank
|
||||
row() { out " $(printf '%-10s' "$1") $2"; [ -z "${3:-}" ] || out " ${D}$3${R}"; }
|
||||
if [ "$init" = none ]; then
|
||||
row "start it" "${B}hamlab serve${R}" "as ${run_as:-its user}, under your own supervisor: nothing starts it at boot"
|
||||
fi
|
||||
if [ -n "$listen" ]; then
|
||||
row "open" "${CYAN}${B}$scheme://$(printf '%s' "$listen" | sed "s/^0\.0\.0\.0/$url_host/")${R}"
|
||||
[ "$tls" != auto ] || out " ${D}the browser warns about the self-signed certificate once${R}"
|
||||
[ "$host" != 127.0.0.1 ] || out " ${D}from elsewhere: ssh -L $port:127.0.0.1:$port $run_as@this-machine${R}"
|
||||
fi
|
||||
case "$init" in
|
||||
systemd) row "logs" "${B}journalctl -u hamlab -f${R}" ;;
|
||||
OpenRC) row "logs" "${B}tail -f /var/log/hamlab.log${R}" ;;
|
||||
esac
|
||||
row "change" "${B}hamlab install --help${R}" "a new address, HTTPS or user; everything else is kept"
|
||||
row "add to hub" "Machines ${ARROW} Add a machine" "on your main Ham Lab, with this address and password"
|
||||
row "remove" "${B}hamlab uninstall${R}" "--purge deletes its config too"
|
||||
rule; blank
|
||||
@@ -0,0 +1,520 @@
|
||||
package api
|
||||
|
||||
// Alerts: rules over sensors, watched by the hub for itself and every machine it manages.
|
||||
//
|
||||
// A rule is a selector, a comparison and a patience: `container.*.running == false for
|
||||
// 120 s`. Every alertEvery the hub asks each machine one shaped query for all the rules'
|
||||
// selectors, compares, and keeps a little state per (rule, machine, sensor): ok, pending
|
||||
// since, or firing since. Going to firing, and coming back, is an event — kept, shown on
|
||||
// the Alerts tab, and sent to whatever channels are set (ntfy, or a webhook: Home
|
||||
// Assistant, Slack, anything that takes a POST). `hamlab.reachable` is a sensor the hub
|
||||
// supplies itself, so "that machine stopped answering" is a rule like any other.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/sensors"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
alertEvery = 15 * time.Second
|
||||
maxEvents = 200
|
||||
)
|
||||
|
||||
type AlertRule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Sensor string `json:"sensor"` // an ID or a pattern
|
||||
Op string `json:"op"` // > >= < <= == !=
|
||||
Value any `json:"value"` // number, bool or string, to match the sensor
|
||||
ForSec int `json:"for_sec"`
|
||||
Severity string `json:"severity"` // info | warn | crit
|
||||
Enabled bool `json:"enabled"`
|
||||
Machines string `json:"machines"` // "*" or comma-separated names; "" is "*"
|
||||
}
|
||||
|
||||
type AlertChannel struct {
|
||||
Type string `json:"type"` // ntfy | webhook
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type AlertEvent struct {
|
||||
T int64 `json:"t"`
|
||||
Kind string `json:"kind"` // firing | resolved
|
||||
Rule string `json:"rule"`
|
||||
RuleID string `json:"rule_id"`
|
||||
Machine string `json:"machine"`
|
||||
Sensor string `json:"sensor"`
|
||||
Value any `json:"value"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
type alertFiring struct {
|
||||
AlertEvent
|
||||
Since int64 `json:"since"`
|
||||
}
|
||||
|
||||
type alertSaved struct {
|
||||
Rules []AlertRule `json:"rules"`
|
||||
Channels []AlertChannel `json:"channels"`
|
||||
Events []AlertEvent `json:"events"`
|
||||
Seeded bool `json:"seeded"`
|
||||
// Firing is what was firing when the hub last looked, so a restart (every deploy is one)
|
||||
// carries on where it was and doesn't announce the same alerts all over again.
|
||||
Firing map[string]alertKept `json:"firing,omitempty"`
|
||||
}
|
||||
|
||||
type alertKept struct {
|
||||
Since int64 `json:"since"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
const maxRules = 200
|
||||
|
||||
type alertState struct {
|
||||
pending time.Time
|
||||
firing bool
|
||||
since time.Time
|
||||
value any
|
||||
}
|
||||
|
||||
type alertEngine struct {
|
||||
s *Server
|
||||
mu sync.Mutex
|
||||
saved alertSaved
|
||||
state map[string]*alertState // ruleID|machine|sensor
|
||||
last time.Time
|
||||
}
|
||||
|
||||
var defaultRules = []AlertRule{
|
||||
{ID: "machine-offline", Name: "Machine not answering", Sensor: "hamlab.reachable", Op: "==", Value: false, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "container-down", Name: "Container down", Sensor: "container.*.down", Op: "==", Value: true, ForSec: 120, Severity: "crit", Enabled: true},
|
||||
{ID: "container-unhealthy", Name: "Container unhealthy", Sensor: "container.*.health", Op: "==", Value: "unhealthy", ForSec: 180, Severity: "warn", Enabled: true},
|
||||
{ID: "service-failed", Name: "A service has failed", Sensor: "services.failed", Op: ">", Value: 0.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "disk-full", Name: "Filesystem nearly full", Sensor: "fs.*.used_pct", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "pool-unhealthy", Name: "ZFS pool not healthy", Sensor: "zfs.*.healthy", Op: "==", Value: false, ForSec: 60, Severity: "crit", Enabled: true},
|
||||
{ID: "drive-failing", Name: "Drive failing SMART", Sensor: "smart.*.passed", Op: "==", Value: false, ForSec: 0, Severity: "crit", Enabled: true},
|
||||
{ID: "on-battery", Name: "UPS on battery", Sensor: "ups.on_battery", Op: "==", Value: true, ForSec: 30, Severity: "crit", Enabled: true},
|
||||
{ID: "hot", Name: "CPU running hot", Sensor: "cpu.temp", Op: ">=", Value: 90.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
{ID: "memory", Name: "Memory nearly exhausted", Sensor: "mem.used_pct", Op: ">=", Value: 95.0, ForSec: 300, Severity: "warn", Enabled: true},
|
||||
}
|
||||
|
||||
func newAlertEngine(s *Server) *alertEngine {
|
||||
e := &alertEngine{s: s, state: map[string]*alertState{}}
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) { raw = st.Alerts })
|
||||
_ = json.Unmarshal(raw, &e.saved)
|
||||
if !e.saved.Seeded {
|
||||
e.saved.Rules, e.saved.Seeded = append([]AlertRule{}, defaultRules...), true
|
||||
}
|
||||
for key, k := range e.saved.Firing {
|
||||
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
|
||||
}
|
||||
for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
|
||||
if r.ID == "container-down" && r.Sensor == "container.*.running" {
|
||||
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
|
||||
}
|
||||
}
|
||||
go e.run()
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *alertEngine) persist() {
|
||||
b, _ := json.Marshal(e.saved)
|
||||
_ = e.s.store.Update(func(st *store.State) { st.Alerts = b })
|
||||
}
|
||||
|
||||
func (e *alertEngine) run() {
|
||||
time.Sleep(20 * time.Second) // let the status sensors take their first readings
|
||||
for {
|
||||
e.evaluate()
|
||||
time.Sleep(alertEvery)
|
||||
}
|
||||
}
|
||||
|
||||
func wants(r AlertRule, machine string) bool {
|
||||
if r.Machines == "" || r.Machines == "*" {
|
||||
return true
|
||||
}
|
||||
for _, m := range strings.Split(r.Machines, ",") {
|
||||
if strings.TrimSpace(m) == machine {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// holds reports whether `got <op> want`, comparing like with like.
|
||||
func holds(got any, op string, want any) bool {
|
||||
switch g := got.(type) {
|
||||
case float64:
|
||||
w, ok := want.(float64)
|
||||
if !ok {
|
||||
if s, isStr := want.(string); isStr {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
w = f
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch op {
|
||||
case ">":
|
||||
return g > w
|
||||
case ">=":
|
||||
return g >= w
|
||||
case "<":
|
||||
return g < w
|
||||
case "<=":
|
||||
return g <= w
|
||||
case "==":
|
||||
return g == w
|
||||
case "!=":
|
||||
return g != w
|
||||
}
|
||||
case bool:
|
||||
w, ok := want.(bool)
|
||||
if !ok {
|
||||
w = fmt.Sprint(want) == "true"
|
||||
}
|
||||
return (op == "==" && g == w) || (op == "!=" && g != w)
|
||||
case string:
|
||||
w := fmt.Sprint(want)
|
||||
return (op == "==" && g == w) || (op == "!=" && g != w)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *alertEngine) evaluate() {
|
||||
e.mu.Lock()
|
||||
rules := append([]AlertRule{}, e.saved.Rules...)
|
||||
e.mu.Unlock()
|
||||
query := map[string]string{}
|
||||
for _, r := range rules {
|
||||
if r.Enabled && r.Sensor != "hamlab.reachable" {
|
||||
query[r.ID] = r.Sensor
|
||||
}
|
||||
}
|
||||
qb, _ := json.Marshal(query)
|
||||
machines := append([]string{e.s.hostName()}, e.s.nodes.Names()...)
|
||||
results := map[string]map[string]any{} // machine → ruleID → value | {id: value}
|
||||
reach := map[string]bool{}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
for _, m := range machines {
|
||||
wg.Add(1)
|
||||
go func(m string) {
|
||||
defer wg.Done()
|
||||
var res map[string]any
|
||||
ok := false
|
||||
if m == e.s.hostName() {
|
||||
if out, err := sensors.Shape(e.s.sensors.All(), qb, false); err == nil {
|
||||
res, ok = out.(map[string]any)
|
||||
}
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
code, b, err := e.s.nodes.Do(ctx, m, "POST", "/api/sensors/query", qb, http.Header{"Content-Type": {"application/json"}})
|
||||
cancel()
|
||||
ok = err == nil && code == 200 && json.Unmarshal(b, &res) == nil
|
||||
}
|
||||
mu.Lock()
|
||||
results[m], reach[m] = res, ok
|
||||
mu.Unlock()
|
||||
}(m)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
now := time.Now()
|
||||
seen := map[string]bool{}
|
||||
var events []AlertEvent
|
||||
e.mu.Lock()
|
||||
for _, r := range rules {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, m := range machines {
|
||||
if !wants(r, m) {
|
||||
continue
|
||||
}
|
||||
readings := map[string]any{}
|
||||
if r.Sensor == "hamlab.reachable" {
|
||||
readings["hamlab.reachable"] = reach[m]
|
||||
} else if !reach[m] {
|
||||
continue // no reading is not a reading: an unreachable machine is the offline rule's business
|
||||
} else if v, ok := results[m][r.ID]; ok {
|
||||
if many, isMap := v.(map[string]any); isMap {
|
||||
readings = many
|
||||
} else if v != nil {
|
||||
readings[r.Sensor] = v
|
||||
}
|
||||
}
|
||||
for id, val := range readings {
|
||||
key := r.ID + "|" + m + "|" + id
|
||||
seen[key] = true
|
||||
st := e.state[key]
|
||||
if st == nil {
|
||||
st = &alertState{}
|
||||
e.state[key] = st
|
||||
}
|
||||
st.value = val
|
||||
if holds(val, r.Op, r.Value) {
|
||||
if st.pending.IsZero() {
|
||||
st.pending = now
|
||||
}
|
||||
if !st.firing && now.Sub(st.pending) >= time.Duration(r.ForSec)*time.Second {
|
||||
st.firing, st.since = true, now
|
||||
events = append(events, AlertEvent{T: now.UnixMilli(), Kind: "firing", Rule: r.Name, RuleID: r.ID, Machine: m, Sensor: id, Value: val, Severity: r.Severity})
|
||||
}
|
||||
} else {
|
||||
if st.firing {
|
||||
events = append(events, AlertEvent{T: now.UnixMilli(), Kind: "resolved", Rule: r.Name, RuleID: r.ID, Machine: m, Sensor: id, Value: val, Severity: r.Severity})
|
||||
}
|
||||
st.pending, st.firing = time.Time{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
byID := map[string]AlertRule{}
|
||||
for _, r := range rules {
|
||||
byID[r.ID] = r
|
||||
}
|
||||
for key, st := range e.state { // no longer watched: the rule was paused, deleted or narrowed, or the sensor is gone
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
if st.firing { // it was on the Alerts tab and perhaps on a phone: say that it is over, and why
|
||||
part := strings.SplitN(key, "|", 3)
|
||||
r, known := byID[part[0]]
|
||||
name, why := r.Name, "no longer watched"
|
||||
if !known {
|
||||
name, why = part[0], "rule deleted"
|
||||
} else if !r.Enabled {
|
||||
why = "rule paused"
|
||||
}
|
||||
if name == "" {
|
||||
name = part[0]
|
||||
}
|
||||
events = append(events, AlertEvent{T: now.UnixMilli(), Kind: "resolved", Rule: name, RuleID: part[0], Machine: part[1], Sensor: part[2], Value: why, Severity: r.Severity})
|
||||
}
|
||||
delete(e.state, key)
|
||||
}
|
||||
e.last = now
|
||||
kept := map[string]alertKept{}
|
||||
for key, st := range e.state {
|
||||
if st.firing {
|
||||
kept[key] = alertKept{Since: st.since.UnixMilli(), Value: st.value}
|
||||
}
|
||||
}
|
||||
changed := len(kept) != len(e.saved.Firing)
|
||||
for k := range kept {
|
||||
if _, ok := e.saved.Firing[k]; !ok {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
e.saved.Firing = kept
|
||||
if changed && len(events) == 0 {
|
||||
e.persist()
|
||||
}
|
||||
if len(events) > 0 {
|
||||
e.saved.Events = append(e.saved.Events, events...)
|
||||
if len(e.saved.Events) > maxEvents {
|
||||
e.saved.Events = e.saved.Events[len(e.saved.Events)-maxEvents:]
|
||||
}
|
||||
e.persist()
|
||||
}
|
||||
channels := append([]AlertChannel{}, e.saved.Channels...)
|
||||
e.mu.Unlock()
|
||||
for _, ev := range events {
|
||||
e.notify(channels, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *alertEngine) firing() []alertFiring {
|
||||
out := []alertFiring{}
|
||||
names := map[string]AlertRule{}
|
||||
for _, r := range e.saved.Rules {
|
||||
names[r.ID] = r
|
||||
}
|
||||
for key, st := range e.state {
|
||||
if !st.firing {
|
||||
continue
|
||||
}
|
||||
p := strings.SplitN(key, "|", 3)
|
||||
r := names[p[0]]
|
||||
out = append(out, alertFiring{AlertEvent: AlertEvent{T: st.since.UnixMilli(), Kind: "firing", Rule: r.Name, RuleID: r.ID, Machine: p[1], Sensor: p[2], Value: st.value, Severity: r.Severity}, Since: st.since.UnixMilli()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Since > out[j].Since })
|
||||
return out
|
||||
}
|
||||
|
||||
func describe(ev AlertEvent) (title, body string) {
|
||||
mark := map[string]string{"firing": "▲", "resolved": "✓"}[ev.Kind]
|
||||
title = fmt.Sprintf("%s %s — %s", mark, ev.Rule, ev.Machine)
|
||||
body = fmt.Sprintf("%s = %v", ev.Sensor, ev.Value)
|
||||
if ev.Kind == "resolved" {
|
||||
body = "back to normal: " + body
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (e *alertEngine) notify(channels []AlertChannel, ev AlertEvent) {
|
||||
title, body := describe(ev)
|
||||
for _, c := range channels {
|
||||
var req *http.Request
|
||||
switch c.Type {
|
||||
case "ntfy":
|
||||
req, _ = http.NewRequest("POST", c.URL, strings.NewReader(body))
|
||||
req.Header.Set("Title", title)
|
||||
req.Header.Set("Priority", map[string]string{"crit": "urgent", "warn": "high"}[ev.Severity])
|
||||
req.Header.Set("Tags", map[string]string{"firing": "warning", "resolved": "white_check_mark"}[ev.Kind])
|
||||
if ev.Kind == "resolved" {
|
||||
req.Header.Set("Priority", "default")
|
||||
}
|
||||
case "webhook":
|
||||
b, _ := json.Marshal(map[string]any{"title": title, "message": body, "event": ev})
|
||||
req, _ = http.NewRequest("POST", c.URL, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if req == nil {
|
||||
continue
|
||||
}
|
||||
go func(req *http.Request, url string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
log.Printf("alert: notifying %s: %v", url, err)
|
||||
return
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
log.Printf("alert: %s answered %d", url, resp.StatusCode)
|
||||
}
|
||||
}(req, c.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- API ----
|
||||
|
||||
func (s *Server) alertsGet(w http.ResponseWriter, _ *http.Request) {
|
||||
e := s.alerts
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
ev := append([]AlertEvent{}, e.saved.Events...)
|
||||
for i, j := 0, len(ev)-1; i < j; i, j = i+1, j-1 {
|
||||
ev[i], ev[j] = ev[j], ev[i]
|
||||
}
|
||||
rules, channels := e.saved.Rules, e.saved.Channels
|
||||
if rules == nil { // an empty list is a list: never hand the page a null to take the length of
|
||||
rules = []AlertRule{}
|
||||
}
|
||||
if channels == nil {
|
||||
channels = []AlertChannel{}
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"rules": rules, "channels": channels, "events": ev, "firing": e.firing(), "checked": e.last.UnixMilli(), "every_sec": int(alertEvery.Seconds())})
|
||||
}
|
||||
|
||||
func (s *Server) alertsPut(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Rules *[]AlertRule `json:"rules"`
|
||||
Channels *[]AlertChannel `json:"channels"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 256<<10)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if body.Rules != nil && len(*body.Rules) > maxRules {
|
||||
jsonErr(w, 400, fmt.Sprintf("at most %d rules: every one is checked on every machine every %d s", maxRules, int(alertEvery.Seconds())))
|
||||
return
|
||||
}
|
||||
if body.Rules != nil {
|
||||
ids := map[string]bool{}
|
||||
for i := range *body.Rules {
|
||||
ru := &(*body.Rules)[i]
|
||||
ru.Name, ru.Sensor = strings.TrimSpace(ru.Name), strings.TrimSpace(ru.Sensor)
|
||||
if ru.ID == "" || ids[ru.ID] || ru.Name == "" || len(ru.Name) > maxNameLen || ru.ForSec < 0 || ru.ForSec > 86400 {
|
||||
jsonErr(w, 400, "every rule needs a unique id, a name (64 characters at most) and a patience between 0 and 86400 s")
|
||||
return
|
||||
}
|
||||
ids[ru.ID] = true
|
||||
if _, err := sensors.Matcher(ru.Sensor); err != nil || ru.Sensor == "" {
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: %q is not a sensor ID or pattern", ru.Name, ru.Sensor))
|
||||
return
|
||||
}
|
||||
if !strings.Contains(" > >= < <= == != ", " "+ru.Op+" ") {
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: the comparison must be one of > >= < <= == !=", ru.Name))
|
||||
return
|
||||
}
|
||||
switch val := ru.Value.(type) {
|
||||
case float64, bool:
|
||||
case string:
|
||||
if len(val) > 200 {
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: the value is too long", ru.Name))
|
||||
return
|
||||
}
|
||||
if ru.Op != "==" && ru.Op != "!=" { // "cpu.usage > ninety" can never be true, and would never say so
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: %s compares numbers, and %q is not one; text can only be == or !=", ru.Name, ru.Op, val))
|
||||
return
|
||||
}
|
||||
default:
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: the value must be a number, true/false or text", ru.Name))
|
||||
return
|
||||
}
|
||||
if _, isBool := ru.Value.(bool); isBool && ru.Op != "==" && ru.Op != "!=" {
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: true/false can only be == or !=", ru.Name))
|
||||
return
|
||||
}
|
||||
if ru.Severity != "info" && ru.Severity != "crit" {
|
||||
ru.Severity = "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
if body.Channels != nil {
|
||||
for _, c := range *body.Channels {
|
||||
if (c.Type != "ntfy" && c.Type != "webhook") || !(strings.HasPrefix(c.URL, "https://") || strings.HasPrefix(c.URL, "http://")) {
|
||||
jsonErr(w, 400, "a channel is {type: ntfy|webhook, url: http(s)://…}")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
e := s.alerts
|
||||
e.mu.Lock()
|
||||
if body.Rules != nil {
|
||||
e.saved.Rules = *body.Rules
|
||||
}
|
||||
if body.Channels != nil {
|
||||
e.saved.Channels = *body.Channels
|
||||
}
|
||||
e.persist()
|
||||
e.mu.Unlock()
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// alertsTest sends one clearly-marked test notification through every channel.
|
||||
func (s *Server) alertsTest(w http.ResponseWriter, _ *http.Request) {
|
||||
e := s.alerts
|
||||
e.mu.Lock()
|
||||
channels := append([]AlertChannel{}, e.saved.Channels...)
|
||||
e.mu.Unlock()
|
||||
if len(channels) == 0 {
|
||||
jsonErr(w, 400, "no channels to test")
|
||||
return
|
||||
}
|
||||
e.notify(channels, AlertEvent{T: time.Now().UnixMilli(), Kind: "firing", Rule: "Test alert", Machine: s.hostName(), Sensor: "hamlab.test", Value: "this is only a test", Severity: "info"})
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package api
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
// (Dockhand, compose) stays the one place that changes what exists.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
type dockerContainer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
State string `json:"state"` // running | exited | paused | restarting | created | dead
|
||||
Status string `json:"status"` // "Up 3 hours (healthy)"
|
||||
Health string `json:"health"` // healthy | unhealthy | starting | none
|
||||
Ports string `json:"ports"`
|
||||
Stack string `json:"stack"` // compose project, "" when started by hand
|
||||
Service string `json:"service"` // compose service
|
||||
Created string `json:"created"`
|
||||
Mem float64 `json:"mem"` // bytes, from the container's cgroup; 0 when unknown
|
||||
}
|
||||
|
||||
func dockerTool() string {
|
||||
for _, t := range []string{"docker", "podman"} {
|
||||
if _, err := exec.LookPath(t); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// dockerRun runs the tool as this user, and again as root if the socket said no.
|
||||
func dockerRun(ctx context.Context, args ...string) ([]byte, error) {
|
||||
tool := dockerTool()
|
||||
if tool == "" {
|
||||
return nil, errors.New("neither docker nor podman is installed here")
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, tool, args...).CombinedOutput()
|
||||
if err != nil && priv.Available() && strings.Contains(strings.ToLower(string(out)), "permission denied") {
|
||||
full := priv.Wrap(append([]string{tool}, args...)...)
|
||||
out, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput()
|
||||
}
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if i := strings.LastIndex(msg, "\n"); i >= 0 {
|
||||
msg = msg[i+1:]
|
||||
}
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return out, errors.New(msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func jsonLines(out []byte, each func(map[string]any)) {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(line), &m) == nil {
|
||||
each(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dstr(m map[string]any, k string) string { s, _ := m[k].(string); return s }
|
||||
|
||||
func labelOf(labels, key string) string {
|
||||
for _, kv := range strings.Split(labels, ",") {
|
||||
if v, ok := strings.CutPrefix(kv, key+"="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) dockerOverview(w http.ResponseWriter, r *http.Request) {
|
||||
if dockerTool() == "" {
|
||||
jsonOK(w, 200, map[string]any{"available": false, "message": "Docker isn't installed on this machine."})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
out, err := dockerRun(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
|
||||
if err != nil {
|
||||
jsonOK(w, 200, map[string]any{"available": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
containers := []dockerContainer{}
|
||||
jsonLines(out, func(m map[string]any) {
|
||||
c := dockerContainer{ID: dstr(m, "ID"), Name: strings.Split(dstr(m, "Names"), ",")[0], Image: dstr(m, "Image"), State: dstr(m, "State"), Status: dstr(m, "Status"),
|
||||
Ports: dstr(m, "Ports"), Created: dstr(m, "CreatedAt"), Health: "none", Stack: labelOf(dstr(m, "Labels"), "com.docker.compose.project"), Service: labelOf(dstr(m, "Labels"), "com.docker.compose.service")}
|
||||
for _, h := range []string{"unhealthy", "healthy", "starting"} {
|
||||
if strings.Contains(c.Status, "("+h+")") || strings.Contains(c.Status, "health: "+h) {
|
||||
c.Health = h
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{"/sys/fs/cgroup/system.slice/docker-" + c.ID + ".scope", "/sys/fs/cgroup/docker/" + c.ID, "/sys/fs/cgroup/machine.slice/libpod-" + c.ID + ".scope"} {
|
||||
if b, err := os.ReadFile(dir + "/memory.current"); err == nil {
|
||||
c.Mem, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
|
||||
break
|
||||
}
|
||||
}
|
||||
containers = append(containers, c)
|
||||
})
|
||||
sort.Slice(containers, func(i, j int) bool {
|
||||
if containers[i].Stack != containers[j].Stack {
|
||||
return containers[i].Stack < containers[j].Stack
|
||||
}
|
||||
return containers[i].Name < containers[j].Name
|
||||
})
|
||||
list := func(args ...string) []map[string]any {
|
||||
rows := []map[string]any{}
|
||||
if out, err := dockerRun(ctx, args...); err == nil {
|
||||
jsonLines(out, func(m map[string]any) { rows = append(rows, m) })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
// Who uses what, from one inspect of every container: an image or a volume nothing uses is
|
||||
// the only kind this page offers to remove.
|
||||
imageUsed, volumeUsers := map[string]bool{}, map[string][]string{}
|
||||
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}}`
|
||||
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") {
|
||||
p := strings.SplitN(line, "|", 3)
|
||||
if len(p) < 3 {
|
||||
continue
|
||||
}
|
||||
if id := strings.TrimPrefix(p[1], "sha256:"); len(id) >= 12 {
|
||||
imageUsed[id[:12]] = true
|
||||
}
|
||||
for _, v := range strings.Split(p[2], ",") {
|
||||
if v != "" {
|
||||
volumeUsers[v] = append(volumeUsers[v], strings.TrimPrefix(p[0], "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
images := list("images", "--format", "{{json .}}")
|
||||
for _, im := range images {
|
||||
im["in_use"] = imageUsed[dstr(im, "ID")]
|
||||
}
|
||||
volumes := list("volume", "ls", "--format", "{{json .}}")
|
||||
for _, v := range volumes {
|
||||
users := volumeUsers[dstr(v, "Name")]
|
||||
if users == nil {
|
||||
users = []string{}
|
||||
}
|
||||
sort.Strings(users)
|
||||
v["used_by"] = users
|
||||
v["anonymous"] = strings.Contains(dstr(v, "Labels"), "com.docker.volume.anonymous")
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"available": true, "tool": dockerTool(), "containers": containers,
|
||||
"images": images, "volumes": volumes, "networks": list("network", "ls", "--format", "{{json .}}")})
|
||||
}
|
||||
|
||||
var dockerRef = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
|
||||
|
||||
func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) {
|
||||
id, action := r.PathValue("id"), r.PathValue("action")
|
||||
if !dockerRef.MatchString(id) || (action != "start" && action != "stop" && action != "restart" && action != "remove") {
|
||||
jsonErr(w, 400, "start, stop, restart or remove a container by name or ID")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
verb := action
|
||||
if action == "remove" { // never forced: a running container has to be stopped first, on purpose
|
||||
verb = "rm"
|
||||
}
|
||||
if _, err := dockerRun(ctx, verb, id); err != nil {
|
||||
code := 500
|
||||
if action == "remove" {
|
||||
code = 409
|
||||
}
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("docker: %s %s (from %s)", action, id, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
var imageRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,254}$`)
|
||||
|
||||
// imagePull runs `docker pull` as a task the page follows.
|
||||
func (s *Server) imagePull(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Image string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !imageRef.MatchString(strings.TrimSpace(body.Image)) {
|
||||
jsonErr(w, 400, "an image looks like nginx:alpine or ghcr.io/owner/name:tag")
|
||||
return
|
||||
}
|
||||
ref := strings.TrimSpace(body.Image)
|
||||
t, err := s.tasks.Start("docker:pull", "pull "+ref, exec.Command(dockerTool(), "pull", ref))
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("docker: pull %s as task %s (from %s)", ref, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
// imageRemove is `docker rmi`, unforced: an image something still uses is refused by Docker, and that is the answer.
|
||||
func (s *Server) imageRemove(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Image string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !imageRef.MatchString(strings.TrimSpace(body.Image)) {
|
||||
jsonErr(w, 400, "which image?")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
if _, err := dockerRun(ctx, "rmi", strings.TrimSpace(body.Image)); err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("docker: rmi %s (from %s)", body.Image, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// imagePrune removes dangling images — layers no tag points at any more, left behind by pulls —
|
||||
// or, asked for {"all": true}, every image no container uses. Those can all be pulled again.
|
||||
func (s *Server) imagePrune(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
var body struct{ All bool }
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||
args := []string{"image", "prune", "-f"}
|
||||
if body.All {
|
||||
args = append(args, "-a")
|
||||
}
|
||||
out, err := dockerRun(ctx, args...)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
log.Printf("docker: image prune (from %s)", clientIP(r))
|
||||
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
||||
}
|
||||
|
||||
// dockerLogs is the tail of a container's log; ?since=<RFC3339> asks only for what is new,
|
||||
// which is how the page follows a log without holding a connection open.
|
||||
func (s *Server) dockerLogs(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !dockerRef.MatchString(id) {
|
||||
jsonErr(w, 400, "bad container")
|
||||
return
|
||||
}
|
||||
args := []string{"logs", "--timestamps"}
|
||||
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 {
|
||||
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
|
||||
if n <= 0 || n > 5000 {
|
||||
n = 300
|
||||
}
|
||||
args = append(args, "--tail", strconv.Itoa(n))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
out, err := dockerRun(ctx, append(args, id)...)
|
||||
if err != nil {
|
||||
code := 500
|
||||
if strings.Contains(strings.ToLower(err.Error()), "no such container") {
|
||||
code = 404
|
||||
}
|
||||
jsonErr(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
type line struct {
|
||||
T string `json:"t"`
|
||||
M string `json:"m"`
|
||||
}
|
||||
lines := []line{}
|
||||
for _, l := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
t, m, ok := strings.Cut(l, " ")
|
||||
if _, err := time.Parse(time.RFC3339Nano, t); !ok || err != nil {
|
||||
t, m = "", l
|
||||
}
|
||||
lines = append(lines, line{t, m})
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"lines": lines})
|
||||
}
|
||||
|
||||
// dockerInspect is the parts of `docker inspect` worth reading on a phone.
|
||||
func (s *Server) dockerInspect(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", id)
|
||||
var all []map[string]any
|
||||
if err != nil || json.Unmarshal(out, &all) != nil || len(all) == 0 {
|
||||
jsonErr(w, 500, fmt.Sprint("inspect failed: ", err))
|
||||
return
|
||||
}
|
||||
c := all[0]
|
||||
dig := func(m map[string]any, path ...string) any {
|
||||
var cur any = m
|
||||
for _, p := range path {
|
||||
mm, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cur = mm[p]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
env := []string{}
|
||||
if list, ok := dig(c, "Config", "Env").([]any); ok {
|
||||
for _, e := range list {
|
||||
k, _, _ := strings.Cut(fmt.Sprint(e), "=")
|
||||
env = append(env, k) // names only: values are where the secrets live
|
||||
}
|
||||
}
|
||||
sort.Strings(env)
|
||||
jsonOK(w, 200, map[string]any{
|
||||
"image": dig(c, "Config", "Image"), "command": dig(c, "Config", "Cmd"), "entrypoint": dig(c, "Config", "Entrypoint"),
|
||||
"restart": dig(c, "HostConfig", "RestartPolicy", "Name"), "network_mode": dig(c, "HostConfig", "NetworkMode"),
|
||||
"memory_limit": dig(c, "HostConfig", "Memory"), "started": dig(c, "State", "StartedAt"), "exit_code": dig(c, "State", "ExitCode"),
|
||||
"error": dig(c, "State", "Error"), "restarts": c["RestartCount"], "mounts": c["Mounts"], "ports": dig(c, "NetworkSettings", "Ports"),
|
||||
"networks": dig(c, "NetworkSettings", "Networks"), "env_names": env,
|
||||
"compose_file": dig(c, "Config", "Labels", "com.docker.compose.project.config_files"),
|
||||
})
|
||||
}
|
||||
|
||||
var volumeRef = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$`)
|
||||
|
||||
// volumeRemove deletes one volume and what is in it, which nothing brings back. So it is never
|
||||
// forced (Docker refuses a volume a container still uses), it is one at a time, and the request
|
||||
// has to name the volume twice — a slip of a script can't delete one by accident.
|
||||
func (s *Server) volumeRemove(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name, Confirm string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || !volumeRef.MatchString(body.Name) {
|
||||
jsonErr(w, 400, "which volume?")
|
||||
return
|
||||
}
|
||||
if body.Confirm != body.Name {
|
||||
jsonErr(w, 400, "name the volume again in \"confirm\" to delete it")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
if _, err := dockerRun(ctx, "volume", "rm", body.Name); err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("docker: volume rm %s (from %s)", body.Name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// volumePrune removes unused *anonymous* volumes only: the nameless ones an image's VOLUME line
|
||||
// leaves behind each time its container is recreated. Named volumes are somebody's data, and a
|
||||
// stack that is merely down leaves its volumes "unused" — those go one at a time, by name.
|
||||
func (s *Server) volumePrune(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
out, err := dockerRun(ctx, "volume", "prune", "-f")
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
log.Printf("docker: volume prune (from %s)", clientIP(r))
|
||||
jsonOK(w, 200, map[string]string{"result": lines[len(lines)-1]})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package api
|
||||
|
||||
// The Space tab: what is using a disk or a pool. The unit is a whole volume, never a
|
||||
// partition: a disk on its own is scanned through every filesystem it backs, and a ZFS pool
|
||||
// through every mounted dataset, as one thing — its member disks are not scanned apart.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/space"
|
||||
"hamlab/internal/storage"
|
||||
)
|
||||
|
||||
// spaceTarget is one thing a scan can measure: a disk on its own, or a ZFS pool as a whole.
|
||||
// A disk that is a member of a pool is not a target of its own — its share of the pool means
|
||||
// nothing by itself — it is listed under the pool. (A disk with a pool partition *and* a
|
||||
// filesystem of its own, root on one partition and a pool on another, is both.)
|
||||
type spaceTarget struct {
|
||||
ID string `json:"id"` // "nvme0" or "pool:tank"
|
||||
Kind string `json:"kind"` // disk | pool
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model,omitempty"` // disk: its model
|
||||
Layout string `json:"layout,omitempty"` // pool: raidz2, mirror, …
|
||||
Health string `json:"health,omitempty"` // pool: ONLINE, DEGRADED, …
|
||||
Size uint64 `json:"size"`
|
||||
Used uint64 `json:"used,omitempty"` // pool: allocated
|
||||
Disks []string `json:"disks,omitempty"` // pool: its member disks
|
||||
Roots []string `json:"roots"` // the mountpoints a scan walks
|
||||
Scan *space.Scan `json:"scan,omitempty"` // the last scan, if any
|
||||
|
||||
datasets map[string]string // root → ZFS dataset, for the ones that are
|
||||
}
|
||||
|
||||
var layoutRe = regexp.MustCompile(`\b(draid\d*|raidz[123]?|mirror)\b`)
|
||||
|
||||
// spaceTargets lists the disks and pools a scan can measure.
|
||||
func (s *Server) spaceTargets(ctx context.Context) []spaceTarget {
|
||||
devs, _ := storage.Devices(ctx)
|
||||
var out []spaceTarget
|
||||
pools := map[string]*spaceTarget{}
|
||||
var poolOrder []string
|
||||
for _, d := range devs {
|
||||
disk := spaceTarget{ID: d.Name, Kind: "disk", Name: d.Name, Model: d.Model, Size: d.Size, Roots: []string{}}
|
||||
seen := map[string]bool{}
|
||||
inPool := false
|
||||
for _, p := range d.Parts {
|
||||
if p.FSType == "zfs_member" && p.Label != "" {
|
||||
inPool = true
|
||||
pt := pools[p.Label]
|
||||
if pt == nil {
|
||||
pt = &spaceTarget{ID: "pool:" + p.Label, Kind: "pool", Name: p.Label, Roots: []string{}, datasets: map[string]string{}}
|
||||
for _, zm := range zfsMounts(ctx, p.Label) {
|
||||
pt.Roots = append(pt.Roots, zm.mount)
|
||||
pt.datasets[zm.mount] = zm.dataset
|
||||
}
|
||||
pools[p.Label] = pt
|
||||
poolOrder = append(poolOrder, p.Label)
|
||||
}
|
||||
pt.Disks = append(pt.Disks, d.Name)
|
||||
continue
|
||||
}
|
||||
if p.Mount != "" && p.Mount != "[SWAP]" && !seen[p.Mount] {
|
||||
seen[p.Mount] = true
|
||||
disk.Roots = append(disk.Roots, p.Mount)
|
||||
}
|
||||
}
|
||||
if !inPool || len(disk.Roots) > 0 {
|
||||
sort.Strings(disk.Roots)
|
||||
out = append(out, disk)
|
||||
}
|
||||
}
|
||||
if len(pools) > 0 {
|
||||
// size, health and layout from zpool; the vdev tree names the layout
|
||||
for _, p := range s.storage.ZFS(ctx).Pools {
|
||||
if pt := pools[p.Name]; pt != nil {
|
||||
pt.Size, pt.Used, pt.Health = p.Size, p.Alloc, p.Health
|
||||
if m := layoutRe.FindString(p.Config); m != "" {
|
||||
pt.Layout = m
|
||||
} else if len(pt.Disks) == 1 {
|
||||
pt.Layout = "single disk"
|
||||
} else if len(pt.Disks) > 1 {
|
||||
pt.Layout = "stripe"
|
||||
}
|
||||
}
|
||||
}
|
||||
var ps []spaceTarget
|
||||
for _, name := range poolOrder {
|
||||
sort.Strings(pools[name].Roots)
|
||||
ps = append(ps, *pools[name])
|
||||
}
|
||||
out = append(ps, out...) // pools first: they are what a NAS is
|
||||
}
|
||||
if out == nil {
|
||||
out = []spaceTarget{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type zfsMount struct{ mount, dataset string }
|
||||
|
||||
// zfsMounts is every mounted filesystem of a pool, with its dataset.
|
||||
func zfsMounts(ctx context.Context, pool string) []zfsMount {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-t", "filesystem", "-r", "-o", "name,mountpoint,mounted", pool).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var mounts []zfsMount
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) == 3 && f[2] == "yes" && strings.HasPrefix(f[1], "/") {
|
||||
mounts = append(mounts, zfsMount{mount: f[1], dataset: f[0]})
|
||||
}
|
||||
}
|
||||
return mounts
|
||||
}
|
||||
|
||||
func (s *Server) spaceOverview(w http.ResponseWriter, r *http.Request) {
|
||||
targets := s.spaceTargets(r.Context())
|
||||
scans := s.space.All()
|
||||
for i := range targets {
|
||||
targets[i].Scan = scans[targets[i].ID]
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"targets": targets})
|
||||
}
|
||||
|
||||
func (s *Server) spaceTarget(w http.ResponseWriter, r *http.Request, id string) (spaceTarget, bool) {
|
||||
for _, t := range s.spaceTargets(r.Context()) {
|
||||
if t.ID == id {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
jsonErr(w, 404, "no such disk or pool")
|
||||
return spaceTarget{}, false
|
||||
}
|
||||
|
||||
func (s *Server) spaceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
st := s.space.Status(r.PathValue("disk"))
|
||||
if st == nil {
|
||||
jsonErr(w, 404, "not scanned")
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, st)
|
||||
}
|
||||
|
||||
func (s *Server) spaceScan(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.spaceTarget(w, r, r.PathValue("disk"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
st, err := s.space.Start(t.ID, t.Roots, t.datasets)
|
||||
if err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("space: scan of %s (%s) started by %s", t.ID, strings.Join(t.Roots, " "), clientIP(r))
|
||||
jsonOK(w, 202, st)
|
||||
}
|
||||
|
||||
func (s *Server) spaceForget(w http.ResponseWriter, r *http.Request) {
|
||||
s.space.Forget(r.PathValue("disk"))
|
||||
log.Printf("space: scan of %s forgotten by %s", r.PathValue("disk"), clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) spaceCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.space.Cancel(r.PathValue("disk")) {
|
||||
jsonErr(w, 404, "no scan running")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) spaceTree(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
path := q.Get("path")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
depth, _ := strconv.Atoi(q.Get("depth"))
|
||||
if depth < 1 || depth > 32 {
|
||||
depth = 1
|
||||
}
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
if limit < 1 || limit > 2000 {
|
||||
limit = 300
|
||||
}
|
||||
min, _ := strconv.ParseInt(q.Get("min"), 10, 64)
|
||||
if min < 0 {
|
||||
min = 0
|
||||
}
|
||||
t, err := s.space.Tree(r.PathValue("disk"), path, depth, limit, min)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
jsonOK(w, 200, t)
|
||||
}
|
||||
|
||||
func (s *Server) spaceLargest(w http.ResponseWriter, r *http.Request) {
|
||||
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
|
||||
if n < 1 || n > 5000 {
|
||||
n = 500
|
||||
}
|
||||
files, err := s.space.Largest(r.PathValue("disk"), n)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
if files == nil {
|
||||
files = []space.File{}
|
||||
}
|
||||
jsonOK(w, 200, files)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/sensors"
|
||||
"hamlab/internal/storage"
|
||||
)
|
||||
|
||||
// statusSensors is the sensors.Provider for what isn't a /proc number: failed services,
|
||||
// pending updates, pool health, drive health. Each source costs an exec or several, so
|
||||
// they are read in the background on their own clocks and served from memory; every
|
||||
// sensor's t says how old its reading really is.
|
||||
type statusSensors struct {
|
||||
s *Server
|
||||
mu sync.RWMutex
|
||||
by map[string][]sensors.Sensor // source → its sensors
|
||||
|
||||
cpuPrev map[string]cpuMark // container ID → CPU time at the previous read
|
||||
|
||||
hist map[string]*sensors.Series // numeric sensors only, statusHistory points each
|
||||
acc map[string]*bucket // the point being built for each, when the window is wider than the read rate
|
||||
}
|
||||
|
||||
// statusHistory is how many readings each status sensor keeps: at 15 s that is over an
|
||||
// hour of container memory; at 10 min, two days of drive wear.
|
||||
const statusHistory = 300
|
||||
|
||||
// A point being averaged, the same idea as the sampler's: these sources are read every 15 to
|
||||
// 300 seconds, so a wide window keeps one point per stride rather than one per read.
|
||||
type bucket struct {
|
||||
last int64 // when this sensor's last point was kept
|
||||
sum float64
|
||||
n float64
|
||||
}
|
||||
|
||||
const (
|
||||
servicesEvery = 30 * time.Second
|
||||
containersEvery = 15 * time.Second
|
||||
updatesEvery = 5 * time.Minute
|
||||
zfsEvery = time.Minute
|
||||
smartEvery = 10 * time.Minute // storage.Manager caches SMART that long anyway, and never wakes a sleeping disk
|
||||
)
|
||||
|
||||
func newStatusSensors(s *Server) *statusSensors {
|
||||
p := &statusSensors{s: s, by: map[string][]sensors.Sensor{}, hist: map[string]*sensors.Series{}, acc: map[string]*bucket{}}
|
||||
go p.loop("services", servicesEvery, p.services)
|
||||
go p.loop("containers", containersEvery, p.containers)
|
||||
go p.loop("updates", updatesEvery, p.updates)
|
||||
go p.loop("zfs", zfsEvery, p.zfs)
|
||||
go p.loop("smart", smartEvery, p.smart)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *statusSensors) loop(name string, every time.Duration, read func(context.Context, int64) []sensors.Sensor) {
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
got := read(ctx, time.Now().UnixMilli())
|
||||
cancel()
|
||||
p.mu.Lock()
|
||||
p.by[name] = got
|
||||
window := p.s.metrics.Window()
|
||||
stride := window.Milliseconds() / statusHistory
|
||||
live := map[string]bool{}
|
||||
for _, x := range got {
|
||||
v, ok := x.Value.(float64)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
live[x.ID] = true
|
||||
h := p.hist[x.ID]
|
||||
if h == nil {
|
||||
h = &sensors.Series{ID: x.ID, Unit: x.Unit}
|
||||
p.hist[x.ID] = h
|
||||
}
|
||||
b := p.acc[x.ID]
|
||||
if b == nil {
|
||||
b = &bucket{}
|
||||
p.acc[x.ID] = b
|
||||
}
|
||||
b.sum, b.n = b.sum+v, b.n+1
|
||||
// due a stride after the last point kept, with half a read of slack for a late timer
|
||||
if b.last != 0 && x.T-b.last < stride-every.Milliseconds()/2 {
|
||||
continue // still filling this point
|
||||
}
|
||||
h.T, h.V = append(h.T, x.T), append(h.V, b.sum/b.n)
|
||||
*b = bucket{last: x.T}
|
||||
if len(h.T) > statusHistory {
|
||||
h.T, h.V = h.T[len(h.T)-statusHistory:], h.V[len(h.V)-statusHistory:]
|
||||
}
|
||||
if cut := x.T - window.Milliseconds(); h.T[0] < cut { // and nothing older than the window
|
||||
i := 0
|
||||
for i < len(h.T) && h.T[i] < cut {
|
||||
i++
|
||||
}
|
||||
h.T, h.V = h.T[i:], h.V[i:]
|
||||
}
|
||||
}
|
||||
for id := range p.hist { // a container that is gone takes its history with it
|
||||
if strings.HasPrefix(id, prefixOf[name]) && !live[id] && prefixOf[name] != "" {
|
||||
delete(p.hist, id)
|
||||
delete(p.acc, id)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if len(got) == 0 { // no pools, no Docker, no SMART: look again, but rarely
|
||||
time.Sleep(10 * every)
|
||||
continue
|
||||
}
|
||||
time.Sleep(every)
|
||||
}
|
||||
}
|
||||
|
||||
// prefixOf says which IDs a source owns, so its loop forgets only its own departed sensors.
|
||||
var prefixOf = map[string]string{"containers": "container.", "smart": "smart.", "zfs": "zfs."}
|
||||
|
||||
// SensorHistories implements sensors.Historian.
|
||||
func (p *statusSensors) SensorHistories(match func(id string) bool) []sensors.Series {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
var out []sensors.Series
|
||||
for id, h := range p.hist {
|
||||
if match(id) {
|
||||
out = append(out, sensors.Series{ID: h.ID, Unit: h.Unit, T: append([]int64{}, h.T...), V: append([]float64{}, h.V...)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *statusSensors) Sensors() []sensors.Sensor {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
var out []sensors.Sensor
|
||||
for _, v := range p.by {
|
||||
out = append(out, v...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *statusSensors) services(ctx context.Context, t int64) []sensors.Sensor {
|
||||
units, err := p.s.svc.List(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var failed, running float64
|
||||
var names []string
|
||||
var per []sensors.Sensor
|
||||
for _, u := range units {
|
||||
if u.Failed {
|
||||
failed++
|
||||
names = append(names, u.Name)
|
||||
}
|
||||
if u.Sub == "running" {
|
||||
running++
|
||||
}
|
||||
// One pair per service that is meant to be doing something: enabled, up, or broken.
|
||||
// (Every static and one-shot unit on the box would triple the catalog for nothing.)
|
||||
short, isService := strings.CutSuffix(u.Name, ".service")
|
||||
if !isService || !(u.Enabled == "enabled" || u.Active == "active" || u.Failed) {
|
||||
continue
|
||||
}
|
||||
id, l := "service."+sensors.Seg(short), map[string]string{"unit": u.Name, "description": u.Description}
|
||||
per = append(per,
|
||||
sensors.Sensor{ID: id + ".active", Name: short, Kind: "state", Value: u.Active == "active", T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".state", Name: short + " state", Kind: "state", Value: u.Sub, T: t, Labels: l})
|
||||
}
|
||||
return append(per, []sensors.Sensor{
|
||||
{ID: "services.failed", Name: "Failed services", Kind: "count", Value: failed, T: t, Warn: sensors.F(1)},
|
||||
{ID: "services.failed_names", Name: "Failed services", Kind: "state", Value: strings.Join(names, ", "), T: t},
|
||||
{ID: "services.running", Name: "Running services", Kind: "count", Value: running, T: t},
|
||||
}...)
|
||||
}
|
||||
|
||||
// containers reads Docker (or Podman) with one `ps`, then each running container's memory
|
||||
// and CPU straight from its cgroup — `docker stats` takes seconds and a daemon round-trip
|
||||
// per call; two small files per container take microseconds.
|
||||
func (p *statusSensors) containers(ctx context.Context, t int64) []sensors.Sensor {
|
||||
var out []byte
|
||||
var err error
|
||||
tool := ""
|
||||
for _, t := range []string{"docker", "podman"} {
|
||||
if _, e := exec.LookPath(t); e != nil {
|
||||
continue
|
||||
}
|
||||
tool = t
|
||||
args := []string{tool, "ps", "-a", "--no-trunc", "--format", "{{json .}}"}
|
||||
if out, err = exec.CommandContext(ctx, args[0], args[1:]...).Output(); err != nil && priv.Available() {
|
||||
full := priv.Wrap(args...) // not in the docker group: ask as root
|
||||
out, err = exec.CommandContext(ctx, full[0], full[1:]...).Output()
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil || len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
// "Not running" is not "down": servarr stopped by hand in June is off, not broken. What is
|
||||
// broken is a container that should be up and isn't — one inspect of the stopped ones tells
|
||||
// them apart: Docker refused to start it (State.Error: yesterday's Gitea), it is stuck
|
||||
// restarting, or it died with a non-zero code that no restart policy picked up. Under
|
||||
// `always` / `unless-stopped` a crash is restarted by Docker, so "exited" there means a
|
||||
// person stopped it. With no restart policy, `docker stop` leaves 143 (the process took the
|
||||
// SIGTERM), 137 (it didn't, and Docker killed it after the grace period) or 130 (SIGINT):
|
||||
// also a person, unless the kernel did the killing for want of memory.
|
||||
type stopped struct {
|
||||
Name string
|
||||
State struct {
|
||||
Status string
|
||||
ExitCode int
|
||||
Error string
|
||||
OOMKilled bool
|
||||
}
|
||||
HostConfig struct{ RestartPolicy struct{ Name string } }
|
||||
}
|
||||
down := map[string]bool{}
|
||||
var ids []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
var c struct{ ID, State string }
|
||||
if json.Unmarshal([]byte(line), &c) == nil && c.ID != "" && c.State != "running" {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
args := append([]string{"inspect"}, ids...)
|
||||
insp, ierr := exec.CommandContext(ctx, tool, args...).Output()
|
||||
if ierr != nil && priv.Available() {
|
||||
full := priv.Wrap(append([]string{tool}, args...)...)
|
||||
insp, _ = exec.CommandContext(ctx, full[0], full[1:]...).Output()
|
||||
}
|
||||
var list []stopped
|
||||
if json.Unmarshal(insp, &list) == nil {
|
||||
for _, c := range list {
|
||||
pol := c.HostConfig.RestartPolicy.Name
|
||||
managed := pol == "always" || pol == "unless-stopped"
|
||||
code := c.State.ExitCode
|
||||
byHand := (code == 130 || code == 137 || code == 143) && !c.State.OOMKilled
|
||||
down[strings.TrimPrefix(c.Name, "/")] = c.State.Status == "restarting" || c.State.Status == "dead" || c.State.Error != "" ||
|
||||
(!managed && c.State.Status == "exited" && code != 0 && !byHand)
|
||||
}
|
||||
}
|
||||
}
|
||||
var res []sensors.Sensor
|
||||
var running, unhealthy, total float64
|
||||
seenCPU := map[string]cpuMark{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
var c struct{ ID, Names, Image, State, Status string }
|
||||
if json.Unmarshal([]byte(line), &c) != nil || c.Names == "" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
name := strings.Split(c.Names, ",")[0]
|
||||
id, l := "container."+sensors.Seg(name), map[string]string{"container": name, "image": c.Image}
|
||||
up := c.State == "running"
|
||||
health := "none"
|
||||
for _, h := range []string{"unhealthy", "healthy", "starting"} { // "unhealthy" first: it contains "healthy"
|
||||
if strings.Contains(c.Status, "("+h+")") || strings.Contains(c.Status, "(health: "+h+")") {
|
||||
health = h
|
||||
break
|
||||
}
|
||||
}
|
||||
if up {
|
||||
running++
|
||||
}
|
||||
if health == "unhealthy" {
|
||||
unhealthy++
|
||||
}
|
||||
res = append(res,
|
||||
sensors.Sensor{ID: id + ".running", Name: name, Kind: "state", Value: up, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".down", Name: name + " down", Kind: "state", Value: down[name], T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".state", Name: name + " state", Kind: "state", Value: c.State, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".health", Name: name + " health", Kind: "state", Value: health, T: t, Labels: l})
|
||||
if !up {
|
||||
continue
|
||||
}
|
||||
cg := ""
|
||||
for _, dir := range []string{"/sys/fs/cgroup/system.slice/docker-" + c.ID + ".scope", "/sys/fs/cgroup/docker/" + c.ID, "/sys/fs/cgroup/machine.slice/libpod-" + c.ID + ".scope"} {
|
||||
if _, e := os.Stat(dir + "/memory.current"); e == nil {
|
||||
cg = dir
|
||||
break
|
||||
}
|
||||
}
|
||||
if cg == "" {
|
||||
continue
|
||||
}
|
||||
if b, e := os.ReadFile(cg + "/memory.current"); e == nil {
|
||||
if v, e := strconv.ParseFloat(strings.TrimSpace(string(b)), 64); e == nil {
|
||||
res = append(res, sensors.Sensor{ID: id + ".mem", Name: name + " memory", Kind: "bytes", Unit: "B", Value: v, T: t, Labels: l})
|
||||
}
|
||||
}
|
||||
if b, e := os.ReadFile(cg + "/cpu.stat"); e == nil {
|
||||
for _, ln := range strings.Split(string(b), "\n") {
|
||||
if f := strings.Fields(ln); len(f) == 2 && f[0] == "usage_usec" {
|
||||
usec, _ := strconv.ParseFloat(f[1], 64)
|
||||
seenCPU[c.ID] = cpuMark{usec, t}
|
||||
if prev, ok := p.cpuPrev[c.ID]; ok && t > prev.t && usec >= prev.usec {
|
||||
// percent of one core, like the Processes tab
|
||||
pctv := (usec - prev.usec) / 1000 / float64(t-prev.t) * 100
|
||||
res = append(res, sensors.Sensor{ID: id + ".cpu", Name: name + " CPU", Kind: "usage", Unit: "%", Value: float64(int(pctv*10+0.5)) / 10, T: t, Min: sensors.F(0), Labels: l})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p.cpuPrev = seenCPU // only this loop touches it
|
||||
return append(res,
|
||||
sensors.Sensor{ID: "containers.total", Name: "Containers", Kind: "count", Value: total, T: t},
|
||||
sensors.Sensor{ID: "containers.running", Name: "Running containers", Kind: "count", Value: running, T: t},
|
||||
sensors.Sensor{ID: "containers.unhealthy", Name: "Unhealthy containers", Kind: "count", Value: unhealthy, T: t, Warn: sensors.F(1)})
|
||||
}
|
||||
|
||||
type cpuMark struct {
|
||||
usec float64
|
||||
t int64
|
||||
}
|
||||
|
||||
func (p *statusSensors) updates(ctx context.Context, t int64) []sensors.Sensor {
|
||||
st := p.s.updates.Status(ctx)
|
||||
if st.Manager == "" {
|
||||
return nil
|
||||
}
|
||||
return []sensors.Sensor{
|
||||
{ID: "updates.pending", Name: "Pending updates", Kind: "count", Value: float64(len(st.Packages)), T: t, Labels: map[string]string{"manager": st.Manager}},
|
||||
{ID: "updates.security", Name: "Pending security updates", Kind: "count", Value: float64(st.SecurityCount), T: t, Warn: sensors.F(1)},
|
||||
{ID: "updates.reboot_required", Name: "Reboot required", Kind: "state", Value: st.RebootNeeded, T: t},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *statusSensors) zfs(ctx context.Context, t int64) []sensors.Sensor {
|
||||
var out []sensors.Sensor
|
||||
for _, pool := range p.s.storage.Pools(ctx) {
|
||||
id, l := "zfs."+sensors.Seg(pool.Name), map[string]string{"pool": pool.Name}
|
||||
out = append(out,
|
||||
sensors.Sensor{ID: id + ".health", Name: pool.Name + " health", Kind: "state", Value: pool.Health, T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".healthy", Name: pool.Name + " healthy", Kind: "state", Value: pool.Health == "ONLINE", T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".size", Name: pool.Name + " size", Kind: "bytes", Unit: "B", Value: float64(pool.Size), T: t, Labels: l},
|
||||
sensors.Sensor{ID: id + ".used", Name: pool.Name + " used", Kind: "bytes", Unit: "B", Value: float64(pool.Alloc), T: t, Min: sensors.F(0), Max: sensors.F(float64(pool.Size)), Labels: l},
|
||||
sensors.Sensor{ID: id + ".used_pct", Name: pool.Name + " used", Kind: "usage", Unit: "%", Value: float64(pool.Cap), T: t, Min: sensors.F(0), Max: sensors.F(100), Labels: l},
|
||||
sensors.Sensor{ID: id + ".frag", Name: pool.Name + " fragmentation", Kind: "fragmentation", Unit: "%", Value: float64(pool.Frag), T: t, Min: sensors.F(0), Max: sensors.F(100), Labels: l},
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *statusSensors) smart(ctx context.Context, t int64) []sensors.Sensor {
|
||||
devs, err := storage.Devices(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []sensors.Sensor
|
||||
for _, d := range devs {
|
||||
sm := p.s.storage.Smart(ctx, d.Name, false)
|
||||
if sm.Passed == nil || sm.Standby || sm.Error != "" {
|
||||
continue // no verdict to report: no smartctl, asleep, or not a SMART device
|
||||
}
|
||||
id, l := "smart."+sensors.Seg(d.Name), map[string]string{"device": d.Name, "model": d.Model}
|
||||
out = append(out,
|
||||
sensors.Sensor{ID: id + ".passed", Name: d.Name + " health", Kind: "state", Value: *sm.Passed, T: sm.FetchedAt, Labels: l},
|
||||
sensors.Sensor{ID: id + ".warnings", Name: d.Name + " warnings", Kind: "count", Value: float64(len(sm.Warnings)), T: sm.FetchedAt, Warn: sensors.F(1), Labels: l},
|
||||
)
|
||||
if sm.PowerOnHours > 0 {
|
||||
out = append(out, sensors.Sensor{ID: id + ".power_on", Name: d.Name + " powered on", Kind: "duration", Unit: "s", Value: float64(sm.PowerOnHours) * 3600, T: sm.FetchedAt, Labels: l})
|
||||
}
|
||||
if sm.NVMe != nil {
|
||||
out = append(out, sensors.Sensor{ID: id + ".wear", Name: d.Name + " endurance used", Kind: "usage", Unit: "%", Value: float64(sm.NVMe.PercentageUsed), T: sm.FetchedAt, Min: sensors.F(0), Max: sensors.F(100), Labels: l})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStreamGate(t *testing.T) {
|
||||
var g streamGate
|
||||
for i := 0; i < maxStreamsPerClient; i++ {
|
||||
if !g.enter("a") {
|
||||
t.Fatalf("stream %d for one client refused early", i)
|
||||
}
|
||||
}
|
||||
if g.enter("a") {
|
||||
t.Fatal("one client went past its ceiling")
|
||||
}
|
||||
if !g.enter("b") {
|
||||
t.Fatal("another client must still get in")
|
||||
}
|
||||
g.leave("a")
|
||||
if !g.enter("a") {
|
||||
t.Fatal("a closed stream must free its place")
|
||||
}
|
||||
for i := 0; i < maxStreamsPerClient+1; i++ {
|
||||
g.leave("a")
|
||||
}
|
||||
g.leave("b")
|
||||
if g.total != -1 && g.total != 0 { // leave() past zero must not wedge the gate open or shut
|
||||
t.Logf("total after over-leave: %d", g.total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
// Updating Ham Lab itself: is there a newer build published, and install it on request.
|
||||
// The check is a plain HTTP GET against whatever `update.url` names — this project's Gitea
|
||||
// releases by default — so a fleet, a stranger's install and an air-gapped copy pointed at
|
||||
// a directory on a NAS all work the same way. Nothing is ever installed unasked: an update
|
||||
// restarts the daemon and drops open terminals, which is not a thing to do to somebody.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/selfupdate"
|
||||
)
|
||||
|
||||
type updateState struct {
|
||||
mu sync.Mutex
|
||||
checking bool
|
||||
installing bool
|
||||
checkedAt time.Time
|
||||
latest selfupdate.Release
|
||||
err string
|
||||
}
|
||||
|
||||
// source is the update source as configured, and whether looking is switched off.
|
||||
func (s *Server) source() (selfupdate.Source, bool) {
|
||||
var u config.UpdateSource
|
||||
s.cfg.View(func(c *config.Config) { u = c.Updates })
|
||||
url := strings.TrimSpace(u.URL)
|
||||
if url == "" {
|
||||
url = selfupdate.DefaultURL
|
||||
}
|
||||
if strings.EqualFold(url, "off") || strings.EqualFold(url, "none") {
|
||||
return selfupdate.Source{}, false
|
||||
}
|
||||
return selfupdate.Source{URL: url, Token: u.Token}, true
|
||||
}
|
||||
|
||||
func (s *Server) checksEnabled() bool {
|
||||
var u config.UpdateSource
|
||||
s.cfg.View(func(c *config.Config) { u = c.Updates })
|
||||
if strings.EqualFold(u.Check, "never") {
|
||||
return false
|
||||
}
|
||||
_, on := s.source()
|
||||
return on
|
||||
}
|
||||
|
||||
// Behind reports whether a newer build than this one has been seen, and which.
|
||||
func (s *Server) behind() (string, bool) {
|
||||
s.update.mu.Lock()
|
||||
defer s.update.mu.Unlock()
|
||||
v := s.update.latest.Version
|
||||
if v == "" || selfupdate.Compare(s.version, v) >= 0 {
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// checkNow asks the source what the newest build is. One at a time.
|
||||
func (s *Server) checkNow(ctx context.Context) {
|
||||
src, on := s.source()
|
||||
if !on {
|
||||
return
|
||||
}
|
||||
s.update.mu.Lock()
|
||||
if s.update.checking {
|
||||
s.update.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.update.checking = true
|
||||
s.update.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
rel, err := src.Latest(ctx, runtime.GOARCH)
|
||||
s.update.mu.Lock()
|
||||
s.update.checking = false
|
||||
s.update.checkedAt = time.Now()
|
||||
if err != nil {
|
||||
s.update.err = err.Error()
|
||||
} else {
|
||||
s.update.err, s.update.latest = "", rel
|
||||
}
|
||||
s.update.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Printf("update check: %v", err)
|
||||
return
|
||||
}
|
||||
if selfupdate.Compare(s.version, rel.Version) < 0 {
|
||||
log.Printf("update check: %s is published; this is %s", rel.Version, s.version)
|
||||
}
|
||||
}
|
||||
|
||||
// watchForUpdates asks once shortly after boot and then daily.
|
||||
func (s *Server) watchForUpdates(ctx context.Context) {
|
||||
t := time.NewTimer(90 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
if s.checksEnabled() {
|
||||
s.checkNow(ctx)
|
||||
}
|
||||
t.Reset(selfupdate.DefaultCheckEvery)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) updateStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
src, on := s.source()
|
||||
s.update.mu.Lock()
|
||||
defer s.update.mu.Unlock()
|
||||
latest := s.update.latest.Version
|
||||
out := map[string]any{
|
||||
"current": s.version, "latest": latest, "arch": runtime.GOARCH,
|
||||
"behind": latest != "" && selfupdate.Compare(s.version, latest) < 0,
|
||||
"checking": s.update.checking,
|
||||
"installing": s.update.installing,
|
||||
"enabled": on, "auto": s.checksEnabled(), "url": src.URL,
|
||||
"notes": s.update.latest.Notes, "published": s.update.latest.Published,
|
||||
}
|
||||
if !s.update.checkedAt.IsZero() {
|
||||
out["checked"] = s.update.checkedAt
|
||||
}
|
||||
if s.update.err != "" {
|
||||
out["error"] = s.update.err
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) updateCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if _, on := s.source(); !on {
|
||||
jsonErr(w, 400, "update checks are switched off (update.url is \"off\")")
|
||||
return
|
||||
}
|
||||
s.checkNow(r.Context())
|
||||
s.updateStatus(w, r)
|
||||
}
|
||||
|
||||
// updateInstall fetches the published build for this machine and puts it in place.
|
||||
func (s *Server) updateInstall(w http.ResponseWriter, r *http.Request) {
|
||||
src, on := s.source()
|
||||
if !on {
|
||||
jsonErr(w, 400, "update checks are switched off (update.url is \"off\")")
|
||||
return
|
||||
}
|
||||
force := r.URL.Query().Get("force") == "1"
|
||||
if err := s.updateAllowed(force); err != nil {
|
||||
jsonErr(w, 409, err.Error())
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Version string `json:"version"` // optional: refuse if what is published is not this
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||
|
||||
s.update.mu.Lock()
|
||||
if s.update.installing {
|
||||
s.update.mu.Unlock()
|
||||
jsonErr(w, 409, "an update is already being installed")
|
||||
return
|
||||
}
|
||||
rel := s.update.latest
|
||||
s.update.installing = true
|
||||
s.update.mu.Unlock()
|
||||
done := func() { s.update.mu.Lock(); s.update.installing = false; s.update.mu.Unlock() }
|
||||
|
||||
ctx := context.WithoutCancel(r.Context()) // a closed browser tab must not abort an install
|
||||
if rel.Version == "" || body.Version != "" && body.Version != rel.Version {
|
||||
var err error
|
||||
if rel, err = src.Latest(ctx, runtime.GOARCH); err != nil {
|
||||
done()
|
||||
jsonErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
s.update.mu.Lock()
|
||||
s.update.latest, s.update.checkedAt, s.update.err = rel, time.Now(), ""
|
||||
s.update.mu.Unlock()
|
||||
}
|
||||
if body.Version != "" && body.Version != rel.Version {
|
||||
done()
|
||||
jsonErr(w, 409, "what is published is now "+rel.Version+", not "+body.Version+"; look again")
|
||||
return
|
||||
}
|
||||
if !force && selfupdate.Compare(s.version, rel.Version) >= 0 {
|
||||
done()
|
||||
jsonErr(w, 409, "this machine is already running "+s.version+"; nothing newer is published")
|
||||
return
|
||||
}
|
||||
tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new")
|
||||
if err := src.Fetch(ctx, rel, runtime.GOARCH, tmp); err != nil {
|
||||
done()
|
||||
jsonErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
newVersion, err := s.installBinary(ctx, tmp)
|
||||
if err != nil {
|
||||
done()
|
||||
jsonErr(w, codeOf(err, 500), err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("update: installed %s (was %s), asked for by %s; restarting", newVersion, s.version, clientIP(r))
|
||||
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
|
||||
s.restartAfterUpdate()
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package api
|
||||
|
||||
// Accounts and their sessions. Every person has their own login, and the console's look
|
||||
// (theme, accents, dashboard layout) follows the account rather than the machine. Anyone
|
||||
// signed in may add or remove accounts — a household console, not a tenancy — but removing
|
||||
// one, or resetting another's password, asks for the caller's own password first, so a
|
||||
// picked-up unlocked phone is not enough.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/auth"
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
// actor is who a request is from: a signed-in account (with its session), or a token.
|
||||
type actor struct {
|
||||
User string // account name, "" 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 // token name, "" for a session
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const actorKey ctxKey = 1
|
||||
|
||||
func withActor(r *http.Request, a actor) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), actorKey, a))
|
||||
}
|
||||
|
||||
func actorOf(r *http.Request) actor {
|
||||
a, _ := r.Context().Value(actorKey).(actor)
|
||||
return a
|
||||
}
|
||||
|
||||
// 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 {
|
||||
switch {
|
||||
case a.User != "":
|
||||
return a.User
|
||||
case a.Token != "":
|
||||
return "token:" + a.Token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
|
||||
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
me := actorOf(r).User
|
||||
counts := s.sessions.Count()
|
||||
var out []map[string]any
|
||||
s.cfg.View(func(c *config.Config) {
|
||||
for _, u := range c.Users {
|
||||
var seen any
|
||||
if !u.LastSeen.IsZero() {
|
||||
seen = u.LastSeen
|
||||
}
|
||||
out = append(out, map[string]any{"name": u.Name, "created": u.Created, "last_seen": seen, "sessions": counts[u.Name], "self": u.Name == me})
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) addUser(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name, Password string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if !auth.UserNameRe.MatchString(name) {
|
||||
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < auth.MinPasswordLen {
|
||||
jsonErr(w, 400, "password must be at least 12 characters")
|
||||
return
|
||||
}
|
||||
h, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
clash := false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
if _, ok := c.User(name); ok {
|
||||
clash = true
|
||||
return
|
||||
}
|
||||
c.Users = append(c.Users, config.User{Name: name, Hash: h, Created: time.Now()})
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if clash {
|
||||
jsonErr(w, 409, "a user with that name exists")
|
||||
return
|
||||
}
|
||||
log.Printf("user %q added (by %s)", name, clientIP(r))
|
||||
jsonOK(w, 200, map[string]any{"name": name})
|
||||
}
|
||||
|
||||
// confirmSelf checks the caller's own password, the gate on anything done to another account.
|
||||
func (s *Server) confirmSelf(w http.ResponseWriter, r *http.Request, current string) bool {
|
||||
me := actorOf(r).User
|
||||
if me == "" {
|
||||
jsonErr(w, 403, "only a signed-in user may do that")
|
||||
return false
|
||||
}
|
||||
var users []config.User
|
||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
||||
if !auth.CheckUser(users, me, current) {
|
||||
jsonErr(w, 403, "your password is wrong")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var body struct{ Current string }
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||
if name == actorOf(r).User {
|
||||
jsonErr(w, 400, "you can't remove the account you are signed in as")
|
||||
return
|
||||
}
|
||||
if !s.confirmSelf(w, r, body.Current) {
|
||||
return
|
||||
}
|
||||
found, last := false, false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
if len(c.Users) <= 1 {
|
||||
last = true
|
||||
return
|
||||
}
|
||||
kept := c.Users[:0]
|
||||
for _, u := range c.Users {
|
||||
if u.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, u)
|
||||
}
|
||||
c.Users = kept
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if last {
|
||||
jsonErr(w, 400, "the last account can't be removed")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
jsonErr(w, 404, "no such user")
|
||||
return
|
||||
}
|
||||
s.sessions.RevokeUser(name)
|
||||
_ = s.store.Update(func(st *store.State) { delete(st.Users, name) })
|
||||
log.Printf("user %q removed (by %s)", name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// resetPassword sets another user's password, on the caller's own password.
|
||||
func (s *Server) resetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var body struct{ Current, Password string }
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if !s.confirmSelf(w, r, body.Current) {
|
||||
return
|
||||
}
|
||||
if len(body.Password) < auth.MinPasswordLen {
|
||||
jsonErr(w, 400, "password must be at least 12 characters")
|
||||
return
|
||||
}
|
||||
if !s.setPassword(w, name, body.Password) {
|
||||
return
|
||||
}
|
||||
if name != actorOf(r).User { // whoever held the old password is signed out
|
||||
s.sessions.RevokeUser(name)
|
||||
}
|
||||
log.Printf("password of %q reset (by %s)", name, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) setPassword(w http.ResponseWriter, name, pw string) bool {
|
||||
h, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
if err := s.cfg.Update(func(c *config.Config) {
|
||||
for i := range c.Users {
|
||||
if c.Users[i].Name == name {
|
||||
c.Users[i].Hash = h
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return false
|
||||
}
|
||||
if !found {
|
||||
jsonErr(w, 404, "no such user")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- sessions: where the caller's account is signed in ----
|
||||
|
||||
func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
if a.User == "" {
|
||||
jsonOK(w, 200, []any{})
|
||||
return
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, ses := range s.sessions.List(a.User) {
|
||||
out = append(out, map[string]any{"id": ses.ID, "created": ses.Created, "last_seen": ses.LastSeen, "expires": ses.Expires, "ip": ses.IP, "agent": ses.Agent, "current": ses.ID == a.Session})
|
||||
}
|
||||
if out == nil {
|
||||
out = []map[string]any{}
|
||||
}
|
||||
jsonOK(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
id := r.PathValue("id")
|
||||
if a.User == "" || !s.sessions.Revoke(a.User, id) {
|
||||
jsonErr(w, 404, "no such session")
|
||||
return
|
||||
}
|
||||
log.Printf("session %s of %q revoked (by %s)", id, a.User, clientIP(r))
|
||||
if id == a.Session {
|
||||
s.setCookie(w, r, "", -1)
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) revokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a := actorOf(r)
|
||||
if a.User == "" {
|
||||
jsonErr(w, 403, "only a signed-in user may do that")
|
||||
return
|
||||
}
|
||||
n := s.sessions.RevokeOthers(a.User, a.token)
|
||||
log.Printf("%d other session(s) of %q revoked (by %s)", n, a.User, clientIP(r))
|
||||
jsonOK(w, 200, map[string]int{"revoked": n})
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package api
|
||||
|
||||
// The ZFS tab: pools, datasets, snapshots, and a schedule for taking and expiring them.
|
||||
//
|
||||
// grid already runs zfSnap from root cron and has done for longer than this repo has
|
||||
// existed, so this does not replace it. It reads the same snapshot names, expires by the
|
||||
// same rule, and surfaces the cron job that drives it — so what is on screen is the truth
|
||||
// about the machine, whether Ham Lab or zfSnap is doing the work. A Ham Lab schedule is for
|
||||
// a machine that has no such tool, and the page says plainly when both are running.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/storage"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
type zfsState struct {
|
||||
Policies []storage.Policy `json:"policies"`
|
||||
}
|
||||
|
||||
func (s *Server) zfsLoad() zfsState {
|
||||
var raw json.RawMessage
|
||||
s.store.View(func(st *store.State) { raw = st.ZFS })
|
||||
var z zfsState
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &z)
|
||||
}
|
||||
if z.Policies == nil {
|
||||
z.Policies = []storage.Policy{}
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
func (s *Server) zfsSave(z zfsState) error {
|
||||
b, err := json.Marshal(z)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.Update(func(st *store.State) { st.ZFS = b })
|
||||
}
|
||||
|
||||
// externalSnapshotJob finds a cron job that is already taking snapshots on this machine, so
|
||||
// the page can say so rather than let two schedules quietly double up.
|
||||
func (s *Server) externalSnapshotJob(ctx context.Context) map[string]any {
|
||||
if s.cron == nil {
|
||||
return nil
|
||||
}
|
||||
ov := s.cron.Overview(ctx)
|
||||
for _, j := range ov.Jobs {
|
||||
if !j.Enabled {
|
||||
continue
|
||||
}
|
||||
c := strings.ToLower(j.Command)
|
||||
if !strings.Contains(c, "zfsnap") && !strings.Contains(c, "zfs-auto-snapshot") && !strings.Contains(c, "sanoid") &&
|
||||
!(strings.Contains(c, "zfs ") && strings.Contains(c, "snapshot")) {
|
||||
continue
|
||||
}
|
||||
return map[string]any{
|
||||
"tool": toolName(c), "schedule": j.Schedule, "command": j.Command,
|
||||
"user": j.User, "source": j.Source, "id": j.ID, "next": j.Next, "last": j.Last,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toolName(cmd string) string {
|
||||
switch {
|
||||
case strings.Contains(cmd, "zfsnap"):
|
||||
return "zfSnap"
|
||||
case strings.Contains(cmd, "zfs-auto-snapshot"):
|
||||
return "zfs-auto-snapshot"
|
||||
case strings.Contains(cmd, "sanoid"):
|
||||
return "sanoid"
|
||||
}
|
||||
return "a cron job"
|
||||
}
|
||||
|
||||
// randomID is a short opaque id for a schedule.
|
||||
func randomID() (string, error) {
|
||||
b := make([]byte, 6)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// ---- the cron file ----
|
||||
//
|
||||
// A schedule is a cron line, written to /etc/cron.d/hamlab-zfs, which Ham Lab owns and
|
||||
// rewrites whole whenever the schedules change. Nothing in the daemon runs them: snapshots
|
||||
// are backup-shaped and should not stop because a web console is down, being upgraded, or
|
||||
// uninstalled. The line calls `hamlab zfs-snapshot <id>` as the account Ham Lab runs as,
|
||||
// which reaches zfs through sudo exactly as the daemon does.
|
||||
|
||||
// syncCron puts the current schedules on the machine.
|
||||
func (s *Server) syncCron(ctx context.Context, z zfsState) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exe, _ = filepath.EvalSymlinks(exe)
|
||||
return s.storage.WriteCronFile(ctx, z.Policies, s.user, exe, s.cfg.Path())
|
||||
}
|
||||
|
||||
// ---- endpoints ----
|
||||
|
||||
func (s *Server) zfsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
z := s.zfsLoad()
|
||||
now := time.Now()
|
||||
dir := s.cfg.DataDir()
|
||||
out := make([]map[string]any, 0, len(z.Policies))
|
||||
for i := range z.Policies {
|
||||
p := z.Policies[i]
|
||||
expr, _ := p.CronExpr()
|
||||
m := map[string]any{"policy": p, "cron": expr, "last": storage.ReadRun(dir, p.ID)}
|
||||
if p.Enabled {
|
||||
if n := p.NextRun(now); !n.IsZero() {
|
||||
m["next"] = n
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{
|
||||
"zfs": s.storage.ZFS(r.Context()), "policies": out, "external": s.externalSnapshotJob(r.Context()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) zfsPolicyPut(w http.ResponseWriter, r *http.Request) {
|
||||
var p storage.Policy
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&p); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
z := s.zfsLoad()
|
||||
found := false
|
||||
for i := range z.Policies {
|
||||
if z.Policies[i].ID == p.ID && p.ID != "" {
|
||||
z.Policies[i] = p
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if p.ID == "" {
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
p.ID = id
|
||||
}
|
||||
z.Policies = append(z.Policies, p)
|
||||
}
|
||||
if err := s.zfsSave(z); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.syncCron(r.Context(), z); err != nil {
|
||||
jsonErr(w, 500, "saved, but writing "+storage.CronFile+" failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
expr, _ := p.CronExpr()
|
||||
log.Printf("zfs schedule %q saved by %s (%s, %q, keep %s)", p.Name, clientIP(r), p.Dataset, expr, p.KeepFor)
|
||||
jsonOK(w, 200, p)
|
||||
}
|
||||
|
||||
func (s *Server) zfsPolicyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
z := s.zfsLoad()
|
||||
kept := z.Policies[:0]
|
||||
found := false
|
||||
for _, p := range z.Policies {
|
||||
if p.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if !found {
|
||||
jsonErr(w, 404, "no such schedule")
|
||||
return
|
||||
}
|
||||
z.Policies = kept
|
||||
if err := s.zfsSave(z); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.syncCron(r.Context(), z); err != nil {
|
||||
jsonErr(w, 500, "removed, but writing "+storage.CronFile+" failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
storage.ForgetRun(s.cfg.DataDir(), id)
|
||||
log.Printf("zfs schedule %s removed by %s (its snapshots are left alone)", id, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (s *Server) zfsPolicy(id string) (storage.Policy, bool) {
|
||||
for _, p := range s.zfsLoad().Policies {
|
||||
if p.ID == id {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return storage.Policy{}, false
|
||||
}
|
||||
|
||||
// zfsPreview is what this schedule would destroy if it ran now. Nothing is deleted.
|
||||
func (s *Server) zfsPreview(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := s.zfsPolicy(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such schedule")
|
||||
return
|
||||
}
|
||||
doomed, err := s.storage.Doomed(r.Context(), &p)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if doomed == nil {
|
||||
doomed = []storage.Snapshot{}
|
||||
}
|
||||
jsonOK(w, 200, map[string]any{"expired": doomed})
|
||||
}
|
||||
|
||||
func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, ok := s.zfsPolicy(id)
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such schedule")
|
||||
return
|
||||
}
|
||||
res, err := s.storage.Run(r.Context(), &p)
|
||||
rec := storage.RunRecord{At: time.Now(), Taken: res.Taken, Pruned: res.Pruned}
|
||||
if err != nil {
|
||||
rec.Error = err.Error()
|
||||
}
|
||||
_ = storage.WriteRun(s.cfg.DataDir(), id, rec)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("zfs schedule %q run by %s: took %s, expired %d", p.Name, clientIP(r), res.Taken, len(res.Pruned))
|
||||
jsonOK(w, 200, res)
|
||||
}
|
||||
|
||||
// zfsSnapshotDelete removes one snapshot, named in full and typed back by the caller.
|
||||
func (s *Server) zfsSnapshotDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Confirm string `json:"confirm"`
|
||||
Recursive bool `json:"recursive"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if body.Confirm != body.Name {
|
||||
jsonErr(w, 400, "type the snapshot's full name to confirm")
|
||||
return
|
||||
}
|
||||
if err := s.storage.DestroySnapshot(r.Context(), body.Name, body.Recursive); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("zfs snapshot %s destroyed by %s%s", body.Name, clientIP(r), map[bool]string{true: " (recursive)", false: ""}[body.Recursive])
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
@@ -3,17 +3,21 @@ package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
@@ -59,6 +63,70 @@ func VerifyPassword(hash, pw string) bool {
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// ---- API tokens (hub → node) ----
|
||||
|
||||
// NewToken returns a random bearer token and its SHA-256 hex, which is what gets stored.
|
||||
func NewToken() (token, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(b)
|
||||
return token, HashToken(token), nil
|
||||
}
|
||||
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// TokenMatches compares a presented token against stored hashes in constant time.
|
||||
func TokenMatches(token string, hashes []string) bool { return TokenIndex(token, hashes) >= 0 }
|
||||
|
||||
// TokenIndex is which of hashes the presented token matches, -1 for none; every hash is
|
||||
// compared whatever the answer.
|
||||
func TokenIndex(token string, hashes []string) int {
|
||||
h := []byte(HashToken(token))
|
||||
idx := -1
|
||||
for i, want := range hashes {
|
||||
if subtle.ConstantTimeCompare(h, []byte(want)) == 1 {
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
|
||||
// UserNameRe is what an account may be called: like a Unix login, lower-case.
|
||||
var UserNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
|
||||
|
||||
const MinPasswordLen = 12
|
||||
|
||||
// dummyHash is verified against when the named user does not exist, so a login attempt
|
||||
// takes the same time either way and the form cannot be used to list accounts.
|
||||
var dummyHash = func() string {
|
||||
h, err := HashPassword("not-a-real-password")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
// CheckUser verifies a password for one of users. A missing user fails in the same time
|
||||
// as a wrong password.
|
||||
func CheckUser(users []config.User, name, pw string) bool {
|
||||
for _, u := range users {
|
||||
if u.Name == name {
|
||||
return VerifyPassword(u.Hash, pw)
|
||||
}
|
||||
}
|
||||
VerifyPassword(dummyHash, pw)
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
|
||||
// Sessions is a token store backed by the state file. Expiry is sliding.
|
||||
type Sessions struct {
|
||||
st *store.Store
|
||||
@@ -72,89 +140,224 @@ func NewSessions(st *store.Store) *Sessions {
|
||||
|
||||
func pruneExpired(state *store.State) {
|
||||
now := time.Now()
|
||||
for tok, exp := range state.Sessions {
|
||||
if exp.Before(now) {
|
||||
for tok, ses := range state.Sessions {
|
||||
if ses.Expires.Before(now) {
|
||||
delete(state.Sessions, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sessions) Create() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
func randomHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tok := hex.EncodeToString(b)
|
||||
err := s.st.Update(func(state *store.State) {
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// Create signs user in from ip with agent, returning the cookie token.
|
||||
func (s *Sessions) Create(user, ip, agent string) (string, error) {
|
||||
tok, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, err := randomHex(6)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(agent) > 200 {
|
||||
agent = agent[:200]
|
||||
}
|
||||
now := time.Now()
|
||||
err = s.st.Update(func(state *store.State) {
|
||||
pruneExpired(state)
|
||||
state.Sessions[tok] = time.Now().Add(SessionTTL)
|
||||
state.Sessions[tok] = store.Session{ID: id, User: user, Expires: now.Add(SessionTTL), Created: now, LastSeen: now, IP: ip, Agent: agent}
|
||||
})
|
||||
return tok, err
|
||||
}
|
||||
|
||||
// Valid reports whether tok is live, sliding its expiry (persisted at most hourly).
|
||||
func (s *Sessions) Valid(tok string) bool {
|
||||
// Lookup reports whether tok is live and whose it is, sliding its expiry and noting the
|
||||
// visit (persisted at most hourly, so a busy console is not writing the file all day).
|
||||
func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) {
|
||||
if tok == "" {
|
||||
return false
|
||||
return store.Session{}, false
|
||||
}
|
||||
ok := false
|
||||
extend := false
|
||||
var ses store.Session
|
||||
ok, touch := false, false
|
||||
s.st.View(func(state *store.State) {
|
||||
exp, found := state.Sessions[tok]
|
||||
if found && exp.After(time.Now()) {
|
||||
ok = true
|
||||
extend = time.Until(exp) < SessionTTL-time.Hour
|
||||
found, have := state.Sessions[tok]
|
||||
if have && found.Expires.After(time.Now()) {
|
||||
ses, ok = found, true
|
||||
touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour
|
||||
}
|
||||
})
|
||||
if ok && extend {
|
||||
if ok && touch {
|
||||
_ = s.st.Update(func(state *store.State) {
|
||||
if _, found := state.Sessions[tok]; found {
|
||||
state.Sessions[tok] = time.Now().Add(SessionTTL)
|
||||
if x, found := state.Sessions[tok]; found {
|
||||
x.Expires = time.Now().Add(SessionTTL)
|
||||
x.LastSeen = time.Now()
|
||||
if ip != "" {
|
||||
x.IP = ip
|
||||
}
|
||||
state.Sessions[tok] = x
|
||||
}
|
||||
})
|
||||
}
|
||||
return ok
|
||||
return ses, ok
|
||||
}
|
||||
|
||||
func (s *Sessions) Delete(tok string) {
|
||||
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
|
||||
}
|
||||
|
||||
// List is every live session of user, newest first.
|
||||
func (s *Sessions) List(user string) []store.Session {
|
||||
var out []store.Session
|
||||
s.st.View(func(state *store.State) {
|
||||
now := time.Now()
|
||||
for _, ses := range state.Sessions {
|
||||
if ses.User == user && ses.Expires.After(now) {
|
||||
out = append(out, ses)
|
||||
}
|
||||
}
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Count is how many live sessions each user has.
|
||||
func (s *Sessions) Count() map[string]int {
|
||||
n := map[string]int{}
|
||||
s.st.View(func(state *store.State) {
|
||||
now := time.Now()
|
||||
for _, ses := range state.Sessions {
|
||||
if ses.Expires.After(now) {
|
||||
n[ses.User]++
|
||||
}
|
||||
}
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
// Revoke ends the session of user with this ID. Reports whether there was one.
|
||||
func (s *Sessions) Revoke(user, id string) bool {
|
||||
found := false
|
||||
_ = s.st.Update(func(state *store.State) {
|
||||
for tok, ses := range state.Sessions {
|
||||
if ses.User == user && ses.ID == id {
|
||||
delete(state.Sessions, tok)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// RevokeOthers ends every session of user except the one holding keep. Returns how many.
|
||||
func (s *Sessions) RevokeOthers(user, keep string) int {
|
||||
n := 0
|
||||
_ = s.st.Update(func(state *store.State) {
|
||||
for tok, ses := range state.Sessions {
|
||||
if ses.User == user && tok != keep {
|
||||
delete(state.Sessions, tok)
|
||||
n++
|
||||
}
|
||||
}
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
// RevokeUser ends every session of user (the account is going away).
|
||||
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
|
||||
|
||||
// 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
|
||||
// global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one
|
||||
// address never blocks another.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
clients map[string]*bucket
|
||||
global bucket
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
failures int
|
||||
lockUntil time.Time
|
||||
last time.Time
|
||||
}
|
||||
|
||||
var ErrLocked = errors.New("too many failed logins")
|
||||
|
||||
func (l *Limiter) Allow() error {
|
||||
const (
|
||||
perClientLock = 5 // failures before a client is locked
|
||||
globalLock = 100 // failures (all clients) in the window before everyone waits a minute
|
||||
globalWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
func (l *Limiter) get(key string) *bucket {
|
||||
if l.clients == nil {
|
||||
l.clients = map[string]*bucket{}
|
||||
}
|
||||
b, ok := l.clients[key]
|
||||
if !ok {
|
||||
b = &bucket{}
|
||||
l.clients[key] = b
|
||||
}
|
||||
// forget clients that have been quiet for an hour
|
||||
if len(l.clients) > 256 {
|
||||
for k, x := range l.clients {
|
||||
if time.Since(x.last) > time.Hour && time.Now().After(x.lockUntil) {
|
||||
delete(l.clients, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (l *Limiter) Allow(key string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if time.Now().Before(l.lockUntil) {
|
||||
now := time.Now()
|
||||
if now.Before(l.global.lockUntil) {
|
||||
return ErrLocked
|
||||
}
|
||||
if now.Sub(l.global.last) > globalWindow {
|
||||
l.global.failures = 0
|
||||
}
|
||||
if b := l.get(key); now.Before(b.lockUntil) {
|
||||
return ErrLocked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Limiter) Fail() {
|
||||
func (l *Limiter) Fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.failures++
|
||||
if l.failures >= 5 {
|
||||
d := 30 * time.Second << uint(l.failures-5)
|
||||
now := time.Now()
|
||||
b := l.get(key)
|
||||
b.failures++
|
||||
b.last = now
|
||||
if b.failures >= perClientLock {
|
||||
d := 30 * time.Second << uint(b.failures-perClientLock)
|
||||
if d > 10*time.Minute {
|
||||
d = 10 * time.Minute
|
||||
}
|
||||
l.lockUntil = time.Now().Add(d)
|
||||
b.lockUntil = now.Add(d)
|
||||
}
|
||||
if now.Sub(l.global.last) > globalWindow {
|
||||
l.global.failures = 0
|
||||
}
|
||||
l.global.failures++
|
||||
l.global.last = now
|
||||
if l.global.failures >= globalLock {
|
||||
l.global.lockUntil = now.Add(time.Minute)
|
||||
l.global.failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) Reset() {
|
||||
func (l *Limiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.failures = 0
|
||||
l.lockUntil = time.Time{}
|
||||
delete(l.clients, key)
|
||||
}
|
||||
|
||||
@@ -7,13 +7,93 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/store"
|
||||
)
|
||||
|
||||
// Token is an API token another Ham Lab (a hub) uses to manage this host. Only the
|
||||
// SHA-256 of the token is kept.
|
||||
type Token struct {
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Created time.Time `json:"created"`
|
||||
// Scope is what the token may do. "" is everything (a hub's token: full control of
|
||||
// this machine). ScopeSensorsRead only reads sensors — what a dashboard gets.
|
||||
Scope string `json:"scope,omitempty"`
|
||||
// Sensors narrows a sensors:read token to the IDs these patterns match. Empty is all.
|
||||
Sensors []string `json:"sensors,omitempty"`
|
||||
// IssuedBy is the account that made the token (or paired the hub that holds it).
|
||||
IssuedBy string `json:"issued_by,omitempty"`
|
||||
}
|
||||
|
||||
const ScopeSensorsRead = "sensors:read"
|
||||
|
||||
// Node is a remote Ham Lab this host (as hub) manages. The token is the one that
|
||||
// node issued to us; the fingerprint pins its self-signed certificate.
|
||||
type Node struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Token string `json:"token"`
|
||||
Added time.Time `json:"added"`
|
||||
}
|
||||
|
||||
// User is an account on this machine's console. Only the argon2id hash is kept.
|
||||
type User struct {
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Created time.Time `json:"created"`
|
||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
|
||||
// is Updates, not Update, because Config already has an Update method.
|
||||
type UpdateSource struct {
|
||||
// URL is a Gitea/Forgejo repo API base or a static directory; empty means the built-in
|
||||
// default, and "off" means never look.
|
||||
URL string `json:"url,omitempty"`
|
||||
// Token is sent when the source is behind a login, so a private Gitea can serve its
|
||||
// own fleet without the instance being opened to everyone.
|
||||
Token string `json:"token,omitempty"`
|
||||
// Check is "daily" (the default) or "never".
|
||||
Check string `json:"check,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
// 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.
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
Users []User `json:"users,omitempty"`
|
||||
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
||||
Tokens []Token `json:"tokens,omitempty"`
|
||||
Nodes []Node `json:"nodes,omitempty"`
|
||||
// Updates: where a newer build comes from. See UpdateSource.
|
||||
Updates UpdateSource `json:"update,omitempty"`
|
||||
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Candidates lists where config.json may live under a home directory, preferred first:
|
||||
// the XDG location, then ~/apps/hamlab, which installs before v0.2 used.
|
||||
func Candidates(home string) []string {
|
||||
return []string{filepath.Join(home, ".config", "hamlab", "config.json"), filepath.Join(home, "apps", "hamlab", "config.json")}
|
||||
}
|
||||
|
||||
// DefaultPath is the config used when neither -config nor $HAMLAB_CONFIG says otherwise:
|
||||
// whichever candidate exists, else the preferred one.
|
||||
func DefaultPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
c := Candidates(home)
|
||||
for _, p := range c {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return c[0]
|
||||
}
|
||||
|
||||
func Default(path string) *Config {
|
||||
@@ -36,10 +116,37 @@ func Load(path string) (*Config, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// MigrateUsers turns the pre-users single password into the first account, named after
|
||||
// the OS user the console runs as. Reports whether anything changed (the caller saves).
|
||||
func (c *Config) MigrateUsers(name string) bool {
|
||||
if len(c.Users) > 0 || c.PasswordHash == "" {
|
||||
return false
|
||||
}
|
||||
c.Users = []User{{Name: name, Hash: c.PasswordHash, Created: time.Now()}}
|
||||
c.PasswordHash = ""
|
||||
return true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for _, u := range c.Users {
|
||||
if u.Name == name {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
|
||||
func (c *Config) Path() string { return c.path }
|
||||
func (c *Config) DataDir() string { return filepath.Dir(c.path) }
|
||||
|
||||
func (c *Config) Save() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.save()
|
||||
}
|
||||
|
||||
func (c *Config) save() error {
|
||||
if err := os.MkdirAll(c.DataDir(), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -47,9 +154,20 @@ func (c *Config) Save() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := c.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, c.path)
|
||||
return store.WriteFileAtomic(c.path, append(b, '\n'), 0o600)
|
||||
}
|
||||
|
||||
// Update applies fn under the lock and saves.
|
||||
func (c *Config) Update(fn func(*Config)) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fn(c)
|
||||
return c.save()
|
||||
}
|
||||
|
||||
// View reads under the lock.
|
||||
func (c *Config) View(fn func(*Config)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fn(c)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
// Package cron reads and writes what the UI calls the **Jobs** tab (named "Cron" until
|
||||
// 2026-09-24; the package and its /api/cron endpoints kept the old name).
|
||||
//
|
||||
// Package cron reads every scheduled job on the host into one list: /etc/crontab,
|
||||
// /etc/cron.d/*, per-user crontabs, the run-parts directories they drive, and systemd
|
||||
// timers. It computes next runs from the cron expressions, pulls last runs from the
|
||||
// journal (cron logs "(user) CMD (command)" via syslog), and can toggle, edit and run
|
||||
// jobs. Reads/writes that need root go through passwordless sudo when available.
|
||||
package cron
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"` // "/etc/crontab", "/etc/cron.d/x", "crontab:user", "/etc/cron.daily"
|
||||
Line int `json:"line"` // 1-based line in Source (0 for run-parts scripts)
|
||||
User string `json:"user"`
|
||||
Schedule string `json:"schedule"`
|
||||
Command string `json:"command"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Next int64 `json:"next,omitempty"` // unix ms
|
||||
Last int64 `json:"last,omitempty"`
|
||||
OutFile string `json:"out_file,omitempty"` // parsed from a ">> file" redirect
|
||||
Note string `json:"note,omitempty"`
|
||||
Editable bool `json:"editable"`
|
||||
raw string
|
||||
}
|
||||
|
||||
type Timer struct {
|
||||
Unit string `json:"unit"`
|
||||
Activates string `json:"activates"`
|
||||
Description string `json:"description"`
|
||||
Active string `json:"active"`
|
||||
Sub string `json:"sub"`
|
||||
Enabled string `json:"enabled"`
|
||||
Next int64 `json:"next,omitempty"`
|
||||
Last int64 `json:"last,omitempty"`
|
||||
}
|
||||
|
||||
type Overview struct {
|
||||
Jobs []Job `json:"jobs"`
|
||||
Timers []Timer `json:"timers"`
|
||||
Logging bool `json:"logging"` // cron lines seen in the journal
|
||||
Anacron bool `json:"anacron"`
|
||||
Sudo bool `json:"sudo"`
|
||||
}
|
||||
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
Command string `json:"command"`
|
||||
User string `json:"user"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished time.Time `json:"finished,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Output string `json:"output"`
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
sudo bool
|
||||
journalSudo bool
|
||||
self string
|
||||
mu sync.Mutex
|
||||
lastRuns map[string]int64 // user\x00command -> unix ms
|
||||
lastAt time.Time
|
||||
lastCursor string // journal cursor of the newest entry folded into lastRuns
|
||||
logging bool
|
||||
runs map[string]*Run
|
||||
}
|
||||
|
||||
func New(sudo, journalSudo bool) *Manager {
|
||||
m := &Manager{sudo: sudo, journalSudo: journalSudo, runs: map[string]*Run{}}
|
||||
if u, err := user.Current(); err == nil {
|
||||
m.self = u.Username
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func run(ctx context.Context, stdin string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return out.String(), errors.New(msg)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// runLines streams a command's stdout to fn line by line, so multi-megabyte journal
|
||||
// dumps never sit in memory whole.
|
||||
func runLines(ctx context.Context, fn func(line []byte), args ...string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
var errb bytes.Buffer
|
||||
cmd.Stderr = &errb
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
|
||||
for sc.Scan() {
|
||||
fn(sc.Bytes())
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) sudoArgs(args ...string) []string {
|
||||
if m.sudo {
|
||||
return priv.Wrap(args...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func jobID(source, raw string) string {
|
||||
h := sha1.Sum([]byte(source + "\n" + raw))
|
||||
return hex.EncodeToString(h[:6])
|
||||
}
|
||||
|
||||
var userRe = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
var cronDName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // cron ignores other names in /etc/cron.d
|
||||
|
||||
// ---- parsing ----
|
||||
|
||||
// parseLine splits one crontab line. withUser is true for system crontabs (6th field is
|
||||
// the user). Returns ok=false for comments, blanks, env assignments and garbage.
|
||||
func parseLine(line string, withUser bool) (schedule, usr, command string, ok bool) {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
return
|
||||
}
|
||||
if i := strings.Index(t, "="); i > 0 && !strings.ContainsAny(t[:i], " \t*") { // NAME=value
|
||||
return
|
||||
}
|
||||
var fields []string
|
||||
if strings.HasPrefix(t, "@") {
|
||||
f := strings.Fields(t)
|
||||
if len(f) < 2 {
|
||||
return
|
||||
}
|
||||
fields = []string{f[0]}
|
||||
t = strings.TrimSpace(t[len(f[0]):])
|
||||
} else {
|
||||
f := strings.Fields(t)
|
||||
if len(f) < 6 {
|
||||
return
|
||||
}
|
||||
fields = f[:5]
|
||||
for _, x := range fields {
|
||||
t = strings.TrimSpace(t[len(x):])
|
||||
}
|
||||
}
|
||||
schedule = strings.Join(fields, " ")
|
||||
if _, err := parseSchedule(schedule); err != nil {
|
||||
return
|
||||
}
|
||||
if withUser {
|
||||
f := strings.Fields(t)
|
||||
if len(f) < 2 || !userRe.MatchString(f[0]) {
|
||||
return
|
||||
}
|
||||
usr = f[0]
|
||||
t = strings.TrimSpace(t[len(f[0]):])
|
||||
}
|
||||
if t == "" {
|
||||
return
|
||||
}
|
||||
return schedule, usr, t, true
|
||||
}
|
||||
|
||||
var redirectRe = regexp.MustCompile(`(?:^|\s)(?:>>|>|2>&1\s*>>|&>>|&>)\s*("?)([^\s"&|;]+)`)
|
||||
|
||||
func outFile(cmd string) string {
|
||||
for _, m := range redirectRe.FindAllStringSubmatch(cmd, -1) {
|
||||
if p := m[2]; strings.HasPrefix(p, "/") && p != "/dev/null" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseSource turns crontab text into jobs. Commented-out lines that still parse as a
|
||||
// job are reported disabled, so a "# 0 5 * * * ..." example shows up as such.
|
||||
func (m *Manager) parseSource(source, text string, withUser, editable bool, defaultUser string, now time.Time, anacron bool) []Job {
|
||||
var jobs []Job
|
||||
lines := strings.Split(text, "\n")
|
||||
isComment := func(i int) bool {
|
||||
return i >= 0 && i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "#")
|
||||
}
|
||||
for i, line := range lines {
|
||||
enabled := true
|
||||
probe := line
|
||||
if t := strings.TrimSpace(line); strings.HasPrefix(t, "#") {
|
||||
// A commented-out job sits on its own; a job-shaped line inside a block of
|
||||
// comments is documentation (Debian's stock header has two such examples).
|
||||
if isComment(i-1) && isComment(i+1) {
|
||||
continue
|
||||
}
|
||||
enabled = false
|
||||
probe = strings.TrimLeft(t, "# ")
|
||||
}
|
||||
schedule, usr, command, ok := parseLine(probe, withUser)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if usr == "" {
|
||||
usr = defaultUser
|
||||
}
|
||||
if !enabled {
|
||||
if _, err := user.Lookup(usr); err != nil {
|
||||
continue // "user-name" placeholder and the like
|
||||
}
|
||||
}
|
||||
j := Job{ID: jobID(source, line), Source: source, Line: i + 1, User: usr, Schedule: schedule, Command: command,
|
||||
Enabled: enabled, OutFile: outFile(command), Editable: editable, raw: line}
|
||||
if enabled {
|
||||
if next, ok := nextRun(schedule, now); ok {
|
||||
j.Next = next.UnixMilli()
|
||||
}
|
||||
}
|
||||
if strings.Contains(command, "run-parts") {
|
||||
if dir := runPartsDir(command); dir != "" {
|
||||
if anacron && strings.Contains(command, "anacron") {
|
||||
j.Note = "skipped: anacron is installed and runs " + dir + " instead"
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
jobs = append(jobs, m.runPartsJobs(dir, j)...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
var runPartsRe = regexp.MustCompile(`run-parts\s+(?:--\S+\s+)*(/\S+)`)
|
||||
|
||||
func runPartsDir(cmd string) string {
|
||||
if m := runPartsRe.FindStringSubmatch(cmd); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runPartsJobs lists the executable scripts a run-parts line will run, as read-only jobs.
|
||||
func (m *Manager) runPartsJobs(dir string, parent Job) []Job {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Job
|
||||
for _, e := range ents {
|
||||
if e.IsDir() || !cronDName.MatchString(e.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil || info.Mode()&0o111 == 0 {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, e.Name())
|
||||
out = append(out, Job{ID: jobID(dir, e.Name()), Source: dir, User: parent.User, Schedule: parent.Schedule, Command: p,
|
||||
Enabled: parent.Enabled, Next: parent.Next, Note: "run by " + parent.Source + " via run-parts", raw: e.Name()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- sources ----
|
||||
|
||||
type source struct {
|
||||
name string // job Source
|
||||
withUser bool
|
||||
user string // default user for user crontabs
|
||||
editable bool
|
||||
}
|
||||
|
||||
func (m *Manager) readSource(ctx context.Context, s source) (string, error) {
|
||||
if strings.HasPrefix(s.name, "crontab:") {
|
||||
u := s.user
|
||||
if u == m.self {
|
||||
out, err := run(ctx, "", "crontab", "-l")
|
||||
if err != nil && strings.Contains(err.Error(), "no crontab") {
|
||||
return "", nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
if !m.sudo {
|
||||
return "", errors.New("needs sudo")
|
||||
}
|
||||
out, err := run(ctx, "", priv.Wrap("crontab", "-u", u, "-l")...)
|
||||
if err != nil && strings.Contains(err.Error(), "no crontab") {
|
||||
return "", nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
b, err := os.ReadFile(s.name)
|
||||
if err != nil && m.sudo {
|
||||
out, err2 := run(ctx, "", priv.Wrap("cat", s.name)...)
|
||||
return out, err2
|
||||
}
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func (m *Manager) sources(ctx context.Context) []source {
|
||||
var out []source
|
||||
if _, err := os.Stat("/etc/crontab"); err == nil {
|
||||
out = append(out, source{name: "/etc/crontab", withUser: true, editable: m.sudo})
|
||||
}
|
||||
if ents, err := os.ReadDir("/etc/cron.d"); err == nil {
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() && cronDName.MatchString(e.Name()) {
|
||||
out = append(out, source{name: "/etc/cron.d/" + e.Name(), withUser: true, editable: m.sudo})
|
||||
}
|
||||
}
|
||||
}
|
||||
// per-user crontabs: Debian keeps them in /var/spool/cron/crontabs, cronie in /var/spool/cron
|
||||
users := map[string]bool{}
|
||||
if m.sudo {
|
||||
for _, dir := range []string{"/var/spool/cron/crontabs", "/var/spool/cron"} {
|
||||
if out, err := run(ctx, "", priv.Wrap("ls", "-1", dir)...); err == nil {
|
||||
for _, n := range strings.Split(out, "\n") {
|
||||
if n = strings.TrimSpace(n); userRe.MatchString(n) {
|
||||
users[n] = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.self != "" {
|
||||
users[m.self] = true
|
||||
}
|
||||
names := make([]string, 0, len(users))
|
||||
for u := range users {
|
||||
names = append(names, u)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, u := range names {
|
||||
out = append(out, source{name: "crontab:" + u, user: u, editable: u == m.self || m.sudo})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) findSource(ctx context.Context, name string) (source, bool) {
|
||||
for _, s := range m.sources(ctx) {
|
||||
if s.name == name {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return source{}, false
|
||||
}
|
||||
|
||||
// ---- overview ----
|
||||
|
||||
func (m *Manager) Overview(ctx context.Context) Overview {
|
||||
now := time.Now()
|
||||
_, anacronErr := os.Stat("/usr/sbin/anacron")
|
||||
anacron := anacronErr == nil
|
||||
ov := Overview{Jobs: []Job{}, Timers: []Timer{}, Anacron: anacron, Sudo: m.sudo}
|
||||
for _, s := range m.sources(ctx) {
|
||||
text, err := m.readSource(ctx, s)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ov.Jobs = append(ov.Jobs, m.parseSource(s.name, text, s.withUser, s.editable, s.user, now, anacron)...)
|
||||
}
|
||||
last := m.lastRunsFromJournal(ctx)
|
||||
ov.Logging = m.logging
|
||||
for i := range ov.Jobs {
|
||||
j := &ov.Jobs[i]
|
||||
if t, ok := last[j.User+"\x00"+j.Command]; ok {
|
||||
j.Last = t
|
||||
}
|
||||
}
|
||||
ov.Timers = m.timers(ctx)
|
||||
return ov
|
||||
}
|
||||
|
||||
var cronLogRe = regexp.MustCompile(`^\((\S+)\) CMD \((.*)\)$`)
|
||||
|
||||
// lastRunsFromJournal maps user+command to the most recent "(user) CMD (command)" line.
|
||||
// The first call scans 30 days; later calls only read entries after the last cursor, so
|
||||
// the cost per refresh is proportional to new cron activity, not to history.
|
||||
func (m *Manager) lastRunsFromJournal(ctx context.Context) map[string]int64 {
|
||||
m.mu.Lock()
|
||||
if time.Since(m.lastAt) < 2*time.Second && m.lastRuns != nil {
|
||||
r := m.lastRuns
|
||||
m.mu.Unlock()
|
||||
return r
|
||||
}
|
||||
cursor := m.lastCursor
|
||||
res := map[string]int64{}
|
||||
for k, v := range m.lastRuns {
|
||||
res[k] = v
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q"}
|
||||
if cursor != "" {
|
||||
args = append(args, "--after-cursor="+cursor)
|
||||
} else {
|
||||
args = append(args, "--since", "-30d", "-n", "20000")
|
||||
}
|
||||
if m.journalSudo {
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
seen := m.logging
|
||||
_ = runLines(ctx, func(line []byte) {
|
||||
var raw struct {
|
||||
Msg any `json:"MESSAGE"`
|
||||
TS string `json:"__REALTIME_TIMESTAMP"`
|
||||
Cursor string `json:"__CURSOR"`
|
||||
}
|
||||
if json.Unmarshal(line, &raw) != nil {
|
||||
return
|
||||
}
|
||||
seen = true
|
||||
if raw.Cursor != "" {
|
||||
cursor = raw.Cursor
|
||||
}
|
||||
msg, _ := raw.Msg.(string)
|
||||
mm := cronLogRe.FindStringSubmatch(msg)
|
||||
if mm == nil {
|
||||
return
|
||||
}
|
||||
us, _ := strconv.ParseInt(raw.TS, 10, 64)
|
||||
k := mm[1] + "\x00" + mm[2]
|
||||
if us/1000 > res[k] {
|
||||
res[k] = us / 1000
|
||||
}
|
||||
}, args...)
|
||||
m.mu.Lock()
|
||||
m.lastRuns, m.lastAt, m.lastCursor, m.logging = res, time.Now(), cursor, seen
|
||||
m.mu.Unlock()
|
||||
return res
|
||||
}
|
||||
|
||||
// JobLogs returns the journal lines for one job's command (most recent last).
|
||||
func (m *Manager) JobLogs(ctx context.Context, j Job, n int) ([]map[string]any, error) {
|
||||
args := []string{"journalctl", "-t", "CRON", "-t", "cron", "-t", "crond", "-o", "json", "--no-pager", "-q", "-n", "20000"}
|
||||
if m.journalSudo {
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
var res []map[string]any
|
||||
needle := "(" + j.User + ") CMD (" + j.Command + ")"
|
||||
err := runLines(ctx, func(line []byte) {
|
||||
if !bytes.Contains(line, []byte(j.Command[:min(len(j.Command), 40)])) { // cheap pre-filter before JSON
|
||||
return
|
||||
}
|
||||
var raw struct {
|
||||
Msg any `json:"MESSAGE"`
|
||||
TS string `json:"__REALTIME_TIMESTAMP"`
|
||||
PID string `json:"_PID"`
|
||||
}
|
||||
if json.Unmarshal(line, &raw) != nil {
|
||||
return
|
||||
}
|
||||
msg, _ := raw.Msg.(string)
|
||||
if !strings.Contains(msg, needle) {
|
||||
return
|
||||
}
|
||||
us, _ := strconv.ParseInt(raw.TS, 10, 64)
|
||||
res = append(res, map[string]any{"t": us / 1000, "p": 6, "ident": "CRON", "pid": raw.PID, "msg": msg})
|
||||
}, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(res) > n {
|
||||
res = res[len(res)-n:]
|
||||
}
|
||||
if res == nil {
|
||||
res = []map[string]any{}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// OutputTail returns the last n lines of the job's redirect target.
|
||||
func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error) {
|
||||
if j.OutFile == "" {
|
||||
return "", errors.New("job has no output file")
|
||||
}
|
||||
out, err := run(ctx, "", m.sudoArgs("tail", "-n", strconv.Itoa(n), j.OutFile)...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ---- timers ----
|
||||
|
||||
// timers: one `systemctl show` over every loaded timer instead of three list commands.
|
||||
func (m *Manager) timers(ctx context.Context) []Timer {
|
||||
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--no-pager",
|
||||
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
|
||||
if err != nil {
|
||||
return []Timer{}
|
||||
}
|
||||
unix := func(v string) int64 { // "Sat 2026-09-19 05:47:13 PDT", "n/a" or ""
|
||||
for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05"} {
|
||||
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
res := []Timer{}
|
||||
for _, block := range strings.Split(out, "\n\n") {
|
||||
t := Timer{}
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "Id":
|
||||
t.Unit = v
|
||||
case "Description":
|
||||
t.Description = v
|
||||
case "ActiveState":
|
||||
t.Active = v
|
||||
case "SubState":
|
||||
t.Sub = v
|
||||
case "UnitFileState":
|
||||
t.Enabled = v
|
||||
case "NextElapseUSecRealtime":
|
||||
t.Next = unix(v)
|
||||
case "LastTriggerUSec":
|
||||
t.Last = unix(v)
|
||||
case "Triggers":
|
||||
t.Activates = strings.Fields(v + " ")[0:1][0]
|
||||
}
|
||||
}
|
||||
if t.Unit != "" {
|
||||
res = append(res, t)
|
||||
}
|
||||
}
|
||||
sort.Slice(res, func(i, j int) bool { return res[i].Unit < res[j].Unit })
|
||||
return res
|
||||
}
|
||||
|
||||
var timerRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.timer$`)
|
||||
|
||||
func (m *Manager) TimerAction(ctx context.Context, unit, action string) error {
|
||||
if !timerRe.MatchString(unit) {
|
||||
return errors.New("invalid timer name")
|
||||
}
|
||||
switch action {
|
||||
case "start", "stop", "enable", "disable":
|
||||
_, err := run(ctx, "", m.sudoArgs("systemctl", action, unit)...)
|
||||
return err
|
||||
case "run": // fire the service the timer activates, right now
|
||||
out, err := run(ctx, "", "systemctl", "show", unit, "-p", "Triggers", "--value")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
svc := strings.TrimSpace(strings.Split(out, " ")[0])
|
||||
if svc == "" {
|
||||
return errors.New("timer triggers nothing")
|
||||
}
|
||||
_, err = run(ctx, "", m.sudoArgs("systemctl", "start", svc)...)
|
||||
return err
|
||||
}
|
||||
return errors.New("unknown action")
|
||||
}
|
||||
|
||||
// ---- job lookup and editing ----
|
||||
|
||||
func (m *Manager) FindJob(ctx context.Context, id string) (Job, bool) {
|
||||
for _, j := range m.Overview(ctx).Jobs {
|
||||
if j.ID == id {
|
||||
return j, true
|
||||
}
|
||||
}
|
||||
return Job{}, false
|
||||
}
|
||||
|
||||
// Source returns a crontab's raw text for the editor.
|
||||
func (m *Manager) Source(ctx context.Context, name string) (text string, editable bool, err error) {
|
||||
s, ok := m.findSource(ctx, name)
|
||||
if !ok {
|
||||
return "", false, errors.New("unknown source")
|
||||
}
|
||||
text, err = m.readSource(ctx, s)
|
||||
return text, s.editable, err
|
||||
}
|
||||
|
||||
// validate rejects any line that is neither blank, comment, env assignment, nor a job.
|
||||
func validate(text string, withUser bool) error {
|
||||
for i, line := range strings.Split(text, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
continue
|
||||
}
|
||||
if k, _, ok := strings.Cut(t, "="); ok && !strings.ContainsAny(strings.TrimSpace(k), " \t*") {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseLine(t, withUser); !ok {
|
||||
return fmt.Errorf("line %d: not a valid cron entry", i+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteSource replaces a crontab. User crontabs go through crontab(1) so cron reloads
|
||||
// them; system files are written in place with their permissions preserved.
|
||||
func (m *Manager) WriteSource(ctx context.Context, name, text string) error {
|
||||
s, ok := m.findSource(ctx, name)
|
||||
if !ok {
|
||||
return errors.New("unknown source")
|
||||
}
|
||||
if !s.editable {
|
||||
return errors.New("source is read-only here (no sudo)")
|
||||
}
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
if err := validate(text, s.withUser); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(name, "crontab:") {
|
||||
if s.user == m.self {
|
||||
_, err := run(ctx, text, "crontab", "-")
|
||||
return err
|
||||
}
|
||||
_, err := run(ctx, text, priv.Wrap("crontab", "-u", s.user, "-")...)
|
||||
return err
|
||||
}
|
||||
if name != "/etc/crontab" && !strings.HasPrefix(name, "/etc/cron.d/") {
|
||||
return errors.New("refusing to write outside /etc/crontab and /etc/cron.d")
|
||||
}
|
||||
_, err := run(ctx, text, priv.Wrap("tee", name)...)
|
||||
m.mu.Lock()
|
||||
m.lastAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Toggle comments or uncomments a job's line. The line must still read as the job did
|
||||
// when it was listed, so a concurrent edit can't be clobbered.
|
||||
func (m *Manager) Toggle(ctx context.Context, j Job, enable bool) error {
|
||||
if j.Line == 0 || !j.Editable {
|
||||
return errors.New("this job can't be toggled here")
|
||||
}
|
||||
s, ok := m.findSource(ctx, j.Source)
|
||||
if !ok {
|
||||
return errors.New("unknown source")
|
||||
}
|
||||
text, err := m.readSource(ctx, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
if j.Line > len(lines) || lines[j.Line-1] != j.raw {
|
||||
return errors.New("crontab changed underneath; reload and try again")
|
||||
}
|
||||
if enable {
|
||||
lines[j.Line-1] = strings.TrimLeft(strings.TrimSpace(j.raw), "# ")
|
||||
} else {
|
||||
lines[j.Line-1] = "#" + j.raw
|
||||
}
|
||||
return m.WriteSource(ctx, j.Source, strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// ---- run now ----
|
||||
|
||||
// StartRun executes the job's command as its user, the way cron would (sh -c, cwd = home,
|
||||
// '%' turned into newlines/stdin), in the background. Output is kept in memory.
|
||||
func (m *Manager) StartRun(ctx context.Context, j Job) (*Run, error) {
|
||||
if j.User != m.self && !m.sudo {
|
||||
return nil, errors.New("needs sudo to run as " + j.User)
|
||||
}
|
||||
cmdline, stdin := cronPercent(j.Command)
|
||||
args := []string{"sh", "-c", cmdline}
|
||||
if j.User != m.self {
|
||||
args = priv.AsUser(j.User, args...)
|
||||
}
|
||||
r := &Run{ID: jobID("run", strconv.FormatInt(time.Now().UnixNano(), 10)), JobID: j.ID, Command: j.Command, User: j.User, Started: time.Now(), Running: true}
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
cmd := exec.CommandContext(runCtx, args[0], args[1:]...)
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
w := &capWriter{r: r}
|
||||
cmd.Stdout, cmd.Stderr = w, w
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.runs[r.ID] = r
|
||||
for id, old := range m.runs { // keep the map small
|
||||
if !old.Running && time.Since(old.Finished) > time.Hour {
|
||||
delete(m.runs, id)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
defer cancel()
|
||||
err := cmd.Wait()
|
||||
r.mu.Lock()
|
||||
r.Running, r.Finished = false, time.Now()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
r.ExitCode = ee.ExitCode()
|
||||
} else {
|
||||
r.ExitCode = -1
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
m.mu.Lock()
|
||||
m.lastAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetRun(id string) (*Run, bool) {
|
||||
m.mu.Lock()
|
||||
r, ok := m.runs[id]
|
||||
m.mu.Unlock()
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// Snapshot returns a copy safe to encode.
|
||||
func (r *Run) Snapshot() Run {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return Run{ID: r.ID, JobID: r.JobID, Command: r.Command, User: r.User, Started: r.Started, Finished: r.Finished,
|
||||
Running: r.Running, ExitCode: r.ExitCode, Output: r.buf.String()}
|
||||
}
|
||||
|
||||
type capWriter struct{ r *Run }
|
||||
|
||||
func (w *capWriter) Write(p []byte) (int, error) {
|
||||
w.r.mu.Lock()
|
||||
defer w.r.mu.Unlock()
|
||||
w.r.buf.Write(p)
|
||||
if w.r.buf.Len() > 256<<10 { // keep the tail
|
||||
b := w.r.buf.Bytes()
|
||||
w.r.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-200<<10:]...))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// cronPercent applies crontab(5) rules: an unescaped % ends the command; later ones are
|
||||
// newlines fed to stdin. "\%" is a literal percent.
|
||||
func cronPercent(cmd string) (string, string) {
|
||||
var out, in strings.Builder
|
||||
cur := &out
|
||||
esc := false
|
||||
for _, c := range cmd {
|
||||
switch {
|
||||
case esc:
|
||||
if c != '%' {
|
||||
cur.WriteRune('\\')
|
||||
}
|
||||
cur.WriteRune(c)
|
||||
esc = false
|
||||
case c == '\\':
|
||||
esc = true
|
||||
case c == '%':
|
||||
if cur == &out {
|
||||
cur = &in
|
||||
} else {
|
||||
in.WriteRune('\n')
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(c)
|
||||
}
|
||||
}
|
||||
return out.String(), in.String()
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// schedule is a parsed five-field cron expression as sets.
|
||||
type schedule struct {
|
||||
min, hour, dom, mon, dow [64]bool
|
||||
domStar, dowStar bool // "*" in dom/dow changes the match rule
|
||||
reboot bool
|
||||
}
|
||||
|
||||
var monthNames = map[string]int{"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12}
|
||||
var dayNames = map[string]int{"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
|
||||
|
||||
var aliases = map[string]string{
|
||||
"@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0",
|
||||
"@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *",
|
||||
}
|
||||
|
||||
func parseSchedule(expr string) (*schedule, error) {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "@reboot" {
|
||||
return &schedule{reboot: true}, nil
|
||||
}
|
||||
if a, ok := aliases[strings.ToLower(expr)]; ok {
|
||||
expr = a
|
||||
}
|
||||
f := strings.Fields(expr)
|
||||
if len(f) != 5 {
|
||||
return nil, errors.New("expected 5 fields")
|
||||
}
|
||||
s := &schedule{}
|
||||
var err error
|
||||
if s.min, err = parseField(f[0], 0, 59, nil); err != nil {
|
||||
return nil, fmt.Errorf("minute: %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(f[1], 0, 23, nil); err != nil {
|
||||
return nil, fmt.Errorf("hour: %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(f[2], 1, 31, nil); err != nil {
|
||||
return nil, fmt.Errorf("day of month: %w", err)
|
||||
}
|
||||
if s.mon, err = parseField(f[3], 1, 12, monthNames); err != nil {
|
||||
return nil, fmt.Errorf("month: %w", err)
|
||||
}
|
||||
if s.dow, err = parseField(f[4], 0, 7, dayNames); err != nil {
|
||||
return nil, fmt.Errorf("day of week: %w", err)
|
||||
}
|
||||
if s.dow[7] { // 7 == Sunday
|
||||
s.dow[0] = true
|
||||
}
|
||||
s.domStar = f[2] == "*"
|
||||
s.dowStar = f[4] == "*"
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func parseField(field string, lo, hi int, names map[string]int) ([64]bool, error) {
|
||||
var set [64]bool
|
||||
atom := func(s string) (int, error) {
|
||||
if names != nil {
|
||||
if v, ok := names[strings.ToLower(s)]; ok {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad value %q", s)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
step := 1
|
||||
if i := strings.Index(part, "/"); i >= 0 {
|
||||
v, err := strconv.Atoi(part[i+1:])
|
||||
if err != nil || v <= 0 {
|
||||
return set, fmt.Errorf("bad step in %q", part)
|
||||
}
|
||||
step, part = v, part[:i]
|
||||
}
|
||||
a, b := lo, hi
|
||||
switch {
|
||||
case part == "*":
|
||||
case strings.Contains(part, "-"):
|
||||
x, y, _ := strings.Cut(part, "-")
|
||||
var err error
|
||||
if a, err = atom(x); err != nil {
|
||||
return set, err
|
||||
}
|
||||
if b, err = atom(y); err != nil {
|
||||
return set, err
|
||||
}
|
||||
default:
|
||||
v, err := atom(part)
|
||||
if err != nil {
|
||||
return set, err
|
||||
}
|
||||
a = v
|
||||
if step == 1 {
|
||||
b = v
|
||||
}
|
||||
}
|
||||
if a < lo || b > hi || a > b {
|
||||
return set, fmt.Errorf("out of range: %q", part)
|
||||
}
|
||||
for v := a; v <= b; v += step {
|
||||
set[v] = true
|
||||
}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func (s *schedule) dayMatches(t time.Time) bool {
|
||||
if !s.mon[int(t.Month())] {
|
||||
return false
|
||||
}
|
||||
dom, dow := s.dom[t.Day()], s.dow[int(t.Weekday())]
|
||||
switch {
|
||||
case s.domStar && s.dowStar:
|
||||
return true
|
||||
case s.domStar:
|
||||
return dow
|
||||
case s.dowStar:
|
||||
return dom
|
||||
default: // both restricted: cron runs when either matches
|
||||
return dom || dow
|
||||
}
|
||||
}
|
||||
|
||||
// NextRun is the first time strictly after `from` that a cron expression fires. Exported so
|
||||
// anything that writes a cron line — the ZFS snapshot schedules — can say when it runs next
|
||||
// using the same evaluator that reads everyone else's.
|
||||
func NextRun(expr string, from time.Time) (time.Time, bool) { return nextRun(expr, from) }
|
||||
|
||||
// ValidSchedule reports whether a cron expression parses.
|
||||
func ValidSchedule(expr string) error {
|
||||
_, err := parseSchedule(expr)
|
||||
return err
|
||||
}
|
||||
|
||||
// nextRun finds the first time strictly after `from` that the schedule fires.
|
||||
func nextRun(expr string, from time.Time) (time.Time, bool) {
|
||||
s, err := parseSchedule(expr)
|
||||
if err != nil || s.reboot {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t := from.Truncate(time.Minute).Add(time.Minute)
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
for i := 0; i < 366*2; i++ {
|
||||
if s.dayMatches(day) {
|
||||
for h := 0; h < 24; h++ {
|
||||
if !s.hour[h] {
|
||||
continue
|
||||
}
|
||||
for mi := 0; mi < 60; mi++ {
|
||||
if !s.min[mi] {
|
||||
continue
|
||||
}
|
||||
c := time.Date(day.Year(), day.Month(), day.Day(), h, mi, 0, 0, day.Location())
|
||||
if !c.Before(t) {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
day = day.AddDate(0, 0, 1)
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNextRun(t *testing.T) {
|
||||
loc := time.UTC
|
||||
from := time.Date(2026, 9, 17, 15, 30, 0, 0, loc) // Thursday
|
||||
cases := []struct {
|
||||
expr string
|
||||
want time.Time
|
||||
}{
|
||||
{"0 1 * * *", time.Date(2026, 9, 18, 1, 0, 0, 0, loc)},
|
||||
{"17 * * * *", time.Date(2026, 9, 17, 16, 17, 0, 0, loc)},
|
||||
{"*/15 * * * *", time.Date(2026, 9, 17, 15, 45, 0, 0, loc)},
|
||||
{"47 6 * * 7", time.Date(2026, 9, 20, 6, 47, 0, 0, loc)}, // Sunday as 7
|
||||
{"52 6 1 * *", time.Date(2026, 10, 1, 6, 52, 0, 0, loc)}, // first of month
|
||||
{"24 0 1-7 * *", time.Date(2026, 10, 1, 0, 24, 0, 0, loc)}, // range
|
||||
{"30 3 * * 0", time.Date(2026, 9, 20, 3, 30, 0, 0, loc)}, // Sunday as 0
|
||||
{"0 9 15 * mon", time.Date(2026, 9, 21, 9, 0, 0, 0, loc)}, // dom OR dow
|
||||
{"@daily", time.Date(2026, 9, 18, 0, 0, 0, 0, loc)},
|
||||
{"@weekly", time.Date(2026, 9, 20, 0, 0, 0, 0, loc)},
|
||||
{"0 0 29 feb *", time.Date(2028, 2, 29, 0, 0, 0, 0, loc)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := nextRun(c.expr, from)
|
||||
if !ok || !got.Equal(c.want) {
|
||||
t.Errorf("%q: got %v ok=%v, want %v", c.expr, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
if _, ok := nextRun("@reboot", from); ok {
|
||||
t.Error("@reboot should have no next run")
|
||||
}
|
||||
for _, bad := range []string{"* * * *", "60 * * * *", "a b c d e", "*/0 * * * *"} {
|
||||
if _, err := parseSchedule(bad); err == nil {
|
||||
t.Errorf("%q should fail", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLine(t *testing.T) {
|
||||
sch, u, cmd, ok := parseLine("25 6\t* * *\troot\ttest -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }", true)
|
||||
if !ok || sch != "25 6 * * *" || u != "root" || !strings_HasPrefix(cmd, "test -x") {
|
||||
t.Errorf("system line: %q %q %q %v", sch, u, cmd, ok)
|
||||
}
|
||||
if _, _, _, ok := parseLine("PATH=/usr/bin:/bin", true); ok {
|
||||
t.Error("env line parsed as job")
|
||||
}
|
||||
if _, _, _, ok := parseLine("# m h dom mon dow command", false); ok {
|
||||
t.Error("comment parsed as job")
|
||||
}
|
||||
if _, u, cmd, ok := parseLine("@reboot /usr/local/bin/thing --flag", false); !ok || u != "" || cmd != "/usr/local/bin/thing --flag" {
|
||||
t.Errorf("@reboot: %q %q %v", u, cmd, ok)
|
||||
}
|
||||
if got := outFile("foo >> /var/log/foo.log 2>&1"); got != "/var/log/foo.log" {
|
||||
t.Errorf("outFile: %q", got)
|
||||
}
|
||||
if c, in := cronPercent("echo hi%line1%line2"); c != "echo hi" || in != "line1\nline2" {
|
||||
t.Errorf("percent: %q %q", c, in)
|
||||
}
|
||||
if c, _ := cronPercent(`date +\%w`); c != "date +%w" {
|
||||
t.Errorf("escaped percent: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
func strings_HasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
|
||||
@@ -0,0 +1,503 @@
|
||||
// Package files is the Files tab: browse, read, write, upload, download, mkdir,
|
||||
// rename, chmod and delete anywhere on the host.
|
||||
//
|
||||
// Two backends share one interface. Local runs in-process as the daemon's user. Sudo
|
||||
// re-executes this same binary as root (`sudo -n hamlab fs …`) for one operation at a
|
||||
// time, streaming file bodies over its stdin/stdout, so root access uses exactly the
|
||||
// same code and the same validation as the unprivileged path.
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // dir | file | link | other
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"` // "-rw-r--r--"
|
||||
Perm string `json:"perm"` // "0644"
|
||||
Mtime int64 `json:"mtime"`
|
||||
User string `json:"user"`
|
||||
Group string `json:"group"`
|
||||
Target string `json:"target,omitempty"` // link target
|
||||
Broken bool `json:"broken,omitempty"` // dangling link
|
||||
}
|
||||
|
||||
type Listing struct {
|
||||
Path string `json:"path"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
Entry
|
||||
Path string `json:"path"`
|
||||
Text bool `json:"text"` // looks like text (for the editor)
|
||||
}
|
||||
|
||||
// FS is what the API talks to.
|
||||
type FS interface {
|
||||
List(ctx context.Context, path string) (Listing, error)
|
||||
Stat(ctx context.Context, path string) (Info, error)
|
||||
Open(ctx context.Context, path string) (io.ReadCloser, error)
|
||||
Write(ctx context.Context, path string, r io.Reader) error
|
||||
Mkdir(ctx context.Context, path string) error
|
||||
Touch(ctx context.Context, path string) error
|
||||
Rename(ctx context.Context, from, to string) error
|
||||
Delete(ctx context.Context, path string, recursive bool) error
|
||||
Chmod(ctx context.Context, path string, perm os.FileMode) error
|
||||
}
|
||||
|
||||
// ---- path rules ----
|
||||
|
||||
// Clean rejects relative paths and normalises the rest. Everything else on the host is
|
||||
// reachable on purpose: this is a root-capable console, the gate is the login.
|
||||
func Clean(p string) (string, error) {
|
||||
if p == "" {
|
||||
return "", errors.New("path required")
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
return "", errors.New("path must be absolute")
|
||||
}
|
||||
c := filepath.Clean(p)
|
||||
if strings.ContainsRune(c, 0) {
|
||||
return "", errors.New("bad path")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// protected are never deleted or renamed as a whole, no matter who asks.
|
||||
var protected = map[string]bool{"/": true, "/bin": true, "/boot": true, "/dev": true, "/etc": true, "/home": true, "/lib": true, "/lib64": true,
|
||||
"/opt": true, "/proc": true, "/root": true, "/run": true, "/sbin": true, "/srv": true, "/sys": true, "/tmp": true, "/usr": true, "/var": true}
|
||||
|
||||
func ParsePerm(s string) (os.FileMode, error) {
|
||||
v, err := strconv.ParseUint(strings.TrimPrefix(s, "0"), 8, 32)
|
||||
if err != nil || v > 0o7777 {
|
||||
return 0, errors.New("mode must be octal like 0644")
|
||||
}
|
||||
return os.FileMode(v), nil
|
||||
}
|
||||
|
||||
// ---- Local ----
|
||||
|
||||
type Local struct{}
|
||||
|
||||
var uidCache, gidCache = map[uint32]string{}, map[uint32]string{}
|
||||
|
||||
func owner(st os.FileInfo) (string, string) {
|
||||
sys, ok := st.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
u, ok := uidCache[sys.Uid]
|
||||
if !ok {
|
||||
u = strconv.Itoa(int(sys.Uid))
|
||||
if x, err := user.LookupId(u); err == nil {
|
||||
u = x.Username
|
||||
}
|
||||
uidCache[sys.Uid] = u
|
||||
}
|
||||
g, ok := gidCache[sys.Gid]
|
||||
if !ok {
|
||||
g = strconv.Itoa(int(sys.Gid))
|
||||
if x, err := user.LookupGroupId(g); err == nil {
|
||||
g = x.Name
|
||||
}
|
||||
gidCache[sys.Gid] = g
|
||||
}
|
||||
return u, g
|
||||
}
|
||||
|
||||
func entryOf(dir, name string, lst os.FileInfo) Entry {
|
||||
e := Entry{Name: name, Size: lst.Size(), Mode: lst.Mode().String(), Perm: fmt.Sprintf("%04o", lst.Mode().Perm()), Mtime: lst.ModTime().UnixMilli()}
|
||||
e.User, e.Group = owner(lst)
|
||||
switch {
|
||||
case lst.Mode()&os.ModeSymlink != 0:
|
||||
e.Type = "link"
|
||||
full := filepath.Join(dir, name)
|
||||
e.Target, _ = os.Readlink(full)
|
||||
if st, err := os.Stat(full); err != nil {
|
||||
e.Broken = true
|
||||
} else if st.IsDir() {
|
||||
e.Type = "dir" // keep dir semantics for navigation; Target says it's a link
|
||||
} else {
|
||||
e.Size = st.Size()
|
||||
}
|
||||
case lst.IsDir():
|
||||
e.Type = "dir"
|
||||
case lst.Mode().IsRegular():
|
||||
e.Type = "file"
|
||||
default:
|
||||
e.Type = "other"
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (Local) List(_ context.Context, path string) (Listing, error) {
|
||||
ents, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return Listing{}, err
|
||||
}
|
||||
l := Listing{Path: path, Entries: make([]Entry, 0, len(ents))}
|
||||
if path != "/" {
|
||||
l.Parent = filepath.Dir(path)
|
||||
}
|
||||
for _, d := range ents {
|
||||
lst, err := d.Info() // Lstat
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l.Entries = append(l.Entries, entryOf(path, d.Name(), lst))
|
||||
}
|
||||
sort.Slice(l.Entries, func(i, j int) bool {
|
||||
a, b := l.Entries[i], l.Entries[j]
|
||||
if (a.Type == "dir") != (b.Type == "dir") {
|
||||
return a.Type == "dir"
|
||||
}
|
||||
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
|
||||
})
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (Local) Stat(_ context.Context, path string) (Info, error) {
|
||||
lst, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
in := Info{Entry: entryOf(filepath.Dir(path), filepath.Base(path), lst), Path: path}
|
||||
if in.Type == "file" && in.Size <= 8<<20 {
|
||||
if f, err := os.Open(path); err == nil {
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := f.Read(buf)
|
||||
f.Close()
|
||||
in.Text = looksText(buf[:n])
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func looksText(b []byte) bool {
|
||||
if len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
ctrl := 0
|
||||
for _, c := range b {
|
||||
if c == 0 {
|
||||
return false
|
||||
}
|
||||
if c < 32 && c != '\n' && c != '\r' && c != '\t' && c != '\f' && c != 0x1b {
|
||||
ctrl++
|
||||
}
|
||||
}
|
||||
return ctrl*20 < len(b)
|
||||
}
|
||||
|
||||
// Open refuses anything that isn't a regular file: devices stream forever (/dev/zero),
|
||||
// FIFOs block on open, sockets can't be read. /proc files are regular and still work.
|
||||
func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return nil, errors.New("is a directory")
|
||||
}
|
||||
if !st.Mode().IsRegular() {
|
||||
return nil, errors.New("not a regular file (device, pipe or socket)")
|
||||
}
|
||||
return os.Open(path)
|
||||
}
|
||||
|
||||
// Write replaces the file's contents, keeping its mode; new files get 0644 (umask).
|
||||
func (Local) Write(_ context.Context, path string, r io.Reader) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (Local) Mkdir(_ context.Context, path string) error { return os.Mkdir(path, 0o755) }
|
||||
|
||||
func (Local) Touch(_ context.Context, path string) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (Local) Rename(_ context.Context, from, to string) error {
|
||||
if protected[from] {
|
||||
return errors.New("refusing to move " + from)
|
||||
}
|
||||
if _, err := os.Lstat(to); err == nil {
|
||||
return errors.New("destination exists")
|
||||
}
|
||||
return os.Rename(from, to)
|
||||
}
|
||||
|
||||
func (Local) Delete(_ context.Context, path string, recursive bool) error {
|
||||
if protected[path] {
|
||||
return errors.New("refusing to delete " + path)
|
||||
}
|
||||
lst, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lst.IsDir() && recursive {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
return os.Remove(path) // fails on a non-empty dir, by design
|
||||
}
|
||||
|
||||
func (Local) Chmod(_ context.Context, path string, perm os.FileMode) error {
|
||||
return os.Chmod(path, perm)
|
||||
}
|
||||
|
||||
// ---- Sudo: the same operations, run as root in a child hamlab ----
|
||||
|
||||
type Sudo struct{ Self string }
|
||||
|
||||
func (s Sudo) cmd(ctx context.Context, args ...string) *exec.Cmd {
|
||||
full := priv.Wrap(append([]string{s.Self, "fs"}, args...)...)
|
||||
return exec.CommandContext(ctx, full[0], full[1:]...)
|
||||
}
|
||||
|
||||
func (s Sudo) runJSON(ctx context.Context, out any, args ...string) error {
|
||||
c := s.cmd(ctx, args...)
|
||||
var errb strings.Builder
|
||||
c.Stderr = &errb
|
||||
b, err := c.Output()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
|
||||
func (s Sudo) List(ctx context.Context, path string) (Listing, error) {
|
||||
var l Listing
|
||||
err := s.runJSON(ctx, &l, "list", path)
|
||||
return l, err
|
||||
}
|
||||
|
||||
func (s Sudo) Stat(ctx context.Context, path string) (Info, error) {
|
||||
var in Info
|
||||
err := s.runJSON(ctx, &in, "stat", path)
|
||||
return in, err
|
||||
}
|
||||
|
||||
type cmdReader struct {
|
||||
io.ReadCloser
|
||||
cmd *exec.Cmd
|
||||
errb *strings.Builder
|
||||
}
|
||||
|
||||
func (r *cmdReader) Close() error {
|
||||
r.ReadCloser.Close()
|
||||
err := r.cmd.Wait()
|
||||
if err != nil && r.errb.Len() > 0 {
|
||||
return errors.New(strings.TrimSpace(r.errb.String()))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) {
|
||||
c := s.cmd(ctx, "read", path)
|
||||
errb := &strings.Builder{}
|
||||
c.Stderr = errb
|
||||
out, err := c.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fail early on "no such file" etc.: the helper stats before writing anything.
|
||||
head := make([]byte, 1)
|
||||
n, rerr := out.Read(head)
|
||||
if n == 0 && rerr != nil {
|
||||
_ = c.Wait()
|
||||
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
if rerr != io.EOF {
|
||||
return nil, rerr
|
||||
}
|
||||
}
|
||||
return &cmdReader{ReadCloser: struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{io.MultiReader(strings.NewReader(string(head[:n])), out), out}, cmd: c, errb: errb}, nil
|
||||
}
|
||||
|
||||
func (s Sudo) Write(ctx context.Context, path string, r io.Reader) error {
|
||||
c := s.cmd(ctx, "write", path)
|
||||
c.Stdin = r
|
||||
var errb strings.Builder
|
||||
c.Stderr = &errb
|
||||
if err := c.Run(); err != nil {
|
||||
if msg := strings.TrimSpace(errb.String()); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Sudo) Mkdir(ctx context.Context, path string) error {
|
||||
return s.runJSON(ctx, nil, "mkdir", path)
|
||||
}
|
||||
func (s Sudo) Touch(ctx context.Context, path string) error {
|
||||
return s.runJSON(ctx, nil, "touch", path)
|
||||
}
|
||||
func (s Sudo) Rename(ctx context.Context, from, to string) error {
|
||||
return s.runJSON(ctx, nil, "rename", from, to)
|
||||
}
|
||||
func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error {
|
||||
if recursive {
|
||||
return s.runJSON(ctx, nil, "delete", "-r", path)
|
||||
}
|
||||
return s.runJSON(ctx, nil, "delete", path)
|
||||
}
|
||||
func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error {
|
||||
return s.runJSON(ctx, nil, "chmod", fmt.Sprintf("%04o", perm), path)
|
||||
}
|
||||
|
||||
// Helper implements `hamlab fs <op> …` for the Sudo backend. Returns the exit code.
|
||||
func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
|
||||
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
|
||||
if len(args) < 2 {
|
||||
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|delete|chmod> …"))
|
||||
}
|
||||
ctx := context.Background()
|
||||
var l Local
|
||||
op := args[0]
|
||||
clean := func(i int) (string, error) {
|
||||
if i >= len(args) {
|
||||
return "", errors.New("path required")
|
||||
}
|
||||
return Clean(args[i])
|
||||
}
|
||||
switch op {
|
||||
case "list", "stat", "read", "write", "mkdir", "touch":
|
||||
p, err := clean(1)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
switch op {
|
||||
case "list":
|
||||
out, err := l.List(ctx, p)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return emit(stdout, out, stderr)
|
||||
case "stat":
|
||||
out, err := l.Stat(ctx, p)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return emit(stdout, out, stderr)
|
||||
case "read":
|
||||
f, err := l.Open(ctx, p)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(stdout, f); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return 0
|
||||
case "write":
|
||||
if err := l.Write(ctx, p, stdin); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
case "mkdir":
|
||||
if err := l.Mkdir(ctx, p); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
case "touch":
|
||||
if err := l.Touch(ctx, p); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
case "rename":
|
||||
from, err := clean(1)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
to, err := clean(2)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := l.Rename(ctx, from, to); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
case "delete":
|
||||
rec := false
|
||||
i := 1
|
||||
if args[1] == "-r" {
|
||||
rec, i = true, 2
|
||||
}
|
||||
p, err := clean(i)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := l.Delete(ctx, p, rec); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
case "chmod":
|
||||
perm, err := ParsePerm(args[1])
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
p, err := clean(2)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := l.Chmod(ctx, p, perm); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
default:
|
||||
return fail(errors.New("unknown op " + op))
|
||||
}
|
||||
fmt.Fprintln(stdout, "{}")
|
||||
return 0
|
||||
}
|
||||
|
||||
func emit(w io.Writer, v any, stderr io.Writer) int {
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
fmt.Fprintln(stderr, err.Error())
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ModTime helper for handlers.
|
||||
func Now() time.Time { return time.Now() }
|
||||
@@ -0,0 +1,141 @@
|
||||
package metrics
|
||||
|
||||
// One kept history point is the average of the samples behind it. At the default ten-minute
|
||||
// window that bucket is a single sample and nothing is averaged; at a day it is five minutes
|
||||
// of them, and the difference matters — a graph of the last day drawn from whichever instant
|
||||
// landed on each five-minute mark would show spikes that never happened and miss the ones
|
||||
// that did.
|
||||
//
|
||||
// The newest sample of the bucket is the template: its shape, labels, totals and slow
|
||||
// sections (filesystems, UPS) are the current ones. Only the moving numbers are averaged,
|
||||
// and only where an entry is present in that sample — a disk plugged in halfway through the
|
||||
// bucket is averaged over the samples that have it.
|
||||
|
||||
func fold(b []Sample) Sample {
|
||||
out := b[len(b)-1]
|
||||
if len(b) == 1 {
|
||||
return out
|
||||
}
|
||||
n := float64(len(b))
|
||||
sum := func(get func(Sample) float64) float64 {
|
||||
var t float64
|
||||
for _, s := range b {
|
||||
t += get(s)
|
||||
}
|
||||
return t / n
|
||||
}
|
||||
sumU := func(get func(Sample) uint64) uint64 {
|
||||
var t uint64
|
||||
for _, s := range b {
|
||||
t += get(s)
|
||||
}
|
||||
return t / uint64(len(b))
|
||||
}
|
||||
out.CPU = sum(func(s Sample) float64 { return s.CPU })
|
||||
out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] })
|
||||
out.Load[1] = sum(func(s Sample) float64 { return s.Load[1] })
|
||||
out.Load[2] = sum(func(s Sample) float64 { return s.Load[2] })
|
||||
|
||||
out.Cores = append([]float64(nil), out.Cores...)
|
||||
for i := range out.Cores {
|
||||
var t, c float64
|
||||
for _, s := range b {
|
||||
if i < len(s.Cores) {
|
||||
t, c = t+s.Cores[i], c+1
|
||||
}
|
||||
}
|
||||
if c > 0 {
|
||||
out.Cores[i] = t / c
|
||||
}
|
||||
}
|
||||
|
||||
out.Mem.Used = sumU(func(s Sample) uint64 { return s.Mem.Used })
|
||||
out.Mem.Avail = sumU(func(s Sample) uint64 { return s.Mem.Avail })
|
||||
out.Mem.Free = sumU(func(s Sample) uint64 { return s.Mem.Free })
|
||||
out.Mem.Cached = sumU(func(s Sample) uint64 { return s.Mem.Cached })
|
||||
out.Swap.Used = sumU(func(s Sample) uint64 { return s.Swap.Used })
|
||||
|
||||
// the rest are lists whose entries are matched by name across the bucket
|
||||
temps := avgBy(b, func(s Sample) map[string][]float64 {
|
||||
m := make(map[string][]float64, len(s.Temps))
|
||||
for _, x := range s.Temps {
|
||||
m[x.Chip+"/"+x.Label] = []float64{x.C}
|
||||
}
|
||||
return m
|
||||
})
|
||||
out.Temps = append([]Temp(nil), out.Temps...)
|
||||
for i, x := range out.Temps {
|
||||
if v, ok := temps[x.Chip+"/"+x.Label]; ok {
|
||||
out.Temps[i].C = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
fans := avgBy(b, func(s Sample) map[string][]float64 {
|
||||
m := make(map[string][]float64, len(s.Fans))
|
||||
for _, x := range s.Fans {
|
||||
m[x.Chip+"/"+x.Label] = []float64{float64(x.RPM)}
|
||||
}
|
||||
return m
|
||||
})
|
||||
out.Fans = append([]Fan(nil), out.Fans...)
|
||||
for i, x := range out.Fans {
|
||||
if v, ok := fans[x.Chip+"/"+x.Label]; ok {
|
||||
out.Fans[i].RPM = int(v[0] + .5)
|
||||
}
|
||||
}
|
||||
|
||||
nets := avgBy(b, func(s Sample) map[string][]float64 {
|
||||
m := make(map[string][]float64, len(s.Net))
|
||||
for _, x := range s.Net {
|
||||
m[x.Iface] = []float64{x.RxBps, x.TxBps}
|
||||
}
|
||||
return m
|
||||
})
|
||||
out.Net = append([]Net(nil), out.Net...)
|
||||
for i, x := range out.Net {
|
||||
if v, ok := nets[x.Iface]; ok {
|
||||
out.Net[i].RxBps, out.Net[i].TxBps = v[0], v[1]
|
||||
}
|
||||
}
|
||||
|
||||
disks := avgBy(b, func(s Sample) map[string][]float64 {
|
||||
m := make(map[string][]float64, len(s.Disks))
|
||||
for _, x := range s.Disks {
|
||||
m[x.Name] = []float64{x.ReadBps, x.WriteBps, x.UtilPct}
|
||||
}
|
||||
return m
|
||||
})
|
||||
out.Disks = append([]Disk(nil), out.Disks...)
|
||||
for i, x := range out.Disks {
|
||||
if v, ok := disks[x.Name]; ok {
|
||||
out.Disks[i].ReadBps, out.Disks[i].WriteBps, out.Disks[i].UtilPct = v[0], v[1], v[2]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// avgBy averages each sample's named numbers across the bucket, per name.
|
||||
func avgBy(b []Sample, of func(Sample) map[string][]float64) map[string][]float64 {
|
||||
sums := map[string][]float64{}
|
||||
count := map[string]float64{}
|
||||
for _, s := range b {
|
||||
for k, vs := range of(s) {
|
||||
acc := sums[k]
|
||||
if acc == nil {
|
||||
acc = make([]float64, len(vs))
|
||||
}
|
||||
for i, v := range vs {
|
||||
if i < len(acc) {
|
||||
acc[i] += v
|
||||
}
|
||||
}
|
||||
sums[k], count[k] = acc, count[k]+1
|
||||
}
|
||||
}
|
||||
for k, acc := range sums {
|
||||
for i := range acc {
|
||||
acc[i] /= count[k]
|
||||
}
|
||||
}
|
||||
return sums
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFoldAverages(t *testing.T) {
|
||||
b := []Sample{
|
||||
{T: 1, CPU: 10, Cores: []float64{10, 0}, Mem: Mem{Total: 100, Used: 10},
|
||||
Temps: []Temp{{Chip: "c", Label: "l", C: 40}}, Net: []Net{{Iface: "eth0", RxBps: 100}},
|
||||
Disks: []Disk{{Name: "sda", ReadBps: 10, UtilPct: 5}}},
|
||||
{T: 2, CPU: 30, Cores: []float64{30, 100}, Mem: Mem{Total: 100, Used: 30},
|
||||
Temps: []Temp{{Chip: "c", Label: "l", C: 60}}, Net: []Net{{Iface: "eth0", RxBps: 300}, {Iface: "new", RxBps: 7}},
|
||||
Disks: []Disk{{Name: "sda", ReadBps: 30, UtilPct: 15}}},
|
||||
}
|
||||
f := fold(b)
|
||||
if f.T != 2 {
|
||||
t.Errorf("a folded point is stamped with the newest sample in it, got %d", f.T)
|
||||
}
|
||||
if f.CPU != 20 || f.Cores[0] != 20 || f.Cores[1] != 50 {
|
||||
t.Errorf("cpu averaged: %v %v", f.CPU, f.Cores)
|
||||
}
|
||||
if f.Mem.Used != 20 || f.Mem.Total != 100 {
|
||||
t.Errorf("memory: used averaged, total taken from the newest, got %+v", f.Mem)
|
||||
}
|
||||
if f.Temps[0].C != 50 || f.Net[0].RxBps != 200 || f.Disks[0].ReadBps != 20 || f.Disks[0].UtilPct != 10 {
|
||||
t.Errorf("lists matched by name: %v %v %v", f.Temps, f.Net, f.Disks)
|
||||
}
|
||||
if len(f.Net) != 2 || f.Net[1].RxBps != 7 {
|
||||
t.Errorf("an interface that appeared inside the bucket is averaged over the samples that have it: %v", f.Net)
|
||||
}
|
||||
// the template's own slices must not be touched: Latest() shares them
|
||||
if b[1].Cores[0] != 30 || b[1].Temps[0].C != 60 {
|
||||
t.Error("fold wrote through to the sample it templated from")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrideAndTrim(t *testing.T) {
|
||||
c := &Collector{interval: 2 * time.Second, window: DefaultWindow}
|
||||
if got := c.stride(); got != 2*time.Second {
|
||||
t.Errorf("a window of %v over %d points is finer than the interval, so the interval wins; got %v", DefaultWindow, History, got)
|
||||
}
|
||||
c.window = 24 * time.Hour
|
||||
if got := c.stride(); got != 24*time.Hour/History {
|
||||
t.Errorf("stride at a day: %v", got)
|
||||
}
|
||||
// a full day of samples at the day stride keeps exactly the budget
|
||||
c.interval, c.window = time.Second, time.Hour
|
||||
stride := c.stride().Milliseconds()
|
||||
for i := 0; i < 5*History; i++ {
|
||||
c.keep(Sample{T: int64(i) * stride})
|
||||
}
|
||||
if len(c.ring) != History {
|
||||
t.Errorf("the point count is the budget, got %d", len(c.ring))
|
||||
}
|
||||
if span := c.ring[len(c.ring)-1].T - c.ring[0].T; span > c.window.Milliseconds() {
|
||||
t.Errorf("the kept history reaches back further than the window: %d ms", span)
|
||||
}
|
||||
// narrowing the window drops what is now too old, at once
|
||||
c.SetWindow(MinWindow)
|
||||
if span := c.ring[len(c.ring)-1].T - c.ring[0].T; span > MinWindow.Milliseconds() {
|
||||
t.Errorf("narrowing the window left %d ms of history", span)
|
||||
}
|
||||
c.SetWindow(365 * 24 * time.Hour)
|
||||
if c.Window() != MaxWindow {
|
||||
t.Errorf("a window past the maximum is the maximum, got %v", c.Window())
|
||||
}
|
||||
}
|
||||
|
||||
// Points are one stride apart — not a stride and a sample — and jitter doesn't change that.
|
||||
func TestKeepSpacing(t *testing.T) {
|
||||
c := &Collector{interval: 2 * time.Second, window: time.Hour} // stride 12 s: six samples a point
|
||||
at := int64(1_000_000)
|
||||
for i := 0; i < 6*40; i++ {
|
||||
jitter := int64((i*37)%41 - 20) // ±20 ms, deterministic
|
||||
c.keep(Sample{T: at + jitter, CPU: float64(i % 6)})
|
||||
at += 2000
|
||||
}
|
||||
if len(c.ring) != 40 {
|
||||
t.Fatalf("240 samples at six a point are 40 points, got %d", len(c.ring))
|
||||
}
|
||||
for i := 1; i < len(c.ring); i++ {
|
||||
if gap := c.ring[i].T - c.ring[i-1].T; gap < 11900 || gap > 12100 {
|
||||
t.Fatalf("point %d is %d ms after the one before; a stride is 12000", i, gap)
|
||||
}
|
||||
}
|
||||
if c.ring[3].CPU != 2.5 {
|
||||
t.Errorf("each point is the mean of its own six samples (0…5 → 2.5), got %v", c.ring[3].CPU)
|
||||
}
|
||||
// and when the stride is the interval, every sample is a point
|
||||
c = &Collector{interval: 2 * time.Second, window: DefaultWindow}
|
||||
for i := 0; i < 50; i++ {
|
||||
c.keep(Sample{T: int64(i)*2000 + int64(i%3) - 1})
|
||||
}
|
||||
if len(c.ring) != 50 {
|
||||
t.Errorf("at the default window every sample is kept, got %d of 50", len(c.ring))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldNVMe(t *testing.T) {
|
||||
// an Apple SSD as the kernel lists it: one controller, three namespaces
|
||||
got := foldNVMe([]Disk{
|
||||
{Name: "nvme0n1", Model: "APPLE SSD AP0512Z", Size: 500 << 30, ReadBps: 100, WriteBps: 200, UtilPct: 12, Temp: 37},
|
||||
{Name: "nvme0n2", Model: "APPLE SSD AP0512Z", Size: 3 << 20, ReadBps: 10, WriteBps: 20, UtilPct: 40},
|
||||
{Name: "nvme0n3", Model: "APPLE SSD AP0512Z", Size: 128 << 20, ReadBps: 1, WriteBps: 2, UtilPct: 5},
|
||||
{Name: "sda", Size: 100 << 30, ReadBps: 7, UtilPct: 3},
|
||||
{Name: "nvme1n1", Size: 200 << 30, ReadBps: 5},
|
||||
})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("three namespaces of one controller are one drive: %v", names(got))
|
||||
}
|
||||
d := got[0]
|
||||
if d.Name != "nvme0" {
|
||||
t.Errorf("the drive is the controller, got %q", d.Name)
|
||||
}
|
||||
if d.Size != 500<<30+3<<20+128<<20 {
|
||||
t.Errorf("its size is all of its namespaces, got %d", d.Size)
|
||||
}
|
||||
if d.ReadBps != 111 || d.WriteBps != 222 {
|
||||
t.Errorf("rates add up, got %v/%v", d.ReadBps, d.WriteBps)
|
||||
}
|
||||
if d.UtilPct != 40 {
|
||||
t.Errorf("busy is the longest of them, not the sum: got %v", d.UtilPct)
|
||||
}
|
||||
if d.Temp != 37 {
|
||||
t.Errorf("a temperature from any namespace is the drive's, got %v", d.Temp)
|
||||
}
|
||||
if got[1].Name != "sda" || got[1].UtilPct != 3 || got[2].Name != "nvme1" {
|
||||
t.Errorf("everything else is left alone: %v", names(got))
|
||||
}
|
||||
if n := len(foldNVMe(nil)); n != 0 {
|
||||
t.Errorf("no disks, no drives, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func names(d []Disk) []string {
|
||||
out := make([]string, len(d))
|
||||
for i, x := range d {
|
||||
out[i] = x.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"hamlab/internal/sensors"
|
||||
)
|
||||
|
||||
// The collector as a sensors.Provider: every number in a Sample, one sensor each. The
|
||||
// IDs are built from names the hardware reports (chip, label, interface, device, mount),
|
||||
// never from enumeration order like hwmon3, which moves between boots.
|
||||
|
||||
// flattener turns one Sample into sensors. With want set it builds only those, so walking
|
||||
// the whole ring for a history costs string compares, not allocations.
|
||||
type flattener struct {
|
||||
want func(id string) bool
|
||||
seen map[string]int
|
||||
out []sensors.Sensor
|
||||
}
|
||||
|
||||
type bounds struct{ min, max, warn, crit *float64 }
|
||||
|
||||
var pct = bounds{min: sensors.F(0), max: sensors.F(100)}
|
||||
|
||||
func upTo(max float64) bounds { return bounds{min: sensors.F(0), max: sensors.F(max)} }
|
||||
|
||||
func (f *flattener) add(id, name, kind, unit string, v any, t int64, b bounds, labels ...string) {
|
||||
f.seen[id]++
|
||||
if n := f.seen[id]; n > 1 { // two identical chips: the second is coretemp_2
|
||||
id = fmt.Sprintf("%s_%d", id, n)
|
||||
}
|
||||
if f.want != nil && !f.want(id) {
|
||||
return
|
||||
}
|
||||
s := sensors.Sensor{ID: id, Name: name, Kind: kind, Unit: unit, Value: v, T: t, Min: b.min, Max: b.max, Warn: b.warn, Crit: b.crit}
|
||||
if len(labels) > 0 {
|
||||
s.Labels = make(map[string]string, len(labels)/2)
|
||||
for i := 0; i+1 < len(labels); i += 2 {
|
||||
s.Labels[labels[i]] = labels[i+1]
|
||||
}
|
||||
}
|
||||
f.out = append(f.out, s)
|
||||
}
|
||||
|
||||
func ratio(part, whole uint64) float64 {
|
||||
if whole == 0 {
|
||||
return 0
|
||||
}
|
||||
return round1(float64(part) / float64(whole) * 100)
|
||||
}
|
||||
|
||||
func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
||||
f := &flattener{want: want, seen: map[string]int{}}
|
||||
t, none := s.T, bounds{}
|
||||
|
||||
f.add("cpu.usage", "CPU", "usage", "%", s.CPU, t, pct)
|
||||
for i, c := range s.Cores {
|
||||
n := strconv.Itoa(i)
|
||||
f.add("cpu.core."+n+".usage", "Core "+n, "usage", "%", c, t, pct, "core", n)
|
||||
}
|
||||
if c := cpuTemp(s.Temps); c > 0 {
|
||||
f.add("cpu.temp", "CPU temperature", "temperature", "°C", c, t, none)
|
||||
}
|
||||
for i, w := range []string{"1", "5", "15"} {
|
||||
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
|
||||
}
|
||||
f.add("uptime", "Uptime", "duration", "s", s.Uptime, t, none)
|
||||
f.add("hamlab.sample_interval", "Sampling interval", "duration", "ms", float64(s.Interval), t, none)
|
||||
|
||||
for _, m := range []struct {
|
||||
id, name string
|
||||
v Mem
|
||||
}{{"mem", "Memory", s.Mem}, {"swap", "Swap", s.Swap}} {
|
||||
f.add(m.id+".total", m.name+" total", "bytes", "B", float64(m.v.Total), t, none)
|
||||
f.add(m.id+".used", m.name+" used", "bytes", "B", float64(m.v.Used), t, upTo(float64(m.v.Total)))
|
||||
f.add(m.id+".avail", m.name+" available", "bytes", "B", float64(m.v.Avail), t, upTo(float64(m.v.Total)))
|
||||
f.add(m.id+".used_pct", m.name+" used", "usage", "%", ratio(m.v.Used, m.v.Total), t, pct)
|
||||
}
|
||||
f.add("mem.free", "Memory free", "bytes", "B", float64(s.Mem.Free), t, upTo(float64(s.Mem.Total)))
|
||||
f.add("mem.cached", "Memory cached", "bytes", "B", float64(s.Mem.Cached), t, upTo(float64(s.Mem.Total)))
|
||||
f.add("mem.buffers", "Memory buffers", "bytes", "B", float64(s.Mem.Buffers), t, upTo(float64(s.Mem.Total)))
|
||||
|
||||
for _, x := range s.Temps {
|
||||
b := bounds{}
|
||||
if x.Max > 0 {
|
||||
b.warn = sensors.F(x.Max)
|
||||
}
|
||||
if x.Crit > 0 {
|
||||
b.crit = sensors.F(x.Crit)
|
||||
}
|
||||
f.add(sensors.ID("temp", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "temperature", "°C", x.C, t, b, "chip", x.Chip, "label", x.Label)
|
||||
}
|
||||
for _, x := range s.Fans {
|
||||
f.add(sensors.ID("fan", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "fan", "rpm", float64(x.RPM), t, none, "chip", x.Chip, "label", x.Label)
|
||||
}
|
||||
|
||||
for _, x := range s.Net {
|
||||
p := "net." + sensors.Seg(x.Iface)
|
||||
f.add(p+".rx", x.Iface+" receive", "rate", "B/s", x.RxBps, t, none, "iface", x.Iface)
|
||||
f.add(p+".tx", x.Iface+" transmit", "rate", "B/s", x.TxBps, t, none, "iface", x.Iface)
|
||||
f.add(p+".rx_total", x.Iface+" received", "bytes", "B", float64(x.RxTotal), t, none, "iface", x.Iface)
|
||||
f.add(p+".tx_total", x.Iface+" transmitted", "bytes", "B", float64(x.TxTotal), t, none, "iface", x.Iface)
|
||||
f.add(p+".up", x.Iface+" link", "state", "", x.Up, t, none, "iface", x.Iface)
|
||||
}
|
||||
|
||||
for _, x := range s.Disks {
|
||||
p := "disk." + sensors.Seg(x.Name)
|
||||
f.add(p+".read", x.Name+" read", "rate", "B/s", x.ReadBps, t, none, "device", x.Name, "model", x.Model)
|
||||
f.add(p+".write", x.Name+" write", "rate", "B/s", x.WriteBps, t, none, "device", x.Name, "model", x.Model)
|
||||
f.add(p+".util", x.Name+" busy", "usage", "%", x.UtilPct, t, pct, "device", x.Name, "model", x.Model)
|
||||
f.add(p+".size", x.Name+" size", "bytes", "B", float64(x.Size), t, none, "device", x.Name, "model", x.Model)
|
||||
if x.Temp > 0 {
|
||||
f.add(p+".temp", x.Name+" temperature", "temperature", "°C", x.Temp, t, none, "device", x.Name, "model", x.Model)
|
||||
}
|
||||
}
|
||||
|
||||
for _, x := range s.FS {
|
||||
p := "fs." + sensors.Seg(x.Mount)
|
||||
// remote says the bytes are on another machine (a CIFS or NFS share), so a view of
|
||||
// this machine's hardware can leave it out without losing the sensor
|
||||
lbl := []string{"mount", x.Mount, "fstype", x.Type}
|
||||
if x.Remote {
|
||||
lbl = append(lbl, "remote", "1")
|
||||
}
|
||||
if x.Disk != "" {
|
||||
lbl = append(lbl, "disk", x.Disk)
|
||||
}
|
||||
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
|
||||
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
|
||||
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
||||
}
|
||||
|
||||
if u := s.UPS; u != nil {
|
||||
ut := s.upsT
|
||||
f.add("ups.battery", "UPS battery", "usage", "%", u.BatteryPct, ut, pct, "model", u.Model)
|
||||
f.add("ups.runtime", "UPS runtime", "duration", "min", u.RuntimeMin, ut, none, "model", u.Model)
|
||||
f.add("ups.load", "UPS load", "power", "W", u.LoadW, ut, none, "model", u.Model)
|
||||
f.add("ups.load_pct", "UPS load", "usage", "%", u.LoadPct, ut, pct, "model", u.Model)
|
||||
f.add("ups.in_v", "UPS input", "voltage", "V", u.InV, ut, none, "model", u.Model)
|
||||
f.add("ups.out_v", "UPS output", "voltage", "V", u.OutV, ut, none, "model", u.Model)
|
||||
f.add("ups.on_battery", "UPS on battery", "state", "", u.OnBattery, ut, none, "model", u.Model)
|
||||
f.add("ups.state", "UPS state", "state", "", u.State, ut, none, "model", u.Model)
|
||||
f.add("ups.source", "UPS power source", "state", "", u.Source, ut, none, "model", u.Model)
|
||||
f.add("ups.last_event", "UPS last event", "state", "", u.LastEvent, ut, none, "model", u.Model, "driver", u.Driver)
|
||||
}
|
||||
return f.out
|
||||
}
|
||||
|
||||
// Sensors implements sensors.Provider.
|
||||
func (c *Collector) Sensors() []sensors.Sensor {
|
||||
s, ok := c.Latest()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return flatten(s, nil)
|
||||
}
|
||||
|
||||
// SensorHistories implements sensors.Historian: the ring, seen through the sensors that
|
||||
// match. A sensor that didn't exist in a sample (a disk plugged in since) simply has a
|
||||
// shorter series; nothing is zero-filled. States have no curve and are left out.
|
||||
func (c *Collector) SensorHistories(match func(id string) bool) []sensors.Series {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
by := map[string]*sensors.Series{}
|
||||
var order []string
|
||||
for _, smp := range c.ring {
|
||||
for _, x := range flatten(smp, match) {
|
||||
v, ok := x.Value.(float64)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s := by[x.ID]
|
||||
if s == nil {
|
||||
s = &sensors.Series{ID: x.ID, Unit: x.Unit, T: []int64{}, V: []float64{}}
|
||||
by[x.ID] = s
|
||||
order = append(order, x.ID)
|
||||
}
|
||||
s.T, s.V = append(s.T, smp.T), append(s.V, v)
|
||||
}
|
||||
}
|
||||
out := make([]sensors.Series, 0, len(order))
|
||||
for _, id := range order {
|
||||
out = append(out, *by[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package metrics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFlatten(t *testing.T) {
|
||||
s := Sample{T: 1000, CPU: 10, Cores: []float64{5, 15}, Mem: Mem{Total: 200, Used: 50},
|
||||
Temps: []Temp{{Chip: "nvme", Label: "Composite", C: 40, Crit: 85}, {Chip: "nvme", Label: "Composite", C: 45}},
|
||||
Fans: []Fan{{Chip: "nct6775", Label: "fan2", RPM: 1100}},
|
||||
FS: []FS{{Mount: "/", Size: 100, Used: 25}}, fsT: 900}
|
||||
got := map[string]any{}
|
||||
for _, x := range flatten(s, nil) {
|
||||
if _, dup := got[x.ID]; dup {
|
||||
t.Errorf("duplicate id %s", x.ID)
|
||||
}
|
||||
got[x.ID] = x.Value
|
||||
}
|
||||
for id, want := range map[string]any{"cpu.usage": 10.0, "cpu.core.1.usage": 15.0, "mem.used_pct": 25.0, "temp.nvme.composite": 40.0,
|
||||
"temp.nvme.composite_2": 45.0, "fan.nct6775.fan2": 1100.0, "fs.root.used_pct": 25.0} {
|
||||
if got[id] != want {
|
||||
t.Errorf("%s = %v, want %v", id, got[id], want)
|
||||
}
|
||||
}
|
||||
one := flatten(s, func(id string) bool { return id == "temp.nvme.composite_2" })
|
||||
if len(one) != 1 || one[0].Value != 45.0 {
|
||||
t.Errorf("only: got %+v", one)
|
||||
}
|
||||
for _, x := range flatten(s, func(id string) bool { return id == "fs.root.used" }) {
|
||||
if x.T != 900 {
|
||||
t.Errorf("fs sensor should carry the fs read time, got %d", x.T)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensorHistories(t *testing.T) {
|
||||
c := NewCollector()
|
||||
c.ring = []Sample{{T: 1, CPU: 10}, {T: 2, CPU: 20, Net: []Net{{Iface: "eth0", Up: true, RxBps: 5}}}}
|
||||
got := map[string][]float64{}
|
||||
for _, h := range c.SensorHistories(func(id string) bool { return id == "cpu.usage" || id == "net.eth0.rx" || id == "net.eth0.up" }) {
|
||||
if len(h.T) != len(h.V) {
|
||||
t.Errorf("%s: t and v differ in length", h.ID)
|
||||
}
|
||||
got[h.ID] = h.V
|
||||
}
|
||||
if len(got["cpu.usage"]) != 2 || got["cpu.usage"][1] != 20 {
|
||||
t.Errorf("cpu: %v", got["cpu.usage"])
|
||||
}
|
||||
if len(got["net.eth0.rx"]) != 1 {
|
||||
t.Errorf("a sensor that appeared later has a shorter history, got %v", got["net.eth0.rx"])
|
||||
}
|
||||
if _, ok := got["net.eth0.up"]; ok {
|
||||
t.Error("a state has no history")
|
||||
}
|
||||
if len(c.SensorHistories(func(string) bool { return false })) != 0 {
|
||||
t.Error("nothing matched, nothing returned")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// Package nodes is the hub side of multi-host: the list of managed Ham Labs, their
|
||||
// health, adding one (pair with its password, receive a token, pin its certificate),
|
||||
// removing one (revoke the token), and the reverse proxy that turns
|
||||
// /api/nodes/<name>/… into /api/… on that node, WebSockets included.
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/config"
|
||||
"hamlab/internal/tlsauto"
|
||||
)
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
|
||||
type Status struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Added time.Time `json:"added"`
|
||||
Online bool `json:"online"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Load string `json:"load,omitempty"`
|
||||
Uptime float64 `json:"uptime,omitempty"`
|
||||
RebootNeed bool `json:"reboot_needed,omitempty"`
|
||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
hubName string
|
||||
mu sync.Mutex
|
||||
status map[string]*Status
|
||||
proxies map[string]*httputil.ReverseProxy
|
||||
clients map[string]*http.Client
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, hubName string) *Manager {
|
||||
return &Manager{cfg: cfg, hubName: hubName, status: map[string]*Status{}, proxies: map[string]*httputil.ReverseProxy{}, clients: map[string]*http.Client{}}
|
||||
}
|
||||
|
||||
func transportFor(n config.Node) *http.Transport {
|
||||
t := &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
}
|
||||
if strings.HasPrefix(n.URL, "https://") {
|
||||
if n.Fingerprint != "" {
|
||||
t.TLSClientConfig = tlsauto.PinnedConfig(n.Fingerprint)
|
||||
} else {
|
||||
t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Manager) client(n config.Node) *http.Client {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if c, ok := m.clients[n.Name]; ok {
|
||||
return c
|
||||
}
|
||||
c := &http.Client{Transport: transportFor(n), Timeout: 15 * time.Second}
|
||||
m.clients[n.Name] = c
|
||||
return c
|
||||
}
|
||||
|
||||
func (m *Manager) node(name string) (config.Node, bool) {
|
||||
var found config.Node
|
||||
ok := false
|
||||
m.cfg.View(func(c *config.Config) {
|
||||
for _, n := range c.Nodes {
|
||||
if n.Name == name {
|
||||
found, ok = n, true
|
||||
}
|
||||
}
|
||||
})
|
||||
return found, ok
|
||||
}
|
||||
|
||||
// ---- probing ----
|
||||
|
||||
func (m *Manager) probe(ctx context.Context, n config.Node) *Status {
|
||||
st := &Status{Name: n.Name, URL: n.URL, Fingerprint: n.Fingerprint, Added: n.Added}
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", strings.TrimRight(n.URL, "/")+"/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+n.Token)
|
||||
resp, err := m.client(n).Do(req)
|
||||
if err != nil {
|
||||
st.Error = shortErr(err)
|
||||
return st
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
st.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
if resp.StatusCode == 401 {
|
||||
st.Error = "token rejected (re-add the machine)"
|
||||
}
|
||||
return st
|
||||
}
|
||||
var me struct {
|
||||
Host, Version, Load string
|
||||
Uptime float64
|
||||
RebootNeeded bool `json:"reboot_needed"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&me) != nil {
|
||||
st.Error = "bad reply"
|
||||
return st
|
||||
}
|
||||
st.Online, st.Host, st.Version, st.Load, st.Uptime, st.RebootNeed, st.LastSeen = true, me.Host, me.Version, me.Load, me.Uptime, me.RebootNeeded, time.Now()
|
||||
return st
|
||||
}
|
||||
|
||||
func shortErr(err error) string {
|
||||
s := err.Error()
|
||||
if i := strings.LastIndex(s, ": "); i >= 0 {
|
||||
s = s[i+2:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Run refreshes every node's status periodically.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
m.refresh(ctx)
|
||||
t := time.NewTicker(15 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
m.refresh(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) refresh(ctx context.Context) {
|
||||
var list []config.Node
|
||||
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
|
||||
var wg sync.WaitGroup
|
||||
for _, n := range list {
|
||||
wg.Add(1)
|
||||
go func(n config.Node) {
|
||||
defer wg.Done()
|
||||
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
st := m.probe(pctx, n)
|
||||
m.mu.Lock()
|
||||
if prev, ok := m.status[n.Name]; ok && !st.Online && prev.Online {
|
||||
st.LastSeen = prev.LastSeen // keep the last good sighting
|
||||
}
|
||||
m.status[n.Name] = st
|
||||
m.mu.Unlock()
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// List returns every managed node with its last known status.
|
||||
func (m *Manager) List() []Status {
|
||||
var list []config.Node
|
||||
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Status, 0, len(list))
|
||||
for _, n := range list {
|
||||
if st, ok := m.status[n.Name]; ok {
|
||||
out = append(out, *st)
|
||||
} else {
|
||||
out = append(out, Status{Name: n.Name, URL: n.URL, Fingerprint: n.Fingerprint, Added: n.Added, Error: "not probed yet"})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- add / remove ----
|
||||
|
||||
// Add pairs with a node: pin its certificate (trust on first use), trade the node's
|
||||
// password for a token named after this hub, and store the node. The password is
|
||||
// used once and never kept.
|
||||
func (m *Manager) Add(ctx context.Context, name, rawURL, username, password string) (Status, error) {
|
||||
if !nameRe.MatchString(name) {
|
||||
return Status{}, errors.New("name: lowercase letters, digits and dashes only")
|
||||
}
|
||||
if _, exists := m.node(name); exists {
|
||||
return Status{}, errors.New("a machine with that name already exists")
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return Status{}, errors.New("address must be http(s)://host:port")
|
||||
}
|
||||
n := config.Node{Name: name, URL: u.Scheme + "://" + u.Host, Added: time.Now()}
|
||||
if u.Scheme == "https" {
|
||||
host := u.Host
|
||||
if _, _, err := net.SplitHostPort(host); err != nil {
|
||||
host += ":443"
|
||||
}
|
||||
fp, err := tlsauto.Peek(host)
|
||||
if err != nil {
|
||||
return Status{}, fmt.Errorf("connect: %s", shortErr(err))
|
||||
}
|
||||
n.Fingerprint = fp
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"username": username, "password": password, "name": "hub:" + m.hubName})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/tokens", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := (&http.Client{Transport: transportFor(n), Timeout: 15 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return Status{}, fmt.Errorf("connect: %s", shortErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var reply struct {
|
||||
Token string `json:"token"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&reply)
|
||||
if resp.StatusCode != 200 || reply.Token == "" {
|
||||
if reply.Error == "" {
|
||||
reply.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return Status{}, errors.New("pairing refused: " + reply.Error)
|
||||
}
|
||||
n.Token = reply.Token
|
||||
if err := m.cfg.Update(func(c *config.Config) { c.Nodes = append(c.Nodes, n) }); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
st := m.probe(ctx, n)
|
||||
m.mu.Lock()
|
||||
m.status[name] = st
|
||||
m.mu.Unlock()
|
||||
return *st, nil
|
||||
}
|
||||
|
||||
// Remove revokes our token on the node (best effort) and forgets it.
|
||||
func (m *Manager) Remove(ctx context.Context, name string) error {
|
||||
n, ok := m.node(name)
|
||||
if !ok {
|
||||
return errors.New("no such machine")
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, "DELETE", n.URL+"/api/tokens/"+url.PathEscape("hub:"+m.hubName), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+n.Token)
|
||||
if resp, err := m.client(n).Do(req); err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
err := m.cfg.Update(func(c *config.Config) {
|
||||
kept := c.Nodes[:0]
|
||||
for _, x := range c.Nodes {
|
||||
if x.Name != name {
|
||||
kept = append(kept, x)
|
||||
}
|
||||
}
|
||||
c.Nodes = kept
|
||||
})
|
||||
m.mu.Lock()
|
||||
delete(m.status, name)
|
||||
delete(m.proxies, name)
|
||||
delete(m.clients, name)
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- update from hub ----
|
||||
|
||||
type PushResult struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Push uploads binPath (normally the hub's own executable) to the node's self-update
|
||||
// endpoint. The node verifies it, installs it and restarts itself. Refused when the
|
||||
// node's architecture differs from ours.
|
||||
func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string, force bool) PushResult {
|
||||
res := PushResult{Name: name}
|
||||
n, ok := m.node(name)
|
||||
if !ok {
|
||||
res.Error = "no such machine"
|
||||
return res
|
||||
}
|
||||
// arch check via /api/me
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", n.URL+"/api/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+n.Token)
|
||||
resp, err := m.client(n).Do(req)
|
||||
if err != nil {
|
||||
res.Error = "unreachable: " + shortErr(err)
|
||||
return res
|
||||
}
|
||||
var me struct {
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&me)
|
||||
resp.Body.Close()
|
||||
if me.Arch != "" && ourArch != "" && me.Arch != ourArch {
|
||||
// look for a sibling build: /usr/local/bin/hamlab-arm64 next to /usr/local/bin/hamlab
|
||||
alt := binPath + "-" + me.Arch
|
||||
if _, err := os.Stat(alt); err != nil {
|
||||
res.Error = fmt.Sprintf("node is %s but only a %s build is installed here (expected %s; run make deploy, which builds it)", me.Arch, ourArch, alt)
|
||||
return res
|
||||
}
|
||||
binPath = alt
|
||||
}
|
||||
f, err := os.Open(binPath)
|
||||
if err != nil {
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
defer f.Close()
|
||||
st, _ := f.Stat()
|
||||
target := n.URL + "/api/self-update"
|
||||
if force {
|
||||
target += "?force=1"
|
||||
}
|
||||
up, _ := http.NewRequestWithContext(ctx, "POST", target, f)
|
||||
up.ContentLength = st.Size()
|
||||
up.Header.Set("Authorization", "Bearer "+n.Token)
|
||||
up.Header.Set("Content-Type", "application/octet-stream")
|
||||
c := &http.Client{Transport: transportFor(n), Timeout: 5 * time.Minute}
|
||||
r2, err := c.Do(up)
|
||||
if err != nil {
|
||||
res.Error = "upload: " + shortErr(err)
|
||||
return res
|
||||
}
|
||||
defer r2.Body.Close()
|
||||
var reply struct {
|
||||
Version string `json:"version"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(r2.Body, 64<<10)).Decode(&reply)
|
||||
if r2.StatusCode != 200 {
|
||||
if reply.Error == "" {
|
||||
reply.Error = fmt.Sprintf("HTTP %d", r2.StatusCode)
|
||||
}
|
||||
res.Error = reply.Error
|
||||
return res
|
||||
}
|
||||
res.OK, res.Version = true, reply.Version
|
||||
return res
|
||||
}
|
||||
|
||||
// PushAll updates every node, in parallel.
|
||||
func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bool) []PushResult {
|
||||
var list []config.Node
|
||||
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
|
||||
out := make([]PushResult, len(list))
|
||||
var wg sync.WaitGroup
|
||||
for i, n := range list {
|
||||
wg.Add(1)
|
||||
go func(i int, name string) {
|
||||
defer wg.Done()
|
||||
out[i] = m.Push(ctx, name, binPath, ourArch, force)
|
||||
}(i, n.Name)
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- proxy ----
|
||||
|
||||
// Proxy returns the handler for /api/nodes/<name>/<rest>: it rewrites to <node>/api/<rest>,
|
||||
// swaps the hub's cookie for the node's bearer token, and drops Origin so the node's
|
||||
// WebSocket origin check (meant for browsers) doesn't reject the hub.
|
||||
// Do makes one request to a managed machine as this hub and returns the whole response.
|
||||
func (m *Manager) Do(ctx context.Context, name, method, path string, body []byte, hdr http.Header) (int, []byte, error) {
|
||||
n, ok := m.node(name)
|
||||
if !ok {
|
||||
return 0, nil, errors.New("no such machine")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(n.URL, "/")+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
for k, v := range hdr {
|
||||
req.Header[k] = v
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+n.Token)
|
||||
resp, err := m.client(n).Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, errors.New(shortErr(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
return resp.StatusCode, b, err
|
||||
}
|
||||
|
||||
// Names lists the managed machines.
|
||||
func (m *Manager) Names() []string {
|
||||
var out []string
|
||||
m.cfg.View(func(c *config.Config) {
|
||||
for _, n := range c.Nodes {
|
||||
out = append(out, n.Name)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) Proxy(name string) (http.Handler, bool) {
|
||||
n, ok := m.node(name)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
m.mu.Lock()
|
||||
if p, ok := m.proxies[name]; ok {
|
||||
m.mu.Unlock()
|
||||
return p, true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
target, _ := url.Parse(n.URL)
|
||||
prefix := "/api/nodes/" + name
|
||||
p := &httputil.ReverseProxy{
|
||||
Director: func(r *http.Request) {
|
||||
r.URL.Scheme, r.URL.Host = target.Scheme, target.Host
|
||||
r.URL.Path = "/api" + strings.TrimPrefix(r.URL.Path, prefix)
|
||||
r.URL.RawPath = ""
|
||||
r.Host = target.Host
|
||||
r.Header.Set("Authorization", "Bearer "+n.Token)
|
||||
r.Header.Del("Cookie")
|
||||
r.Header.Del("Origin")
|
||||
r.Header.Del("X-Forwarded-For")
|
||||
r.Header.Del("X-Forwarded-Proto")
|
||||
},
|
||||
Transport: transportFor(n),
|
||||
FlushInterval: -1, // stream task output / log tails immediately
|
||||
// The node answers with its own policy headers and this hub adds the same ones to
|
||||
// everything it serves; relayed twice, Access-Control-Allow-Origin is invalid and a
|
||||
// browser on another origin refuses the response. The hub's copy is the one that counts.
|
||||
ModifyResponse: func(resp *http.Response) error {
|
||||
for _, h := range []string{"Access-Control-Allow-Origin", "X-Frame-Options", "Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy"} {
|
||||
resp.Header.Del(h)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": name + " unreachable: " + shortErr(err)})
|
||||
},
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.proxies[name] = p
|
||||
m.mu.Unlock()
|
||||
return p, true
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package priv is the one place that knows how this daemon gets root: it already is root,
|
||||
// or it has passwordless sudo, or passwordless doas (Alpine, some BSD-minded setups), or
|
||||
// it has nothing and the privileged features stay off.
|
||||
package priv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
mode string // "root" | "sudo" | "doas" | ""
|
||||
prefix []string
|
||||
probed time.Time
|
||||
reprobe = 30 * time.Second
|
||||
)
|
||||
|
||||
// detect settles how root is reached. Finding a way is remembered for good; finding none
|
||||
// is only believed for a while, because the probe can fail for reasons that pass — sudo
|
||||
// taking longer than the timeout on a machine that is still booting left a daemon
|
||||
// convinced it had no root until its next restart.
|
||||
func detect() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if mode != "" || (!probed.IsZero() && time.Since(probed) < reprobe) {
|
||||
return
|
||||
}
|
||||
probed = time.Now()
|
||||
if os.Geteuid() == 0 {
|
||||
mode = "root"
|
||||
return
|
||||
}
|
||||
for _, tool := range []string{"sudo", "doas"} {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err := exec.CommandContext(ctx, tool, "-n", "true").Run()
|
||||
cancel()
|
||||
if err == nil {
|
||||
mode, prefix = tool, []string{tool, "-n"}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode reports how root is reached: "root", "sudo", "doas", or "" when it can't be.
|
||||
func Mode() string { detect(); mu.Lock(); defer mu.Unlock(); return mode }
|
||||
|
||||
// Available reports whether commands can be run as root without a prompt.
|
||||
func Available() bool { return Mode() != "" }
|
||||
|
||||
// Wrap returns args prefixed so they run as root. Without privilege it returns args
|
||||
// unchanged; callers gate on Available() where that matters.
|
||||
func Wrap(args ...string) []string {
|
||||
detect()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append(append([]string{}, prefix...), args...)
|
||||
}
|
||||
|
||||
// AsUser returns args prefixed so they run as another user with that user's HOME.
|
||||
func AsUser(user string, args ...string) []string {
|
||||
switch Mode() {
|
||||
case "sudo":
|
||||
return append([]string{"sudo", "-n", "-u", user, "-H"}, args...)
|
||||
case "doas":
|
||||
return append([]string{"doas", "-n", "-u", user}, args...)
|
||||
case "root":
|
||||
if user == "root" {
|
||||
return args
|
||||
}
|
||||
// su exists everywhere (util-linux, shadow, busybox), needs no password from root
|
||||
// and sets HOME for the target user.
|
||||
return []string{"su", "-s", "/bin/sh", user, "-c", Quote(args)}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Quote joins args into one POSIX shell command line.
|
||||
func Quote(args []string) string {
|
||||
q := make([]string, len(args))
|
||||
for i, a := range args {
|
||||
q[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
|
||||
}
|
||||
return strings.Join(q, " ")
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Package selfupdate finds out whether a newer Ham Lab has been published and fetches it.
|
||||
//
|
||||
// A source is one URL. Two shapes are understood, told apart by the URL itself:
|
||||
//
|
||||
// - a Gitea/Forgejo repository API base — ".../api/v1/repos/<owner>/<repo>" — whose
|
||||
// newest release names the version and carries the binaries as assets;
|
||||
// - any other URL, treated as a static directory holding VERSION, SHA256SUMS and
|
||||
// hamlab-linux-<arch>, which is exactly what `make release` writes and what
|
||||
// install.sh already downloads.
|
||||
//
|
||||
// Either way the binary is checked against SHA256SUMS before it is allowed near the
|
||||
// running one. A token is sent when the source is behind a login, so a private Gitea
|
||||
// serves its own fleet without the instance being opened to the world.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultCheckEvery is how often a daemon asks, when it is asking at all.
|
||||
const DefaultCheckEvery = 24 * time.Hour
|
||||
|
||||
// DefaultURL is where a build comes from when the config does not say: the project's own
|
||||
// releases. An install elsewhere points `update.url` at its own, or at "off".
|
||||
const DefaultURL = "https://git.bigham.dev/api/v1/repos/mitchell/hamlab"
|
||||
|
||||
// maxBinary is the most we will download; a hamlab binary is ~20 MB.
|
||||
const maxBinary = 512 << 20
|
||||
|
||||
// Source is where updates come from.
|
||||
type Source struct {
|
||||
URL string // API base or static directory
|
||||
Token string // optional; for a source behind a login
|
||||
}
|
||||
|
||||
// Release is one published build.
|
||||
type Release struct {
|
||||
Version string `json:"version"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Published time.Time `json:"published,omitempty"`
|
||||
// Binary and Sums are where to get this build for the architecture that was asked for.
|
||||
Binary string `json:"-"`
|
||||
Sums string `json:"-"`
|
||||
}
|
||||
|
||||
// AssetName is what a build for an architecture is called, in a release and on disk.
|
||||
func AssetName(arch string) string { return "hamlab-linux-" + arch }
|
||||
|
||||
func (s Source) api() bool { return strings.Contains(s.URL, "/api/v1/repos/") }
|
||||
|
||||
func (s Source) get(ctx context.Context, url string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.Token != "" {
|
||||
req.Header.Set("Authorization", "token "+s.Token)
|
||||
}
|
||||
req.Header.Set("User-Agent", "hamlab-selfupdate")
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
switch resp.StatusCode {
|
||||
case 401, 403:
|
||||
return nil, fmt.Errorf("%s: the update source needs a login (HTTP %d); set update.token", url, resp.StatusCode)
|
||||
case 404:
|
||||
return nil, fmt.Errorf("%s: nothing published there (HTTP 404)", url)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s Source) getAll(ctx context.Context, url string, limit int64) ([]byte, error) {
|
||||
resp, err := s.get(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
}
|
||||
|
||||
// Latest is the newest published build, and where to get it for arch.
|
||||
func (s Source) Latest(ctx context.Context, arch string) (Release, error) {
|
||||
if s.URL == "" {
|
||||
return Release{}, errors.New("no update source configured")
|
||||
}
|
||||
base := strings.TrimSuffix(s.URL, "/")
|
||||
if !s.api() {
|
||||
b, err := s.getAll(ctx, base+"/VERSION", 4096)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
v := strings.TrimSpace(strings.SplitN(string(b), "\n", 2)[0])
|
||||
if v == "" {
|
||||
return Release{}, errors.New("the source published an empty VERSION")
|
||||
}
|
||||
return Release{Version: v, Binary: base + "/" + AssetName(arch), Sums: base + "/SHA256SUMS"}, nil
|
||||
}
|
||||
b, err := s.getAll(ctx, base+"/releases/latest", 4<<20)
|
||||
if err != nil {
|
||||
return Release{}, err
|
||||
}
|
||||
var rel struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &rel); err != nil {
|
||||
return Release{}, fmt.Errorf("the source did not answer with a release: %w", err)
|
||||
}
|
||||
if rel.TagName == "" {
|
||||
return Release{}, errors.New("the newest release has no tag")
|
||||
}
|
||||
out := Release{Version: rel.TagName, Notes: rel.Body, Published: rel.PublishedAt}
|
||||
want := AssetName(arch)
|
||||
for _, a := range rel.Assets {
|
||||
switch a.Name {
|
||||
case want:
|
||||
out.Binary = a.URL
|
||||
case "SHA256SUMS":
|
||||
out.Sums = a.URL
|
||||
}
|
||||
}
|
||||
if out.Binary == "" {
|
||||
return out, fmt.Errorf("release %s has no %s", rel.TagName, want)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Fetch downloads the build for arch to dest and checks it against SHA256SUMS. A release
|
||||
// without sums is refused: an unverified binary is not worth installing over a working one.
|
||||
func (s Source) Fetch(ctx context.Context, rel Release, arch, dest string) error {
|
||||
if rel.Binary == "" {
|
||||
return errors.New("that release has no binary for this machine")
|
||||
}
|
||||
if rel.Sums == "" {
|
||||
return errors.New("that release has no SHA256SUMS, so the download cannot be verified")
|
||||
}
|
||||
sums, err := s.getAll(ctx, rel.Sums, 1<<20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want := sumFor(string(sums), AssetName(arch))
|
||||
if want == "" {
|
||||
return fmt.Errorf("SHA256SUMS has no entry for %s", AssetName(arch))
|
||||
}
|
||||
resp, err := s.get(ctx, rel.Binary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
f, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, maxBinary))
|
||||
cerr := f.Close()
|
||||
if err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(dest)
|
||||
return err
|
||||
}
|
||||
if n < 1<<20 {
|
||||
os.Remove(dest)
|
||||
return fmt.Errorf("downloaded %d bytes: too small to be hamlab", n)
|
||||
}
|
||||
if got := hex.EncodeToString(h.Sum(nil)); got != want {
|
||||
os.Remove(dest)
|
||||
return fmt.Errorf("checksum mismatch: SHA256SUMS says %s, the download is %s", want[:16]+"…", got[:16]+"…")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sumFor finds a file's checksum in SHA256SUMS ("<hex> <name>", or "*<name>" for binary mode).
|
||||
func sumFor(sums, name string) string {
|
||||
for _, line := range strings.Split(sums, "\n") {
|
||||
f := strings.Fields(strings.TrimSpace(line))
|
||||
if len(f) == 2 && (f[1] == name || f[1] == "*"+name) && len(f[0]) == 64 {
|
||||
return strings.ToLower(f[0])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Compare orders two versions: -1 if a is older than b, 0 if the same, 1 if newer.
|
||||
// It is install.sh's vnum() rule, so the daemon and the installer never disagree about
|
||||
// which way round two builds go: v0.1.0-69-g9c4f9df → 0001000069, the commits since the
|
||||
// tag included, the hash ignored.
|
||||
func Compare(a, b string) int {
|
||||
na, oka := vnum(a)
|
||||
nb, okb := vnum(b)
|
||||
switch {
|
||||
case !oka && !okb:
|
||||
return strings.Compare(a, b)
|
||||
case !oka:
|
||||
return -1
|
||||
case !okb:
|
||||
return 1
|
||||
case na < nb:
|
||||
return -1
|
||||
case na > nb:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func vnum(v string) (int64, bool) {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
if i := strings.LastIndex(v, "-g"); i >= 0 && isHex(v[i+2:]) {
|
||||
v = v[:i]
|
||||
}
|
||||
if v == "" {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.FieldsFunc(v, func(r rune) bool { return r == '.' || r == '-' })
|
||||
nums := make([]int64, 4)
|
||||
for i := 0; i < 4 && i < len(parts); i++ {
|
||||
n, err := strconv.ParseInt(parts[i], 10, 64)
|
||||
if err != nil {
|
||||
if i == 0 {
|
||||
return 0, false
|
||||
}
|
||||
break
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
// %d%03d%03d%05d, as the installer does it
|
||||
return ((nums[0]*1000+nums[1])*1000+nums[2])*100000 + nums[3], true
|
||||
}
|
||||
|
||||
func isHex(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !strings.ContainsRune("0123456789abcdefABCDEF", r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompareMatchesInstaller(t *testing.T) {
|
||||
older := []([2]string){
|
||||
{"v0.1.0-69-g9c4f9df", "v0.1.0-107-gad88a48"},
|
||||
{"v0.1.0", "v0.1.0-1-gabc1234"},
|
||||
{"v0.1.0-107", "v0.2.0"},
|
||||
{"v0.9.9-999", "v1.0.0"},
|
||||
{"v0.1.9", "v0.1.10"},
|
||||
}
|
||||
for _, p := range older {
|
||||
if got := Compare(p[0], p[1]); got != -1 {
|
||||
t.Errorf("Compare(%q, %q) = %d, want -1", p[0], p[1], got)
|
||||
}
|
||||
if got := Compare(p[1], p[0]); got != 1 {
|
||||
t.Errorf("Compare(%q, %q) = %d, want 1", p[1], p[0], got)
|
||||
}
|
||||
}
|
||||
same := []string{"v0.1.0-107-gad88a48", "v1.2.3", "dev"}
|
||||
for _, v := range same {
|
||||
if got := Compare(v, v); got != 0 {
|
||||
t.Errorf("Compare(%q, %q) = %d, want 0", v, v, got)
|
||||
}
|
||||
}
|
||||
// the hash is ignored: the same build described two ways is the same build
|
||||
if got := Compare("v0.1.0-107-gad88a48", "v0.1.0-107-gffffff1"); got != 0 {
|
||||
t.Errorf("hash should not count: %d", got)
|
||||
}
|
||||
// "dev" is not a version; anything numbered beats it
|
||||
if got := Compare("dev", "v0.1.0"); got != -1 {
|
||||
t.Errorf("Compare(dev, v0.1.0) = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// serve puts a release behind both shapes of source and returns the two base URLs.
|
||||
func serve(t *testing.T, version string, body []byte, token string) (static, api string, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(body)
|
||||
sums := fmt.Sprintf("%s %s\n%s install.sh\n", hex.EncodeToString(sum[:]), AssetName("amd64"), strings.Repeat("0", 64))
|
||||
mux := http.NewServeMux()
|
||||
guard := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if token != "" && r.Header.Get("Authorization") != "token "+token {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
mux.HandleFunc("/dl/VERSION", guard(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, version) }))
|
||||
mux.HandleFunc("/dl/SHA256SUMS", guard(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums) }))
|
||||
mux.HandleFunc("/dl/"+AssetName("amd64"), guard(func(w http.ResponseWriter, r *http.Request) { w.Write(body) }))
|
||||
mux.HandleFunc("/api/v1/repos/m/hamlab/releases/latest", guard(func(w http.ResponseWriter, r *http.Request) {
|
||||
base := "http://" + r.Host + "/dl/"
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"tag_name": version, "body": "notes here",
|
||||
"assets": []map[string]string{
|
||||
{"name": AssetName("amd64"), "browser_download_url": base + AssetName("amd64")},
|
||||
{"name": "SHA256SUMS", "browser_download_url": base + "SHA256SUMS"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
srv = httptest.NewServer(mux)
|
||||
return srv.URL + "/dl", srv.URL + "/api/v1/repos/m/hamlab", srv
|
||||
}
|
||||
|
||||
func TestLatestAndFetch(t *testing.T) {
|
||||
body := make([]byte, 2<<20)
|
||||
copy(body, "ELF fake hamlab")
|
||||
static, api, srv := serve(t, "v0.9.0-3-gdeadbee", body, "")
|
||||
defer srv.Close()
|
||||
|
||||
for _, url := range []string{static, api} {
|
||||
s := Source{URL: url}
|
||||
rel, err := s.Latest(context.Background(), "amd64")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", url, err)
|
||||
}
|
||||
if rel.Version != "v0.9.0-3-gdeadbee" {
|
||||
t.Fatalf("%s: version %q", url, rel.Version)
|
||||
}
|
||||
dest := filepath.Join(t.TempDir(), "hamlab.new")
|
||||
if err := s.Fetch(context.Background(), rel, "amd64", dest); err != nil {
|
||||
t.Fatalf("%s: fetch: %v", url, err)
|
||||
}
|
||||
got, _ := os.ReadFile(dest)
|
||||
if len(got) != len(body) {
|
||||
t.Fatalf("%s: got %d bytes, want %d", url, len(got), len(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusesBadChecksum(t *testing.T) {
|
||||
body := make([]byte, 2<<20)
|
||||
static, _, srv := serve(t, "v1.0.0", body, "")
|
||||
defer srv.Close()
|
||||
s := Source{URL: static}
|
||||
rel, err := s.Latest(context.Background(), "amd64")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel.Sums = static + "/VERSION" // sums that name nothing
|
||||
dest := filepath.Join(t.TempDir(), "hamlab.new")
|
||||
if err := s.Fetch(context.Background(), rel, "amd64", dest); err == nil {
|
||||
t.Fatal("a release whose sums do not name the binary must be refused")
|
||||
}
|
||||
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||
t.Fatal("a refused download must not be left on disk")
|
||||
}
|
||||
// and an arch nobody published
|
||||
if _, err := s.Latest(context.Background(), "riscv64"); err == nil {
|
||||
t.Log("static sources cannot know an arch is missing until the fetch; that is fine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIsSentAndMissingTokenSaysSo(t *testing.T) {
|
||||
body := make([]byte, 2<<20)
|
||||
_, api, srv := serve(t, "v1.0.0", body, "s3cret")
|
||||
defer srv.Close()
|
||||
if _, err := (Source{URL: api}).Latest(context.Background(), "amd64"); err == nil || !strings.Contains(err.Error(), "update.token") {
|
||||
t.Fatalf("a walled source should say a token is needed, got %v", err)
|
||||
}
|
||||
rel, err := (Source{URL: api, Token: "s3cret"}).Latest(context.Background(), "amd64")
|
||||
if err != nil || rel.Version != "v1.0.0" {
|
||||
t.Fatalf("with the token: %v %+v", err, rel)
|
||||
}
|
||||
dest := filepath.Join(t.TempDir(), "hamlab.new")
|
||||
if err := (Source{URL: api, Token: "s3cret"}).Fetch(context.Background(), rel, "amd64", dest); err != nil {
|
||||
t.Fatalf("fetch with token: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package sensors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// promFamily names the metric family for a sensor and scales its value to the base unit
|
||||
// Prometheus convention wants (seconds, not minutes).
|
||||
func promFamily(s Sensor) (name string, scale float64) {
|
||||
scale = 1
|
||||
unit := map[string]string{"%": "percent", "°C": "celsius", "rpm": "rpm", "B/s": "bytes_per_second", "B": "bytes",
|
||||
"s": "seconds", "V": "volts", "W": "watts", "MHz": "megahertz"}[s.Unit]
|
||||
if s.Unit == "min" {
|
||||
unit, scale = "seconds", 60
|
||||
}
|
||||
if s.Kind == "count" { // hamlab_count{id=…} would say nothing; name it after the thing counted
|
||||
return "hamlab_" + strings.ReplaceAll(Seg(strings.ReplaceAll(s.ID, ".", "_")), "-", "_"), 1
|
||||
}
|
||||
name = "hamlab_" + s.Kind
|
||||
if unit != "" {
|
||||
name += "_" + unit
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var promEsc = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`)
|
||||
|
||||
// WriteProm writes sensors in the Prometheus text exposition format: one family per kind
|
||||
// of measurement, the sensor's ID and its real-world labels as labels. Booleans are 0/1;
|
||||
// a text state is a 1 carrying the text as a label, the usual way to export an enum.
|
||||
func WriteProm(w io.Writer, all []Sensor) {
|
||||
fams := map[string][]string{}
|
||||
for _, s := range all {
|
||||
name, scale := promFamily(s)
|
||||
labels := []string{`id="` + promEsc.Replace(s.ID) + `"`}
|
||||
keys := make([]string, 0, len(s.Labels))
|
||||
for k := range s.Labels {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if s.Labels[k] != "" {
|
||||
labels = append(labels, Seg(k)+`="`+promEsc.Replace(s.Labels[k])+`"`)
|
||||
}
|
||||
}
|
||||
var v float64
|
||||
switch x := s.Value.(type) {
|
||||
case float64:
|
||||
v = x * scale
|
||||
case bool:
|
||||
if x {
|
||||
v = 1
|
||||
}
|
||||
case string:
|
||||
v = 1
|
||||
labels = append(labels, `value="`+promEsc.Replace(x)+`"`)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
fams[name] = append(fams[name], fmt.Sprintf("%s{%s} %v", name, strings.Join(labels, ","), v))
|
||||
}
|
||||
names := make([]string, 0, len(fams))
|
||||
for n := range fams {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(w, "# TYPE %s gauge\n", n)
|
||||
for _, line := range fams[n] {
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Package sensors is the one standard way anything reads what Ham Lab measures.
|
||||
//
|
||||
// Every measurable thing is a Sensor: a stable dotted ID ("cpu.core.3.usage",
|
||||
// "fan.nct6775.fan2", "fs.root.used_pct"), a little metadata, and a current value. A
|
||||
// consumer discovers them from the catalog, then asks for exactly the ones it wants —
|
||||
// one by ID, many by glob, or as a shaped query where the client sends the object it
|
||||
// wants and gets it back with the values filled in. See SENSORS.md.
|
||||
//
|
||||
// Sources of sensors are Providers. The registry only joins them; it measures nothing.
|
||||
package sensors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sensor is one measurable thing and its reading. Value is a float64, bool or string.
|
||||
type Sensor struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // human label
|
||||
Kind string `json:"kind"` // usage | load | temperature | fan | rate | bytes | duration | voltage | power | state
|
||||
Unit string `json:"unit,omitempty"` // % · °C · rpm · B/s · B · s · min · V · W · MHz
|
||||
Value any `json:"value"`
|
||||
T int64 `json:"t"` // unix ms the value was read — slow sensors are older than the tick
|
||||
Min *float64 `json:"min,omitempty"`
|
||||
Max *float64 `json:"max,omitempty"`
|
||||
Warn *float64 `json:"warn,omitempty"` // the hardware's own thresholds, where it states them
|
||||
Crit *float64 `json:"crit,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"` // the real names behind the ID: chip, iface, mount…
|
||||
}
|
||||
|
||||
// Series is a numeric sensor's recent past.
|
||||
type Series struct {
|
||||
ID string `json:"id"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
T []int64 `json:"t"`
|
||||
V []float64 `json:"v"`
|
||||
}
|
||||
|
||||
// Provider is a source of sensors. Sensors returns every sensor it has, with current values.
|
||||
type Provider interface {
|
||||
Sensors() []Sensor
|
||||
}
|
||||
|
||||
// Historian is a Provider that also remembers: the recent past of every numeric sensor
|
||||
// of its own that match accepts.
|
||||
type Historian interface {
|
||||
SensorHistories(match func(id string) bool) []Series
|
||||
}
|
||||
|
||||
// Ticker is a Provider that can say when its values have changed.
|
||||
type Ticker interface {
|
||||
Subscribe() (tick <-chan struct{}, cancel func())
|
||||
}
|
||||
|
||||
// Subscribe follows the first provider that ticks. ok is false when none does.
|
||||
func (r *Registry) Subscribe() (tick <-chan struct{}, cancel func(), ok bool) {
|
||||
for _, p := range r.providers {
|
||||
if t, is := p.(Ticker); is {
|
||||
tick, cancel = t.Subscribe()
|
||||
return tick, cancel, true
|
||||
}
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
// F is a pointer to a float, for the optional bounds.
|
||||
func F(v float64) *float64 { return &v }
|
||||
|
||||
// Seg makes one ID segment from a real-world name: lower case, [a-z0-9_-] only, so IDs
|
||||
// survive URLs, query strings and templating languages untouched. "Package id 0" →
|
||||
// "package_id_0", "/mnt/tank" → "mnt_tank", "/" → "root".
|
||||
func Seg(name string) string {
|
||||
if name == "/" {
|
||||
return "root"
|
||||
}
|
||||
var b strings.Builder
|
||||
gap := false
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||
if gap && b.Len() > 0 {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
gap = false
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
gap = true
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "unnamed"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ID joins already-safe segments.
|
||||
func ID(segs ...string) string { return strings.Join(segs, ".") }
|
||||
|
||||
// Registry joins providers into one namespace.
|
||||
type Registry struct{ providers []Provider }
|
||||
|
||||
func NewRegistry(p ...Provider) *Registry { return &Registry{providers: p} }
|
||||
|
||||
func (r *Registry) Add(p Provider) { r.providers = append(r.providers, p) }
|
||||
|
||||
// All returns every sensor, sorted by ID. Two sensors that would share an ID (two
|
||||
// identical chips) get "_2", "_3" on the colliding one, in the provider's order.
|
||||
func (r *Registry) All() []Sensor {
|
||||
var out []Sensor
|
||||
seen := map[string]int{}
|
||||
for _, p := range r.providers {
|
||||
for _, s := range p.Sensors() {
|
||||
seen[s.ID]++
|
||||
if n := seen[s.ID]; n > 1 {
|
||||
s.ID = fmt.Sprintf("%s_%d", s.ID, n)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// History is one sensor's recent past. ok is false when nothing remembers it.
|
||||
func (r *Registry) History(id string) (Series, bool) {
|
||||
got := r.Histories(func(x string) bool { return x == id })
|
||||
if len(got) == 0 {
|
||||
return Series{}, false
|
||||
}
|
||||
return got[0], true
|
||||
}
|
||||
|
||||
// Histories is the recent past of every numeric sensor match accepts, sorted by ID.
|
||||
func (r *Registry) Histories(match func(id string) bool) []Series {
|
||||
out := []Series{}
|
||||
for _, p := range r.providers {
|
||||
if h, ok := p.(Historian); ok {
|
||||
out = append(out, h.SensorHistories(match)...)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// Matcher turns comma-separated selectors (IDs and globs) into a match function.
|
||||
func Matcher(selectors string) (func(id string) bool, error) {
|
||||
var sels []string
|
||||
for _, sel := range strings.Split(selectors, ",") {
|
||||
if sel = strings.TrimSpace(sel); sel == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := path.Match(sel, ""); err != nil {
|
||||
return nil, fmt.Errorf("bad pattern %q", sel)
|
||||
}
|
||||
sels = append(sels, sel)
|
||||
}
|
||||
return func(id string) bool {
|
||||
for _, sel := range sels {
|
||||
if ok, _ := path.Match(sel, id); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsGlob reports whether a selector names many sensors rather than one.
|
||||
func IsGlob(sel string) bool { return strings.ContainsAny(sel, "*?[") }
|
||||
|
||||
// Select returns the sensors a selector names: an exact ID, or a glob where * crosses
|
||||
// dots ("fan.*" is every fan, "cpu.core.*.usage" every core).
|
||||
func Select(all []Sensor, sel string) ([]Sensor, error) {
|
||||
if !IsGlob(sel) {
|
||||
for _, s := range all {
|
||||
if s.ID == sel {
|
||||
return []Sensor{s}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := path.Match(sel, ""); err != nil {
|
||||
return nil, fmt.Errorf("bad pattern %q", sel)
|
||||
}
|
||||
var out []Sensor
|
||||
for _, s := range all {
|
||||
if ok, _ := path.Match(sel, s.ID); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Shape answers a shaped query: the client sends the object it wants, with selectors
|
||||
// where the values go, and gets the same object back with them filled in.
|
||||
//
|
||||
// {"cpu": "cpu.usage", "fans": "fan.*", "box": {"up": "uptime"}}
|
||||
// → {"cpu": 12.4, "fans": {"fan.nct6775.fan1": 820}, "box": {"up": 80520}}
|
||||
//
|
||||
// An exact ID becomes its value (null if there is no such sensor here — machines differ,
|
||||
// and a dashboard shouldn't fail because one has no fans). A glob becomes an object keyed
|
||||
// by ID. Objects and arrays are walked. With full, every value is the whole Sensor.
|
||||
func Shape(all []Sensor, query json.RawMessage, full bool) (any, error) {
|
||||
var q any
|
||||
if err := json.Unmarshal(query, &q); err != nil {
|
||||
return nil, errors.New("query is not valid JSON")
|
||||
}
|
||||
budget := MaxValues
|
||||
return shape(all, q, full, 0, &budget)
|
||||
}
|
||||
|
||||
// MaxValues is how many sensor values one query may return. A query is a few bytes that
|
||||
// can ask for everything many times over ({"a":"*","b":"*",…}); without a ceiling 10 KB of
|
||||
// request buys 27 MB of response. Ten thousand is thirty times a whole machine.
|
||||
const MaxValues = 10000
|
||||
|
||||
func shape(all []Sensor, q any, full bool, depth int, budget *int) (any, error) {
|
||||
if depth > 16 {
|
||||
return nil, errors.New("query nested too deep")
|
||||
}
|
||||
leaf := func(s Sensor) any {
|
||||
if full {
|
||||
return s
|
||||
}
|
||||
return s.Value
|
||||
}
|
||||
switch v := q.(type) {
|
||||
case string:
|
||||
got, err := Select(all, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !IsGlob(v) {
|
||||
if len(got) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
*budget--
|
||||
return leaf(got[0]), nil
|
||||
}
|
||||
if *budget -= len(got); *budget < 0 {
|
||||
return nil, fmt.Errorf("query returns more than %d values; ask for less", MaxValues)
|
||||
}
|
||||
m := make(map[string]any, len(got))
|
||||
for _, s := range got {
|
||||
m[s.ID] = leaf(s)
|
||||
}
|
||||
return m, nil
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(v))
|
||||
for k, sub := range v {
|
||||
r, err := shape(all, sub, full, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[k] = r
|
||||
}
|
||||
return m, nil
|
||||
case []any:
|
||||
a := make([]any, len(v))
|
||||
for i, sub := range v {
|
||||
r, err := shape(all, sub, full, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a[i] = r
|
||||
}
|
||||
return a, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("a query holds sensor IDs, objects and arrays; got %v", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package sensors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fake []Sensor
|
||||
|
||||
func (f fake) Sensors() []Sensor { return f }
|
||||
|
||||
func TestSeg(t *testing.T) {
|
||||
for in, want := range map[string]string{"/": "root", "/mnt/tank": "mnt_tank", "Package id 0": "package_id_0", "enp3s0": "enp3s0", " ": "unnamed", "Tctl+": "tctl", "nvme0n1": "nvme0n1"} {
|
||||
if got := Seg(in); got != want {
|
||||
t.Errorf("Seg(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDedupes(t *testing.T) {
|
||||
r := NewRegistry(fake{{ID: "temp.nvme.composite", Value: 40.0}}, fake{{ID: "temp.nvme.composite", Value: 50.0}})
|
||||
all := r.All()
|
||||
if len(all) != 2 || all[0].ID != "temp.nvme.composite" || all[1].ID != "temp.nvme.composite_2" {
|
||||
t.Fatalf("got %+v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShape(t *testing.T) {
|
||||
all := []Sensor{{ID: "cpu.usage", Value: 12.5, Unit: "%"}, {ID: "fan.a.fan1", Value: 800.0}, {ID: "fan.a.fan2", Value: 900.0}, {ID: "net.eth0.up", Value: true}}
|
||||
got, err := Shape(all, json.RawMessage(`{"cpu":"cpu.usage","fans":"fan.*","box":{"link":"net.eth0.up","gone":"ups.battery"},"list":["cpu.usage"]}`), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"cpu": 12.5,
|
||||
"fans": map[string]any{"fan.a.fan1": 800.0, "fan.a.fan2": 900.0},
|
||||
"box": map[string]any{"link": true, "gone": nil},
|
||||
"list": []any{12.5},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %#v\nwant %#v", got, want)
|
||||
}
|
||||
full, _ := Shape(all, json.RawMessage(`"cpu.usage"`), true)
|
||||
if s, ok := full.(Sensor); !ok || s.Unit != "%" {
|
||||
t.Fatalf("full: got %#v", full)
|
||||
}
|
||||
for _, bad := range []string{`{"x": 5}`, `{"x": "fan.["}`, `not json`} {
|
||||
if _, err := Shape(all, json.RawMessage(bad), false); err == nil {
|
||||
t.Errorf("Shape(%s) should fail", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProm(t *testing.T) {
|
||||
var b strings.Builder
|
||||
WriteProm(&b, []Sensor{
|
||||
{ID: "cpu.usage", Kind: "usage", Unit: "%", Value: 12.5},
|
||||
{ID: "ups.runtime", Kind: "duration", Unit: "min", Value: 2.0},
|
||||
{ID: "net.eth0.up", Kind: "state", Value: true, Labels: map[string]string{"iface": "eth0"}},
|
||||
{ID: "ups.state", Kind: "state", Value: `Nor"mal`},
|
||||
{ID: "updates.pending", Kind: "count", Value: 3.0},
|
||||
})
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
"# TYPE hamlab_usage_percent gauge\n" + `hamlab_usage_percent{id="cpu.usage"} 12.5`,
|
||||
`hamlab_duration_seconds{id="ups.runtime"} 120`,
|
||||
`hamlab_state{id="net.eth0.up",iface="eth0"} 1`,
|
||||
`hamlab_state{id="ups.state",value="Nor\"mal"} 1`,
|
||||
`hamlab_updates_pending{id="updates.pending"} 3`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapeBudget(t *testing.T) {
|
||||
all := make([]Sensor, 300)
|
||||
for i := range all {
|
||||
all[i] = Sensor{ID: "s." + strings.Repeat("x", i%7) + string(rune('a'+i%26)) + strings.Repeat("y", i/26), Value: 1.0}
|
||||
}
|
||||
q := map[string]string{}
|
||||
for i := 0; i < 40; i++ { // 40 × 300 = 12 000 values
|
||||
q["k"+strings.Repeat("z", i)] = "*"
|
||||
}
|
||||
b, _ := json.Marshal(q)
|
||||
if _, err := Shape(all, b, false); err == nil {
|
||||
t.Fatal("a query returning more than MaxValues must be refused")
|
||||
}
|
||||
if _, err := Shape(all, json.RawMessage(`{"a":"*","b":"*"}`), false); err != nil {
|
||||
t.Fatalf("an ordinary query must pass: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Package services wraps systemctl and journalctl for the Services tab: list units,
|
||||
// show one, read its unit file, read and tail its journal, and start/stop/etc.
|
||||
//
|
||||
// Reads run as the daemon's user (systemd exposes state to everyone; the journal needs
|
||||
// the systemd-journal or adm group, or sudo). Actions go through passwordless sudo when
|
||||
// it's available, otherwise plain systemctl so polkit gets its say.
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
type Unit struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Load string `json:"load"`
|
||||
Active string `json:"active"`
|
||||
Sub string `json:"sub"`
|
||||
Enabled string `json:"enabled"` // enabled | disabled | static | masked | … | "" (unknown)
|
||||
Failed bool `json:"failed"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Unit
|
||||
Props map[string]string `json:"props"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
T int64 `json:"t"` // unix ms
|
||||
Priority int `json:"p"` // syslog priority 0-7
|
||||
Ident string `json:"ident"`
|
||||
PID string `json:"pid,omitempty"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
var unitRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.service$`)
|
||||
|
||||
func Valid(unit string) bool { return unitRe.MatchString(unit) }
|
||||
|
||||
var Actions = map[string]bool{"start": true, "stop": true, "restart": true, "reload": true, "enable": true, "disable": true, "reset-failed": true}
|
||||
|
||||
// Properties shown on the detail page, in display order.
|
||||
var showProps = []string{"Id", "Description", "LoadState", "ActiveState", "SubState", "UnitFileState", "FragmentPath",
|
||||
"MainPID", "ExecMainStartTimestamp", "ActiveEnterTimestamp", "InactiveEnterTimestamp", "NRestarts", "Result",
|
||||
"MemoryCurrent", "MemoryPeak", "CPUUsageNSec", "TasksCurrent", "ExecStart", "User", "Restart"}
|
||||
|
||||
type Manager struct {
|
||||
sudo bool // actions via sudo -n
|
||||
journalSudo bool // journal needs sudo (user isn't in systemd-journal/adm)
|
||||
mu sync.Mutex
|
||||
cache []Unit
|
||||
cacheAt time.Time
|
||||
}
|
||||
|
||||
// ErrNoSystemd is what the Services tab says on OpenRC, runit and friends.
|
||||
var ErrNoSystemd = errors.New("this host doesn't run systemd, which the Services tab needs")
|
||||
|
||||
// HasSystemd reports whether systemd is the running init (the test sd_booted() uses).
|
||||
func HasSystemd() bool {
|
||||
_, err := os.Stat("/run/systemd/system")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func New() *Manager {
|
||||
m := &Manager{}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
m.sudo = priv.Available()
|
||||
// Prefer reading the journal as ourselves: a plain child can be killed cleanly when a
|
||||
// tail ends, whereas a root journalctl under sudo can only be reached with sudo kill.
|
||||
var errb bytes.Buffer
|
||||
probe := exec.CommandContext(ctx, "journalctl", "--system", "-n", "1", "-q", "-o", "cat")
|
||||
probe.Stderr = &errb
|
||||
if err := probe.Run(); err != nil || strings.Contains(strings.ToLower(errb.String()), "permission") {
|
||||
m.journalSudo = m.sudo
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Sudo reports whether actions will run through passwordless sudo.
|
||||
func (m *Manager) Sudo() bool { return m.sudo }
|
||||
|
||||
// JournalSudo reports whether reading the system journal needs sudo for this user.
|
||||
func (m *Manager) JournalSudo() bool { return m.journalSudo }
|
||||
|
||||
func run(ctx context.Context, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return out.String(), errors.New(msg)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// List merges `list-units --all` (runtime state) with `list-unit-files` (enablement).
|
||||
// Cached for a few seconds because the UI polls it.
|
||||
func (m *Manager) List(ctx context.Context) ([]Unit, error) {
|
||||
if !HasSystemd() {
|
||||
return nil, ErrNoSystemd
|
||||
}
|
||||
m.mu.Lock()
|
||||
if time.Since(m.cacheAt) < 200*time.Millisecond && m.cache != nil { // coalesce simultaneous clients only
|
||||
c := m.cache
|
||||
m.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
var out, files string
|
||||
var err error
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
files, _ = run(ctx, "systemctl", "list-unit-files", "--type=service", "--output=json", "--no-pager")
|
||||
close(done)
|
||||
}()
|
||||
out, err = run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain")
|
||||
<-done
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var units []listedUnit
|
||||
if err := json.Unmarshal([]byte(out), &units); err != nil {
|
||||
// systemd before 246 (RHEL 8, Ubuntu 20.04) ignores --output=json here and prints
|
||||
// the table; the columns have been the same since the beginning.
|
||||
if units = parseUnitTable(out); len(units) == 0 {
|
||||
return nil, fmt.Errorf("parse list-units: %w", err)
|
||||
}
|
||||
}
|
||||
var uf []listedFile
|
||||
if json.Unmarshal([]byte(files), &uf) != nil {
|
||||
uf = parseFileTable(files)
|
||||
}
|
||||
enabled := map[string]string{}
|
||||
for _, f := range uf {
|
||||
enabled[f.UnitFile] = f.State
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
list := make([]Unit, 0, len(units)+len(uf))
|
||||
for _, u := range units {
|
||||
if strings.Contains(u.Unit, "@.") { // uninstantiated template
|
||||
continue
|
||||
}
|
||||
seen[u.Unit] = true
|
||||
list = append(list, Unit{Name: u.Unit, Description: u.Description, Load: u.Load, Active: u.Active, Sub: u.Sub,
|
||||
Enabled: enabled[u.Unit], Failed: u.Active == "failed"})
|
||||
}
|
||||
// Unit files that exist but were never loaded this boot (disabled and never started).
|
||||
for _, f := range uf {
|
||||
if seen[f.UnitFile] || strings.Contains(f.UnitFile, "@.") || f.State == "masked" || f.State == "alias" {
|
||||
continue
|
||||
}
|
||||
list = append(list, Unit{Name: f.UnitFile, Load: "not-loaded", Active: "inactive", Sub: "dead", Enabled: f.State})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
|
||||
m.mu.Lock()
|
||||
m.cache, m.cacheAt = list, time.Now()
|
||||
m.mu.Unlock()
|
||||
return list, nil
|
||||
}
|
||||
|
||||
type listedUnit struct{ Unit, Load, Active, Sub, Description string }
|
||||
|
||||
type listedFile struct {
|
||||
UnitFile string `json:"unit_file"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// parseUnitTable reads the plain `systemctl list-units` table: UNIT LOAD ACTIVE SUB DESCRIPTION…
|
||||
func parseUnitTable(text string) []listedUnit {
|
||||
var units []listedUnit
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(strings.TrimLeft(line, "●*× "))
|
||||
if len(f) < 4 || !strings.HasSuffix(f[0], ".service") {
|
||||
continue
|
||||
}
|
||||
units = append(units, listedUnit{Unit: f[0], Load: f[1], Active: f[2], Sub: f[3], Description: strings.Join(f[4:], " ")})
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
// parseFileTable reads the plain `systemctl list-unit-files` table: UNIT FILE, STATE[, PRESET]
|
||||
func parseFileTable(text string) []listedFile {
|
||||
var uf []listedFile
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if f := strings.Fields(line); len(f) >= 2 && strings.HasSuffix(f[0], ".service") {
|
||||
uf = append(uf, listedFile{UnitFile: f[0], State: f[1]})
|
||||
}
|
||||
}
|
||||
return uf
|
||||
}
|
||||
|
||||
func (m *Manager) invalidate() {
|
||||
m.mu.Lock()
|
||||
m.cacheAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) Show(ctx context.Context, unit string) (Detail, error) {
|
||||
if !Valid(unit) {
|
||||
return Detail{}, errors.New("invalid unit name")
|
||||
}
|
||||
out, err := run(ctx, "systemctl", "show", unit, "-p", strings.Join(showProps, ","), "--no-pager")
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
props := map[string]string{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok && v != "" && v != "[not set]" && v != "infinity" {
|
||||
props[k] = v
|
||||
}
|
||||
}
|
||||
if props["LoadState"] == "not-found" {
|
||||
return Detail{}, errors.New("no such unit")
|
||||
}
|
||||
d := Detail{Props: props}
|
||||
d.Name, d.Description = unit, props["Description"]
|
||||
d.Load, d.Active, d.Sub, d.Enabled = props["LoadState"], props["ActiveState"], props["SubState"], props["UnitFileState"]
|
||||
d.Failed = d.Active == "failed"
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Cat(ctx context.Context, unit string) (string, error) {
|
||||
if !Valid(unit) {
|
||||
return "", errors.New("invalid unit name")
|
||||
}
|
||||
return run(ctx, "systemctl", "cat", unit, "--no-pager")
|
||||
}
|
||||
|
||||
func parseEntry(line []byte) (Entry, bool) {
|
||||
var raw map[string]any
|
||||
if json.Unmarshal(line, &raw) != nil {
|
||||
return Entry{}, false
|
||||
}
|
||||
e := Entry{Priority: 6}
|
||||
switch v := raw["MESSAGE"].(type) {
|
||||
case string:
|
||||
e.Msg = v
|
||||
case []any: // non-UTF-8 payload comes as a byte array
|
||||
b := make([]byte, 0, len(v))
|
||||
for _, x := range v {
|
||||
if f, ok := x.(float64); ok {
|
||||
b = append(b, byte(f))
|
||||
}
|
||||
}
|
||||
e.Msg = string(b)
|
||||
default:
|
||||
e.Msg = ""
|
||||
}
|
||||
if p, ok := raw["PRIORITY"].(string); ok {
|
||||
e.Priority, _ = strconv.Atoi(p)
|
||||
}
|
||||
if ts, ok := raw["__REALTIME_TIMESTAMP"].(string); ok {
|
||||
us, _ := strconv.ParseInt(ts, 10, 64)
|
||||
e.T = us / 1000
|
||||
}
|
||||
if id, ok := raw["SYSLOG_IDENTIFIER"].(string); ok {
|
||||
e.Ident = id
|
||||
} else if c, ok := raw["_COMM"].(string); ok {
|
||||
e.Ident = c
|
||||
}
|
||||
if pid, ok := raw["_PID"].(string); ok {
|
||||
e.PID = pid
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
func (m *Manager) journalArgs(unit string, n int) []string {
|
||||
args := []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "-o", "json", "--no-pager", "--all"}
|
||||
if m.journalSudo {
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Journal returns the last n entries.
|
||||
func (m *Manager) Journal(ctx context.Context, unit string, n int) ([]Entry, error) {
|
||||
if !Valid(unit) {
|
||||
return nil, errors.New("invalid unit name")
|
||||
}
|
||||
out, err := run(ctx, m.journalArgs(unit, n)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := []Entry{}
|
||||
for _, line := range bytes.Split([]byte(out), []byte("\n")) {
|
||||
if e, ok := parseEntry(line); ok {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Tail streams entries (last n, then follow) to fn until ctx is cancelled.
|
||||
func (m *Manager) Tail(ctx context.Context, unit string, n int, fn func(Entry)) error {
|
||||
if !Valid(unit) {
|
||||
return errors.New("invalid unit name")
|
||||
}
|
||||
args := append(m.journalArgs(unit, n), "-f")
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
// Own process group so the kill reaches journalctl itself, not just a sudo wrapper.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
pg := "-" + strconv.Itoa(cmd.Process.Pid)
|
||||
if m.journalSudo { // children are root's; only sudo can signal them
|
||||
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 err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
|
||||
for sc.Scan() {
|
||||
if e, ok := parseEntry(sc.Bytes()); ok {
|
||||
fn(e)
|
||||
}
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Action runs start/stop/restart/reload/enable/disable/reset-failed.
|
||||
func (m *Manager) Action(ctx context.Context, action, unit string) error {
|
||||
if !Valid(unit) {
|
||||
return errors.New("invalid unit name")
|
||||
}
|
||||
if !Actions[action] {
|
||||
return errors.New("unknown action")
|
||||
}
|
||||
args := []string{"systemctl", action, unit, "--no-pager"}
|
||||
if m.sudo {
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
_, err := run(ctx, args...)
|
||||
m.invalidate()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTables(t *testing.T) {
|
||||
u := parseUnitTable(" UNIT LOAD ACTIVE SUB DESCRIPTION\n● bad.service loaded failed failed Bad thing here\n ssh.service loaded active running OpenBSD Secure Shell server\n\nLOAD = x\n12 loaded units listed.\n")
|
||||
if len(u) != 2 || u[0].Unit != "bad.service" || u[0].Active != "failed" || u[1].Description != "OpenBSD Secure Shell server" {
|
||||
t.Fatalf("%+v", u)
|
||||
}
|
||||
f := parseFileTable("UNIT FILE STATE VENDOR PRESET\nssh.service enabled enabled\nfoo@.service static -\n\n2 unit files listed.\n")
|
||||
if len(f) != 2 || f[0].State != "enabled" {
|
||||
t.Fatalf("%+v", f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/sbin/openrc-run
|
||||
# Ham Lab web console (written by hamlab install)
|
||||
|
||||
name="hamlab"
|
||||
description="Ham Lab web console"
|
||||
|
||||
export HAMLAB_CONFIG="@CONFIG@"
|
||||
export HOME="@HOME@"
|
||||
export USER="@USER@"
|
||||
|
||||
supervisor="supervise-daemon"
|
||||
command="@BIN@"
|
||||
command_args="serve"
|
||||
command_user="@USER@:@GROUP@"
|
||||
directory="@HOME@"
|
||||
respawn_delay=3
|
||||
respawn_max=0
|
||||
output_log="/var/log/hamlab.log"
|
||||
error_log="/var/log/hamlab.log"
|
||||
|
||||
depend() {
|
||||
need localmount
|
||||
after net firewall
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath -f -o "@USER@:@GROUP@" -m 0640 /var/log/hamlab.log
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Ham Lab web console
|
||||
After=network.target
|
||||
# never give up restarting
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=@USER@
|
||||
Group=@GROUP@
|
||||
# read the system journal without sudo (keeps the daemon's own log clean)
|
||||
SupplementaryGroups=@GROUPS@
|
||||
WorkingDirectory=-@HOME@
|
||||
Environment=HAMLAB_CONFIG=@CONFIG@
|
||||
ExecStart=@BIN@ serve
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=10
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,694 @@
|
||||
// Package setup is `hamlab install` and `hamlab uninstall`: the binary installs itself, so
|
||||
// a new machine needs nothing but this one file. It copies itself into place, writes the
|
||||
// config, sets the login password and registers a service with whichever init the host
|
||||
// runs (systemd or OpenRC), as root or through sudo/doas.
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"hamlab/internal/auth"
|
||||
"hamlab/internal/config"
|
||||
)
|
||||
|
||||
//go:embed hamlab.service
|
||||
var systemdUnit string
|
||||
|
||||
//go:embed hamlab.openrc
|
||||
var openrcScript string
|
||||
|
||||
const (
|
||||
unitPath = "/etc/systemd/system/hamlab.service"
|
||||
openrcPath = "/etc/init.d/hamlab"
|
||||
sudoersPath = "/etc/sudoers.d/hamlab-quiet"
|
||||
)
|
||||
|
||||
type options struct {
|
||||
user, listen, tls, prefix, data string
|
||||
passwordStdin, openFirewall bool
|
||||
noStart bool
|
||||
}
|
||||
|
||||
func say(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// elevate re-runs this command as root through sudo or doas (which may prompt), naming
|
||||
// the invoking user so the service still runs as them.
|
||||
func elevate(sub string, args []string, keepUser bool) error {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tool := range []string{"sudo", "doas"} {
|
||||
path, err := exec.LookPath(tool)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
full := []string{tool, self, sub}
|
||||
if u, err := user.Current(); err == nil && keepUser && !hasFlag(args, "user") {
|
||||
full = append(full, "--user", u.Username)
|
||||
}
|
||||
full = append(full, args...)
|
||||
return syscall.Exec(path, full, os.Environ())
|
||||
}
|
||||
return errors.New("this needs root, and neither sudo nor doas is installed: run it as root")
|
||||
}
|
||||
|
||||
func hasFlag(args []string, name string) bool {
|
||||
for _, a := range args {
|
||||
a = strings.TrimLeft(a, "-")
|
||||
if a == name || strings.HasPrefix(a, name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Install is `hamlab install [flags]`.
|
||||
func Install(args []string, version string) error {
|
||||
var o options
|
||||
fs := flag.NewFlagSet("install", flag.ContinueOnError)
|
||||
fs.StringVar(&o.user, "user", "", "user the console runs as; terminals are this user's shell (default: you)")
|
||||
fs.StringVar(&o.listen, "listen", "", "listen address, e.g. 0.0.0.0:8100 (default: keep, else 127.0.0.1:8100)")
|
||||
fs.StringVar(&o.tls, "tls", "", `"auto" serves HTTPS with a self-signed certificate, "off" plain HTTP (default: keep)`)
|
||||
fs.StringVar(&o.prefix, "prefix", "/usr/local/bin", "directory the binary is installed into")
|
||||
fs.StringVar(&o.data, "data", "", "config and state directory (default: ~/.config/hamlab of the user)")
|
||||
fs.BoolVar(&o.passwordStdin, "password-stdin", false, "read the login password from stdin instead of prompting")
|
||||
fs.BoolVar(&o.openFirewall, "open-firewall", false, "open the listen port in firewalld/ufw when one is active")
|
||||
fs.BoolVar(&o.noStart, "no-start", false, "install and enable, but don't start the service")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return elevate("install", args, true)
|
||||
}
|
||||
sbinPath()
|
||||
|
||||
// who
|
||||
name := o.user
|
||||
for _, env := range []string{"SUDO_USER", "DOAS_USER"} {
|
||||
if name == "" {
|
||||
name = os.Getenv(env)
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = "root"
|
||||
}
|
||||
acct, err := lookupUser(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// where
|
||||
dataDir := o.data
|
||||
if dataDir == "" {
|
||||
dataDir = filepath.Dir(existingConfig(acct.home))
|
||||
}
|
||||
cfgPath := filepath.Join(dataDir, "config.json")
|
||||
|
||||
// binary
|
||||
dest := filepath.Join(o.prefix, "hamlab")
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self, _ = filepath.EvalSymlinks(self)
|
||||
if err := os.MkdirAll(o.prefix, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if self != dest {
|
||||
if err := CopyAtomic(self, dest); err != nil {
|
||||
return fmt.Errorf("install binary: %w", err)
|
||||
}
|
||||
// builds for other architectures sit beside it so a hub can push them to matching machines
|
||||
sibs, _ := filepath.Glob(filepath.Join(filepath.Dir(self), "hamlab-linux-*"))
|
||||
for _, sib := range sibs {
|
||||
arch := strings.TrimPrefix(filepath.Base(sib), "hamlab-linux-")
|
||||
if sib == self || strings.Contains(arch, ".") {
|
||||
continue
|
||||
}
|
||||
if err := CopyAtomic(sib, dest+"-"+arch); err != nil {
|
||||
say("warning: %s: %v", sib, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
restorecon(dest)
|
||||
say("binary %s (%s)", dest, version)
|
||||
|
||||
// config + password
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if o.listen != "" {
|
||||
if _, _, err := net.SplitHostPort(o.listen); err != nil {
|
||||
return fmt.Errorf("--listen %q: want host:port", o.listen)
|
||||
}
|
||||
cfg.Listen = o.listen
|
||||
}
|
||||
switch o.tls {
|
||||
case "":
|
||||
case "auto":
|
||||
cfg.TLS = "auto"
|
||||
case "off", "none":
|
||||
cfg.TLS = ""
|
||||
default:
|
||||
return fmt.Errorf("--tls %q: want auto or off", o.tls)
|
||||
}
|
||||
generated := ""
|
||||
cfg.MigrateUsers(acct.name)
|
||||
if len(cfg.Users) == 0 || o.passwordStdin {
|
||||
pw, gen, err := choosePassword(o.passwordStdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// the first account is named after the OS user the service runs as; --password-stdin
|
||||
// on an existing install resets that first account
|
||||
if len(cfg.Users) == 0 {
|
||||
cfg.Users = []config.User{{Name: acct.name, Hash: h, Created: time.Now()}}
|
||||
} else {
|
||||
cfg.Users[0].Hash = h
|
||||
}
|
||||
if gen {
|
||||
generated = pw
|
||||
}
|
||||
}
|
||||
made := firstMissing(dataDir) // e.g. ~/.config, when this is the first thing to use it
|
||||
if err := cfg.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := chownData(dataDir, made, acct); err != nil {
|
||||
return err
|
||||
}
|
||||
say("config %s", cfgPath)
|
||||
|
||||
// service
|
||||
vars := map[string]string{"@USER@": acct.name, "@GROUP@": acct.group, "@HOME@": acct.home, "@BIN@": dest, "@CONFIG@": cfgPath,
|
||||
"@GROUPS@": journalGroups()}
|
||||
initSys := ""
|
||||
switch {
|
||||
case isDir("/run/systemd/system"):
|
||||
initSys = "systemd"
|
||||
unit := fill(systemdUnit, vars)
|
||||
if vars["@GROUPS@"] == "" {
|
||||
unit = dropLines(unit, "SupplementaryGroups=")
|
||||
}
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
restorecon(unitPath)
|
||||
if err := runAll([][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "hamlab.service"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !o.noStart {
|
||||
if err := run("systemctl", "restart", "hamlab.service"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case exists("/sbin/openrc-run") || exists("/usr/sbin/openrc-run") || have("rc-service"):
|
||||
initSys = "OpenRC"
|
||||
if err := os.WriteFile(openrcPath, []byte(fill(openrcScript, vars)), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run("rc-update", "add", "hamlab", "default"); err != nil {
|
||||
say("warning: rc-update: %v", err)
|
||||
}
|
||||
if !o.noStart {
|
||||
if err := run("rc-service", "hamlab", "restart"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if initSys != "" {
|
||||
say("service hamlab (%s), runs as %s, starts at boot", initSys, acct.name)
|
||||
}
|
||||
if acct.name != "root" {
|
||||
quietSudoers()
|
||||
}
|
||||
|
||||
port := firewall(cfg.Listen, o.openFirewall)
|
||||
|
||||
scheme := "http"
|
||||
if cfg.TLS == "auto" {
|
||||
scheme = "https"
|
||||
}
|
||||
url := scheme + "://" + browseAddr(cfg.Listen)
|
||||
switch {
|
||||
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)
|
||||
case o.noStart:
|
||||
case healthy(cfg.Listen, cfg.TLS == "auto"):
|
||||
say("\nHam Lab is up: %s", url)
|
||||
default:
|
||||
say("\nInstalled, but %s isn't answering yet. Check: %s", url, map[string]string{"systemd": "journalctl -u hamlab -n 30", "OpenRC": "tail /var/log/hamlab.log"}[initSys])
|
||||
}
|
||||
if generated != "" {
|
||||
say("Login: user %s, password (shown once; change it in Settings): %s", cfg.Users[0].Name, generated)
|
||||
}
|
||||
if host, _, _ := net.SplitHostPort(cfg.Listen); isLoopback(host) {
|
||||
say("It only listens on this machine. For LAN access: hamlab install --listen 0.0.0.0:%s", port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uninstall is `hamlab uninstall [--purge]`.
|
||||
func Uninstall(args []string) error {
|
||||
fs := flag.NewFlagSet("uninstall", flag.ContinueOnError)
|
||||
purge := fs.Bool("purge", false, "also delete the config and state directory")
|
||||
prefix := fs.String("prefix", "/usr/local/bin", "directory the binary was installed into")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return elevate("uninstall", args, false)
|
||||
}
|
||||
sbinPath()
|
||||
dataDir := ""
|
||||
if b, err := os.ReadFile(unitPath); err == nil {
|
||||
dataDir = configDirFrom(string(b), "Environment=HAMLAB_CONFIG=")
|
||||
_ = run("systemctl", "disable", "--now", "hamlab.service")
|
||||
os.Remove(unitPath)
|
||||
_ = run("systemctl", "daemon-reload")
|
||||
}
|
||||
if b, err := os.ReadFile(openrcPath); err == nil {
|
||||
dataDir = configDirFrom(string(b), "export HAMLAB_CONFIG=")
|
||||
_ = run("rc-service", "hamlab", "stop")
|
||||
_ = run("rc-update", "del", "hamlab", "default")
|
||||
os.Remove(openrcPath)
|
||||
}
|
||||
os.Remove(sudoersPath)
|
||||
bins, _ := filepath.Glob(filepath.Join(*prefix, "hamlab-*"))
|
||||
for _, b := range append(bins, filepath.Join(*prefix, "hamlab")) {
|
||||
os.Remove(b)
|
||||
}
|
||||
say("Ham Lab removed.")
|
||||
switch {
|
||||
case dataDir == "":
|
||||
case *purge:
|
||||
if err := purgeData(dataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
say("Config and state kept in %s (uninstall --purge deletes them).", dataDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sbinPath makes sure the admin tools resolve: a root shell reached with plain `su` keeps
|
||||
// the user's PATH, which on Debian has no sbin in it.
|
||||
func sbinPath() {
|
||||
os.Setenv("PATH", os.Getenv("PATH")+":/usr/local/sbin:/usr/sbin:/sbin:/usr/local/bin:/usr/bin:/bin")
|
||||
}
|
||||
|
||||
// ownFiles is everything Ham Lab writes into its data directory.
|
||||
var ownFiles = []string{"config.json", "config.json.tmp", "state.json", "state.json.tmp", "state.json.corrupt", "cert.pem", "key.pem", "hamlab.prev", "hamlab.new"}
|
||||
|
||||
// purgeData deletes Ham Lab's data. The whole directory goes only when it is unmistakably
|
||||
// ours (named "hamlab"); a --data pointed at a shared directory such as a home loses
|
||||
// just the files Ham Lab wrote.
|
||||
func purgeData(dir string) error {
|
||||
if filepath.Base(dir) == "hamlab" {
|
||||
say("Deleted %s.", dir)
|
||||
return os.RemoveAll(dir)
|
||||
}
|
||||
for _, f := range ownFiles {
|
||||
os.Remove(filepath.Join(dir, f))
|
||||
}
|
||||
say("Deleted Ham Lab's files in %s (the directory isn't Ham Lab's alone, so it stays).", dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func configDirFrom(text, key string) string {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if v, ok := strings.CutPrefix(strings.TrimSpace(line), key); ok {
|
||||
if v = strings.Trim(v, `"'`); strings.HasSuffix(v, "/config.json") && filepath.Dir(v) != "/" {
|
||||
return filepath.Dir(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- pieces ----
|
||||
|
||||
type account struct {
|
||||
name, group, home string
|
||||
uid, gid int
|
||||
}
|
||||
|
||||
func lookupUser(name string) (account, error) {
|
||||
a := account{name: name}
|
||||
if u, err := user.Lookup(name); err == nil {
|
||||
a.uid, _ = strconv.Atoi(u.Uid)
|
||||
a.gid, _ = strconv.Atoi(u.Gid)
|
||||
a.home = u.HomeDir
|
||||
} else if out, err2 := exec.Command("getent", "passwd", name).Output(); err2 == nil { // LDAP/SSSD accounts
|
||||
f := strings.Split(strings.TrimSpace(string(out)), ":")
|
||||
if len(f) < 6 {
|
||||
return a, fmt.Errorf("no such user: %s", name)
|
||||
}
|
||||
a.uid, _ = strconv.Atoi(f[2])
|
||||
a.gid, _ = strconv.Atoi(f[3])
|
||||
a.home = f[5]
|
||||
} else {
|
||||
return a, fmt.Errorf("no such user: %s", name)
|
||||
}
|
||||
if a.home == "" || !isDir(a.home) {
|
||||
return a, fmt.Errorf("user %s has no home directory (%q); pick another with --user", name, a.home)
|
||||
}
|
||||
a.group = strconv.Itoa(a.gid)
|
||||
if g, err := user.LookupGroupId(a.group); err == nil {
|
||||
a.group = g.Name
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// existingConfig returns the config path for a home directory: whichever already exists,
|
||||
// else the default for a new install.
|
||||
func existingConfig(home string) string {
|
||||
for _, p := range config.Candidates(home) {
|
||||
if exists(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return config.Candidates(home)[0]
|
||||
}
|
||||
|
||||
// journalGroups lists the groups that can read the system journal and exist here. A unit
|
||||
// naming a group the host doesn't have fails to start, and "adm" is not universal.
|
||||
func journalGroups() string {
|
||||
var have []string
|
||||
for _, g := range []string{"systemd-journal", "adm"} {
|
||||
if _, err := user.LookupGroup(g); err == nil {
|
||||
have = append(have, g)
|
||||
}
|
||||
}
|
||||
return strings.Join(have, " ")
|
||||
}
|
||||
|
||||
func choosePassword(fromStdin bool) (pw string, generated bool, err error) {
|
||||
switch {
|
||||
case fromStdin:
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
pw = strings.TrimRight(line, "\r\n")
|
||||
case term.IsTerminal(int(os.Stdin.Fd())):
|
||||
fmt.Fprint(os.Stderr, "Login password for the web console (12+ characters, empty to generate one): ")
|
||||
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(p1) > 0 {
|
||||
fmt.Fprint(os.Stderr, "Again: ")
|
||||
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !bytes.Equal(p1, p2) {
|
||||
return "", false, errors.New("passwords do not match")
|
||||
}
|
||||
}
|
||||
pw = string(p1)
|
||||
}
|
||||
if pw == "" && !fromStdin {
|
||||
return randomPassword(), true, nil
|
||||
}
|
||||
if len(pw) < 12 {
|
||||
return "", false, errors.New("password must be at least 12 characters")
|
||||
}
|
||||
return pw, false, nil
|
||||
}
|
||||
|
||||
func randomPassword() string {
|
||||
const alphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no look-alikes
|
||||
b := make([]byte, 20)
|
||||
_, _ = rand.Read(b)
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// CopyAtomic puts src at dst with mode 0755 by writing beside it and renaming, so dst is
|
||||
// always either the old file or the whole new one, and a running dst is never written to.
|
||||
func CopyAtomic(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
tmp := filepath.Join(filepath.Dir(dst), "."+filepath.Base(dst)+".new")
|
||||
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, in)
|
||||
if err == nil {
|
||||
err = out.Sync()
|
||||
}
|
||||
if cerr := out.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Chmod(tmp, 0o755)
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Rename(tmp, dst)
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmp)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// firstMissing returns the topmost directory that creating dir would have to make, or "".
|
||||
func firstMissing(dir string) string {
|
||||
missing := ""
|
||||
for d := filepath.Clean(dir); d != "/" && d != "." && !exists(d); d = filepath.Dir(d) {
|
||||
missing = d
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// chownData hands Ham Lab's data to the account that runs it. Directories this install
|
||||
// created are ours to hand over whole. In one that already existed (a --data pointed at
|
||||
// something shared), only the files Ham Lab writes change owner: a recursive chown of
|
||||
// somebody else's tree can't be undone.
|
||||
func chownData(dataDir, made string, a account) error {
|
||||
if made != "" {
|
||||
return chownTree(made, a)
|
||||
}
|
||||
if filepath.Base(dataDir) == "hamlab" {
|
||||
return chownTree(dataDir, a)
|
||||
}
|
||||
for _, f := range ownFiles {
|
||||
if p := filepath.Join(dataDir, f); exists(p) {
|
||||
if err := os.Lchown(p, a.uid, a.gid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chownTree(dir string, a account) error {
|
||||
return filepath.Walk(dir, func(p string, _ os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Lchown(p, a.uid, a.gid)
|
||||
})
|
||||
}
|
||||
|
||||
func fill(tmpl string, vars map[string]string) string {
|
||||
for k, v := range vars {
|
||||
tmpl = strings.ReplaceAll(tmpl, k, v)
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func dropLines(text, prefix string) string {
|
||||
var keep []string
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if !strings.HasPrefix(l, prefix) {
|
||||
keep = append(keep, l)
|
||||
}
|
||||
}
|
||||
return strings.Join(keep, "\n")
|
||||
}
|
||||
|
||||
// quietSudoers keeps the daemon's read-only polling (SMART, UPS, journal) out of the auth
|
||||
// log: at 250 ms refresh rates the sudo session lines would otherwise dominate it.
|
||||
func quietSudoers() {
|
||||
if !isDir("/etc/sudoers.d") || !have("visudo") {
|
||||
return
|
||||
}
|
||||
var cmds []string
|
||||
for _, bin := range []string{"smartctl", "pwrstat", "journalctl", "tail", "cat", "ls", "crontab"} {
|
||||
for _, dir := range []string{"/usr/sbin", "/usr/bin", "/sbin", "/bin"} {
|
||||
if exists(dir + "/" + bin) {
|
||||
cmds = append(cmds, dir+"/"+bin)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, bin := range []string{"zpool", "zfs"} { // listing only: scrubs and snapshots still log
|
||||
for _, dir := range []string{"/usr/sbin", "/sbin", "/usr/bin", "/bin"} {
|
||||
if exists(dir + "/" + bin) {
|
||||
cmds = append(cmds, dir+"/"+bin+" list *", dir+"/"+bin+" status *")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cmds) == 0 {
|
||||
return
|
||||
}
|
||||
text := "# Ham Lab: don't log the daemon's read-only polling commands (written by hamlab install)\n" +
|
||||
"Cmnd_Alias HAMLAB_QUIET = " + strings.Join(cmds, ", ") + "\n" +
|
||||
"Defaults!HAMLAB_QUIET !syslog, !pam_session, !pam_setcred\n"
|
||||
tmp := sudoersPath + ".new"
|
||||
if os.WriteFile(tmp, []byte(text), 0o440) != nil {
|
||||
return
|
||||
}
|
||||
if exec.Command("visudo", "-cf", tmp).Run() != nil {
|
||||
os.Remove(tmp)
|
||||
say("warning: sudoers snippet failed validation; skipped")
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, sudoersPath)
|
||||
}
|
||||
|
||||
// firewall reports (and with open, fixes) an active host firewall in front of a
|
||||
// non-loopback listen address. It returns the port.
|
||||
func firewall(listen string, open bool) string {
|
||||
host, port, _ := net.SplitHostPort(listen)
|
||||
if isLoopback(host) {
|
||||
return port
|
||||
}
|
||||
switch {
|
||||
case have("firewall-cmd") && exec.Command("firewall-cmd", "--state").Run() == nil:
|
||||
if open {
|
||||
if err := runAll([][]string{{"firewall-cmd", "--quiet", "--permanent", "--add-port=" + port + "/tcp"}, {"firewall-cmd", "--quiet", "--reload"}}); err != nil {
|
||||
say("warning: firewalld: %v", err)
|
||||
} else {
|
||||
say("firewall firewalld: opened %s/tcp", port)
|
||||
}
|
||||
} else {
|
||||
say("firewall firewalld is active and will block port %s. Open it: hamlab install --open-firewall", port)
|
||||
}
|
||||
case have("ufw"):
|
||||
out, _ := exec.Command("ufw", "status").Output()
|
||||
if !bytes.Contains(out, []byte("Status: active")) {
|
||||
break
|
||||
}
|
||||
if open {
|
||||
if err := run("ufw", "allow", port+"/tcp"); err != nil {
|
||||
say("warning: ufw: %v", err)
|
||||
} else {
|
||||
say("firewall ufw: opened %s/tcp", port)
|
||||
}
|
||||
} else {
|
||||
say("firewall ufw is active and will block port %s. Open it: hamlab install --open-firewall", port)
|
||||
}
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
ip := net.ParseIP(host)
|
||||
return host == "localhost" || (ip != nil && ip.IsLoopback())
|
||||
}
|
||||
|
||||
// browseAddr turns a listen address into one a browser can open.
|
||||
func browseAddr(listen string) string {
|
||||
host, port, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
return listen
|
||||
}
|
||||
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
||||
host = "localhost"
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
for _, a := range addrs {
|
||||
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && !n.IP.IsLoopback() {
|
||||
host = n.IP.String()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// healthy polls /healthz for up to 10 s.
|
||||
func healthy(listen string, https bool) bool {
|
||||
host, port, _ := net.SplitHostPort(listen)
|
||||
if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
scheme := "http"
|
||||
if https {
|
||||
scheme = "https"
|
||||
}
|
||||
c := &http.Client{Timeout: 2 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} // self-signed; reachability only
|
||||
for i := 0; i < 20; i++ {
|
||||
if resp, err := c.Get(scheme + "://" + net.JoinHostPort(host, port) + "/healthz"); err == nil {
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode < 500
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// restorecon fixes the SELinux label of a file we created, where SELinux exists.
|
||||
func restorecon(path string) {
|
||||
if have("restorecon") {
|
||||
_ = exec.Command("restorecon", "-F", path).Run()
|
||||
}
|
||||
}
|
||||
|
||||
func run(args ...string) error {
|
||||
out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return fmt.Errorf("%s: %s", strings.Join(args, " "), msg)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAll(cmds [][]string) error {
|
||||
for _, c := range cmds {
|
||||
if err := run(c...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func have(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
|
||||
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||
func isDir(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() }
|
||||
@@ -0,0 +1,73 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnitWithoutJournalGroups(t *testing.T) {
|
||||
vars := map[string]string{"@USER@": "ham", "@GROUP@": "ham", "@HOME@": "/home/ham", "@BIN@": "/usr/local/bin/hamlab",
|
||||
"@CONFIG@": "/home/ham/.config/hamlab/config.json", "@GROUPS@": ""}
|
||||
unit := dropLines(fill(systemdUnit, vars), "SupplementaryGroups=")
|
||||
if strings.Contains(unit, "SupplementaryGroups") || strings.Contains(unit, "@") {
|
||||
t.Fatalf("unit still has a groups line or an unfilled placeholder:\n%s", unit)
|
||||
}
|
||||
if !strings.Contains(unit, "ExecStart=/usr/local/bin/hamlab serve") {
|
||||
t.Fatalf("ExecStart missing:\n%s", unit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDirFrom(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Environment=HAMLAB_CONFIG=/home/ham/.config/hamlab/config.json": "/home/ham/.config/hamlab",
|
||||
`export HAMLAB_CONFIG="/root/.config/hamlab/config.json"`: "/root/.config/hamlab",
|
||||
"Environment=HAMLAB_CONFIG=/config.json": "",
|
||||
"Environment=HAMLAB_CONFIG=/etc/passwd": "",
|
||||
} {
|
||||
key := "Environment=HAMLAB_CONFIG="
|
||||
if strings.HasPrefix(in, "export") {
|
||||
key = "export HAMLAB_CONFIG="
|
||||
}
|
||||
if got := configDirFrom(in, key); got != want {
|
||||
t.Errorf("%q: got %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --purge must never take a shared directory with it.
|
||||
func TestPurgeSparesSharedDirectory(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
os.WriteFile(filepath.Join(home, "config.json"), []byte("{}"), 0o600)
|
||||
os.WriteFile(filepath.Join(home, "thesis.txt"), []byte("irreplaceable"), 0o600)
|
||||
if err := purgeData(home); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "thesis.txt")); err != nil {
|
||||
t.Fatal("purge deleted a file that wasn't Ham Lab's")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "config.json")); err == nil {
|
||||
t.Fatal("purge left config.json behind")
|
||||
}
|
||||
|
||||
own := filepath.Join(t.TempDir(), "hamlab")
|
||||
os.MkdirAll(own, 0o700)
|
||||
os.WriteFile(filepath.Join(own, "config.json"), []byte("{}"), 0o600)
|
||||
if err := purgeData(own); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(own); err == nil {
|
||||
t.Fatal("purge left Ham Lab's own directory behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstMissing(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
if got := firstMissing(filepath.Join(base, "a", "b", "c")); got != filepath.Join(base, "a") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := firstMissing(base); got != "" {
|
||||
t.Errorf("existing dir: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package space
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// The tree crosses from the helper process to the daemon as a preorder stream of varints:
|
||||
// a few bytes per node, decoded without reflection, so a million nodes take well under a
|
||||
// second either way.
|
||||
|
||||
const codecMagic = "HLSPACE2"
|
||||
|
||||
func Encode(w io.Writer, root *Node) error {
|
||||
bw := bufio.NewWriterSize(w, 1<<20)
|
||||
if _, err := bw.WriteString(codecMagic); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf [binary.MaxVarintLen64]byte
|
||||
putU := func(v uint64) error { n := binary.PutUvarint(buf[:], v); _, err := bw.Write(buf[:n]); return err }
|
||||
putI := func(v int64) error { n := binary.PutVarint(buf[:], v); _, err := bw.Write(buf[:n]); return err }
|
||||
var enc func(n *Node) error
|
||||
enc = func(n *Node) error {
|
||||
if err := putU(uint64(len(n.Name))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := bw.WriteString(n.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
flags := uint64(0)
|
||||
if n.Dir {
|
||||
flags |= 1
|
||||
}
|
||||
if n.Mount {
|
||||
flags |= 2
|
||||
}
|
||||
if n.Denied {
|
||||
flags |= 4
|
||||
}
|
||||
if n.Partial {
|
||||
flags |= 8
|
||||
}
|
||||
for _, f := range []func() error{
|
||||
func() error { return putU(flags) },
|
||||
func() error { return putI(n.Size) },
|
||||
func() error { return putI(n.Apparent) },
|
||||
func() error { return putI(n.MTime) },
|
||||
func() error { return putI(n.MTimeNs) },
|
||||
func() error { return putU(uint64(n.Files)) },
|
||||
func() error { return putU(uint64(n.Dirs)) },
|
||||
func() error { return putU(uint64(n.SmallN)) },
|
||||
func() error { return putI(n.SmallSize) },
|
||||
func() error { return putU(uint64(len(n.Children))) },
|
||||
} {
|
||||
if err := f(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
if err := enc(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := enc(root); err != nil {
|
||||
return err
|
||||
}
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
func Decode(r io.Reader) (*Node, error) {
|
||||
br := bufio.NewReaderSize(r, 1<<20)
|
||||
magic := make([]byte, len(codecMagic))
|
||||
if _, err := io.ReadFull(br, magic); err != nil || string(magic) != codecMagic {
|
||||
return nil, errors.New("space: not a scan stream")
|
||||
}
|
||||
var dec func() (*Node, error)
|
||||
dec = func() (*Node, error) {
|
||||
l, err := binary.ReadUvarint(br)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l > 1<<16 {
|
||||
return nil, errors.New("space: bad name length")
|
||||
}
|
||||
name := make([]byte, l)
|
||||
if _, err := io.ReadFull(br, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := &Node{Name: string(name)}
|
||||
flags, err := binary.ReadUvarint(br)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Dir, n.Mount, n.Denied, n.Partial = flags&1 != 0, flags&2 != 0, flags&4 != 0, flags&8 != 0
|
||||
if n.Size, err = binary.ReadVarint(br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.Apparent, err = binary.ReadVarint(br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.MTime, err = binary.ReadVarint(br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.MTimeNs, err = binary.ReadVarint(br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := binary.ReadUvarint(br)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Files = uint32(u)
|
||||
if u, err = binary.ReadUvarint(br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Dirs = uint32(u)
|
||||
if u, err = binary.ReadUvarint(br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.SmallN = uint32(u)
|
||||
if n.SmallSize, err = binary.ReadVarint(br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cnt, err := binary.ReadUvarint(br)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cnt > 1<<24 {
|
||||
return nil, errors.New("space: bad child count")
|
||||
}
|
||||
if cnt > 0 {
|
||||
n.Children = make([]*Node, 0, cnt)
|
||||
for i := uint64(0); i < cnt; i++ {
|
||||
c, err := dec()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Children = append(n.Children, c)
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
return dec()
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
package space
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
// ---- the helper: `hamlab space-walk <root>…`, run by the daemon (as root when it can) ----
|
||||
//
|
||||
// The walk runs in its own process so that root can read every directory, so that the
|
||||
// memory of the walk is given back when it ends, and so that a runaway scan can be killed
|
||||
// without touching the console. The daemon writes a header line to the helper's stdin
|
||||
// (how each root is to be walked, and what changed) followed by the last scan's tree when
|
||||
// there is one, then holds stdin open: EOF means the daemon has gone or wants the walk
|
||||
// stopped. Progress goes to stderr as JSON lines; a result line and the tree to stdout.
|
||||
|
||||
type header struct {
|
||||
Prev bool `json:"prev"`
|
||||
Modes map[string]Mode `json:"modes,omitempty"`
|
||||
Dirty []string `json:"dirty,omitempty"`
|
||||
Touched []string `json:"touched,omitempty"`
|
||||
}
|
||||
|
||||
type resultLine struct {
|
||||
Complete bool `json:"complete"`
|
||||
Partial int64 `json:"partial"`
|
||||
Reused int64 `json:"reused"`
|
||||
Read int64 `json:"read"`
|
||||
}
|
||||
|
||||
func Helper(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, "usage: hamlab space-walk <mountpoint>...")
|
||||
return 2
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
|
||||
go func() { <-sig; cancel() }()
|
||||
in := bufio.NewReaderSize(stdin, 1<<20)
|
||||
line, err := in.ReadBytes('\n')
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "error: no header:", err)
|
||||
return 2
|
||||
}
|
||||
var h header
|
||||
if err := json.Unmarshal(line, &h); err != nil {
|
||||
fmt.Fprintln(stderr, "error: bad header:", err)
|
||||
return 2
|
||||
}
|
||||
opts := Options{Modes: h.Modes, Dirty: set(h.Dirty), Touched: set(h.Touched)}
|
||||
if h.Prev {
|
||||
if opts.Prev, err = Decode(in); err != nil {
|
||||
fmt.Fprintln(stderr, "error: last tree:", err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
go func() { _, _ = io.Copy(io.Discard, in); cancel() }() // the daemon went away, or asked us to stop
|
||||
enc := json.NewEncoder(stderr)
|
||||
res, err := Walk(ctx, args, opts, func(p Progress) { _ = enc.Encode(p) })
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
fmt.Fprintln(stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
rl, _ := json.Marshal(resultLine{Complete: res.Complete, Partial: res.Partial, Reused: res.Reused, Read: res.Read})
|
||||
if _, err := stdout.Write(append(rl, '\n')); err != nil {
|
||||
return 1
|
||||
}
|
||||
if err := Encode(stdout, res.Root); err != nil {
|
||||
fmt.Fprintln(stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if !res.Complete {
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func set(list []string) map[string]bool {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]bool, len(list))
|
||||
for _, s := range list {
|
||||
m[s] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---- the daemon side: one scan per target, the last result kept, on disk too ----
|
||||
|
||||
type Scan struct {
|
||||
Disk string `json:"disk"`
|
||||
Roots []string `json:"roots"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished time.Time `json:"finished,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
Loading bool `json:"loading,omitempty"` // read back from disk after a restart, not yet decoded
|
||||
Error string `json:"error,omitempty"`
|
||||
Progress Progress `json:"progress"`
|
||||
AsRoot bool `json:"as_root"`
|
||||
// How this scan was done: "full", or "refresh" / "resume" from the one before, and by what
|
||||
// means per root ("zfs diff" or "mtime").
|
||||
Method string `json:"method,omitempty"`
|
||||
ByRoot map[string]string `json:"by_root,omitempty"`
|
||||
Partial bool `json:"partial,omitempty"` // cancelled before it finished; Scan again resumes it
|
||||
// once finished:
|
||||
Files uint32 `json:"files"`
|
||||
Dirs uint32 `json:"dirs"`
|
||||
Size int64 `json:"size"`
|
||||
Denied int64 `json:"denied"`
|
||||
Read int64 `json:"read"` // directories read this time
|
||||
Reused int64 `json:"reused"` // directories kept from the scan before
|
||||
PartialDirs int64 `json:"partial_dirs"` // directories not read (cancelled)
|
||||
// Bases is, per root that is a ZFS dataset, the newest snapshot that existed when the
|
||||
// scan started: the next scan asks `zfs diff` what changed since it.
|
||||
Bases map[string]string `json:"bases,omitempty"`
|
||||
|
||||
root *Node
|
||||
largest []File
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
self string // this binary, for the helper
|
||||
dir string // where results are kept between restarts
|
||||
mu sync.Mutex
|
||||
scans map[string]*Scan
|
||||
}
|
||||
|
||||
// New makes a manager that keeps results under dir and reads back the ones there.
|
||||
func New(self, dir string) *Manager {
|
||||
m := &Manager{self: self, dir: dir, scans: map[string]*Scan{}}
|
||||
m.loadAll()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) fileFor(id, ext string) string {
|
||||
safe := strings.NewReplacer(":", "_", "/", "_", "\\", "_").Replace(id)
|
||||
return filepath.Join(m.dir, safe+ext)
|
||||
}
|
||||
|
||||
// loadAll reads the saved scans' metadata now and their trees in the background, one at a
|
||||
// time, so a restart does not double the memory of the last scans all at once.
|
||||
func (m *Manager) loadAll() {
|
||||
metas, _ := filepath.Glob(filepath.Join(m.dir, "*.json"))
|
||||
var pending []*Scan
|
||||
for _, mp := range metas {
|
||||
b, err := os.ReadFile(mp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := &Scan{}
|
||||
if json.Unmarshal(b, s) != nil || s.Disk == "" {
|
||||
continue
|
||||
}
|
||||
s.Running, s.cancel = false, nil
|
||||
s.Loading = true
|
||||
m.scans[s.Disk] = s
|
||||
pending = append(pending, s)
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for _, s := range pending {
|
||||
root, err := m.readTree(s.Disk)
|
||||
m.mu.Lock()
|
||||
if err != nil {
|
||||
log.Printf("space: saved scan of %s unreadable (%v); dropped", s.Disk, err)
|
||||
if m.scans[s.Disk] == s {
|
||||
delete(m.scans, s.Disk)
|
||||
}
|
||||
} else if m.scans[s.Disk] == s {
|
||||
s.root, s.Loading = root, false
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) readTree(id string) (*Node, error) {
|
||||
f, err := os.Open(m.fileFor(id, ".scan"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
br := bufio.NewReaderSize(f, 1<<20)
|
||||
if _, err := br.ReadBytes('\n'); err != nil { // the result line
|
||||
return nil, err
|
||||
}
|
||||
return Decode(br)
|
||||
}
|
||||
|
||||
func (m *Manager) saveMeta(s *Scan) {
|
||||
if m.dir == "" {
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(m.dir, 0o700)
|
||||
b, err := json.MarshalIndent(s, "", " ")
|
||||
if err == nil {
|
||||
err = writeAtomic(m.fileFor(s.Disk, ".json"), b)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("space: save %s: %v", s.Disk, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAtomic(path string, b []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// Status is the scan of id (a copy without the tree), or nil.
|
||||
func (m *Manager) Status(id string) *Scan {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.copyOf(m.scans[id])
|
||||
}
|
||||
|
||||
func (m *Manager) copyOf(s *Scan) *Scan {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
c := *s
|
||||
c.root, c.largest, c.cancel = nil, nil, nil
|
||||
return &c
|
||||
}
|
||||
|
||||
func (m *Manager) All() map[string]*Scan {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := map[string]*Scan{}
|
||||
for k, s := range m.scans {
|
||||
out[k] = m.copyOf(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start scans the roots of id in the background, from the last scan of it when there is
|
||||
// one. datasets names the ZFS dataset behind each root that has one.
|
||||
func (m *Manager) Start(id string, roots []string, datasets map[string]string) (*Scan, error) {
|
||||
if len(roots) == 0 {
|
||||
return nil, errors.New("nothing is mounted from this disk")
|
||||
}
|
||||
m.mu.Lock()
|
||||
prev := m.scans[id]
|
||||
if prev != nil && prev.Running {
|
||||
m.mu.Unlock()
|
||||
return nil, errors.New("a scan of this disk is already running")
|
||||
}
|
||||
if prev != nil && (prev.root == nil || prev.Loading) {
|
||||
prev = nil // nothing to start from
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Scan{Disk: id, Roots: roots, Started: time.Now(), Running: true, AsRoot: priv.Available(), cancel: cancel, Bases: map[string]string{}}
|
||||
if prev != nil {
|
||||
// the new scan takes the old one's place in the list while it runs, but keeps its
|
||||
// tree, so the pages still answer until the new one is in
|
||||
s.root, s.largest = prev.root, prev.largest
|
||||
s.Files, s.Dirs, s.Size = prev.Files, prev.Dirs, prev.Size
|
||||
}
|
||||
m.scans[id] = s
|
||||
m.mu.Unlock()
|
||||
go m.run(ctx, s, prev, datasets)
|
||||
return m.Status(id), nil
|
||||
}
|
||||
|
||||
// plan decides how each root is walked this time, given the last scan.
|
||||
func (m *Manager) plan(ctx context.Context, s, prev *Scan, datasets map[string]string) (header, Options) {
|
||||
h := header{Modes: map[string]Mode{}}
|
||||
opts := Options{Modes: h.Modes}
|
||||
if prev == nil || prev.root == nil {
|
||||
s.Method = "full"
|
||||
return h, opts
|
||||
}
|
||||
h.Prev = true
|
||||
opts.Prev = prev.root
|
||||
s.ByRoot = map[string]string{}
|
||||
dirty, touched := map[string]bool{}, map[string]bool{}
|
||||
// every dataset is asked at once what changed; the answers come back in root order
|
||||
type diffed struct {
|
||||
changed []change
|
||||
err error
|
||||
took time.Duration
|
||||
}
|
||||
answers := map[string]chan diffed{}
|
||||
for _, r := range s.Roots {
|
||||
if base := prev.Bases[r]; base != "" && datasets[r] != "" {
|
||||
ch := make(chan diffed, 1)
|
||||
answers[r] = ch
|
||||
go func(r, base string) {
|
||||
t0 := time.Now()
|
||||
c, err := zfsDiff(ctx, base, r)
|
||||
ch <- diffed{changed: c, err: err, took: time.Since(t0)}
|
||||
}(r, base)
|
||||
}
|
||||
}
|
||||
for _, r := range s.Roots {
|
||||
mode := ModeMTime
|
||||
how := "mtime"
|
||||
if ch, ok := answers[r]; ok {
|
||||
m.mu.Lock()
|
||||
s.Progress.Current = "asking zfs what changed in " + r
|
||||
m.mu.Unlock()
|
||||
d := <-ch
|
||||
if d.err == nil {
|
||||
mode, how = ModeDiff, fmt.Sprintf("zfs diff since %s: %d changes in %s", strings.TrimPrefix(prev.Bases[r], datasets[r]), len(d.changed), d.took.Round(time.Millisecond))
|
||||
for _, c := range d.changed {
|
||||
dirty[c.parent] = true
|
||||
if c.dir {
|
||||
dirty[c.path] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
how = "mtime (zfs diff: " + d.err.Error() + ")"
|
||||
}
|
||||
}
|
||||
if Find(prev.root, r) == nil {
|
||||
mode, how = ModeFull, "full (new root)"
|
||||
}
|
||||
h.Modes[r] = mode
|
||||
s.ByRoot[r] = how
|
||||
}
|
||||
m.mu.Lock()
|
||||
s.Progress.Current = ""
|
||||
m.mu.Unlock()
|
||||
// what the last walk never got to is read whatever the change list says
|
||||
for _, p := range partialPaths(prev.root, "/") {
|
||||
dirty[p] = true
|
||||
}
|
||||
for p := range dirty {
|
||||
for a := filepath.Dir(p); ; a = filepath.Dir(a) {
|
||||
touched[a] = true
|
||||
if a == "/" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for p := range dirty {
|
||||
h.Dirty = append(h.Dirty, p)
|
||||
}
|
||||
for p := range touched {
|
||||
h.Touched = append(h.Touched, p)
|
||||
}
|
||||
sort.Strings(h.Dirty)
|
||||
sort.Strings(h.Touched)
|
||||
opts.Dirty, opts.Touched = dirty, touched
|
||||
if prev.Partial {
|
||||
s.Method = "resume"
|
||||
} else {
|
||||
s.Method = "refresh"
|
||||
}
|
||||
return h, opts
|
||||
}
|
||||
|
||||
func partialPaths(n *Node, path string) []string {
|
||||
var out []string
|
||||
var walk func(n *Node, path string)
|
||||
walk = func(n *Node, path string) {
|
||||
if n.Partial {
|
||||
out = append(out, path)
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
if c.Dir {
|
||||
walk(c, join(path, c.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(n, path)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) run(ctx context.Context, s *Scan, prev *Scan, datasets map[string]string) {
|
||||
finish := func(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s.Running = false
|
||||
s.Finished = time.Now()
|
||||
if err != nil {
|
||||
s.Error = err.Error()
|
||||
}
|
||||
m.saveMeta(s)
|
||||
}
|
||||
// the newest snapshot of each dataset, so the next scan can ask what changed since
|
||||
for _, r := range s.Roots {
|
||||
if ds := datasets[r]; ds != "" {
|
||||
if snap := newestSnapshot(ctx, ds); snap != "" {
|
||||
s.Bases[r] = snap
|
||||
}
|
||||
}
|
||||
}
|
||||
h, _ := m.plan(ctx, s, prev, datasets)
|
||||
if ctx.Err() != nil { // cancelled while zfs was being asked
|
||||
finish(errors.New("cancelled"))
|
||||
return
|
||||
}
|
||||
args := append([]string{m.self, "space-walk"}, s.Roots...)
|
||||
if s.AsRoot {
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
stdin, _ := cmd.StdinPipe() // held open: closing it tells the helper to stop
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
if err := cmd.Start(); err != nil {
|
||||
finish(err)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
hb, _ := json.Marshal(h)
|
||||
_, err := stdin.Write(append(hb, '\n'))
|
||||
if err == nil && h.Prev {
|
||||
err = Encode(stdin, prev.root)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("space: %s: sending the last tree: %v", s.Disk, err)
|
||||
}
|
||||
<-ctx.Done()
|
||||
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||
stdin.Close()
|
||||
}()
|
||||
var lastErr string
|
||||
go func() {
|
||||
sc := bufio.NewScanner(stderr)
|
||||
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
var p Progress
|
||||
if strings.HasPrefix(line, "{") && json.Unmarshal([]byte(line), &p) == nil {
|
||||
m.mu.Lock()
|
||||
s.Progress = p
|
||||
m.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if line != "" {
|
||||
lastErr = line
|
||||
}
|
||||
}
|
||||
}()
|
||||
// the result streams to disk as it is decoded, so a restart finds it
|
||||
_ = os.MkdirAll(m.dir, 0o700)
|
||||
tmp := m.fileFor(s.Disk, ".scan.tmp")
|
||||
out, ferr := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
var src io.Reader = stdout
|
||||
if ferr == nil {
|
||||
src = io.TeeReader(stdout, out)
|
||||
}
|
||||
br := bufio.NewReaderSize(src, 1<<20)
|
||||
var rl resultLine
|
||||
line, rerr := br.ReadBytes('\n')
|
||||
if rerr == nil {
|
||||
rerr = json.Unmarshal(line, &rl)
|
||||
}
|
||||
var root *Node
|
||||
var decErr error
|
||||
if rerr == nil {
|
||||
root, decErr = Decode(br)
|
||||
} else {
|
||||
decErr = rerr
|
||||
_, _ = io.Copy(io.Discard, br)
|
||||
}
|
||||
if ferr == nil {
|
||||
out.Close()
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
cancelled := ctx.Err() != nil
|
||||
if root == nil {
|
||||
_ = os.Remove(tmp)
|
||||
switch {
|
||||
case cancelled:
|
||||
finish(errors.New("cancelled"))
|
||||
case waitErr != nil && lastErr != "":
|
||||
finish(errors.New(strings.TrimPrefix(lastErr, "error: ")))
|
||||
case waitErr != nil:
|
||||
finish(waitErr)
|
||||
default:
|
||||
finish(decErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ferr == nil {
|
||||
_ = os.Rename(tmp, m.fileFor(s.Disk, ".scan"))
|
||||
}
|
||||
m.mu.Lock()
|
||||
s.root, s.largest = root, nil
|
||||
s.Files, s.Dirs, s.Size, s.Denied = root.Files, root.Dirs, root.Size, s.Progress.Denied
|
||||
s.Read, s.Reused, s.PartialDirs = rl.Read, rl.Reused, rl.Partial
|
||||
s.Partial = !rl.Complete
|
||||
s.Running = false
|
||||
s.Finished = time.Now()
|
||||
if s.Partial {
|
||||
s.Error = "cancelled"
|
||||
}
|
||||
m.saveMeta(s)
|
||||
m.mu.Unlock()
|
||||
what := "scanned"
|
||||
if s.Partial {
|
||||
what = "partly scanned (cancelled)"
|
||||
}
|
||||
log.Printf("space: %s %s: %d files, %d dirs, %d bytes in %s (%s; %d dirs read, %d kept%s)", s.Disk, what, root.Files, root.Dirs, root.Size, s.Finished.Sub(s.Started).Round(time.Millisecond), s.Method, rl.Read, rl.Reused, map[bool]string{true: ", as root", false: ""}[s.AsRoot])
|
||||
}
|
||||
|
||||
func (m *Manager) Cancel(id string) bool {
|
||||
m.mu.Lock()
|
||||
s := m.scans[id]
|
||||
m.mu.Unlock()
|
||||
if s == nil || !s.Running || s.cancel == nil {
|
||||
return false
|
||||
}
|
||||
s.cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
// Forget drops the scan of id, in memory and on disk.
|
||||
func (m *Manager) Forget(id string) {
|
||||
m.mu.Lock()
|
||||
if s := m.scans[id]; s != nil && s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
delete(m.scans, id)
|
||||
m.mu.Unlock()
|
||||
_ = os.Remove(m.fileFor(id, ".scan"))
|
||||
_ = os.Remove(m.fileFor(id, ".json"))
|
||||
}
|
||||
|
||||
// ---- ZFS: what changed since the last scan ----
|
||||
|
||||
type change struct {
|
||||
path, parent string
|
||||
dir bool
|
||||
}
|
||||
|
||||
// newestSnapshot is the most recent snapshot of a dataset, "" when it has none.
|
||||
func newestSnapshot(ctx context.Context, dataset string) string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "zfs", "list", "-H", "-t", "snapshot", "-o", "name", "-s", "creation", "-d", "1", dataset).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
last := lines[len(lines)-1]
|
||||
if !strings.Contains(last, "@") {
|
||||
return ""
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// zfsDiff lists what changed in a dataset since a snapshot: every changed, added, removed
|
||||
// or renamed path, with the directory whose listing that touches.
|
||||
func zfsDiff(ctx context.Context, snapshot, mount string) ([]change, error) {
|
||||
// A diff slower than this is not worth waiting for: the walk by mtime is bounded. The
|
||||
// clock runs inside sudo — `timeout` is zfs's parent and kills it as root — because sudo
|
||||
// on Debian gives the command a session of its own, out of reach of our process group;
|
||||
// the context is only a backstop.
|
||||
const patience = 3 * time.Minute
|
||||
ctx, cancel := context.WithTimeout(ctx, patience+30*time.Second)
|
||||
defer cancel()
|
||||
args := priv.Wrap("timeout", "-s", "KILL", strconv.Itoa(int(patience.Seconds())), "zfs", "diff", "-H", "-F", snapshot)
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
t0 := time.Now()
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changes []change
|
||||
sc := bufio.NewScanner(out)
|
||||
sc.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for sc.Scan() {
|
||||
f := strings.Split(sc.Text(), "\t")
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
isDir := f[1] == "/"
|
||||
for _, p := range f[2:] { // a rename has the old and the new path
|
||||
p = unescape(p)
|
||||
if !strings.HasPrefix(p, mount) {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, change{path: p, parent: filepath.Dir(p), dir: isDir})
|
||||
}
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) || time.Since(t0) >= patience {
|
||||
msg = "gave up after " + patience.String()
|
||||
}
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
if i := strings.IndexByte(msg, '\n'); i > 0 {
|
||||
msg = msg[:i]
|
||||
}
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// unescape undoes zfs diff's \ooo escapes for spaces and odd bytes in names.
|
||||
func unescape(s string) string {
|
||||
if !strings.Contains(s, "\\") {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' && i+3 < len(s) {
|
||||
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
||||
b.WriteByte(byte(v))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Tree is the subtree at path, cut to depth levels of at most limit children each, leaving
|
||||
// out children smaller than min bytes; what is left out is summed into MoreN/MoreSize. A
|
||||
// treemap asks deep with a min the size of the smallest tile it could draw; a folder list
|
||||
// asks one level with no min.
|
||||
type Tree struct {
|
||||
Node
|
||||
Path string `json:"path"`
|
||||
Children []*Tree `json:"children,omitempty"`
|
||||
MoreN int `json:"more_n,omitempty"`
|
||||
MoreSize int64 `json:"more_size,omitempty"`
|
||||
Total int64 `json:"total"` // the whole scan's size, for percentages
|
||||
}
|
||||
|
||||
func (m *Manager) Tree(id, path string, depth, limit int, min int64) (*Tree, error) {
|
||||
m.mu.Lock()
|
||||
s := m.scans[id]
|
||||
var root *Node
|
||||
if s != nil {
|
||||
root = s.root
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if root == nil {
|
||||
if s != nil && s.Loading {
|
||||
return nil, errors.New("the last scan is still being read back")
|
||||
}
|
||||
return nil, errors.New("no scan of this disk yet")
|
||||
}
|
||||
n := Find(root, path)
|
||||
if n == nil {
|
||||
return nil, errors.New("no such path in the scan")
|
||||
}
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
t := cut(n, path, depth, limit, min)
|
||||
t.Total = root.Size
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func cut(n *Node, path string, depth, limit int, min int64) *Tree {
|
||||
t := &Tree{Node: *n, Path: path}
|
||||
t.Node.Children = nil
|
||||
if !n.Dir {
|
||||
return t
|
||||
}
|
||||
if depth <= 0 { // as deep as was asked: what is inside is one lump, not nothing
|
||||
if len(n.Children) > 0 {
|
||||
t.MoreN, t.MoreSize = len(n.Children), n.Size-n.SmallSize
|
||||
}
|
||||
return t
|
||||
}
|
||||
for i, c := range n.Children {
|
||||
if i >= limit || (c.Size < min && i > 0) { // children are sorted largest first: the first is always kept
|
||||
t.MoreN++
|
||||
t.MoreSize += c.Size
|
||||
continue
|
||||
}
|
||||
t.Children = append(t.Children, cut(c, join(path, c.Name), depth-1, limit, min))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Largest is the n biggest files of the last scan of id.
|
||||
func (m *Manager) Largest(id string, n int) ([]File, error) {
|
||||
m.mu.Lock()
|
||||
s := m.scans[id]
|
||||
var root *Node
|
||||
var have []File
|
||||
if s != nil {
|
||||
root, have = s.root, s.largest
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if root == nil {
|
||||
return nil, errors.New("no scan of this disk yet")
|
||||
}
|
||||
if len(have) < n {
|
||||
have = Largest(root, n) // the tree is never changed once made, so no lock while walking it
|
||||
m.mu.Lock()
|
||||
if s.root == root {
|
||||
s.largest = have
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
if len(have) > n {
|
||||
return have[:n], nil
|
||||
}
|
||||
return have, nil
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
// Package space measures what is using a disk — it is what the UI calls the **Usage** tab
|
||||
// (renamed from "Space" on 2026-09-24; the package and its /api/space endpoints kept the
|
||||
// old name rather than churn a working API): a parallel walk of the filesystems a disk
|
||||
// backs, kept as a tree of directories and the files worth seeing, the way WizTree does it.
|
||||
//
|
||||
// The walk never crosses a filesystem boundary by itself — /proc, /sys, another disk's
|
||||
// mount — but every mountpoint the disk backs is a root of the same scan, and the roots
|
||||
// are grafted into one tree at their paths, so a ZFS pool of many datasets, or a disk with
|
||||
// / and /home on separate partitions, reads as one picture.
|
||||
//
|
||||
// Files below KeepMin are not kept one by one: a directory of a million small files is
|
||||
// one "smaller files" entry with their count and total, which is all a treemap can show
|
||||
// of them anyway, and what keeps a scan of a few million files within a few hundred MB.
|
||||
//
|
||||
// A walk can start from the last one. Given the previous tree, a directory is read again
|
||||
// only when something says it changed: on ZFS, `zfs diff` against a snapshot older than
|
||||
// the last scan names every path that changed, and directories it does not name are kept
|
||||
// as they were without so much as a stat; elsewhere a directory whose mtime is what it was
|
||||
// keeps its list and only its kept files are stat'ed again, since a file can grow without
|
||||
// its directory noticing. A directory the last walk did not finish (it was cancelled) is
|
||||
// read again whatever else is true, which is what makes a cancelled scan resumable.
|
||||
package space
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KeepMin is the smallest file kept as its own node.
|
||||
const KeepMin = 128 << 10
|
||||
|
||||
// Node is a directory or a file. Sizes are bytes allocated on disk (what fills the disk);
|
||||
// Apparent is the length. For a directory both include everything beneath it.
|
||||
type Node struct {
|
||||
Name string `json:"name"`
|
||||
Dir bool `json:"dir"`
|
||||
Size int64 `json:"size"`
|
||||
Apparent int64 `json:"apparent"`
|
||||
MTime int64 `json:"mtime"` // unix seconds
|
||||
MTimeNs int64 `json:"-"` // directories: the same to the nanosecond, so a change in the same second is not missed
|
||||
Files uint32 `json:"files"` // beneath a directory (kept and small alike)
|
||||
Dirs uint32 `json:"dirs"`
|
||||
SmallN uint32 `json:"small_n,omitempty"` // files here below KeepMin, not listed
|
||||
SmallSize int64 `json:"small_size,omitempty"` // their allocated bytes
|
||||
Mount bool `json:"mount,omitempty"` // another filesystem starts here (not this disk's, or a root of its own)
|
||||
Denied bool `json:"denied,omitempty"` // could not be read
|
||||
Partial bool `json:"partial,omitempty"` // the walk was cancelled before this directory was read
|
||||
Children []*Node `json:"children,omitempty"` // directories: sorted by Size, largest first
|
||||
}
|
||||
|
||||
// Progress is how far a running walk has got.
|
||||
type Progress struct {
|
||||
Files int64 `json:"files"`
|
||||
Dirs int64 `json:"dirs"` // directories read
|
||||
Reused int64 `json:"reused"` // directories kept from the last scan without reading
|
||||
Bytes int64 `json:"bytes"`
|
||||
Denied int64 `json:"denied"`
|
||||
Current string `json:"current"`
|
||||
}
|
||||
|
||||
// Mode is how a root is walked when there is a previous tree.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
ModeFull Mode = iota // read everything
|
||||
ModeMTime // read a directory again only if its mtime changed; stat its kept files
|
||||
ModeDiff // read only the directories a change list names
|
||||
)
|
||||
|
||||
// Options is what a walk starts from.
|
||||
type Options struct {
|
||||
Prev *Node // the last scan's tree, or nil
|
||||
// Modes says how each root is walked (by root path); a root not listed is ModeFull.
|
||||
Modes map[string]Mode
|
||||
// Dirty is every directory a change list named (ModeDiff roots): those are read again.
|
||||
// Touched is every ancestor of a dirty directory: those are kept but looked into.
|
||||
Dirty, Touched map[string]bool
|
||||
}
|
||||
|
||||
type walker struct {
|
||||
ctx context.Context
|
||||
opts Options
|
||||
mu sync.Mutex
|
||||
queue []job
|
||||
cond *sync.Cond
|
||||
pending int // dirs queued or being read
|
||||
files atomic.Int64
|
||||
dirs atomic.Int64
|
||||
reused atomic.Int64
|
||||
bytes atomic.Int64
|
||||
denied atomic.Int64
|
||||
partial atomic.Int64
|
||||
curMu sync.Mutex
|
||||
current string
|
||||
stopping bool
|
||||
}
|
||||
|
||||
type job struct {
|
||||
path string
|
||||
node *Node
|
||||
prev *Node // the same directory in the last scan, if it had one
|
||||
dev uint64 // the filesystem this root is on; a child on another is a mountpoint, not descended
|
||||
mode Mode
|
||||
}
|
||||
|
||||
// Result is what a walk returns besides the tree.
|
||||
type Result struct {
|
||||
Root *Node
|
||||
Partial int64 // directories not read because the walk was cancelled
|
||||
Reused int64
|
||||
Read int64
|
||||
Complete bool
|
||||
}
|
||||
|
||||
// Walk measures roots (mountpoints, absolute) and returns them grafted into one tree whose
|
||||
// root is "/". progress, if set, is called about four times a second. A cancelled walk
|
||||
// still returns the tree, with the directories it did not get to marked Partial.
|
||||
func Walk(ctx context.Context, roots []string, opts Options, progress func(Progress)) (Result, error) {
|
||||
w := &walker{ctx: ctx, opts: opts}
|
||||
w.cond = sync.NewCond(&w.mu)
|
||||
root := &Node{Name: "/", Dir: true}
|
||||
sort.Strings(roots)
|
||||
trees := map[string]*Node{}
|
||||
for _, r := range roots {
|
||||
r = filepath.Clean(r)
|
||||
if !filepath.IsAbs(r) {
|
||||
continue
|
||||
}
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(r, &st); err != nil || st.Mode&syscall.S_IFMT != syscall.S_IFDIR {
|
||||
continue
|
||||
}
|
||||
n := &Node{Name: filepath.Base(r), Dir: true, MTime: int64(st.Mtim.Sec), MTimeNs: st.Mtim.Nano()}
|
||||
if r == "/" {
|
||||
n = root
|
||||
}
|
||||
trees[r] = n
|
||||
var prev *Node
|
||||
if opts.Prev != nil {
|
||||
prev = Find(opts.Prev, r)
|
||||
}
|
||||
mode := opts.Modes[r]
|
||||
if prev == nil {
|
||||
mode = ModeFull
|
||||
}
|
||||
w.push(job{path: r, node: n, prev: prev, dev: uint64(st.Dev), mode: mode})
|
||||
}
|
||||
if len(trees) == 0 {
|
||||
return Result{Root: root}, os.ErrNotExist
|
||||
}
|
||||
workers := 32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); w.run() }()
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
tick := time.NewTicker(250 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
cancelled := false
|
||||
for waiting := true; waiting; {
|
||||
select {
|
||||
case <-done:
|
||||
waiting = false
|
||||
case <-tick.C:
|
||||
if progress != nil {
|
||||
progress(w.snapshot())
|
||||
}
|
||||
case <-ctx.Done():
|
||||
cancelled = true
|
||||
w.mu.Lock()
|
||||
w.stopping = true
|
||||
for _, j := range w.queue { // never read: say so, so the next walk reads them
|
||||
j.node.Partial = true
|
||||
w.partial.Add(1)
|
||||
}
|
||||
w.queue = nil
|
||||
w.cond.Broadcast()
|
||||
w.mu.Unlock()
|
||||
<-done
|
||||
waiting = false
|
||||
}
|
||||
}
|
||||
if progress != nil {
|
||||
progress(w.snapshot())
|
||||
}
|
||||
// graft each root at its path; deepest last so parents exist first
|
||||
for _, r := range roots {
|
||||
n, ok := trees[filepath.Clean(r)]
|
||||
if !ok || n == root {
|
||||
continue
|
||||
}
|
||||
graft(root, filepath.Clean(r), n)
|
||||
}
|
||||
total(root)
|
||||
res := Result{Root: root, Partial: w.partial.Load(), Reused: w.reused.Load(), Read: w.dirs.Load(), Complete: !cancelled}
|
||||
if cancelled {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (w *walker) snapshot() Progress {
|
||||
w.curMu.Lock()
|
||||
cur := w.current
|
||||
w.curMu.Unlock()
|
||||
return Progress{Files: w.files.Load(), Dirs: w.dirs.Load(), Reused: w.reused.Load(), Bytes: w.bytes.Load(), Denied: w.denied.Load(), Current: cur}
|
||||
}
|
||||
|
||||
func (w *walker) push(j job) {
|
||||
w.mu.Lock()
|
||||
if w.stopping {
|
||||
j.node.Partial = true
|
||||
w.partial.Add(1)
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.queue = append(w.queue, j)
|
||||
w.pending++
|
||||
w.cond.Signal()
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// run takes directories off the queue until there are none left and none being read.
|
||||
func (w *walker) run() {
|
||||
for {
|
||||
w.mu.Lock()
|
||||
for len(w.queue) == 0 && w.pending > 0 && !w.stopping {
|
||||
w.cond.Wait()
|
||||
}
|
||||
if w.stopping || (len(w.queue) == 0 && w.pending == 0) {
|
||||
w.cond.Broadcast()
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
j := w.queue[len(w.queue)-1] // depth first: keeps the queue short and the disk head local
|
||||
w.queue = w.queue[:len(w.queue)-1]
|
||||
w.mu.Unlock()
|
||||
w.visit(j)
|
||||
w.mu.Lock()
|
||||
w.pending--
|
||||
if w.pending == 0 {
|
||||
w.cond.Broadcast()
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func join(dir, name string) string {
|
||||
if dir == "/" {
|
||||
return "/" + name
|
||||
}
|
||||
return dir + "/" + name
|
||||
}
|
||||
|
||||
// visit decides whether a directory must be read or can be taken from the last scan.
|
||||
func (w *walker) visit(j job) {
|
||||
if j.prev == nil || j.prev.Partial || j.prev.Denied || j.mode == ModeFull {
|
||||
w.readDir(j)
|
||||
return
|
||||
}
|
||||
switch j.mode {
|
||||
case ModeDiff:
|
||||
if w.opts.Dirty[j.path] {
|
||||
w.readDir(j)
|
||||
return
|
||||
}
|
||||
if !w.opts.Touched[j.path] {
|
||||
w.reuse(j, false) // nothing under here changed: keep the whole subtree, no stat at all
|
||||
return
|
||||
}
|
||||
w.reuse(j, true) // something deeper changed: keep this list, look into the children
|
||||
case ModeMTime:
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(j.path, &st); err != nil {
|
||||
w.readDir(j)
|
||||
return
|
||||
}
|
||||
if st.Mtim.Nano() != j.prev.MTimeNs {
|
||||
w.readDir(j) // an entry was added, removed or renamed
|
||||
return
|
||||
}
|
||||
w.reuse(j, true) // same entries; files may have grown, and subdirectories are their own question
|
||||
}
|
||||
}
|
||||
|
||||
// reuse keeps a directory as the last scan saw it. With descend, its subdirectories are
|
||||
// visited (each decides for itself) and, in mtime mode, its kept files are stat'ed again.
|
||||
func (w *walker) reuse(j job, descend bool) {
|
||||
w.reused.Add(1)
|
||||
n, p := j.node, j.prev
|
||||
n.MTime, n.MTimeNs, n.SmallN, n.SmallSize, n.Denied, n.Mount = p.MTime, p.MTimeNs, p.SmallN, p.SmallSize, p.Denied, p.Mount
|
||||
if !descend {
|
||||
n.Children = p.Children
|
||||
n.Apparent = p.Apparent - childApparent(p) // total() adds the children back
|
||||
w.files.Add(int64(p.Files))
|
||||
w.bytes.Add(p.Size)
|
||||
w.reused.Add(int64(p.Dirs))
|
||||
return
|
||||
}
|
||||
w.files.Add(int64(p.SmallN))
|
||||
w.bytes.Add(p.SmallSize)
|
||||
n.Apparent = p.Apparent - childApparent(p) // the small files' lengths; total() adds the children back
|
||||
var st syscall.Stat_t
|
||||
for _, c := range p.Children {
|
||||
if c.Dir {
|
||||
d := &Node{Name: c.Name, Dir: true, MTime: c.MTime, MTimeNs: c.MTimeNs, Mount: c.Mount}
|
||||
n.Children = append(n.Children, d)
|
||||
if c.Mount {
|
||||
continue
|
||||
}
|
||||
w.push(job{path: join(j.path, c.Name), node: d, prev: c, dev: j.dev, mode: j.mode})
|
||||
continue
|
||||
}
|
||||
if j.mode == ModeMTime {
|
||||
if err := syscall.Lstat(join(j.path, c.Name), &st); err != nil {
|
||||
continue // gone since (the directory's mtime would have said so, but be safe)
|
||||
}
|
||||
c = &Node{Name: c.Name, Size: int64(st.Blocks) * 512, Apparent: int64(st.Size), MTime: int64(st.Mtim.Sec)}
|
||||
}
|
||||
n.Children = append(n.Children, c)
|
||||
w.files.Add(1)
|
||||
w.bytes.Add(c.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func childApparent(p *Node) int64 {
|
||||
var a int64
|
||||
for _, c := range p.Children {
|
||||
a += c.Apparent
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (w *walker) readDir(j job) {
|
||||
w.curMu.Lock()
|
||||
w.current = j.path
|
||||
w.curMu.Unlock()
|
||||
w.dirs.Add(1)
|
||||
f, err := os.Open(j.path)
|
||||
if err != nil {
|
||||
j.node.Denied = true
|
||||
w.denied.Add(1)
|
||||
return
|
||||
}
|
||||
names, err := f.Readdirnames(-1)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
j.node.Denied = true
|
||||
w.denied.Add(1)
|
||||
}
|
||||
var prevDirs map[string]*Node
|
||||
if j.prev != nil {
|
||||
prevDirs = map[string]*Node{}
|
||||
for _, c := range j.prev.Children {
|
||||
if c.Dir {
|
||||
prevDirs[c.Name] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
var st syscall.Stat_t
|
||||
for _, name := range names {
|
||||
p := join(j.path, name)
|
||||
if err := syscall.Lstat(p, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
switch st.Mode & syscall.S_IFMT {
|
||||
case syscall.S_IFDIR:
|
||||
c := &Node{Name: name, Dir: true, MTime: int64(st.Mtim.Sec), MTimeNs: st.Mtim.Nano()}
|
||||
j.node.Children = append(j.node.Children, c)
|
||||
if uint64(st.Dev) != j.dev {
|
||||
c.Mount = true // something else is mounted here; if it is this disk's too, it is a root of its own
|
||||
continue
|
||||
}
|
||||
w.push(job{path: p, node: c, prev: prevDirs[name], dev: j.dev, mode: j.mode})
|
||||
case syscall.S_IFREG, syscall.S_IFLNK:
|
||||
alloc := int64(st.Blocks) * 512
|
||||
w.files.Add(1)
|
||||
w.bytes.Add(alloc)
|
||||
if alloc >= KeepMin || int64(st.Size) >= KeepMin {
|
||||
j.node.Children = append(j.node.Children, &Node{Name: name, Size: alloc, Apparent: int64(st.Size), MTime: int64(st.Mtim.Sec)})
|
||||
} else {
|
||||
j.node.SmallN++
|
||||
j.node.SmallSize += alloc
|
||||
j.node.Apparent += int64(st.Size) // added to the directory's own apparent total below
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// graft puts a root's tree at path under root, making any directories in between.
|
||||
func graft(root *Node, path string, tree *Node) {
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
cur := root
|
||||
for i, part := range parts {
|
||||
var next *Node
|
||||
for _, c := range cur.Children {
|
||||
if c.Name == part && c.Dir {
|
||||
next = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if i == len(parts)-1 {
|
||||
if next == nil {
|
||||
cur.Children = append(cur.Children, tree)
|
||||
} else {
|
||||
// the parent's walk saw this directory as a mountpoint; the walk of the mount fills it
|
||||
next.Children, next.SmallN, next.SmallSize, next.Apparent, next.Denied, next.Partial = tree.Children, tree.SmallN, tree.SmallSize, tree.Apparent, tree.Denied, tree.Partial
|
||||
next.Mount = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if next == nil {
|
||||
next = &Node{Name: part, Dir: true}
|
||||
cur.Children = append(cur.Children, next)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
}
|
||||
|
||||
// total sums every directory from its children and sorts them largest first.
|
||||
func total(n *Node) {
|
||||
if !n.Dir {
|
||||
return
|
||||
}
|
||||
n.Size = n.SmallSize
|
||||
n.Files = n.SmallN
|
||||
n.Dirs = 0
|
||||
apparent := n.Apparent // the small files' lengths
|
||||
for _, c := range n.Children {
|
||||
total(c)
|
||||
n.Size += c.Size
|
||||
apparent += c.Apparent
|
||||
if c.Dir {
|
||||
n.Dirs += 1 + c.Dirs
|
||||
n.Files += c.Files
|
||||
} else {
|
||||
n.Files++
|
||||
}
|
||||
}
|
||||
n.Apparent = apparent
|
||||
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Size > n.Children[j].Size })
|
||||
}
|
||||
|
||||
// Find follows path (absolute, "/" for the root) down the tree.
|
||||
func Find(root *Node, path string) *Node {
|
||||
cur := root
|
||||
for _, part := range strings.Split(strings.Trim(filepath.Clean(path), "/"), "/") {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
var next *Node
|
||||
for _, c := range cur.Children {
|
||||
if c.Name == part {
|
||||
next = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// File is one of the largest files, with its full path.
|
||||
type File struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Apparent int64 `json:"apparent"`
|
||||
MTime int64 `json:"mtime"`
|
||||
}
|
||||
|
||||
// Largest is the n biggest files in the tree, largest first.
|
||||
func Largest(root *Node, n int) []File {
|
||||
var out []File
|
||||
minIdx := -1
|
||||
var walk func(n *Node, path string)
|
||||
walk = func(node *Node, path string) {
|
||||
for _, c := range node.Children {
|
||||
p := join(path, c.Name)
|
||||
if c.Dir {
|
||||
if c.Size > 0 && (len(out) < n || c.Size > out[minIdx].Size) {
|
||||
walk(c, p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(out) < n {
|
||||
out = append(out, File{Path: p, Size: c.Size, Apparent: c.Apparent, MTime: c.MTime})
|
||||
if minIdx < 0 || c.Size < out[minIdx].Size {
|
||||
minIdx = len(out) - 1
|
||||
}
|
||||
} else if c.Size > out[minIdx].Size {
|
||||
out[minIdx] = File{Path: p, Size: c.Size, Apparent: c.Apparent, MTime: c.MTime}
|
||||
minIdx = 0
|
||||
for i := range out {
|
||||
if out[i].Size < out[minIdx].Size {
|
||||
minIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root, "/")
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Size > out[j].Size })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package space
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mk(t *testing.T, dir string, files map[string]int) {
|
||||
for p, n := range files {
|
||||
full := filepath.Join(dir, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, make([]byte, n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAndResume(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mk(t, dir, map[string]int{"a/big1": 300 << 10, "a/small": 100, "b/c/big2": 500 << 10, "b/d/big3": 200 << 10})
|
||||
res, err := Walk(context.Background(), []string{dir}, Options{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := Find(res.Root, dir)
|
||||
if root == nil || root.Files != 4 {
|
||||
t.Fatalf("full walk: %+v", root)
|
||||
}
|
||||
want := root.Size
|
||||
// grow a file in place (its directory's mtime does not change) and add a file elsewhere
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := os.WriteFile(filepath.Join(dir, "a/big1"), make([]byte, 600<<10), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mk(t, dir, map[string]int{"b/d/big4": 400 << 10})
|
||||
res2, err := Walk(context.Background(), []string{dir}, Options{Prev: res.Root, Modes: map[string]Mode{dir: ModeMTime}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r2 := Find(res2.Root, dir)
|
||||
if r2.Files != 5 || r2.Size != want+(300<<10)+(400<<10) {
|
||||
t.Fatalf("mtime refresh: files %d size %d want %d (reused %d read %d)", r2.Files, r2.Size, want+(300<<10)+(400<<10), res2.Reused, res2.Read)
|
||||
}
|
||||
if res2.Reused == 0 || res2.Read == 0 {
|
||||
t.Fatalf("mtime refresh should reuse some and read some: reused %d read %d", res2.Reused, res2.Read)
|
||||
}
|
||||
// diff mode: only b/d named dirty; a's growth must NOT be seen (that is the contract), b/d's new file must
|
||||
dirty := map[string]bool{filepath.Join(dir, "b/d"): true}
|
||||
touched := map[string]bool{dir: true, filepath.Join(dir, "b"): true}
|
||||
res3, err := Walk(context.Background(), []string{dir}, Options{Prev: res.Root, Modes: map[string]Mode{dir: ModeDiff}, Dirty: dirty, Touched: touched}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r3 := Find(res3.Root, dir)
|
||||
if r3.Files != 5 || r3.Size != want+(400<<10) || res3.Read != 1 {
|
||||
t.Fatalf("diff refresh: files %d size %d want %d read %d", r3.Files, r3.Size, want+(400<<10), res3.Read)
|
||||
}
|
||||
// a cancelled walk marks what it did not read; resuming reads those
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
res4, err := Walk(ctx, []string{dir}, Options{}, nil)
|
||||
if err == nil || res4.Complete {
|
||||
t.Fatal("cancelled walk should say so")
|
||||
}
|
||||
if len(partialPaths(res4.Root, "/")) == 0 {
|
||||
t.Fatal("cancelled walk should leave partial directories")
|
||||
}
|
||||
res5, err := Walk(context.Background(), []string{dir}, Options{Prev: res4.Root, Modes: map[string]Mode{dir: ModeMTime}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r5 := Find(res5.Root, dir); r5.Files != 5 || len(partialPaths(res5.Root, "/")) != 0 {
|
||||
t.Fatalf("resume: files %d partial %v", r5.Files, partialPaths(res5.Root, "/"))
|
||||
}
|
||||
// the codec round-trips the partial flag
|
||||
pr, pw, _ := os.Pipe()
|
||||
go func() { _ = Encode(pw, res4.Root); pw.Close() }()
|
||||
back, err := Decode(pr)
|
||||
if err != nil || len(partialPaths(back, "/")) != len(partialPaths(res4.Root, "/")) {
|
||||
t.Fatalf("codec: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package storage
|
||||
|
||||
// Scheduled snapshots with a retention period: take a snapshot of a dataset on a schedule,
|
||||
// and drop the ones that have aged out.
|
||||
//
|
||||
// **The naming is zfSnap's, deliberately.** grid has run zfSnap v1.11.1 from root cron since
|
||||
// before this repo existed and it works well, so rather than invent a second convention and
|
||||
// have two tools disagree about what a snapshot is, this writes and reads exactly what
|
||||
// zfSnap does: `<dataset>@2026-09-24_02.30.01--6w`, with the retention encoded in the name.
|
||||
// That has three consequences worth knowing. Ham Lab can show and manage the snapshots
|
||||
// zfSnap already took. The two can run side by side without fighting, because both only
|
||||
// ever destroy snapshots that have passed the expiry written into their own name. And
|
||||
// changing a policy's retention does not retroactively re-date old snapshots — each one
|
||||
// carries the rule it was taken under, which is the behaviour you want from a backup.
|
||||
//
|
||||
// Two rules make this safe to run unattended. Only a snapshot whose name parses as a stamp
|
||||
// and a TTL is ever a candidate — anything taken by hand or named anything else is invisible
|
||||
// to it. And every policy can be asked what it would delete, before it deletes anything.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/cron"
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
// stampLayout is zfSnap's timestamp: 2026-09-24_02.30.01.
|
||||
const stampLayout = "2006-01-02_15.04.05"
|
||||
|
||||
// ttlSep separates the stamp from the retention in a snapshot name, as zfSnap writes it.
|
||||
const ttlSep = "--"
|
||||
|
||||
// Policy is a standing instruction: snapshot this dataset on this schedule, keep the
|
||||
// snapshots this long.
|
||||
type Policy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Dataset string `json:"dataset"`
|
||||
// Recursive takes one atomic snapshot of the dataset and everything under it (zfs -r).
|
||||
Recursive bool `json:"recursive"`
|
||||
// Every is hourly | daily | weekly | monthly | custom. At is "HH:MM" (the minute alone
|
||||
// is used for hourly). Weekday is 0=Sunday, Day is 1–28. With "custom", Cron is a plain
|
||||
// cron expression and the rest is ignored — the escape hatch for anything the four
|
||||
// common shapes cannot say.
|
||||
Every string `json:"every"`
|
||||
At string `json:"at"`
|
||||
Weekday int `json:"weekday"`
|
||||
Day int `json:"day"`
|
||||
Cron string `json:"cron,omitempty"`
|
||||
// KeepFor is how long a snapshot lives: "6w", "30d", "12h". It is written into the
|
||||
// snapshot's own name, zfSnap-style, so each one carries the rule it was taken under.
|
||||
// KeepMin is a floor — that many newest snapshots survive however old they are, so a
|
||||
// dataset is never left with none.
|
||||
KeepFor string `json:"keep_for"`
|
||||
KeepMin int `json:"keep_min"`
|
||||
Prefix string `json:"prefix"`
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
}
|
||||
|
||||
func (p *Policy) prefix() string { return strings.TrimSpace(p.Prefix) }
|
||||
|
||||
// Validate reports what is wrong with a policy, or nil.
|
||||
func (p *Policy) Validate() error {
|
||||
if !datasetRe.MatchString(p.Dataset) {
|
||||
return errors.New("dataset: a pool or pool/dataset name")
|
||||
}
|
||||
if pre := p.prefix(); pre != "" && (!snapRe.MatchString(pre) || strings.Contains(pre, ttlSep)) {
|
||||
return errors.New("prefix: letters, digits, . _ : and - only, and no --")
|
||||
}
|
||||
switch p.Every {
|
||||
case "hourly", "daily", "weekly", "monthly":
|
||||
case "custom":
|
||||
if strings.TrimSpace(p.Cron) == "" {
|
||||
return errors.New("a custom schedule needs a cron expression")
|
||||
}
|
||||
default:
|
||||
return errors.New("every: hourly, daily, weekly, monthly or custom")
|
||||
}
|
||||
if _, err := p.CronExpr(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := p.clock(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Every == "weekly" && (p.Weekday < 0 || p.Weekday > 6) {
|
||||
return errors.New("weekday: 0 (Sunday) to 6")
|
||||
}
|
||||
if p.Every == "monthly" && (p.Day < 1 || p.Day > 28) {
|
||||
return errors.New("day: 1 to 28, so every month has one")
|
||||
}
|
||||
if p.KeepFor != "" {
|
||||
if _, err := ParseAge(p.KeepFor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.KeepMin < 0 {
|
||||
return errors.New("keep at least: 0 or more")
|
||||
}
|
||||
if p.KeepFor == "" {
|
||||
return errors.New("a retention period is required — it is written into each snapshot's name, and is what lets it be cleaned up later")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Policy) clock() (hour, min int, err error) {
|
||||
at := strings.TrimSpace(p.At)
|
||||
if at == "" {
|
||||
return 0, 0, nil
|
||||
}
|
||||
h, m, ok := strings.Cut(at, ":")
|
||||
if !ok {
|
||||
return 0, 0, errors.New("at: HH:MM")
|
||||
}
|
||||
hour, err = strconv.Atoi(h)
|
||||
if err == nil {
|
||||
min, err = strconv.Atoi(m)
|
||||
}
|
||||
if err != nil || hour < 0 || hour > 23 || min < 0 || min > 59 {
|
||||
return 0, 0, errors.New("at: HH:MM, 00:00 to 23:59")
|
||||
}
|
||||
return hour, min, nil
|
||||
}
|
||||
|
||||
// ParseAge reads "6w", "30d", "12h", "90m" as a duration. Weeks and days are what a
|
||||
// retention period is actually written in, and time.ParseDuration knows neither.
|
||||
func ParseAge(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
if s == "" {
|
||||
return 0, errors.New("empty period")
|
||||
}
|
||||
unit := s[len(s)-1]
|
||||
n, err := strconv.Atoi(s[:len(s)-1])
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("%q: a number and a unit, like 6w, 30d or 12h", s)
|
||||
}
|
||||
switch unit {
|
||||
case 'm':
|
||||
return time.Duration(n) * time.Minute, nil
|
||||
case 'h':
|
||||
return time.Duration(n) * time.Hour, nil
|
||||
case 'd':
|
||||
return time.Duration(n) * 24 * time.Hour, nil
|
||||
case 'w':
|
||||
return time.Duration(n) * 7 * 24 * time.Hour, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%q: the unit is m, h, d or w", s)
|
||||
}
|
||||
|
||||
// CronExpr renders the schedule as a cron expression — which is what actually runs it. The
|
||||
// four common shapes are sugar over this; a custom policy states it outright. One expression
|
||||
// is the only description of when a schedule fires, so the page, the cron file and the next
|
||||
// run can never disagree.
|
||||
func (p *Policy) CronExpr() (string, error) {
|
||||
if p.Every == "custom" {
|
||||
expr := strings.Join(strings.Fields(p.Cron), " ")
|
||||
if err := cron.ValidSchedule(expr); err != nil {
|
||||
return "", fmt.Errorf("cron expression: %w", err)
|
||||
}
|
||||
if strings.HasPrefix(expr, "@reboot") {
|
||||
return "", errors.New("@reboot is not a schedule for snapshots")
|
||||
}
|
||||
return expr, nil
|
||||
}
|
||||
hour, min, err := p.clock()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch p.Every {
|
||||
case "hourly":
|
||||
return fmt.Sprintf("%d * * * *", min), nil
|
||||
case "daily":
|
||||
return fmt.Sprintf("%d %d * * *", min, hour), nil
|
||||
case "weekly":
|
||||
return fmt.Sprintf("%d %d * * %d", min, hour, p.Weekday), nil
|
||||
case "monthly":
|
||||
return fmt.Sprintf("%d %d %d * *", min, hour, p.Day), nil
|
||||
}
|
||||
return "", errors.New("unknown schedule")
|
||||
}
|
||||
|
||||
// NextRun is when cron will next fire this schedule, read with the same evaluator the Jobs
|
||||
// tab uses on everyone else's lines.
|
||||
func (p *Policy) NextRun(now time.Time) time.Time {
|
||||
expr, err := p.CronExpr()
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
t, ok := cron.NextRun(expr, now)
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// SnapTime reads a snapshot's name in zfSnap's convention and returns when it was taken,
|
||||
// when it expires, and whether the name parsed at all. A name without a TTL never expires.
|
||||
// An optional prefix in front of the stamp is allowed, so a policy can label its own.
|
||||
func SnapTime(name string) (taken time.Time, expires time.Time, ok bool) {
|
||||
_, snap, cut := strings.Cut(name, "@")
|
||||
if !cut {
|
||||
return
|
||||
}
|
||||
body, ttl, hasTTL := strings.Cut(snap, ttlSep)
|
||||
// an optional "<prefix>-" before the stamp
|
||||
stamp := body
|
||||
if len(body) > len(stampLayout) {
|
||||
stamp = body[len(body)-len(stampLayout):]
|
||||
}
|
||||
t, err := time.ParseInLocation(stampLayout, stamp, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
taken, ok = t, true
|
||||
if hasTTL {
|
||||
if d, err := ParseAge(ttl); err == nil {
|
||||
expires = t.Add(d)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Doomed is which of this dataset's snapshots have passed the expiry written into their own
|
||||
// names, oldest first — exactly what zfSnap -d would remove. This is what the UI shows
|
||||
// before anything is deleted, and what Run then acts on. KeepMin holds back that many of the
|
||||
// newest whatever their names say, so a dataset is never left with none.
|
||||
func (m *Manager) Doomed(ctx context.Context, p *Policy) ([]Snapshot, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := m.Snapshots(ctx, p.Dataset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dated []Snapshot
|
||||
expiry := map[string]time.Time{}
|
||||
for _, s := range all {
|
||||
taken, exp, ok := SnapTime(s.Name)
|
||||
if !ok || exp.IsZero() {
|
||||
continue // hand-made, or kept forever: not ours to remove
|
||||
}
|
||||
_ = taken
|
||||
dated = append(dated, s)
|
||||
expiry[s.Name] = exp
|
||||
}
|
||||
sort.Slice(dated, func(i, j int) bool { return dated[i].Created > dated[j].Created }) // newest first
|
||||
now := time.Now()
|
||||
var doomed []Snapshot
|
||||
for i, s := range dated {
|
||||
if i < p.KeepMin {
|
||||
continue // the floor: these survive however old they are
|
||||
}
|
||||
if expiry[s.Name].Before(now) {
|
||||
doomed = append(doomed, s)
|
||||
}
|
||||
}
|
||||
sort.Slice(doomed, func(i, j int) bool { return doomed[i].Created < doomed[j].Created }) // oldest first
|
||||
return doomed, nil
|
||||
}
|
||||
|
||||
// DestroySnapshot removes one snapshot. recursive also removes the same-named snapshot on
|
||||
// every dataset beneath it, which is what a recursive policy created.
|
||||
func (m *Manager) DestroySnapshot(ctx context.Context, full string, recursive bool) error {
|
||||
dataset, snap, ok := strings.Cut(full, "@")
|
||||
if !ok || !datasetRe.MatchString(dataset) || !snapRe.MatchString(snap) {
|
||||
return errors.New("bad snapshot name")
|
||||
}
|
||||
args := []string{"zfs", "destroy"}
|
||||
if recursive {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
out, code, err := m.run(ctx, append(args, full)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != 0 {
|
||||
return errors.New(strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SnapshotRecursive takes dataset@name, optionally of everything beneath it too.
|
||||
func (m *Manager) SnapshotRecursive(ctx context.Context, dataset, name string, recursive bool) error {
|
||||
if !datasetRe.MatchString(dataset) || !snapRe.MatchString(name) {
|
||||
return errors.New("bad snapshot name")
|
||||
}
|
||||
args := []string{"zfs", "snapshot"}
|
||||
if recursive {
|
||||
args = append(args, "-r")
|
||||
}
|
||||
out, code, err := m.run(ctx, append(args, dataset+"@"+name)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != 0 {
|
||||
return errors.New(strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunResult is what one pass of a policy did.
|
||||
type RunResult struct {
|
||||
Taken string `json:"taken"`
|
||||
Pruned []string `json:"pruned"`
|
||||
Skipped int `json:"skipped"` // snapshots that are not this policy's, left alone
|
||||
}
|
||||
|
||||
// Run takes the snapshot and prunes what has aged out. It is the same code the scheduler
|
||||
// calls, so "Run now" and the timer cannot drift apart.
|
||||
func (m *Manager) Run(ctx context.Context, p *Policy) (RunResult, error) {
|
||||
var res RunResult
|
||||
if err := p.Validate(); err != nil {
|
||||
return res, err
|
||||
}
|
||||
name := time.Now().Format(stampLayout)
|
||||
if pre := strings.TrimSpace(p.Prefix); pre != "" {
|
||||
name = pre + "-" + name
|
||||
}
|
||||
if p.KeepFor != "" {
|
||||
name += ttlSep + strings.TrimSpace(strings.ToLower(p.KeepFor)) // zfSnap's convention: the retention lives in the name
|
||||
}
|
||||
if err := m.SnapshotRecursive(ctx, p.Dataset, name, p.Recursive); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Taken = p.Dataset + "@" + name
|
||||
doomed, err := m.Doomed(ctx, p)
|
||||
if err != nil {
|
||||
return res, err // the snapshot was taken; the prune is what failed
|
||||
}
|
||||
for _, s := range doomed {
|
||||
if err := m.DestroySnapshot(ctx, s.Name, p.Recursive); err != nil {
|
||||
return res, fmt.Errorf("destroying %s: %w", s.Name, err)
|
||||
}
|
||||
res.Pruned = append(res.Pruned, s.Name)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ---- what happened when it ran ----
|
||||
//
|
||||
// The policies are a definition the daemon owns; what a run did is written by whatever ran
|
||||
// it, which is a cron job in its own process. Keeping the two apart means the CLI never has
|
||||
// to write state.json behind the daemon's back — the last writer would win and one of them
|
||||
// would lose its work.
|
||||
|
||||
// RunRecord is the outcome of one run of one schedule.
|
||||
type RunRecord struct {
|
||||
At time.Time `json:"at"`
|
||||
Taken string `json:"taken,omitempty"`
|
||||
Pruned []string `json:"pruned,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func runPath(dir, id string) string { return filepath.Join(dir, "zfs-runs", id+".json") }
|
||||
|
||||
// WriteRun records what a run did, beside the config.
|
||||
func WriteRun(dir, id string, r RunRecord) error {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "zfs-runs"), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := runPath(dir, id)
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p)
|
||||
}
|
||||
|
||||
// ReadRun is the last thing a schedule did, or the zero value if it has never run.
|
||||
func ReadRun(dir, id string) RunRecord {
|
||||
var r RunRecord
|
||||
b, err := os.ReadFile(runPath(dir, id))
|
||||
if err == nil {
|
||||
_ = json.Unmarshal(b, &r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ForgetRun drops a schedule's record, for when the schedule itself goes.
|
||||
func ForgetRun(dir, id string) { _ = os.Remove(runPath(dir, id)) }
|
||||
|
||||
// ---- the cron file ----
|
||||
|
||||
// CronFile is where Ham Lab writes the snapshot schedules. One file it owns entirely and
|
||||
// rewrites whole, so there is never a half-updated set of lines, and so a schedule removed
|
||||
// in the UI is removed from the machine.
|
||||
const CronFile = "/etc/cron.d/hamlab-zfs"
|
||||
|
||||
// CronFileText renders every schedule as cron lines. user is who they run as — the account
|
||||
// Ham Lab itself runs as, which reaches zfs through sudo exactly as the daemon does, so the
|
||||
// files it writes stay owned by that account rather than by root.
|
||||
func CronFileText(policies []Policy, user, exe, config string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Ham Lab snapshot schedules — written by the ZFS tab, rewritten whole on every change.\n")
|
||||
b.WriteString("# Edit them there, not here: anything added by hand is lost the next time a schedule changes.\n")
|
||||
b.WriteString("SHELL=/bin/sh\n")
|
||||
b.WriteString("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n\n")
|
||||
for i := range policies {
|
||||
p := &policies[i]
|
||||
if !p.Enabled {
|
||||
b.WriteString("# " + p.Name + " — paused\n")
|
||||
continue
|
||||
}
|
||||
expr, err := p.CronExpr()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %w", p.Name, err)
|
||||
}
|
||||
name := strings.ReplaceAll(p.Name, "\n", " ")
|
||||
fmt.Fprintf(&b, "# %s — %s, keep %s\n", name, p.Dataset, p.KeepFor)
|
||||
fmt.Fprintf(&b, "%s %s %s -config %s zfs-snapshot %s\n\n", expr, user, exe, config, p.ID)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// WriteCronFile puts the schedules on the machine, or removes the file when there are none.
|
||||
func (m *Manager) WriteCronFile(ctx context.Context, policies []Policy, user, exe, config string) error {
|
||||
live := false
|
||||
for i := range policies {
|
||||
if policies[i].Enabled {
|
||||
live = true
|
||||
}
|
||||
}
|
||||
if !live {
|
||||
out, code, err := m.run(ctx, "rm", "-f", CronFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != 0 {
|
||||
return errors.New(strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
text, err := CronFileText(policies, user, exe, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.writeRoot(ctx, CronFile, text)
|
||||
}
|
||||
|
||||
// writeRoot puts text at a root-owned path, through the same privilege escalation every
|
||||
// other write here uses.
|
||||
func (m *Manager) writeRoot(ctx context.Context, path, text string) error {
|
||||
args := []string{"tee", path}
|
||||
if m.sudo {
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
var errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = nil, &errb
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/cron"
|
||||
)
|
||||
|
||||
func TestSnapTimeReadsZfSnapNames(t *testing.T) {
|
||||
// the real thing off grid
|
||||
taken, exp, ok := SnapTime("tank@2026-09-24_02.30.01--6w")
|
||||
if !ok {
|
||||
t.Fatal("zfSnap's own name must parse")
|
||||
}
|
||||
if taken.Format("2006-01-02 15:04:05") != "2026-09-24 02:30:01" {
|
||||
t.Fatalf("taken = %v", taken)
|
||||
}
|
||||
if got := exp.Sub(taken); got != 6*7*24*time.Hour {
|
||||
t.Fatalf("ttl = %v, want 6w", got)
|
||||
}
|
||||
// a labelled one from a policy with a prefix
|
||||
if _, e, ok := SnapTime("tank/apps@nightly-2026-09-24_02.30.01--30d"); !ok || e.IsZero() {
|
||||
t.Fatal("a prefixed name must parse and expire")
|
||||
}
|
||||
// no TTL: never expires
|
||||
if _, e, ok := SnapTime("tank@2026-09-24_02.30.01"); !ok || !e.IsZero() {
|
||||
t.Fatal("a name without a TTL must parse and never expire")
|
||||
}
|
||||
// things that are not ours
|
||||
for _, n := range []string{"tank@before-upgrade", "tank@manual", "tank", "tank@2026-13-45_99.99.99--6w"} {
|
||||
if _, _, ok := SnapTime(n); ok {
|
||||
t.Errorf("%q must not parse as a scheduled snapshot", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronExprAndNextRun(t *testing.T) {
|
||||
now := time.Date(2026, 9, 24, 9, 15, 0, 0, time.Local)
|
||||
for _, c := range []struct {
|
||||
p Policy
|
||||
want string
|
||||
}{
|
||||
{Policy{Every: "daily", At: "02:30"}, "30 2 * * *"},
|
||||
{Policy{Every: "hourly", At: "00:30"}, "30 * * * *"},
|
||||
{Policy{Every: "weekly", At: "04:00", Weekday: 0}, "0 4 * * 0"},
|
||||
{Policy{Every: "monthly", At: "05:15", Day: 12}, "15 5 12 * *"},
|
||||
{Policy{Every: "custom", Cron: "*/15 9-17 * * 1-5"}, "*/15 9-17 * * 1-5"},
|
||||
} {
|
||||
got, err := c.p.CronExpr()
|
||||
if err != nil || got != c.want {
|
||||
t.Errorf("CronExpr(%+v) = %q, %v; want %q", c.p.Every, got, err, c.want)
|
||||
}
|
||||
}
|
||||
// the next run is read with the same evaluator the Jobs tab uses
|
||||
daily := Policy{Every: "daily", At: "02:30"}
|
||||
if n := daily.NextRun(now); n.Hour() != 2 || n.Minute() != 30 || n.Day() != 25 {
|
||||
t.Fatalf("NextRun = %v, want 25th 02:30", n)
|
||||
}
|
||||
// a custom expression that does not parse is refused, not silently ignored
|
||||
if _, err := (&Policy{Every: "custom", Cron: "not a schedule"}).CronExpr(); err == nil {
|
||||
t.Fatal("a bad cron expression must be refused")
|
||||
}
|
||||
if _, err := (&Policy{Every: "custom", Cron: "@reboot"}).CronExpr(); err == nil {
|
||||
t.Fatal("@reboot is not a snapshot schedule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronFileText(t *testing.T) {
|
||||
ps := []Policy{
|
||||
{ID: "abc", Name: "tank nightly", Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Enabled: true},
|
||||
{ID: "def", Name: "paused one", Dataset: "tank/apps", Every: "hourly", At: "00:05", KeepFor: "2d"},
|
||||
}
|
||||
text, err := CronFileText(ps, "core", "/usr/local/bin/hamlab", "/home/core/apps/hamlab/config.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(text, "30 2 * * * core /usr/local/bin/hamlab -config /home/core/apps/hamlab/config.json zfs-snapshot abc") {
|
||||
t.Fatalf("the enabled schedule is missing or malformed:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "zfs-snapshot def") {
|
||||
t.Fatalf("a paused schedule must not be scheduled:\n%s", text)
|
||||
}
|
||||
// every line cron will read must parse as a schedule
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.Contains(line, "=") {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if err := cron.ValidSchedule(strings.Join(f[:5], " ")); err != nil {
|
||||
t.Errorf("line %q: %v", line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRefusesNonsense(t *testing.T) {
|
||||
ok := Policy{Dataset: "tank/apps", Every: "daily", At: "02:30", KeepFor: "6w"}
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("a sane policy: %v", err)
|
||||
}
|
||||
bad := []Policy{
|
||||
{Dataset: "", Every: "daily", At: "02:30", KeepFor: "6w"},
|
||||
{Dataset: "tank", Every: "yearly", At: "02:30", KeepFor: "6w"},
|
||||
{Dataset: "tank", Every: "custom", KeepFor: "6w"},
|
||||
{Dataset: "tank", Every: "custom", Cron: "not a schedule", KeepFor: "6w"},
|
||||
{Dataset: "tank", Every: "daily", At: "25:00", KeepFor: "6w"},
|
||||
{Dataset: "tank", Every: "daily", At: "02:30"}, // no retention
|
||||
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6 weeks"}, // unparseable
|
||||
{Dataset: "tank", Every: "monthly", At: "02:30", Day: 31, KeepFor: "6w"}, // not every month has one
|
||||
{Dataset: "tank", Every: "daily", At: "02:30", KeepFor: "6w", Prefix: "a--b"},
|
||||
}
|
||||
for i, p := range bad {
|
||||
if err := p.Validate(); err == nil {
|
||||
t.Errorf("bad policy %d was accepted: %+v", i, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAge(t *testing.T) {
|
||||
for s, want := range map[string]time.Duration{
|
||||
"6w": 6 * 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour,
|
||||
"12h": 12 * time.Hour, "90m": 90 * time.Minute,
|
||||
} {
|
||||
if got, err := ParseAge(s); err != nil || got != want {
|
||||
t.Errorf("ParseAge(%q) = %v, %v; want %v", s, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"", "6", "w", "-1d", "0w", "6y", "six weeks"} {
|
||||
if _, err := ParseAge(s); err == nil {
|
||||
t.Errorf("ParseAge(%q) should fail", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
// Package storage is the Disks tab: block devices from lsblk, SMART health via
|
||||
// smartctl (optional; the tab explains how to get it), and ZFS pools/datasets/snapshots
|
||||
// when the zfs tools and module are present. Spinning disks are never woken just to
|
||||
// read SMART (-n standby).
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
)
|
||||
|
||||
// ---- block devices ----
|
||||
|
||||
type Part struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // part | crypt | lvm | md | …
|
||||
FSType string `json:"fstype,omitempty"`
|
||||
Mount string `json:"mount,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Size uint64 `json:"size"`
|
||||
Used uint64 `json:"used,omitempty"`
|
||||
FSSize uint64 `json:"fs_size,omitempty"`
|
||||
}
|
||||
|
||||
type Device 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"`
|
||||
Tran string `json:"tran,omitempty"`
|
||||
Size uint64 `json:"size"`
|
||||
Rotational bool `json:"rotational"`
|
||||
Removable bool `json:"removable"`
|
||||
Temp float64 `json:"temp,omitempty"`
|
||||
Parts []Part `json:"parts"`
|
||||
Namespaces []string `json:"namespaces,omitempty"` // NVMe: the block devices behind this controller
|
||||
}
|
||||
|
||||
type lsblkNode struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Size uint64 `json:"size"`
|
||||
Model *string `json:"model"`
|
||||
Serial *string `json:"serial"`
|
||||
Vendor *string `json:"vendor"`
|
||||
Rota bool `json:"rota"`
|
||||
Tran *string `json:"tran"`
|
||||
FSType *string `json:"fstype"`
|
||||
Mountpoint *string `json:"mountpoint"`
|
||||
Label *string `json:"label"`
|
||||
RM bool `json:"rm"`
|
||||
Children []lsblkNode `json:"children"`
|
||||
}
|
||||
|
||||
func str(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*p)
|
||||
}
|
||||
|
||||
func Devices(ctx context.Context) ([]Device, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
var root struct {
|
||||
Blockdevices []lsblkNode `json:"blockdevices"`
|
||||
}
|
||||
// lsblk knows labels and stacked devices (LVM, LUKS, md); without it (Alpine and other
|
||||
// busybox systems don't ship it) or with one too old for JSON, /sys has the essentials.
|
||||
out, err := exec.CommandContext(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,SIZE,MODEL,SERIAL,VENDOR,ROTA,TRAN,FSTYPE,MOUNTPOINT,LABEL,RM").Output()
|
||||
if err != nil || json.Unmarshal(out, &root) != nil {
|
||||
root.Blockdevices = sysBlockDevices()
|
||||
}
|
||||
devs := []Device{}
|
||||
for _, n := range root.Blockdevices {
|
||||
if n.Type != "disk" || n.Size == 0 || strings.HasPrefix(n.Name, "zd") || mmcAreaRe.MatchString(n.Name) {
|
||||
continue
|
||||
}
|
||||
d := Device{Name: n.Name, Model: str(n.Model), Vendor: str(n.Vendor), Serial: str(n.Serial), Tran: str(n.Tran),
|
||||
Size: n.Size, Rotational: n.Rota, Removable: n.RM, Temp: blockTemp(n.Name), Parts: []Part{}}
|
||||
switch {
|
||||
case strings.HasPrefix(n.Name, "mmcblk"):
|
||||
d.Kind = mmcKind(n.Name)
|
||||
if d.Model == "" {
|
||||
d.Model = strings.TrimSpace(readStrFile("/sys/block/" + n.Name + "/device/name"))
|
||||
}
|
||||
case d.Tran == "usb":
|
||||
d.Kind = "usb"
|
||||
case d.Tran == "nvme" || strings.HasPrefix(n.Name, "nvme"):
|
||||
d.Kind = "nvme"
|
||||
case n.Rota:
|
||||
d.Kind = "hdd"
|
||||
case d.Tran == "sata" || d.Tran == "sas" || strings.HasPrefix(n.Name, "sd"):
|
||||
d.Kind = "ssd"
|
||||
default:
|
||||
d.Kind = "other"
|
||||
}
|
||||
var walk func(nodes []lsblkNode)
|
||||
walk = func(nodes []lsblkNode) {
|
||||
for _, c := range nodes {
|
||||
p := Part{Name: c.Name, Type: c.Type, FSType: str(c.FSType), Mount: str(c.Mountpoint), Label: str(c.Label), Size: c.Size}
|
||||
if p.Mount != "" && p.Mount != "[SWAP]" {
|
||||
var st syscall.Statfs_t
|
||||
if syscall.Statfs(p.Mount, &st) == nil {
|
||||
bs := uint64(st.Bsize)
|
||||
p.FSSize, p.Used = st.Blocks*bs, (st.Blocks-st.Bfree)*bs
|
||||
}
|
||||
}
|
||||
d.Parts = append(d.Parts, p)
|
||||
walk(c.Children)
|
||||
}
|
||||
}
|
||||
walk(n.Children)
|
||||
devs = append(devs, d)
|
||||
}
|
||||
devs = foldNVMe(devs)
|
||||
sort.Slice(devs, func(i, j int) bool { return devs[i].Name < devs[j].Name })
|
||||
return devs, nil
|
||||
}
|
||||
|
||||
// sysBlockDevices builds the same tree lsblk would from /sys/block and /proc/mounts.
|
||||
func sysBlockDevices() []lsblkNode {
|
||||
type mnt struct{ point, fstype string }
|
||||
mounts := map[string]mnt{}
|
||||
if b, err := os.ReadFile("/proc/mounts"); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if f := strings.Fields(line); len(f) >= 3 && strings.HasPrefix(f[0], "/dev/") {
|
||||
dev := f[0]
|
||||
if real, err := filepath.EvalSymlinks(dev); err == nil { // /dev/disk/by-uuid/…, /dev/mapper/…
|
||||
dev = real
|
||||
}
|
||||
if _, seen := mounts[filepath.Base(dev)]; !seen {
|
||||
mounts[filepath.Base(dev)] = mnt{strings.ReplaceAll(f[1], `\040`, " "), f[2]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile("/proc/swaps"); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if f := strings.Fields(line); len(f) > 0 && strings.HasPrefix(f[0], "/dev/") {
|
||||
mounts[filepath.Base(f[0])] = mnt{"[SWAP]", "swap"}
|
||||
}
|
||||
}
|
||||
}
|
||||
sp := func(v string) *string {
|
||||
if v = strings.TrimSpace(v); v == "" {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
node := func(dir, name, typ string) lsblkNode {
|
||||
n := lsblkNode{Name: name, Type: typ, Size: u64(strings.TrimSpace(readStrFile(dir+"/size"))) * 512}
|
||||
if m, ok := mounts[name]; ok {
|
||||
n.Mountpoint, n.FSType = sp(m.point), sp(m.fstype)
|
||||
}
|
||||
return n
|
||||
}
|
||||
var out []lsblkNode
|
||||
ents, _ := os.ReadDir("/sys/block")
|
||||
for _, e := range ents {
|
||||
name := e.Name()
|
||||
dir := "/sys/block/" + name
|
||||
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "zram") ||
|
||||
strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "sr") || strings.HasPrefix(name, "nbd") {
|
||||
continue
|
||||
}
|
||||
n := node(dir, name, "disk")
|
||||
n.Model, n.Vendor = sp(readStrFile(dir+"/device/model")), sp(readStrFile(dir+"/device/vendor"))
|
||||
n.Serial = sp(readStrFile(dir + "/device/serial"))
|
||||
n.Rota = strings.TrimSpace(readStrFile(dir+"/queue/rotational")) == "1"
|
||||
n.RM = strings.TrimSpace(readStrFile(dir+"/removable")) == "1"
|
||||
real, _ := filepath.EvalSymlinks(dir)
|
||||
switch {
|
||||
case strings.HasPrefix(name, "nvme"):
|
||||
n.Tran = sp("nvme")
|
||||
case strings.Contains(real, "/usb"):
|
||||
n.Tran = sp("usb")
|
||||
case strings.Contains(real, "/ata"):
|
||||
n.Tran = sp("sata")
|
||||
}
|
||||
subs, _ := os.ReadDir(dir)
|
||||
for _, sub := range subs {
|
||||
if _, err := os.Stat(dir + "/" + sub.Name() + "/partition"); err == nil {
|
||||
n.Children = append(n.Children, node(dir+"/"+sub.Name(), sub.Name(), "part"))
|
||||
}
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// An eMMC chip's boot and RPMB areas are hardware regions of the same chip, not drives;
|
||||
// the kernel lists them as disks of their own (mmcblk0boot0, mmcblk0boot1, mmcblk0rpmb).
|
||||
var mmcAreaRe = regexp.MustCompile(`^mmcblk\d+(boot\d+|rpmb|gp\d+)$`)
|
||||
|
||||
func mmcKind(name string) string {
|
||||
if strings.TrimSpace(readStrFile("/sys/block/"+name+"/device/type")) == "MMC" {
|
||||
return "emmc"
|
||||
}
|
||||
return "sd"
|
||||
}
|
||||
|
||||
// mmcHealth is the eMMC answer to SMART: the chip's own wear and end-of-life registers,
|
||||
// which the kernel publishes in /sys. SD cards have neither; smartctl speaks to neither.
|
||||
func mmcHealth(s Smart) Smart {
|
||||
dir := "/sys/block/" + s.Device + "/device/"
|
||||
s.Available, s.Supported = true, true
|
||||
s.SelfTestNote = "not a feature of eMMC or SD"
|
||||
s.Serial = strings.TrimSpace(readStrFile(dir + "serial"))
|
||||
s.Firmware = strings.TrimSpace(readStrFile(dir + "fwrev"))
|
||||
// fwrev is eight raw bytes in hex; vendors usually put ASCII in them ("0001")
|
||||
if b, err := hex.DecodeString(strings.TrimPrefix(s.Firmware, "0x")); err == nil {
|
||||
if t := strings.TrimRight(string(b), "\x00 "); t != "" && strings.IndexFunc(t, func(r rune) bool { return r < 0x20 || r > 0x7e }) < 0 {
|
||||
s.Firmware = t
|
||||
}
|
||||
}
|
||||
s.Model = strings.TrimSpace(readStrFile(dir + "name"))
|
||||
hx := func(v string) int { n, _ := strconv.ParseInt(strings.TrimPrefix(v, "0x"), 16, 32); return int(n) }
|
||||
life := strings.Fields(readStrFile(dir + "life_time"))
|
||||
eol := strings.TrimSpace(readStrFile(dir + "pre_eol_info"))
|
||||
if len(life) != 2 || eol == "" {
|
||||
s.Error = "This card reports no health data (SD cards don't; eMMC 5.0 and later does)."
|
||||
return s
|
||||
}
|
||||
m := &MMCHealth{LifeA: hx(life[0]), LifeB: hx(life[1]), PreEOL: hx(eol)}
|
||||
s.MMC = m
|
||||
ok := m.PreEOL <= 1 && m.LifeA < 10 && m.LifeB < 10
|
||||
s.Passed = &ok
|
||||
if m.PreEOL == 2 {
|
||||
s.Warnings = append(s.Warnings, "80% of reserved blocks consumed")
|
||||
} else if m.PreEOL >= 3 {
|
||||
s.Warnings = append(s.Warnings, "90% of reserved blocks consumed — replace soon")
|
||||
}
|
||||
if m.LifeA >= 10 || m.LifeB >= 10 {
|
||||
s.Warnings = append(s.Warnings, "estimated lifetime reached")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`)
|
||||
|
||||
// foldNVMe merges nvme0n1, nvme0n2, … into one physical device "nvme0": one SSD, one
|
||||
// SMART record, however many namespaces the controller presents (Apple exposes three).
|
||||
func foldNVMe(devs []Device) []Device {
|
||||
byCtrl := map[string]*Device{}
|
||||
var out []Device
|
||||
for _, d := range devs {
|
||||
m := nsRe.FindStringSubmatch(d.Name)
|
||||
if m == nil {
|
||||
out = append(out, d)
|
||||
continue
|
||||
}
|
||||
ctrl := m[1]
|
||||
c, ok := byCtrl[ctrl]
|
||||
if !ok {
|
||||
nd := d
|
||||
nd.Name = ctrl
|
||||
nd.Parts = []Part{}
|
||||
nd.Namespaces = nil
|
||||
if mdl := strings.TrimSpace(readStrFile("/sys/class/nvme/" + ctrl + "/model")); mdl != "" {
|
||||
nd.Model = mdl
|
||||
}
|
||||
if sn := strings.TrimSpace(readStrFile("/sys/class/nvme/" + ctrl + "/serial")); sn != "" {
|
||||
nd.Serial = sn
|
||||
}
|
||||
if t := ctrlTemp(ctrl); t > 0 {
|
||||
nd.Temp = t
|
||||
}
|
||||
nd.Size = 0
|
||||
c = &nd
|
||||
byCtrl[ctrl] = c
|
||||
out = append(out, Device{}) // placeholder keeps order; filled below
|
||||
c.Namespaces = []string{}
|
||||
}
|
||||
c.Size += d.Size
|
||||
c.Namespaces = append(c.Namespaces, d.Name)
|
||||
c.Parts = append(c.Parts, d.Parts...)
|
||||
}
|
||||
// replace placeholders with the folded controllers, in first-seen order
|
||||
res := out[:0]
|
||||
seen := map[string]bool{}
|
||||
for _, d := range out {
|
||||
if d.Name != "" {
|
||||
res = append(res, d)
|
||||
continue
|
||||
}
|
||||
for _, c := range byCtrl {
|
||||
if !seen[c.Name] {
|
||||
seen[c.Name] = true
|
||||
res = append(res, *c)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func readStrFile(p string) string {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func ctrlTemp(ctrl string) float64 {
|
||||
dirs, _ := filepath.Glob("/sys/class/nvme/" + ctrl + "/hwmon*")
|
||||
for _, d := range dirs {
|
||||
if b, err := os.ReadFile(filepath.Join(d, "temp1_input")); err == nil {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil {
|
||||
return float64(v) / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func blockTemp(name string) float64 {
|
||||
for _, pat := range []string{"/sys/block/" + name + "/device/hwmon*", "/sys/block/" + name + "/device/hwmon/hwmon*"} {
|
||||
dirs, _ := filepath.Glob(pat)
|
||||
for _, d := range dirs {
|
||||
if b, err := os.ReadFile(filepath.Join(d, "temp1_input")); err == nil {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil {
|
||||
return float64(v) / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var devRe = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
|
||||
|
||||
func validDevice(name string) bool {
|
||||
if !devRe.MatchString(name) {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat("/sys/block/" + name); err == nil {
|
||||
return true
|
||||
}
|
||||
_, err := os.Stat("/sys/class/nvme/" + name) // NVMe controller (nvme0), which is what smartctl wants anyway
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---- SMART ----
|
||||
|
||||
type Attr struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value int `json:"value"`
|
||||
Worst int `json:"worst"`
|
||||
Thresh int `json:"thresh"`
|
||||
Raw string `json:"raw"`
|
||||
Failed string `json:"failed,omitempty"`
|
||||
Warn bool `json:"warn"`
|
||||
}
|
||||
|
||||
type Smart struct {
|
||||
Device string `json:"device"`
|
||||
Available bool `json:"available"` // smartctl installed
|
||||
Supported bool `json:"supported"` // device answered
|
||||
Standby bool `json:"standby"` // spun down; not read
|
||||
Passed *bool `json:"passed"`
|
||||
Temp int `json:"temp,omitempty"`
|
||||
PowerOnHours int `json:"power_on_hours,omitempty"`
|
||||
PowerCycles int `json:"power_cycles,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Attrs []Attr `json:"attrs"`
|
||||
NVMe *NVMeLog `json:"nvme,omitempty"`
|
||||
MMC *MMCHealth `json:"mmc,omitempty"`
|
||||
SelfTest string `json:"self_test,omitempty"`
|
||||
SelfTestPct int `json:"self_test_remaining,omitempty"`
|
||||
SelfTestOK bool `json:"self_test_supported"`
|
||||
SelfTestNote string `json:"self_test_note,omitempty"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Error string `json:"error,omitempty"`
|
||||
FetchedAt int64 `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// MMCHealth is the eMMC EXT_CSD wear report. Life values are tenths: 1 = 0–10% of rated
|
||||
// life used, 10 = 90–100%, 11 = exceeded, 0 = not reported. A is the SLC area, B the MLC.
|
||||
type MMCHealth struct {
|
||||
LifeA int `json:"life_a"`
|
||||
LifeB int `json:"life_b"`
|
||||
PreEOL int `json:"pre_eol"` // 1 normal · 2 warning · 3 urgent
|
||||
}
|
||||
|
||||
type NVMeLog struct {
|
||||
CriticalWarning int `json:"critical_warning"`
|
||||
PercentageUsed int `json:"percentage_used"`
|
||||
AvailableSpare int `json:"available_spare"`
|
||||
SpareThreshold int `json:"spare_threshold"`
|
||||
MediaErrors int64 `json:"media_errors"`
|
||||
ErrorLogEntries int64 `json:"error_log_entries"`
|
||||
DataUnitsRead uint64 `json:"data_read_bytes"`
|
||||
DataUnitsWritten uint64 `json:"data_written_bytes"`
|
||||
UnsafeShutdowns int64 `json:"unsafe_shutdowns"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
sudo bool
|
||||
smartctl bool
|
||||
mu sync.Mutex
|
||||
cache map[string]Smart
|
||||
}
|
||||
|
||||
// lookPath also checks the sbin directories, which a user's PATH often lacks.
|
||||
func lookPath(bin string) (string, bool) {
|
||||
if p, err := exec.LookPath(bin); err == nil {
|
||||
return p, true
|
||||
}
|
||||
for _, d := range []string{"/usr/sbin", "/sbin", "/usr/local/sbin"} {
|
||||
if _, err := os.Stat(d + "/" + bin); err == nil {
|
||||
return d + "/" + bin, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func New(sudo bool) *Manager {
|
||||
_, ok := lookPath("smartctl")
|
||||
return &Manager{sudo: sudo, smartctl: ok, cache: map[string]Smart{}}
|
||||
}
|
||||
|
||||
// HasSmartctl re-probes when it was missing at startup, so an install from the Disks
|
||||
// tab takes effect without a daemon restart.
|
||||
func (m *Manager) HasSmartctl() bool {
|
||||
if !m.smartctl {
|
||||
_, m.smartctl = lookPath("smartctl")
|
||||
}
|
||||
return m.smartctl
|
||||
}
|
||||
|
||||
func (m *Manager) run(ctx context.Context, args ...string) ([]byte, int, error) {
|
||||
if p, ok := lookPath(args[0]); ok {
|
||||
args[0] = p
|
||||
}
|
||||
if m.sudo {
|
||||
args = priv.Wrap(args...)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
err := cmd.Run()
|
||||
code := 0
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
code = ee.ExitCode()
|
||||
err = nil // smartctl and zpool use exit codes as status; the caller decides
|
||||
if len(out.Bytes()) == 0 && errb.Len() > 0 {
|
||||
err = errors.New(strings.TrimSpace(strings.Split(errb.String(), "\n")[0])) // nothing usable came back; say why
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return out.Bytes(), code, errors.New(msg)
|
||||
}
|
||||
return out.Bytes(), code, nil
|
||||
}
|
||||
|
||||
func num(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Smart reads SMART for one device. Cached for 10 minutes; spinning disks in standby are
|
||||
// left asleep and reported as such. force re-reads (and will wake the disk).
|
||||
func (m *Manager) Smart(ctx context.Context, dev string, force bool) Smart {
|
||||
s := Smart{Device: dev, Available: m.smartctl, Attrs: []Attr{}, Warnings: []string{}, FetchedAt: time.Now().UnixMilli()}
|
||||
if !validDevice(dev) {
|
||||
s.Error = "unknown device"
|
||||
return s
|
||||
}
|
||||
if strings.HasPrefix(dev, "mmcblk") {
|
||||
return mmcHealth(s)
|
||||
}
|
||||
if !m.HasSmartctl() {
|
||||
return s
|
||||
}
|
||||
m.mu.Lock()
|
||||
if c, ok := m.cache[dev]; ok && !force && time.Since(time.UnixMilli(c.FetchedAt)) < 10*time.Minute {
|
||||
m.mu.Unlock()
|
||||
return c
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
args := []string{"smartctl", "-j", "-a"}
|
||||
if !force {
|
||||
args = append(args, "-n", "standby")
|
||||
}
|
||||
args = append(args, "/dev/"+dev)
|
||||
out, code, err := m.run(ctx, args...)
|
||||
if err != nil && len(out) == 0 {
|
||||
s.Error = err.Error()
|
||||
return s
|
||||
}
|
||||
var raw map[string]any
|
||||
if json.Unmarshal(out, &raw) != nil {
|
||||
s.Error = "smartctl: unreadable output"
|
||||
return s
|
||||
}
|
||||
if code&2 != 0 { // -n standby: device is asleep (also "open failed", but JSON will say)
|
||||
if msgs, ok := raw["smartctl"].(map[string]any); ok {
|
||||
for _, mm := range asList(msgs["messages"]) {
|
||||
if strings.Contains(strings.ToLower(asString(mm["string"])), "standby") {
|
||||
s.Standby = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.Standby {
|
||||
m.mu.Lock()
|
||||
m.cache[dev] = s
|
||||
m.mu.Unlock()
|
||||
return s
|
||||
}
|
||||
}
|
||||
if st, ok := raw["smart_status"].(map[string]any); ok {
|
||||
p := st["passed"] == true
|
||||
s.Passed = &p
|
||||
s.Supported = true
|
||||
}
|
||||
s.Model = asString(raw["model_name"])
|
||||
s.Serial = asString(raw["serial_number"])
|
||||
s.Firmware = asString(raw["firmware_version"])
|
||||
if t, ok := raw["temperature"].(map[string]any); ok {
|
||||
s.Temp = int(num(t["current"]))
|
||||
}
|
||||
if t, ok := raw["power_on_time"].(map[string]any); ok {
|
||||
s.PowerOnHours = int(num(t["hours"]))
|
||||
}
|
||||
s.PowerCycles = int(num(raw["power_cycle_count"]))
|
||||
|
||||
if a, ok := raw["ata_smart_attributes"].(map[string]any); ok {
|
||||
for _, row := range asList(a["table"]) {
|
||||
at := Attr{ID: int(num(row["id"])), Name: asString(row["name"]), Value: int(num(row["value"])), Worst: int(num(row["worst"])), Thresh: int(num(row["thresh"])), Failed: asString(row["when_failed"])}
|
||||
if rw, ok := row["raw"].(map[string]any); ok {
|
||||
at.Raw = asString(rw["string"])
|
||||
rv := num(rw["value"])
|
||||
switch at.ID {
|
||||
case 5, 187, 188, 196, 197, 198:
|
||||
at.Warn = rv > 0
|
||||
case 199:
|
||||
at.Warn = rv > 0
|
||||
case 10, 11:
|
||||
at.Warn = rv > 100
|
||||
}
|
||||
if at.Warn {
|
||||
s.Warnings = append(s.Warnings, at.Name+" = "+at.Raw)
|
||||
}
|
||||
}
|
||||
if at.Failed != "" {
|
||||
at.Warn = true
|
||||
s.Warnings = append(s.Warnings, at.Name+" failed ("+at.Failed+")")
|
||||
}
|
||||
s.Attrs = append(s.Attrs, at)
|
||||
}
|
||||
}
|
||||
if n, ok := raw["nvme_smart_health_information_log"].(map[string]any); ok {
|
||||
l := &NVMeLog{CriticalWarning: int(num(n["critical_warning"])), PercentageUsed: int(num(n["percentage_used"])),
|
||||
AvailableSpare: int(num(n["available_spare"])), SpareThreshold: int(num(n["available_spare_threshold"])),
|
||||
MediaErrors: num(n["media_errors"]), ErrorLogEntries: num(n["num_err_log_entries"]), UnsafeShutdowns: num(n["unsafe_shutdowns"]),
|
||||
DataUnitsRead: uint64(num(n["data_units_read"])) * 512000, DataUnitsWritten: uint64(num(n["data_units_written"])) * 512000}
|
||||
s.NVMe = l
|
||||
s.Supported = true
|
||||
if l.CriticalWarning != 0 {
|
||||
s.Warnings = append(s.Warnings, "critical warning flags set")
|
||||
}
|
||||
if l.MediaErrors > 0 {
|
||||
s.Warnings = append(s.Warnings, "media errors: "+strconv.FormatInt(l.MediaErrors, 10))
|
||||
}
|
||||
if l.SpareThreshold > 0 && l.AvailableSpare < l.SpareThreshold {
|
||||
s.Warnings = append(s.Warnings, "available spare below threshold")
|
||||
}
|
||||
if l.PercentageUsed >= 90 {
|
||||
s.Warnings = append(s.Warnings, "endurance "+strconv.Itoa(l.PercentageUsed)+"% used")
|
||||
}
|
||||
}
|
||||
// Can this drive run self-tests from here? ATA says so outright; NVMe drives prove it
|
||||
// by exposing the self-test log, and some (WD SN580 among them) advertise the command
|
||||
// but reject the log page, which makes the whole feature unusable.
|
||||
if d, ok := raw["ata_smart_data"].(map[string]any); ok {
|
||||
if caps, ok := d["capabilities"].(map[string]any); ok {
|
||||
s.SelfTestOK = caps["self_tests_supported"] == true
|
||||
if !s.SelfTestOK {
|
||||
s.SelfTestNote = "drive reports no self-test support"
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := raw["nvme_self_test_log"]; ok {
|
||||
s.SelfTestOK = true
|
||||
} else if s.NVMe != nil {
|
||||
s.SelfTestNote = "this drive's firmware doesn't expose the NVMe self-test log, so tests can't be run or read from here"
|
||||
}
|
||||
if msgs, ok := raw["smartctl"].(map[string]any); ok {
|
||||
for _, mm := range asList(msgs["messages"]) {
|
||||
if strings.Contains(asString(mm["string"]), "Self-test Log failed") {
|
||||
s.SelfTestOK = false
|
||||
s.SelfTestNote = "firmware rejects the self-test log page (" + asString(mm["string"]) + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
if d, ok := raw["ata_smart_data"].(map[string]any); ok {
|
||||
if st, ok := d["self_test"].(map[string]any); ok {
|
||||
if ss, ok := st["status"].(map[string]any); ok {
|
||||
s.SelfTest = asString(ss["string"])
|
||||
s.SelfTestPct = int(num(ss["remaining_percent"]))
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.Passed != nil && !*s.Passed {
|
||||
s.Warnings = append([]string{"SMART overall health: FAILED"}, s.Warnings...)
|
||||
}
|
||||
if !s.Supported && s.Error == "" {
|
||||
s.Error = "device does not report SMART"
|
||||
if msgs, ok := raw["smartctl"].(map[string]any); ok {
|
||||
for _, mm := range asList(msgs["messages"]) {
|
||||
if asString(mm["severity"]) == "error" {
|
||||
s.Error = asString(mm["string"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cache[dev] = s
|
||||
m.mu.Unlock()
|
||||
return s
|
||||
}
|
||||
|
||||
func asList(v any) []map[string]any {
|
||||
var out []map[string]any
|
||||
if l, ok := v.([]any); ok {
|
||||
for _, x := range l {
|
||||
if mm, ok := x.(map[string]any); ok {
|
||||
out = append(out, mm)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SelfTest starts a short or long self-test.
|
||||
func (m *Manager) SelfTest(ctx context.Context, dev, kind string) error {
|
||||
if !validDevice(dev) {
|
||||
return errors.New("unknown device")
|
||||
}
|
||||
if kind != "short" && kind != "long" {
|
||||
return errors.New("test must be short or long")
|
||||
}
|
||||
if !m.smartctl {
|
||||
return errors.New("smartctl is not installed")
|
||||
}
|
||||
if sm := m.Smart(ctx, dev, false); sm.Supported && !sm.SelfTestOK {
|
||||
return errors.New("self-tests aren't available on this drive: " + sm.SelfTestNote)
|
||||
}
|
||||
out, code, err := m.run(ctx, "smartctl", "-t", kind, "/dev/"+dev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != 0 {
|
||||
return errors.New(strings.TrimSpace(lastLine(string(out))))
|
||||
}
|
||||
m.mu.Lock()
|
||||
delete(m.cache, dev)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimSpace(s), "\n")
|
||||
return lines[len(lines)-1]
|
||||
}
|
||||
|
||||
// ---- ZFS ----
|
||||
|
||||
type Pool struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size"`
|
||||
Alloc uint64 `json:"alloc"`
|
||||
Free uint64 `json:"free"`
|
||||
Frag int `json:"frag"`
|
||||
Cap int `json:"cap"`
|
||||
Health string `json:"health"`
|
||||
Dedup string `json:"dedup"`
|
||||
Scan string `json:"scan,omitempty"`
|
||||
Errors string `json:"errors,omitempty"`
|
||||
Config string `json:"config,omitempty"` // vdev tree, verbatim
|
||||
}
|
||||
|
||||
type Dataset struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Used uint64 `json:"used"`
|
||||
Avail uint64 `json:"avail"`
|
||||
Refer uint64 `json:"refer"`
|
||||
UsedBySnap uint64 `json:"used_by_snapshots"`
|
||||
Mount string `json:"mount,omitempty"`
|
||||
Compress string `json:"compressratio"`
|
||||
Snapshots int `json:"snapshots"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Name string `json:"name"`
|
||||
Used uint64 `json:"used"`
|
||||
Created int64 `json:"created"` // unix ms
|
||||
}
|
||||
|
||||
type ZFS struct {
|
||||
Available bool `json:"available"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Pools []Pool `json:"pools"`
|
||||
Datasets []Dataset `json:"datasets"`
|
||||
}
|
||||
|
||||
var poolRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]*$`)
|
||||
var datasetRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.:/-]*$`)
|
||||
var snapRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`)
|
||||
|
||||
func u64(s string) uint64 { v, _ := strconv.ParseUint(s, 10, 64); return v }
|
||||
func atoi(s string) int { v, _ := strconv.Atoi(strings.TrimSuffix(s, "%")); return v }
|
||||
|
||||
// Pools is the pool list alone, for callers that poll: one `zpool list`, tried without
|
||||
// root first (it needs none on Linux) so polling writes nothing to the auth log.
|
||||
func (m *Manager) Pools(ctx context.Context) []Pool {
|
||||
bin, ok := lookPath("zpool")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
args := []string{"list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup"}
|
||||
out, err := exec.CommandContext(ctx, bin, args...).Output()
|
||||
if err != nil {
|
||||
var code int
|
||||
if out, code, err = m.run(ctx, append([]string{"zpool"}, args...)...); err != nil || code != 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return parsePools(out)
|
||||
}
|
||||
|
||||
func parsePools(out []byte) []Pool {
|
||||
pools := []Pool{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 8 {
|
||||
continue
|
||||
}
|
||||
pools = append(pools, Pool{Name: f[0], Size: u64(f[1]), Alloc: u64(f[2]), Free: u64(f[3]), Frag: atoi(f[4]), Cap: atoi(f[5]), Health: f[6], Dedup: f[7]})
|
||||
}
|
||||
return pools
|
||||
}
|
||||
|
||||
func (m *Manager) ZFS(ctx context.Context) ZFS {
|
||||
z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}}
|
||||
if _, ok := lookPath("zpool"); !ok {
|
||||
z.Message = "ZFS tools not installed"
|
||||
return z
|
||||
}
|
||||
out, code, err := m.run(ctx, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup")
|
||||
if err != nil || code != 0 {
|
||||
msg := "zpool list failed"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
z.Message = msg
|
||||
return z
|
||||
}
|
||||
z.Available = true
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 8 {
|
||||
continue
|
||||
}
|
||||
p := Pool{Name: f[0], Size: u64(f[1]), Alloc: u64(f[2]), Free: u64(f[3]), Frag: atoi(f[4]), Cap: atoi(f[5]), Health: f[6], Dedup: f[7]}
|
||||
if st, _, err := m.run(ctx, "zpool", "status", p.Name); err == nil {
|
||||
p.Scan, p.Errors, p.Config = parseStatus(string(st))
|
||||
}
|
||||
z.Pools = append(z.Pools, p)
|
||||
}
|
||||
snapCount := map[string]int{}
|
||||
if so, _, err := m.run(ctx, "zfs", "list", "-t", "snapshot", "-H", "-o", "name"); err == nil {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(so)), "\n") {
|
||||
if ds, _, ok := strings.Cut(line, "@"); ok {
|
||||
snapCount[ds]++
|
||||
}
|
||||
}
|
||||
}
|
||||
if do, _, err := m.run(ctx, "zfs", "list", "-Hp", "-t", "filesystem,volume", "-o", "name,type,used,avail,refer,usedbysnapshots,mountpoint,compressratio"); err == nil {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(do)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 8 {
|
||||
continue
|
||||
}
|
||||
d := Dataset{Name: f[0], Type: f[1], Used: u64(f[2]), Avail: u64(f[3]), Refer: u64(f[4]), UsedBySnap: u64(f[5]), Compress: f[7], Snapshots: snapCount[f[0]]}
|
||||
if f[6] != "-" && f[6] != "none" && f[6] != "legacy" {
|
||||
d.Mount = f[6]
|
||||
}
|
||||
z.Datasets = append(z.Datasets, d)
|
||||
}
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
// parseStatus pulls the scan line, the errors line and the vdev table out of zpool status.
|
||||
func parseStatus(text string) (scan, errs, config string) {
|
||||
lines := strings.Split(text, "\n")
|
||||
inConfig := false
|
||||
var cfg []string
|
||||
for i := 0; i < len(lines); i++ {
|
||||
l := lines[i]
|
||||
t := strings.TrimSpace(l)
|
||||
switch {
|
||||
case strings.HasPrefix(t, "scan:"):
|
||||
scan = strings.TrimSpace(strings.TrimPrefix(t, "scan:"))
|
||||
for i+1 < len(lines) && strings.HasPrefix(lines[i+1], "\t") && !strings.Contains(lines[i+1], ":") {
|
||||
i++
|
||||
scan += " " + strings.TrimSpace(lines[i])
|
||||
}
|
||||
case strings.HasPrefix(t, "errors:"):
|
||||
errs = strings.TrimSpace(strings.TrimPrefix(t, "errors:"))
|
||||
inConfig = false
|
||||
case strings.HasPrefix(t, "config:"):
|
||||
inConfig = true
|
||||
case inConfig && t != "":
|
||||
cfg = append(cfg, strings.TrimPrefix(l, "\t"))
|
||||
}
|
||||
}
|
||||
return scan, errs, strings.Join(cfg, "\n")
|
||||
}
|
||||
|
||||
func (m *Manager) Snapshots(ctx context.Context, dataset string) ([]Snapshot, error) {
|
||||
if !datasetRe.MatchString(dataset) {
|
||||
return nil, errors.New("bad dataset name")
|
||||
}
|
||||
out, code, err := m.run(ctx, "zfs", "list", "-t", "snapshot", "-Hp", "-d", "1", "-o", "name,used,creation", "-s", "creation", dataset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code != 0 {
|
||||
return nil, errors.New("zfs list failed")
|
||||
}
|
||||
snaps := []Snapshot{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
f := strings.Split(line, "\t")
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
c, _ := strconv.ParseInt(f[2], 10, 64)
|
||||
snaps = append(snaps, Snapshot{Name: f[0], Used: u64(f[1]), Created: c * 1000})
|
||||
}
|
||||
return snaps, nil
|
||||
}
|
||||
|
||||
// Scrub starts, pauses or stops a scrub. action: start | pause | stop.
|
||||
func (m *Manager) Scrub(ctx context.Context, pool, action string) error {
|
||||
if !poolRe.MatchString(pool) {
|
||||
return errors.New("bad pool name")
|
||||
}
|
||||
args := []string{"zpool", "scrub"}
|
||||
switch action {
|
||||
case "start":
|
||||
case "pause":
|
||||
args = append(args, "-p")
|
||||
case "stop":
|
||||
args = append(args, "-s")
|
||||
default:
|
||||
return errors.New("unknown scrub action")
|
||||
}
|
||||
out, code, err := m.run(ctx, append(args, pool)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != 0 {
|
||||
return errors.New(strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSnapshot takes dataset@name.
|
||||
func (m *Manager) CreateSnapshot(ctx context.Context, dataset, name string) error {
|
||||
if !datasetRe.MatchString(dataset) || !snapRe.MatchString(name) {
|
||||
return errors.New("bad snapshot name")
|
||||
}
|
||||
out, code, err := m.run(ctx, "zfs", "snapshot", dataset+"@"+name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != 0 {
|
||||
return errors.New(strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The /sys fallback must find the same disks and partitions lsblk does.
|
||||
func TestSysBlockDevices(t *testing.T) {
|
||||
if _, err := os.Stat("/sys/block"); err != nil {
|
||||
t.Skip("no /sys/block")
|
||||
}
|
||||
for _, d := range sysBlockDevices() {
|
||||
if d.Name == "" || d.Type != "disk" {
|
||||
t.Fatalf("bad node %+v", d)
|
||||
}
|
||||
t.Logf("%s size=%d tran=%s rota=%v parts=%d", d.Name, d.Size, str(d.Tran), d.Rota, len(d.Children))
|
||||
for _, c := range d.Children {
|
||||
t.Logf(" %s size=%d fs=%s mount=%s", c.Name, c.Size, str(c.FSType), str(c.Mountpoint))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package store
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,9 +16,58 @@ type Terminal struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Session is one signed-in browser. The map key is the cookie's token; ID is what the
|
||||
// UI sees and revokes by, so a session list never shows a usable token.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON also reads the pre-users form, a bare expiry time: such a session has no
|
||||
// user yet; the server gives it to the migrated first account.
|
||||
func (s *Session) UnmarshalJSON(b []byte) error {
|
||||
if len(b) > 0 && b[0] == '"' {
|
||||
return json.Unmarshal(b, &s.Expires)
|
||||
}
|
||||
type plain Session
|
||||
return json.Unmarshal(b, (*plain)(s))
|
||||
}
|
||||
|
||||
// UserState is what follows an account rather than the machine: how the console looks
|
||||
// to that person and how their dashboard is laid out.
|
||||
type UserState struct {
|
||||
UI json.RawMessage `json:"ui,omitempty"` // theme, accents; opaque to the server
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Terminals []Terminal `json:"terminals"`
|
||||
Sessions map[string]time.Time `json:"sessions"` // token -> expiry
|
||||
Sessions map[string]Session `json:"sessions"` // token -> session
|
||||
Users map[string]*UserState `json:"users,omitempty"`
|
||||
// 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.
|
||||
UI json.RawMessage `json:"ui,omitempty"`
|
||||
Alerts json.RawMessage `json:"alerts,omitempty"`
|
||||
ZFS json.RawMessage `json:"zfs,omitempty"` // snapshot schedules (internal/api/zfs.go) // rules, channels and recent events (internal/api/alerts.go)
|
||||
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.
|
||||
func (st *State) UserState(name string) *UserState {
|
||||
if st.Users == nil {
|
||||
st.Users = map[string]*UserState{}
|
||||
}
|
||||
u := st.Users[name]
|
||||
if u == nil {
|
||||
u = &UserState{}
|
||||
st.Users[name] = u
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -33,12 +83,16 @@ func Open(path string) (*Store, error) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
// State is conveniences (sessions, terminal names, layout). A file mangled by a power
|
||||
// cut must not keep the console down: set it aside and start clean.
|
||||
if err := json.Unmarshal(b, &s.state); err != nil {
|
||||
return nil, err
|
||||
s.state = State{}
|
||||
_ = os.Rename(path, path+".corrupt")
|
||||
log.Printf("state: %s unreadable (%v); kept as %s.corrupt, starting fresh", path, err, path)
|
||||
}
|
||||
}
|
||||
if s.state.Sessions == nil {
|
||||
s.state.Sessions = map[string]time.Time{}
|
||||
s.state.Sessions = map[string]Session{}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -63,9 +117,27 @@ func (s *Store) save() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return WriteFileAtomic(s.path, b, 0o600)
|
||||
}
|
||||
|
||||
// WriteFileAtomic writes beside path, syncs, then renames: after a crash the file is the
|
||||
// old version or the new one, never empty or half-written.
|
||||
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
_, err = f.Write(data)
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// Package tasks runs long commands (apt upgrades, and later anything else the UI kicks
|
||||
// off) in the background, keeps their output, and lets any number of clients follow it
|
||||
// live. It is also the task history: what ran, when, and how it ended.
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxOutput = 1 << 20 // bytes kept per task
|
||||
maxHistory = 200
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Kind string // "updates", "cron", …
|
||||
Title string
|
||||
Command string
|
||||
Started time.Time
|
||||
Finished time.Time
|
||||
Running bool
|
||||
ExitCode int
|
||||
Err string
|
||||
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
subs map[chan []byte]struct{}
|
||||
cancel context.CancelFunc
|
||||
onCancel func()
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Command string `json:"command"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished *time.Time `json:"finished,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Err string `json:"error,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
tasks map[string]*Task
|
||||
}
|
||||
|
||||
func New() *Manager { return &Manager{tasks: map[string]*Task{}} }
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 6)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Start launches cmd with its output captured into a new task. cmd must not have
|
||||
// Stdout/Stderr set. The task ends when the process exits or Cancel is called.
|
||||
func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t := &Task{ID: newID(), Kind: kind, Title: title, Command: cmd.String(), Started: time.Now(), Running: true,
|
||||
subs: map[chan []byte]struct{}{}, cancel: cancel}
|
||||
w := &writer{t: t}
|
||||
cmd.Stdout, cmd.Stderr = w, w
|
||||
// Its own process group: a task is usually `sh -c "a && b"`, and cancelling must reach a and
|
||||
// b, not just the shell — killing only the shell left compose running and, because compose
|
||||
// still held the output pipe, left the task "running" too. WaitDelay is the backstop for
|
||||
// anything that escapes the group and keeps the pipe open.
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.tasks[t.ID] = t
|
||||
m.prune()
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
var err error
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-ctx.Done():
|
||||
t.mu.Lock()
|
||||
hook := t.onCancel
|
||||
t.mu.Unlock()
|
||||
if hook != nil {
|
||||
go hook()
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) // asked first: compose and apt both stop cleanly
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(4 * time.Second):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
_ = cmd.Process.Kill()
|
||||
err = <-done
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.Err = "cancelled"
|
||||
t.mu.Unlock()
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.Running, t.Finished = false, time.Now()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
t.ExitCode = ee.ExitCode()
|
||||
} else {
|
||||
t.ExitCode = -1
|
||||
if t.Err == "" {
|
||||
t.Err = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
for ch := range t.subs {
|
||||
close(ch)
|
||||
}
|
||||
t.subs = map[chan []byte]struct{}{}
|
||||
t.mu.Unlock()
|
||||
cancel()
|
||||
}()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// prune keeps the newest maxHistory finished tasks. Caller holds m.mu.
|
||||
func (m *Manager) prune() {
|
||||
if len(m.tasks) <= maxHistory {
|
||||
return
|
||||
}
|
||||
var finished []*Task
|
||||
for _, t := range m.tasks {
|
||||
if !t.Running {
|
||||
finished = append(finished, t)
|
||||
}
|
||||
}
|
||||
sort.Slice(finished, func(i, j int) bool { return finished[i].Started.Before(finished[j].Started) })
|
||||
for i := 0; i < len(finished) && len(m.tasks) > maxHistory; i++ {
|
||||
delete(m.tasks, finished[i].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Get(id string) (*Task, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tasks[id]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// List returns every task newest first, without output.
|
||||
func (m *Manager) List() []Snapshot {
|
||||
m.mu.Lock()
|
||||
all := make([]*Task, 0, len(m.tasks))
|
||||
for _, t := range m.tasks {
|
||||
all = append(all, t)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Started.After(all[j].Started) })
|
||||
out := make([]Snapshot, 0, len(all))
|
||||
for _, t := range all {
|
||||
out = append(out, t.Snapshot(false))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RunningOf reports whether a task of the given kind is in flight.
|
||||
func (m *Manager) RunningOf(kind string) (*Task, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, t := range m.tasks {
|
||||
if t.Kind == kind && t.Running {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (t *Task) Snapshot(withOutput bool) Snapshot {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
s := Snapshot{ID: t.ID, Kind: t.Kind, Title: t.Title, Command: t.Command, Started: t.Started, Running: t.Running, ExitCode: t.ExitCode, Err: t.Err}
|
||||
if !t.Running {
|
||||
f := t.Finished
|
||||
s.Finished = &f
|
||||
}
|
||||
if withOutput {
|
||||
s.Output = t.buf.String()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Subscribe returns the output so far plus a channel of further chunks, closed when the
|
||||
// task ends. Call unsub when done following.
|
||||
func (t *Task) Subscribe() (backlog []byte, ch chan []byte, unsub func()) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
backlog = append([]byte(nil), t.buf.Bytes()...)
|
||||
if !t.Running {
|
||||
c := make(chan []byte)
|
||||
close(c)
|
||||
return backlog, c, func() {}
|
||||
}
|
||||
ch = make(chan []byte, 256)
|
||||
t.subs[ch] = struct{}{}
|
||||
return backlog, ch, func() {
|
||||
t.mu.Lock()
|
||||
if _, ok := t.subs[ch]; ok {
|
||||
delete(t.subs, ch)
|
||||
close(ch)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Task) Cancel() { t.cancel() }
|
||||
|
||||
// OnCancel sets something to do, besides stopping the task's own processes, when the task is
|
||||
// cancelled: whatever it started that isn't its child (a command inside a container).
|
||||
func (t *Task) OnCancel(fn func()) {
|
||||
t.mu.Lock()
|
||||
t.onCancel = fn
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
type writer struct{ t *Task }
|
||||
|
||||
func (w *writer) Write(p []byte) (int, error) {
|
||||
t := w.t
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.buf.Write(p)
|
||||
if t.buf.Len() > maxOutput {
|
||||
b := t.buf.Bytes()
|
||||
t.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-maxOutput*3/4:]...))
|
||||
}
|
||||
chunk := append([]byte(nil), p...)
|
||||
for ch := range t.subs {
|
||||
select {
|
||||
case ch <- chunk:
|
||||
default: // slow follower: drop the chunk rather than stall the process
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -68,6 +69,7 @@ type Manager struct {
|
||||
shell string
|
||||
home string
|
||||
OnChange func([]Info) // called (without locks held) when the list changes
|
||||
closing atomic.Bool
|
||||
}
|
||||
|
||||
func NewManager(shell, home string) *Manager {
|
||||
@@ -130,6 +132,15 @@ func (m *Manager) pump(s *Session) {
|
||||
s.mu.Unlock()
|
||||
_ = s.ptmx.Close()
|
||||
m.changed()
|
||||
// An exited shell stays listed for a while so the exit is visible, then reaps itself.
|
||||
time.AfterFunc(10*time.Minute, func() {
|
||||
m.mu.Lock()
|
||||
cur, ok := m.sessions[s.ID]
|
||||
m.mu.Unlock()
|
||||
if ok && cur == s && !s.running {
|
||||
_ = m.Close(s.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
|
||||
@@ -274,13 +285,15 @@ func (m *Manager) Close(id string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) changed() {
|
||||
if m.OnChange != nil {
|
||||
if m.OnChange != nil && !m.closing.Load() {
|
||||
m.OnChange(m.List())
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown hangs up every shell. Called on daemon exit.
|
||||
// Shutdown hangs up every shell. Called on daemon exit. The saved list is left as it
|
||||
// was, because that list is what the next start re-opens.
|
||||
func (m *Manager) Shutdown() {
|
||||
m.closing.Store(true)
|
||||
for _, s := range m.List() {
|
||||
_ = m.Close(s.ID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Package tlsauto makes a self-signed certificate for a node so hub↔node traffic is
|
||||
// encrypted without any CA. The hub pins the certificate's fingerprint.
|
||||
package tlsauto
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fingerprint formats a certificate's SHA-256 the way the UI shows it.
|
||||
func Fingerprint(der []byte) string {
|
||||
sum := sha256.Sum256(der)
|
||||
h := hex.EncodeToString(sum[:])
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(h); i += 2 {
|
||||
if i > 0 {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
b.WriteString(strings.ToUpper(h[i : i+2]))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Ensure returns the cert/key paths and fingerprint, generating them on first use.
|
||||
func Ensure(dir string) (certPath, keyPath, fingerprint string, err error) {
|
||||
certPath, keyPath = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
c, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err == nil && len(c.Certificate) > 0 {
|
||||
return certPath, keyPath, Fingerprint(c.Certificate[0]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "hamlab@" + host, Organization: []string{"Ham Lab"}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{host, "localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
for _, a := range addrs {
|
||||
if ipn, ok := a.(*net.IPNet); ok && !ipn.IP.IsLoopback() && ipn.IP.To4() != nil {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ipn.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return certPath, keyPath, Fingerprint(der), nil
|
||||
}
|
||||
|
||||
// PinnedConfig returns a client TLS config that accepts exactly one certificate.
|
||||
func PinnedConfig(fingerprint string) *tls.Config {
|
||||
return &tls.Config{
|
||||
InsecureSkipVerify: true, // we verify by fingerprint below, not by CA
|
||||
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(raw) == 0 {
|
||||
return errors.New("no certificate presented")
|
||||
}
|
||||
if Fingerprint(raw[0]) != fingerprint {
|
||||
return errors.New("certificate fingerprint changed: " + Fingerprint(raw[0]))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Peek connects and returns the fingerprint the server presents (trust on first use).
|
||||
func Peek(addr string) (string, error) {
|
||||
d := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return "", errors.New("no certificate presented")
|
||||
}
|
||||
return Fingerprint(certs[0].Raw), nil
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
// Package updates is the package-manager view: what can be upgraded, when the index
|
||||
// was last refreshed, whether a reboot is pending, and the refresh/upgrade actions,
|
||||
// which run as background tasks so their output can be followed live.
|
||||
//
|
||||
// Supported: apt (Debian, Ubuntu), dnf (Fedora, RHEL family), zypper (openSUSE, SLES),
|
||||
// pacman (Arch) and apk (Alpine). All of them list, refresh, upgrade and install; pacman
|
||||
// upgrades everything or nothing, because Arch doesn't support partial upgrades.
|
||||
package updates
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/tasks"
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
Name string `json:"name"`
|
||||
Current string `json:"current"`
|
||||
Candidate string `json:"candidate"`
|
||||
Suite string `json:"suite,omitempty"`
|
||||
Security bool `json:"security"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Manager string `json:"manager"` // apt | dnf | zypper | pacman | apk | ""
|
||||
CanUpgrade bool `json:"can_upgrade"`
|
||||
Selective bool `json:"selective"` // individual packages can be upgraded
|
||||
Packages []Package `json:"packages"`
|
||||
SecurityCount int `json:"security_count"`
|
||||
LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms
|
||||
LastUpgrade int64 `json:"last_upgrade,omitempty"`
|
||||
RebootNeeded bool `json:"reboot_needed"`
|
||||
RebootPkgs []string `json:"reboot_pkgs"`
|
||||
AutoUpgrades bool `json:"auto_upgrades"`
|
||||
RunningTask string `json:"running_task,omitempty"`
|
||||
Sudo bool `json:"sudo"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
sudo bool
|
||||
tasks *tasks.Manager
|
||||
pm string
|
||||
}
|
||||
|
||||
func New(sudo bool, tm *tasks.Manager) *Manager {
|
||||
m := &Manager{sudo: sudo, tasks: tm}
|
||||
for _, pm := range []string{"apt-get", "dnf", "zypper", "pacman", "apk"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
m.pm = strings.TrimSuffix(pm, "-get")
|
||||
break
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) sudoArgs(args ...string) []string {
|
||||
if m.sudo {
|
||||
return priv.Wrap(args...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func out(ctx context.Context, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "DEBIAN_FRONTEND=noninteractive")
|
||||
b, err := cmd.Output()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return string(b), errors.New(strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return string(b), err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// newestMtime: the newest mtime among the paths matching any of the globs.
|
||||
func newestMtime(globs ...string) int64 {
|
||||
var best int64
|
||||
for _, g := range globs {
|
||||
matches, _ := filepath.Glob(g)
|
||||
for _, m := range matches {
|
||||
if t := mtimeMs(m); t > best {
|
||||
best = t
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// dnfLastUpgrade: newest transaction in `dnf history` whose action includes an upgrade,
|
||||
// else the newest transaction of any kind.
|
||||
func dnfLastUpgrade(ctx context.Context) int64 {
|
||||
text, err := out(ctx, "dnf", "history", "list")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
re := regexp.MustCompile(`(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\S+)`)
|
||||
var newest, upgrade int64
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
m := re.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ms := t.UnixMilli()
|
||||
if ms > newest {
|
||||
newest = ms
|
||||
}
|
||||
if strings.Contains(m[2], "U") && ms > upgrade {
|
||||
upgrade = ms
|
||||
}
|
||||
}
|
||||
if upgrade > 0 {
|
||||
return upgrade
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
func mtimeMs(p string) int64 {
|
||||
st, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return st.ModTime().UnixMilli()
|
||||
}
|
||||
|
||||
// ---- status ----
|
||||
|
||||
func (m *Manager) Status(ctx context.Context) Status {
|
||||
s := Status{Manager: m.pm, Packages: []Package{}, RebootPkgs: []string{}, Sudo: m.sudo}
|
||||
if t, ok := m.tasks.RunningOf("updates"); ok {
|
||||
s.RunningTask = t.ID
|
||||
}
|
||||
s.CanUpgrade = m.sudo && m.pm != ""
|
||||
s.Selective = m.pm != "pacman"
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
s.Packages = aptUpgradable(ctx)
|
||||
s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp")
|
||||
if s.LastRefresh == 0 {
|
||||
s.LastRefresh = mtimeMs("/var/lib/apt/lists/partial")
|
||||
}
|
||||
if s.LastRefresh == 0 {
|
||||
s.LastRefresh = mtimeMs("/var/lib/apt/lists")
|
||||
}
|
||||
s.LastUpgrade = aptLastUpgrade()
|
||||
s.AutoUpgrades = aptAutoUpgrades(ctx)
|
||||
case "dnf":
|
||||
s.Packages = dnfUpgradable(ctx)
|
||||
s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf")
|
||||
s.LastUpgrade = dnfLastUpgrade(ctx)
|
||||
case "zypper":
|
||||
s.Packages = zypperUpgradable(ctx)
|
||||
s.LastRefresh = newestMtime("/var/cache/zypp/raw/*", "/var/cache/zypp/raw")
|
||||
s.LastUpgrade = mtimeMs("/var/log/zypp/history")
|
||||
case "pacman":
|
||||
s.Packages = pacmanUpgradable(ctx)
|
||||
s.LastRefresh = newestMtime("/var/lib/pacman/sync/*.db", "/var/lib/pacman/sync")
|
||||
s.LastUpgrade = pacmanLastUpgrade()
|
||||
case "apk":
|
||||
s.Packages = apkUpgradable(ctx)
|
||||
s.LastRefresh = newestMtime("/var/cache/apk/APKINDEX.*", "/var/lib/apk/APKINDEX.*")
|
||||
s.LastUpgrade = mtimeMs("/lib/apk/db/installed")
|
||||
}
|
||||
for _, p := range s.Packages {
|
||||
if p.Security {
|
||||
s.SecurityCount++
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/run/reboot-required"); err == nil {
|
||||
s.RebootNeeded = true
|
||||
if b, err := os.ReadFile("/run/reboot-required.pkgs"); err == nil {
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(b)), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
s.RebootPkgs = append(s.RebootPkgs, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if m.pm != "apt" {
|
||||
s.RebootNeeded = kernelChanged()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// kernelChanged: the running kernel isn't the newest installed one (dnf/pacman hosts).
|
||||
func kernelChanged() bool {
|
||||
b, err := os.ReadFile("/proc/sys/kernel/osrelease")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
running := strings.TrimSpace(string(b))
|
||||
ents, err := os.ReadDir("/lib/modules")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
newest := ""
|
||||
for _, e := range ents {
|
||||
if e.Name() > newest {
|
||||
newest = e.Name()
|
||||
}
|
||||
}
|
||||
return newest != "" && newest != running
|
||||
}
|
||||
|
||||
// ---- apt ----
|
||||
|
||||
var aptListRe = regexp.MustCompile(`^(\S+)/(\S+)\s+(\S+)\s+\S+\s+\[upgradable from: (\S+)\]`)
|
||||
|
||||
func aptUpgradable(ctx context.Context) []Package {
|
||||
text, _ := out(ctx, "apt", "list", "--upgradable")
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
m := aptListRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
pkgs = append(pkgs, Package{Name: m[1], Suite: m[2], Candidate: m[3], Current: m[4],
|
||||
Security: strings.Contains(m[2], "security")})
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return []Package{}
|
||||
}
|
||||
// One apt-cache call for all the descriptions.
|
||||
args := append([]string{"apt-cache", "show", "--no-all-versions"}, names(pkgs)...)
|
||||
if text, err := out(ctx, args...); err == nil {
|
||||
desc := map[string]string{}
|
||||
cur := ""
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(line, "Package: ") {
|
||||
cur = strings.TrimPrefix(line, "Package: ")
|
||||
} else if strings.HasPrefix(line, "Description-en: ") || strings.HasPrefix(line, "Description: ") {
|
||||
if _, ok := desc[cur]; !ok {
|
||||
_, v, _ := strings.Cut(line, ": ")
|
||||
desc[cur] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range pkgs {
|
||||
pkgs[i].Description = desc[pkgs[i].Name]
|
||||
}
|
||||
}
|
||||
sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Name < pkgs[j].Name })
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func names(pkgs []Package) []string {
|
||||
n := make([]string, len(pkgs))
|
||||
for i, p := range pkgs {
|
||||
n[i] = p.Name
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// aptLastUpgrade: the Start-Date of the last history.log entry that upgraded something.
|
||||
func aptLastUpgrade() int64 {
|
||||
f, err := os.Open("/var/log/apt/history.log")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
var last, start string
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64<<10), 8<<20)
|
||||
for sc.Scan() {
|
||||
l := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(l, "Start-Date: "):
|
||||
start = strings.TrimPrefix(l, "Start-Date: ")
|
||||
case strings.HasPrefix(l, "Upgrade: "):
|
||||
last = start
|
||||
}
|
||||
}
|
||||
if last == "" {
|
||||
return 0
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", last, time.Local)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return t.UnixMilli()
|
||||
}
|
||||
|
||||
func aptAutoUpgrades(ctx context.Context) bool {
|
||||
text, _ := out(ctx, "apt-config", "dump", "APT::Periodic::Unattended-Upgrade")
|
||||
return strings.Contains(text, `"1"`)
|
||||
}
|
||||
|
||||
// ---- dnf / zypper / pacman / apk ----
|
||||
|
||||
func dnfUpgradable(ctx context.Context) []Package {
|
||||
text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) != 3 || !strings.Contains(f[0], ".") {
|
||||
continue
|
||||
}
|
||||
name := f[0][:strings.LastIndex(f[0], ".")]
|
||||
pkgs = append(pkgs, Package{Name: name, Candidate: f[1], Suite: f[2], Security: strings.Contains(f[2], "security")})
|
||||
}
|
||||
if pkgs == nil {
|
||||
return []Package{}
|
||||
}
|
||||
// check-update only names the candidate; one rpm query fills in what's installed.
|
||||
args := append([]string{"rpm", "-q", "--qf", `%{NAME} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`}, names(pkgs)...)
|
||||
if text, _ := out(ctx, args...); text != "" { // exits non-zero when any name isn't installed (new dependencies)
|
||||
cur := map[string]string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if f := strings.Fields(line); len(f) == 2 {
|
||||
cur[f[0]] = f[1] // several installed (kernels): the last listed is the newest
|
||||
}
|
||||
}
|
||||
for i := range pkgs {
|
||||
pkgs[i].Current = cur[pkgs[i].Name]
|
||||
}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
func pacmanUpgradable(ctx context.Context) []Package {
|
||||
text, _ := out(ctx, "pacman", "-Qu")
|
||||
var pkgs []Package
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line) // name old -> new
|
||||
if len(f) == 4 && f[2] == "->" {
|
||||
pkgs = append(pkgs, Package{Name: f[0], Current: f[1], Candidate: f[3]})
|
||||
}
|
||||
}
|
||||
if pkgs == nil {
|
||||
return []Package{}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// zypperUpgradable parses the table `zypper list-updates` prints:
|
||||
// S | Repository | Name | Current Version | Available Version | Arch
|
||||
func zypperUpgradable(ctx context.Context) []Package {
|
||||
text, _ := out(ctx, "zypper", "--non-interactive", "--quiet", "list-updates")
|
||||
pkgs := []Package{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Split(line, "|")
|
||||
if len(f) < 5 || strings.TrimSpace(f[0]) != "v" {
|
||||
continue
|
||||
}
|
||||
repo := strings.TrimSpace(f[1])
|
||||
pkgs = append(pkgs, Package{Name: strings.TrimSpace(f[2]), Current: strings.TrimSpace(f[3]), Candidate: strings.TrimSpace(f[4]),
|
||||
Suite: repo, Security: strings.Contains(strings.ToLower(repo), "security")})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
var apkVerRe = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\s+<\s+(\S+)`)
|
||||
|
||||
// apkUpgradable parses `apk version -l '<'`: "musl-1.2.4-r2 < 1.2.5-r0".
|
||||
func apkUpgradable(ctx context.Context) []Package {
|
||||
text, _ := out(ctx, "apk", "version", "-l", "<")
|
||||
pkgs := []Package{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if m := apkVerRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
|
||||
pkgs = append(pkgs, Package{Name: m[1], Current: m[2], Candidate: m[3]})
|
||||
}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// pacmanLastUpgrade: the timestamp of the last "upgraded" line in pacman.log.
|
||||
func pacmanLastUpgrade() int64 {
|
||||
f, err := os.Open("/var/log/pacman.log")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
last := ""
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
||||
for sc.Scan() {
|
||||
if l := sc.Text(); strings.Contains(l, "[ALPM] upgraded ") && strings.HasPrefix(l, "[") {
|
||||
if end := strings.IndexByte(l, ']'); end > 1 {
|
||||
last = l[1:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} {
|
||||
if t, err := time.ParseInLocation(layout, last, time.Local); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- actions ----
|
||||
|
||||
// Never starts with '-', so a name can't be read as an option by any of the managers.
|
||||
var pkgNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+._-]{0,127}$`)
|
||||
|
||||
func checkNames(pkgs []string) error {
|
||||
for _, p := range pkgs {
|
||||
if !pkgNameRe.MatchString(p) {
|
||||
return errors.New("bad package name: " + p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) start(args ...string) (*tasks.Task, error) {
|
||||
if m.pm == "" {
|
||||
return nil, errors.New("no supported package manager on this host")
|
||||
}
|
||||
if !m.sudo {
|
||||
return nil, errors.New("needs root: run Ham Lab as root, or give its user passwordless sudo or doas")
|
||||
}
|
||||
if t, ok := m.tasks.RunningOf("updates"); ok {
|
||||
return nil, errors.New("another package task is running: " + t.Title)
|
||||
}
|
||||
full := priv.Wrap(append([]string{"env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)...)
|
||||
cmd := exec.Command(full[0], full[1:]...)
|
||||
return m.tasks.Start("updates", strings.Join(args, " "), cmd)
|
||||
}
|
||||
|
||||
func (m *Manager) Refresh() (*tasks.Task, error) {
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
return m.start("apt-get", "update")
|
||||
case "dnf":
|
||||
return m.start("dnf", "-y", "makecache", "--refresh")
|
||||
case "zypper":
|
||||
return m.start("zypper", "--non-interactive", "refresh")
|
||||
case "pacman":
|
||||
return m.start("pacman", "-Sy", "--noconfirm")
|
||||
case "apk":
|
||||
return m.start("apk", "update")
|
||||
}
|
||||
return m.start()
|
||||
}
|
||||
|
||||
// rolling reports a distro whose supported upgrade path is a full distribution upgrade.
|
||||
func rolling() bool {
|
||||
b, _ := os.ReadFile("/etc/os-release")
|
||||
return strings.Contains(string(b), "tumbleweed") || strings.Contains(string(b), "microos")
|
||||
}
|
||||
|
||||
// Upgrade upgrades everything, or only the named packages. Nothing here can block on a
|
||||
// question: apt keeps the current config file on a conflict, the rest run non-interactive.
|
||||
func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) {
|
||||
if err := checkNames(pkgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all := len(pkgs) == 0
|
||||
var args []string
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
args = []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
|
||||
if all {
|
||||
args = append(args, "dist-upgrade")
|
||||
} else {
|
||||
args = append(args, "install", "--only-upgrade")
|
||||
}
|
||||
case "dnf":
|
||||
args = []string{"dnf", "-y", "upgrade"}
|
||||
case "zypper":
|
||||
args = []string{"zypper", "--non-interactive", "update"}
|
||||
if all && rolling() {
|
||||
args = []string{"zypper", "--non-interactive", "dist-upgrade"}
|
||||
}
|
||||
case "pacman":
|
||||
if !all {
|
||||
return nil, errors.New("pacman upgrades everything or nothing: Arch doesn't support partial upgrades")
|
||||
}
|
||||
args = []string{"pacman", "-Syu", "--noconfirm"}
|
||||
case "apk":
|
||||
args = []string{"apk", "upgrade"}
|
||||
if all {
|
||||
args = []string{"apk", "upgrade", "--update-cache"}
|
||||
}
|
||||
}
|
||||
return m.start(append(args, pkgs...)...)
|
||||
}
|
||||
|
||||
// Install installs packages (used by other tabs for optional tooling like smartmontools).
|
||||
func (m *Manager) Install(pkgs []string) (*tasks.Task, error) {
|
||||
if len(pkgs) == 0 {
|
||||
return nil, errors.New("no packages")
|
||||
}
|
||||
if err := checkNames(pkgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var args []string
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
args = []string{"apt-get", "-y", "--no-install-recommends", "install"}
|
||||
case "dnf":
|
||||
args = []string{"dnf", "-y", "install"}
|
||||
case "zypper":
|
||||
args = []string{"zypper", "--non-interactive", "install", "--no-recommends"}
|
||||
case "pacman":
|
||||
args = []string{"pacman", "-S", "--noconfirm", "--needed"}
|
||||
case "apk":
|
||||
args = []string{"apk", "add"}
|
||||
}
|
||||
return m.start(append(args, pkgs...)...)
|
||||
}
|
||||
|
||||
func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) {
|
||||
if !pkgNameRe.MatchString(pkg) {
|
||||
return "", errors.New("bad package name")
|
||||
}
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
text, err := out(ctx, "apt-get", "changelog", "-qq", pkg)
|
||||
if len(text) > 64<<10 {
|
||||
text = text[:64<<10] + "\n…"
|
||||
}
|
||||
return text, err
|
||||
case "dnf":
|
||||
return out(ctx, "dnf", "-q", "changelog", pkg)
|
||||
}
|
||||
return "", errors.New("changelogs not available for " + m.pm)
|
||||
}
|
||||
|
||||
// Power runs systemctl reboot / poweroff.
|
||||
func (m *Manager) Power(ctx context.Context, action string) error {
|
||||
if action != "reboot" && action != "poweroff" {
|
||||
return errors.New("unknown power action")
|
||||
}
|
||||
_, err := out(ctx, m.sudoArgs("systemctl", action)...)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
# Publish what `make release` built as a release on Gitea, which is where every Ham Lab
|
||||
# looks for a newer build (Settings → Updates, and `update.url` in config.json).
|
||||
#
|
||||
# make release && scripts/gitea-release.sh [--force]
|
||||
#
|
||||
# The token needs write:repository and is read from $HAMLAB_PUBLISH_TOKEN, else from
|
||||
# ~/.config/hamlab-publish-token. It is never printed.
|
||||
#
|
||||
# A release is a deliberate act, not part of `make deploy`: deploying pushes this build to
|
||||
# the machines this hub owns, while publishing offers it to every install everywhere.
|
||||
set -eu
|
||||
|
||||
REPO=${HAMLAB_REPO:-https://git.bigham.dev/api/v1/repos/mitchell/hamlab}
|
||||
DIST=${DIST:-dist}
|
||||
FORCE=0
|
||||
[ "${1:-}" != "--force" ] || FORCE=1
|
||||
|
||||
die() { printf '%s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ -f "$DIST/VERSION" ] || die "no $DIST/VERSION — run: make release"
|
||||
VERSION=$(head -n 1 "$DIST/VERSION" | tr -cd 'A-Za-z0-9._+-')
|
||||
[ -n "$VERSION" ] || die "$DIST/VERSION is empty"
|
||||
|
||||
TOKEN=${HAMLAB_PUBLISH_TOKEN:-}
|
||||
if [ -z "$TOKEN" ] && [ -r "$HOME/.config/hamlab-publish-token" ]; then
|
||||
TOKEN=$(cat "$HOME/.config/hamlab-publish-token")
|
||||
fi
|
||||
[ -n "$TOKEN" ] || die "no publish token: set HAMLAB_PUBLISH_TOKEN or write ~/.config/hamlab-publish-token (Gitea → Settings → Applications, scope write:repository)"
|
||||
|
||||
api() { # method path [curl args...]
|
||||
m=$1; p=$2; shift 2
|
||||
curl -sS -X "$m" -H "Authorization: token $TOKEN" "$REPO$p" "$@"
|
||||
}
|
||||
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "publishing $VERSION ($(echo "$SHA" | cut -c1-8)) to $REPO"
|
||||
|
||||
# An existing release for this version is replaced only when asked: re-publishing a version
|
||||
# with different binaries is how an install ends up with something nobody can reproduce.
|
||||
existing=$(api GET "/releases/tags/$VERSION" | python3 -c 'import json,sys
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: d = {}
|
||||
print(d.get("id") or "")' 2>/dev/null || true)
|
||||
if [ -n "$existing" ]; then
|
||||
[ "$FORCE" = 1 ] || die "$VERSION is already published (release $existing). Re-publish with --force."
|
||||
echo " replacing release $existing"
|
||||
api DELETE "/releases/$existing" -o /dev/null
|
||||
fi
|
||||
|
||||
notes=$(git log -1 --pretty=%B | head -20)
|
||||
payload=$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target": sys.argv[2], "name": sys.argv[1], "body": sys.argv[3], "draft": False, "prerelease": False}))' "$VERSION" "$SHA" "$notes")
|
||||
id=$(printf '%s' "$payload" | api POST /releases -H 'Content-Type: application/json' -d @- | python3 -c 'import json,sys
|
||||
d = json.load(sys.stdin)
|
||||
if not d.get("id"): sys.exit("gitea refused the release: %s" % d.get("message", d))
|
||||
print(d["id"])')
|
||||
[ -n "$id" ] || die "could not create the release"
|
||||
echo " release $id created"
|
||||
|
||||
n=0
|
||||
for f in "$DIST"/hamlab-linux-* "$DIST/SHA256SUMS" "$DIST/install.sh" "$DIST/VERSION"; do
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f")
|
||||
api POST "/releases/$id/assets?name=$name" -F "attachment=@$f" -o /dev/null
|
||||
n=$((n + 1))
|
||||
echo " + $name"
|
||||
done
|
||||
echo "published $VERSION with $n assets"
|
||||
echo " every Ham Lab pointed at this repo will offer it in Settings → Updates"
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"icons": "node scripts/icons.mjs"
|
||||
"icons": "node scripts/icons.mjs",
|
||||
"test": "node scripts/test-graphcols.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 310 KiB |
@@ -1,5 +1,5 @@
|
||||
// Ham Lab service worker: cache hashed assets, never the API, network-first for the shell.
|
||||
const CACHE = 'hamlab-v1';
|
||||
const CACHE = 'hamlab-v3'; // bumped when files under /icons/ change: they are served cache-first
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
||||
|
||||
@@ -1,26 +1,68 @@
|
||||
// Renders the PWA icons with node-canvas (borrowed from ../../calorie-counter/node_modules).
|
||||
// Renders the PWA icon set and the login logo from scripts/logo-source.png, with node-canvas
|
||||
// (borrowed from ../../calorie-counter/node_modules). node scripts/icons.mjs
|
||||
//
|
||||
// The artwork is a rounded square on a transparent background, a little off-centre in its
|
||||
// file. It is trimmed to its own edge, then drawn three ways:
|
||||
// icon-192 / icon-512 / logo the artwork alone, transparent corners, filling the square
|
||||
// apple-touch-icon iOS paints transparency black and cuts its own, tighter
|
||||
// corners: a dark ground from the artwork's own palette, the
|
||||
// artwork inset a little so its neon ring clears that cut
|
||||
// icon-512-maskable Android may crop to a circle 80% across: the whole artwork
|
||||
// sits inside that circle, on the same dark ground to the edge
|
||||
import { createRequire } from 'node:module'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createCanvas } = require('/home/core/projects/calorie-counter/node_modules/canvas')
|
||||
const { createCanvas, loadImage } = require('/home/core/projects/calorie-counter/node_modules/canvas')
|
||||
|
||||
function draw(size, { pad = 0, radius = 0 }) {
|
||||
const c = createCanvas(size, size), g = c.getContext('2d')
|
||||
g.fillStyle = '#111418'
|
||||
if (radius) { g.beginPath(); g.roundRect(0, 0, size, size, radius); g.fill() } else g.fillRect(0, 0, size, size)
|
||||
const inner = size - pad * 2
|
||||
g.fillStyle = '#e3a33b'
|
||||
g.font = `bold ${inner * 0.62}px "DejaVu Serif", serif`
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle'
|
||||
g.fillText('H', size / 2, size / 2 + inner * 0.03)
|
||||
// prompt caret
|
||||
g.fillStyle = '#58a6ff'
|
||||
g.font = `bold ${inner * 0.22}px "DejaVu Sans Mono", monospace`
|
||||
g.fillText('>_', size / 2 + inner * 0.27, size / 2 + inner * 0.27)
|
||||
return c.toBuffer('image/png')
|
||||
const img = await loadImage(new URL('./logo-source.png', import.meta.url).pathname)
|
||||
const src = createCanvas(img.width, img.height), sg = src.getContext('2d')
|
||||
sg.drawImage(img, 0, 0)
|
||||
const px = sg.getImageData(0, 0, img.width, img.height), d = px.data
|
||||
// The export left the artwork at alpha 252 and a stray alpha-1 speck outside it: make what
|
||||
// is there fully there, what isn't fully gone, and keep the anti-aliased edge between.
|
||||
let x0 = img.width, y0 = img.height, x1 = 0, y1 = 0
|
||||
for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) {
|
||||
const i = (y * img.width + x) * 4 + 3
|
||||
if (d[i] >= 240) d[i] = 255; else if (d[i] < 8) d[i] = 0
|
||||
if (d[i] > 128) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y }
|
||||
}
|
||||
writeFileSync('public/icons/icon-192.png', draw(192, {}))
|
||||
writeFileSync('public/icons/icon-512.png', draw(512, {}))
|
||||
writeFileSync('public/icons/icon-512-maskable.png', draw(512, { pad: 80 }))
|
||||
writeFileSync('public/icons/apple-touch-icon.png', draw(180, {}))
|
||||
console.log('icons written')
|
||||
sg.putImageData(px, 0, 0)
|
||||
// a square around the artwork's own centre, as wide as its longer side: never stretched
|
||||
const side = Math.max(x1 - x0, y1 - y0) + 1, cx = (x0 + x1) / 2, cy = (y0 + y1) / 2
|
||||
const art = createCanvas(side, side)
|
||||
art.getContext('2d').drawImage(src, Math.round(cx - side / 2), Math.round(cy - side / 2), side, side, 0, 0, side, side)
|
||||
|
||||
// Shrinking six-fold in one step aliases the fine lines; halving until close does not.
|
||||
function shrink(from, size) {
|
||||
let cur = from
|
||||
while (cur.width / 2 >= size) {
|
||||
const next = createCanvas(Math.ceil(cur.width / 2), Math.ceil(cur.height / 2)), g = next.getContext('2d')
|
||||
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; g.quality = 'best'; g.patternQuality = 'best'
|
||||
g.drawImage(cur, 0, 0, next.width, next.height)
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
function draw(size, { scale = 1, ground = false } = {}) {
|
||||
const c = createCanvas(size, size), g = c.getContext('2d')
|
||||
if (ground) { // the artwork's own night-green, lit a little behind the middle
|
||||
const bg = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size * 0.72)
|
||||
bg.addColorStop(0, '#0b2e2a'); bg.addColorStop(1, '#03110f')
|
||||
g.fillStyle = bg; g.fillRect(0, 0, size, size)
|
||||
}
|
||||
const w = Math.round(size * scale), from = shrink(art, w)
|
||||
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; g.quality = 'best'; g.patternQuality = 'best'
|
||||
g.drawImage(from, (size - w) / 2, (size - w) / 2, w, w)
|
||||
return c.toBuffer('image/png', { compressionLevel: 9 })
|
||||
}
|
||||
|
||||
const out = {
|
||||
'icon-192.png': draw(192),
|
||||
'icon-512.png': draw(512),
|
||||
'logo.png': draw(480),
|
||||
'apple-touch-icon.png': draw(180, { scale: 0.95, ground: true }),
|
||||
'icon-512-maskable.png': draw(512, { scale: 0.68, ground: true }),
|
||||
}
|
||||
for (const [name, buf] of Object.entries(out)) { writeFileSync(new URL('../public/icons/' + name, import.meta.url).pathname, buf); console.log(name, Math.round(buf.length / 1024) + ' KB') }
|
||||
console.log(`artwork ${x1 - x0 + 1}×${y1 - y0 + 1} at ${x0},${y0} → ${side} px square`)
|
||||
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,42 @@
|
||||
// node scripts/test-graphcols.mjs — the rule in src/graphcols.ts, on a curve scrolled one point at a time.
|
||||
// (No test runner in this project: esbuild, which vite already brings, turns the one file into something node can load.)
|
||||
import { buildSync } from 'esbuild'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const out = join(mkdtempSync(join(tmpdir(), 'graphcols-')), 'graphcols.mjs')
|
||||
buildSync({ entryPoints: [new URL('../src/graphcols.ts', import.meta.url).pathname], outfile: out, format: 'esm', logLevel: 'error' })
|
||||
const { columnsOf } = await import(pathToFileURL(out).href)
|
||||
|
||||
const fails = []
|
||||
const check = (ok, what, detail) => { if (!ok) fails.push(what + (detail !== undefined ? ' — ' + JSON.stringify(detail).slice(0, 200) : '')) }
|
||||
const CAP = 300, COLS = 90 // the net graph: 300 points of history in about 90 dot-columns
|
||||
const base = Array.from({ length: CAP }, (_, i) => (i === 100 ? 900 : i === 180 ? 500 : i === 251 ? 700 : 10))
|
||||
const spikes = (cols) => cols.filter((v) => v > 100).sort((a, b) => b - a).join(',')
|
||||
|
||||
// what Top drew before 2026-09-21: the nearest point for each column, counted from the right edge
|
||||
const nearest = (values, cap, cols) => { const n = Math.min(values.length, cap), seen = []; for (let c = 0; c < cols; c++) { const i = Math.floor((c * cap) / cols) - (cap - n); if (i >= 0) seen.push(values[values.length - n + i]) } return seen }
|
||||
|
||||
let values = base.slice(), advance = CAP - 1, prev = null
|
||||
const oldShapes = new Set(), newShapes = new Set()
|
||||
for (let step = 0; step < 40; step++) {
|
||||
oldShapes.add(spikes(nearest(values, CAP, COLS)))
|
||||
const cols = columnsOf(values, advance, CAP, COLS)
|
||||
newShapes.add(spikes(cols))
|
||||
if (prev) check([0, 1].some((sh) => cols.slice(0, -1).every((v, j) => prev[j + sh] === undefined || prev[j + sh] === v)), `frame ${step} is the one before, slid left by a column at most`)
|
||||
prev = cols
|
||||
values.push(10); values.shift(); advance++
|
||||
}
|
||||
check(oldShapes.size > 1, 'the test reproduces the fault it guards against (spikes flickering under nearest-point drawing)', [...oldShapes])
|
||||
check(newShapes.size === 1 && [...newShapes][0] === '900,700,500', 'every spike is drawn, at its height, on every frame', [...newShapes])
|
||||
check(columnsOf(base, CAP - 1, CAP, COLS).length === COLS, 'a full curve fills the box')
|
||||
const young = columnsOf(base.slice(-30), 29, CAP, COLS).length
|
||||
check(young >= 8 && young <= 10, 'a tenth of a history takes a tenth of the box, from the right', young)
|
||||
const wide = columnsOf(base, CAP - 1, CAP, 390)
|
||||
check(wide.length === 390 && Math.max(...wide) === 900, 'a box wider than the history is filled, nothing invented', wide.length)
|
||||
check(columnsOf([], 0, CAP, COLS).length === 0 && columnsOf([5], 0, 2, 50).length >= 1, 'empty and single-point curves')
|
||||
|
||||
if (fails.length) { console.error('graphcols: FAIL\n ' + fails.join('\n ')); process.exit(1) }
|
||||
console.log('graphcols: ok')
|
||||
@@ -0,0 +1,132 @@
|
||||
// Alerts: rules over sensors, watched by this hub for itself and every machine it manages.
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, AlertChannel, AlertEvent, AlertRule, AlertsInfo } from './api'
|
||||
import { accentFor } from './accents'
|
||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||
|
||||
const OPS = ['>', '>=', '<', '<=', '==', '!=']
|
||||
const ago = (ms: number) => { const s = Math.max(0, (Date.now() - ms) / 1000); return s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : s < 129600 ? `${Math.round(s / 3600)} h` : `${Math.round(s / 86400)} d` }
|
||||
const patience = (s: number) => (s === 0 ? 'at once' : s < 120 ? `for ${s} s` : `for ${Math.round(s / 60)} min`)
|
||||
const show = (v: unknown) => (typeof v === 'number' ? String(Math.round(v * 10) / 10) : String(v))
|
||||
const parse = (raw: string): number | boolean | string => (raw === 'true' ? true : raw === 'false' ? false : raw.trim() !== '' && !isNaN(Number(raw)) ? Number(raw) : raw)
|
||||
|
||||
// A rule names the machines it watches by the name the hub knows them by; on screen each is
|
||||
// titled by its own hostname, like everywhere else.
|
||||
function RuleDialog({ rule, machines, title, onSave, onClose }: { rule: AlertRule; machines: string[]; title: (n: string) => string; onSave: (r: AlertRule) => void; onClose: () => void }) {
|
||||
const [r, setR] = useState({ ...rule, value: String(rule.value) })
|
||||
const [hits, setHits] = useState<string>('')
|
||||
useEscape(onClose)
|
||||
useEffect(() => { // what the selector matches right now, across the fleet
|
||||
if (!r.sensor.trim()) { setHits(''); return }
|
||||
const t = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/fleet/sensors/query', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ machines: '*', query: { x: r.sensor.trim() } }) })
|
||||
if (!res.ok) { setHits((await res.json().catch(() => ({}))).error ?? 'not a valid pattern'); return }
|
||||
const by = await res.json() as Record<string, { x?: unknown; error?: string }>
|
||||
const n = Object.entries(by).map(([m, v]) => [m, v.error ? 0 : v.x && typeof v.x === 'object' ? Object.keys(v.x).length : v.x === null || v.x === undefined ? 0 : 1] as const)
|
||||
setHits(r.sensor.trim() === 'hamlab.reachable' ? 'every machine: whether it answers the hub' : n.every(([, c]) => c === 0) ? 'matches nothing right now' : 'matches ' + n.filter(([, c]) => c).map(([m, c]) => `${c} on ${m}`).join(', '))
|
||||
} catch { setHits('') }
|
||||
}, 350)
|
||||
return () => clearTimeout(t)
|
||||
}, [r.sensor])
|
||||
const all = !r.machines || r.machines === '*'
|
||||
const picked = all ? [] : r.machines.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal picker" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onSave({ ...r, name: r.name.trim(), sensor: r.sensor.trim(), value: parse(r.value) }) }}>
|
||||
<h3>{rule.name ? 'Edit rule' : 'New rule'}</h3>
|
||||
<label className="muted">Name<input autoFocus value={r.name} maxLength={64} onChange={(e) => setR({ ...r, name: e.target.value })} placeholder="Tank nearly full" /></label>
|
||||
<label className="muted">Sensor — an ID or a pattern; the Widgets tab lists them all
|
||||
<input className="mono" value={r.sensor} onChange={(e) => setR({ ...r, sensor: e.target.value })} placeholder="zfs.tank.used_pct" spellCheck={false} autoCapitalize="off" />
|
||||
</label>
|
||||
{hits && <div className="muted small">{hits}</div>}
|
||||
<div className="rule-cond">
|
||||
<select value={r.op} onChange={(e) => setR({ ...r, op: e.target.value })}>{OPS.map((o) => <option key={o}>{o}</option>)}</select>
|
||||
<input className="mono" value={r.value} onChange={(e) => setR({ ...r, value: e.target.value })} placeholder="90 · true · unhealthy" spellCheck={false} autoCapitalize="off" />
|
||||
<span className="muted">for</span>
|
||||
<input className="mono" type="number" min={0} max={86400} value={r.for_sec} onChange={(e) => setR({ ...r, for_sec: Math.max(0, Number(e.target.value) || 0) })} style={{ width: 90 }} /><span className="muted">s</span>
|
||||
</div>
|
||||
<div className="chips">{(['info', 'warn', 'crit'] as const).map((s) => <button type="button" key={s} className={'chip' + (r.severity === s ? ' on' : '')} onClick={() => setR({ ...r, severity: s })}>{s === 'crit' ? 'critical' : s === 'warn' ? 'warning' : 'info'}</button>)}</div>
|
||||
<div className="chips">
|
||||
<button type="button" className={'chip' + (all ? ' on' : '')} onClick={() => setR({ ...r, machines: '*' })}>every machine</button>
|
||||
{machines.map((m) => <button type="button" key={m} className={'chip' + (picked.includes(m) ? ' on' : '')} onClick={() => { const next = picked.includes(m) ? picked.filter((x) => x !== m) : [...picked, m]; setR({ ...r, machines: next.length ? next.join(',') : '*' }) }}>{title(m)}</button>)}
|
||||
</div>
|
||||
<div className="svc-actions"><button type="submit" className="btn on" disabled={!r.name.trim() || !r.sensor.trim()}>Save</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Alerts({ machines, titleOf: title }: { machines: string[]; titleOf: (n: string) => string }) {
|
||||
const [a, setA] = useState<AlertsInfo | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [editing, setEditing] = useState<AlertRule | null>(null)
|
||||
const [chan, setChan] = useState<AlertChannel>({ type: 'ntfy', url: '' })
|
||||
const load = useCallback(async () => { try { const r = await api.alerts(); setA({ ...r, rules: r.rules ?? [], channels: r.channels ?? [], events: r.events ?? [], firing: r.firing ?? [] }); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [])
|
||||
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 5000); return () => clearInterval(t) }, [load])
|
||||
if (!a) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Loading alerts…" />}</div>
|
||||
|
||||
const save = async (patch: { rules?: AlertRule[]; channels?: AlertChannel[] }, done: string) => {
|
||||
try { await api.saveAlerts(patch); toast(done); window.dispatchEvent(new Event('hamlab:alerts')); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const saveRule = (r: AlertRule) => { const exists = a.rules.some((x) => x.id === r.id); save({ rules: exists ? a.rules.map((x) => (x.id === r.id ? r : x)) : [...a.rules, r] }, exists ? 'rule saved' : 'rule added'); setEditing(null) }
|
||||
const sevClass = (s: string) => (s === 'crit' ? 'bad' : s === 'warn' ? 'warn' : '')
|
||||
|
||||
return (
|
||||
<div className="dash alerts">
|
||||
<div className="dash-bar"><h2 className="section">Now</h2><span className="spacer" /><span className="muted small">checked {a.checked > 0 ? `${ago(a.checked)} ago` : 'not yet'} · every {a.every_sec} s · all machines</span></div>
|
||||
{a.firing.length === 0 ? <section className="card all-clear"><span className="good">✓ All clear</span><span className="muted small"> — {a.rules.filter((r) => r.enabled).length} rules watching {machines.length + 1} machine{machines.length ? 's' : ''}</span></section> : (
|
||||
<div className="cards">
|
||||
{a.firing.map((f) => (
|
||||
<section className={'card firing ' + sevClass(f.severity)} key={f.rule_id + f.machine + f.sensor}>
|
||||
<div className="sub-head"><span className={'sdot ' + (f.severity === 'crit' ? 'bad' : 'warn')} /><span className="dname">{f.rule}</span><span className="spacer" /><span className="badge" style={{ color: accentFor(f.machine), borderColor: accentFor(f.machine) }}>{title(f.machine)}</span></div>
|
||||
<div className="mono small">{f.sensor} = {show(f.value)}</div>
|
||||
<div className="muted small">for {ago(f.since)}</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Rules</h2><span className="spacer" />
|
||||
<button className="btn" onClick={() => setEditing({ id: 'r' + Date.now().toString(36), name: '', sensor: '', op: '>=', value: 90, for_sec: 300, severity: 'warn', enabled: true, machines: '*' })}>+ Rule</button>
|
||||
</div>
|
||||
<section className="card rules">
|
||||
{a.rules.length === 0 && <div className="empty">No rules. Nothing is being watched.</div>}
|
||||
{a.rules.map((r) => (
|
||||
<div className={'rule' + (r.enabled ? '' : ' off')} key={r.id}>
|
||||
<button className={'toggle' + (r.enabled ? ' on' : '')} title={r.enabled ? 'watching — tap to pause' : 'paused — tap to watch'} onClick={() => save({ rules: a.rules.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)) }, r.enabled ? `${r.name}: paused` : `${r.name}: watching`)}><i /></button>
|
||||
<div className="rule-body" onClick={() => setEditing(r)}>
|
||||
<div className="rule-name"><span className={'sdot ' + (r.severity === 'crit' ? 'bad' : r.severity === 'warn' ? 'warn' : 'ok')} />{r.name}{a.firing.some((f) => f.rule_id === r.id) && <span className="badge bad">firing</span>}</div>
|
||||
<div className="mono small muted">{r.sensor} {r.op} {show(r.value)} · {patience(r.for_sec)}{r.machines && r.machines !== '*' ? ` · ${r.machines}` : ''}</div>
|
||||
</div>
|
||||
<button className="icon-btn" title="delete" onClick={async () => { if (await askYes(`Delete the rule "${r.name}"?`)) save({ rules: a.rules.filter((x) => x.id !== r.id) }, 'rule deleted') }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Where alerts go</h2><span className="spacer" />{a.channels.length > 0 && <Btn className="btn" onClick={async () => { try { await api.testAlerts(); toast('test sent') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Send a test</Btn>}</div>
|
||||
<section className="card">
|
||||
<div className="muted small" style={{ marginBottom: 8 }}>Always here, and on the rail. To reach your phone: an <b>ntfy</b> topic URL (the ntfy app, from ntfy.sh or your own server), or a <b>webhook</b> — Home Assistant, Slack, anything that takes a POST. Sent when an alert fires and again when it clears.</div>
|
||||
{a.channels.map((c, i) => <div className="row" key={i}><span className="badge" style={{ marginLeft: 0 }}>{c.type}</span><span className="k mono small">{c.url}</span><Btn className="btn danger" confirm={`Remove this ${c.type} channel?`} onClick={() => save({ channels: a.channels.filter((_, j) => j !== i) }, 'channel removed')}>Remove</Btn></div>)}
|
||||
<form className="chan-add" onSubmit={(e) => { e.preventDefault(); if (!/^https?:\/\/./.test(chan.url.trim())) { toast('a channel needs an http(s) URL', 'err'); return } save({ channels: [...a.channels, { type: chan.type, url: chan.url.trim() }] }, 'channel added'); setChan({ ...chan, url: '' }) }}>
|
||||
<select value={chan.type} onChange={(e) => setChan({ ...chan, type: e.target.value as AlertChannel['type'] })}><option value="ntfy">ntfy</option><option value="webhook">webhook</option></select>
|
||||
<input className="mono" value={chan.url} onChange={(e) => setChan({ ...chan, url: e.target.value })} placeholder={chan.type === 'ntfy' ? 'https://ntfy.sh/your-secret-topic' : 'https://…/api/webhook/…'} spellCheck={false} autoCapitalize="off" />
|
||||
<button className="btn on" disabled={!chan.url.trim()}>Add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">History</h2><span className="spacer" /><span className="muted small">the last {a.events.length}</span></div>
|
||||
<section className="card">
|
||||
{a.events.length === 0 && <div className="empty">Nothing has fired yet.</div>}
|
||||
{a.events.map((e: AlertEvent, i) => (
|
||||
<div className="ev" key={i}>
|
||||
<span className={e.kind === 'firing' ? (e.severity === 'crit' ? 'warn-text bad-text' : 'warn-text') : 'good'}>{e.kind === 'firing' ? '▲' : '✓'}</span>
|
||||
<span className="ev-main">{e.rule} <span className="muted">· {title(e.machine)} · </span><span className="mono small muted">{e.sensor} = {show(e.value)}</span></span>
|
||||
<span className="muted small num" title={new Date(e.t).toLocaleString()}>{ago(e.t)} ago</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
{editing && <RuleDialog rule={editing} machines={machines} title={title} onSave={saveRule} onClose={() => setEditing(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,37 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, Me, Unauthenticated } from './api'
|
||||
import Login from './Login'
|
||||
import Shell from './Shell'
|
||||
import { Dialogs } from './ui'
|
||||
import { applyTheme, currentThemeId } from './themes'
|
||||
import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs'
|
||||
import { setAccents } from './accents'
|
||||
import { setRailOrder } from './railorder'
|
||||
|
||||
export default function App() {
|
||||
const [me, setMe] = useState<Me | null | undefined>(undefined)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try { setMe(await api.me()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
|
||||
try {
|
||||
const m = await api.meHere(); setMe(m)
|
||||
api.uiPrefs().then((p) => {
|
||||
if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme)
|
||||
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
|
||||
if (p.accents && typeof p.accents === 'object') setAccents(p.accents, false)
|
||||
if (Array.isArray(p.rail)) setRailOrder(p.rail, false)
|
||||
}).catch(() => { /* keep local */ })
|
||||
// The graph window is the machines' own setting; the copy here only labels the curves
|
||||
// and spaces the live points, so take the hub's word for it at every login.
|
||||
api.historyWindow().then((h) => { if (h.seconds && h.seconds !== getHistorySec()) setHistorySec(h.seconds) }).catch(() => { /* keep local */ })
|
||||
} catch (e) { if (e instanceof Unauthenticated) setMe(null) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
// Any page that gets a 401 from the hub sends us straight back to the login form.
|
||||
useEffect(() => {
|
||||
const h = () => setMe((m) => (m ? null : m))
|
||||
window.addEventListener('hamlab:unauthenticated', h)
|
||||
return () => window.removeEventListener('hamlab:unauthenticated', h)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!me) return
|
||||
const t = setInterval(refresh, 30000)
|
||||
@@ -19,5 +41,5 @@ export default function App() {
|
||||
|
||||
if (me === undefined) return <div className="center muted">…</div>
|
||||
if (me === null) return <Login onLogin={refresh} />
|
||||
return <Shell me={me} onLoggedOut={() => setMe(null)} />
|
||||
return <><Dialogs /><Shell me={me} onLoggedOut={() => setMe(null)} /></>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { api, SmartInfo, StorageDevice } from './api'
|
||||
import { fmtBytes, fmtRate } from './fmt'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import { askText, Btn, Loading, Meter, Spinner, toast } from './ui'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { Sensor, useSensorStream } from './sensors'
|
||||
|
||||
const pct = (v: number) => `${Math.round(v)}%`
|
||||
const KIND: Record<string, string> = { nvme: 'NVMe', ssd: 'SSD', hdd: 'HDD', usb: 'USB', emmc: 'eMMC', sd: 'SD', other: 'disk' }
|
||||
const hours = (h: number) => (h >= 24 * 365 ? `${(h / 8760).toFixed(1)} y` : h >= 48 ? `${Math.round(h / 24)} d` : `${h} h`)
|
||||
const ago = (ms: number) => { const s = (Date.now() - ms) / 1000; return s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
|
||||
|
||||
|
||||
// ---- SMART panel ----
|
||||
function SmartPanel({ dev, rotational }: { dev: string; rotational: boolean }) {
|
||||
const [s, setS] = useState<SmartInfo | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const load = useCallback(async (force = false) => {
|
||||
setS(null)
|
||||
try { setS(await api.smart(dev, force)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [dev])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (err) return <div className="error">{err}</div>
|
||||
if (!s) return <Loading label="Reading health…" />
|
||||
if (!s.available) return <div className="notice">smartmontools isn't installed, so there's no SMART data. Install it from the button above.</div>
|
||||
if (s.standby) return <div className="notice">Disk is spun down; SMART wasn't read so it stays asleep. <Btn className="btn" onClick={() => load(true)}>Wake and read</Btn></div>
|
||||
if (s.error) return <div className="notice">{s.error}</div>
|
||||
const ok = s.passed === true && s.warnings.length === 0
|
||||
return (
|
||||
<div className="smart">
|
||||
<div className="props">
|
||||
<div><span className="k">Health</span><span className={ok ? 'good' : 'warn-text'}>{s.passed === null ? 'unknown' : s.passed ? 'PASSED' : 'FAILED'}{s.warnings.length > 0 && ` · ${s.warnings.length} warning${s.warnings.length > 1 ? 's' : ''}`}</span></div>
|
||||
{s.temp > 0 && <div><span className="k">Temperature</span><span>{s.temp}°C</span></div>}
|
||||
{s.power_on_hours > 0 && <div><span className="k">Powered on</span><span>{hours(s.power_on_hours)} · {s.power_cycles} cycles</span></div>}
|
||||
{s.firmware && <div><span className="k">Firmware</span><span className="mono">{s.firmware}</span></div>}
|
||||
{s.serial && <div><span className="k">Serial</span><span className="mono">{s.serial}</span></div>}
|
||||
{s.self_test && <div><span className="k">Self-test</span><span>{s.self_test}{s.self_test_remaining ? ` (${s.self_test_remaining}% left)` : ''}</span></div>}
|
||||
<div><span className="k">Read</span><span className="muted">{ago(s.fetched_at)} <button className="link" onClick={() => load(true)}>refresh</button></span></div>
|
||||
</div>
|
||||
{s.warnings.length > 0 && <div className="notice attn">{s.warnings.join(' · ')}</div>}
|
||||
{s.mmc && (
|
||||
<div className="props">
|
||||
<div><span className="k">Wear</span><span>{[['A', s.mmc.life_a], ['B', s.mmc.life_b]].filter(([, v]) => v).map(([k, v]) => `type ${k} ${v === 11 ? 'past rated life' : `${(v as number) * 10 - 10}–${(v as number) * 10}% used`}`).join(' · ') || 'not reported'}</span></div>
|
||||
<div><span className="k">Reserve</span><span>{['not reported', 'normal', 'warning — 80% of spare blocks used', 'urgent — 90% of spare blocks used'][s.mmc.pre_eol] ?? s.mmc.pre_eol}</span></div>
|
||||
</div>
|
||||
)}
|
||||
{s.nvme && (
|
||||
<div className="props">
|
||||
<div><span className="k">Endurance</span><span>{s.nvme.percentage_used}% used · spare {s.nvme.available_spare}% (threshold {s.nvme.spare_threshold}%)</span></div>
|
||||
<div><span className="k">Written</span><span>{fmtBytes(s.nvme.data_written_bytes)} · read {fmtBytes(s.nvme.data_read_bytes)}</span></div>
|
||||
<div><span className="k">Errors</span><span>{s.nvme.media_errors} media · {s.nvme.error_log_entries} logged · {s.nvme.unsafe_shutdowns} unsafe shutdowns</span></div>
|
||||
</div>
|
||||
)}
|
||||
{s.attrs.length > 0 && (
|
||||
<div className="attrs-wrap">
|
||||
<table className="attrs">
|
||||
<thead><tr><th>ID</th><th>Attribute</th><th>Raw</th><th>Value</th><th>Worst</th><th>Thresh</th></tr></thead>
|
||||
<tbody>
|
||||
{s.attrs.map((a) => (
|
||||
<tr key={a.id} className={a.warn ? 'warn' : ''}>
|
||||
<td className="num">{a.id}</td><td>{a.name.replace(/_/g, ' ')}{a.failed && <span className="badge bad">{a.failed}</span>}</td>
|
||||
<td className="num raw">{a.raw}</td><td className="num">{a.value}</td><td className="num">{a.worst}</td><td className="num">{a.thresh}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!s.self_test_supported && <div className="muted small">Self-tests: {s.self_test_note || 'not available on this drive'}.</div>}
|
||||
{s.self_test_supported && <div className="svc-actions">
|
||||
<Btn className="btn" confirm={`Start a short self-test on ${dev}?`} onClick={async () => { if (await api.smartTest(dev, 'short').then(() => true, (e) => { toast(String(e.message ?? e), 'err'); return false })) { toast('short test started'); load(true) } }}>Short test</Btn>
|
||||
<Btn className="btn" confirm={`Start a long self-test on ${dev}? ${rotational ? 'This can take hours on a spinning disk.' : ''}`} onClick={async () => { if (await api.smartTest(dev, 'long').then(() => true, (e) => { toast(String(e.message ?? e), 'err'); return false })) { toast('long test started'); load(true) } }}>Long test</Btn>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- device card ----
|
||||
function DeviceCard({ d, live, forceOpen }: { d: StorageDevice; live?: { read_bps: number; write_bps: number; util_pct: number }; forceOpen: boolean | null }) {
|
||||
const [own, setOwn] = useState(false)
|
||||
const open = forceOpen ?? own
|
||||
const setOpen = (v: boolean) => setOwn(v)
|
||||
return (
|
||||
<section className="card disk">
|
||||
<div className="sub-head">
|
||||
<span className="badge">{KIND[d.kind] ?? d.kind}</span>
|
||||
<span className="dname">{d.name}</span>
|
||||
<span className="model">{[d.vendor, d.model].filter(Boolean).join(' ')}</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{fmtBytes(d.size)}{d.temp ? ` · ${d.temp.toFixed(0)}°C` : ''}</span>
|
||||
</div>
|
||||
{live && <div className="sub-head num muted">read {fmtRate(live.read_bps)} · write {fmtRate(live.write_bps)} · {pct(live.util_pct)} busy</div>}
|
||||
<div className="muted small disk-summary">
|
||||
{d.serial && <span>S/N {d.serial}</span>}
|
||||
{d.namespaces && d.namespaces.length > 1 && <span>{d.namespaces.length} namespaces</span>}
|
||||
<span>{d.parts.length} partition{d.parts.length === 1 ? '' : 's'}</span>
|
||||
{d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').length > 0 && <span>mounted at {d.parts.filter((p) => p.mount && p.mount !== '[SWAP]').map((p) => p.mount).join(', ')}</span>}
|
||||
{d.parts.some((p) => p.mount === '[SWAP]') && <span>swap</span>}
|
||||
</div>
|
||||
{forceOpen === null && <div className="svc-actions"><button className="btn" onClick={() => setOpen(!open)}>{open ? 'Hide SMART' : 'SMART'}</button></div>}
|
||||
{open && <SmartPanel dev={d.name} rotational={d.rotational} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
export default function Disks() {
|
||||
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
|
||||
const [hasSmart, setHasSmart] = useState(true)
|
||||
const [err, setErr] = useState('')
|
||||
const [task, setTask] = useState<string | null>(null)
|
||||
const [allSmart, setAllSmart] = useState<boolean>(() => localStorage.getItem('hamlab.disks.smart') === '1')
|
||||
const rate = useRefreshMs()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.storage()
|
||||
setDevs(r.devices); setHasSmart(r.smartctl); setErr('')
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
// Live per-device rates come from the sensor stream, grouped by the kernel name in each sensor's labels.
|
||||
const stream = useSensorStream<{ d: Record<string, Sensor> }>({ d: 'disk.*' }, { everyMs: rate })
|
||||
const live = useMemo(() => {
|
||||
const by: Record<string, { name: string; read_bps: number; write_bps: number; util_pct: number }> = {}
|
||||
for (const x of Object.values(stream.data?.d ?? {})) {
|
||||
const name = x.labels?.device
|
||||
if (!name || typeof x.value !== 'number') continue
|
||||
const row = (by[name] ??= { name, read_bps: 0, write_bps: 0, util_pct: 0 })
|
||||
if (x.id.endsWith('.read')) row.read_bps = x.value
|
||||
else if (x.id.endsWith('.write')) row.write_bps = x.value
|
||||
else if (x.id.endsWith('.util')) row.util_pct = x.value
|
||||
}
|
||||
return { disks: Object.values(by) }
|
||||
}, [stream.data])
|
||||
|
||||
if (!devs) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Scanning disks…" />}</div>
|
||||
const liveFor = (dev: StorageDevice) => {
|
||||
const names = dev.namespaces?.length ? dev.namespaces : [dev.name]
|
||||
const rows = (live?.disks ?? []).filter((x) => names.includes(x.name))
|
||||
if (rows.length === 0) return undefined
|
||||
return { read_bps: rows.reduce((a, x) => a + x.read_bps, 0), write_bps: rows.reduce((a, x) => a + x.write_bps, 0), util_pct: Math.max(...rows.map((x) => x.util_pct)) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dash disks">
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="dash-bar">
|
||||
<h2 className="section">Devices · {devs.length}</h2>
|
||||
<span className="spacer" />
|
||||
{hasSmart && <button className={'chip' + (allSmart ? ' on' : '')} title="show SMART for every drive" onClick={() => { const v = !allSmart; setAllSmart(v); localStorage.setItem('hamlab.disks.smart', v ? '1' : '0') }}>{allSmart ? 'SMART: all open' : 'SMART: show all'}</button>}
|
||||
{!hasSmart && <Btn className="btn" onClick={async () => { try { const t = await api.installPackages(['smartmontools']); setTask(t.id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>Install smartmontools</Btn>}
|
||||
<Btn className="btn" title="reload" onClick={load}>↻</Btn>
|
||||
</div>
|
||||
{!hasSmart && <div className="notice">SMART health needs smartmontools on this host. Install it with the button, then reload.</div>}
|
||||
{task && <TaskOutput id={task} onDone={(t) => { toast(`${t.title}: ${t.exit_code === 0 ? 'finished' : `exited ${t.exit_code}`}`, t.exit_code === 0 ? 'ok' : 'err'); load() }} />}
|
||||
<div className="cards disk-cards">
|
||||
{devs.map((d) => <DeviceCard key={d.name} d={d} live={liveFor(d)} forceOpen={allSmart ? true : null} />)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// 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
|
||||
// come from the same container sensors everything else uses.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import StackNew, { DupFrom } from './StackNew'
|
||||
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } from './ui'
|
||||
|
||||
type Tab = 'containers' | 'stacks' | '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 ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
|
||||
|
||||
function LogView({ id }: { id: string }) {
|
||||
const [lines, setLines] = useState<{ t: string; m: string }[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [follow, setFollow] = useState(true)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const last = useRef('')
|
||||
useEffect(() => {
|
||||
let stop = false; setLines([]); last.current = ''
|
||||
const tick = async () => {
|
||||
if (stop) return
|
||||
if (document.visibilityState === 'visible') {
|
||||
try {
|
||||
const r = await api.dockerLogs(id, last.current)
|
||||
const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines
|
||||
if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) }
|
||||
setErr('')
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
window.setTimeout(tick, 2000)
|
||||
}
|
||||
tick()
|
||||
return () => { stop = true }
|
||||
}, [id])
|
||||
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow])
|
||||
return (
|
||||
<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>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<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.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Record<string, Sensor>; onBack: () => void; onChanged: () => void }) {
|
||||
const [tab, setTab] = useState<'logs' | 'inspect'>('logs')
|
||||
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 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 s = (k: string) => live[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
||||
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<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="props">
|
||||
<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>
|
||||
{c.stack && <div><span className="k">Stack</span><span>{c.stack}{c.service && c.service !== c.name ? ` · ${c.service}` : ''}</span></div>}
|
||||
{running && <div><span className="k">Using</span><span>{fmtValue(s('cpu')?.value, '%')} of a core · {fmtValue(s('mem')?.value ?? c.mem, 'B')}</span></div>}
|
||||
{ports(c.ports) && <div><span className="k">Ports</span><span className="mono small">{ports(c.ports)}</span></div>}
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
{!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" confirm={`Restart ${c.name}?`} onClick={act('restart')}>Restart</Btn>}
|
||||
{running && <Btn className="btn danger" confirm={`Stop ${c.name}? It stays stopped until something starts it.`} onClick={act('stop')}>Stop</Btn>}
|
||||
</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>
|
||||
{tab === 'logs' && <LogView id={c.name} />}
|
||||
{tab === 'inspect' && (!info ? <Loading label="Inspecting…" /> : (
|
||||
<div className="props">
|
||||
<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>
|
||||
{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.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>)}
|
||||
<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>
|
||||
))}
|
||||
</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 }[] {
|
||||
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))
|
||||
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1])
|
||||
const out: { s: ' ' | '+' | '-'; t: string }[] = []; let i = 0, j = 0
|
||||
while (i < n && j < m) { if (x[i] === y[j]) { out.push({ s: ' ', t: x[i] }); i++; j++ } else if (L[i + 1][j] >= L[i][j + 1]) out.push({ s: '-', t: x[i++] }); else out.push({ s: '+', t: y[j++] }) }
|
||||
while (i < n) out.push({ s: '-', t: x[i++] }); while (j < m) out.push({ s: '+', t: y[j++] })
|
||||
return out
|
||||
}
|
||||
|
||||
// What a Deploy would change: each file against what this console last deployed (or, before
|
||||
// the first deploy from here, the copy the last save replaced). Only changed lines and a
|
||||
// little around them; a .env shows which keys changed, never their values.
|
||||
function DiffView({ files }: { files: StackDiff[] }) {
|
||||
const changed = files.filter((f) => f.base && f.old !== f.new)
|
||||
if (files.every((f) => !f.base)) return <div className="muted small">Nothing to compare with yet: this console hasn't deployed or saved this stack before.</div>
|
||||
if (changed.length === 0) return <div className="muted small">No changes since {files.some((f) => f.base === 'deployed') ? 'the last deploy from here' : 'the last save'}: Deploy would only recreate what Docker itself thinks has changed.</div>
|
||||
return <>{changed.map((f) => { const d = lineDiff(f.old, f.new); const keep = new Set<number>(); d.forEach((l, i) => { if (l.s !== ' ') for (let k = i - 2; k <= i + 2; k++) keep.add(k) })
|
||||
return <div key={f.name}><div className="muted small">{f.name} · against {f.base === 'deployed' ? 'what was last deployed from here' : 'the copy the last save replaced'}</div>
|
||||
<pre className="diff">{d.map((l, i) => (keep.has(i) ? <div key={i} className={l.s === '+' ? 'add' : l.s === '-' ? 'del' : ''}>{l.s} {l.t}</div> : keep.has(i - 1) ? <div key={i} className="muted"> ⋯</div> : null))}</pre></div> })}</>
|
||||
}
|
||||
|
||||
// A .env is mostly secrets: keys shown, values hidden until asked for.
|
||||
const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.indexOf('='); return i > 0 && !l.trim().startsWith('#') && l.length > i + 1 ? l.slice(0, i + 1) + '••••••••' : l }).join('\n')
|
||||
|
||||
// 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.
|
||||
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 [text, setText] = useState(''); const [orig, setOrig] = useState('')
|
||||
const [err, setErr] = useState(''); const [task, setTask] = useState('')
|
||||
const [file, setFile] = useState<number | 'env'>(0); const [sum, setSum] = useState('')
|
||||
const [reveal, setReveal] = useState(false); const [diff, setDiff] = useState<StackDiff[] | null>(null)
|
||||
const [upd, setUpd] = useState<{ images: ImageCheck[]; checked: number } | null>(null); const [checking, setChecking] = useState(false)
|
||||
useEffect(() => { setUpd(null); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }, [name])
|
||||
useEffect(() => { setReveal(false) }, [file, name])
|
||||
const load = useCallback(async () => { try { const r = await api.stackFile(name, file); setSt(r.stack); setText(r.text); setOrig(r.text); setSum(r.sum); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name, file])
|
||||
useEffect(() => { setSt(null); setTask(startTask ?? ''); load() }, [load]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const dirty = text !== orig
|
||||
useEffect(() => { setUnsaved(dirty ? `${name}'s compose file` : ''); return () => setUnsaved('') }, [dirty, name])
|
||||
const save = async () => { try { await api.saveStack(name, file, text, sum); setOrig(text); setErr(''); setDiff(null); api.stackFile(name, file).then((r) => setSum(r.sum)).catch(() => {}); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
|
||||
const remove = async () => {
|
||||
const typed = await askText(`Delete the stack "${name}"?\n\nIts containers are removed and its compose file${st?.has_env ? ' and .env' : ''} moved to .hamlab-trash beside it — moved, not erased. Volumes and bind-mounted data are not touched.\n\nType its name to confirm.`, '', 'Delete')
|
||||
if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return }
|
||||
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 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') } }
|
||||
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>
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<CloseBtn onClick={onBack} />
|
||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
|
||||
{file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</button></div> :
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||||
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
|
||||
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}
|
||||
<span className="spacer" />
|
||||
<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" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</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 className="svc-actions">
|
||||
<span className="muted small">{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''}</span><span className="spacer" />
|
||||
<button className="btn" disabled={dirty} title={dirty ? 'save first' : 'a new stack that starts as a copy of this one'} onClick={duplicate}>Duplicate…</button>
|
||||
{st.containers.length > 0 && <Btn className="btn danger" title="docker compose down" confirm={`Take ${st.name} down? Its ${st.containers.length} container${st.containers.length === 1 ? ' is' : 's are'} removed (not just stopped). The compose file, volumes and bind-mounted data stay, and Deploy brings it back.`} onClick={run('down')}>Down</Btn>}
|
||||
<button className="btn danger" onClick={remove}>Delete…</button>
|
||||
</div>
|
||||
{task && <TaskOutput id={task} onDone={() => { onChanged(); load(); setDiff(null); api.stackUpdates(name).then(setUpd).catch(() => {}) }} />}
|
||||
<div className="stack-extra">
|
||||
<div className="sub-head"><span className="k">What Deploy would change</span><span className="spacer" /><button className="link" onClick={async () => { try { setDiff((await api.stackDiff(name)).files) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }}>{diff ? 'refresh' : 'show'}</button></div>
|
||||
{diff && <DiffView files={diff} />}
|
||||
<div className="sub-head"><span className="k">Images</span><span className="spacer" /><span className="muted small">{upd?.checked ? `checked ${new Date(upd.checked).toLocaleString()}` : 'not checked'}</span> <Btn className="link" busy={checking} onClick={async () => { setChecking(true); try { setUpd(await api.stackUpdates(name, true)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setChecking(false) } }}>check for updates</Btn></div>
|
||||
{(st.images ?? []).map((img) => { const c = upd?.images.find((x) => x.image === img); return <div className="img-row" key={img}><span className="mono small">{img}</span><span className="spacer" />{c ? <span className={'badge' + (c.state === 'update' ? ' attn' : '')} title={c.detail}>{c.state === 'update' ? 'update available' : c.state === 'current' ? 'up to date' : 'unknown'}</span> : <span className="muted small">—</span>}</div> })}
|
||||
</div>
|
||||
<div className="muted small">{st.containers.join(' · ')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Docker() {
|
||||
const [d, setD] = useState<DockerInfo | null>(null)
|
||||
const [stacks, setStacks] = useState<Stack[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [tab, setTab] = useState<Tab>('containers')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sel, setSel] = useHashSel('docker')
|
||||
const [making, setMaking] = useState<'' | 'stack' | 'container'>('')
|
||||
const [dup, setDup] = useState<DupFrom | null>(null)
|
||||
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
|
||||
const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('')
|
||||
const rate = useRefreshMs()
|
||||
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, [])
|
||||
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
|
||||
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
|
||||
const sensors = live.data?.c ?? {}
|
||||
const byStack = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase(); const out = new Map<string, DockerContainer[]>()
|
||||
for (const c of d?.containers ?? []) { if (f && !`${c.name} ${c.image} ${c.stack}`.toLowerCase().includes(f)) continue; const k = c.stack || 'started by hand'; out.set(k, [...(out.get(k) ?? []), c]) }
|
||||
return out
|
||||
}, [d, filter])
|
||||
// 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 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])
|
||||
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>
|
||||
const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
|
||||
const cur = selStack ? undefined : d.containers.find((c) => c.name === sel)
|
||||
const shownTab: Tab = selStack ? 'stacks' : tab
|
||||
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 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']] }
|
||||
return (
|
||||
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
|
||||
<div className="svc-list">
|
||||
<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>
|
||||
{(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') } }}>
|
||||
<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" />
|
||||
<button className="btn on" disabled={!pull.trim()}>Pull</button>
|
||||
{(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</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>}
|
||||
{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 === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
||||
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
||||
</div>
|
||||
<div className="svc-rows">
|
||||
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
|
||||
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
|
||||
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
|
||||
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
|
||||
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
|
||||
</div>
|
||||
))}
|
||||
{shownTab === 'stacks' && stacks?.length === 0 && <div className="muted small" style={{ padding: 12 }}>No compose stacks here.</div>}
|
||||
{shownTab === 'containers' && [...byStack].map(([stack, list]) => (
|
||||
<div key={stack}>
|
||||
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
|
||||
{list.map((c) => (
|
||||
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
|
||||
<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-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{shownTab === 'containers' && byStack.size === 0 && <div className="muted small" style={{ padding: 12 }}>No container matches.</div>}
|
||||
{shownTab === 'volumes' && (
|
||||
<table className="dtable"><thead><tr><th>Volume</th><th>Used by</th><th /></tr></thead>
|
||||
<tbody>{d.volumes.map((row) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], anon = !!row.anonymous
|
||||
const remove = async () => {
|
||||
if (anon) { if (!(await askYes(`Delete this anonymous volume and everything in it?\n\n${name}\n\nNothing uses it. It can't be brought back.`, 'Delete'))) return }
|
||||
else { const typed = await askText(`Delete the volume "${name}" and everything in it?\n\nNothing uses it right now — but if it belongs to a stack that is only down, this is that stack's data. It can't be brought back.\n\nType its name to confirm.`, '', 'Delete'); if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } }
|
||||
try { await api.removeVolume(name); toast('volume deleted'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') }
|
||||
}
|
||||
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>
|
||||
)}
|
||||
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && (
|
||||
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <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>
|
||||
)}
|
||||
</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>))}
|
||||
{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 === '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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// One widget, alone, for an iframe in someone else's dashboard (Glance, Home Assistant…):
|
||||
// /embed/widget?type=gauge&sensor=cpu.usage&token=<read-only token>
|
||||
// &machine=grid &title=… &theme=dark|light|cyberpunk|retro &every=<ms> &bare=1
|
||||
// It stays live over the sensor stream. The token must be a read-only sensor token; a
|
||||
// full-control token is refused in a URL by design.
|
||||
import { useSensorStream, Source } from './sensors'
|
||||
import { Widget, WidgetData, WidgetSpec, WidgetType, WIDGET_TYPES } from './widgets'
|
||||
import { applyTheme } from './themes'
|
||||
|
||||
const p = new URLSearchParams(location.search)
|
||||
if (p.get('theme')) applyTheme(p.get('theme')!)
|
||||
if (p.get('bare') === '1') document.body.style.background = 'transparent' // sit on the host dashboard's own card
|
||||
|
||||
export default function Embed() {
|
||||
const sensor = p.get('sensor') ?? ''
|
||||
const asked = p.get('type') as WidgetType | null
|
||||
const type: WidgetType = /[*?[]/.test(sensor) ? 'list' : asked && WIDGET_TYPES.some((t) => t.id === asked && !t.many) ? asked : 'stat'
|
||||
const spec: WidgetSpec = { id: 'w', type, sensor, title: p.get('title') ?? undefined }
|
||||
const src: Source = { machine: p.get('machine') || null, token: p.get('token') ?? undefined }
|
||||
const every = Number(p.get('every')) || undefined
|
||||
const live = useSensorStream<{ w: WidgetData }>(sensor ? { w: sensor } : null, { src, everyMs: every })
|
||||
return (
|
||||
<div className={'embed' + (p.get('bare') === '1' ? ' bare' : '')}>
|
||||
{!sensor ? <div className="error">embed needs ?sensor=</div> : (
|
||||
<div className={'tile w-' + type}>
|
||||
<Widget spec={spec} data={live.data ? live.data.w : undefined} src={src} />
|
||||
{live.error && !live.data && <div className="sub">{src.token ? live.error : 'no token in the URL (&token=…)'}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, FileEntry, FileInfo } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||
import { useHashSel } from './hashsel'
|
||||
|
||||
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
|
||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
||||
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
||||
const MAX_EDIT = 2 * 1024 * 1024
|
||||
|
||||
function icon(e: FileEntry): string {
|
||||
if (e.type === 'dir') return e.target ? '🔗' : '📁'
|
||||
if (e.type === 'link') return e.broken ? '⛓️💥' : '🔗'
|
||||
if (e.type === 'other') return '⚙️'
|
||||
if (isImage(e.name)) return '🖼️'
|
||||
if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️'
|
||||
if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜'
|
||||
if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
type Sort = 'name' | 'size' | 'mtime'
|
||||
|
||||
// ---- editor / viewer ----
|
||||
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
|
||||
const [info, setInfo] = useState<FileInfo | null>(null)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [orig, setOrig] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const name = path.split('/').pop() ?? path
|
||||
const dirty = text !== null && text !== orig
|
||||
|
||||
useEffect(() => {
|
||||
setInfo(null); setText(null); setErr('')
|
||||
let stop = false
|
||||
;(async () => {
|
||||
try {
|
||||
const i = await api.fileStat(path, sudo)
|
||||
if (stop) return
|
||||
setInfo(i)
|
||||
if (i.text && i.size <= MAX_EDIT && !isImage(name)) {
|
||||
const t = await api.fileRead(path, sudo)
|
||||
if (stop) return
|
||||
setText(t); setOrig(t)
|
||||
}
|
||||
} catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
|
||||
})()
|
||||
return () => { stop = true }
|
||||
}, [path, sudo, name])
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (text === null || !dirty) return
|
||||
setSaving(true)
|
||||
try { await api.fileWrite(path, text, sudo); setOrig(text); toast(`${name} saved`); onChanged() }
|
||||
catch (e) { toast(`save failed — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
finally { setSaving(false) }
|
||||
}, [text, dirty, path, sudo, name, onChanged])
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save() } }
|
||||
window.addEventListener('keydown', h)
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [save])
|
||||
|
||||
const close = async () => { if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return; setUnsaved(''); onClose() }
|
||||
// Unsaved edits are announced, so leaving the page, switching machine or closing the tab asks first.
|
||||
useEffect(() => {
|
||||
setUnsaved(dirty ? name : '')
|
||||
if (!dirty) return
|
||||
const h = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' }
|
||||
window.addEventListener('beforeunload', h)
|
||||
return () => { window.removeEventListener('beforeunload', h); setUnsaved('') }
|
||||
}, [dirty, name])
|
||||
const dl = api.fileDownloadURL(path, sudo)
|
||||
|
||||
return (
|
||||
<div className="viewer">
|
||||
<div className="viewer-bar">
|
||||
<button className="btn" onClick={close}>‹ Back</button>
|
||||
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
||||
<span className="spacer" />
|
||||
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
||||
<a className="btn" href={dl} download={name}>Download</a>
|
||||
{text !== null && <Btn className="btn on" disabled={!dirty || saving} onClick={save}>Save</Btn>}
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
{!info && !err && <Loading label="Opening…" />}
|
||||
{info && text !== null && (
|
||||
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
||||
)}
|
||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
||||
{info && text === null && !isImage(name) && !err && (
|
||||
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) {
|
||||
const [hashPath, setHashPath] = useHashSel('files') // the folder is in the URL; without one, the last folder that opened
|
||||
const [path, setPath] = useState<string>(() => (hashPath.startsWith('/') ? hashPath : sessionStorage.getItem('hamlab.files.path') || '/'))
|
||||
useEffect(() => { setHashPath(path) }, [path, setHashPath])
|
||||
useEffect(() => { if (hashPath.startsWith('/') && hashPath !== path) setPath(hashPath) }, [hashPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const [sudo, setSudo] = useState<boolean>(() => sessionStorage.getItem('hamlab.files.sudo') === '1')
|
||||
const [entries, setEntries] = useState<FileEntry[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [hidden, setHidden] = useState(false)
|
||||
const [sort, setSort] = useState<Sort>('name')
|
||||
const [q, setQ] = useState('')
|
||||
const [pathInput, setPathInput] = useState(path)
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [menu, setMenu] = useState<string | null>(null)
|
||||
const [upload, setUpload] = useState<{ name: string; pct: number; n: number; of: number } | null>(null)
|
||||
const [drag, setDrag] = useState(false)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const canSudo = useRef(true)
|
||||
|
||||
const load = useCallback(async (p = path, s = sudo) => {
|
||||
try {
|
||||
const l = await api.files(p, s)
|
||||
setEntries(l.entries); setErr('')
|
||||
canSudo.current = true
|
||||
sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed'
|
||||
setErr(msg); setEntries([])
|
||||
}
|
||||
}, [path, sudo])
|
||||
|
||||
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
|
||||
useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, [])
|
||||
useEscape(() => setMenu(null), menu !== null)
|
||||
|
||||
const go = (p: string) => { setOpen(null); setPath(p || '/') }
|
||||
const crumbs = path === '/' ? [] : path.slice(1).split('/')
|
||||
|
||||
const shown = (entries ?? []).filter((e) => (hidden || !e.name.startsWith('.')) && (!q || e.name.toLowerCase().includes(q.toLowerCase())))
|
||||
.sort((a, b) => {
|
||||
if ((a.type === 'dir') !== (b.type === 'dir')) return a.type === 'dir' ? -1 : 1
|
||||
if (sort === 'size') return b.size - a.size
|
||||
if (sort === 'mtime') return b.mtime - a.mtime
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
|
||||
})
|
||||
|
||||
// ---- actions ----
|
||||
const act = async (label: string, fn: () => Promise<unknown>) => {
|
||||
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
// A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.)
|
||||
const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true }
|
||||
const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
|
||||
const touch = async () => { const n = await askText('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
|
||||
const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
|
||||
const move = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
|
||||
const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
|
||||
const del = async (e: FileEntry) => {
|
||||
const full = join(path, e.name)
|
||||
if (e.type === 'dir' && !e.target) {
|
||||
const typed = await askText(`Delete the folder "${e.name}" and everything in it?\nType its name to confirm.`)
|
||||
if (typed !== e.name) return
|
||||
act(`deleted ${e.name}`, () => api.fileDelete(full, true, sudo))
|
||||
} else if (await askYes(`Delete ${e.name}?`)) act(`deleted ${e.name}`, () => api.fileDelete(full, false, sudo))
|
||||
}
|
||||
const copyPath = async (e: FileEntry) => { try { await navigator.clipboard.writeText(join(path, e.name)); toast('path copied') } catch { await askText('Path', join(path, e.name)) } }
|
||||
const openTerm = (p: string) => { onOpenInTerminal?.(`cd ${shellQuote(p)}`); }
|
||||
|
||||
// ---- upload ----
|
||||
const uploadFiles = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files)
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const f = list[i]
|
||||
let overwrite = false
|
||||
if ((entries ?? []).some((e) => e.name === f.name)) {
|
||||
if (!await askYes(`"${f.name}" already exists here. Replace it?`)) continue
|
||||
overwrite = true
|
||||
}
|
||||
setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
|
||||
try {
|
||||
await api.fileUpload(path, f, sudo, (pct) => setUpload({ name: f.name, pct, n: i + 1, of: list.length }), overwrite)
|
||||
} catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break }
|
||||
}
|
||||
setUpload(null)
|
||||
toast(list.length === 1 ? `uploaded ${list[0].name}` : `uploaded ${list.length} files`)
|
||||
load()
|
||||
}
|
||||
const onDrop = (e: DragEvent) => { e.preventDefault(); setDrag(false); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }
|
||||
|
||||
if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
||||
|
||||
return (
|
||||
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
|
||||
<div className="files-bar">
|
||||
<form className="crumbs" onSubmit={(e) => { e.preventDefault(); go(pathInput.trim()) }}>
|
||||
<button type="button" className="crumb" onClick={() => go('/')}>/</button>
|
||||
{crumbs.map((c, i) => <button type="button" key={i} className="crumb" onClick={() => go('/' + crumbs.slice(0, i + 1).join('/'))}>{c}</button>)}
|
||||
<input className="path-input" value={pathInput} onChange={(e) => setPathInput(e.target.value)} onBlur={() => setPathInput(path)} spellCheck={false} />
|
||||
</form>
|
||||
<div className="files-tools">
|
||||
<button className={'btn' + (sudo ? ' on' : '')} title="browse as root" onClick={() => setSudo(!sudo)}>{sudo ? 'root' : 'sudo'}</button>
|
||||
<button className={'btn' + (hidden ? ' on' : '')} title="show hidden files" onClick={() => setHidden(!hidden)}>.hidden</button>
|
||||
<select className="btn" value={sort} onChange={(e) => setSort(e.target.value as Sort)}><option value="name">name</option><option value="size">size</option><option value="mtime">modified</option></select>
|
||||
<input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn" onClick={mkdir}>+ folder</button>
|
||||
<button className="btn" onClick={touch}>+ file</button>
|
||||
<button className="btn" onClick={() => fileInput.current?.click()}>Upload</button>
|
||||
<input ref={fileInput} type="file" multiple style={{ display: 'none' }} onChange={(e) => { if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = '' }} />
|
||||
{onOpenInTerminal && <button className="btn" title="cd here in the active terminal" onClick={() => openTerm(path)}>>_ here</button>}
|
||||
<Btn className="btn" title="reload" onClick={() => load()}>↻</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{upload && <div className="upload-bar"><Spinner /> {upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%<div className="meter"><div className="fill" style={{ width: `${upload.pct}%` }} /></div></div>}
|
||||
{err && <div className="error" style={{ padding: '6px 12px' }}>{err}{/permission denied/i.test(err) && !sudo && <> · <button className="link" onClick={() => setSudo(true)}>retry as root</button></>}</div>}
|
||||
|
||||
<div className="file-list">
|
||||
{entries === null && <Loading label="Listing…" />}
|
||||
{path !== '/' && entries !== null && (
|
||||
<div className="file-row up" onClick={() => go(path.slice(0, path.lastIndexOf('/')) || '/')}><span className="ficon">↩</span><span className="fname">..</span></div>
|
||||
)}
|
||||
{shown.map((e) => {
|
||||
const full = join(path, e.name)
|
||||
const isDir = e.type === 'dir'
|
||||
return (
|
||||
<div key={e.name} className={'file-row' + (e.broken ? ' broken' : '')} onClick={() => (isDir ? go(full) : setOpen(full))}>
|
||||
<span className="ficon">{icon(e)}</span>
|
||||
<span className="fname" title={e.target ? `→ ${e.target}` : e.name}>{e.name}{e.target && <span className="muted"> → {e.target}</span>}</span>
|
||||
<span className="fmeta num">{isDir ? '' : fmtBytes(e.size)}</span>
|
||||
<span className="fmeta num">{fmtTime(e.mtime)}</span>
|
||||
<span className="fmeta num perm">{e.perm} {e.user}</span>
|
||||
<button className="icon-btn more" onClick={(ev) => { ev.stopPropagation(); setMenu(menu === e.name ? null : e.name) }}>⋯</button>
|
||||
{menu === e.name && (
|
||||
<div className="ctx" onClick={(ev) => ev.stopPropagation()}>
|
||||
{!isDir && <a href={api.fileDownloadURL(full, sudo)} download={e.name} onClick={() => setMenu(null)}>Download</a>}
|
||||
{isDir && onOpenInTerminal && <button onClick={() => { setMenu(null); openTerm(full) }}>Open in terminal</button>}
|
||||
<button onClick={() => { setMenu(null); rename(e) }}>Rename</button>
|
||||
<button onClick={() => { setMenu(null); move(e) }}>Move…</button>
|
||||
<button onClick={() => { setMenu(null); chmod(e) }}>Permissions ({e.perm})</button>
|
||||
<button onClick={() => { setMenu(null); copyPath(e) }}>Copy path</button>
|
||||
<button className="danger" onClick={() => { setMenu(null); del(e) }}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{entries !== null && shown.length === 0 && !err && <div className="muted" style={{ padding: 12 }}>{q ? 'Nothing matches.' : 'Empty folder.'}{!hidden && entries.some((e) => e.name.startsWith('.')) ? ' (hidden files not shown)' : ''}</div>}
|
||||
</div>
|
||||
{drag && <div className="drop-hint">Drop to upload into {path}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Every widget type, then every sensor on this machine, each drawn once and live. It is
|
||||
// the catalog made visible: what exists, what it's called, and what it looks like.
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Sensor, fetchCatalog, fraction, useSensorStream } from './sensors'
|
||||
import { WIDGET_TYPES, Widget, WidgetData, WidgetSpec, WidgetType } from './widgets'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { Loading, toast } from './ui'
|
||||
|
||||
const FAMILY: Record<string, string> = {
|
||||
cpu: 'CPU', load: 'Load', uptime: 'Uptime', mem: 'Memory', swap: 'Swap', temp: 'Temperatures', fan: 'Fans', net: 'Network', disk: 'Disks',
|
||||
fs: 'Filesystems', ups: 'UPS', zfs: 'ZFS', smart: 'Drive health', services: 'Services', service: 'Each service', containers: 'Containers',
|
||||
container: 'Each container', updates: 'Updates', hamlab: 'Ham Lab',
|
||||
}
|
||||
const ORDER = Object.keys(FAMILY)
|
||||
|
||||
// The widget a sensor suits best: a gauge for a percentage, a bar for anything else with a
|
||||
// scale, a plain number or word for the rest.
|
||||
function best(s: Sensor): WidgetType {
|
||||
if (fraction(s) === null) return 'stat'
|
||||
return s.unit === '%' ? 'gauge' : 'bar'
|
||||
}
|
||||
|
||||
const copy = async (text: string) => { try { await navigator.clipboard.writeText(text); toast(`copied ${text}`) } catch { toast(text) } }
|
||||
|
||||
function Tile({ spec, data, note }: { spec: WidgetSpec; data: WidgetData; note: string }) {
|
||||
return (
|
||||
<button className={'tile gallery-tile w-' + spec.type} title="copy the sensor ID" onClick={() => copy(spec.sensor)}>
|
||||
<Widget spec={spec} data={data} />
|
||||
<div className="gallery-id mono">{note}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Gallery() {
|
||||
const [cat, setCat] = useState<Sensor[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const rate = useRefreshMs()
|
||||
const [find, setFind] = useState('')
|
||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
// Values only, at most once a second: the catalog already holds everything that doesn't change.
|
||||
const live = useSensorStream<[Record<string, Sensor['value']>]>(['*'], { full: false, everyMs: Math.max(1000, rate) })
|
||||
const now = Date.now()
|
||||
const sensors = useMemo(() => {
|
||||
const v = live.data?.[0]
|
||||
return (cat ?? []).map((s) => (v && v[s.id] !== undefined ? { ...s, value: v[s.id], t: now } : s))
|
||||
}, [cat, live.data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (!cat) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Reading the catalog…" />}</div>
|
||||
|
||||
const byId = new Map(sensors.map((s) => [s.id, s]))
|
||||
const families = new Map<string, Sensor[]>()
|
||||
const needle = find.trim().toLowerCase()
|
||||
const shown = needle ? sensors.filter((s) => s.id.includes(needle) || s.name.toLowerCase().includes(needle) || Object.values(s.labels ?? {}).some((l) => l.toLowerCase().includes(needle))) : sensors
|
||||
for (const s of shown) { const f = s.id.split('.')[0]; families.set(f, [...(families.get(f) ?? []), s]) }
|
||||
const names = [...families.keys()].sort((a, b) => (ORDER.indexOf(a) + 1 || 99) - (ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b))
|
||||
const demo = byId.get('cpu.usage')
|
||||
const cores = Object.fromEntries(sensors.filter((s) => /^cpu\.core\.\d+\.usage$/.test(s.id)).map((s) => [s.id, s]))
|
||||
|
||||
return (
|
||||
<div className="dash gallery">
|
||||
<div className="notice" style={{ marginBottom: 10 }}>
|
||||
These widgets are for <b>dashboards outside Ham Lab</b> — Glance, Home Assistant, a wall
|
||||
display. Each one is a page at <span className="mono">/embed/widget</span> you put in an
|
||||
iframe, reading this machine through a <b>read-only sensor token</b> (Settings → Access
|
||||
tokens). Ham Lab shows you these numbers on its own pages; this tab is for handing them
|
||||
to something else.
|
||||
</div>
|
||||
<div className="dash-bar">
|
||||
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
|
||||
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
|
||||
<span className="spacer" />
|
||||
</div>
|
||||
|
||||
{!needle && <h2 className="section">Widget types</h2>}
|
||||
{!needle && <div className="kpis">
|
||||
{demo && WIDGET_TYPES.filter((t) => !t.many).map((t) => <Tile key={t.id} spec={{ id: t.id, type: t.id, sensor: 'cpu.usage', title: t.label }} data={demo} note={`type=${t.id}`} />)}
|
||||
<Tile spec={{ id: 'list', type: 'list', sensor: 'cpu.core.*.usage', title: 'List' }} data={cores} note="type=list · cpu.core.*.usage" />
|
||||
</div>}
|
||||
{needle && shown.length === 0 && <div className="muted">No sensor matches “{find}”.</div>}
|
||||
|
||||
{names.map((f) => (
|
||||
<section key={f}>
|
||||
<h2 className="section">{FAMILY[f] ?? f} <span className="muted">· {families.get(f)!.length} · <button className="link mono" onClick={() => copy(f + '.*')}>{f}.*</button></span></h2>
|
||||
<div className="kpis">
|
||||
{families.get(f)!.map((s) => <Tile key={s.id} spec={{ id: s.id, type: best(s), sensor: s.id }} data={s} note={s.id} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
||||
import { Logs } from './Services'
|
||||
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import { useHashSel } from './hashsel'
|
||||
|
||||
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }
|
||||
function rel(ms: number): string {
|
||||
const s = Math.round((ms - Date.now()) / 1000), a = Math.abs(s)
|
||||
const d = Math.floor(a / 86400), h = Math.floor((a % 86400) / 3600), m = Math.floor((a % 3600) / 60)
|
||||
const txt = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m` : `${a}s`
|
||||
return s >= 0 ? `in ${txt}` : `${txt} ago`
|
||||
}
|
||||
|
||||
// Plain-English reading of the common five-field shapes; anything else shows the raw expression.
|
||||
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)
|
||||
|
||||
// ---- source editor ----
|
||||
function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () => void; onSaved: () => void }) {
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [editable, setEditable] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
useEffect(() => { api.cronSource(name).then((r) => { setText(r.text); setEditable(r.editable) }).catch((e) => setErr(String(e))) }, [name])
|
||||
async function save() {
|
||||
if (text === null) return
|
||||
setBusy(true); setErr('')
|
||||
try { await api.cronWriteSource(name, text); toast(`${srcLabel(name)} saved`); onSaved(); onClose() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'save failed') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="cron-detail">
|
||||
<CloseBtn onClick={onClose} />
|
||||
<button className="btn back" onClick={onClose}>‹ Back</button>
|
||||
<h2>{srcLabel(name)}</h2>
|
||||
{!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>}
|
||||
{text === null ? <Loading label="Loading crontab…" /> : <textarea className="editor" value={text} spellCheck={false} readOnly={!editable} onChange={(e) => setText(e.target.value)} />}
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button className="btn on" disabled={!editable || busy || text === null} onClick={save}>{busy && <Spinner />}Save</button>
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
<div className="notice">Lines are validated before saving; user crontabs are installed with <code>crontab -</code> so cron picks them up immediately.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- job detail ----
|
||||
function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob; logging: boolean; onChanged: () => void; onBack: () => void; onEdit: (src: string) => void }) {
|
||||
const [tab, setTab] = useState<'logs' | 'output' | 'run'>('logs')
|
||||
const [logs, setLogs] = useState<LogEntry[] | null>(null)
|
||||
const [out, setOut] = useState<{ file: string; text: string } | null>(null)
|
||||
const [run, setRun] = useState<CronRun | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const poll = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => { setLogs(null); setOut(null); setRun(null); setTab('logs'); setErr('') }, [job.id])
|
||||
useEffect(() => { if (tab === 'logs' && !logs) api.cronJobLogs(job.id).then(setLogs).catch((e) => setErr(String(e))) }, [tab, logs, job.id])
|
||||
useEffect(() => { if (tab === 'output' && !out) api.cronJobOutput(job.id).then(setOut).catch((e) => setOut({ file: job.out_file ?? '', text: String(e) })) }, [tab, out, job.id, job.out_file])
|
||||
useEffect(() => () => clearTimeout(poll.current), [])
|
||||
|
||||
async function toggle() {
|
||||
if (job.enabled && !await askYes(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
|
||||
setBusy('toggle'); setErr('')
|
||||
try { await api.cronJobToggle(job.id, !job.enabled); toast(job.enabled ? 'job disabled' : 'job enabled'); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
||||
}
|
||||
async function runNow() {
|
||||
if (!await askYes(`Run now as ${job.user}?\n\n${job.command}`)) return
|
||||
setBusy('run'); setErr('')
|
||||
try {
|
||||
const r = await api.cronJobRun(job.id)
|
||||
setRun(r); setTab('run')
|
||||
const tick = async () => {
|
||||
try { const cur = await api.cronRun(r.id); setRun(cur); if (cur.running) poll.current = window.setTimeout(tick, 1000); else { toast(`run finished (exit ${cur.exit_code})`, cur.exit_code === 0 ? 'ok' : 'err'); onChanged() } }
|
||||
catch { /* run expired */ }
|
||||
}
|
||||
poll.current = window.setTimeout(tick, 1000)
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cron-detail">
|
||||
<CloseBtn onClick={onBack} />
|
||||
<button className="btn back" onClick={onBack}>‹ Cron</button>
|
||||
<h2>{job.command}</h2>
|
||||
<div className="props">
|
||||
<div><span className="k">Schedule</span><span className="mono" title={job.schedule}>{job.schedule} · {describe(job.schedule)}</span></div>
|
||||
<div><span className="k">Next</span><span>{job.next ? `${rel(job.next)} · ${fmtTime(job.next)}` : job.enabled ? '—' : 'disabled'}</span></div>
|
||||
<div><span className="k">Last</span><span>{job.last ? `${rel(job.last)} · ${fmtTime(job.last)}` : logging ? 'not seen in the last 30 days' : 'unknown (cron isn’t logging here)'}</span></div>
|
||||
<div><span className="k">User</span><span>{job.user}</span></div>
|
||||
<div><span className="k">Source</span><span className="mono">{srcLabel(job.source)}{job.line ? `:${job.line}` : ''}</span></div>
|
||||
{job.out_file && <div><span className="k">Output</span><span className="mono">{job.out_file}</span></div>}
|
||||
</div>
|
||||
{job.note && <div className="notice">{job.note}</div>}
|
||||
<div className="svc-actions">
|
||||
<button className="btn" disabled={!!busy} onClick={runNow}>{busy === 'run' && <Spinner />}Run now</button>
|
||||
{job.line > 0 && <button className="btn" disabled={!!busy || !job.editable} onClick={toggle}>{busy === 'toggle' && <Spinner />}{job.enabled ? 'Disable' : 'Enable'}</button>}
|
||||
{job.line > 0 && <button className="btn" onClick={() => onEdit(job.source)}>Edit {srcLabel(job.source)}</button>}
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="tabs">
|
||||
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Cron log</button>
|
||||
{job.out_file && <button className={tab === 'output' ? 'on' : ''} onClick={() => setTab('output')}>Output file</button>}
|
||||
{run && <button className={tab === 'run' ? 'on' : ''} onClick={() => setTab('run')}>Run output</button>}
|
||||
</div>
|
||||
{tab === 'logs' && (
|
||||
<div className="logs"><div className="log-lines">
|
||||
{logs === null ? <div className="muted"><Spinner /> loading…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
|
||||
: [...logs].reverse().map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
|
||||
</div></div>
|
||||
)}
|
||||
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : <span className="muted"><Spinner /> reading…</span>}</pre>}
|
||||
{tab === 'run' && run && (
|
||||
<>
|
||||
<div className="muted">{run.running ? <><Spinner /> running…</> : `exited ${run.exit_code} · ${new Date(run.finished!).toLocaleTimeString()}`}</div>
|
||||
<pre className="run-out">{run.output || (run.running ? '' : '(no output)')}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- timer detail ----
|
||||
function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () => void; onBack: () => void }) {
|
||||
const [busy, setBusy] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
async function act(a: string) {
|
||||
if ((a === 'stop' || a === 'disable') && !await askYes(`${a} ${t.unit}?`)) return
|
||||
if (a === 'run' && !await askYes(`Start ${t.activates} now?`)) return
|
||||
setBusy(a); setErr('')
|
||||
try { await api.cronTimerAction(t.unit, a); toast(`${t.unit}: ${a} ok`); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${t.unit}: ${a} failed`, 'err') } finally { setBusy('') }
|
||||
}
|
||||
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
|
||||
return (
|
||||
<div className="cron-detail">
|
||||
<CloseBtn onClick={onBack} />
|
||||
<button className="btn back" onClick={onBack}>‹ Cron</button>
|
||||
<h2>{t.unit}</h2>
|
||||
<div className="muted">{t.description}</div>
|
||||
<div className="props">
|
||||
<div><span className="k">Runs</span><span className="mono">{t.activates}</span></div>
|
||||
<div><span className="k">State</span><span>{t.active}{t.sub ? ` (${t.sub})` : ''} · {t.enabled || '—'}</span></div>
|
||||
<div><span className="k">Next</span><span>{t.next ? `${rel(t.next)} · ${fmtTime(t.next)}` : '—'}</span></div>
|
||||
<div><span className="k">Last</span><span>{t.last ? `${rel(t.last)} · ${fmtTime(t.last)}` : 'never'}</span></div>
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
<B a="run" label="Run now" />
|
||||
{t.active === 'active' ? <B a="stop" label="Stop timer" /> : <B a="start" label="Start timer" />}
|
||||
{t.enabled === 'enabled' ? <B a="disable" label="Disable" /> : t.enabled === 'static' ? null : <B a="enable" label="Enable" />}
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="tabs"><button className="on">Logs of {t.activates}</button></div>
|
||||
<Logs unit={t.activates} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
export default function Jobs() {
|
||||
const [ov, setOv] = useState<CronOverview | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [hashSel, setHashSel] = useHashSel('jobs') // "job:<id>" or "timer:<unit>", in the URL
|
||||
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
|
||||
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setOv(await api.cron()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
const rate = useRefreshMs(LIST_FLOOR)
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, rate); return () => clearInterval(t) }, [load, rate])
|
||||
|
||||
const needle = q.trim().toLowerCase()
|
||||
const match = (...s: string[]) => !needle || s.some((x) => x.toLowerCase().includes(needle))
|
||||
const jobs = (ov?.jobs ?? []).filter((j) => match(j.command, j.source, j.user, j.schedule))
|
||||
const timers = (ov?.timers ?? []).filter((t) => match(t.unit, t.description, t.activates))
|
||||
const selJob = sel?.kind === 'job' ? jobs.find((j) => j.id === sel.id) ?? ov?.jobs.find((j) => j.id === sel.id) : undefined
|
||||
const selTimer = sel?.kind === 'timer' ? ov?.timers.find((t) => t.unit === sel.id) : undefined
|
||||
const sources = [...new Set((ov?.jobs ?? []).filter((j) => j.line > 0).map((j) => j.source))]
|
||||
|
||||
let pane
|
||||
if (editing) pane = <SourceEditor name={editing} onClose={() => setEditing(null)} onSaved={load} />
|
||||
else if (selJob) pane = <JobDetail job={selJob} logging={!!ov?.logging} onChanged={load} onBack={() => setSel(null)} onEdit={setEditing} />
|
||||
else if (selTimer) pane = <TimerDetail t={selTimer} onChanged={load} onBack={() => setSel(null)} />
|
||||
else pane = <div className="svc-empty muted">Select a job or timer.</div>
|
||||
|
||||
return (
|
||||
<div className={'cron jobs' + (sel || editing ? ' has-sel' : '')}>
|
||||
<div className="cron-list">
|
||||
<div className="svc-tools">
|
||||
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
|
||||
<div className="chips">
|
||||
{sources.map((s) => <button key={s} className="chip" title="edit" onClick={() => { setSel(null); setEditing(s) }}>✎ {srcLabel(s)}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
|
||||
<div className="cron-rows">
|
||||
{!ov && !err && <Loading label="Reading crontabs…" />}
|
||||
{ov && <h3>Scheduled jobs · {jobs.length}</h3>}
|
||||
{jobs.map((j) => (
|
||||
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
|
||||
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
|
||||
<span className="cmd" title={j.command}>{j.command}</span>
|
||||
<span className="when">{j.next ? rel(j.next) : j.enabled ? '' : 'off'}</span>
|
||||
<span className="meta"><span>{describe(j.schedule)}</span><span>{j.user}</span><span>{srcLabel(j.source)}</span>{j.last && <span>last {rel(j.last)}</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
{ov && jobs.length === 0 && <div className="muted" style={{ padding: 12 }}>No cron jobs.</div>}
|
||||
{ov && <h3>systemd timers · {timers.length}</h3>}
|
||||
{timers.map((t) => (
|
||||
<div key={t.unit} className={'cron-row' + (sel?.kind === 'timer' && sel.id === t.unit ? ' active' : '') + (t.active === 'active' ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'timer', id: t.unit }) }}>
|
||||
<span className={'sdot ' + (t.active === 'active' ? 'ok' : '')} />
|
||||
<span className="cmd" title={t.unit}>{t.unit.replace(/\.timer$/, '')}</span>
|
||||
<span className="when">{t.next ? rel(t.next) : t.active !== 'active' ? 'stopped' : ''}</span>
|
||||
<span className="meta"><span>{t.description}</span>{t.last && <span>last {rel(t.last)}</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{pane}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { api } from './api'
|
||||
import { Spinner } from './ui'
|
||||
|
||||
const USER_KEY = 'hamlab.user' // the last account signed in on this device, offered again
|
||||
|
||||
export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||
const [user, setUser] = useState(localStorage.getItem(USER_KEY) ?? '')
|
||||
const [pw, setPw] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -9,18 +13,22 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true); setErr('')
|
||||
try { await api.login(pw); onLogin() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'login failed') }
|
||||
finally { setBusy(false) }
|
||||
try {
|
||||
const r = await api.login(user.trim(), pw)
|
||||
localStorage.setItem(USER_KEY, r?.user ?? user.trim())
|
||||
onLogin()
|
||||
} catch (e) { setErr((e instanceof Error && e.message) || 'login failed'); setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="center">
|
||||
<form className="login" onSubmit={submit}>
|
||||
<h1>Ham Lab</h1>
|
||||
<input type="password" autoFocus autoComplete="current-password" placeholder="password"
|
||||
value={pw} onChange={(e) => setPw(e.target.value)} />
|
||||
<button disabled={busy || !pw}>{busy ? '…' : 'Sign in'}</button>
|
||||
<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"
|
||||
value={user} onChange={(e) => setUser(e.target.value)} disabled={busy} />
|
||||
<input type="password" autoFocus={!!user} autoComplete="current-password" placeholder="password"
|
||||
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
|
||||
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
|
||||
{err && <div className="error">{err}</div>}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MachineStatus, titleOf } from './api'
|
||||
import { useEscape } from './ui'
|
||||
import { accentFor } from './accents'
|
||||
|
||||
const hostOf = (url: string) => { try { return new URL(url).hostname } catch { return url } }
|
||||
|
||||
// The host picker at the top of the rail. The hub is always first; managed machines follow
|
||||
// with their live status. Picking one re-points the whole console at it.
|
||||
export default function MachineSwitcher({ hub, hubAddr, current, machines, onChange, onManage }: {
|
||||
hub: string; hubAddr?: string; current: string | null; machines: MachineStatus[]; onChange: (name: string | null) => void; onManage: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { if (box.current && !box.current.contains(e.target as Node)) setOpen(false) }
|
||||
window.addEventListener('click', h)
|
||||
return () => window.removeEventListener('click', h)
|
||||
}, [])
|
||||
useEscape(() => setOpen(false), open)
|
||||
const cur = current ? machines.find((m) => m.name === current) : null
|
||||
const title = (name: string | null) => titleOf(name, machines, hub)
|
||||
const dotFor = (m?: MachineStatus | null) => (!m ? 'ok' : m.online ? 'ok' : 'bad')
|
||||
return (
|
||||
<div className="mswitch" ref={box}>
|
||||
<button className="mswitch-btn" onClick={() => setOpen(!open)} title="switch machine">
|
||||
<span className={'dot ' + (current ? dotFor(cur) : 'ok')} />
|
||||
<span className="mname">{title(current)}</span>
|
||||
<span className="muted small">{current ? (cur?.online ? 'remote' : 'offline') : 'this host'}</span>
|
||||
<span className="chev">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mmenu">
|
||||
<button className={'mitem' + (!current ? ' on' : '')} onClick={() => { setOpen(false); onChange(null) }}>
|
||||
<span className="dot ok" /><span className="mname" style={{ color: accentFor(hub) }}>{hub}</span><span className="muted small mono">{hubAddr || 'this host'}</span>
|
||||
</button>
|
||||
{machines.map((m) => (
|
||||
<button key={m.name} className={'mitem' + (current === m.name ? ' on' : '')} onClick={() => { setOpen(false); onChange(m.name) }} title={m.error || (m.host && m.host !== m.name ? `${m.url} · added as ${m.name}` : m.url)}>
|
||||
<span className={'dot ' + dotFor(m)} /><span className="mname" style={{ color: accentFor(m.name) }}>{m.host || m.name}</span><span className="muted small mono">{hostOf(m.url)}{m.online ? '' : ' · offline'}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="mitem manage" onClick={() => { setOpen(false); onManage() }}>+ manage machines…</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { api, MachineStatus, Me } from './api'
|
||||
import { Btn, Loading, askYes, toast } from './ui'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import { ACCENTS, accentFor, getAccents, setAccents } from './accents'
|
||||
|
||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||
const ago = (iso?: string) => { if (!iso) return 'never'; const s = (Date.now() - Date.parse(iso)) / 1000; return s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
|
||||
const shortFp = (fp?: string) => (fp ? fp.split(':').slice(0, 6).join(':') + '…' : '')
|
||||
|
||||
export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChanged: () => void; onOpen: (name: string) => void }) {
|
||||
const [list, setList] = useState<MachineStatus[] | null>(null)
|
||||
const [fp, setFp] = useState<string | undefined>()
|
||||
const [err, setErr] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('https://')
|
||||
const [user, setUser] = useState('')
|
||||
const [pw, setPw] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addErr, setAddErr] = useState('')
|
||||
const norm = (v?: string) => (v ?? '').replace(/^v/, '').replace(/-dirty$/, '')
|
||||
const outdated = (m: MachineStatus) => m.online && norm(m.version) !== norm(hub.version)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { const r = await api.machines(); setList(r.nodes); setFp(r.fingerprint); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setList([]) }
|
||||
}, [])
|
||||
const rate = useRefreshMs(LIST_FLOOR)
|
||||
useEffect(() => { load(); const t = setInterval(load, rate); return () => clearInterval(t) }, [load, rate])
|
||||
|
||||
async function add(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setAdding(true); setAddErr('')
|
||||
try {
|
||||
const st = await api.addMachine(name.trim().toLowerCase(), url.trim(), user.trim(), pw)
|
||||
toast(`${st.name} added${st.online ? ` · ${st.host} v${st.version}` : ' (offline)'}`)
|
||||
setName(''); setUrl('https://'); setUser(''); setPw('')
|
||||
await load(); onChanged()
|
||||
} catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') }
|
||||
finally { setAdding(false) }
|
||||
}
|
||||
async function update(m: MachineStatus) {
|
||||
if (!await askYes(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
|
||||
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) }
|
||||
catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed'
|
||||
if (msg.startsWith('busy:') && await askYes(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
|
||||
try { const r = await api.updateMachine(m.name, true); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e2) { toast(`${m.name}: ${e2 instanceof Error ? e2.message : 'failed'}`, 'err') }
|
||||
} else toast(`${m.name}: ${msg}`, 'err')
|
||||
}
|
||||
}
|
||||
async function updateAll() {
|
||||
const n = list?.filter(outdated).length ?? 0
|
||||
if (!await askYes(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
|
||||
try {
|
||||
let res = await api.updateAllMachines()
|
||||
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
||||
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
||||
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
||||
setTimeout(load, 4000)
|
||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
async function remove(m: MachineStatus) {
|
||||
if (!await askYes(`Remove ${m.name}? Its access token will be revoked. Nothing on ${m.name} itself is changed.`)) return
|
||||
try { await api.removeMachine(m.name); toast(`${m.name} removed`); await load(); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
if (!list) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Loading machines…" />}</div>
|
||||
return (
|
||||
<div className="dash machines">
|
||||
<div className="dash-bar"><h2 className="section">This host</h2></div>
|
||||
<section className="card">
|
||||
<div className="sub-head"><span className="dot ok" /><span className="dname" style={{ color: accentFor(hub.host) }}>{hub.host}</span><span className="badge">hub</span><span className="spacer" /><span className="num muted">v{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}</span></div>
|
||||
<div className="muted small">{fp ? <>Certificate <span className="mono">{fp}</span></> : 'Serving plain HTTP (behind a reverse proxy). Set "tls": "auto" in config.json for a self-signed certificate if another hub should manage this host.'}</div>
|
||||
<Swatches name={hub.host} />
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Managed machines · {list.length}</h2><span className="spacer" />{list.some(outdated) && <Btn className="btn on" onClick={updateAll}>Update {list.filter(outdated).length} outdated</Btn>}</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
{list.length === 0 && <div className="notice">No machines yet. Install Ham Lab on another host, set its password, then add it below.</div>}
|
||||
<div className="cards">
|
||||
{list.map((m) => (
|
||||
<section className="card" key={m.name}>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (m.online ? 'ok' : 'bad')} />
|
||||
<span className="dname" style={{ color: accentFor(m.name) }}>{m.host || m.name}</span>
|
||||
<span className="muted small mono">{m.url}</span>
|
||||
<span className="spacer" />
|
||||
{m.reboot_needed && <span className="badge" title="reboot required">⟳</span>}
|
||||
{outdated(m) && <span className="badge bad" title={`running ${norm(m.version)}, hub has ${norm(hub.version)}`}>outdated</span>}
|
||||
</div>
|
||||
<div className="props">
|
||||
<div><span className="k">Status</span><span className={m.online ? 'good' : 'warn-text'}>{m.online ? 'online' : m.error || 'offline'}</span></div>
|
||||
{/* the name it was added under is its identity here: it is what Remove, Update and the URL use */}
|
||||
<div><span className="k">Added as</span><span className="mono">{m.name}{m.host && m.host !== m.name ? ' · renamed itself since' : ''}</span></div>
|
||||
{m.version && <div><span className="k">Version</span><span>v{m.version.replace(/^v/, '')}</span></div>}
|
||||
{m.online && <div><span className="k">Load</span><span>{m.load} · up {fmtUptime(m.uptime ?? 0)}</span></div>}
|
||||
<div><span className="k">Seen</span><span>{ago(m.last_seen)}</span></div>
|
||||
{m.fingerprint && <div><span className="k">Cert</span><span className="mono" title={m.fingerprint}>{shortFp(m.fingerprint)}</span></div>}
|
||||
<div><span className="k">Added</span><span>{new Date(m.added).toLocaleDateString()}</span></div>
|
||||
</div>
|
||||
<Swatches name={m.name} />
|
||||
<div className="svc-actions">
|
||||
<button className="btn on" disabled={!m.online} onClick={() => onOpen(m.name)}>Open</button>
|
||||
{outdated(m) && <Btn className="btn" onClick={() => update(m)}>Update</Btn>}
|
||||
<Btn className="btn danger" onClick={() => remove(m)}>Remove</Btn>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Add a machine</h2></div>
|
||||
<form className="card addform" onSubmit={add}>
|
||||
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9_\-]*" title="lower-case letters, digits, - and _" required /></label>
|
||||
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
|
||||
<label>A user on it <input value={user} onChange={(e) => setUser(e.target.value)} placeholder="core" autoComplete="off" spellCheck={false} autoCapitalize="off" /></label>
|
||||
<label>Their password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label>
|
||||
{addErr && <div className="error">{addErr}</div>}
|
||||
<div className="svc-actions">
|
||||
<button className="btn on" disabled={adding || !name || !pw}>{adding ? 'Pairing…' : 'Add machine'}</button>
|
||||
<span className="muted small">The password is used once to obtain an access token and is not stored. The user may be left blank on a machine with a single account. The machine's certificate is pinned on first contact.</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The colour a machine wears in the header stripe and the switcher: ten to tap, or any other.
|
||||
export function Swatches({ name }: { name: string }) {
|
||||
const [, bump] = useState(0)
|
||||
const cur = accentFor(name)
|
||||
const pick = (c: string) => { setAccents({ ...getAccents(), [name]: c }); bump((n) => n + 1) }
|
||||
return (
|
||||
<div className="swatches" title={`${name}'s colour — it marks the header whenever you are on this machine`}>
|
||||
{ACCENTS.map((c) => <button key={c} className={'swatch' + (c.toLowerCase() === cur.toLowerCase() ? ' on' : '')} style={{ background: c }} aria-label={c} onClick={() => pick(c)} />)}
|
||||
<input type="color" value={cur} onChange={(e) => pick(e.target.value)} aria-label="another colour" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
import { Btn, Spinner, useEscape } from './ui'
|
||||
|
||||
// Header power control: a small button, a dialog that needs the hostname typed back.
|
||||
// `label` is what the user types (the machine name on a remote); `host` is what the node checks.
|
||||
export default function Power({ host, label, rebootNeeded }: { host: string; label: string; rebootNeeded?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [typed, setTyped] = useState('')
|
||||
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
|
||||
useEscape(() => { if (!sent) setOpen(false) }, open)
|
||||
const [err, setErr] = useState('')
|
||||
const ok = typed.trim() === label
|
||||
|
||||
async function go(action: 'reboot' | 'poweroff') {
|
||||
setErr('')
|
||||
try { await api.power(action, host); setSent(action) }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={'icon-btn power' + (rebootNeeded ? ' attn' : '')} title={rebootNeeded ? 'reboot required' : 'power'} onClick={() => { setOpen(true); setTyped(''); setSent(''); setErr('') }}>
|
||||
{/* drawn, not typed: ⏻ (U+23FB) is missing from Windows' fonts and shows as a box of hex digits */}
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M8 1.8v6" /><path d="M4.6 4.2a5.2 5.2 0 1 0 6.8 0" /></svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="modal-back" onClick={() => !sent && setOpen(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Power · {label}</h3>
|
||||
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
|
||||
{sent ? (
|
||||
<div className="muted"><Spinner /> {sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="muted">Type <b>{label}</b> to confirm
|
||||
<input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={label} />
|
||||
</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!ok} onClick={() => go('reboot')}>Reboot</Btn>
|
||||
<Btn className="btn danger" disabled={!ok} onClick={() => go('poweroff')}>Shut down</Btn>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +1,65 @@
|
||||
import { useState } from 'react'
|
||||
import { Term } from './api'
|
||||
import { ReactNode, useRef, useState } from 'react'
|
||||
import { orderBy, resetRailOrder, setRailOrder, useRailOrder } from './railorder'
|
||||
|
||||
export interface Group { id: string; label: string; icon: string; soon?: boolean }
|
||||
// Every group is a page: clicking it opens it. `soon` groups are inert placeholders
|
||||
// for the roadmap.
|
||||
export interface Group { id: string; label: string; icon: ReactNode; soon?: boolean }
|
||||
|
||||
interface Props {
|
||||
groups: Group[]; terms: Term[]; active: string | null
|
||||
onSelect: (id: string) => void; onNew: () => void
|
||||
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||||
}
|
||||
interface Props { groups: Group[]; view: string; header?: ReactNode; badges?: Record<string, number>; onView: (id: string) => void }
|
||||
|
||||
// The pages are in the order the account keeps (railorder.ts). "Arrange" at the foot puts a
|
||||
// handle on each; drag one up or down — pointer events, so a finger works as well as a mouse —
|
||||
// and the order is saved with the UI prefs when it is let go.
|
||||
export default function Rail({ groups, view, header, badges, onView }: Props) {
|
||||
const saved = useRailOrder()
|
||||
const [arranging, setArranging] = useState(false)
|
||||
const [drag, setDrag] = useState<{ id: string; order: string[] } | null>(null)
|
||||
const rows = useRef(new Map<string, HTMLElement>())
|
||||
const ordered = drag ? orderBy(groups, drag.order) : orderBy(groups, saved)
|
||||
|
||||
const start = (id: string) => (e: React.PointerEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
setDrag({ id, order: ordered.map((g) => g.id) })
|
||||
}
|
||||
const move = (e: React.PointerEvent<HTMLElement>) => {
|
||||
if (!drag) return
|
||||
// where the pointer is among the rows: the row whose middle it has crossed
|
||||
const y = e.clientY
|
||||
const order = drag.order.slice()
|
||||
const from = order.indexOf(drag.id)
|
||||
let to = from
|
||||
order.forEach((id, i) => {
|
||||
const el = rows.current.get(id)
|
||||
if (!el || id === drag.id) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const mid = r.top + r.height / 2
|
||||
if (i < from && y < mid && i < to) to = i
|
||||
if (i > from && y > mid && i > to) to = i
|
||||
})
|
||||
if (to !== from) { order.splice(from, 1); order.splice(to, 0, drag.id); setDrag({ id: drag.id, order }) }
|
||||
}
|
||||
const end = () => { if (drag) { setRailOrder(drag.order); setDrag(null) } }
|
||||
|
||||
export default function Rail({ groups, terms, active, onSelect, onNew, onRename, onClose }: Props) {
|
||||
const [open, setOpen] = useState<string>('terminal')
|
||||
return (
|
||||
<nav className="rail">
|
||||
{groups.map((g) => {
|
||||
const expanded = open === g.id
|
||||
return (
|
||||
<div key={g.id} className={'group' + (expanded ? ' open' : '') + (g.soon ? ' soon' : '')}>
|
||||
<button className="group-head" onClick={() => !g.soon && setOpen(expanded ? '' : g.id)} disabled={g.soon}>
|
||||
<span className="chev">{expanded ? '▾' : '▸'}</span>
|
||||
<nav className={'rail' + (arranging ? ' arranging' : '')}>
|
||||
{header}
|
||||
{ordered.map((g) => (
|
||||
<div key={g.id} ref={(el) => { if (el) rows.current.set(g.id, el); else rows.current.delete(g.id) }} className={'group' + (g.soon ? ' soon' : '') + (drag?.id === g.id ? ' dragging' : '')}>
|
||||
<button className={'group-head' + (view === g.id ? ' active' : '')} disabled={g.soon}
|
||||
onClick={() => { if (!g.soon && !arranging) onView(g.id) }}>
|
||||
<span className="handle" title="drag to move" onPointerDown={start(g.id)} onPointerMove={move} onPointerUp={end} onPointerCancel={end}>⋮⋮</span>
|
||||
<span className="gicon">{g.icon}</span>
|
||||
<span>{g.label}</span>
|
||||
{g.soon && <span className="badge">soon</span>}
|
||||
{!!badges?.[g.id] && <span className="rail-badge" title={`${badges[g.id]} firing`}>{badges[g.id]}</span>}
|
||||
</button>
|
||||
{expanded && g.id === 'terminal' && (
|
||||
<div className="leaves">
|
||||
{terms.map((t) => (
|
||||
<div key={t.id} className={'leaf' + (t.id === active ? ' active' : '') + (t.running ? '' : ' exited')}
|
||||
onClick={() => onSelect(t.id)}>
|
||||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||
<span className="lname" title={t.name}>{t.name}</span>
|
||||
{t.id === active && (
|
||||
<span className="leaf-actions">
|
||||
<button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>
|
||||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button className="leaf new" onClick={onNew}>+ New Terminal</button>
|
||||
<div className="rail-foot">
|
||||
<button className={arranging ? 'on' : ''} title="put the pages in your own order" onClick={() => setArranging(!arranging)}>{arranging ? 'Done' : '⇅ Arrange'}</button>
|
||||
{arranging && saved.length > 0 && <button onClick={() => resetRailOrder()}>Reset</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
|
||||
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import { useHashSel } from './hashsel'
|
||||
|
||||
type Filter = 'all' | 'running' | 'failed' | 'enabled'
|
||||
|
||||
function fmtBytes(b: number): string {
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB']
|
||||
let i = 0
|
||||
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
|
||||
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
|
||||
}
|
||||
function fmtCPU(ns: number): string {
|
||||
const s = ns / 1e9
|
||||
return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s`
|
||||
}
|
||||
function since(ts?: string): string {
|
||||
if (!ts) return ''
|
||||
const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
|
||||
if (isNaN(t)) return ts
|
||||
const s = Math.max(0, (Date.now() - t) / 1000)
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return (d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m` : `${Math.floor(s)}s`) + ' ago'
|
||||
}
|
||||
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString() }
|
||||
|
||||
function stateClass(u: ServiceUnit): string {
|
||||
if (u.failed) return 'bad'
|
||||
if (u.active === 'active' && u.sub === 'running') return 'ok'
|
||||
if (u.active === 'active') return 'exited'
|
||||
if (u.active === 'activating' || u.active === 'deactivating') return 'warn'
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---- log viewer: seeded from the REST fetch, then followed over a WebSocket ----
|
||||
export function Logs({ unit }: { unit: string }) {
|
||||
const [lines, setLines] = useState<LogEntry[]>([])
|
||||
const [live, setLive] = useState(true)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [ready, setReady] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const pausedRef = useRef(false)
|
||||
pausedRef.current = paused
|
||||
const pending = useRef<LogEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setLines([]); setErr(''); setReady(false); pending.current = []
|
||||
let closed = false
|
||||
let readyTimer: number | undefined
|
||||
let sock: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
// fixed when the log opens, for the same reason as a terminal's: a reconnect must not
|
||||
// follow the console to another machine
|
||||
const url = wsURLPath(`/api/services/${encodeURIComponent(unit)}/logs/tail?n=200`)
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
const s = new WebSocket(url)
|
||||
sock = s
|
||||
s.onopen = () => { setLive(true); setErr(''); readyTimer = window.setTimeout(() => setReady(true), 1500) }
|
||||
s.onmessage = (ev) => {
|
||||
setReady(true)
|
||||
const e = JSON.parse(ev.data) as LogEntry
|
||||
if (pausedRef.current) { pending.current.push(e); return }
|
||||
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), e] : [...prev, e]))
|
||||
}
|
||||
s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, 3000) }
|
||||
s.onerror = () => { setErr('log stream disconnected'); s.close() }
|
||||
}
|
||||
connect()
|
||||
return () => { closed = true; clearTimeout(retry); clearTimeout(readyTimer); sock?.close() }
|
||||
}, [unit])
|
||||
|
||||
// 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 (!paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, paused])
|
||||
|
||||
return (
|
||||
<div className="logs">
|
||||
<div className="logs-bar">
|
||||
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'}{err && ` · ${err}`}</span>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
|
||||
<button className="btn" onClick={() => setLines([])}>Clear</button>
|
||||
</div>
|
||||
<div className="log-lines" ref={box}>
|
||||
{lines.length === 0 && (ready ? <div className="muted">No journal entries.</div> : <div className="muted"><Spinner /> loading journal…</div>)}
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className={'log' + (l.p <= 3 ? ' err' : l.p === 4 ? ' warn' : l.p >= 7 ? ' debug' : '')}>
|
||||
<span className="lt">{fmtTime(l.t)}</span>
|
||||
<span className="li">{l.ident}{l.pid ? `[${l.pid}]` : ''}</span>
|
||||
<span className="lm">{l.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- detail pane ----
|
||||
function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => void; onBack: () => void }) {
|
||||
const [d, setD] = useState<ServiceDetail | null>(null)
|
||||
const [tab, setTab] = useState<'logs' | 'file'>('logs')
|
||||
const [file, setFile] = useState<string>('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setD(await api.service(name)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [name])
|
||||
const rate = useRefreshMs(LIST_FLOOR)
|
||||
useEffect(() => { setD(null); setFile(''); setTab('logs'); load() }, [load])
|
||||
useEffect(() => { const t = setInterval(load, rate); return () => clearInterval(t) }, [load, rate])
|
||||
useEffect(() => { if (tab === 'file' && !file) api.serviceFile(name).then((r) => setFile(r.content)).catch((e) => setFile(String(e))) }, [tab, file, name])
|
||||
|
||||
async function act(action: string) {
|
||||
const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
|
||||
if (confirmFor[action] && !await askYes(`${confirmFor[action]} ${name}?`)) return
|
||||
setBusy(action); setErr('')
|
||||
try { await api.serviceAction(name, action); await load(); onChanged(); toast(`${name}: ${action} ok`) }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
if (!d) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Services</button>{err ? <div className="error">{err}</div> : <Loading label="Loading service…" />}</div>
|
||||
const p = d.props
|
||||
const running = d.active === 'active'
|
||||
const enabled = d.enabled === 'enabled'
|
||||
const Btn = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
|
||||
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<CloseBtn onClick={onBack} />
|
||||
<button className="btn back" onClick={onBack}>‹ Services</button>
|
||||
<div className="svc-head">
|
||||
<span className={'sdot ' + stateClass(d)} />
|
||||
<h2>{d.name}</h2>
|
||||
</div>
|
||||
<div className="muted">{d.description}</div>
|
||||
<div className="svc-actions">
|
||||
{running ? <Btn a="stop" label="Stop" /> : <Btn a="start" label="Start" />}
|
||||
<Btn a="restart" label="Restart" />
|
||||
{p.ExecReload || d.sub === 'running' ? <Btn a="reload" label="Reload" /> : null}
|
||||
{enabled ? <Btn a="disable" label="Disable" /> : d.enabled === 'static' || d.enabled === 'masked' ? null : <Btn a="enable" label="Enable" />}
|
||||
{d.failed && <Btn a="reset-failed" label="Reset failed" />}
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
<div className="props">
|
||||
<div><span className="k">State</span><span>{d.active} ({d.sub}){d.failed && p.Result ? ` · ${p.Result}` : ''}</span></div>
|
||||
<div><span className="k">Enabled</span><span>{d.enabled || '—'}</span></div>
|
||||
<div><span className="k">Since</span><span>{running ? since(p.ActiveEnterTimestamp) : since(p.InactiveEnterTimestamp)}</span></div>
|
||||
{p.MainPID && p.MainPID !== '0' && <div><span className="k">PID</span><span>{p.MainPID}</span></div>}
|
||||
{p.MemoryCurrent && <div><span className="k">Memory</span><span>{fmtBytes(+p.MemoryCurrent)}{p.MemoryPeak ? ` (peak ${fmtBytes(+p.MemoryPeak)})` : ''}</span></div>}
|
||||
{p.CPUUsageNSec && <div><span className="k">CPU time</span><span>{fmtCPU(+p.CPUUsageNSec)}</span></div>}
|
||||
{p.TasksCurrent && <div><span className="k">Tasks</span><span>{p.TasksCurrent}</span></div>}
|
||||
{p.NRestarts && p.NRestarts !== '0' && <div><span className="k">Restarts</span><span>{p.NRestarts}</span></div>}
|
||||
{p.User && <div><span className="k">User</span><span>{p.User}</span></div>}
|
||||
{p.FragmentPath && <div><span className="k">Unit file</span><span className="mono">{p.FragmentPath}</span></div>}
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
|
||||
<button className={tab === 'file' ? 'on' : ''} onClick={() => setTab('file')}>Unit file</button>
|
||||
</div>
|
||||
{tab === 'logs' ? <Logs unit={name} /> : <pre className="unit-file">{file || <span className="muted"><Spinner /> loading…</span>}</pre>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
export default function Services() {
|
||||
const [units, setUnits] = useState<ServiceUnit[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [hashSel, setHashSel] = useHashSel('services') // the selected unit lives in the URL
|
||||
const sel = hashSel || null, setSel = (v: string | null) => setHashSel(v ?? '')
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
finally { setLoaded(true) }
|
||||
}, [])
|
||||
const rate = useRefreshMs(LIST_FLOOR)
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, rate); return () => clearInterval(t) }, [load, rate])
|
||||
|
||||
const needle = q.trim().toLowerCase()
|
||||
const shown = units.filter((u) => {
|
||||
if (filter === 'running' && !(u.active === 'active' && u.sub === 'running')) return false
|
||||
if (filter === 'failed' && !u.failed) return false
|
||||
if (filter === 'enabled' && u.enabled !== 'enabled') return false
|
||||
return !needle || u.name.toLowerCase().includes(needle) || u.description.toLowerCase().includes(needle)
|
||||
})
|
||||
const counts = {
|
||||
running: units.filter((u) => u.active === 'active' && u.sub === 'running').length,
|
||||
failed: units.filter((u) => u.failed).length,
|
||||
enabled: units.filter((u) => u.enabled === 'enabled').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'svc' + (sel ? ' has-sel' : '')}>
|
||||
<div className="svc-list">
|
||||
<div className="svc-tools">
|
||||
<div className="tools-row"><input placeholder="filter services" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
|
||||
<div className="chips">
|
||||
{([['all', `All ${units.length}`], ['running', `Running ${counts.running}`], ['failed', `Failed ${counts.failed}`], ['enabled', `Enabled ${counts.enabled}`]] as [Filter, string][]).map(([f, label]) => (
|
||||
<button key={f} className={'chip' + (filter === f ? ' on' : '') + (f === 'failed' && counts.failed ? ' bad' : '')} onClick={() => setFilter(f)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-rows">
|
||||
{!loaded && <Loading label="Listing services…" />}
|
||||
{shown.map((u) => (
|
||||
<div key={u.name} className={'svc-row' + (u.name === sel ? ' active' : '')} onClick={() => setSel(u.name)}>
|
||||
<span className={'sdot ' + stateClass(u)} />
|
||||
<span className="svc-name">
|
||||
<span className="n">{u.name.replace(/\.service$/, '')}</span>
|
||||
<span className="d">{u.description}</span>
|
||||
</span>
|
||||
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
{loaded && shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
</div>
|
||||
</div>
|
||||
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchCatalog } from './sensors'
|
||||
import { api, Me, TokenInfo, UpdateStatus } from './api'
|
||||
import { savePrefs } from './accents'
|
||||
import { applyTheme, currentThemeId, THEMES } from './themes'
|
||||
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
||||
import { fmtMs, fmtWindowSec, getHistorySec, getRefreshMs, HISTORY_OPTIONS, HISTORY_POINTS, REFRESH_OPTIONS, setHistorySec, setRefreshMs, strideMs } from './prefs'
|
||||
|
||||
const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` }
|
||||
|
||||
// Small painted preview of a theme: rail, header, a tile and a bar, in its own colours.
|
||||
function Preview({ vars }: { vars: Record<string, string> }) {
|
||||
const v = (k: string) => vars[k]
|
||||
return (
|
||||
<div className="tprev" style={{ background: v('--bg'), borderColor: v('--border') }}>
|
||||
<div className="tprev-head" style={{ background: v('--bg2'), borderColor: v('--border') }}><span style={{ color: v('--accent') }}>Ham Lab</span><span className="tprev-dot" style={{ background: v('--ok') }} /></div>
|
||||
<div className="tprev-body">
|
||||
<div className="tprev-rail" style={{ background: v('--bg2'), borderColor: v('--border') }}>
|
||||
<span style={{ background: v('--bg3'), boxShadow: `inset 2px 0 0 ${v('--accent')}` }} /><span /><span />
|
||||
</div>
|
||||
<div className="tprev-main">
|
||||
<div className="tprev-tile" style={{ background: v('--bg2'), borderColor: v('--border') }}>
|
||||
<i style={{ color: v('--muted') }}>CPU</i><b style={{ color: v('--fg') }}>12%</b>
|
||||
<div className="tprev-bar" style={{ background: v('--track') }}><div style={{ width: '40%', background: v('--s1') }} /></div>
|
||||
</div>
|
||||
<div className="tprev-term" style={{ background: v('--term-bg'), color: v('--accent2') }}>$ _</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Settings({ me }: { me: Me }) {
|
||||
const [theme, setTheme] = useState(currentThemeId())
|
||||
const [refresh, setRefresh] = useState(getRefreshMs())
|
||||
const [history, setHistory] = useState(getHistorySec())
|
||||
const [histErr, setHistErr] = useState('')
|
||||
const [tokens, setTokens] = useState<TokenInfo[] | null>(null)
|
||||
const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null)
|
||||
const [making, setMaking] = useState(false)
|
||||
|
||||
const loadTokens = useCallback(async () => { try { setTokens(await api.tokens()) } catch { setTokens([]) } }, [])
|
||||
useEffect(() => { loadTokens() }, [loadTokens])
|
||||
|
||||
const pick = async (id: string) => {
|
||||
applyTheme(id); setTheme(id)
|
||||
await savePrefs()
|
||||
}
|
||||
const pickRefresh = async (ms: number) => {
|
||||
setRefreshMs(ms); setRefresh(ms)
|
||||
await savePrefs()
|
||||
}
|
||||
// The window is the machine's own setting, not a look: the hub takes it and passes it on.
|
||||
useEffect(() => { api.historyWindow().then((h) => { setHistory(h.seconds); setHistorySec(h.seconds) }).catch(() => {}) }, [])
|
||||
const pickHistory = async (sec: number) => {
|
||||
const was = history
|
||||
setHistory(sec); setHistorySec(sec); setHistErr('')
|
||||
try { const h = await api.setHistoryWindow(sec); setHistory(h.seconds); setHistorySec(h.seconds) }
|
||||
catch (e) { setHistory(was); setHistorySec(was); setHistErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
|
||||
async function revoke(name: string) {
|
||||
if (!await askYes(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return
|
||||
try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dash settings">
|
||||
<div className="dash-bar"><h2 className="section">Appearance</h2><span className="spacer" /><span className="muted small">follows you to every device you sign in on</span></div>
|
||||
<div className="themes">
|
||||
{THEMES.map((t) => (
|
||||
<button key={t.id} className={'theme-card' + (theme === t.id ? ' on' : '')} onClick={() => pick(t.id)}>
|
||||
<Preview vars={t.vars} />
|
||||
<div className="theme-name">{t.name}{theme === t.id && <span className="badge">active</span>}</div>
|
||||
<div className="muted small">{t.blurb}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Refresh rate</h2><span className="spacer" /><span className="muted small">every live view, on every machine</span></div>
|
||||
<section className="card">
|
||||
<div className="chips">
|
||||
{REFRESH_OPTIONS.map((ms) => <button key={ms} className={'chip' + (refresh === ms ? ' on' : '')} onClick={() => pickRefresh(ms)}>{fmtMs(ms)}</button>)}
|
||||
</div>
|
||||
<div className="muted small" style={{ marginTop: 8 }}>
|
||||
Every page polls at exactly this rate: dashboard, processes, disks, services, cron, updates, machines. The sampler on each machine follows it too, so it is also the finest a graph can be — how far back the graphs reach is the next setting down.
|
||||
Fast rates cost CPU on the host, especially on the list pages, which run commands to refresh. Nothing is throttled behind your back.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Graph history</h2><span className="spacer" /><span className="muted small">every graph, on every machine</span></div>
|
||||
<section className="card">
|
||||
<div className="chips">
|
||||
{HISTORY_OPTIONS.map((sec) => <button key={sec} className={'chip' + (history === sec ? ' on' : '')} onClick={() => pickHistory(sec)}>{fmtWindowSec(sec)}</button>)}
|
||||
</div>
|
||||
{histErr && <div className="error" style={{ marginTop: 8 }}>{histErr}</div>}
|
||||
<div className="muted small" style={{ marginTop: 8 }}>
|
||||
How far back every sparkline reaches — the dashboard, the processes page, the disks
|
||||
page and any sparkline widget. Each machine keeps {HISTORY_POINTS} points per curve whatever you
|
||||
pick, so a wider window is a coarser one: <b>{fmtWindowSec(history)}</b> puts a point every{' '}
|
||||
{fmtWindowSec(strideMs(history, refresh) / 1000)}, each the average of what happened
|
||||
in between rather than one instant out of it. Memory on the machine doesn't change.
|
||||
The history is kept in memory, so a machine that restarts starts its graphs again.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
|
||||
<section className="card">
|
||||
<div className="muted small" style={{ marginBottom: 8 }}>A <b>full</b> token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A <b>read-only</b> token can only read sensors — that's the one to give a dashboard.</div>
|
||||
{tokens === null && <Loading label="Loading…" />}
|
||||
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
|
||||
{tokens && tokens.map((t) => (
|
||||
<div className="row" key={t.name}>
|
||||
<span className="k mono">{t.name}</span>
|
||||
<span className="badge" style={{ marginLeft: 0 }} title={t.sensors?.length ? t.sensors.join(', ') : undefined}>{t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'}</span>
|
||||
<span className="muted small">issued {new Date(t.created).toLocaleString()}{t.issued_by ? ` by ${t.issued_by}` : ''}</span>
|
||||
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
|
||||
</div>
|
||||
))}
|
||||
{fresh && (
|
||||
<div className="notice attn" style={{ marginTop: 8 }}>
|
||||
<div>Token for <b>{fresh.name}</b> — shown once, copy it now:</div>
|
||||
<div className="mono small" style={{ wordBreak: 'break-all', userSelect: 'all', margin: '6px 0' }}>{fresh.token}</div>
|
||||
<Btn className="btn" onClick={async () => { try { await navigator.clipboard.writeText(fresh.token); toast('copied') } catch { toast('select and copy it by hand', 'err') } }}>Copy</Btn>{' '}
|
||||
<Btn className="btn" onClick={() => setFresh(null)}>Done</Btn>
|
||||
</div>
|
||||
)}
|
||||
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||
<Btn className="btn" onClick={() => setMaking(true)}>+ Read-only sensor token</Btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
|
||||
|
||||
<Updates me={me} />
|
||||
|
||||
<div className="dash-bar"><h2 className="section">About</h2></div>
|
||||
<section className="card">
|
||||
<div className="props">
|
||||
<div><span className="k">Version</span><span className="mono">{me.version}</span></div>
|
||||
<div><span className="k">Host</span><span>{me.host} · {me.arch}</span></div>
|
||||
<div><span className="k">Runs as</span><span>{me.user}</span></div>
|
||||
<div><span className="k">Uptime</span><span>{fmtUptime(me.uptime)}</span></div>
|
||||
{me.fingerprint && <div><span className="k">Certificate</span><span className="mono small">{me.fingerprint}</span></div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// What is published, and installing it. An update replaces the binary and restarts the
|
||||
// daemon, which drops open terminals — so it only ever happens when it is asked for.
|
||||
function Updates({ me }: { me: Me }) {
|
||||
const [st, setSt] = useState<UpdateStatus | null>(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const load = useCallback(async () => { try { setSt(await api.update()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const check = async () => {
|
||||
setBusy('check'); setErr('')
|
||||
try { setSt(await api.checkUpdate()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
const install = async (force = false) => {
|
||||
if (!st) return
|
||||
if (!await askYes(`Install ${st.latest} on ${me.host}? Ham Lab restarts, which closes open terminals on this machine.`, 'Install')) return
|
||||
setBusy('install'); setErr('')
|
||||
try {
|
||||
await api.installUpdate(st.latest, force)
|
||||
toast(`${st.latest} installed — reconnecting`)
|
||||
setTimeout(() => window.location.reload(), 4000)
|
||||
} catch (e) {
|
||||
const m = e instanceof Error ? e.message : 'failed'
|
||||
setBusy('')
|
||||
if (/^busy:/.test(m) && await askYes(m.replace(/^busy: /, '') + '. Install anyway?', 'Install anyway')) { install(true); return }
|
||||
setErr(m)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar"><h2 className="section">Updates</h2><span className="spacer" />
|
||||
<span className="muted small">{st?.auto ? 'checked daily' : st?.enabled ? 'checked when you ask' : 'switched off'}</span>
|
||||
</div>
|
||||
<section className="card">
|
||||
<div className="props">
|
||||
<div><span className="k">Installed</span><span className="mono">{st?.current ?? me.version}</span></div>
|
||||
<div><span className="k">Published</span><span className="mono">{st?.checking ? <Spinner /> : st?.latest || '—'}</span></div>
|
||||
<div><span className="k">Checked</span><span>{st?.checked ? new Date(st.checked).toLocaleString() : 'not yet'}</span></div>
|
||||
<div><span className="k">Source</span><span className="mono small" title={st?.url}>{st?.enabled ? sourceName(st.url) : 'off'}</span></div>
|
||||
</div>
|
||||
{st?.behind && (
|
||||
<div className="notice attn" style={{ marginTop: 10 }}>
|
||||
<b>{st.latest}</b> is published; this machine runs <b>{st.current}</b>.
|
||||
{st.published && <span className="muted"> Released {new Date(st.published).toLocaleDateString()}.</span>}
|
||||
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
|
||||
</div>
|
||||
)}
|
||||
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>This is the newest published build.</div>}
|
||||
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
|
||||
<div className="svc-actions" style={{ marginTop: 10 }}>
|
||||
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
|
||||
{st?.behind && <Btn className="btn on" busy={busy === 'install'} disabled={!!busy} onClick={() => install()}>Install {st.latest}</Btn>}
|
||||
<span className="muted small">Installing restarts Ham Lab on this machine. Terminals here close; the shells do not survive it.</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// A source shown as something readable: the repo it is, or the host it is on.
|
||||
function sourceName(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const m = u.pathname.match(/\/api\/v1\/repos\/(.+)$/)
|
||||
return m ? `${u.host}/${m[1]}` : u.host + u.pathname
|
||||
} catch { return url }
|
||||
}
|
||||
|
||||
// One dialog for a read-only token: what will use it, and (optionally) which sensor
|
||||
// families it may see — picked from this machine's own catalog, or typed as patterns.
|
||||
function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) {
|
||||
const [name, setName] = useState('glance')
|
||||
const [pats, setPats] = useState('')
|
||||
const [fams, setFams] = useState<string[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
useEffect(() => { fetchCatalog({ machine: null }).then((c) => setFams([...new Set(c.map((s) => s.id.split('.')[0]))].sort())).catch(() => {}) }, [])
|
||||
const list = pats.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
const toggle = (f: string) => { const p = f === 'uptime' ? f : f + '.*'; setPats((list.includes(p) ? list.filter((x) => x !== p) : [...list, p]).join(', ')) }
|
||||
const make = async () => { try { onMade(await api.createReadToken(name.trim(), list)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); make() }}>
|
||||
<h3>Read-only sensor token</h3>
|
||||
<label className="muted">Name — what will use it
|
||||
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} maxLength={64} spellCheck={false} autoCapitalize="off" />
|
||||
</label>
|
||||
<label className="muted">Limit it to these sensors — empty means every sensor
|
||||
<input className="mono" value={pats} onChange={(e) => setPats(e.target.value)} placeholder="cpu.*, temp.*, zfs.tank.*" spellCheck={false} autoCapitalize="off" />
|
||||
</label>
|
||||
<div className="chips">{fams.map((f) => <button type="button" key={f} className={'chip' + (list.includes(f === 'uptime' ? f : f + '.*') ? ' on' : '')} onClick={() => toggle(f)}>{f}</button>)}</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions"><button type="submit" className="btn on" disabled={!name.trim()}>Create</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +1,297 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, Me, Term } from './api'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, Me, Term, titleOf } from './api'
|
||||
import Rail, { Group } from './Rail'
|
||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||
import TermTabs from './TermTabs'
|
||||
import KeyBar from './KeyBar'
|
||||
import Gallery from './Gallery'
|
||||
import Docker from './Docker'
|
||||
import Alerts from './Alerts'
|
||||
import Services from './Services'
|
||||
import Jobs from './Jobs'
|
||||
import Updates from './Updates'
|
||||
import Disks from './Disks'
|
||||
import ZFS from './ZFS'
|
||||
import Files from './Files'
|
||||
import Top from './Top'
|
||||
import Machines from './Machines'
|
||||
import Settings from './Settings'
|
||||
import Usage from './Usage'
|
||||
import Users from './Users'
|
||||
import MachineSwitcher from './MachineSwitcher'
|
||||
import { MachineStatus, setMachine } from './api'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import Power from './Power'
|
||||
import { applyAccent } from './accents'
|
||||
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
||||
|
||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||
const FolderIcon = (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M1.5 4a1 1 0 0 1 1-1h3.2l1.6 1.6h6.2a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ⏱ is an emoji on phones (full colour, its own size); an outline clock matches the rest.
|
||||
const ClockIcon = (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.2" /><path d="M8 4.6V8l2.3 1.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DockerIcon = (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 5.2 8 2l6 3.2v5.6L8 14l-6-3.2z" /><path d="M2 5.2 8 8.4l6-3.2M8 8.4V14" />
|
||||
</svg>
|
||||
)
|
||||
const BellIcon = (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 11V7.2a4 4 0 0 1 8 0V11l1.2 1.6H2.8z" /><path d="M6.6 14.2h2.8" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{ id: 'procs', label: 'Processes', icon: '▚' },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '⚙', soon: true },
|
||||
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
|
||||
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', soon: true },
|
||||
{ id: 'services', label: 'Services', icon: '◈' },
|
||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||
{ id: 'jobs', label: 'Jobs', icon: ClockIcon },
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||
{ id: 'disks', label: 'Disks', icon: '▤' },
|
||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||
{ id: 'users', label: 'Users', icon: '⚇' },
|
||||
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
|
||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||
|
||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||
// Pages that were renamed, so a link or a bookmark made before it still lands where it meant.
|
||||
const RENAMED_VIEWS: Record<string, View> = { space: 'usage', cron: 'jobs', top: 'procs', dashboard: 'procs' }
|
||||
|
||||
function parseHash(): { machine: string | null; view: View } | null {
|
||||
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection
|
||||
if (!m) return null
|
||||
const view = VIEWS.includes(m[2] as View) ? (m[2] as View) : RENAMED_VIEWS[m[2]]
|
||||
if (!view) return null
|
||||
return { machine: m[1] === 'hub' ? null : m[1], view }
|
||||
}
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
||||
export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
||||
// Which machine we're looking at. null = the hub itself. Set before anything renders so
|
||||
// every page's first request already goes to the right place.
|
||||
// The URL wins when it has an opinion; the last machine is only for a bare address. "hub" is
|
||||
// an opinion — it is null here — so ?? would have read straight past it to the remembered
|
||||
// machine, and a link to a page of the hub reloaded onto whichever machine was last open.
|
||||
const [machine, setMachineState] = useState<string | null>(() => { const h = parseHash(); const m = h ? h.machine : sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
|
||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||
const [remoteErr, setRemoteErr] = useState('')
|
||||
// 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.
|
||||
const title = titleOf(machine, machines, hubMe.host)
|
||||
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
|
||||
const listRate = useRefreshMs(LIST_FLOOR)
|
||||
const [terms, setTerms] = useState<Term[]>([])
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'procs')
|
||||
const [drawer, setDrawer] = useState(false)
|
||||
const [online, setOnline] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const handles = useRef<Record<string, TermHandle>>({})
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.terminals()
|
||||
setTerms(list)
|
||||
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
||||
}, [])
|
||||
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
|
||||
}, [machine])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
// 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 */ } }, [])
|
||||
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, listRate); return () => clearInterval(t) }, [loadMachines, listRate])
|
||||
|
||||
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
||||
const switchMachine = async (name: string | null) => {
|
||||
if (name !== machine && !(await okToLeave())) return
|
||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
||||
setMachineState(name)
|
||||
}
|
||||
|
||||
// keep the hash in step, and follow it when the browser navigates
|
||||
const [firing, setFiring] = useState(0)
|
||||
useEffect(() => {
|
||||
const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) }
|
||||
tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick)
|
||||
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
|
||||
}, [])
|
||||
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
|
||||
// Hub-only pages make no sense on a remote machine. Judged once both have settled, not while
|
||||
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
|
||||
// inside the machine change read the page we were leaving — so a link from Settings to a
|
||||
// machine's Disks arrived at its Dashboard.
|
||||
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
|
||||
|
||||
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
|
||||
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
||||
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${title} — Ham Lab` }, [view, title])
|
||||
useEffect(() => {
|
||||
const want = `#/${machine ?? 'hub'}/${view}`
|
||||
if (location.hash !== want && !location.hash.startsWith(want + '/')) history.pushState(null, '', want)
|
||||
}, [machine, view])
|
||||
useEffect(() => {
|
||||
const h = async () => {
|
||||
const p = parseHash(); if (!p) return
|
||||
if (hasUnsaved() && (p.machine !== machine || p.view !== view)) { // Back/Forward away from unsaved work: stay put until answered
|
||||
const there = location.hash
|
||||
history.pushState(null, '', `#/${machine ?? 'hub'}/${view}`)
|
||||
if (!(await okToLeave())) return
|
||||
history.replaceState(null, '', there)
|
||||
}
|
||||
if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view)
|
||||
}
|
||||
window.addEventListener('popstate', h)
|
||||
return () => window.removeEventListener('popstate', h)
|
||||
}) // no deps: needs the latest switchMachine/view each time
|
||||
useEffect(() => {
|
||||
if (!machine) { setRemoteMe(null); return }
|
||||
let stop = false
|
||||
const tick = async () => { try { const m = await api.me(); if (!stop) { setRemoteMe(m); setRemoteErr('') } } catch (e) { if (!stop) setRemoteErr(e instanceof Error ? e.message : 'unreachable') } }
|
||||
tick()
|
||||
const t = setInterval(tick, listRate)
|
||||
return () => { stop = true; clearInterval(t) }
|
||||
}, [machine, listRate])
|
||||
|
||||
async function create() {
|
||||
setCreating(true)
|
||||
try {
|
||||
const t = await api.createTerminal()
|
||||
await reload()
|
||||
setActive(t.id)
|
||||
setView('terminal')
|
||||
setDrawer(false)
|
||||
} catch (e) { toast(`new terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
finally { setCreating(false) }
|
||||
}
|
||||
async function rename(t: Term) {
|
||||
const name = window.prompt('Rename terminal', t.name)
|
||||
if (name && name.trim() && name !== t.name) { await api.renameTerminal(t.id, name.trim()); await reload() }
|
||||
const name = await askText('Rename terminal', t.name)
|
||||
if (name && name.trim() && name !== t.name) {
|
||||
try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
}
|
||||
async function close(t: Term) {
|
||||
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
|
||||
await api.deleteTerminal(t.id)
|
||||
delete handles.current[t.id]
|
||||
await reload()
|
||||
if (t.running && !await askYes(`Close "${t.name}"? The shell will be hung up.`)) return
|
||||
try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
|
||||
catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
}
|
||||
async function logout() { await api.logout(); onLoggedOut() }
|
||||
|
||||
// Files → "open in terminal": type into the active shell (creating one if needed) and switch to it.
|
||||
async function runInTerminal(cmd: string) {
|
||||
let id = active
|
||||
if (!id || !terms.some((t) => t.id === id && t.running)) {
|
||||
try { const t = await api.createTerminal(); await reload(); id = t.id; setActive(t.id) } catch (e) { toast(`terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return }
|
||||
}
|
||||
setView('terminal'); setDrawer(false)
|
||||
const send = (tries: number) => { const h = handles.current[id!]; if (h) h.input(cmd + '\n'); else if (tries > 0) window.setTimeout(() => send(tries - 1), 100) }
|
||||
window.setTimeout(() => send(20), 50)
|
||||
}
|
||||
|
||||
// Tab order is the user's, kept per machine in this browser; new terminals join at the end.
|
||||
const orderKey = `hamlab.termorder.${machine ?? 'hub'}`
|
||||
const [order, setOrder] = useState<string[]>([])
|
||||
useEffect(() => { try { setOrder(JSON.parse(localStorage.getItem(orderKey) ?? '[]')) } catch { setOrder([]) } }, [orderKey])
|
||||
const ordered = useMemo(() => [...terms].sort((a, b) => { const i = order.indexOf(a.id), j = order.indexOf(b.id); return (i < 0 ? 1e9 : i) - (j < 0 ? 1e9 : j) }), [terms, order])
|
||||
const moveTab = (id: string, before: string | null) => {
|
||||
const ids = ordered.map((t) => t.id).filter((x) => x !== id)
|
||||
const at = before ? ids.indexOf(before) : -1
|
||||
ids.splice(at < 0 ? ids.length : at, 0, id)
|
||||
setOrder(ids); localStorage.setItem(orderKey, JSON.stringify(ids))
|
||||
}
|
||||
// Alt+Shift+←/→ switches tabs, Alt+Shift+T opens one. Caught before xterm sees the keys.
|
||||
useEffect(() => {
|
||||
if (view !== 'terminal') return
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return
|
||||
const dir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0
|
||||
if (dir && ordered.length > 1) { const i = ordered.findIndex((t) => t.id === active); setActive(ordered[(i + dir + ordered.length) % ordered.length].id) }
|
||||
else if (e.key.toLowerCase() === 't') create()
|
||||
else return
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
}
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}) // no deps: needs the latest tabs each time
|
||||
|
||||
const activeRef = useRef(active); activeRef.current = active
|
||||
const activeTerm = terms.find((t) => t.id === active)
|
||||
const termView = view === 'terminal'
|
||||
|
||||
return (
|
||||
<div className={'app' + (drawer ? ' drawer-open' : '')}>
|
||||
<Toasts />
|
||||
<header className="header">
|
||||
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
|
||||
<span className="brand">Ham Lab</span>
|
||||
<span className="host">{me.user}@{me.host}</span>
|
||||
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
|
||||
<span className="spacer" />
|
||||
<span className="stat" title="load average">{me.load}</span>
|
||||
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
|
||||
<button className="link" onClick={logout}>logout</button>
|
||||
<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'} />
|
||||
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
|
||||
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
||||
<Btn className="link" onClick={logout}>logout</Btn>
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
<Rail groups={GROUPS} terms={terms} active={active}
|
||||
onSelect={(id) => { setActive(id); setDrawer(false) }}
|
||||
onNew={create} onRename={rename} onClose={close} />
|
||||
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
|
||||
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main">
|
||||
{terms.length === 0 && (
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
{view === 'settings' && <Settings me={hubMe} />}
|
||||
{view === 'users' && <Users me={hubMe} />}
|
||||
{view === 'usage' && <Usage key={machine ?? ''} />}
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||
{view === 'widgets' && <Gallery />}
|
||||
{view === 'docker' && <Docker />}
|
||||
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
|
||||
{view === 'procs' && <Top />}
|
||||
{view === 'services' && <Services />}
|
||||
{view === 'jobs' && <Jobs />}
|
||||
{view === 'updates' && <Updates />}
|
||||
{view === 'disks' && <Disks />}
|
||||
{view === 'zfs' && <ZFS />}
|
||||
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
||||
{termView && <TermTabs terms={ordered} active={active} creating={creating}
|
||||
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)} />}
|
||||
{termView && terms.length === 0 && (
|
||||
<div className="center muted">
|
||||
<div>No terminals.</div>
|
||||
<button className="primary" onClick={create}>+ New Terminal</button>
|
||||
<Btn className="primary" busy={creating} onClick={create}>+ New Terminal</Btn>
|
||||
</div>
|
||||
)}
|
||||
{terms.map((t) => (
|
||||
<TerminalPane key={t.id} id={t.id} active={t.id === active}
|
||||
<TerminalPane key={t.id} id={t.id} active={termView && t.id === active}
|
||||
register={(h) => { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }}
|
||||
onStatus={(ok) => { if (t.id === active) setOnline(ok) }}
|
||||
onExit={reload} />
|
||||
@@ -86,14 +299,14 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
|
||||
{termView && activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
|
||||
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
|
||||
|
||||
<footer className="footer">
|
||||
<span>v{me.version}</span>
|
||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{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 === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
export interface SparkSeries { name: string; values: number[]; color: string }
|
||||
|
||||
interface Props {
|
||||
series: SparkSeries[]
|
||||
max?: number // fixed ceiling (e.g. 100 for percent); otherwise auto from data
|
||||
format: (v: number) => string
|
||||
height?: number
|
||||
}
|
||||
|
||||
const W = 300
|
||||
|
||||
// Thin-line sparkline with a crosshair tooltip. Stroke stays 2px at any width via
|
||||
// non-scaling-stroke; the box is stretched with preserveAspectRatio="none".
|
||||
export default function Sparkline({ series, max, format, height = 44 }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const n = Math.max(...series.map((s) => s.values.length), 0)
|
||||
if (n < 2) return <div className="spark" style={{ height }} />
|
||||
|
||||
let top = max ?? 0
|
||||
if (max === undefined) for (const s of series) for (const v of s.values) if (v > top) top = v
|
||||
if (top <= 0) top = 1
|
||||
const H = height
|
||||
const x = (i: number) => (i / (n - 1)) * W
|
||||
const y = (v: number) => H - 2 - (Math.min(v, top) / top) * (H - 4)
|
||||
const path = (vals: number[]) => {
|
||||
const off = n - vals.length
|
||||
return vals.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i + off).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
}
|
||||
|
||||
const move = (clientX: number) => {
|
||||
const r = box.current?.getBoundingClientRect()
|
||||
if (!r || r.width === 0) return
|
||||
const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width))
|
||||
setHover(Math.round(f * (n - 1)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={box} className="spark" style={{ height }}
|
||||
onMouseMove={(e) => move(e.clientX)} onMouseLeave={() => setHover(null)}
|
||||
onTouchStart={(e) => move(e.touches[0].clientX)} onTouchMove={(e) => move(e.touches[0].clientX)} onTouchEnd={() => setHover(null)}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
|
||||
<line x1={0} x2={W} y1={H - 2} y2={H - 2} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
{series.map((s) => (
|
||||
<path key={s.name} d={path(s.values)} fill="none" stroke={s.color} strokeWidth={2}
|
||||
strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{hover !== null && (
|
||||
<line x1={x(hover)} x2={x(hover)} y1={0} y2={H} stroke="var(--muted)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
</svg>
|
||||
{hover !== null && (
|
||||
<div className="tip" style={{ left: `${(hover / (n - 1)) * 100}%` }}>
|
||||
{series.map((s) => {
|
||||
const v = s.values[hover - (n - s.values.length)]
|
||||
return v === undefined ? null : <span key={s.name}><span className="sw" style={{ background: s.color }} />{s.name} {format(v)} </span>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// A new stack — written as compose, or filled in as a form that writes the compose for you.
|
||||
// "New container" is the same dialog opened on the form: a container made here is a
|
||||
// one-service stack, so it can be edited, redeployed and updated like everything else on the
|
||||
// page, where a bare `docker run` could only ever be thrown away and typed again.
|
||||
// "Duplicate" is the same dialog again, opened on an existing stack's compose text.
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api, StackHome } from './api'
|
||||
import { askYes, Btn, Loading, useEscape } from './ui'
|
||||
|
||||
interface Form { image: string; ports: string; volumes: string; env: string; labels: string; restart: string; command: string; network: string }
|
||||
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', labels: '', restart: 'unless-stopped', command: '', network: '' }
|
||||
const START = 'services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n'
|
||||
const lines = (t: string) => t.split('\n').map((x) => x.trim()).filter(Boolean)
|
||||
const q = (v: string) => JSON.stringify(v) // a JSON string is a valid YAML string, with every awkward character already dealt with
|
||||
const dollars = (v: string) => v.replace(/\$/g, '$$$$') // a literal $ would be read by compose as a variable: doubled, it is a dollar again
|
||||
|
||||
// network: '' is compose's default (a network of the stack's own), 'host' is the machine's, anything
|
||||
// else is a network that already exists — joined, and declared external so compose doesn't make another.
|
||||
export function composeOf(name: string, f: Form): string {
|
||||
const svc = name || 'app'
|
||||
const out = ['services:', ` ${svc}:`, ` image: ${q(f.image.trim() || 'nginx:alpine')}`, ` container_name: ${q(svc)}`]
|
||||
if (f.restart !== 'no') out.push(` restart: ${f.restart}`)
|
||||
if (f.command.trim()) out.push(` command: ${q(f.command.trim())}`)
|
||||
if (f.network === 'host') out.push(' network_mode: host')
|
||||
else if (lines(f.ports).length) out.push(' ports:', ...lines(f.ports).map((p) => ` - ${q(p)}`))
|
||||
if (f.network && f.network !== 'host') out.push(' networks:', ` - ${q(f.network)}`)
|
||||
if (lines(f.volumes).length) out.push(' volumes:', ...lines(f.volumes).map((v) => ` - ${q(v)}`))
|
||||
if (lines(f.env).length) out.push(' environment:', ...lines(f.env).map((e) => ` - ${q(dollars(e))}`))
|
||||
if (lines(f.labels).length) out.push(' labels:', ...lines(f.labels).map((l) => ` - ${q(dollars(l))}`))
|
||||
if (f.network && f.network !== 'host') out.push('networks:', ` ${q(f.network)}:`, ' external: true')
|
||||
return out.join('\n') + '\n'
|
||||
}
|
||||
|
||||
// the last two folders say which home it is; the whole path is written out under the box
|
||||
const tail = (dir: string) => '…/' + dir.split('/').filter(Boolean).slice(-2).join('/')
|
||||
|
||||
export interface DupFrom { name: string; text: string; hasEnv: boolean; files: number }
|
||||
export interface Taken { names: string[]; ports: Record<string, string> } // container names in use · published host port → who has it
|
||||
|
||||
interface Props { mode: 'stack' | 'container'; from?: DupFrom; networks?: string[]; taken?: Taken; onClose: () => void; onCreated: (name: string, deploy: boolean) => void }
|
||||
|
||||
export default function StackNew({ mode, from, networks = [], taken, onClose, onCreated }: Props) {
|
||||
const startName = from ? `${from.name}-copy`.slice(0, 63) : ''
|
||||
const startText = from?.text ?? START
|
||||
const [homes, setHomes] = useState<StackHome[] | null>(null)
|
||||
const [home, setHome] = useState('')
|
||||
const [name, setName] = useState(startName)
|
||||
const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose')
|
||||
const [form, setForm] = useState<Form>(EMPTY)
|
||||
const [text, setText] = useState(startText)
|
||||
const [env, setEnv] = useState('')
|
||||
const [copyEnv, setCopyEnv] = useState(!!from?.hasEnv)
|
||||
const [err, setErr] = useState('')
|
||||
const [seeded, setSeeded] = useState('') // what the form last wrote into the compose box
|
||||
// a stray tap outside, or Escape, must not throw away a compose file someone has typed
|
||||
const typed = name !== startName || !!env || text !== startText || JSON.stringify(form) !== JSON.stringify(EMPTY)
|
||||
const close = async () => { if (!typed || (await askYes('Discard this new ' + mode + '? What you have typed is lost.', 'Discard'))) onClose() }
|
||||
useEscape(close)
|
||||
useEffect(() => { api.stackHomes().then((r) => { const hs = r.homes ?? []; setHomes(hs); setHome(hs.find((h) => h.default)?.dir ?? hs[0]?.dir ?? '') }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
const okName = /^[a-z0-9][a-z0-9_-]{0,62}$/.test(name)
|
||||
const built = useMemo(() => composeOf(name, form), [name, form])
|
||||
const compose = how === 'form' ? built : text
|
||||
const picked = homes?.find((h) => h.dir === home)
|
||||
// What would collide with something already running. A warning, not a refusal: the stack may be
|
||||
// meant to replace that container, and "Create only" collides with nothing.
|
||||
const clashes = useMemo(() => {
|
||||
if (!taken) return []
|
||||
const out: string[] = []
|
||||
for (const m of compose.matchAll(/^\s*container_name:\s*["']?([^"'\s#]+)/gm)) if (taken.names.includes(m[1])) out.push(`a container called ${m[1]} already exists`)
|
||||
if (!/^\s*network_mode:\s*["']?host/m.test(compose)) for (const m of compose.matchAll(/^\s*-\s*["']?(?:\d{1,3}(?:\.\d{1,3}){3}:)?(\d+):\d+/gm)) if (taken.ports[m[1]]) out.push(`port ${m[1]} is already published by ${taken.ports[m[1]]}`)
|
||||
return [...new Set(out)]
|
||||
}, [compose, taken])
|
||||
const create = (deploy: boolean) => async () => {
|
||||
try { await api.createStack({ name, text: compose, env: from && copyEnv ? '' : env, home, env_from: from && copyEnv ? from.name : undefined }); onCreated(name, deploy) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
// The form writes the compose box only when the form has something new to say; text edited by
|
||||
// hand since is asked about, never silently replaced.
|
||||
const toCompose = async () => {
|
||||
if (how === 'form' && form.image.trim() && built !== seeded) {
|
||||
const mine = text !== startText && text !== seeded
|
||||
if (!mine || (await askYes('Replace the compose text you edited with what the form says now?', 'Replace'))) { setText(built); setSeeded(built) }
|
||||
}
|
||||
setHow('compose')
|
||||
}
|
||||
const set = (k: keyof Form) => (e: { target: { value: string } }) => setForm({ ...form, [k]: e.target.value })
|
||||
const sameTail = (h: StackHome) => (homes ?? []).filter((o) => tail(o.dir) === tail(h.dir)).length > 1
|
||||
return (
|
||||
<div className="modal-back" onClick={close}>
|
||||
<div className="modal stack-new" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{from ? `Duplicate ${from.name}` : mode === 'container' ? 'New container' : 'New stack'}</h3>
|
||||
{mode === 'container' && <div className="muted small">Made as a one-service stack, so you can edit it, redeploy it and update its image later from the Stacks tab.</div>}
|
||||
{from && <div className="muted small">A new stack that starts as a copy of {from.name}'s compose file{from.files > 1 ? ` (its first file only: ${from.name} is made of ${from.files})` : ''}. {from.name} itself is not changed. Volumes and bind-mounted folders named in it would be <b>shared</b> with the original unless you change them.</div>}
|
||||
{!homes && !err && <Loading label="Finding where this machine keeps its stacks…" />}
|
||||
{homes && homes.length === 0 && <div className="notice">Docker isn't installed on this machine.</div>}
|
||||
{homes && homes.length > 0 && <>
|
||||
<label className="muted">Name
|
||||
<input autoFocus className="mono" value={name} onChange={(e) => { setName(e.target.value.toLowerCase()); setErr('') }} placeholder={mode === 'container' ? 'whoami' : 'my-stack'} maxLength={63} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
</label>
|
||||
{name && !okName && <div className="error">lower-case letters, digits, - and _ — starting with a letter or digit</div>}
|
||||
{homes.length > 1 && <label className="muted">Where
|
||||
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{sameTail(h) ? h.dir : tail(h.dir)} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
|
||||
</label>}
|
||||
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
|
||||
{!from && <div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={toCompose}>Compose</button></div>}
|
||||
{how === 'form' ? <>
|
||||
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
|
||||
<div className="two">
|
||||
<label className="muted">Ports — host:container, one per line<textarea className="mono" rows={2} value={form.ports} onChange={set('ports')} placeholder={'8080:80'} disabled={form.network === 'host'} spellCheck={false} /></label>
|
||||
<label className="muted">Volumes — host path or name : container path<textarea className="mono" rows={2} value={form.volumes} onChange={set('volumes')} placeholder={'/home/core/apps/whoami:/data'} spellCheck={false} /></label>
|
||||
</div>
|
||||
<div className="two">
|
||||
<label className="muted">Environment — KEY=value, one per line<textarea className="mono" rows={2} value={form.env} onChange={set('env')} placeholder={'TZ=America/Los_Angeles'} spellCheck={false} /></label>
|
||||
<label className="muted">Labels — key=value, one per line<textarea className="mono" rows={2} value={form.labels} onChange={set('labels')} placeholder={'com.example.owner=mitchell'} spellCheck={false} /></label>
|
||||
</div>
|
||||
<div className="two">
|
||||
<label className="muted">Restart<select value={form.restart} onChange={set('restart')}><option value="unless-stopped">unless-stopped</option><option value="always">always</option><option value="on-failure">on-failure</option><option value="no">no</option></select></label>
|
||||
<label className="muted">Network<select value={form.network} onChange={set('network')}><option value="">its own (the default)</option><option value="host">host — this machine's network; ports don't apply</option>{networks.map((n) => <option key={n} value={n}>join {n}</option>)}</select></label>
|
||||
</div>
|
||||
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
|
||||
<pre className="unit-file stack-preview">{built}</pre>
|
||||
</> : <>
|
||||
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
{from?.hasEnv && <label className="check"><input type="checkbox" checked={copyEnv} onChange={(e) => setCopyEnv(e.target.checked)} /> <span>Copy {from.name}'s .env too <span className="muted">— copied on the machine itself; its values are never shown here</span></span></label>}
|
||||
{!(from && copyEnv) && <label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
|
||||
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
|
||||
</label>}
|
||||
</>}
|
||||
<div className="modal-foot">
|
||||
{clashes.length > 0 && <div className="clash">Deploying this as it stands would fail: {clashes.join('; ')}.</div>}
|
||||
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(true)}>Create and deploy</Btn>
|
||||
<Btn className="btn" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(false)}>Create only</Btn>
|
||||
<span className="spacer" /><button className="btn" onClick={close}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
{err && !homes && <div className="error">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, TaskInfo, wsURLPath } from './api'
|
||||
import { Btn, Spinner } from './ui'
|
||||
|
||||
// Follows one background task: backlog, then live chunks over a WebSocket, until it exits.
|
||||
export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: TaskInfo) => void }) {
|
||||
const [task, setTask] = useState<TaskInfo | null>(null)
|
||||
const [text, setText] = useState('')
|
||||
const [connected, setConnected] = useState(false)
|
||||
const box = useRef<HTMLPreElement>(null)
|
||||
const doneRef = useRef(onDone)
|
||||
doneRef.current = onDone
|
||||
|
||||
useEffect(() => {
|
||||
setText(''); setTask(null); setConnected(false)
|
||||
api.task(id).then(setTask).catch(() => { /* shown as unknown */ })
|
||||
const s = new WebSocket(wsURLPath(`/api/tasks/${id}/stream`))
|
||||
s.binaryType = 'arraybuffer'
|
||||
const dec = new TextDecoder()
|
||||
s.onopen = () => setConnected(true)
|
||||
s.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) { setText((t) => t + dec.decode(ev.data, { stream: true })); return }
|
||||
try {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.type === 'exit') api.task(id).then((t) => { setTask(t); doneRef.current?.(t) }).catch(() => { /* ignore */ })
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return () => s.close()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { if (box.current) box.current.scrollTop = box.current.scrollHeight }, [text])
|
||||
|
||||
const running = !task || task.running
|
||||
const failed = !!task && !task.running && (task.exit_code !== 0 || !!task.error)
|
||||
const cancelled = task?.error === 'cancelled'
|
||||
// how it ended is a badge, legible at a glance: nobody should have to read compose's output to learn a deploy failed
|
||||
const verdict = !task || task.running ? '' : cancelled ? 'cancelled' : task.error ? 'failed' : task.exit_code === 0 ? 'finished' : `failed · exit ${task.exit_code}`
|
||||
const status = !task ? 'starting…' : task.running ? 'running…' : task.error && !cancelled ? task.error : ''
|
||||
return (
|
||||
<div className={'task' + (running ? ' running' : cancelled ? ' done' : failed ? ' failed' : ' done')}>
|
||||
<div className="logs-bar">
|
||||
{running ? <Spinner /> : <span className={'dot ' + (failed ? 'bad' : 'ok')} />}
|
||||
<span>{task?.title ?? '…'}</span>
|
||||
{verdict && <span className={'badge ' + (failed && !cancelled ? 'bad' : cancelled ? '' : 'ok')}>{verdict}</span>}
|
||||
{status && <span className="muted">· {status}</span>}
|
||||
<span className="spacer" />
|
||||
{task?.running && <Btn className="btn" onClick={() => api.cancelTask(id)}>Cancel</Btn>}
|
||||
</div>
|
||||
<pre className="run-out task-out" ref={box}>{text || (running ? (connected ? 'waiting for output…' : 'connecting…') : '(no output)')}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Term } from './api'
|
||||
import { Spinner } from './ui'
|
||||
|
||||
interface Props {
|
||||
terms: Term[]; active: string | null; creating?: boolean
|
||||
onSelect: (id: string) => void; onNew: () => void
|
||||
onRename: (t: Term) => void; onClose: (t: Term) => void
|
||||
onMove: (id: string, before: string | null) => void // put a tab before another, or (null) at the end
|
||||
onFocusTerm: () => void
|
||||
}
|
||||
|
||||
// Tab strip across the top of the terminal page: one tab per session, "+" at the end.
|
||||
export default function TermTabs({ terms, active, creating, onSelect, onNew, onRename, onClose, onMove, onFocusTerm }: Props) {
|
||||
const [dragging, setDragging] = useState<string | null>(null)
|
||||
const [over, setOver] = useState<string | null>(null)
|
||||
const strip = useRef<HTMLDivElement>(null)
|
||||
// keep the active tab in sight when the strip overflows
|
||||
useEffect(() => { strip.current?.querySelector('.ttab.active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }, [active, terms.length])
|
||||
return (
|
||||
// Buttons must not take focus from the terminal; the tabs themselves must stay draggable, which a
|
||||
// prevented mousedown would stop — so a tab click hands focus back afterwards instead.
|
||||
<div className="term-tabs" ref={strip} onMouseDown={(e) => { if ((e.target as HTMLElement).closest('button')) e.preventDefault() }}>
|
||||
{terms.map((t, i) => {
|
||||
const sel = t.id === active
|
||||
return (
|
||||
<div key={t.id} className={'ttab' + (sel ? ' active' : '') + (t.running ? '' : ' exited') + (over === t.id && dragging !== t.id ? ' drop' : '')}
|
||||
draggable onDragStart={(e) => { setDragging(t.id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', t.id) }}
|
||||
onDragOver={(e) => { if (dragging) { e.preventDefault(); setOver(t.id) } }} onDragLeave={() => setOver((o) => (o === t.id ? null : o))}
|
||||
onDrop={(e) => { e.preventDefault(); if (dragging && dragging !== t.id) onMove(dragging, t.id); setDragging(null); setOver(null); onFocusTerm() }} onDragEnd={() => { setDragging(null); setOver(null) }}
|
||||
onClick={() => { onSelect(t.id); onFocusTerm() }} onDoubleClick={() => onRename(t)}
|
||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t) } }}>
|
||||
<span className={'sdot' + (t.running ? ' run' : '')} />
|
||||
<span className="tname" title={t.name}>{t.name}</span>
|
||||
{sel && terms.length > 1 && <span className="ttab-move">
|
||||
<button className="icon-btn" title="move left" disabled={i === 0} onClick={(e) => { e.stopPropagation(); onMove(t.id, terms[i - 1].id) }}>‹</button>
|
||||
<button className="icon-btn" title="move right" disabled={i === terms.length - 1} onClick={(e) => { e.stopPropagation(); onMove(t.id, terms[i + 2]?.id ?? null) }}>›</button>
|
||||
</span>}
|
||||
{sel && <button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>}
|
||||
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button className="ttab new" title="new terminal (Alt+Shift+T) · Alt+Shift+←/→ switches tabs" disabled={creating} onClick={onNew}>{creating ? <Spinner /> : '+'}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { wsURL } from './api'
|
||||
import { Spinner } from './ui'
|
||||
|
||||
export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void }
|
||||
|
||||
@@ -13,13 +15,7 @@ interface Props {
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
const THEME = {
|
||||
background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78',
|
||||
black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff',
|
||||
magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198',
|
||||
brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
||||
}
|
||||
import { currentThemeId, themeById } from './themes'
|
||||
|
||||
export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
@@ -28,11 +24,12 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
const cbs = useRef({ onStatus, onExit })
|
||||
cbs.current = { onStatus, onExit }
|
||||
const fitRef = useRef<() => void>(() => {})
|
||||
const [conn, setConn] = useState<'connecting' | 'open' | 'reconnecting' | 'exited'>('connecting')
|
||||
|
||||
useEffect(() => {
|
||||
const el = box.current!
|
||||
const term = new Terminal({
|
||||
theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
theme: themeById(currentThemeId()).xterm, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14,
|
||||
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
|
||||
})
|
||||
@@ -49,7 +46,8 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
let lastSize = ''
|
||||
const enc = new TextEncoder()
|
||||
|
||||
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)) }
|
||||
const queued: string[] = []
|
||||
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)); else queued.push(s) }
|
||||
const sendResize = () => {
|
||||
const key = `${term.cols}x${term.rows}`
|
||||
if (key === lastSize) return
|
||||
@@ -62,30 +60,54 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
sendResize()
|
||||
}
|
||||
|
||||
// The address is fixed when the pane opens, not rebuilt on each attempt: it carries which
|
||||
// machine this terminal belongs to, and the console may have moved to another machine
|
||||
// before a reconnect fires — which had the hub's terminals being asked for on a node.
|
||||
const url = wsURL(id)
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
const sock = new WebSocket(wsURL(id))
|
||||
const sock = new WebSocket(url)
|
||||
sock.binaryType = 'arraybuffer'
|
||||
ws = sock
|
||||
sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() }
|
||||
sock.onopen = () => { retry = 0; setConn('open'); cbs.current.onStatus(true); lastSize = ''; doFit(); while (queued.length) sock.send(enc.encode(queued.shift()!)) }
|
||||
sock.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
|
||||
try {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; cbs.current.onExit() }
|
||||
if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; setConn('exited'); cbs.current.onExit() }
|
||||
if (m.type === 'closed') closed = true
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
sock.onclose = () => {
|
||||
if (ws !== sock) return
|
||||
if (ws !== sock || closed) return
|
||||
cbs.current.onStatus(false)
|
||||
if (closed) return
|
||||
setConn('reconnecting')
|
||||
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
|
||||
}
|
||||
sock.onerror = () => sock.close()
|
||||
}
|
||||
connect()
|
||||
|
||||
// Ctrl+C / Ctrl+V as on any desktop. Returning false tells xterm to leave the key alone,
|
||||
// and *not* preventing the default lets the browser fire its own copy / paste event —
|
||||
// which xterm answers with its selection, or pastes (bracketed, if the shell asked). That
|
||||
// path needs no navigator.clipboard, so it works over plain http on the LAN as well.
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== 'keydown') return true
|
||||
const k = e.key.toLowerCase(), ctrl = e.ctrlKey && !e.altKey && !e.metaKey
|
||||
const copy = (ctrl && k === 'c' && (e.shiftKey || term.hasSelection())) || (ctrl && k === 'insert')
|
||||
const paste = (ctrl && k === 'v') || (e.shiftKey && !ctrl && k === 'insert')
|
||||
if (copy) {
|
||||
if (!term.hasSelection()) { e.preventDefault(); return false } // Ctrl+Shift+C with nothing selected: not devtools, not ^C
|
||||
if (e.shiftKey) { e.preventDefault(); document.execCommand('copy') } // Chrome keeps Ctrl+Shift+C for itself; copy by hand
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(term.getSelection()).catch(() => {})
|
||||
window.setTimeout(() => term.clearSelection(), 50) // so the next Ctrl+C is an interrupt again
|
||||
return false
|
||||
}
|
||||
if (paste) return false
|
||||
return true
|
||||
})
|
||||
|
||||
term.onData((d) => {
|
||||
if (ctrlArmed && d.length === 1) {
|
||||
ctrlArmed = false
|
||||
@@ -103,6 +125,9 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } }
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
|
||||
const onTheme = (e: Event) => { term.options.theme = (e as CustomEvent).detail.xterm }
|
||||
window.addEventListener('hamlab:theme', onTheme)
|
||||
|
||||
register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } })
|
||||
|
||||
return () => {
|
||||
@@ -110,6 +135,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
closed = true
|
||||
clearTimeout(retryTimer)
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
window.removeEventListener('hamlab:theme', onTheme)
|
||||
ro.disconnect()
|
||||
ws?.close()
|
||||
term.dispose()
|
||||
@@ -124,5 +150,12 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
|
||||
return () => clearTimeout(t)
|
||||
}, [active])
|
||||
|
||||
return <div ref={box} className="term" style={{ display: active ? 'block' : 'none' }} />
|
||||
return (
|
||||
<div className="term" style={{ display: active ? 'block' : 'none' }}>
|
||||
<div ref={box} className="term-inner" />
|
||||
{(conn === 'connecting' || conn === 'reconnecting') && (
|
||||
<div className="term-overlay"><Spinner /> {conn === 'connecting' ? 'connecting…' : 'reconnecting…'}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
// A whole machine on one screen, drawn the way btop draws it: black ground, boxes with their
|
||||
// name set into the border, and every curve made of coloured dots that go green → yellow → red
|
||||
// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and
|
||||
// /api/procs for the table — so this works on any machine the console can see, whether or not
|
||||
// btop is installed on it.
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, ProcTable, Proc, Sample } from './api'
|
||||
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
|
||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
||||
import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySec, useRefreshMs } from './prefs'
|
||||
import { Loading, askYes, toast } from './ui'
|
||||
import { Theme, currentThemeId, themeById } from './themes'
|
||||
import { columnsOf } from './graphcols'
|
||||
import { savePrefs } from './accents'
|
||||
|
||||
const MAX = 300 // points kept per curve, as the machines keep them
|
||||
|
||||
// ---- the palette: the skin's own terminal colours ----
|
||||
// Every skin carries sixteen terminal colours chosen to be read on its terminal ground, which
|
||||
// is exactly what this page is: so the ground is the skin's --term-bg and every curve and
|
||||
// meter is built from its green, yellow, red, blue, magenta and cyan. Dark skins come out
|
||||
// looking like btop; the light ones like btop on paper.
|
||||
type GradName = 'cpu' | 'used' | 'avail' | 'cached' | 'free' | 'down' | 'up' | 'temp' | 'io'
|
||||
type Palette = Record<GradName, string[]> & { vars: Record<string, string> }
|
||||
function paletteOf(t: Theme): Palette {
|
||||
const x = t.xterm as Record<string, string>
|
||||
const c = (k: string, fb: string) => x[k] || fb
|
||||
const green = c('green', '#00d35c'), yellow = c('yellow', '#c8e000'), red = c('red', '#ff3c3c'), blue = c('blue', '#4c8cff'), magenta = c('magenta', '#c850ff'), cyan = c('cyan', '#50d0ff')
|
||||
return {
|
||||
cpu: [green, yellow, red],
|
||||
used: [red, c('brightRed', red), yellow],
|
||||
avail: [yellow, c('brightYellow', yellow), c('brightYellow', yellow)],
|
||||
cached: [blue, c('brightBlue', blue), cyan],
|
||||
free: [green, c('brightGreen', green), c('brightGreen', green)],
|
||||
down: [magenta, c('brightMagenta', magenta), c('brightMagenta', magenta)],
|
||||
up: [blue, c('brightBlue', blue), cyan],
|
||||
temp: [blue, c('brightBlue', blue), cyan],
|
||||
io: [yellow, c('brightYellow', yellow), red],
|
||||
vars: { '--t-green': green, '--t-blue': blue, '--t-magenta': magenta, '--t-red': red },
|
||||
}
|
||||
}
|
||||
function usePalette(): Palette {
|
||||
const [pal, setPal] = useState(() => paletteOf(themeById(currentThemeId())))
|
||||
useEffect(() => {
|
||||
const h = (e: Event) => setPal(paletteOf((e as CustomEvent<Theme>).detail))
|
||||
window.addEventListener('hamlab:theme', h)
|
||||
return () => window.removeEventListener('hamlab:theme', h)
|
||||
}, [])
|
||||
return pal
|
||||
}
|
||||
const Pal = createContext<Palette>(paletteOf(themeById('dark')))
|
||||
|
||||
// mix(a, b, t) on hex, so a dot's colour can fall anywhere along a three-stop gradient
|
||||
function rgbOf(h: string): number[] {
|
||||
if (h.startsWith('#')) { const x = h.length === 4 ? h.replace(/#(.)(.)(.)/, '#$1$1$2$2$3$3') : h; return [0, 1, 2].map((i) => parseInt(x.slice(1 + i * 2, 3 + i * 2), 16)) }
|
||||
const m = h.match(/[\d.]+/g); return m ? m.slice(0, 3).map(Number) : [128, 128, 128]
|
||||
}
|
||||
function mix(a: string, b: string, t: number): string {
|
||||
const x = rgbOf(a), y = rgbOf(b)
|
||||
const c = [0, 1, 2].map((i) => Math.round(x[i] + (y[i] - x[i]) * t))
|
||||
return `rgb(${c[0]},${c[1]},${c[2]})`
|
||||
}
|
||||
function at(g: readonly string[], t: number): string {
|
||||
const x = Math.max(0, Math.min(1, t)) * (g.length - 1)
|
||||
const i = Math.min(g.length - 2, Math.floor(x))
|
||||
return mix(g[i], g[i + 1], x - i)
|
||||
}
|
||||
|
||||
// ---- the dot graph ----
|
||||
// One dot column per sample, filled from the bottom, each dot coloured by how high it sits.
|
||||
function Graph({ values, tick, advance, cap, max, grad, flip = false }: { values: number[]; tick: number; advance: number; cap: number; max?: number; grad: GradName; flip?: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const pal = useContext(Pal)
|
||||
const [[w, height], setSize] = useState([0, 0])
|
||||
useEffect(() => {
|
||||
if (!box.current) return
|
||||
const ro = new ResizeObserver((e) => setSize([Math.floor(e[0].contentRect.width), Math.floor(e[0].contentRect.height)]))
|
||||
ro.observe(box.current)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const c = ref.current
|
||||
if (!c || w < 4 || height < 4) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
c.width = Math.round(w * dpr); c.height = Math.round(height * dpr)
|
||||
const g = c.getContext('2d')!
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
g.clearRect(0, 0, w, height)
|
||||
const step = 3, rows = Math.max(1, Math.floor(height / step)), cols = Math.max(1, Math.floor(w / step))
|
||||
// The box is the whole window, always: `cap` points wide (what the window holds at this
|
||||
// rate) whatever the box measures, so a full history fills it edge to edge and a machine
|
||||
// that has only just started draws in from the right, as btop does. Which points each
|
||||
// column draws — the peak of its own fixed slice of the history — is graphcols.ts.
|
||||
const seen = columnsOf(values, advance, cap, cols)
|
||||
const top = max ?? Math.max(1, ...seen)
|
||||
const stops = pal[grad]
|
||||
for (let i = 0; i < seen.length; i++) {
|
||||
// anything above nothing lights the bottom row: the curve keeps a baseline, as btop's does
|
||||
const lit = seen[i] > 0 ? Math.max(1, Math.round((seen[i] / top) * rows)) : 0
|
||||
const x = w - (seen.length - i) * step
|
||||
for (let r = 0; r < lit; r++) {
|
||||
const t = rows > 1 ? r / (rows - 1) : 1
|
||||
g.fillStyle = at(stops, t)
|
||||
const y = flip ? r * step + 1 : height - (r + 1) * step + 1
|
||||
g.fillRect(x, y, 2, 2)
|
||||
}
|
||||
}
|
||||
}, [values, tick, advance, cap, w, height, max, grad, flip, pal]) // tick: the curve is appended to in place
|
||||
return <div className="tg" ref={box} data-n={values.length}><canvas ref={ref} style={{ width: '100%', height: '100%' }} /></div>
|
||||
}
|
||||
|
||||
// ---- the dotted meter ----
|
||||
function Meter({ pct, grad }: { pct: number; grad: GradName }) {
|
||||
const p = Math.max(0, Math.min(100, pct))
|
||||
const g = useContext(Pal)[grad]
|
||||
return (
|
||||
<span className="tm">
|
||||
<i className="tm-t" />
|
||||
<i className="tm-f" style={{ background: `linear-gradient(90deg, ${g[0]}, ${g[1]}, ${g[2]})`, clipPath: `inset(0 ${100 - p}% 0 0)` }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- a box, with its name set into the border ----
|
||||
function Box({ n, name, right, className = '', children }: { n?: number; name: string; right?: React.ReactNode; className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className={'tbox ' + className}>
|
||||
<span className="tbox-name">{n !== undefined && <b>{'¹²³⁴⁵⁶'[n - 1]}</b>}{name}</span>
|
||||
{right !== undefined && <span className="tbox-right">{right}</span>}
|
||||
<div className="tbox-in">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- formatting, btop's short units ----
|
||||
const K = 1024
|
||||
function bytes(b: number, digits = 2): string {
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
|
||||
let i = 0
|
||||
while (b >= K && i < u.length - 1) { b /= K; i++ }
|
||||
return `${i === 0 ? Math.round(b) : b.toFixed(digits)} ${u[i]}`
|
||||
}
|
||||
function shortBytes(b: number): string { // 720M, 9.4M, 0B — the process table's width is precious
|
||||
const u = ['B', 'K', 'M', 'G', 'T']
|
||||
let i = 0
|
||||
while (b >= K && i < u.length - 1) { b /= K; i++ }
|
||||
return `${b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1)}${u[i]}`
|
||||
}
|
||||
function bits(bps: number): string {
|
||||
const u = ['bps', 'Kibps', 'Mibps', 'Gibps']
|
||||
let b = bps * 8, i = 0
|
||||
while (b >= K && i < u.length - 1) { b /= K; i++ }
|
||||
return `${i === 0 ? Math.round(b) : b.toFixed(2)} ${u[i]}`
|
||||
}
|
||||
function rate(bps: number): string {
|
||||
const u = ['B/s', 'KiB/s', 'MiB/s', 'GiB/s']
|
||||
let b = bps, i = 0
|
||||
while (b >= K && i < u.length - 1) { b /= K; i++ }
|
||||
return `${i === 0 ? Math.round(b) : b.toFixed(2)} ${u[i]}`
|
||||
}
|
||||
const pad = (s: string | number, n: number) => String(s).padStart(n)
|
||||
function upfor(sec: number): string {
|
||||
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60), s = Math.floor(sec % 60)
|
||||
return (d ? `${d}d ` : '') + `${pad(h, 2)}:${pad(m, 2)}${d ? '' : ':' + pad(s, 2)}`.replace(/ /g, '0')
|
||||
}
|
||||
|
||||
// A per-process curve, as braille: cheap enough to draw one for every row, every tick.
|
||||
const BR = [0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80] // dot order: (col,row) → bit
|
||||
function braille(values: number[], cells = 7, max = 100): string {
|
||||
const cols = cells * 2
|
||||
const seen = values.slice(-cols)
|
||||
const top = Math.max(1, max)
|
||||
let out = ''
|
||||
for (let c = 0; c < cells; c++) {
|
||||
let bitsOn = 0
|
||||
for (let half = 0; half < 2; half++) {
|
||||
const i = seen.length - cols + c * 2 + half
|
||||
const v = i >= 0 && i < seen.length ? seen[i] : 0
|
||||
const lit = v > 0 ? Math.max(1, Math.min(4, Math.round((v / top) * 4))) : 0
|
||||
for (let r = 0; r < lit; r++) bitsOn |= BR[half * 4 + (3 - r)]
|
||||
}
|
||||
out += String.fromCharCode(0x2800 + bitsOn)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the curves this page keeps ----
|
||||
const CURVES = 'cpu.usage,cpu.core.*.usage,net.*.rx,net.*.tx,disk.*.util'
|
||||
|
||||
function useCurves(live: Values | null, t: number, refreshMs: number, historySec: number, resumed: number) {
|
||||
const cur = useRef<Map<string, number[]>>(new Map())
|
||||
const lastT = useRef(0)
|
||||
const binN = useRef(1)
|
||||
const adv = useRef(0) // the number of the newest point, counted since the page opened: what a graph's columns are cut by
|
||||
const [ticks, bump] = useState(0)
|
||||
const seed = useCallback(async () => {
|
||||
try {
|
||||
const hs = await fetchHistories(CURVES)
|
||||
const m = new Map<string, number[]>()
|
||||
for (const h of hs) m.set(h.id, h.v.slice(-MAX))
|
||||
// The stride is timed on this clock only. The history's own timestamps are the machine's:
|
||||
// measured against them, a viewer whose clock runs minutes slow would wait those minutes
|
||||
// before its curves gained a single new point.
|
||||
cur.current = m; lastT.current = Date.now(); binN.current = 1
|
||||
bump((x) => x + 1)
|
||||
} catch { /* the curves fill in live instead */ }
|
||||
}, [])
|
||||
useEffect(() => { seed() }, [seed, resumed, historySec]) // a new window is a new spacing: start from what the machine now keeps
|
||||
useEffect(() => {
|
||||
if (!live || !t) return
|
||||
const { fresh, start } = nextPoint(t, lastT.current, strideMs(historySec, refreshMs), refreshMs)
|
||||
binN.current = fresh ? 1 : binN.current + 1
|
||||
lastT.current = start
|
||||
if (fresh) adv.current++
|
||||
const n = binN.current
|
||||
for (const [id, v] of Object.entries(live)) {
|
||||
if (typeof v !== 'number') continue
|
||||
if (!/^cpu\.usage$|^cpu\.core\.\d+\.usage$|^net\..+\.(rx|tx)$|^disk\..+\.util$/.test(id)) continue
|
||||
const arr = cur.current.get(id) ?? []
|
||||
if (!cur.current.has(id)) cur.current.set(id, arr)
|
||||
if (fresh || arr.length === 0) { arr.push(v); if (arr.length > MAX) arr.shift() }
|
||||
else arr[arr.length - 1] += (v - arr[arr.length - 1]) / n
|
||||
}
|
||||
bump((x) => x + 1)
|
||||
}, [live, t, refreshMs, historySec])
|
||||
return [cur.current, ticks, adv.current] as const
|
||||
}
|
||||
|
||||
// ---- the page ----
|
||||
export default function Top() {
|
||||
const refreshMs = useRefreshMs()
|
||||
const historySec = useHistorySec()
|
||||
const [clock, setClock] = useState(() => new Date())
|
||||
useEffect(() => { const i = window.setInterval(() => setClock(new Date()), 1000); return () => window.clearInterval(i) }, [])
|
||||
|
||||
const catalog = useRef<Catalog>(new Map())
|
||||
const refetching = useRef(false)
|
||||
const [ready, setReady] = useState(false)
|
||||
const stream = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
|
||||
useEffect(() => { fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])); setReady(true) }).catch(() => setReady(true)) }, [])
|
||||
const values = useMemo(() => (stream.data ? mergeValues(stream.data) : null), [stream.data])
|
||||
useEffect(() => {
|
||||
if (!values || catalog.current.size === 0 || refetching.current || !stale(values, catalog.current)) return
|
||||
refetching.current = true
|
||||
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
|
||||
}, [values])
|
||||
const s: Sample | null = useMemo(() => (values && ready ? sampleOf(values, catalog.current) : null), [values, ready])
|
||||
const [curves, tick, advance] = useCurves(values, s?.t ?? 0, refreshMs, historySec, stream.resumed)
|
||||
|
||||
// the process table, and the CPU model, come from the same place the Processes page reads
|
||||
const [pt, setPt] = useState<ProcTable | null>(null)
|
||||
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
|
||||
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
|
||||
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
|
||||
const [pools, setPools] = useState<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
api.storage().then((r) => {
|
||||
if (!live) return
|
||||
const m: Record<string, string> = {}
|
||||
for (const d of r.devices) for (const part of d.parts) if (part.fstype === 'zfs_member' && part.label) m[d.name] = part.label
|
||||
setPools(m)
|
||||
}).catch(() => { /* the rows simply say less */ })
|
||||
return () => { live = false }
|
||||
}, [])
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
let stop = false
|
||||
const poll = async () => { try { const r = await api.procs(); if (!stop) { setPt(r); setErr('') } } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') } }
|
||||
poll()
|
||||
const i = window.setInterval(poll, refreshMs) // exactly the rate, like every other page: nothing is throttled behind his back
|
||||
return () => { stop = true; window.clearInterval(i) }
|
||||
}, [refreshMs])
|
||||
|
||||
// the same global rate Settings sets: saved on the hub, so it sticks and every machine's sampler follows
|
||||
const rateIdx = (() => { const i = REFRESH_OPTIONS.findIndex((x) => x >= refreshMs); return i < 0 ? REFRESH_OPTIONS.length - 1 : i })()
|
||||
const setRate = (i: number) => { setRefreshMs(REFRESH_OPTIONS[Math.max(0, Math.min(REFRESH_OPTIONS.length - 1, i))]); savePrefs() }
|
||||
const pal = usePalette()
|
||||
// how many points the history window holds at this rate: the width of every graph
|
||||
const cap = Math.max(2, Math.min(MAX, Math.round((historySec * 1000) / strideMs(historySec, refreshMs))))
|
||||
|
||||
const [sort, setSort] = useState<'cpu' | 'mem' | 'pid' | 'name'>('cpu')
|
||||
const [rev, setRev] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sel, setSel] = useState<number | null>(null)
|
||||
|
||||
// a short cpu curve per process, built from what we have seen since the page opened
|
||||
const pcurve = useRef<Map<number, number[]>>(new Map())
|
||||
useEffect(() => {
|
||||
if (!pt) return
|
||||
const live = new Set<number>()
|
||||
for (const p of pt.procs) {
|
||||
live.add(p.pid)
|
||||
const a = pcurve.current.get(p.pid) ?? []
|
||||
a.push(p.cpu); if (a.length > 14) a.shift()
|
||||
pcurve.current.set(p.pid, a)
|
||||
}
|
||||
for (const pid of pcurve.current.keys()) if (!live.has(pid)) pcurve.current.delete(pid)
|
||||
}, [pt])
|
||||
|
||||
const procs = useMemo(() => {
|
||||
if (!pt) return []
|
||||
const f = filter.trim().toLowerCase()
|
||||
const list = pt.procs.filter((p) => !f || p.name.toLowerCase().includes(f) || (p.cmd ?? '').toLowerCase().includes(f) || p.user.toLowerCase().includes(f) || String(p.pid) === f)
|
||||
const key = (p: Proc) => (sort === 'cpu' ? p.cpu : sort === 'mem' ? p.rss : sort === 'pid' ? p.pid : 0)
|
||||
list.sort((a, b) => (sort === 'name' ? a.name.localeCompare(b.name) : key(b) - key(a)))
|
||||
return rev ? list.reverse() : list
|
||||
}, [pt, filter, sort, rev])
|
||||
|
||||
const signal = async (p: Proc, sig: 'TERM' | 'KILL') => {
|
||||
if (!(await askYes(`Send SIG${sig} to ${p.name} (pid ${p.pid})?`))) return
|
||||
try { await api.procSignal(p.pid, sig); toast(`SIG${sig} sent to ${p.pid}`) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
|
||||
if (!s && !pt) return <div className="top" style={pal.vars as React.CSSProperties}><Loading label={err || 'Reading the machine…'} /></div>
|
||||
|
||||
const cpu = s?.cpu ?? pt?.cpu ?? 0
|
||||
const cores = (s?.cores?.length ? s.cores : pt?.cores) ?? []
|
||||
const load = s?.load ?? pt?.load ?? [0, 0, 0]
|
||||
const mem = s?.mem ?? pt?.mem ?? { total: 0, used: 0, avail: 0 }
|
||||
const uptime = s?.uptime ?? pt?.uptime ?? 0
|
||||
// "11th Gen Intel(R) Core(TM) i7-11700T @ 1.40GHz" is a whole line of border to itself; the
|
||||
// clock is already beside it, and the generation is in the model number
|
||||
const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').replace(/@.*$/, '').replace(/^\d+th Gen\s*/i, '').replace(/\s+/g, ' ').trim()
|
||||
// the hottest thing that looks like a CPU sensor, and one per core where the chip names them;
|
||||
// a second thread of a core (CPU 8 on an 8-core chip with hyper-threading) wears its core's
|
||||
const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label))?.c }
|
||||
const cpuTemp = pt?.cpu_temp ?? s?.temps.find((t) => /package|tctl|tdie|cpu/i.test(t.label))?.c ?? coreTemp(0)
|
||||
// the temperature columns are there for every row once any row has one, so the % column lines up
|
||||
const anyTemp = cpuTemp !== undefined || cores.some((_, i) => coreTemp(i) !== undefined)
|
||||
const cached = (mem as { cached?: number }).cached ?? 0
|
||||
const free = (mem as { free?: number }).free ?? Math.max(0, mem.total - mem.used - cached)
|
||||
const swap = s?.swap ?? pt?.swap
|
||||
const net = (s?.net ?? pt?.net ?? []).filter((n) => n.up && n.iface !== 'lo')
|
||||
// the one that has actually carried traffic, not whichever the kernel listed first — wg0
|
||||
// and a dormant wlan should not push the real NIC out of the way. Tapping a name switches.
|
||||
const busiest = net.length ? net.reduce((a, b) => (b.rx_total + b.tx_total > a.rx_total + a.tx_total ? b : a)) : undefined
|
||||
const iface = net.find((n) => n.iface === pickedIf) ?? busiest
|
||||
// the disks box is this machine's own storage: a CIFS or NFS share is somebody else's disk
|
||||
const fs = (s?.fs ?? []).filter((f) => !f.remote)
|
||||
const pct = (a: number, b: number) => (b > 0 ? (a / b) * 100 : 0)
|
||||
// the top of a graph's scale: the peak of what it is drawing — the window, not everything kept
|
||||
const peak = (id: string) => Math.max(1, ...(curves.get(id) ?? []).slice(-cap))
|
||||
|
||||
return (
|
||||
<Pal.Provider value={pal}>
|
||||
<div className="top" style={pal.vars as React.CSSProperties}>
|
||||
<Box n={1} name="cpu" className="top-cpu" right={<>
|
||||
<span className="t-clock">{clock.toLocaleTimeString([], { hour12: false })}</span>
|
||||
<span className="t-rate">
|
||||
<button title="slower" disabled={rateIdx >= REFRESH_OPTIONS.length - 1} onClick={() => setRate(rateIdx + 1)}>-</button>
|
||||
{fmtMs(refreshMs)}
|
||||
<button title="faster" disabled={rateIdx <= 0} onClick={() => setRate(rateIdx - 1)}>+</button>
|
||||
</span>
|
||||
</>}>
|
||||
<div className="t-cpu">
|
||||
<div className="t-cpu-graph">
|
||||
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} cap={cap} max={100} grad="cpu" />
|
||||
<div className="t-up">up {upfor(uptime)}</div>
|
||||
</div>
|
||||
<div className="t-cores">
|
||||
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}</div>
|
||||
<div className="t-core">
|
||||
<span className="t-core-n">CPU</span>
|
||||
<Meter pct={cpu} grad="cpu" />
|
||||
<span className="t-core-p">{Math.round(cpu)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{cpuTemp !== undefined && <Meter pct={Math.min(100, (cpuTemp / 100) * 100)} grad="temp" />}</span><span className="t-core-c">{cpuTemp !== undefined ? `${Math.round(cpuTemp)}°C` : ''}</span></>}
|
||||
</div>
|
||||
{cores.map((v, i) => {
|
||||
const t = coreTemp(i)
|
||||
return (
|
||||
<div className="t-core" key={i}>
|
||||
<span className="t-core-n">C{i}</span>
|
||||
<span className="t-core-g" title={pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(curves.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
|
||||
<span className="t-core-p">{Math.round(v)}%</span>
|
||||
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="t-load">Load AVG:<span className="spacer" />{load.map((x) => x.toFixed(2)).join(' ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box n={2} name="mem" className="top-mem">
|
||||
<div className="t-row"><b>Total:</b><span className="spacer" />{bytes(mem.total)}</div>
|
||||
{([['Used', mem.used, 'used'], ['Available', mem.avail, 'avail'], ['Cached', cached, 'cached'], ['Free', free, 'free']] as const).map(([k, v, g]) => (
|
||||
<div className="t-mem" key={k}>
|
||||
<div className="t-row"><b>{k}:</b><span className="spacer" />{bytes(v)}</div>
|
||||
<div className="t-row t-sub"><span className="t-pct">{Math.round(pct(v, mem.total))}%</span></div>
|
||||
<Meter pct={pct(v, mem.total)} grad={g as GradName} />
|
||||
</div>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box n={3} name="disks" className="top-disks" right="io">
|
||||
<div className="t-disks">
|
||||
{(s?.disks ?? []).map((d) => {
|
||||
// what is on this drive: the filesystems the sampler could attribute to it. A ZFS
|
||||
// pool or an md array spans several drives, so nothing is attributed and the row
|
||||
// shows what is true of the hardware — how busy it is, and how hot.
|
||||
const on = fs.filter((f) => f.disk === d.name)
|
||||
const used = on.reduce((a, f) => a + f.used, 0)
|
||||
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
|
||||
})}
|
||||
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box n={4} name="net" className="top-net" right={net.length ? (
|
||||
<span className="t-ifs">
|
||||
{net.map((n) => (
|
||||
<button key={n.iface} className={'t-if' + (iface && n.iface === iface.iface ? ' on' : '')}
|
||||
title={`${n.iface} — ${bytes(n.rx_total)} in, ${bytes(n.tx_total)} out`} onClick={() => setPickedIf(n.iface)}>{n.iface}</button>
|
||||
))}
|
||||
</span>
|
||||
) : undefined}>
|
||||
{iface ? (
|
||||
<div className="t-net">
|
||||
<div className="t-net-graphs">
|
||||
<div className="t-net-g">
|
||||
<span className="t-scale">{rate(peak(`net.${segOf(iface.iface)}.rx`))}</span>
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.rx`) ?? []} tick={tick} advance={advance} cap={cap} grad="down" />
|
||||
</div>
|
||||
<div className="t-net-g">
|
||||
<Graph values={curves.get(`net.${segOf(iface.iface)}.tx`) ?? []} tick={tick} advance={advance} cap={cap} grad="up" flip />
|
||||
<span className="t-scale t-scale-b">{rate(peak(`net.${segOf(iface.iface)}.tx`))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="t-net-nums">
|
||||
<div className="t-dim">download</div>
|
||||
<div className="t-row"><span className="t-dl">▼</span> {rate(iface.rx_bps)}<span className="spacer" />({bits(iface.rx_bps)})</div>
|
||||
<div className="t-row"><span className="t-dl">▼</span> Top:<span className="spacer" />({bits(Math.max(0, ...(curves.get(`net.${segOf(iface.iface)}.rx`) ?? [0])))})</div>
|
||||
<div className="t-row"><span className="t-dl">▼</span> Total:<span className="spacer" />{bytes(iface.rx_total)}</div>
|
||||
<div className="t-gap" />
|
||||
<div className="t-row"><span className="t-ul">▲</span> {rate(iface.tx_bps)}<span className="spacer" />({bits(iface.tx_bps)})</div>
|
||||
<div className="t-row"><span className="t-ul">▲</span> Top:<span className="spacer" />({bits(Math.max(0, ...(curves.get(`net.${segOf(iface.iface)}.tx`) ?? [0])))})</div>
|
||||
<div className="t-row"><span className="t-ul">▲</span> Total:<span className="spacer" />{bytes(iface.tx_total)}</div>
|
||||
<div className="t-dim t-up-label">upload</div>
|
||||
</div>
|
||||
</div>
|
||||
) : <div className="t-dim">no interface is up</div>}
|
||||
</Box>
|
||||
|
||||
<Box n={5} name="proc" className="top-proc" right={<>
|
||||
<span className="t-sorts">
|
||||
{(['pid', 'name', 'mem', 'cpu'] as const).map((k) => <button key={k} className={sort === k ? 'on' : ''} onClick={() => setSort(k)}>{k}</button>)}
|
||||
<button className={rev ? 'on' : ''} onClick={() => setRev(!rev)}>reverse</button>
|
||||
</span>
|
||||
</>}>
|
||||
<div className="t-proc">
|
||||
<input className="t-filter" value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||
<div className="t-thead">
|
||||
<span className="c-pid">Pid:</span><span className="c-name">Program:</span><span className="c-cmd">Command:</span>
|
||||
<span className="c-thr">Threads:</span><span className="c-user">User:</span><span className="c-mem">MemB</span><span className="c-g" /><span className="c-cpu">Cpu%</span>
|
||||
</div>
|
||||
<Rows procs={procs} sel={sel} onSel={setSel} curves={pcurve} empty={err || 'no process matches'} />
|
||||
<div className="t-foot">
|
||||
{sel !== null && procs.find((p) => p.pid === sel) ? (() => { const p = procs.find((x) => x.pid === sel)!; return <>
|
||||
<span className="t-dim">{p.pid} {p.name}</span><span className="spacer" />
|
||||
<button onClick={() => signal(p, 'TERM')}>terminate</button>
|
||||
<button onClick={() => signal(p, 'KILL')}>kill</button>
|
||||
</> })() : <><span className="t-dim">select a process to signal it</span><span className="spacer" /></>}
|
||||
<span className="t-count">{procs.length}/{pt?.total ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
</Pal.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// The rows are the heaviest thing on the page — hundreds of them — so they are redrawn when the
|
||||
// table is read again, not on every tick of the curves above them.
|
||||
const Rows = memo(function Rows({ procs, sel, onSel, curves, empty }: { procs: Proc[]; sel: number | null; onSel: (p: number | null) => void; curves: React.RefObject<Map<number, number[]>>; empty: string }) {
|
||||
return (
|
||||
<div className="t-rows">
|
||||
{procs.map((p) => {
|
||||
const c = curves.current?.get(p.pid) ?? []
|
||||
return (
|
||||
<div key={p.pid} className={'t-prow' + (sel === p.pid ? ' on' : '')} onClick={() => onSel(sel === p.pid ? null : p.pid)}>
|
||||
<span className="c-pid">{p.pid}</span>
|
||||
<span className="c-name">{p.name}</span>
|
||||
<span className="c-cmd" title={p.cmd || p.name}>{p.cmd || p.name}</span>
|
||||
<span className="c-thr">{p.threads}</span>
|
||||
<span className="c-user">{p.user}</span>
|
||||
<span className="c-mem">{shortBytes(p.rss)}</span>
|
||||
<span className="c-g">{braille(c, 7, Math.max(5, ...(c.length ? c : [0])))}</span>
|
||||
<span className="c-cpu">{p.cpu.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{procs.length === 0 && <div className="t-dim" style={{ padding: 8 }}>{empty}</div>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// One physical drive. `used` is the bytes of the filesystems on it, left out when none can
|
||||
// be attributed to it — a pool member's share of a pool is not a number worth inventing.
|
||||
function Disk({ name, note, size, used, io, temp, mounts, pool }: { name: string; note?: string; size: number; used?: number; io?: number; temp?: number; mounts?: string[]; pool?: string }) {
|
||||
const upct = used !== undefined && size > 0 ? (used / size) * 100 : 0
|
||||
return (
|
||||
<div className="t-disk">
|
||||
<div className="t-row">
|
||||
<b>{name}</b>
|
||||
{note && <span className="t-dim t-model" title={note}>{note}</span>}
|
||||
<span className="spacer" />
|
||||
{temp !== undefined && temp > 0 && <span className="t-temp">{Math.round(temp)}°C</span>}
|
||||
{bytes(size)}
|
||||
</div>
|
||||
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
|
||||
{used !== undefined ? (
|
||||
<>
|
||||
<div className="t-row t-sub"><span>Used:</span><span className="t-pct">{Math.round(upct)}%</span><Meter pct={upct} grad="used" /><span className="t-amt">{bytes(used, 1)}</span></div>
|
||||
<div className="t-row t-sub"><span>Free:</span><span className="t-pct">{Math.round(100 - upct)}%</span><Meter pct={100 - upct} grad="free" /><span className="t-amt">{bytes(Math.max(0, size - used), 1)}</span></div>
|
||||
{mounts && mounts.length > 0 && <div className="t-row t-sub t-dim t-mounts" title={mounts.join(' ')}>{mounts.join(' ')}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className="t-row t-sub t-dim">{pool ? `member of ZFS pool ${pool}` : 'nothing mounted from it'}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// the sensor ID segment for an interface name, the same rule the server uses
|
||||
const segOf = (name: string) => name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, TaskInfo, UpdatePackage, UpdatesStatus } from './api'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import { Btn, Loading, Spinner, toast } from './ui'
|
||||
|
||||
function ago(ms?: number): string {
|
||||
if (!ms) return 'never'
|
||||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return (d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`) + ' ago'
|
||||
}
|
||||
|
||||
export default function Updates() {
|
||||
const [st, setSt] = useState<UpdatesStatus | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [reloading, setReloading] = useState(false)
|
||||
const [sel, setSel] = useState<Set<string>>(new Set())
|
||||
const [task, setTask] = useState<string | null>(null)
|
||||
const [taskRunning, setTaskRunning] = useState(false)
|
||||
const [open, setOpen] = useState<string | null>(null)
|
||||
const [changelog, setChangelog] = useState<Record<string, string>>({})
|
||||
const taskBox = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setReloading(true)
|
||||
try {
|
||||
const s = await api.updates()
|
||||
setSt(s); setErr('')
|
||||
if (s.running_task) { setTask(s.running_task); setTaskRunning(true) }
|
||||
setSel((prev) => new Set([...prev].filter((n) => s.packages.some((p) => p.name === n))))
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
finally { setReloading(false) }
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function start(fn: () => Promise<TaskInfo>) {
|
||||
setErr('')
|
||||
try {
|
||||
const t = await fn()
|
||||
setTask(t.id); setTaskRunning(true)
|
||||
window.setTimeout(() => taskBox.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50)
|
||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
function done(t: TaskInfo) {
|
||||
setTaskRunning(false)
|
||||
const ok = t.exit_code === 0 && !t.error
|
||||
toast(`${t.title}: ${ok ? 'finished' : t.error || `exited ${t.exit_code}`}`, ok ? 'ok' : 'err')
|
||||
load()
|
||||
}
|
||||
async function showChangelog(p: UpdatePackage) {
|
||||
if (open === p.name) { setOpen(null); return }
|
||||
setOpen(p.name)
|
||||
if (!changelog[p.name]) {
|
||||
try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
|
||||
catch (e) { setChangelog((c) => ({ ...c, [p.name]: e instanceof Error ? e.message : 'unavailable' })) }
|
||||
}
|
||||
}
|
||||
const toggle = (n: string) => setSel((s) => { const x = new Set(s); if (x.has(n)) x.delete(n); else x.add(n); return x })
|
||||
|
||||
if (!st) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Checking packages…" />}</div>
|
||||
const canAct = st.can_upgrade && !taskRunning
|
||||
|
||||
return (
|
||||
<div className="dash updates">
|
||||
{!st.manager && <div className="notice">No supported package manager found (looked for apt, dnf, zypper, pacman, apk).</div>}
|
||||
{st.manager && !st.sudo && <div className="notice">Read-only: refreshing and upgrading need root. Run Ham Lab as root, or give its user passwordless sudo or doas.</div>}
|
||||
{st.reboot_needed && (
|
||||
<div className="notice attn">
|
||||
<b>Reboot required</b>{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}</>} — use the power button in the header.
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
<div className="kpis">
|
||||
<div className="tile"><div className="label">Upgradable</div><div className="value">{st.packages.length}</div><div className="sub">{st.security_count ? `${st.security_count} security` : 'no security updates'}</div></div>
|
||||
<div className="tile"><div className="label">Index refreshed</div><div className="value">{ago(st.last_refresh)}</div><div className="sub">{st.manager}</div></div>
|
||||
<div className="tile"><div className="label">Last upgrade</div><div className="value">{ago(st.last_upgrade)}</div><div className="sub">from {st.manager} history</div></div>
|
||||
<div className="tile"><div className="label">Unattended</div><div className="value">{st.auto_upgrades ? 'on' : 'off'}</div><div className="sub">automatic upgrades</div></div>
|
||||
</div>
|
||||
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</Btn>
|
||||
<Btn className="btn on" disabled={!canAct || st.packages.length === 0} confirm={`Upgrade all ${st.packages.length} packages?`} onClick={() => start(() => api.upgrade([]))}>Upgrade all</Btn>
|
||||
<Btn className="btn" disabled={!canAct || sel.size === 0 || !st.selective} title={st.selective ? undefined : `${st.manager} upgrades everything or nothing`} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</Btn>
|
||||
<span className="spacer" />
|
||||
{st.packages.length > 0 && <button className="btn" onClick={() => setSel(sel.size === st.packages.length ? new Set() : new Set(st.packages.map((p) => p.name)))}>{sel.size === st.packages.length ? 'None' : 'All'}</button>}
|
||||
<button className="btn" title="reload" disabled={reloading} onClick={load}>{reloading ? <Spinner /> : '↻'}</button>
|
||||
</div>
|
||||
|
||||
<div ref={taskBox}>{task && <TaskOutput id={task} onDone={done} />}</div>
|
||||
|
||||
<div className="card pkgs">
|
||||
{st.packages.length === 0 && <div className="empty">Everything is up to date.</div>}
|
||||
{st.packages.map((p) => (
|
||||
<div key={p.name}>
|
||||
<div className={'pkg' + (sel.has(p.name) ? ' sel' : '')}>
|
||||
<input type="checkbox" checked={sel.has(p.name)} onChange={() => toggle(p.name)} />
|
||||
<span className="pkg-name" onClick={() => showChangelog(p)}>
|
||||
<span className="n">{p.name}{p.security && <span className="badge bad">security</span>}</span>
|
||||
<span className="d">{p.description}</span>
|
||||
</span>
|
||||
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span>
|
||||
</div>
|
||||
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
// Usage: what is using a disk, WizTree-style. (The tab was called Space until 2026-09-24;
|
||||
// the Go package and the /api/space endpoints behind it still carry the old name.) Pick a disk, scan it (a parallel walk of every
|
||||
// filesystem the disk backs, as root when the host allows), then read the result two ways:
|
||||
// a tree of folders largest-first, and a treemap of whatever folder is selected, where every
|
||||
// file is a tile sized by what it takes on disk and coloured by what kind of thing it is.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, SpaceFile, SpaceTarget, SpaceTree } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui'
|
||||
|
||||
// ---- what kind of file a tile is: the colour it wears (the validated categorical set, dark and light) ----
|
||||
|
||||
interface Kind { id: string; label: string; dark: string; light: string; ext: string[] }
|
||||
const KINDS: Kind[] = [
|
||||
{ id: 'video', label: 'Video', dark: '#9085e9', light: '#4a3aa7', ext: ['mp4', 'mkv', 'avi', 'mov', 'webm', 'm4v', 'ts', 'wmv', 'flv', 'mpg', 'mpeg', 'vob', 'm2ts'] },
|
||||
{ id: 'image', label: 'Images', dark: '#d55181', light: '#e87ba4', ext: ['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'raw', 'dng', 'cr2', 'nef', 'arw', 'tif', 'tiff', 'bmp', 'svg', 'psd'] },
|
||||
{ id: 'audio', label: 'Audio', dark: '#199e70', light: '#1baf7a', ext: ['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma', 'aiff'] },
|
||||
{ id: 'archive', label: 'Archives & images', dark: '#d95926', light: '#eb6834', ext: ['zip', 'tar', 'gz', 'tgz', 'xz', 'zst', 'bz2', '7z', 'rar', 'iso', 'img', 'qcow2', 'vmdk', 'vdi', 'vhd', 'vhdx', 'deb', 'rpm', 'apk', 'jar', 'whl'] },
|
||||
{ id: 'doc', label: 'Documents', dark: '#c98500', light: '#eda100', ext: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'txt', 'md', 'epub', 'mobi', 'rtf', 'csv'] },
|
||||
{ id: 'code', label: 'Code & config', dark: '#3987e5', light: '#2a78d6', ext: ['js', 'mjs', 'ts', 'tsx', 'jsx', 'go', 'py', 'rs', 'c', 'h', 'cpp', 'java', 'sh', 'css', 'html', 'json', 'yaml', 'yml', 'toml', 'xml', 'sql', 'wasm', 'so', 'a', 'o', 'node'] },
|
||||
{ id: 'data', label: 'Databases & logs', dark: '#e66767', light: '#e34948', ext: ['db', 'sqlite', 'sqlite3', 'log', 'ldb', 'sst', 'wal', 'journal', 'bin', 'dat', 'idx', 'pack', 'ibd', 'frm', 'mdb', 'rdb', 'aof'] },
|
||||
{ id: 'other', label: 'Other', dark: '#008300', light: '#008300', ext: [] },
|
||||
]
|
||||
const BY_EXT = new Map<string, Kind>()
|
||||
for (const k of KINDS) for (const e of k.ext) BY_EXT.set(e, k)
|
||||
function kindOf(name: string): Kind {
|
||||
const dot = name.lastIndexOf('.')
|
||||
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
|
||||
return (ext && BY_EXT.get(ext)) || KINDS[KINDS.length - 1]
|
||||
}
|
||||
function lightTheme(): boolean {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim()
|
||||
return /^#[0-9a-f]{6}$/i.test(bg) && parseInt(bg.slice(1, 3), 16) > 128
|
||||
}
|
||||
function cssVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() }
|
||||
|
||||
const fmtN = (n: number) => n.toLocaleString()
|
||||
const fmtWhen = (t: number) => (t ? new Date(t * 1000).toLocaleDateString() : '')
|
||||
const ago = (iso?: string) => {
|
||||
if (!iso) return ''
|
||||
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
|
||||
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`
|
||||
}
|
||||
const fmtDur = (a: string, b?: string) => { const ms = (b ? new Date(b).getTime() : Date.now()) - new Date(a).getTime(); return ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s` }
|
||||
const parentOf = (p: string) => (p === '/' ? '/' : p.slice(0, p.lastIndexOf('/')) || '/')
|
||||
|
||||
export default function Usage() {
|
||||
const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [sel, setSel] = useHashSel('usage')
|
||||
const [tab, setTab] = useState<'tree' | 'files'>('tree')
|
||||
const rate = useRefreshMs()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { const r = await api.space(); setTargets(r.targets); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const target = targets?.find((t) => t.id === sel) ?? targets?.find((t) => t.roots.length > 0) ?? targets?.[0]
|
||||
useEffect(() => { if (target && target.id !== sel) setSel(target.id) }, [target, sel, setSel])
|
||||
const scan = target?.scan
|
||||
// while a scan runs its status alone is polled at the refresh rate (the target list means
|
||||
// lsblk and zpool, which need not run twice a second); the list is read again when it ends
|
||||
useEffect(() => {
|
||||
if (!scan?.running || !target) return
|
||||
const id = target.id
|
||||
const t = setInterval(async () => {
|
||||
try {
|
||||
const st = await api.spaceStatus(id)
|
||||
setTargets((ts) => ts?.map((x) => (x.id === id ? { ...x, scan: st } : x)) ?? ts)
|
||||
} catch { /* the next tick */ }
|
||||
}, Math.max(250, rate))
|
||||
return () => clearInterval(t)
|
||||
}, [scan?.running, target, rate])
|
||||
|
||||
const start = async () => { if (!target) return; try { await api.spaceScan(target.id); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||
const cancel = async () => { if (!target) return; try { await api.spaceCancel(target.id); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||
const forget = async () => { if (!target || !await askYes(`Forget the saved scan of ${target.name}? The next scan reads everything again.`)) return; try { await api.spaceForget(target.id); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||
// the target list is not polled during a scan; when one ends it is read again, so the
|
||||
// picker's "scanned … ago" and the new tree come in together
|
||||
const wasRunning = useRef(false)
|
||||
useEffect(() => { if (wasRunning.current && !scan?.running) load(); wasRunning.current = !!scan?.running }, [scan?.running, load])
|
||||
|
||||
if (err && !targets) return <div className="dash"><div className="error">{err}</div></div>
|
||||
if (!targets) return <div className="dash"><Loading label="Looking at the disks…" /></div>
|
||||
// a result to show: the last finished one, kept while a refresh of it runs
|
||||
const have = !!scan && !scan.loading && scan.files > 0 && (!scan.error || scan.error === 'cancelled')
|
||||
const done = have && !scan.running
|
||||
return (
|
||||
<div className="space">
|
||||
<div className="space-bar">
|
||||
<div className="space-pick-row">
|
||||
<TargetPicker targets={targets} current={target ?? null} onPick={(t) => setSel(t.id)} />
|
||||
{target && (
|
||||
<div className="space-status">
|
||||
{scan?.running ? (
|
||||
<>
|
||||
<Spinner /><span className="mono small">{fmtN(scan.progress.files)} files · {fmtBytes(scan.progress.bytes)} · {fmtDur(scan.started)}{scan.progress.reused ? ` · ${fmtN(scan.progress.reused)} folders kept, ${fmtN(scan.progress.dirs)} read` : ''}</span>
|
||||
<Btn className="btn danger" onClick={cancel}>Cancel</Btn>
|
||||
</>
|
||||
) : scan?.loading ? (
|
||||
<><Spinner /><span className="muted small">reading the last scan back…</span></>
|
||||
) : (
|
||||
<>
|
||||
{done && <span className="small" title={scan.by_root ? Object.entries(scan.by_root).map(([r, how]) => `${r}: ${how}`).join('\n') : undefined}>
|
||||
<b>{fmtBytes(scan.size)}</b> in {fmtN(scan.files)} files, {fmtN(scan.dirs)} folders · {scan.partial ? <span className="warn">partly scanned, {fmtN(scan.partial_dirs)} folders not yet read</span> : `scanned ${ago(scan.finished)}`} in {fmtDur(scan.started, scan.finished)}
|
||||
{scan.method && scan.method !== 'full' ? ` · ${scan.method === 'resume' ? 'resumed' : 'refreshed'}: ${fmtN(scan.read)} folders read, ${fmtN(scan.reused)} kept` : ''}
|
||||
{scan.as_root ? '' : ' · not as root, so some folders were skipped'}{scan.denied ? ` · ${fmtN(scan.denied)} unreadable` : ''}
|
||||
</span>}
|
||||
{scan?.error && scan.error !== 'cancelled' && <span className="error small">{scan.error}</span>}
|
||||
<Btn className="btn on" disabled={target.roots.length === 0} onClick={start} title={done ? (scan.partial ? 'reads the folders the cancelled scan did not get to' : 'reads only what changed since the last scan (zfs diff on a pool with snapshots; folder times elsewhere)') : undefined}>{done ? (scan.partial ? 'Resume' : 'Rescan') : 'Scan'}</Btn>
|
||||
{done && <Btn className="btn" onClick={forget} title="drop the saved result; the next scan reads everything">Forget</Btn>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{target && <div className="muted small space-about">{describe(target)}{scan?.running && scan.progress.current && <span className="mono cur" title={scan.progress.current}> · {scan.progress.current}</span>}</div>}
|
||||
</div>
|
||||
{target && have && (
|
||||
<>
|
||||
<div className="chips space-tabs">
|
||||
<button className={'chip' + (tab === 'tree' ? ' on' : '')} onClick={() => setTab('tree')}>Folders</button>
|
||||
<button className={'chip' + (tab === 'files' ? ' on' : '')} onClick={() => setTab('files')}>Largest files</button>
|
||||
</div>
|
||||
{tab === 'tree' ? <Explorer key={target.id + (scan?.finished ?? '')} disk={target.id} /> : <LargestFiles key={target.id + (scan?.finished ?? '')} disk={target.id} />}
|
||||
</>
|
||||
)}
|
||||
{target && !have && !scan?.running && !scan?.loading && <div className="svc-empty muted">{target.roots.length === 0 ? 'Nothing is mounted from it, so there is nothing to scan.' : scan?.error && scan.error !== 'cancelled' ? 'The last scan failed. Scan again.' : `Scan the ${target.kind} to see what is on it.`}</div>}
|
||||
{target && !have && scan?.running && <div className="svc-empty muted">Scanning…</div>}
|
||||
{target && scan?.loading && <div className="svc-empty muted">Reading the last scan back…</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// What a target is, in a line: "ZFS pool · raidz2 · 8 disks: sda sdb … · 29 TiB, 13 TiB used · scans /tank and 9 more"
|
||||
function describe(t: SpaceTarget): string {
|
||||
const parts: string[] = []
|
||||
if (t.kind === 'pool') {
|
||||
parts.push(`ZFS pool${t.layout ? ' · ' + t.layout : ''}${t.health && t.health !== 'ONLINE' ? ' · ' + t.health : ''}`)
|
||||
if (t.disks?.length) parts.push(`${t.disks.length} disk${t.disks.length === 1 ? '' : 's'}: ${t.disks.join(' ')}`)
|
||||
parts.push(`${fmtBytes(t.size)}${t.used ? `, ${fmtBytes(t.used)} used` : ''}`)
|
||||
} else {
|
||||
parts.push(`disk${t.model ? ' · ' + t.model : ''} · ${fmtBytes(t.size)}`)
|
||||
}
|
||||
if (t.roots.length === 0) parts.push('nothing mounted')
|
||||
else if (t.roots.length <= 3) parts.push('scans ' + t.roots.join(', '))
|
||||
else parts.push(`scans ${t.roots.slice(0, 2).join(', ')} and ${t.roots.length - 2} more`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
// The dropdown of what can be scanned: pools first, each with its layout and members, then
|
||||
// disks on their own. A disk with nothing mounted is listed but can't be picked.
|
||||
function TargetPicker({ targets, current, onPick }: { targets: SpaceTarget[]; current: SpaceTarget | null; onPick: (t: SpaceTarget) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => { if (box.current && !box.current.contains(e.target as Node)) setOpen(false) }
|
||||
window.addEventListener('click', h)
|
||||
return () => window.removeEventListener('click', h)
|
||||
}, [])
|
||||
useEscape(() => setOpen(false), open)
|
||||
const state = (t: SpaceTarget) => t.scan?.running ? 'scanning' : t.scan?.finished && !t.scan.error ? `scanned ${ago(t.scan.finished)}` : t.roots.length === 0 ? 'nothing mounted' : 'not scanned'
|
||||
const icon = (t: SpaceTarget) => (t.kind === 'pool' ? '⛁' : '▤')
|
||||
return (
|
||||
<div className="tpick" ref={box}>
|
||||
<button className="tpick-btn" onClick={() => setOpen(!open)} title="pick a disk or pool">
|
||||
{current ? <><span className="ticon">{icon(current)}</span><b>{current.name}</b><span className="muted small">{current.kind === 'pool' ? `ZFS pool${current.layout ? ' · ' + current.layout : ''}` : current.model || 'disk'} · {fmtBytes(current.size)}</span>{current.scan?.running && <Spinner size={10} />}</> : <span className="muted">no disks</span>}
|
||||
<span className="chev">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="tmenu">
|
||||
{targets.map((t) => (
|
||||
<button key={t.id} className={'titem' + (current?.id === t.id ? ' on' : '') + (t.roots.length === 0 ? ' off' : '')} disabled={t.roots.length === 0} onClick={() => { setOpen(false); onPick(t) }}>
|
||||
<span className="ticon">{icon(t)}</span>
|
||||
<span className="tmain">
|
||||
<span className="tname"><b>{t.name}</b><span className="muted small"> · {t.kind === 'pool' ? `ZFS pool${t.layout ? ' · ' + t.layout : ''}${t.health && t.health !== 'ONLINE' ? ' · ' + t.health : ''}` : t.model || 'disk'} · {fmtBytes(t.size)}</span></span>
|
||||
<span className="muted small tsub">{t.kind === 'pool' && t.disks?.length ? `${t.disks.length} disks: ${t.disks.join(' ')}` : t.roots.length ? t.roots.slice(0, 3).join(' ') + (t.roots.length > 3 ? ` +${t.roots.length - 3}` : '') : 'nothing mounted'}</span>
|
||||
</span>
|
||||
<span className="muted small tstate">{state(t)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the folder tree and the treemap of the selected folder ----
|
||||
|
||||
interface Row { t: SpaceTree; depth: number; kind: 'node' | 'small' | 'more'; parent: number }
|
||||
|
||||
function Explorer({ disk }: { disk: string }) {
|
||||
const [cache, setCache] = useState<Record<string, SpaceTree>>({}) // one level per folder, for the rows
|
||||
const [maps, setMaps] = useState<Record<string, SpaceTree>>({}) // deep, pruned to what a tile could show, for the map
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(['/']))
|
||||
const [focus, setFocus] = useState('/')
|
||||
const pending = useRef(new Set<string>())
|
||||
|
||||
const fetch = useCallback(async (path: string) => {
|
||||
if (pending.current.has(path)) return
|
||||
pending.current.add(path)
|
||||
try { const t = await api.spaceTree(disk, path, 1, 200); setCache((c) => ({ ...c, [path]: t })) }
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
finally { pending.current.delete(path) }
|
||||
}, [disk])
|
||||
// The map of a folder reaches as deep as there is room to draw: everything under a
|
||||
// four-thousandth of the folder would be a tile smaller than a few pixels, so it is left out.
|
||||
const fetchMap = useCallback(async (path: string, size: number) => {
|
||||
const key = 'map:' + path
|
||||
if (pending.current.has(key)) return
|
||||
pending.current.add(key)
|
||||
try { const t = await api.spaceTree(disk, path, 24, 300, Math.max(1, Math.floor(size / 6000))); setMaps((m) => ({ ...m, [path]: t })) }
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
finally { pending.current.delete(key) }
|
||||
}, [disk])
|
||||
useEffect(() => { if (!cache['/']) fetch('/') }, [cache, fetch])
|
||||
useEffect(() => { if (!cache[focus]) fetch(focus) }, [focus, cache, fetch])
|
||||
useEffect(() => { const c = cache[focus]; if (c && !maps[focus]) fetchMap(focus, c.size) }, [focus, cache, maps, fetchMap])
|
||||
|
||||
const toggle = (path: string) => {
|
||||
setOpen((o) => { const n = new Set(o); if (n.has(path)) n.delete(path); else { n.add(path); if (!cache[path]) fetch(path) } return n })
|
||||
}
|
||||
const select = (path: string) => {
|
||||
setFocus(path)
|
||||
// open every folder down to it so the row is on screen
|
||||
setOpen((o) => { const n = new Set(o); let p = path; while (p !== '/') { p = parentOf(p); n.add(p) } return n })
|
||||
if (!cache[path]) fetch(path)
|
||||
}
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const out: Row[] = []
|
||||
const walk = (t: SpaceTree, depth: number) => {
|
||||
out.push({ t, depth, kind: 'node', parent: t.size })
|
||||
if (!t.dir || !open.has(t.path)) return
|
||||
const full = cache[t.path] ?? t // the row's own fetch has the full list; the parent's cut copy does until then
|
||||
for (const c of full.children ?? []) walk(c, depth + 1)
|
||||
if (full.small_n) out.push({ t: { ...full, name: `${fmtN(full.small_n)} smaller files`, dir: false, size: full.small_size ?? 0, files: full.small_n, path: full.path + '/*' }, depth: depth + 1, kind: 'small', parent: full.size })
|
||||
if (full.more_n) out.push({ t: { ...full, name: `${fmtN(full.more_n)} more items`, dir: false, size: full.more_size ?? 0, path: full.path + '/…' }, depth: depth + 1, kind: 'more', parent: full.size })
|
||||
}
|
||||
if (cache['/']) walk(cache['/'], 0)
|
||||
// parent sizes for the bars: each row's share of the folder it sits in
|
||||
const stack: number[] = []
|
||||
for (const r of out) { stack.length = r.depth; r.parent = stack[r.depth - 1] ?? r.t.size; stack[r.depth] = r.t.size }
|
||||
return out
|
||||
}, [cache, open])
|
||||
|
||||
const root = cache['/']
|
||||
if (!root) return <Loading label="Reading the scan…" />
|
||||
const focused = cache[focus]
|
||||
const map = maps[focus]
|
||||
return (
|
||||
<div className="space-split">
|
||||
<div className="space-tree">
|
||||
<div className="space-row head">
|
||||
<span className="n">Name</span><span className="r">Size</span><span className="pct">of parent</span><span className="r c">Files</span><span className="r c">Folders</span><span className="r d">Modified</span>
|
||||
</div>
|
||||
<div className="space-rows">
|
||||
{rows.map((r) => {
|
||||
const t = r.t
|
||||
const pct = r.parent > 0 ? (t.size / r.parent) * 100 : 0
|
||||
const isOpen = open.has(t.path)
|
||||
return (
|
||||
<div key={t.path} className={'space-row' + (t.path === focus ? ' active' : '') + (r.kind !== 'node' ? ' agg' : '')} style={{ paddingLeft: 8 + r.depth * 16 }}
|
||||
onClick={() => { if (r.kind === 'node') { if (t.dir) { select(t.path); if (!isOpen) toggle(t.path) } else select(parentOf(t.path)) } }}>
|
||||
<span className="n">
|
||||
{t.dir && r.kind === 'node' ? <button className="tw" onClick={(e) => { e.stopPropagation(); toggle(t.path) }}>{isOpen ? '▾' : '▸'}</button> : <span className="tw" />}
|
||||
<span className="ico" style={r.kind === 'node' && !t.dir ? { background: lightTheme() ? kindOf(t.name).light : kindOf(t.name).dark } : undefined}>{t.dir && r.kind === 'node' ? '▰' : ''}</span>
|
||||
<span className="nm" title={t.path}>{t.name}{t.mount && t.dir && r.kind === 'node' ? <span className="muted small"> mount</span> : ''}{t.denied ? <span className="muted small"> unreadable</span> : ''}{t.partial ? <span className="warn small"> not yet read</span> : ''}</span>
|
||||
</span>
|
||||
<span className="r mono">{fmtBytes(t.size)}</span>
|
||||
<span className="pct"><span className="bar"><span style={{ width: `${Math.min(100, pct)}%` }} /></span><span className="muted small">{pct >= 10 ? pct.toFixed(0) : pct.toFixed(1)}%</span></span>
|
||||
<span className="r c muted">{t.dir && r.kind === 'node' ? fmtN(t.files) : r.kind === 'small' ? fmtN(t.files) : ''}</span>
|
||||
<span className="r c muted">{t.dir && r.kind === 'node' ? fmtN(t.dirs) : ''}</span>
|
||||
<span className="r d muted small">{r.kind === 'node' ? fmtWhen(t.mtime) : ''}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-map">
|
||||
<div className="space-crumbs">
|
||||
<button className="btn" disabled={focus === '/'} title="up one folder" onClick={() => select(parentOf(focus))}>↑</button>
|
||||
{crumbs(focus).map((c) => <button key={c.path} className={'crumb' + (c.path === focus ? ' on' : '')} onClick={() => select(c.path)}>{c.name}</button>)}
|
||||
<span className="spacer" />
|
||||
{focused && <span className="muted small">{fmtBytes(focused.size)} · {root.size > 0 ? ((focused.size / root.size) * 100).toFixed(1) : 0}% of the disk's {fmtBytes(root.size)}</span>}
|
||||
</div>
|
||||
{map ? <Treemap tree={map} onOpen={select} /> : <Loading label="Laying it out…" />}
|
||||
<div className="space-legend">
|
||||
{KINDS.map((k) => <span key={k.id}><i style={{ background: lightTheme() ? k.light : k.dark }} />{k.label}</span>)}
|
||||
<span><i className="dir" />Folder</span><span><i className="small" />Smaller files</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function crumbs(path: string): { name: string; path: string }[] {
|
||||
const out = [{ name: '/', path: '/' }]
|
||||
let p = ''
|
||||
for (const part of path.split('/').filter(Boolean)) { p += '/' + part; out.push({ name: part, path: p }) }
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the treemap: every file a tile, grouped by folder, drawn on a canvas ----
|
||||
//
|
||||
// WizTree's picture: the whole area is files, sized by what they take and coloured by kind,
|
||||
// and folders are only the grouping — a thin line, and a name on the biggest ones directly
|
||||
// under the folder being looked at. No folder gets a box of its own deeper down, because a
|
||||
// box costs a header and padding at every level, and a chain of single-child folders (as
|
||||
// every Linux system has) turned the picture into a staircase of headers with nothing inside.
|
||||
|
||||
interface Tile { x: number; y: number; w: number; h: number; t: SpaceTree; agg?: 'small' | 'more'; depth: number; head: number }
|
||||
|
||||
// squarify lays items (largest first) into rect, keeping tiles as square as it can.
|
||||
function squarify(items: { size: number; t: SpaceTree; agg?: 'small' | 'more' }[], x: number, y: number, w: number, h: number, depth: number, out: Tile[]) {
|
||||
const total = items.reduce((a, b) => a + b.size, 0)
|
||||
if (total <= 0 || w <= 1 || h <= 1) return
|
||||
let i = 0
|
||||
let cx = x, cy = y, cw = w, ch = h
|
||||
while (i < items.length) {
|
||||
const vertical = cw < ch // lay the next row along the shorter side
|
||||
const side = vertical ? cw : ch
|
||||
const scale = (cw * ch) / items.slice(i).reduce((a, b) => a + b.size, 0)
|
||||
let row: typeof items = []
|
||||
let rowSum = 0
|
||||
let worst = Infinity
|
||||
for (let j = i; j < items.length; j++) {
|
||||
const s = items[j].size * scale
|
||||
if (s <= 0) { i = j + 1; continue }
|
||||
const sum = rowSum + s
|
||||
const thick = sum / side
|
||||
let wst = 0
|
||||
for (const r of [...row, items[j]]) { const len = (r.size * scale) / thick; wst = Math.max(wst, len / thick, thick / len) }
|
||||
if (row.length && wst > worst) break
|
||||
row.push(items[j]); rowSum = sum; worst = wst
|
||||
}
|
||||
if (row.length === 0) break
|
||||
const thick = rowSum / side
|
||||
let off = 0
|
||||
for (const r of row) {
|
||||
const len = (r.size * scale) / thick
|
||||
const tile: Tile = vertical ? { x: cx, y: cy + off, w: thick, h: len, t: r.t, agg: r.agg, depth, head: 0 } : { x: cx + off, y: cy, w: len, h: thick, t: r.t, agg: r.agg, depth, head: 0 }
|
||||
out.push(tile)
|
||||
off += len
|
||||
}
|
||||
if (vertical) { cy += thick; ch -= thick } else { cx += thick; cw -= thick }
|
||||
i += row.length
|
||||
}
|
||||
}
|
||||
|
||||
function layout(tree: SpaceTree, x: number, y: number, w: number, h: number, depth: number, out: Tile[]) {
|
||||
const items: { size: number; t: SpaceTree; agg?: 'small' | 'more' }[] = (tree.children ?? []).filter((c) => c.size > 0).map((c) => ({ size: c.size, t: c }))
|
||||
if (tree.small_n && tree.small_size) items.push({ size: tree.small_size, t: { ...tree, name: `${fmtN(tree.small_n)} smaller files`, dir: false, path: tree.path + '/*', children: undefined }, agg: 'small' })
|
||||
if (tree.more_n && tree.more_size) items.push({ size: tree.more_size, t: { ...tree, name: `${fmtN(tree.more_n)} more items`, dir: false, path: tree.path + '/…', children: undefined }, agg: 'more' })
|
||||
items.sort((a, b) => b.size - a.size)
|
||||
const tiles: Tile[] = []
|
||||
squarify(items, x, y, w, h, depth, tiles)
|
||||
for (const t of tiles) {
|
||||
out.push(t)
|
||||
const kids = (t.t.children?.length ?? 0) + (t.t.small_size ? 1 : 0) + (t.t.more_size ? 1 : 0)
|
||||
if (!t.t.dir || kids === 0 || t.w < 3 || t.h < 3) continue
|
||||
// the folders directly under the one being looked at get a name strip when there is room;
|
||||
// everything deeper is grouping only
|
||||
t.head = depth === 0 && t.h >= 34 && t.w >= 50 ? 14 : 0
|
||||
const pad = depth === 0 ? 1 : 0
|
||||
layout(t.t, t.x + pad, t.y + pad + t.head, t.w - 2 * pad, t.h - 2 * pad - t.head, depth + 1, out)
|
||||
}
|
||||
}
|
||||
|
||||
function Treemap({ tree, onOpen }: { tree: SpaceTree; onOpen: (path: string) => void }) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
const tiles = useRef<Tile[]>([])
|
||||
const [size, setSize] = useState({ w: 0, h: 0 })
|
||||
const [hover, setHover] = useState<{ x: number; y: number; tile: Tile } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = box.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }))
|
||||
ro.observe(el)
|
||||
setSize({ w: el.clientWidth, h: el.clientHeight })
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const c = canvas.current
|
||||
if (!c || size.w === 0 || size.h === 0) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
c.width = Math.round(size.w * dpr); c.height = Math.round(size.h * dpr)
|
||||
const g = c.getContext('2d')
|
||||
if (!g) return
|
||||
g.scale(dpr, dpr)
|
||||
const light = lightTheme()
|
||||
const bg = cssVar('--bg2') || '#171b21', border = cssVar('--border') || '#2a313b', fg = cssVar('--fg') || '#d0d7de', muted = cssVar('--muted') || '#7d8590'
|
||||
g.fillStyle = bg
|
||||
g.fillRect(0, 0, size.w, size.h)
|
||||
const out: Tile[] = []
|
||||
layout(tree, 0, 0, size.w, size.h, 0, out)
|
||||
tiles.current = out
|
||||
const font = cssVar('--font') || 'system-ui, sans-serif'
|
||||
const dirFill = light ? '#dfe3e8' : '#1c2229'
|
||||
for (const t of out) {
|
||||
if (t.w < 0.5 || t.h < 0.5) continue
|
||||
const isDir = t.t.dir && !t.agg
|
||||
if (isDir) {
|
||||
// the folder's own colour shows only where its children don't cover it: the strip,
|
||||
// the padding, and anything too small to have been asked for
|
||||
g.fillStyle = dirFill
|
||||
g.fillRect(t.x, t.y, t.w, t.h)
|
||||
if (t.depth <= 1 && t.w >= 4 && t.h >= 4) { g.strokeStyle = border; g.lineWidth = 1; g.strokeRect(t.x + 0.5, t.y + 0.5, t.w - 1, t.h - 1) }
|
||||
if (t.head) {
|
||||
g.save()
|
||||
g.beginPath(); g.rect(t.x + 1, t.y + 1, t.w - 2, t.head - 1); g.clip()
|
||||
g.font = `600 11px ${font}`; g.fillStyle = fg; g.textBaseline = 'top'
|
||||
const label = t.t.name
|
||||
g.fillText(label, t.x + 4, t.y + 1, t.w - 8)
|
||||
const sz = fmtBytes(t.t.size)
|
||||
if (t.w > g.measureText(label).width + g.measureText(sz).width + 20) { g.font = `10px ${font}`; g.fillStyle = muted; g.textAlign = 'right'; g.fillText(sz, t.x + t.w - 4, t.y + 2) }
|
||||
g.restore()
|
||||
}
|
||||
continue
|
||||
}
|
||||
let fill: string
|
||||
if (t.agg) fill = t.agg === 'small' ? (light ? '#c4cad1' : '#39424c') : (light ? '#d3d8de' : '#2f3740')
|
||||
else { const k = kindOf(t.t.name); fill = light ? k.light : k.dark }
|
||||
g.fillStyle = fill
|
||||
g.fillRect(t.x, t.y, t.w, t.h)
|
||||
if (t.w >= 3 && t.h >= 3) { g.strokeStyle = bg; g.lineWidth = 1; g.strokeRect(t.x + 0.5, t.y + 0.5, t.w - 1, t.h - 1) }
|
||||
if (t.w >= 46 && t.h >= 14) {
|
||||
g.save()
|
||||
g.beginPath(); g.rect(t.x + 1, t.y + 1, t.w - 2, t.h - 2); g.clip()
|
||||
g.font = `11px ${font}`; g.textBaseline = 'top'
|
||||
g.fillStyle = t.agg ? muted : (light ? '#111' : '#fff')
|
||||
g.fillText(t.t.name, t.x + 4, t.y + 2, t.w - 8)
|
||||
if (t.h >= 28) { g.font = `10px ${font}`; g.fillStyle = t.agg ? muted : (light ? 'rgba(0,0,0,.7)' : 'rgba(255,255,255,.8)'); g.fillText(fmtBytes(t.t.size), t.x + 4, t.y + 15, t.w - 8) }
|
||||
g.restore()
|
||||
}
|
||||
}
|
||||
}, [tree, size])
|
||||
|
||||
const at = (e: React.MouseEvent) => {
|
||||
const r = canvas.current!.getBoundingClientRect()
|
||||
const x = e.clientX - r.left, y = e.clientY - r.top
|
||||
// the deepest tile under the pointer
|
||||
let best: Tile | null = null
|
||||
for (const t of tiles.current) if (x >= t.x && x < t.x + t.w && y >= t.y && y < t.y + t.h && (!best || t.depth > best.depth)) best = t
|
||||
return { x, y, tile: best }
|
||||
}
|
||||
// a path relative to the folder being looked at, for the tooltip
|
||||
const within = (p: string) => { const base = tree.path === '/' ? '' : tree.path; return p.startsWith(base + '/') ? p.slice(base.length + 1) : p }
|
||||
return (
|
||||
<div className="space-canvas" ref={box}>
|
||||
<canvas ref={canvas} style={{ width: size.w, height: size.h }}
|
||||
onMouseMove={(e) => { const h = at(e); setHover(h.tile ? { x: h.x, y: h.y, tile: h.tile } : null) }}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onClick={(e) => { const h = at(e); if (!h.tile) return; const t = h.tile.t; onOpen(t.dir && !h.tile.agg ? t.path : parentOf(t.path)) }} />
|
||||
{hover && (
|
||||
<div className="space-tip" style={{ left: Math.min(hover.x + 12, size.w - 280), top: Math.min(hover.y + 12, size.h - 70) }}>
|
||||
<div className="mono small" style={{ wordBreak: 'break-all' }}>{hover.tile.agg ? hover.tile.t.name + ' in ' + (within(parentOf(hover.tile.t.path)) || '.') : within(hover.tile.t.path)}</div>
|
||||
<div><b>{fmtBytes(hover.tile.t.size)}</b> <span className="muted">· {tree.size > 0 ? ((hover.tile.t.size / tree.size) * 100).toFixed(1) : 0}% of {tree.name === '/' ? 'the disk' : tree.name}</span></div>
|
||||
{hover.tile.t.dir && !hover.tile.agg && <div className="muted small">{fmtN(hover.tile.t.files)} files · {fmtN(hover.tile.t.dirs)} folders · click to open</div>}
|
||||
{!hover.tile.t.dir && !hover.tile.agg && <div className="muted small">{kindOf(hover.tile.t.name).label} · {fmtWhen(hover.tile.t.mtime)}{hover.tile.t.apparent !== hover.tile.t.size ? ` · ${fmtBytes(hover.tile.t.apparent)} apparent` : ''}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the largest files on the disk ----
|
||||
|
||||
function LargestFiles({ disk }: { disk: string }) {
|
||||
const [files, setFiles] = useState<SpaceFile[] | null>(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
useEffect(() => { api.spaceLargest(disk, 1000).then(setFiles).catch((e) => toast(e instanceof Error ? e.message : 'failed', 'err')) }, [disk])
|
||||
if (!files) return <Loading label="Finding the largest files…" />
|
||||
const q = filter.toLowerCase()
|
||||
const shown = q ? files.filter((f) => f.path.toLowerCase().includes(q)) : files
|
||||
const top = shown[0]?.size ?? 1
|
||||
return (
|
||||
<div className="space-files">
|
||||
<div className="dash-bar"><input className="filter" placeholder="filter by path" value={filter} onChange={(e) => setFilter(e.target.value)} spellCheck={false} /><span className="muted small">the {fmtN(files.length)} largest files{q ? ` · ${fmtN(shown.length)} match` : ''}</span></div>
|
||||
<div className="space-rows">
|
||||
{shown.map((f) => (
|
||||
<div key={f.path} className="space-row file">
|
||||
<span className="n"><span className="ico" style={{ background: lightTheme() ? kindOf(f.path).light : kindOf(f.path).dark }} /><span className="nm mono" title={f.path}>{f.path}</span></span>
|
||||
<span className="r mono">{fmtBytes(f.size)}</span>
|
||||
<span className="pct"><span className="bar"><span style={{ width: `${(f.size / top) * 100}%` }} /></span></span>
|
||||
<span className="r d muted small">{fmtWhen(f.mtime)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// The Users page: your own account, where it is signed in, and the accounts on this host.
|
||||
// Hub-only, like Machines: accounts are each machine's own, and a hub reaches a node with
|
||||
// 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 { createRoot } from 'react-dom/client'
|
||||
import { api, Me, Session, User } from './api'
|
||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||
|
||||
export default function Users({ me }: { me: Me }) {
|
||||
return (
|
||||
<div className="dash settings">
|
||||
<Account me={me} />
|
||||
<Sessions />
|
||||
<Accounts me={me} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Account({ me }: { me: Me }) {
|
||||
const [cur, setCur] = useState('')
|
||||
const [pw1, setPw1] = useState('')
|
||||
const [pw2, setPw2] = useState('')
|
||||
const [pwErr, setPwErr] = useState('')
|
||||
async function changePassword(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setPwErr('')
|
||||
if (pw1 !== pw2) { setPwErr('New passwords differ.'); return }
|
||||
if (pw1.length < 12) { setPwErr('At least 12 characters.'); return }
|
||||
try { await api.changePassword(cur, pw1); toast('password changed'); setCur(''); setPw1(''); setPw2('') }
|
||||
catch (err) { setPwErr(err instanceof Error ? err.message : 'failed') }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar"><h2 className="section">Account · {me.account ?? '?'}@{me.host}</h2></div>
|
||||
<form className="card addform" onSubmit={changePassword}>
|
||||
<label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label>
|
||||
<label>New password <input type="password" value={pw1} onChange={(e) => setPw1(e.target.value)} autoComplete="new-password" required /></label>
|
||||
<label>New password again <input type="password" value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" required /></label>
|
||||
{pwErr && <div className="error">{pwErr}</div>}
|
||||
<div className="svc-actions"><button className="btn on" disabled={!cur || !pw1 || !pw2}>Change password</button><span className="muted small">Your login on this host only. Other machines keep their own accounts. Your sessions stay signed in.</span></div>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Where this account is signed in. Revoking one signs that device out at its next request.
|
||||
function Sessions() {
|
||||
const [list, setList] = useState<Session[] | null>(null)
|
||||
const load = useCallback(async () => { try { setList(await api.sessions()) } catch { setList([]) } }, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const revoke = async (s: Session) => {
|
||||
if (s.current && !await askYes('Sign out this device? You will be back at the login form.')) return
|
||||
try { await api.revokeSession(s.id); toast(s.current ? 'signed out' : 'session revoked'); if (s.current) window.dispatchEvent(new Event('hamlab:unauthenticated')); else load() }
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const others = list?.filter((s) => !s.current).length ?? 0
|
||||
const revokeOthers = async () => {
|
||||
if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return
|
||||
try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar"><h2 className="section">Sessions</h2><span className="spacer" /><span className="muted small">where you are signed in</span></div>
|
||||
<section className="card">
|
||||
{list === null && <Loading label="Loading…" />}
|
||||
{list && list.map((s) => (
|
||||
<div className="row" key={s.id}>
|
||||
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
|
||||
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
|
||||
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
|
||||
</div>
|
||||
))}
|
||||
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The accounts on this host. Anyone signed in may add one; removing one, or resetting a
|
||||
// password, asks for your own password first.
|
||||
function Accounts({ me }: { me: Me }) {
|
||||
const [list, setList] = useState<User[] | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [pw, setPw] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [reset, setReset] = useState<User | null>(null)
|
||||
const load = useCallback(async () => { try { setList(await api.users()) } catch { setList([]) } }, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const add = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr('')
|
||||
if (pw.length < 12) { setErr('At least 12 characters.'); return }
|
||||
try { await api.addUser(name.trim(), pw); toast(`${name.trim()} added`); setName(''); setPw(''); load() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
const remove = async (u: User) => {
|
||||
const cur = await askPassword(`Remove the account "${u.name}"? Their sessions end and their settings are gone. Your password to confirm:`)
|
||||
if (cur === null) return
|
||||
try { await api.removeUser(u.name, cur); toast(`${u.name} removed`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar"><h2 className="section">Users · {me.host}</h2><span className="spacer" /><span className="muted small">each with their own look and dashboard</span></div>
|
||||
<section className="card">
|
||||
{list === null && <Loading label="Loading…" />}
|
||||
{list && list.map((u) => (
|
||||
<div className="row" key={u.name}>
|
||||
<span className="k mono">{u.name}{u.self && <span className="badge" style={{ marginLeft: 6 }}>you</span>}</span>
|
||||
<span className="muted small">{u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()}</span>
|
||||
<span style={{ display: 'flex', gap: 6 }}>
|
||||
{!u.self && <Btn className="btn" onClick={() => setReset(u)}>Reset password</Btn>}
|
||||
{!u.self && <Btn className="btn danger" onClick={() => remove(u)}>Remove</Btn>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<form className="card addform" onSubmit={add} style={{ marginTop: 8 }}>
|
||||
<label>New user <input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-z0-9][a-z0-9._\-]{0,31}" title="lower-case letters, digits, . _ and -" autoComplete="off" spellCheck={false} autoCapitalize="off" required /></label>
|
||||
<label>Their password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" required /></label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions"><button className="btn on" disabled={!name || !pw}>Add user</button><span className="muted small">Every user can do everything a signed-in console can. Accounts are this host's own; a hub reaches other machines with its token, not with these.</span></div>
|
||||
</form>
|
||||
{reset && <ResetDialog user={reset} onClose={() => setReset(null)} onDone={() => { setReset(null); load() }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetDialog({ user, onClose, onDone }: { user: User; onClose: () => void; onDone: () => void }) {
|
||||
const [cur, setCur] = useState('')
|
||||
const [pw, setPw] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
const go = async () => {
|
||||
setErr('')
|
||||
if (pw.length < 12) { setErr('At least 12 characters.'); return }
|
||||
try { await api.resetPassword(user.name, cur, pw); toast(`password of ${user.name} reset`); onDone() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}>
|
||||
<h3>Reset the password of {user.name}</h3>
|
||||
<label className="muted">Their new password<input type="password" autoFocus value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" /></label>
|
||||
<label className="muted">Your password, to confirm<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
|
||||
<div className="muted small">They are signed out everywhere and sign in again with the new one.</div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions"><button type="submit" className="btn on" disabled={!cur || !pw}>Reset</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A password typed into a modal; null when cancelled.
|
||||
function askPassword(prompt: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const done = (v: string | null) => { root.unmount(); host.remove(); resolve(v) }
|
||||
const root = createRoot(host)
|
||||
root.render(<PasswordDialog prompt={prompt} onDone={done} />)
|
||||
})
|
||||
}
|
||||
|
||||
function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) {
|
||||
const [v, setV] = useState('')
|
||||
useEscape(() => onDone(null))
|
||||
return (
|
||||
<div className="modal-back" onClick={() => onDone(null)}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}>
|
||||
<div>{prompt}</div>
|
||||
<input type="password" autoFocus value={v} onChange={(e) => setV(e.target.value)} autoComplete="current-password" />
|
||||
<div className="svc-actions"><button type="submit" className="btn danger" disabled={!v}>Confirm</button><span className="spacer" /><button type="button" className="btn" onClick={() => onDone(null)}>Cancel</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A short name for a browser from its user agent: "Safari on iPhone", "Firefox on Linux".
|
||||
function device(ua?: string): string {
|
||||
if (!ua) return 'unknown device'
|
||||
const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : ''
|
||||
const br = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : /curl\//.test(ua) ? 'curl' : ua.split(/[\s/]/)[0] || 'browser'
|
||||
return os ? `${br} on ${os}` : br
|
||||
}
|
||||
|
||||
function ago(iso?: string): string {
|
||||
if (!iso) return 'never'
|
||||
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`
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Sensor, fetchCatalog, fmtValue } from './sensors'
|
||||
import { apiPath } from './api'
|
||||
import { WIDGET_TYPES, WidgetSpec, WidgetType } from './widgets'
|
||||
import { Btn, Loading, useEscape } from './ui'
|
||||
|
||||
// Pick a sensor from this machine's catalog (or type a pattern) and how to draw it.
|
||||
export default function WidgetPicker({ onAdd, onClose, initial }: { onAdd: (w: WidgetSpec) => void; onClose: () => void; initial?: WidgetSpec }) {
|
||||
const [cat, setCat] = useState<Sensor[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sensor, setSensor] = useState(initial?.sensor ?? '')
|
||||
const [type, setType] = useState<WidgetType>(initial?.type ?? 'stat')
|
||||
const [title, setTitle] = useState(initial?.title ?? '')
|
||||
const [bad, setBad] = useState('')
|
||||
// One bad pattern would 400 the stream every widget on the page shares, so ask the API first.
|
||||
const add = async () => {
|
||||
try {
|
||||
const r = await fetch(apiPath('/api/sensors/query'), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ w: sensor }) })
|
||||
if (!r.ok) { setBad((await r.json().catch(() => ({}))).error ?? `the API refused it (${r.status})`); return }
|
||||
} catch { setBad('could not reach the API'); return }
|
||||
onAdd({ id: initial?.id ?? 'w' + Date.now().toString(36), type: pattern ? 'list' : type === 'list' ? 'stat' : type, sensor, title: title.trim() || undefined })
|
||||
}
|
||||
useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
|
||||
const shown = useMemo(() => {
|
||||
const f = filter.trim().toLowerCase()
|
||||
return (cat ?? []).filter((s) => !f || s.id.includes(f) || s.name.toLowerCase().includes(f))
|
||||
}, [cat, filter])
|
||||
useEscape(onClose)
|
||||
const pattern = /[*?[]/.test(sensor)
|
||||
const pick = (id: string) => { setSensor(id); setBad(''); if (/[*?[]/.test(id)) setType('list') }
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal picker" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{initial ? 'Edit widget' : 'Add a widget'}</h3>
|
||||
<input autoFocus value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter sensors — cpu, temp, fan, enp3s0…" />
|
||||
{err && <div className="error">{err}</div>}
|
||||
{!cat && !err && <Loading label="Reading the catalog…" />}
|
||||
{cat && (
|
||||
<div className="picker-list">
|
||||
{shown.map((s) => (
|
||||
<button key={s.id} className={'picker-row' + (s.id === sensor ? ' active' : '')} onClick={() => pick(s.id)}>
|
||||
<span className="mono">{s.id}</span><span className="muted num">{fmtValue(s.value, s.unit)}</span>
|
||||
</button>
|
||||
))}
|
||||
{shown.length === 0 && <div className="muted small" style={{ padding: 8 }}>No sensor matches.</div>}
|
||||
</div>
|
||||
)}
|
||||
<label className="muted">Sensor ID, or a pattern for a list (<span className="mono">fan.*</span>, <span className="mono">cpu.core.*.usage</span>)
|
||||
<input className="mono" value={sensor} onChange={(e) => pick(e.target.value.trim())} placeholder="cpu.usage" />
|
||||
</label>
|
||||
<div className="chips">
|
||||
{WIDGET_TYPES.filter((t) => !!t.many === pattern).map((t) => <button key={t.id} className={'chip' + (type === t.id ? ' on' : '')} onClick={() => setType(t.id)}>{t.label}</button>)}
|
||||
</div>
|
||||
<label className="muted">Title (optional)
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="the sensor's own name" />
|
||||
</label>
|
||||
{bad && <div className="error">{bad}</div>}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={!sensor} onClick={add}>{initial ? 'Save' : 'Add'}</Btn>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// ZFS: pools, datasets, snapshots, and a schedule for taking and expiring them.
|
||||
//
|
||||
// Split out of the Disks tab on 2026-09-24. Disks is the hardware; this is the filesystem
|
||||
// on top of it, which has enough of its own to say.
|
||||
//
|
||||
// On the schedule: `grid` has run zfSnap from root cron since before Ham Lab existed, and it
|
||||
// works. So this reads and writes the same snapshot names — `2026-09-24_02.30.01--6w`, with
|
||||
// the retention in the name — rather than inventing a second convention for the two tools to
|
||||
// disagree over. An existing cron job is detected and shown, so the page tells the truth
|
||||
// about the machine whether Ham Lab or something else is doing the work.
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
|
||||
|
||||
const ago = (t?: string | number) => {
|
||||
if (!t) return 'never'
|
||||
const s = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000)
|
||||
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`
|
||||
}
|
||||
const soon = (t?: string) => {
|
||||
if (!t) return '—'
|
||||
const s = (new Date(t).getTime() - Date.now()) / 1000
|
||||
return s < 60 ? 'any moment' : s < 3600 ? `in ${Math.round(s / 60)} min` : s < 86400 ? `in ${Math.round(s / 3600)} h` : `in ${Math.round(s / 86400)} d`
|
||||
}
|
||||
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const describe = (p: ZFSPolicy) => {
|
||||
const when = p.every === 'custom' ? `on ${p.cron}`
|
||||
: p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}`
|
||||
: p.every === 'daily' ? `every day at ${p.at}`
|
||||
: p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}`
|
||||
: `on day ${p.day} of the month at ${p.at}`
|
||||
return `${when} · keep ${p.keep_for}${p.recursive ? ' · recursive' : ''}`
|
||||
}
|
||||
|
||||
// ---- pools and datasets (moved from the Disks tab) ----
|
||||
function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: () => void }) {
|
||||
const scrubbing = /scrub in progress/i.test(p.scan ?? '')
|
||||
const paused = /scrub paused/i.test(p.scan ?? '')
|
||||
const [cfg, setCfg] = useState(false)
|
||||
const act = (a: 'start' | 'pause' | 'stop') => async () => {
|
||||
try { await api.zfsScrub(p.name, a); toast(`scrub ${a}: ok`); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (p.health === 'ONLINE' ? 'ok' : p.health === 'DEGRADED' ? 'warn' : 'bad')} />
|
||||
<span className="dname">{p.name}</span>
|
||||
<span className="badge">{p.health}</span>
|
||||
<span className="spacer" />
|
||||
<span className="num">{fmtBytes(p.alloc)} / {fmtBytes(p.size)} · {p.cap}%</span>
|
||||
</div>
|
||||
<Meter frac={p.cap / 100} cls={p.cap >= 90 ? 'bad' : p.cap >= 80 ? 'warn' : ''} />
|
||||
<div className="props" style={{ marginTop: 8 }}>
|
||||
<div><span className="k">Free</span><span>{fmtBytes(p.free)} · fragmentation {p.frag}% · dedup {p.dedup}</span></div>
|
||||
{p.scan && <div><span className="k">Scan</span><span style={{ whiteSpace: 'normal' }}>{p.scan}</span></div>}
|
||||
{p.errors && <div><span className="k">Errors</span><span className={p.errors.startsWith('No known') ? '' : 'warn-text'}>{p.errors}</span></div>}
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
{!scrubbing && <Btn className="btn" confirm={`Start a scrub of ${p.name}? It reads the whole pool and can take many hours.`} onClick={act('start')}>{paused ? 'Resume scrub' : 'Scrub'}</Btn>}
|
||||
{scrubbing && <Btn className="btn" onClick={act('pause')}>Pause scrub</Btn>}
|
||||
{(scrubbing || paused) && <Btn className="btn" confirm="Stop the scrub?" onClick={act('stop')}>Stop scrub</Btn>}
|
||||
<button className="btn" onClick={() => setCfg(!cfg)}>{cfg ? 'Hide vdevs' : 'Vdevs'}</button>
|
||||
</div>
|
||||
{cfg && <pre className="unit-file">{p.config}</pre>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
|
||||
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
async function toggle() {
|
||||
setOpen(!open)
|
||||
if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } }
|
||||
}
|
||||
async function snapshot() {
|
||||
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
|
||||
const name = await askText(`Snapshot name for ${d.name}@`, def)
|
||||
if (!name) return
|
||||
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const depth = d.name.split('/').length - 1
|
||||
return (
|
||||
<>
|
||||
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
|
||||
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
|
||||
<span className="num">{fmtBytes(d.used)}</span>
|
||||
<span className="num muted">{fmtBytes(d.refer)}</span>
|
||||
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
|
||||
<span className="num muted">{d.snapshots}</span>
|
||||
<span className="num muted">{d.compressratio}</span>
|
||||
<span className="ds-mount muted">{d.mount}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="ds-snaps">
|
||||
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div>
|
||||
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
|
||||
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- schedules ----
|
||||
|
||||
function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; onAdopt: () => void }) {
|
||||
return (
|
||||
<div className={'notice' + (mine > 0 ? ' attn' : '')} style={{ marginBottom: 8 }}>
|
||||
<b>{x.tool}</b> is already taking snapshots on this machine, from cron as <b>{x.user}</b>:
|
||||
<div className="mono small" style={{ margin: '4px 0', wordBreak: 'break-all' }}>{x.schedule} {x.command}</div>
|
||||
<span className="muted">in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''}</span>
|
||||
{mine > 0
|
||||
? <div className="warn" style={{ marginTop: 6 }}>A Ham Lab schedule is running as well. Both use the same names and both only remove snapshots that have passed the expiry written into them, so they will not fight — but they will take twice as many. Keep one.</div>
|
||||
: <div className="svc-actions" style={{ marginTop: 6 }}>
|
||||
<Btn className="btn" onClick={onAdopt}>Adopt as a schedule</Btn>
|
||||
<span className="muted small">Copies it into a Ham Lab schedule you can edit here. Turn the old cron job off on the Jobs tab once you are happy with it.</span>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
type ExternalJob = ZFSExternal
|
||||
|
||||
function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) {
|
||||
const [expired, setExpired] = useState<ZFSSnapshot[] | null>(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
const preview = async () => {
|
||||
setBusy('preview')
|
||||
try { setExpired((await api.zfsExpired(p.id)).expired) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
||||
}
|
||||
const run = async () => {
|
||||
const n = expired?.length
|
||||
const warn = n ? ` It will also destroy ${n} expired snapshot${n === 1 ? '' : 's'}.` : ''
|
||||
if (!await askYes(`Take a snapshot of ${p.dataset} now?${warn}`, 'Run now')) return
|
||||
setBusy('run')
|
||||
try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); setExpired(null); onChanged() }
|
||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!await askYes(`Remove the schedule "${p.name}"? The snapshots it already took are left exactly as they are.`, 'Remove')) return
|
||||
try { await api.deleteZfsSchedule(p.id); toast('schedule removed'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const toggle = async () => {
|
||||
try { await api.saveZfsSchedule({ ...p, enabled: !p.enabled }); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (last.error ? 'bad' : p.enabled ? 'ok' : '')} />
|
||||
<span className="dname">{p.name || p.dataset}</span>
|
||||
{!p.enabled && <span className="badge">paused</span>}
|
||||
<span className="spacer" />
|
||||
<span className="muted small">{p.enabled ? soon(next) : 'not scheduled'}</span>
|
||||
</div>
|
||||
<div className="props" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Dataset</span><span className="mono">{p.dataset}</span></div>
|
||||
<div><span className="k">When</span><span>{describe(p)}</span></div>
|
||||
<div><span className="k">Cron</span><span className="mono small" title={`in ${'/etc/cron.d/hamlab-zfs'}`}>{cron}</span></div>
|
||||
<div><span className="k">Last run</span><span>{ago(last.at)}{last.pruned?.length ? ` · ${last.pruned.length} expired removed` : ''}</span></div>
|
||||
{last.taken && <div><span className="k">Last snapshot</span><span className="mono small">{last.taken.split('@')[1]}</span></div>}
|
||||
{p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
|
||||
</div>
|
||||
{last.error && <div className="error" style={{ marginTop: 8 }}>{last.error}</div>}
|
||||
{expired && (
|
||||
<div className="notice" style={{ marginTop: 8 }}>
|
||||
{expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : (
|
||||
<>
|
||||
<b>{expired.length}</b> snapshot{expired.length === 1 ? '' : 's'} would be destroyed by a run now:
|
||||
<div className="snap-list" style={{ marginTop: 6, maxHeight: 180, overflow: 'auto' }}>
|
||||
{expired.map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleDateString()} · {fmtBytes(s.used)}</span></div>)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn" busy={busy === 'preview'} onClick={preview}>What would it delete?</Btn>
|
||||
<Btn className="btn on" busy={busy === 'run'} onClick={run}>Run now</Btn>
|
||||
<button className="btn" onClick={onEdit}>Edit</button>
|
||||
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const BLANK: Partial<ZFSPolicy> = { name: '', dataset: '', recursive: true, every: 'daily', at: '02:30', weekday: 0, day: 1, keep_for: '6w', keep_min: 1, enabled: true }
|
||||
|
||||
function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolicy>; datasets: string[]; onClose: () => void; onSaved: () => void }) {
|
||||
const [f, setF] = useState<Partial<ZFSPolicy>>({ ...BLANK, ...p })
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
const set = (k: keyof ZFSPolicy, v: unknown) => setF((x) => ({ ...x, [k]: v }))
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr('')
|
||||
try { await api.saveZfsSchedule({ ...f, name: (f.name || '').trim() || f.dataset }); onSaved() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||
<h3>{f.id ? 'Edit schedule' : 'New snapshot schedule'}</h3>
|
||||
<label className="muted">Dataset
|
||||
<select value={f.dataset} onChange={(e) => set('dataset', e.target.value)} required>
|
||||
<option value="">choose…</option>
|
||||
{datasets.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="muted">Name — what this schedule is for
|
||||
<input value={f.name} onChange={(e) => set('name', e.target.value)} placeholder={f.dataset || 'nightly'} maxLength={64} />
|
||||
</label>
|
||||
<label className="muted">How often
|
||||
<span className="chips">
|
||||
{(['hourly', 'daily', 'weekly', 'monthly', 'custom'] as const).map((k) => (
|
||||
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
|
||||
))}
|
||||
</span>
|
||||
</label>
|
||||
{f.every === 'custom' ? (
|
||||
<label className="muted">Cron expression — minute hour day month weekday
|
||||
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 */6 * * *" spellCheck={false} required />
|
||||
</label>
|
||||
) : (
|
||||
<label className="muted">At
|
||||
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
{f.every === 'weekly' && (
|
||||
<label className="muted">On
|
||||
<select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}>
|
||||
{WEEKDAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{f.every === 'monthly' && (
|
||||
<label className="muted">Day of the month — 1 to 28, so every month has one
|
||||
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
|
||||
</label>
|
||||
)}
|
||||
<label className="muted">Snapshot name prefix — optional; the timestamp and retention follow it
|
||||
<input value={f.prefix ?? ''} onChange={(e) => set('prefix', e.target.value)} placeholder="none" spellCheck={false} />
|
||||
</label>
|
||||
<label className="muted">Keep for — written into each snapshot's name, as zfSnap does it: 6w, 30d, 12h
|
||||
<input value={f.keep_for} onChange={(e) => set('keep_for', e.target.value)} placeholder="6w" spellCheck={false} required />
|
||||
</label>
|
||||
<label className="muted">Always keep the newest — a floor, whatever their age
|
||||
<input type="number" min={0} max={99} value={f.keep_min} onChange={(e) => set('keep_min', Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={!!f.recursive} onChange={(e) => set('recursive', e.target.checked)} style={{ width: 'auto' }} />
|
||||
Recursive — one atomic snapshot of this dataset and everything under 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>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on" disabled={!f.dataset || !f.keep_for}>Save</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// adopt reads an existing cron job into the fields of a new schedule, so replacing it is a
|
||||
// matter of checking the numbers rather than retyping them. zfSnap's own flags are read
|
||||
// where they can be: -a <age> is the retention, -r is recursive.
|
||||
function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
|
||||
const cmd = x.command
|
||||
const keep = /-a\s+(\d+[mhdwMy])/.exec(cmd)?.[1] ?? /--\s*(\d+[mhdw])\b/.exec(cmd)?.[1] ?? '6w'
|
||||
const f = x.schedule.trim().split(/\s+/)
|
||||
const min = Number(f[0]), hour = Number(f[1])
|
||||
const simple = f.length === 5 && Number.isInteger(min) && Number.isInteger(hour) && f[2] === '*' && f[3] === '*' && f[4] === '*'
|
||||
const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset
|
||||
return {
|
||||
...BLANK, name: `${x.tool} (adopted)`, dataset: ds,
|
||||
recursive: /\s-\w*r/.test(cmd) || true,
|
||||
every: simple ? 'daily' : 'custom',
|
||||
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
|
||||
cron: simple ? '' : x.schedule.trim(),
|
||||
keep_for: keep, keep_min: 1, enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function ZFS() {
|
||||
const [ov, setOv] = useState<ZFSOverview | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [editing, setEditing] = useState<Partial<ZFSPolicy> | null>(null)
|
||||
const rate = useRefreshMs()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try { setOv(await api.zfsOverview()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => { const t = setInterval(load, Math.max(5000, rate * 10)); return () => clearInterval(t) }, [load, rate])
|
||||
|
||||
if (err && !ov) return <div className="dash"><div className="error">{err}</div></div>
|
||||
if (!ov) return <div className="dash"><Loading label="Reading ZFS…" /></div>
|
||||
const zfs: ZFSInfo = ov.zfs
|
||||
const datasets = zfs.datasets.filter((d) => d.type !== 'volume').map((d) => d.name)
|
||||
const mine = ov.policies.filter((x) => x.policy.enabled).length
|
||||
|
||||
if (!zfs.available) return <div className="dash"><div className="notice">{zfs.message || 'No ZFS on this host.'}</div></div>
|
||||
|
||||
return (
|
||||
<div className="dash">
|
||||
<div className="dash-bar"><h2 className="section">Pools</h2></div>
|
||||
{zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
|
||||
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
|
||||
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
|
||||
</div>
|
||||
{ov.external && <ExternalNotice x={ov.external} mine={mine} onAdopt={() => setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />}
|
||||
{ov.policies.length === 0 && !ov.external && <div className="notice">Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.</div>}
|
||||
<div className="cards">{ov.policies.map(({ policy, cron, next, last }) => (
|
||||
<ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
))}</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Datasets</h2><span className="spacer" /><span className="muted small">used · referenced · in snapshots · count · compression · mount</span></div>
|
||||
<section className="card ds-table">
|
||||
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
||||
</section>
|
||||
|
||||
{editing && <ScheduleDialog p={editing} datasets={datasets.length ? datasets : zfs.pools.map((p) => p.name)} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Each machine wears a colour — a stripe across the header, its name in the switcher — so
|
||||
// you always know which box you are typing into. A colour you pick wins; otherwise the
|
||||
// machine's name picks one, the same one on every device.
|
||||
import { api } from './api'
|
||||
import { currentThemeId } from './themes'
|
||||
import { getRefreshMs } from './prefs'
|
||||
import { getRailOrder } from './railorder'
|
||||
|
||||
export const ACCENTS = ['#e3a33b', '#3fb950', '#58a6ff', '#f778ba', '#ff7b72', '#a371f7', '#39c5cf', '#d4d44a', '#ff9e64', '#9aa7b4']
|
||||
const KEY = 'hamlab.accents'
|
||||
|
||||
export function getAccents(): Record<string, string> { try { return JSON.parse(localStorage.getItem(KEY) ?? '{}') ?? {} } catch { return {} } }
|
||||
export function accentFor(name: string): string {
|
||||
const picked = getAccents()[name]
|
||||
if (picked && /^#[0-9a-f]{6}$/i.test(picked)) return picked
|
||||
let h = 0; for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
|
||||
return ACCENTS[h % ACCENTS.length]
|
||||
}
|
||||
export function applyAccent(name: string) { document.documentElement.style.setProperty('--machine', accentFor(name)) }
|
||||
export function setAccents(all: Record<string, string>, save = true) {
|
||||
localStorage.setItem(KEY, JSON.stringify(all))
|
||||
window.dispatchEvent(new Event('hamlab:accents'))
|
||||
if (save) savePrefs()
|
||||
}
|
||||
// Everything the hub remembers about how the console looks, saved as one object.
|
||||
export function savePrefs() { return api.saveUIPrefs({ theme: currentThemeId(), refreshMs: getRefreshMs(), accents: getAccents(), rail: getRailOrder() }).catch(() => { /* local still applied */ }) }
|
||||