Author SHA1 Message Date
Mitchell-JohnsonandClaude Opus 5 b5454c1022 Public downloads: the installer and the updater come from Gitea releases, no token
REQUIRE_SIGNIN_VIEW is off and mitchell/hamlab is public, so anyone can clone it or pull
a release. The other five repos are individually private and 404 to anonymous. Gitea
resolves releases/download/latest/<asset>, so the install one-liner and HAMLAB_URL point
at the newest release and never need changing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 18:59:42 -07:00
Mitchell-JohnsonandClaude Opus 5 1599ac65ef PLAN: the updater shipped; what is left is where a public download lives
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:33:20 -07:00
Mitchell-JohnsonandClaude Opus 5 68bf3bb218 An updater: what is published on Gitea, and installing it from Settings
A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo
API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is
what make release already writes and what install.sh already downloads. The binary is
checked against SHA256SUMS before it goes near the running one, and a token is sent when
the source is behind a login, so a private Gitea serves its own fleet without opening the
instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the
installer never disagree about which build is newer.

Settings gains an Updates card: installed, published, when it last looked, the source,
release notes, Check for updates, and Install. The rail badges Settings when a newer
build is out. Nothing installs unasked — it restarts the daemon and drops terminals.

scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release.
Deliberate, and not part of deploy: deploy pushes to the machines this hub owns,
publishing offers the build to every install anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:31:32 -07:00
Mitchell-JohnsonandClaude Opus 5 98a8de8091 PLAN: to do — deprecate copyparty, deprecate Dockhand
The Dockhand entry is the app half only; migrating the stacks stays in hamlet's
docs/pending.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:32:52 -07:00
Mitchell-JohnsonandClaude Fable 5.1 ad88a48c01 space: the diff's clock runs inside sudo, so zfs is killed too
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 01:19:19 -07:00
Mitchell-JohnsonandClaude Fable 5.1 314a021a3e space: zfs diffs run side by side, give up after four minutes, and die properly
The timeout killed sudo and left zfs holding the pipe, so the refresh of tank sat on
immich's diff indefinitely. The process group is killed now, and a slow dataset is walked
by mtime instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 01:13:05 -07:00
Mitchell-JohnsonandClaude Fable 5.1 53d1da08a5 Space keeps its scans and refreshes them: zfs diff, folder mtimes, resume after cancel
A scan is saved beside the config and read back after a restart. The next scan starts
from it: on a pool with snapshots zfs diff names what changed and only those folders are
read; elsewhere a folder whose mtime is unchanged keeps its list and only its kept files
are stat'ed again. A cancelled scan keeps what it walked and Resume reads the rest.
Forget drops the saved result. The treemap is every file a tile, grouped by folder, with
no nested boxes: the folder chains of a Linux system made a staircase of empty headers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 00:43:30 -07:00
Mitchell-JohnsonandClaude Fable 5.1 c96d6facad PLAN: Space scans a disk or a pool, from a dropdown
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:28:00 -07:00
Mitchell-JohnsonandClaude Fable 5.1 8f77cc23e1 Space: the picker itself — pools with layout and members, then disks on their own
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:27:20 -07:00
Mitchell-JohnsonandClaude Fable 5.1 e9fa21768e Space: the unit is a disk on its own or a ZFS pool as a whole, picked from a dropdown
A pool's member disks are listed under it, not offered apart; the picker shows the
layout, members, size and scan state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:26:03 -07:00
Mitchell-JohnsonandClaude Fable 5.1 4280b2226a Top: a hyper-thread wears its core's temperature, and the columns line up
The process table carries each logical CPU's core id from sysfs topology; a row with
no reading still has the temperature cells, so the % column is one column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:22:54 -07:00
Mitchell-JohnsonandClaude Fable 5.1 41e63f74e9 space: 32-bit builds — stat fields are int32 there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:19:19 -07:00
Mitchell-JohnsonandClaude Fable 5.1 fc5236471e Space: what is using a disk, WizTree-style
Pick a disk, not a partition; the scan walks every filesystem it backs, as root when
the host allows, in a helper process that streams the tree back. A tree of folders
largest-first over a treemap of the selected folder, nested to the file and coloured by
kind, plus the thousand largest files. The rail no longer offers the hub-only pages
while a remote machine is being looked at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 20:18:36 -07:00
Mitchell-JohnsonandClaude Fable 5.1 7e3e916f25 Arrange the rail: drag pages into your own order, kept with the account
The close cross on a detail pane is drawn, not a glyph, and sits in no box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:59:25 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b8ea7abcc7 A × closes a detail pane on a wide screen: Docker, Services, Cron
On a phone the list gives way to the pane and Back returns; on a desktop the list stays
beside it and there was no way to put the pane away.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:52:35 -07:00
Mitchell-JohnsonandClaude Fable 5.1 027a2a575c A Users tab: own password, sessions and accounts in one place
Settings keeps appearance, rates, tokens and About.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:45:01 -07:00
Mitchell-JohnsonandClaude Fable 5.1 2b54a06821 Users, with their own settings, and a list of where each is signed in
The login is a user name and a password. The old single password becomes the account
named after the OS user on first boot, along with its sessions and the machine-wide look.
Settings gains Users (add, remove, reset a password — your own password confirms) and
Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents
and the dashboard layout follow the account; the refresh rate, graph history and alerts
stay the machine's. Pairing a machine takes a user on it. Log lines name the actor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 18:20:30 -07:00
Mitchell-JohnsonandClaude Opus 5 db214ebc2b PLAN: to do — users, with their own settings
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:36:53 -07:00
Mitchell-JohnsonandClaude Opus 5 e558ca7ace PLAN: to do — a public download page and an updater that uses it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:47:34 -07:00
Mitchell-JohnsonandClaude Opus 5 4b255fe63f A drive is a drive: NVMe namespaces fold into their controller
An Apple SSD presents three namespaces and the kernel lists each as a whole disk, so the
dashboard was showing one drive as three. The Disks page has always folded them; now the
dashboard, the Top tab and the disk.* sensors do too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:11:03 -07:00
Mitchell-JohnsonandClaude Opus 5 5761ee09db BUGS: note the one Firefox console line that is navigation, not a fault
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:11:37 -07:00
Mitchell-JohnsonandClaude Opus 5 5412e316de A live socket stays with the machine it was opened for
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 23:53:18 -07:00
Mitchell-JohnsonandClaude Opus 5 3c7e46104c Bug bash 5: the URL, the fleet loop, and an empty card
A link to a hub page no longer reloads onto the last machine; a link from a hub-only page to
a machine's page arrives at that page; a page being left no longer asks about the next page's
selection; the hub holds its machines to the rate it is itself running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 23:26:36 -07:00
Mitchell-JohnsonandClaude Opus 5 a7b8319974 Disks: the SMART table stays inside its card
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 21:09:43 -07:00
Mitchell-JohnsonandClaude Opus 5 dbfe163a75 A machine is titled by its own hostname, and follows a rename
The name a machine was added under stays its identity — the URL, its accent, what the hub's
API calls it — and is what the Machines page shows as 'Added as'. The hostname is read live
rather than at start-up, so renaming a machine reaches the console without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:04:17 -07:00
Mitchell-JohnsonandClaude Fable 5.1 ec94cb8bff Top: a graph's past holds still — columns draw the peak of their own fixed slice of history
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 10:54:30 -07:00
Mitchell-JohnsonandClaude Fable 5.1 be0f2a5aaf The hub keeps every machine's sampling rate and history window in step
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 10:14:23 -07:00
Mitchell-JohnsonandClaude Fable 5.1 668e716601 Top wears the skin; the refresh rate reaches every machine; bug bash 4
The process table's columns fit the box they are in, the rate control saves, curves are
timed on the viewer's clock and keep to the stride, the power icon is drawn, and a young
history draws in from the right instead of being stretched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 10:12:45 -07:00
Mitchell-JohnsonandClaude Opus 5 ef3dbdf543 Top: a long CPU model keeps to its line
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 23:54:33 -07:00
Mitchell-JohnsonandClaude Opus 5 0587f50142 A Top tab: the whole machine on one screen, in btop's style
Boxes with their name in the border, dot curves that climb green to red, per-core meters and
temperatures, memory and filesystems, per-interface traffic and the process table. Drawn from
the sensor stream and /api/procs, so it works on every machine the console can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 23:52:29 -07:00
Mitchell-JohnsonandClaude Opus 5 b8eb4f5d40 Services, Docker and Cron fill the window when nothing is selected
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:08:09 -07:00
Mitchell-JohnsonandClaude Opus 5 04dd98e2cd Settings: one window for how far back every graph reaches, 1 minute to 24 hours
Each machine keeps 300 points per curve whatever the window, so memory doesn't follow it;
a wider window means wider points, each the average of its bucket rather than one instant
out of it. The hub passes the setting to every machine it manages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 19:07:55 -07:00
Mitchell-JohnsonandClaude Opus 5 885b050d02 The Monitors tab is called Dashboard again
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:29:09 -07:00
Mitchell-JohnsonandClaude Fable 5.1 1e992ca1eb Stacks: Duplicate, Labels and Network in the form, task verdict badges, a short Where; Cancel really cancels
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 13:12:54 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a229f2b597 Dialogs scroll inside themselves on a phone: nothing squeezed, clear of the status bar, buttons pinned
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 12:43:23 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a446a03b7d Bug bash 3: solid HUD dialogs, remote stacks deployed from the hub's shell, stopped-on-purpose is not down, the New stack dialog keeps what you typed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 12:30:15 -07:00
Mitchell-JohnsonandClaude Fable 5.1 634e286796 Docker: delete volumes nothing uses, prune anonymous ones; images marked in use, remove unused
A named volume is deleted by typing its name, one at a time; the API wants it named
twice; nothing is forced. Prune touches anonymous volumes only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 08:01:57 -07:00
Mitchell-JohnsonandClaude Fable 5.1 c017b422d5 Docker: new stacks and containers, stack down/delete, container remove, image pull/remove/prune
A new stack is a folder in a home (where stacks are already kept: Dockhand's group here,
~/stacks elsewhere) with compose.yaml and an optional root-only .env, validated by compose
before it is kept and deployed the way its neighbours were — inside dockhand, or to a node
over tcp from the hub. New container is the same dialog on a form that writes the compose.
Homes are scanned, so undeployed stacks stay listed. Delete moves a stack's files to
.hamlab-trash; nothing is forced, and volumes and data are never touched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 07:50:43 -07:00
Mitchell-JohnsonandClaude Fable 5.1 df6c1dcebf Login: no title under the logo — the logo has the name (it is the image's alt text now)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 19:43:44 -07:00
Mitchell-JohnsonandClaude Fable 5.1 3a882e2d84 New app icon and login logo: the HamLab penguin
Every icon is rendered from web/scripts/logo-source.png by scripts/icons.mjs: trimmed to
the artwork's own edge, transparent corners for the browser icons, a dark ground from the
artwork's palette for iOS (which paints transparency black) and for Android's maskable
icon, where the whole artwork sits inside the 80% safe circle. The login screen no longer
crops the logo to a circle or repeats the wordmark. Service worker cache bumped so
installed apps fetch the new files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 19:41:47 -07:00
Mitchell-JohnsonandClaude Fable 5.1 1d48267d90 Stacks: remote stacks editable from the hub, .env editing, deploy diff, image update checks; a gone container says so
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 16:42:54 -07:00
Mitchell-JohnsonandClaude Fable 5.1 eddba4250b Bug bash 2: 11 bugs in Stacks, Alerts, Docker, skins and the installer, fixed and verified (BUGS.md)
Stacks: deploy with every compose file (labels from inspect, not comma-joined ps);
refuse a stale overwrite; empty list without Docker. Alerts: firing state survives a
restart without re-announcing; vanished alerts are recorded as cleared with the reason;
rule values validated; 200 rules. Docker: 404 for a removed container's logs. Skins: four
palettes lifted to readable contrast. Phone: bigger toggles and swatches. Installer:
deploy republishes; an older published build is never installed silently.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:59:51 -07:00
Mitchell-JohnsonandClaude Fable 5.1 9c4f9dfa9f Docker: a Stacks section — edit a compose file, validate, deploy; fix the sideways scroll on phones
Stacks are found from container labels; the file is resolved on the host or through an
orchestrator's mounts, and compose runs where the file makes sense (inside Dockhand here),
so a redeploy is identical to how the stack was deployed. Save validates with compose and
keeps a .hamlab-bak; Deploy / Update / Restart / Stop run as followed tasks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:23:50 -07:00
Mitchell-JohnsonandClaude Fable 5.1 08fb9d6e38 container.<name>.down: should be up and isn't — not merely stopped; the default alert watches that
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:10:17 -07:00
Mitchell-JohnsonandClaude Fable 5.1 cb1617c2b8 Alerts tab, Docker tab, nine new skins including HUD, a colour for every machine
Alerts: rules over sensors, watched fleet-wide from the hub; ntfy and webhook channels;
firing count on the rail. Docker: containers by stack with live use, start/stop/restart,
following logs, inspect, images/volumes/networks. Skins: HUD, Nord, Gruvbox, Catppuccin
(+Latte), Dracula, Tokyo Night, Solarized (+Light). Accents: per-machine colour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:07:15 -07:00
Mitchell-JohnsonandClaude Fable 5.1 63fcba6077 Rail: an outline clock for Cron instead of the ⏱ emoji
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:06:15 -07:00
Mitchell-JohnsonandClaude Fable 5.1 4542fa6c99 installer messages fit 80 columns
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:14:16 -07:00
Mitchell-JohnsonandClaude Fable 5.1 d9470bbfe1 install.sh: every line fits 80 columns; the closing card is a tidy table
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:13:27 -07:00
Mitchell-JohnsonandClaude Fable 5.1 3c0c504e35 README: the public install one-liner
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:57:31 -07:00
Mitchell-JohnsonandClaude Fable 5.1 e75fa0d759 install.sh: an interactive installer — user, address, HTTPS, firewall, password — that still runs unattended; make publish
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:56:42 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a838adcb27 Machine menu shows each machine's address instead of its load
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:45:24 -07:00
Mitchell-JohnsonandClaude Fable 5.1 d3f2a83249 Terminal: Ctrl+C copies a selection (else ^C), Ctrl+V pastes, over http too; drop Log out everywhere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:44:17 -07:00
Mitchell-JohnsonandClaude Fable 5.1 d7ed4d8549 Quality of life: in-app dialogs, selections in the URL, editable widgets, token dialog, gallery search, status history, stream errors that say why, tab reordering and shortcuts, log out everywhere
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:34:59 -07:00
Mitchell-JohnsonandClaude Fable 5.1 8ee2a2d293 Bug bash 2026-09-19: 17 bugs fixed and verified (BUGS.md)
Widgets: a bad pattern no longer silences every widget; malformed layouts load.
Shell: lost session returns to login at once; tab titles; Escape closes menus and dialogs.
Files: names are one path segment; unsaved edits ask before page/machine/tab changes;
only folders that opened are remembered. Terminals: unique default names; 64-char names.
API: queries capped at 10 000 values; 32 streams per client, 512 in all; only
/embed/widget is frameable. ZFS sensors: one unprivileged zpool list, no sudo journal spam.
Phone: 36 px inputs; dataset rows as wide as their cells. Machines: valid name pattern.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:18:23 -07:00
Mitchell-JohnsonandClaude Fable 5.1 36de4ad4a1 external demo: public-token guidance
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 09:14:30 -07:00
Mitchell-JohnsonandClaude Fable 5.1 47b21a6eca Hub proxy: don't relay a node's policy headers on top of the hub's own
Access-Control-Allow-Origin arrived twice on /api/nodes/<m>/sensors, which a
browser on another origin refuses. Also: demo lists say when the token hides them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 09:11:35 -07:00
Mitchell-JohnsonandClaude Fable 5.1 62605d57eb Widgets tab links the external demo when config.json names one (demo_url)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 08:58:36 -07:00
Mitchell-JohnsonandClaude Fable 5.1 65a9888784 Sensor API answers other origins (token only); external demo page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 08:49:59 -07:00
Mitchell-JohnsonandClaude Fable 5.1 0b0b2897cb Rail: Widgets sits under Machines
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 08:37:52 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b1c1404181 Widgets tab: every widget type and every sensor, live, one of each
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 08:29:15 -07:00
Mitchell-JohnsonandClaude Fable 5.1 4c2eb57ea6 Sensors: per-service and container sensors, pattern-limited tokens, fleet query, steady sampler
- service.<unit>.active|state for services that are enabled, up or failed
- containers.* and container.<name>.running|state|health|mem|cpu (cgroup reads, no docker stats)
- sensors:read tokens can be limited to ID patterns, enforced on every endpoint and,
  through the hub's filter header, on managed machines; the header can only narrow
- /api/fleet/sensors/query: one shaped query, every machine, one request
- the sampler waits what's left of the interval, so 250 ms means 4 a second
- Monitors asks for the families it draws instead of *
- unpowered hwmon sentinels (-93 °C) are not temperatures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 08:08:44 -07:00
Mitchell-JohnsonandClaude Fable 5.1 4ddf10be51 The full switch: Monitors and Disks read only the sensor API; /api/metrics retired
- stock Monitors modules fed by one sensor stream, the catalog and one batch of
  histories (GET /api/sensors/history?ids=…), through sampleAdapter.ts
- the stream closes while the page is hidden and refills its curves on return
- Disks tab live rates from the stream
- new sensors: hamlab.sample_interval, ups.last_event
- stream: asking for about the sampling rate means every sample
- NUT runtime rounded at the source

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:52:14 -07:00
Mitchell-JohnsonandClaude Fable 5.1 f01175ceca Embed: bare=1 is really transparent
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:40:36 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b05a053920 Sensors: read-only tokens, live stream, Prometheus, status sensors, widgets, embeds
- sensors:read tokens open the sensor endpoints (here and on managed machines
  through the hub) and nothing else; full tokens are never accepted from a URL
- /api/sensors/stream: a shaped query that keeps answering, over SSE
- /metrics: Prometheus text format
- services, updates, ZFS and SMART as background status sensors
- widget library over the sensor API; custom widgets on the dashboard
- /embed/widget: one live widget in its own bundle, the only frameable path
- Dashboard tab is now Monitors

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:37:10 -07:00
Mitchell-JohnsonandClaude Fable 5.1 468f45251d priv: a failed root probe is retried, not cached for the life of the daemon
The Mac mini's daemon started while the machine was still booting, sudo -n true
took longer than the 3 s timeout, and sync.Once kept the answer: no root, so the
next self-update could not replace the binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:05:32 -07:00
Mitchell-JohnsonandClaude Fable 5.1 43bf787696 Sensors: one standard read interface — catalog, single value, history, shaped query
Every number the collector samples becomes a sensor with a stable dotted ID
(cpu.core.3.usage, fan.nct6775.fan2, fs.root.used_pct). Clients discover them
from the catalog and ask for exactly what they want, including as a shaped
query: send the object you want, get it back with the values filled in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:04:03 -07:00
Mitchell-JohnsonandClaude Fable 5.1 01aeb226ea Disks: show eMMC firmware revision as text when it is text
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 04:50:05 -07:00
Mitchell-JohnsonandClaude Fable 5.1 232163ff4c Disks: eMMC is one device, not three, and reports wear from its own registers
mmcblkNboot0/boot1/rpmb are hardware areas of the chip; hide them on Disks and
Dashboard. Badge eMMC/SD instead of SSD. smartctl cannot talk to MMC, so read
life_time and pre_eol_info from /sys instead of showing its error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 04:48:52 -07:00
Mitchell-JohnsonandClaude Fable 5.1 488d929733 Portable install: the binary installs itself; static builds on five architectures; root via sudo, doas or uid 0; every package manager; older systemd; no lsblk
- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through
  sudo or doas, runs as the invoking user, sets or generates the password, names only
  journal groups that exist, waits for /healthz, reports an active firewalld/ufw.
  --purge and the data chown never touch a directory that isn't Ham Lab's alone.
- install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the
  binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm
  (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc.
- internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas.
- Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions.
- Services falls back to the plain systemctl table before systemd 246 and says so on
  hosts without systemd. Disks falls back to /sys/block without lsblk.
- Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure.
- Self-update: the new build must pass `check` against the live config, is put in
  place by atomic rename, and the daemon re-execs itself (same under any supervisor).
- State and config writes are fsynced; a corrupt state.json is set aside, not fatal.
- Terminal shutdown no longer empties the saved list, so names come back after a
  restart by design rather than by winning a race.
- Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:00:20 -07:00
Mitchell-JohnsonandClaude Fable 5.1 91b7f4f009 Terminal sessions move from the rail into a tab strip on the terminal page
The rail is now a flat list of pages; Terminal is one of them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:10:22 -07:00
Mitchell-JohnsonandClaude Fable 5.1 7eb00d089f BUGS.md: accurate reap verification; note the resurrect fix
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:04:20 -07:00
Mitchell-JohnsonandClaude Fable 5.1 1ee4673d7c Don't resurrect exited terminals on daemon restart
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:53:29 -07:00
Mitchell-JohnsonandClaude Fable 5.1 44e85b768f BUGS.md: fix pass outcomes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:53:05 -07:00
Mitchell-JohnsonandClaude Fable 5.1 c1805f6ad5 Bug 14: stream journal reads instead of buffering them; soft memory limit for the GC
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:51:09 -07:00
Mitchell-JohnsonandClaude Fable 5.1 03be65ee99 pprof behind the login for memory investigation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:47:16 -07:00
Mitchell-JohnsonandClaude Fable 5.1 f093b322ee Bug bash fixes: hash routing + back button, hub-only pages reset on machine switch, exited terminals reap after 10 min, self-update refuses while busy unless forced, touch-sized controls, power confirm by machine name, down interfaces collapsed and empty modules hidden, load label, offline backoff, cron log newest first, procs empty message, timer timestamp parsing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:46:16 -07:00
Mitchell-JohnsonandClaude Fable 5.1 27f9665606 Bug bash fixes: special-file reads, per-client lockout, prefs validation, security headers, kernel-thread guard, login feedback, error text; plus journal-quiet sudoers + journal groups, upload overwrite guard, machines pattern, dnf freshness, cron/services speed, clean version
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:41:52 -07:00
Mitchell-JohnsonandClaude Fable 5.1 57c58ebffb Bug bash 2026-09-18: findings list (discovery only)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:34:55 -07:00
Mitchell-JohnsonandClaude Fable 5.1 0af24f41c3 Disks: one card per physical drive (NVMe namespaces folded under their controller), partitions summarised on one line instead of listed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:18:20 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a372378098 Mac mini support: arm64 cross-build installed beside the binary and pushed by arch; ARM machine name from the device tree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:13:09 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b6502ba362 Disks: one toggle opens SMART for every drive, remembered per device
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:41:38 -07:00
Mitchell-JohnsonandClaude Fable 5.1 4b8a2b15ed Refresh rate is honoured exactly everywhere: no list floor, sampler up to 10 s, server caches reduced to coalescing only
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:32:36 -07:00
Mitchell-JohnsonandClaude Fable 5.1 bbf0f2580f Global refresh rate in Settings drives every live view and each machine's sampler; per-page pickers removed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:27:13 -07:00
Mitchell-JohnsonandClaude Fable 5.1 a4f39859b6 Settings page: themes (Dark, Light, Cyberpunk, Retro) with xterm palettes and hub-synced choice, change password, access tokens, about
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:14:01 -07:00
Mitchell-JohnsonandClaude Fable 5.1 254e0cdd58 Update from hub: node self-update endpoint, hub push per machine / all, push-nodes CLI run by make deploy, outdated badges + Update buttons on Machines
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:04:31 -07:00
Mitchell-JohnsonandClaude Fable 5.1 e9be94e32b SMART: detect drives whose firmware rejects the self-test log (WD SN580) and hide the test buttons with an explanation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 11:37:49 -07:00
Mitchell-JohnsonandClaude Fable 5.1 340232b724 Header dot: don't go red on machine switch; reflect remote reachability
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 11:36:04 -07:00
Mitchell-JohnsonandClaude Fable 5.1 7dcdb5dc98 Multi-host phase 1: API tokens, auto TLS with pinned fingerprints, hub-side node manager + reverse proxy (WebSockets included), machine switcher in the rail, Machines page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 11:35:29 -07:00
Mitchell-JohnsonandClaude Fable 5.1 fc42675e9f Processes: show current / max clock so idle scaling isn't mistaken for the rated speed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 06:13:24 -07:00
Mitchell-JohnsonandClaude Fable 5.1 bd475eaafd Processes tab: whole page scrolls, tools + column header pinned together, detail sheet sticks to the bottom, metrics boxes collapsible
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 06:12:29 -07:00
Mitchell-JohnsonandClaude Fable 5.1 b802dcf313 Processes tab: btop-style cpu/mem/net boxes with labels, gradient bars, block graph, CPU model/GHz/temp, memory breakdown, per-interface rates, and a refresh interval control
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 06:05:09 -07:00
Mitchell-JohnsonandClaude Fable 5.1 e4df878ac0 Processes tab (btop-style): every process with CPU%, memory, cpu time, start, sortable + filter, per-core and memory bars, detail pane with terminate/kill; module removed from the dashboard; sampling only while the tab is open
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 06:01:12 -07:00
Mitchell-JohnsonandClaude Fable 5.1 ff922bda74 Dashboard: Processes module (top by CPU/memory, PID, user, threads, SIGTERM), sampled at most every 2 s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 05:58:11 -07:00
Mitchell-JohnsonandClaude Fable 5.1 4986af5ac9 Drop the top activity bar
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 05:55:11 -07:00
Mitchell-JohnsonandClaude Fable 5.1 116f4c72f6 Rail: proper folder icon for Files (inline SVG, matches the monochrome glyphs)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 05:54:20 -07:00
Mitchell-JohnsonandClaude Fable 5.1 2d23ba8582 Files rail icon: use a glyph iOS renders
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 05:48:33 -07:00
Mitchell-JohnsonandClaude Fable 5.1 63b020d556 Files tab: browser with breadcrumbs/sort/filter, text editor, image preview, streamed upload with progress, download, mkdir/touch/rename/move/chmod/delete, open-in-terminal; root toggle via sudo-reexecuted 'hamlab fs' helper
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 05:39:57 -07:00
Mitchell-JohnsonandClaude Fable 5.1 38d4ee09ae storage: re-probe smartctl lazily so an install from the Disks tab works without a restart
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 22:53:32 -07:00
Mitchell-JohnsonandClaude Fable 5.1 c704e87065 Disks tab: lsblk devices with partitions/usage/live IO, SMART via smartctl (standby-aware, self-tests, install button), ZFS pools/datasets/snapshots with scrub and snapshot-now
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 22:48:08 -07:00
Mitchell-JohnsonandClaude Fable 5.1 724f30c02b Feedback sweep: spinners on every async button, toasts for action outcomes, top activity bar, loading states on every page, terminal connecting overlay, dashboard save indicator, task pane pinned above the package list with auto-scroll and completion toast
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 18:04:45 -07:00
Mitchell-JohnsonandClaude Fable 5.1 d08b246b5f Land on the Dashboard by default
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 17:44:08 -07:00
Mitchell-JohnsonandClaude Fable 5.1 acb11193b9 Updates tab (apt list/refresh/upgrade/changelog, reboot flag) on a new background tasks package with live WS streaming; header power button with hostname confirmation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 17:40:29 -07:00
Mitchell-JohnsonandClaude Fable 5.1 41c2f05535 Cron tab: crontabs, cron.d, user crontabs, run-parts dirs and systemd timers in one list; next/last run, run now, enable/disable, in-place editing, cron log and output file
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:21:35 -07:00
Mitchell-JohnsonandClaude Fable 5.1 bc891b1042 Services tab: systemd unit list/filter, detail with start/stop/restart/reload/enable/disable, live journal tail over WS, unit file viewer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 15:35:03 -07:00
Mitchell-JohnsonandClaude Fable 5.1 cd3979efce Dashboard: stop flex children shrinking (bar text overflowed), wrap the bar, gate saves on loaded layout, fix double v in footer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 14:07:56 -07:00
Mitchell-JohnsonandClaude Fable 5.1 1751afc974 Configurable update interval (100 ms – 2 s): drives daemon sampling and UI polling, saved with the dashboard layout
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:54:48 -07:00
Mitchell-JohnsonandClaude Fable 5.1 eb70daf129 Dashboard customize mode: reorder/hide modules and individual rows, layout saved server-side (GET/PUT /api/prefs/dashboard)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:46:41 -07:00
Mitchell-JohnsonandClaude Fable 5.1 7053cce7fe Portability: sysfs-only sensors with thermal-zone fallback, /sys/block disk discovery, fs blacklist, pwrstat/NUT/apcupsd UPS, login shell from passwd, templated unit + install for any user, cross-compile targets
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:43:44 -07:00
Mitchell-JohnsonandClaude Fable 5.1 3c6f4d4639 Dashboard tab: CPU/mem/load, hwmon temps+fans, net, disks, filesystems, UPS with 10-min sparklines
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:37:39 -07:00
Mitchell-JohnsonandClaude Fable 5.1 24d64245ec Ham Lab logo: PWA icon set + login screen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:14:30 -07:00
Mitchell-JohnsonandClaude Fable 5.1 18111c089b CLAUDE.md: import Hamlet's rules; project settings
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 12:57:04 -07:00
110 changed files with 24049 additions and 339 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(systemctl status *)",
"Bash(systemctl is-active *)"
]
}
}
+1
View File
@@ -2,3 +2,4 @@ bin/
internal/web/dist/
web/node_modules/
web/dist/
dist/
+610
View File
@@ -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.
+13
View File
@@ -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.
+69 -8
View File
@@ -1,26 +1,87 @@
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
PUBLISH ?= /home/core/apps/hamlab-demo/site
.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 publish 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
@# The public installer must never lag the fleet: it would offer everyone an older build, and
@# "upgrade" a current machine backwards. Publish only where this hub serves the download site.
@if [ -d $(PUBLISH) ]; then $(MAKE) --no-print-directory publish; fi
# 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
# Put the release where `curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh` finds it:
# the static site beside the demo page (nginx, public through NPM). Binaries first, VERSION
# last, so nobody is ever pointed at files that aren't there yet.
publish: release
mkdir -p $(PUBLISH)/dl
cp dist/hamlab-linux-* dist/SHA256SUMS $(PUBLISH)/dl/
cp dist/install.sh $(PUBLISH)/dl/install.sh && cp dist/install.sh $(PUBLISH)/install.sh
cp dist/VERSION $(PUBLISH)/dl/VERSION
@echo "published $(VERSION): curl -fsSL https://hamlab-demo.bigham.dev/install.sh | sh"
+366 -27
View File
@@ -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,370 @@ 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. 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.
- **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; the rolling copy on
`hamlab-demo.bigham.dev` stays as a mirror. 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.
## Open questions
- Idle-session reaping policy (never / after N days) — leaning never; it's a
+85 -4
View File
@@ -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.
+258
View File
@@ -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.
+325 -18
View File
@@ -2,16 +2,23 @@
package main
import (
"bufio"
"context"
"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 +27,19 @@ 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/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 +48,23 @@ 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
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 +73,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 +117,55 @@ 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 "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 +176,76 @@ 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.
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 +259,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 +308,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 +331,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 +392,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"
}
-18
View File
@@ -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
-11
View File
@@ -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
+21
View File
@@ -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 (the `hamlab-demo` one shows
hardware numbers and counts, not container or service names). Deployed on `core` as the `hamlab-demo` stack —
see Hamlet `docs/docker.md`. Update it with
`cp examples/external-demo/index.html /home/core/apps/hamlab-demo/site/`.
To link it from the Widgets tab, set `"demo_url": "http://…"` in the hub's `config.json`
and restart Ham Lab.
+179
View File
@@ -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>
Executable
+382
View File
@@ -0,0 +1,382 @@
#!/bin/sh
# Ham Lab installer.
#
# curl -fsSL https://hamlab-demo.bigham.dev/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
+520
View File
@@ -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)
}
+396
View File
@@ -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]})
}
+2203 -28
View File
File diff suppressed because it is too large Load Diff
+224
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+375
View File
@@ -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
}
+29
View File
@@ -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)
}
}
+217
View File
@@ -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()
}
+266
View File
@@ -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})
}
+233 -30
View File
@@ -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)
}
+126 -5
View File
@@ -7,13 +7,96 @@ 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"`
// DemoURL, when set, is linked from the Widgets tab: a page outside Ham Lab that
// shows the sensor API being used (examples/external-demo).
DemoURL string `json:"demo_url,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 +119,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 +157,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 store.WriteFileAtomic(c.path, append(b, '\n'), 0o600)
}
return os.Rename(tmp, c.path)
// 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)
}
+810
View File
@@ -0,0 +1,810 @@
// 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()
}
+163
View File
@@ -0,0 +1,163 @@
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 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
}
+68
View File
@@ -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 }
+503
View File
@@ -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() }
+141
View File
@@ -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
}
+143
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
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)
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, "mount", x.Mount, "fstype", x.Type)
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), "mount", x.Mount, "fstype", x.Type)
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, "mount", x.Mount, "fstype", x.Type)
}
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
}
+56
View File
@@ -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")
}
}
+464
View File
@@ -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
}
+92
View File
@@ -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, " ")
}
+264
View File
@@ -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
}
+145
View File
@@ -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)
}
}
+76
View File
@@ -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)
}
}
}
+275
View File
@@ -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)
}
}
+95
View File
@@ -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)
}
}
+368
View File
@@ -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
}
+14
View File
@@ -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)
}
}
+28
View File
@@ -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
}
+23
View File
@@ -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
+694
View File
@@ -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() }
+73
View File
@@ -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)
}
}
+147
View File
@@ -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()
}
+735
View File
@@ -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
}
+514
View File
@@ -0,0 +1,514 @@
// Package space measures what is using a disk: 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
}
+87
View File
@@ -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)
}
}
+926
View File
@@ -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
}
+22
View File
@@ -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))
}
}
}
+79 -6
View File
@@ -5,6 +5,7 @@ package store
import (
"encoding/json"
"errors"
"log"
"os"
"sync"
"time"
@@ -15,9 +16,59 @@ 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 {
Dashboard json.RawMessage `json:"dashboard,omitempty"` // layout prefs, opaque to the server
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"`
// Dashboard and UI are the pre-users machine-wide prefs; on first boot with users they
// move to the first account. Kept so an old state file still reads.
Dashboard json.RawMessage `json:"dashboard,omitempty"`
UI json.RawMessage `json:"ui,omitempty"`
Alerts json.RawMessage `json:"alerts,omitempty"` // 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 +84,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 +118,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)
}
+260
View File
@@ -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
}
+15 -2
View File
@@ -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)
}
+125
View File
@@ -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
}
+544
View File
@@ -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
}
+69
View File
@@ -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"
+2 -1
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

+1 -1
View File
@@ -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()));
+63 -21
View File
@@ -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`)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+42
View File
@@ -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')
+132
View File
@@ -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>
)
}
+24 -2
View File
@@ -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)} /></>
}
+245
View File
@@ -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 Cron() {
const [ov, setOv] = useState<CronOverview | null>(null)
const [err, setErr] = useState('')
const [q, setQ] = useState('')
const [hashSel, setHashSel] = useHashSel('cron') // "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' + (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>Cron 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>
)
}
+474
View File
@@ -0,0 +1,474 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline'
import { Loading, Spinner, askYes, toast } from './ui'
import { fmtMs, fmtWindowSec, nextPoint, strideMs, useHistorySec, useRefreshMs } from './prefs'
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
import { Catalog, MONITOR_HISTORY, MONITOR_SHAPE, Values, mergeValues, sampleOf, seriesOf, stale } from './sampleAdapter'
import { Widget, WidgetData, WidgetSpec, shapeOf } from './widgets'
import WidgetPicker from './WidgetPicker'
const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)'
// ---- module registry: the customizable blocks, in default order ----
type Kind = 'tile' | 'card'
interface Module { id: string; label: string; kind: Kind }
const MODULES: Module[] = [
{ id: 'cpu', label: 'CPU', kind: 'tile' },
{ id: 'mem', label: 'Memory', kind: 'tile' },
{ id: 'load', label: 'Load', kind: 'tile' },
{ id: 'uptime', label: 'Uptime', kind: 'tile' },
{ id: 'temps', label: 'Temperatures', kind: 'card' },
{ id: 'fans', label: 'Fans', kind: 'card' },
{ id: 'net', label: 'Network', kind: 'card' },
{ id: 'disks', label: 'Disks', kind: 'card' },
{ id: 'fs', label: 'Filesystems', kind: 'card' },
{ id: 'ups', label: 'UPS', kind: 'card' },
]
const byId = (id: string) => MODULES.find((m) => m.id === id)
const DEFAULT_INTERVAL = 2000
const clampSampler = (ms: number) => Math.min(10000, Math.max(100, ms))
interface Layout { order: string[]; hidden: Set<string>; hiddenItems: Set<string>; intervalMs: number; widgets: WidgetSpec[] }
// Unknown ids are dropped, missing ones appended, so an old saved layout survives new modules.
function normalize(raw: DashPrefs): Layout {
// Saved prefs are whatever some client last wrote; a wrong shape must not take the page down.
const list = <T,>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
const p = { order: list<string>(raw?.order), hidden: list<string>(raw?.hidden), hiddenItems: list<string>(raw?.hiddenItems), widgets: list<WidgetSpec>(raw?.widgets), intervalMs: typeof raw?.intervalMs === 'number' ? raw.intervalMs : undefined }
const known = new Set(MODULES.map((m) => m.id))
const order = (p.order ?? []).filter((id) => known.has(id))
for (const m of MODULES) if (!order.includes(m.id)) order.push(m.id)
const iv = p.intervalMs && p.intervalMs >= 100 && p.intervalMs <= 2000 ? p.intervalMs : DEFAULT_INTERVAL
return { order, hidden: new Set((p.hidden ?? []).filter((id) => known.has(id))), hiddenItems: new Set(p.hiddenItems ?? []), intervalMs: iv,
widgets: (p.widgets ?? []).filter((w) => w && typeof w.id === 'string' && typeof w.sensor === 'string') }
}
const toPrefs = (l: Layout): DashPrefs => ({ order: l.order, hidden: [...l.hidden], hiddenItems: [...l.hiddenItems], intervalMs: l.intervalMs, widgets: l.widgets })
// ---- formatting ----
// A live value either starts a new point or is averaged into the one being built — the same
// rule the machine itself uses to keep its history, so the tail of a six-hour graph isn't a
// minute of 2-second samples crushed against the right edge. n is how many values this point
// has had, including this one.
function pushPoint(arr: number[], v: number, fresh: boolean, n: number) {
if (!fresh && arr.length) { arr[arr.length - 1] += (v - arr[arr.length - 1]) / n; return }
arr.push(v)
if (arr.length > MAX) arr.shift()
}
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
export function fmtBytes(b: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
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]}`
}
export function fmtRate(bps: number): string {
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
let i = 0
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
}
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`
}
const pct = (v: number) => `${Math.round(v)}%`
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' { return frac >= bad ? 'bad' : frac >= warn ? 'warn' : '' }
// Chips with many sensors (per-core temps on a big CPU) collapse to one row showing the hottest.
function collapseTemps(temps: Temp[]): (Temp & { key: string })[] {
const byChip = new Map<string, Temp[]>()
for (const t of temps) (byChip.get(t.chip) ?? byChip.set(t.chip, []).get(t.chip)!).push(t)
const out: (Temp & { key: string })[] = []
for (const [chip, list] of byChip) {
if (list.length <= 8) { out.push(...list.map((t) => ({ ...t, key: `${t.chip}/${t.label}` }))); continue }
const hot = list.reduce((a, b) => (b.c > a.c ? b : a))
out.push({ ...hot, label: `${list.length} sensors · hottest ${hot.label}`, chip, key: `${chip}/*` })
}
return out
}
// ---- small building blocks ----
function Meter({ frac, cls }: { frac: number; cls?: string }) {
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Legend({ items }: { items: { name: string; color: string }[] }) {
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
}
interface Edit {
on: boolean
hideItem: (key: string) => void
unhideItem: (key: string) => void
hiddenItems: Set<string>
}
// In customize mode every row gets a ✕; hidden rows come back as chips under the module.
function HideBtn({ k, edit }: { k: string; edit: Edit }) {
return edit.on ? <button className="hide-item" title="hide" onClick={() => edit.hideItem(k)}>✕</button> : null
}
function HiddenChips({ prefix, edit, label }: { prefix: string; edit: Edit; label: (k: string) => string }) {
if (!edit.on) return null
const keys = [...edit.hiddenItems].filter((k) => k.startsWith(prefix + ':'))
if (keys.length === 0) return null
return <div className="chips inline">{keys.map((k) => <button key={k} className="chip" title="show again" onClick={() => edit.unhideItem(k)}>+ {label(k.slice(prefix.length + 1))}</button>)}</div>
}
// ---- module bodies ----
interface Ctx { s: Sample; h: Series | null; edit: Edit }
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
return <><div className="label">{label}</div><div className="value">{value}</div>{sub && <div className="sub">{sub}</div>}{children}</>
}
function body(id: string, { s, h, edit }: Ctx): ReactNode {
const shown = (key: string) => !edit.hiddenItems.has(key)
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
switch (id) {
case 'cpu':
return (
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length === 0 ? undefined : s.cores.length <= 16 ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : `${s.cores.length} cores · busiest ${pct(Math.max(...s.cores))}`}>
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
</Tile>
)
case 'mem':
return (
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
</Tile>
)
case 'load':
return <Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
case 'uptime':
return <Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
case 'temps': {
const rows = collapseTemps(s.temps).filter((t) => shown(`temp:${t.key}`))
return (
<>
<h2>Temperatures</h2>
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
{rows.map((t) => {
const limit = t.crit || t.max || 100
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
return (
<div className="row" key={t.key}>
<span className="k">{t.label}<small>{t.chip}</small></span>
<Meter frac={t.c / limit} cls={cls} />
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}<HideBtn k={`temp:${t.key}`} edit={edit} /></span>
</div>
)
})}
<HiddenChips prefix="temp" edit={edit} label={(k) => k.replace('/*', '')} />
</>
)
}
case 'fans': {
const rows = s.fans.filter((f) => shown(`fan:${f.chip}/${f.label}`))
return (
<>
<h2>Fans</h2>
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
{rows.map((f) => (
<div className="row" key={`${f.chip}/${f.label}`}>
<span className="k">{f.label}<small>{f.chip}</small></span>
<span />
<span className="num">{f.rpm} rpm<HideBtn k={`fan:${f.chip}/${f.label}`} edit={edit} /></span>
</div>
))}
<HiddenChips prefix="fan" edit={edit} label={(k) => k} />
</>
)
}
case 'net':
return (
<>
<h2>Network</h2>
{s.net.filter((x) => shown(`net:${x.iface}`)).map((x) => (
<div className="sub-card" key={x.iface}>
<div className="sub-head">
<span className={'dot ' + (x.up ? 'ok' : '')} />
<span>{x.iface}</span>
{!x.up && <span className="muted small">down</span>}
<span className="spacer" />
{x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
<HideBtn k={`net:${x.iface}`} edit={edit} />
</div>
{x.up && h?.net[x.iface] && (
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
)}
{x.up && <div className="legend">
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
</div>}
</div>
))}
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
</>
)
case 'disks':
return (
<>
<h2>Disks</h2>
{s.disks.length === 0 && <div className="empty">No block devices found.</div>}
{s.disks.filter((d) => shown(`disk:${d.name}`)).map((d) => (
<div className="sub-card" key={d.name}>
<div className="sub-head">
<span>{d.name}</span>
<span className="model">{d.model}</span>
<span className="spacer" />
<span className="num">{fmtBytes(d.size)} · {d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
<HideBtn k={`disk:${d.name}`} edit={edit} />
</div>
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
{h?.disk[d.name] && (
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
)}
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
</div>
))}
<HiddenChips prefix="disk" edit={edit} label={(k) => k} />
</>
)
case 'fs':
return (
<>
<h2>Filesystems</h2>
{s.fs.filter((f) => shown(`fs:${f.mount}`)).map((f) => (
<div className="row" key={f.mount}>
<span className="k">{f.mount}<small>{f.type}</small></span>
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}<HideBtn k={`fs:${f.mount}`} edit={edit} /></span>
</div>
))}
<HiddenChips prefix="fs" edit={edit} label={(k) => k} />
</>
)
case 'ups':
return (
<>
<h2>UPS</h2>
{!s.ups && <div className="empty">No UPS daemon found (looks for pwrstat, NUT upsc, or apcaccess).</div>}
{s.ups && (
<>
<div className="sub-head">
<span className={'dot ' + (s.ups.on_battery ? 'bad' : 'ok')} />
<span>{s.ups.state} · {s.ups.source}</span>
<span className="spacer" />
<span className="model">{s.ups.model} · {s.ups.driver}</span>
</div>
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
</>
)}
</>
)
}
return null
}
// ---- the dashboard ----
export default function Dashboard() {
const [latest, setLatest] = useState<Sample | null>(null)
const [err, setErr] = useState('')
const [layout, setLayout] = useState<Layout>(() => normalize({}))
const [editing, setEditing] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('')
const savedTimer = useRef<number | undefined>(undefined)
const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(undefined)
const refreshMs = useRefreshMs()
const historySec = useHistorySec()
// Persist layout changes server-side (debounced) so every device sees the same dashboard.
const update = useCallback((fn: (l: Layout) => Layout) => {
setLayout((prev) => {
const next = fn(prev)
clearTimeout(saveTimer.current)
setSaving('saving')
saveTimer.current = window.setTimeout(async () => {
try {
await api.saveDashPrefs(toPrefs(next))
setSaving('saved')
clearTimeout(savedTimer.current)
savedTimer.current = window.setTimeout(() => setSaving(''), 1500)
} catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}, 300)
return next
})
}, [])
useEffect(() => {
let stop = false
api.dashPrefs().then((p) => { if (!stop) { setLayout(normalize(p)); setLoaded(true) } }).catch(() => setErr('could not load layout'))
return () => { stop = true }
}, [])
// Everything on this page comes from the sensor API: the catalog for names, labels and
// thresholds; one batch of histories to seed the curves; one stream for the values.
const catalog = useRef<Catalog>(new Map())
const refetching = useRef(false)
const binN = useRef(1) // values averaged into the point currently being built
const lastPush = useRef(0) // when that point was started, by this browser's clock
const stock = useSensorStream<unknown[]>(MONITOR_SHAPE, { full: false, everyMs: refreshMs })
const seed = useCallback(async () => {
try {
catalog.current = new Map((await fetchCatalog()).map((x) => [x.id, x]))
hist.current = seriesOf(await fetchHistories(MONITOR_HISTORY), catalog.current)
lastPush.current = Date.now(); binN.current = 1
setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { seed() }, [seed, stock.resumed]) // on load, and again after the page was hidden: the curves have a gap to refill
useEffect(() => {
if (!stock.data || catalog.current.size === 0) return
const v: Values = mergeValues(stock.data)
if (stale(v, catalog.current) && !refetching.current) { // something appeared (a disk, an interface): learn its labels
refetching.current = true
fetchCatalog().then((c) => { catalog.current = new Map(c.map((x) => [x.id, x])) }).catch(() => {}).finally(() => window.setTimeout(() => { refetching.current = false }, 10000))
}
const s = sampleOf(v, catalog.current)
const h = hist.current
if (h) {
// The stride is timed on this clock alone (lastPush), never against the history's own
// timestamps: those are the machine's, and a viewer whose clock runs minutes slow would
// otherwise wait those minutes before its curves gained a point.
const np = nextPoint(s.t, h.t.length === 0 ? 0 : lastPush.current, strideMs(historySec, refreshMs), refreshMs)
const fresh = np.fresh
lastPush.current = np.start
binN.current = fresh ? 1 : binN.current + 1
if (fresh) pushPoint(h.t, s.t, true, 1)
const n = h.t.length
// a curve that has no point in this one yet starts one, whatever the others are doing:
// an interface or disk that appeared mid-point must still line up with the time axis
const push = (arr: number[], x: number) => pushPoint(arr, x, fresh || arr.length < n, binN.current)
push(h.cpu, s.cpu); push(h.mem, s.mem.used)
for (const x of s.net) {
const c = (h.net[x.iface] ??= { rx: [], tx: [] })
pad(c.rx, n - 1); pad(c.tx, n - 1); push(c.rx, x.rx_bps); push(c.tx, x.tx_bps)
}
for (const d of s.disks) {
const c = (h.disk[d.name] ??= { r: [], w: [], u: [] })
pad(c.r, n - 1); pad(c.w, n - 1); pad(c.u, n - 1); push(c.r, d.read_bps); push(c.w, d.write_bps); push(c.u, d.util_pct)
}
for (const t of s.temps) {
const c = (h.temp[`${t.chip}/${t.label}`] ??= [])
pad(c, n - 1); push(c, t.c)
}
}
setLatest(s)
}, [stock.data, historySec, refreshMs])
useEffect(() => { if (stock.error && !latest) setErr(stock.error) }, [stock.error, latest])
// The sampler on this machine follows the global rate (clamped to what it supports).
useEffect(() => {
if (!loaded) return
const want = clampSampler(refreshMs)
if (layout.intervalMs !== want) update((l) => ({ ...l, intervalMs: want }))
}, [refreshMs, loaded]) // eslint-disable-line react-hooks/exhaustive-deps
// Custom widgets are fed by one shaped query over the sensor stream — the same API any outside dashboard uses.
const [picking, setPicking] = useState(false)
const [editingWidget, setEditingWidget] = useState<WidgetSpec | null>(null)
const live = useSensorStream<Record<string, WidgetData>>(shapeOf(layout.widgets))
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
const edit: Edit = {
on: editing,
hiddenItems: layout.hiddenItems,
hideItem: (k) => update((l) => ({ ...l, hiddenItems: new Set([...l.hiddenItems, k]) })),
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
}
const ctx: Ctx = { s: latest, h: hist.current, edit }
// Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
const move = (id: string, dir: -1 | 1) => update((l) => {
const kind = byId(id)!.kind
const same = l.order.filter((x) => byId(x)?.kind === kind && !l.hidden.has(x))
const i = same.indexOf(id), j = i + dir
if (j < 0 || j >= same.length) return l
const order = [...l.order]
const a = order.indexOf(id), b = order.indexOf(same[j])
;[order[a], order[b]] = [order[b], order[a]]
return { ...l, order }
})
const hideMod = (id: string) => update((l) => ({ ...l, hidden: new Set([...l.hidden, id]) }))
const showMod = (id: string) => update((l) => { const s = new Set(l.hidden); s.delete(id); return { ...l, hidden: s } })
const reset = async () => { if (await askYes(layout.widgets.length ? `Reset the layout and remove ${layout.widgets.length} custom widget${layout.widgets.length > 1 ? 's' : ''}?` : 'Reset the layout to the default?')) update(() => normalize({})) }
const tools = (id: string, kind: Kind) => {
if (!editing) return null
const same = visible(kind), i = same.indexOf(id)
return (
<div className="mod-tools">
<button title="move up" disabled={i === 0} onClick={() => move(id, -1)}>▲</button>
<button title="move down" disabled={i === same.length - 1} onClick={() => move(id, 1)}>▼</button>
<button title="hide module" onClick={() => hideMod(id)}>hide</button>
</div>
)
}
return (
<div className={'dash' + (editing ? ' editing' : '')}>
<div className="dash-bar">
{err && <span className="error">{err}</span>}
{editing ? <span className="hint">last {fmtWindowSec(historySec)} · ▲▼ reorder · ✕ hide · refresh rate is in Settings</span>
: <span className="hint">last {fmtWindowSec(historySec)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<span className="spacer" />
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
{saving === 'saved' && <span className="saved">saved ✓</span>}
{editing && <button className="btn" onClick={reset}>Reset</button>}
<button className={'btn' + (editing ? ' on' : '')} disabled={!loaded} title={loaded ? '' : 'layout not loaded'} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
</div>
{visible('tile').length > 0 && (
<div className="kpis">
{visible('tile').map((id) => <div className="tile" key={id}>{tools(id, 'tile')}{body(id, ctx)}</div>)}
</div>
)}
{(layout.widgets.length > 0 || editing) && (
<div className="kpis widgets">
{layout.widgets.map((w, i) => (
<div className={'tile w-' + w.type} key={w.id}>
{editing && (
<div className="mod-tools">
<button title="move left" disabled={i === 0} onClick={() => update((l) => { const a = [...l.widgets]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; return { ...l, widgets: a } })}>◀</button>
<button title="move right" disabled={i === layout.widgets.length - 1} onClick={() => update((l) => { const a = [...l.widgets]; [a[i + 1], a[i]] = [a[i], a[i + 1]]; return { ...l, widgets: a } })}>▶</button>
<button title="change its sensor, type or title" onClick={() => setEditingWidget(w)}>edit</button>
<button title="remove widget" onClick={() => update((l) => ({ ...l, widgets: l.widgets.filter((x) => x.id !== w.id) }))}>remove</button>
</div>
)}
<Widget spec={w} data={live.data ? live.data[w.id] : undefined} />
</div>
))}
{editing && <button className="tile add-widget" onClick={() => setPicking(true)}>+ Add widget</button>}
{live.error && !live.data && layout.widgets.length > 0 && <div className="notice attn" style={{ gridColumn: '1 / -1' }}>The widgets' stream isn't answering: {live.error}.</div>}
</div>
)}
{editingWidget && <WidgetPicker initial={editingWidget} onClose={() => setEditingWidget(null)} onAdd={(w) => { update((l) => ({ ...l, widgets: l.widgets.map((x) => (x.id === w.id ? w : x)) })); setEditingWidget(null) }} />}
{picking && <WidgetPicker onClose={() => setPicking(false)} onAdd={(w) => { update((l) => ({ ...l, widgets: [...l.widgets, w] })); setPicking(false) }} />}
{visible('card').length > 0 && (
<div className="cards">
{visible('card').map((id) => <section className="card" key={id}>{tools(id, 'card')}{body(id, ctx)}</section>)}
</div>
)}
{editing && hiddenMods.length > 0 && (
<div className="card">
<h2>Hidden modules</h2>
<div className="chips">{hiddenMods.map((id) => <button key={id} className="chip" onClick={() => showMod(id)}>+ {byId(id)!.label}</button>)}</div>
</div>
)}
</div>
)
}
+254
View File
@@ -0,0 +1,254 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api, SmartInfo, StorageDevice, ZFSInfo, ZFSSnapshot } from './api'
import { fmtBytes, fmtRate } from './Dashboard'
import TaskOutput from './TaskOutput'
import { Btn, Loading, Spinner, askText, 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` }
function Meter({ frac, cls }: { frac: number; cls?: string }) {
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
// ---- 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>
)
}
// ---- ZFS ----
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>
)}
</>
)
}
// ---- page ----
export default function Disks() {
const [devs, setDevs] = useState<StorageDevice[] | null>(null)
const [zfs, setZfs] = useState<ZFSInfo | 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); setZfs(r.zfs); 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 className="dash-bar"><h2 className="section">ZFS</h2></div>
{zfs && !zfs.available && <div className="notice">{zfs.message || 'No ZFS pools on this host.'}</div>}
{zfs?.available && zfs.pools.length === 0 && <div className="notice">ZFS is present but no pools are imported.</div>}
{zfs?.available && zfs.pools.length > 0 && (
<>
<div className="cards">{zfs.pools.map((p) => <PoolCard key={p.name} p={p} onChanged={load} />)}</div>
<section className="card datasets">
<div className="ds-row head"><span>Dataset</span><span className="num">Used</span><span className="num">Refer</span><span className="num">In snaps</span><span className="num">Snaps</span><span className="num">Ratio</span><span>Mount</span></div>
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
</section>
</>
)}
</div>
)
}
+275
View File
@@ -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>
)
}
+32
View File
@@ -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>
)
}
+254
View File
@@ -0,0 +1,254 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { api, FileEntry, FileInfo } from './api'
import { fmtBytes } from './Dashboard'
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)}>&gt;_ 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>
)
}
+84
View File
@@ -0,0 +1,84 @@
// 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({ demoUrl }: { demoUrl?: string }) {
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="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" />
{demoUrl && <a className="btn" href={demoUrl} target="_blank" rel="noreferrer" title="a page outside Ham Lab, built on the same sensor API">External demo ↗</a>}
</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>
)
}
+15 -7
View File
@@ -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>
+47
View File
@@ -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>
)
}
+138
View File
@@ -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>
)
}
+53
View File
@@ -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>
)}
</>
)
}
+166
View File
@@ -0,0 +1,166 @@
import React, { useCallback, useEffect, useState } from 'react'
import { api, Proc, ProcTable } from './api'
import { fmtBytes, fmtRate } from './Dashboard'
import { Btn, Loading, askYes, toast } from './ui'
import { useRefreshMs } from './prefs'
type Col = 'cpu' | 'rss' | 'pid' | 'name' | 'user' | 'started' | 'cpu_time'
const pct = (v: number) => `${Math.round(v)}%`
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 fmtDur = (s: number) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}` : s >= 60 ? `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, '0')}` : `${s.toFixed(1)}s`)
const fmtStart = (ms: number) => { const d = new Date(ms), now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) }
const STATE: Record<string, string> = { R: 'running', S: 'sleeping', D: 'disk wait', Z: 'zombie', T: 'stopped', t: 'traced', I: 'idle' }
const BLOCKS = '▁▂▃▄▅▆▇█'
// btop-style gradient bar: the track carries the green→yellow→red gradient and a cover
// from the right hides the unfilled part, so colour depends on position not on level.
function GBar({ frac }: { frac: number }) {
return <div className="gbar"><div className="gcover" style={{ width: `${100 - Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Row({ label, frac, value }: { label: string; frac: number; value: string }) {
return <div className="brow"><span className="bl">{label}</span><GBar frac={frac} /><span className="bv">{value}</span></div>
}
// Block-character graph, coloured per column by its value, like btop's cpu graph.
function Graph({ values, max = 100 }: { values: number[]; max?: number }) {
const cols = 60
const v = values.slice(-cols)
const pad = cols - v.length
return (
<div className="graph" aria-hidden="true">
{pad > 0 && <span className="gpad">{'\u2581'.repeat(pad)}</span>}
{v.map((x, i) => { const f = Math.min(1, Math.max(0, x / max)); return <span key={i} className={f >= 0.8 ? 'g3' : f >= 0.5 ? 'g2' : 'g1'}>{BLOCKS[Math.min(7, Math.round(f * 7))]}</span> })}
</div>
)
}
function Box({ title, right, children, className = '' }: { title: string; right?: string; children: React.ReactNode; className?: string }) {
return <section className={'box ' + className}><span className="box-title">{title}</span>{right && <span className="box-right">{right}</span>}{children}</section>
}
export default function Procs() {
const [t, setT] = useState<ProcTable | null>(null)
const [err, setErr] = useState('')
const [sort, setSort] = useState<Col>('cpu')
const [asc, setAsc] = useState(false)
const [q, setQ] = useState('')
const [mine, setMine] = useState(false)
const [sel, setSel] = useState<number | null>(null)
const [paused, setPaused] = useState(false)
const [showTop, setShowTop] = useState<boolean>(() => localStorage.getItem('hamlab.procs.top') !== '0')
const interval = useRefreshMs()
const load = useCallback(async () => {
try { setT(await api.procs()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => {
load()
const id = window.setInterval(() => { if (!paused && document.visibilityState === 'visible') load() }, interval)
return () => window.clearInterval(id)
}, [load, paused, interval])
const head = (c: Col, label: string, cls = '') => (
<button className={'th ' + cls + (sort === c ? ' on' : '')} title={{ cpu: 'share of one core, right now', rss: 'resident memory (RSS)', cpu_time: 'total CPU time used since it started', started: 'when the process started', pid: 'process id', name: 'program name', user: 'owner' }[c]} onClick={() => { if (sort === c) setAsc(!asc); else { setSort(c); setAsc(c === 'name' || c === 'user' || c === 'pid') } }}>
{label}{sort === c ? (asc ? ' ▲' : ' ▼') : ''}
</button>
)
if (!t) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Reading processes…" />}</div>
const needle = q.trim().toLowerCase()
const rows = t.procs
.filter((p) => (!needle || p.name.toLowerCase().includes(needle) || (p.cmd ?? '').toLowerCase().includes(needle) || p.user.toLowerCase().includes(needle) || String(p.pid) === needle) && (!mine || p.user !== 'root'))
.sort((a, b) => {
const va = a[sort] as number | string, vb = b[sort] as number | string
const r = typeof va === 'string' ? (va as string).localeCompare(vb as string) : (va as number) - (vb as number)
return asc ? r : -r
})
const selected = sel !== null ? t.procs.find((p) => p.pid === sel) : undefined
const memFrac = t.mem.total ? t.mem.used / t.mem.total : 0
const swapFrac = t.swap.total ? t.swap.used / t.swap.total : 0
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.name}`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return (
<div className="procs-page">
{showTop && <div className="procs-top">
<Box title="cpu" right={`${t.cpu_model ?? ''}${t.cpu_mhz ? ` · ${(t.cpu_mhz / 1000).toFixed(2)}${t.cpu_max_mhz ? ` / ${(t.cpu_max_mhz / 1000).toFixed(1)}` : ''} GHz` : ''}${t.cpu_temp ? ` · ${t.cpu_temp.toFixed(0)}°C` : ''}`}>
<div className="cpu-total">
<Graph values={t.cpu_hist} />
<div className="cpu-total-v"><span className="bl">CPU</span><span className="bv big">{pct(t.cpu)}</span><span className="muted small">total, all cores</span></div>
</div>
<div className="cores">
{t.cores.map((c, i) => <Row key={i} label={`C${i}`} frac={c / 100} value={pct(c)} />)}
</div>
<div className="box-foot muted">
<span>load avg <b>{t.load[0].toFixed(2)}</b> {t.load[1].toFixed(2)} {t.load[2].toFixed(2)} <small>(1 · 5 · 15 min, {t.cores.length} cores)</small></span>
<span>up <b>{fmtUptime(t.uptime)}</b></span>
<span><b>{t.total}</b> tasks, <b>{t.running}</b> running</span>
</div>
</Box>
<Box title="mem" right={`${fmtBytes(t.mem.total)} total`}>
<Row label="Used" frac={memFrac} value={`${fmtBytes(t.mem.used)} · ${pct(memFrac * 100)}`} />
<Row label="Available" frac={t.mem.total ? t.mem.avail / t.mem.total : 0} value={fmtBytes(t.mem.avail)} />
<Row label="Cached" frac={t.mem.total ? (t.mem.cached ?? 0) / t.mem.total : 0} value={fmtBytes(t.mem.cached ?? 0)} />
<Row label="Free" frac={t.mem.total ? (t.mem.free ?? 0) / t.mem.total : 0} value={fmtBytes(t.mem.free ?? 0)} />
{t.swap.total > 0 ? (
<><div className="box-sub">swap · {fmtBytes(t.swap.total)} total</div>
<Row label="Used" frac={swapFrac} value={`${fmtBytes(t.swap.used)} · ${pct(swapFrac * 100)}`} />
<Row label="Free" frac={t.swap.total ? t.swap.avail / t.swap.total : 0} value={fmtBytes(t.swap.avail)} /></>
) : <div className="box-sub muted">no swap</div>}
<div className="box-foot muted small">Used = total − available. Cached is file cache the kernel gives back under pressure, so "available" is the honest headroom.</div>
</Box>
<Box title="net" className="netbox">
{t.net.filter((n) => n.up).map((n) => (
<div className="netrow" key={n.iface}>
<span className="bl mono">{n.iface}</span>
<span className="bv"><span className="dn">▼ {fmtRate(n.rx_bps)}</span> <span className="upc">▲ {fmtRate(n.tx_bps)}</span></span>
<span className="muted small">{fmtBytes(n.rx_total)} in · {fmtBytes(n.tx_total)} out</span>
</div>
))}
{t.net.filter((n) => n.up).length === 0 && <div className="muted">no interfaces up</div>}
</Box>
</div>}
<div className="procs-sticky">
<div className="procs-tools">
<button className={'chip' + (showTop ? ' on' : '')} title="show or hide the cpu/mem/net boxes" onClick={() => { setShowTop(!showTop); localStorage.setItem('hamlab.procs.top', showTop ? '0' : '1') }}>{showTop ? 'metrics ▴' : 'metrics ▾'}</button>
<input placeholder="filter name, command, user, pid" value={q} onChange={(e) => setQ(e.target.value)} />
<button className={'chip' + (mine ? ' on' : '')} onClick={() => setMine(!mine)}>hide root</button>
<button className={'chip' + (paused ? ' on' : '')} onClick={() => setPaused(!paused)}>{paused ? 'paused' : 'live'}</button>
<span className="muted small">{rows.length} shown</span>
</div>
<div className="prow phead">
{head('pid', 'PID', 'num')}{head('user', 'User')}{head('name', 'Program')}{head('cpu', 'CPU %', 'num')}{head('rss', 'Memory', 'num')}{head('cpu_time', 'CPU time', 'num')}{head('started', 'Started', 'num')}
</div>
</div>
<div className="ptable">
{rows.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
{rows.map((p) => (
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
<span className="num muted">{p.pid}</span>
<span className="ell">{p.user}</span>
<span className="ell mono">{p.name}{p.threads > 1 && <small className="muted"> ×{p.threads}</small>}</span>
<span className={'num' + (p.cpu >= 50 ? ' hot' : '')}>{p.cpu.toFixed(1)}</span>
<span className="num">{fmtBytes(p.rss)}</span>
<span className="num muted">{fmtDur(p.cpu_time)}</span>
<span className="num muted">{fmtStart(p.started)}</span>
</div>
))}
</div>
{selected && (
<div className="pdetail">
<div className="sub-head"><b className="mono">{selected.name}</b><span className="muted">pid {selected.pid} · parent {selected.ppid} · {selected.user} · {STATE[selected.state] ?? selected.state} · nice {selected.nice} · {selected.threads} threads</span><span className="spacer" /><button className="icon-btn" onClick={() => setSel(null)}>✕</button></div>
<div className="mono cmdline">{selected.cmd || '(kernel thread)'}</div>
<div className="svc-actions">
<Btn className="btn" onClick={() => signal(selected, 'TERM')}>Terminate</Btn>
<Btn className="btn danger" onClick={() => signal(selected, 'KILL')}>Kill −9</Btn>
<span className="muted">cpu {selected.cpu.toFixed(1)}% · {fmtBytes(selected.rss)} · running {fmtDur(selected.cpu_time)} of cpu since {new Date(selected.started).toLocaleString()}</span>
</div>
</div>
)}
</div>
)
}
+50 -34
View File
@@ -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>
)
}
+239
View File
@@ -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>
)
}
+250
View File
@@ -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>
)
}
+238 -27
View File
@@ -1,84 +1,295 @@
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 Dashboard from './Dashboard'
import Gallery from './Gallery'
import Docker from './Docker'
import Alerts from './Alerts'
import Services from './Services'
import Cron from './Cron'
import Updates from './Updates'
import Disks from './Disks'
import Files from './Files'
import Procs from './Procs'
import Top from './Top'
import Machines from './Machines'
import Settings from './Settings'
import Space from './Space'
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: 'dashboard', label: 'Dashboard', icon: '◔' },
{ id: 'top', label: 'Top', icon: '▚' },
{ 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: 'cron', label: 'Cron', icon: ClockIcon },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'space', label: 'Space', 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' | 'dashboard' | 'top' | 'widgets' | 'docker' | 'alerts' | 'services' | 'cron' | 'updates' | 'disks' | 'space' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'top', 'widgets', 'docker', 'alerts', 'services', 'cron', 'updates', 'disks', 'space', '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.
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 || !VIEWS.includes(m[2] as View)) return null
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as 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 ?? 'dashboard')
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('dashboard') }, [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 === 'space' && <Space key={machine ?? ''} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
{view === 'dashboard' && <Dashboard />}
{view === 'widgets' && <Gallery demoUrl={hubMe.demo_url} />}
{view === 'docker' && <Docker />}
{view === 'alerts' && <Alerts machines={machines.map((m) => m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />}
{view === 'top' && <Top />}
{view === 'procs' && <Procs />}
{view === 'services' && <Services />}
{view === 'cron' && <Cron />}
{view === 'updates' && <Updates />}
{view === 'disks' && <Disks />}
{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 +297,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 === 'top' ? 'Top' : view === 'dashboard' ? 'Dashboard' : view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'space' ? 'Space' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
+496
View File
@@ -0,0 +1,496 @@
// Space: what is using a disk, WizTree-style. 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 './Dashboard'
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 Space() {
const [targets, setTargets] = useState<SpaceTarget[] | null>(null)
const [err, setErr] = useState('')
const [sel, setSel] = useHashSel('space')
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>
)
}
+64
View File
@@ -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>
)
}
+142
View File
@@ -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>
)
}
+52
View File
@@ -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>
)
}
+47
View File
@@ -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>
)
}
+49 -16
View File
@@ -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>
)
}
+486
View File
@@ -0,0 +1,486 @@
// 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 [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')
const iface = net[0]
const fs = s?.fs ?? []
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">
{swap && swap.total > 0 && <Disk name="swap" size={swap.total} used={swap.used} />}
{fs.map((f) => {
// which drive is behind a mount isn't in the sample, so only the root filesystem
// gets an IO line, against the machine's largest drive — which is the one it is on
const d = f.mount === '/' ? [...(s?.disks ?? [])].sort((a, b) => b.size - a.size)[0] : undefined
return <Disk key={f.mount} name={f.mount === '/' ? 'root' : f.mount.split('/').filter(Boolean).pop() ?? f.mount} size={f.size} used={f.used} io={d ? d.util_pct : undefined} />
})}
{fs.length === 0 && <div className="t-dim">no filesystems reported</div>}
</div>
</Box>
<Box n={4} name="net" className="top-net" right={iface ? <span className="t-if">{iface.iface}</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>
)
})
function Disk({ name, size, used, io }: { name: string; size: number; used: number; io?: number }) {
const upct = size > 0 ? (used / size) * 100 : 0
return (
<div className="t-disk">
<div className="t-row"><b>{name}</b><span className="spacer" />{bytes(size)}</div>
{io !== undefined && <div className="t-row t-sub"><span>IO%</span><Meter pct={io} grad="io" /></div>}
<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>
</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, '')
+110
View File
@@ -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>
)
}
+191
View File
@@ -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`
}
+67
View File
@@ -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>
)
}
+26
View File
@@ -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 */ }) }
+242 -9
View File
@@ -1,13 +1,50 @@
export class Unauthenticated extends Error {}
export class Unauthenticated extends Error { constructor(msg = 'unauthenticated') { super(msg) } }
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const r = await fetch(path, {
// Which machine the console is looking at. '' is this host; otherwise every /api/… call
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
// the machine list itself) pass local=true and are never rewritten.
let base = ''
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
// A machine that just failed to answer is left alone for a few seconds instead of
// being hit again on every tick of every page.
const downUntil: Record<string, number> = {}
function machineOf(p: string): string | null { const m = p.match(/^\/api\/nodes\/([a-z0-9-]+)\//); return m ? m[1] : null }
let inflight = 0
function track(delta: number) {
inflight += delta
window.dispatchEvent(new CustomEvent('hamlab:inflight', { detail: inflight }))
}
async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
const target = local ? path : apiPath(path)
const mach = machineOf(target)
if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`)
track(1)
let r: Response
try {
r = await fetch(target, {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (r.status === 401) throw new Unauthenticated()
} catch (e) {
track(-1)
throw new Error('network error')
}
track(-1)
if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000
if (r.status === 401) {
// The hub itself said no: the session is gone. (A 401 relayed from a managed machine is that
// machine rejecting the hub, which is not ours to fix by logging in again.)
if (!mach && target !== '/api/login') window.dispatchEvent(new Event('hamlab:unauthenticated'))
let msg = 'unauthenticated'
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
throw new Unauthenticated(msg)
}
if (!r.ok) {
let msg = r.statusText
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
@@ -17,20 +54,216 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
return r.json()
}
export interface Me { host: string; user: string; version: string; load: string; uptime: number }
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
export interface Me { demo_url?: string; addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
// What a machine is called on screen: the hostname it reports, which follows a rename within
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
// what the hub's own API calls it — and is what we fall back to until it has been reached.
export const titleOf = (name: string | null, machines: MachineStatus[], hubHost: string): string =>
name ? machines.find((m) => m.name === name)?.host || name : hubHost
export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string }
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
export interface Fan { chip: string; label: string; rpm: number }
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
export interface Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
export interface FS { mount: string; type: string; size: number; used: number }
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; uptime: number; procs: Proc[]; total: number; running: number }
export interface Sample {
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number
}
export interface Series {
t: number[]; cpu: number[]; mem: number[]
net: Record<string, { rx: number[]; tx: number[] }>
disk: Record<string, { r: number[]; w: number[]; u: number[] }>
temp: Record<string, number[]>
}
// Dashboard layout. `order` lists module ids top-to-bottom; `hidden` are modules switched
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
export interface HistoryWindow { seconds: number; points: number; stride_sec: number; min_seconds: number; max_seconds: number }
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number; widgets?: { id: string; type: 'stat' | 'gauge' | 'bar' | 'spark' | 'list'; sensor: string; title?: string }[] }
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
export interface LogEntry { t: number; p: number; ident: string; pid?: string; msg: string }
export interface CronJob { id: string; source: string; line: number; user: string; schedule: string; command: string; enabled: boolean; next?: number; last?: number; out_file?: string; note?: string; editable: boolean }
export interface CronTimer { unit: string; activates: string; description: string; active: string; sub: string; enabled: string; next?: number; last?: number }
export interface CronOverview { jobs: CronJob[]; timers: CronTimer[]; logging: boolean; anacron: boolean; sudo: boolean }
export interface CronRun { id: string; job_id: string; command: string; user: string; started: string; finished?: string; running: boolean; exit_code: number; output: string }
export interface TaskInfo { id: string; kind: string; title: string; command: string; started: string; finished?: string; running: boolean; exit_code: number; error?: string; output?: string }
export interface UpdatePackage { name: string; current: string; candidate: string; suite?: string; security: boolean; description?: string }
export interface UpdatesStatus { manager: string; can_upgrade: boolean; selective: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean }
export interface StoragePart { name: string; type: string; fstype?: string; mount?: string; label?: string; size: number; used?: number; fs_size?: number }
export interface StorageDevice { name: string; kind: string; model?: string; vendor?: string; serial?: string; tran?: string; size: number; rotational: boolean; removable: boolean; temp?: number; parts: StoragePart[]; namespaces?: string[] }
export interface SmartAttr { id: number; name: string; value: number; worst: number; thresh: number; raw: string; failed?: string; warn: boolean }
export interface SmartInfo { device: string; available: boolean; supported: boolean; standby: boolean; passed: boolean | null; temp: number; power_on_hours: number; power_cycles: number; model?: string; serial?: string; firmware?: string; attrs: SmartAttr[]; nvme?: { critical_warning: number; percentage_used: number; available_spare: number; spare_threshold: number; media_errors: number; error_log_entries: number; data_read_bytes: number; data_written_bytes: number; unsafe_shutdowns: number }; mmc?: { life_a: number; life_b: number; pre_eol: number }; self_test?: string; self_test_remaining?: number; self_test_supported: boolean; self_test_note?: string; warnings: string[]; error?: string; fetched_at: number }
export interface DockerContainer { id: string; name: string; image: string; state: string; status: string; health: string; ports: string; stack: string; service: string; created: string; mem: number }
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
export interface ImageCheck { image: string; state: 'current' | 'update' | 'unknown'; detail?: string }
export interface StackDiff { name: string; base: '' | 'deployed' | 'saved'; old: string; new: string }
export interface AlertRule { id: string; name: string; sensor: string; op: string; value: number | boolean | string; for_sec: number; severity: 'info' | 'warn' | 'crit'; enabled: boolean; machines: string }
export interface AlertChannel { type: 'ntfy' | 'webhook'; url: string }
export interface AlertEvent { t: number; kind: 'firing' | 'resolved'; rule: string; rule_id: string; machine: string; sensor: string; value: unknown; severity: string }
export interface AlertsInfo { rules: AlertRule[]; channels: AlertChannel[]; events: AlertEvent[]; firing: (AlertEvent & { since: number })[]; checked: number; every_sec: number }
export interface ZFSSnapshot { name: string; used: number; created: number }
export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] }
export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record<string, string>; rail?: string[] }
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
export interface SpaceTarget { id: string; kind: 'disk' | 'pool'; name: string; model?: string; layout?: string; health?: string; size: number; used?: number; disks?: string[]; roots: string[]; scan?: SpaceScan }
export interface SpaceNode { name: string; dir: boolean; size: number; apparent: number; mtime: number; files: number; dirs: number; small_n?: number; small_size?: number; mount?: boolean; denied?: boolean; partial?: boolean }
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
export interface FileInfo extends FileEntry { path: string; text: boolean }
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
export const api = {
login: (password: string) => req<void>('POST', '/api/login', { password }),
logout: () => req<void>('POST', '/api/logout'),
procs: () => req<ProcTable>('GET', '/api/procs'),
space: () => req<{ targets: SpaceTarget[] }>('GET', '/api/space'),
spaceStatus: (disk: string) => req<SpaceScan>('GET', `/api/space/${encodeURIComponent(disk)}`),
spaceScan: (disk: string) => req<SpaceScan>('POST', `/api/space/${encodeURIComponent(disk)}/scan`),
spaceCancel: (disk: string) => req<void>('POST', `/api/space/${encodeURIComponent(disk)}/cancel`),
spaceForget: (disk: string) => req<void>('DELETE', `/api/space/${encodeURIComponent(disk)}`),
spaceTree: (disk: string, path: string, depth = 1, limit = 200, min = 0) => req<SpaceTree>('GET', `/api/space/${encodeURIComponent(disk)}/tree?path=${encodeURIComponent(path)}&depth=${depth}&limit=${limit}&min=${min}`),
spaceLargest: (disk: string, n = 500) => req<SpaceFile[]>('GET', `/api/space/${encodeURIComponent(disk)}/largest?n=${n}`),
procSignal: (pid: number, signal: 'TERM' | 'KILL') => req<void>('POST', `/api/procs/${pid}/signal`, { signal }),
files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`),
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
fileRead: async (path: string, sudo: boolean): Promise<string> => {
const r = await fetch(apiPath(`/api/files/read?${q(path, sudo, '&max=2097152')}`), { credentials: 'same-origin' })
if (r.status === 401) throw new Unauthenticated()
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
return r.text()
},
fileReadURL: (path: string, sudo: boolean) => apiPath(`/api/files/read?${q(path, sudo)}`),
fileDownloadURL: (path: string, sudo: boolean) => apiPath(`/api/files/download?${q(path, sudo)}`),
fileWrite: async (path: string, text: string, sudo: boolean) => {
const r = await fetch(apiPath(`/api/files/write?${q(path, sudo)}`), { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
if (r.status === 401) throw new Unauthenticated()
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
},
fileUpload: (dir: string, file: File, sudo: boolean, onProgress: (pct: number) => void, overwrite = false) => new Promise<void>((resolve, reject) => {
const x = new XMLHttpRequest()
x.open('PUT', apiPath(`/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}${overwrite ? '&overwrite=1' : ''}`))
x.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) }
x.onload = () => { if (x.status >= 200 && x.status < 300) resolve(); else { let m = x.statusText; try { m = JSON.parse(x.responseText).error ?? m } catch { /* */ } reject(new Error(m)) } }
x.onerror = () => reject(new Error('network error'))
x.send(file)
}),
fileMkdir: (path: string, sudo: boolean) => req<void>('POST', `/api/files/mkdir${sudo ? '?sudo=1' : ''}`, { path }),
fileTouch: (path: string, sudo: boolean) => req<void>('POST', `/api/files/touch${sudo ? '?sudo=1' : ''}`, { path }),
fileRename: (path: string, to: string, sudo: boolean) => req<void>('POST', `/api/files/rename${sudo ? '?sudo=1' : ''}`, { path, to }),
fileDelete: (path: string, recursive: boolean, sudo: boolean) => req<void>('POST', `/api/files/delete${sudo ? '?sudo=1' : ''}`, { path, recursive }),
fileChmod: (path: string, mode: string, sudo: boolean) => req<void>('POST', `/api/files/chmod${sudo ? '?sudo=1' : ''}`, { path, mode }),
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
zfsScrub: (pool: string, action: 'start' | 'pause' | 'stop') => req<void>('POST', `/api/storage/zfs/scrub/${encodeURIComponent(pool)}/${action}`),
zfsSnapshot: (dataset: string, name: string) => req<void>('POST', '/api/storage/zfs/snapshot', { dataset, name }),
installPackages: (packages: string[]) => req<TaskInfo>('POST', '/api/updates/install', { packages }),
tasks: () => req<TaskInfo[]>('GET', '/api/tasks'),
task: (id: string) => req<TaskInfo>('GET', `/api/tasks/${id}`),
cancelTask: (id: string) => req<void>('POST', `/api/tasks/${id}/cancel`),
updates: () => req<UpdatesStatus>('GET', '/api/updates'),
refreshUpdates: () => req<TaskInfo>('POST', '/api/updates/refresh'),
upgrade: (packages: string[]) => req<TaskInfo>('POST', '/api/updates/upgrade', { packages }),
changelog: (pkg: string) => req<{ text: string }>('GET', `/api/updates/changelog?pkg=${encodeURIComponent(pkg)}`),
power: (action: 'reboot' | 'poweroff', confirm: string) => req<void>('POST', `/api/power/${action}`, { confirm }),
cron: () => req<CronOverview>('GET', '/api/cron'),
cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`),
cronWriteSource: (name: string, text: string) => req<void>('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }),
cronJobLogs: (id: string, n = 200) => req<LogEntry[]>('GET', `/api/cron/jobs/${id}/logs?n=${n}`),
cronJobOutput: (id: string, n = 200) => req<{ file: string; text: string }>('GET', `/api/cron/jobs/${id}/output?n=${n}`),
cronJobToggle: (id: string, enable: boolean) => req<void>('POST', `/api/cron/jobs/${id}/${enable ? 'enable' : 'disable'}`),
cronJobRun: (id: string) => req<CronRun>('POST', `/api/cron/jobs/${id}/run`),
cronRun: (id: string) => req<CronRun>('GET', `/api/cron/runs/${id}`),
cronTimerAction: (unit: string, action: string) => req<void>('POST', `/api/cron/timers/${encodeURIComponent(unit)}/${action}`),
services: async () => (await req<{ units: ServiceUnit[]; sudo: boolean }>('GET', '/api/services')).units,
service: (unit: string) => req<ServiceDetail>('GET', `/api/services/${encodeURIComponent(unit)}`),
serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`),
serviceLogs: (unit: string, n = 200) => req<LogEntry[]>('GET', `/api/services/${encodeURIComponent(unit)}/logs?n=${n}`),
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
historyWindow: () => req<HistoryWindow>('GET', '/api/prefs/history', undefined, true),
setHistoryWindow: (seconds: number) => req<HistoryWindow>('PUT', '/api/prefs/history', { seconds }, true),
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
login: (username: string, password: string) => req<{ user: string }>('POST', '/api/login', { username, password }, true),
users: () => req<User[]>('GET', '/api/users', undefined, true),
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
logout: () => req<void>('POST', '/api/logout', undefined, true),
meHere: () => req<Me>('GET', '/api/me', undefined, true),
me: () => req<Me>('GET', '/api/me'),
machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true),
addMachine: (name: string, url: string, username: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, username, password }, true),
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
update: () => req<UpdateStatus>('GET', '/api/update'),
checkUpdate: () => req<UpdateStatus>('POST', '/api/update/check'),
installUpdate: (version: string, force = false) => req<{ version: string; previous: string }>('POST', `/api/update/install${force ? '?force=1' : ''}`, { version }),
tokens: () => req<TokenInfo[]>('GET', '/api/tokens', undefined, true),
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
docker: () => req<DockerInfo>('GET', '/api/docker'),
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'),
createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
deleteStack: (name: string) => req<{ trash: string; moved: number }>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/delete`, { confirm: name }),
pullImage: (image: string) => req<TaskInfo>('POST', '/api/docker/images/pull', { image }),
removeImage: (image: string) => req<void>('POST', '/api/docker/images/remove', { image }),
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
uiPrefs: () => req<UIPrefs>('GET', '/api/prefs/ui', undefined, true),
saveUIPrefs: (p: UIPrefs) => req<void>('PUT', '/api/prefs/ui', p, true),
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
terminals: () => req<Term[]>('GET', '/api/terminals'),
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
}
export function wsURL(id: string): string {
export function wsURLPath(path: string): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${location.host}/api/terminals/${id}/attach`
return `${proto}//${location.host}${apiPath(path)}`
}
export const wsURL = (id: string) => wsURLPath(`/api/terminals/${id}/attach`)
+41
View File
@@ -0,0 +1,41 @@
// Which points of a curve does each dot-column of a graph draw?
//
// A curve holds up to `cap` points (the whole history window); a box is `cols` columns wide,
// and a narrow one — the net graph, a phone — has fewer columns than points. Taking "the
// nearest point" for each column draws one point in three and, worse, a *different* one in
// three every time the curve advances: a spike is there, gone, there again, and the whole
// graph reshapes once a second. So:
//
// - a column draws the PEAK of all the points that fall in it, so nothing is ever skipped; and
// - columns are cut from the data, not from the box: point number `a` (counted since the page
// opened, `advance` being the newest) always belongs to column ⌊a·cols/cap⌋, whatever has
// arrived since. A finished column therefore never changes — the graph only ever slides
// left by whole columns, and only its newest column is still moving.
//
// With more columns than points (a wide box) each point simply spans several columns.
// Returns one value per column, oldest first, right-aligned: a young curve has fewer columns
// than the box and draws in from the right.
export function columnsOf(values: number[], advance: number, cap: number, cols: number): number[] {
const n = Math.min(values.length, cap)
if (n === 0 || cols < 1 || cap < 1) return []
const at = (a: number) => values[values.length - 1 - (advance - a)] // the point numbered a
const oldest = advance - n + 1
const out: number[] = []
if (cols >= cap) {
const kLast = Math.ceil(((advance + 1) * cols) / cap) - 1
for (let k = kLast - cols + 1; k <= kLast; k++) {
const a = Math.floor((k * cap) / cols)
if (a >= oldest && a <= advance) out.push(at(a))
}
return out
}
const kLast = Math.floor((advance * cols) / cap)
for (let k = kLast - cols + 1; k <= kLast; k++) {
const a0 = Math.max(oldest, Math.ceil((k * cap) / cols)), a1 = Math.min(advance, Math.ceil(((k + 1) * cap) / cols) - 1)
if (a1 < a0) continue
let peak = at(a0)
for (let a = a0 + 1; a <= a1; a++) peak = Math.max(peak, at(a))
out.push(peak)
}
return out
}

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