Compare commits
13
Commits
829617c932
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd4688358 | ||
|
|
95bb026b7f | ||
|
|
a6d7ff436f | ||
|
|
574d9855ce | ||
|
|
ad576d8c1a | ||
|
|
c40de5067b | ||
|
|
0e57419d8b | ||
|
|
f1c66ce886 | ||
|
|
a516cf4cad | ||
|
|
e9a87af5e8 | ||
|
|
1e2dcacb2c | ||
|
|
4b238ea93e | ||
|
|
5edad2de6a |
@@ -16,7 +16,7 @@ stub tab or a roadmap item.
|
|||||||
| Service | `hamlab.service`, `User=core`, `Restart=always`, binary at `/usr/local/bin/hamlab` | Same pattern as `calorie-counter.service`. Runs as `core` so `sudo` works in the terminal without a password, as on the SSH path. |
|
| Service | `hamlab.service`, `User=core`, `Restart=always`, binary at `/usr/local/bin/hamlab` | Same pattern as `calorie-counter.service`. Runs as `core` so `sudo` works in the terminal without a password, as on the SSH path. |
|
||||||
| Listen | `10.0.0.234:8100` (free) | NPM is a container, so loopback-only won't do. Firewalling to NPM is a later hardening step. |
|
| Listen | `10.0.0.234:8100` (free) | NPM is a container, so loopback-only won't do. Firewalling to NPM is a later hardening step. |
|
||||||
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
||||||
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
| Ingress | NPM proxy host `lab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
||||||
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
||||||
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
|
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
|
||||||
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
||||||
@@ -37,7 +37,7 @@ stub tab or a roadmap item.
|
|||||||
│ Cron │ │
|
│ Cron │ │
|
||||||
│ … │ │
|
│ … │ │
|
||||||
├──────────────┴───────────────────────────────────────────────┤
|
├──────────────┴───────────────────────────────────────────────┤
|
||||||
│ footer: v0.1.0 · up 12d · hamlab.bigham.dev │
|
│ footer: v0.1.0 · up 12d · lab.bigham.dev │
|
||||||
└──────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ hamlab/
|
|||||||
- Mobile drawer, key bar, fit-on-rotate.
|
- Mobile drawer, key bar, fit-on-rotate.
|
||||||
- `manifest.webmanifest`, icons (Ham logo on black, same recipe as HamTV), service
|
- `manifest.webmanifest`, icons (Ham logo on black, same recipe as HamTV), service
|
||||||
worker caching the app shell only (never `/api`).
|
worker caching the app shell only (never `/api`).
|
||||||
- NPM proxy host for `hamlab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2.
|
- NPM proxy host for `lab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2.
|
||||||
- Stub groups for Services / Cron / ZFS / Dashboard.
|
- Stub groups for Services / Cron / ZFS / Dashboard.
|
||||||
|
|
||||||
**Done when:** from the phone, open the PWA, log in, open two terminals, run
|
**Done when:** from the phone, open the PWA, log in, open two terminals, run
|
||||||
@@ -417,39 +417,9 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
||||||
had taken left untouched and fully readable here — which is what adopting its naming
|
had taken left untouched and fully readable here — which is what adopting its naming
|
||||||
bought. See `hamlet/docs/hosts.md`.
|
bought. See `hamlet/docs/hosts.md`.
|
||||||
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`).
|
- **KVM tab** — shipped 2026-09-24, **removed 2026-11-09** at Mitchell's call: not needed —
|
||||||
Paste a device's address; its own web page appears in the tab. **Nothing in it knows
|
the KVMs are reached over WireGuard (see `hamlet/docs/hosts.md`). It proxied a device's web
|
||||||
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid`
|
page through Ham Lab's origin; the code is in git history before that date.
|
||||||
at `10.0.0.217`, but none of that is in the code.
|
|
||||||
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
|
|
||||||
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
|
|
||||||
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
|
|
||||||
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
|
|
||||||
**The general problem, and the answer.** A device UI asks for its assets by absolute path
|
|
||||||
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
|
|
||||||
bodies per device would not be general. So the prefix is only the entry point: any request
|
|
||||||
Ham Lab does not serve itself is routed to whichever KVM the asking page belongs to, read
|
|
||||||
from the **Referer**, falling back to a **cookie** when there is none (a WebSocket upgrade).
|
|
||||||
Ham Lab's own routes always match first, so a device can never shadow the console, and the
|
|
||||||
fallback is gated on a live session. The device never receives Ham Lab's session cookie,
|
|
||||||
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
|
|
||||||
now. Redirects to the device's root are rewritten back through the prefix.
|
|
||||||
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
|
|
||||||
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
|
|
||||||
refused before it loaded.
|
|
||||||
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
|
|
||||||
does not need to, since one with focus receives them directly. Fullscreen plus
|
|
||||||
`navigator.keyboard.lock()` is what takes Escape, Tab and Ctrl-W back from the browser;
|
|
||||||
that is Chromium-only, and the tab says which version you are getting rather than
|
|
||||||
pretending.
|
|
||||||
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
|
|
||||||
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
|
|
||||||
to `grid` and you get grid's; adding a second for a machine replaces the first. The
|
|
||||||
addresses are still kept **on the hub** rather than on each machine, because a KVM earns
|
|
||||||
its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub
|
|
||||||
proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at
|
|
||||||
all. Not done: holding the device's own login on the hub, so its page does not ask for a
|
|
||||||
second password.
|
|
||||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||||
|
|||||||
+3
-5
@@ -20,10 +20,10 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments
|
|||||||
| 1.1 | **B** | `internal/selfupdate/selfupdate.go:36` `DefaultURL = "https://git.bigham.dev/api/v1/repos/mitchell/hamlab"` | Every install with no `update.url` checks **this** Gitea 90 s after boot and daily (`internal/api/update.go:1951, 2016`). A stranger's install phones home here by default. | Default should be **off** (or a build-time `-X` variable set at release). Installer asks or `install.sh` passes `HAMLAB_URL` through into `update.url`. |
|
| 1.1 | **B** | `internal/selfupdate/selfupdate.go:36` `DefaultURL = "https://git.bigham.dev/api/v1/repos/mitchell/hamlab"` | Every install with no `update.url` checks **this** Gitea 90 s after boot and daily (`internal/api/update.go:1951, 2016`). A stranger's install phones home here by default. | Default should be **off** (or a build-time `-X` variable set at release). Installer asks or `install.sh` passes `HAMLAB_URL` through into `update.url`. |
|
||||||
| 1.2 | **B** | `install.sh:4, 27` `HAMLAB_URL` default; `README.md:23-25, 31` | The curl one-liner and the install source are this Gitea. | Default comes from the release that served the script (`@URL@` filled by `make release`), or is required. |
|
| 1.2 | **B** | `install.sh:4, 27` `HAMLAB_URL` default; `README.md:23-25, 31` | The curl one-liner and the install source are this Gitea. | Default comes from the release that served the script (`@URL@` filled by `make release`), or is required. |
|
||||||
| 1.3 | **B** | `scripts/gitea-release.sh:14` `REPO` default | Publishing goes to `mitchell/hamlab` on this Gitea. | Require `HAMLAB_REPO`; no default. |
|
| 1.3 | **B** | `scripts/gitea-release.sh:14` `REPO` default | Publishing goes to `mitchell/hamlab` on this Gitea. | Require `HAMLAB_REPO`; no default. |
|
||||||
| 1.4 | **C** | `README.md:100` "On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager" | Lab deployment fact in the product README. | Move to `hamlet/docs/hosts.md`. |
|
| 1.4 | **C** | `README.md:100` "On `core` it is public at `https://lab.bigham.dev` behind Nginx Proxy Manager" | Lab deployment fact in the product README. | Move to `hamlet/docs/hosts.md`. |
|
||||||
| 1.5 | **C** | `examples/external-demo/index.html:8` `base: 'http://10.0.0.234:8100'` | Demo config names the hub's LAN IP. | `http://<hamlab-host>:8100`. |
|
| 1.5 | **C** | `examples/external-demo/index.html:8` `base: 'http://10.0.0.234:8100'` | Demo config names the hub's LAN IP. | `http://<hamlab-host>:8100`. |
|
||||||
| 1.6 | **C** | `SENSORS.md:149` Prometheus target `10.0.0.234:8100`; `:187` `&machine=grid`; `:196-208` Glance examples at `hamlab.bigham.dev`, `zfs.tank.used_pct`, `/api/nodes/grid/...`; `:218-220` `grid,wyse`, `{"core":…,"grid":…,"mac":…}` | Docs use real host names, pool and domain. | Generic placeholders (`<host>`, `<machine>`, `<pool>`). |
|
| 1.6 | **C** | `SENSORS.md:149` Prometheus target `10.0.0.234:8100`; `:187` `&machine=grid`; `:196-208` Glance examples at `lab.bigham.dev`, `zfs.tank.used_pct`, `/api/nodes/grid/...`; `:218-220` `grid,wyse`, `{"core":…,"grid":…,"mac":…}` | Docs use real host names, pool and domain. | Generic placeholders (`<host>`, `<machine>`, `<pool>`). |
|
||||||
| 1.7 | **C** | `PLAN.md:3-4` "bare-metal Debian 13 host. First target: `core` (10.0.0.234)"; decisions table rows (Listen `10.0.0.234:8100`, Data `/home/core/apps/hamlab/`, Ingress NPM/`hamlab.bigham.dev`, Repo) | Product plan is written as this lab's plan. | Keep as history, or split lab facts out to `hamlet`. The repo is public. |
|
| 1.7 | **C** | `PLAN.md:3-4` "bare-metal Debian 13 host. First target: `core` (10.0.0.234)"; decisions table rows (Listen `10.0.0.234:8100`, Data `/home/core/apps/hamlab/`, Ingress NPM/`lab.bigham.dev`, Repo) | Product plan is written as this lab's plan. | Keep as history, or split lab facts out to `hamlet`. The repo is public. |
|
||||||
| 1.9 | **C** | `BUGS.md` throughout | Bug bashes narrate `core`, `grid`, `wyse`, `hamtv`, `gate`, the Mac. | History; fine to leave, but it is public. |
|
| 1.9 | **C** | `BUGS.md` throughout | Bug bashes narrate `core`, `grid`, `wyse`, `hamtv`, `gate`, the Mac. | History; fine to leave, but it is public. |
|
||||||
| 1.10 | **C** | `cmd/hamlab/main.go:1` "Proxmox-style web console for a bare-metal Debian host"; `internal/hardware/hardware.go:5` "what Debian and its kin already have"; `internal/hardware/hardware.go:29` `// "GMKtec NucBox G3"`; `internal/metrics/metrics.go:1358` "did, on grid, 2026-10-02"; `internal/api/server.go:1382-1383` `["grid", …]`, `?machines=grid,wyse`; `internal/api/stacks.go:10` `/home/core/apps/dockhand/stacks/…`; `internal/api/dockersched.go:5` "unbound, nginx and pihole"; `internal/api/zfs.go:5` "grid already runs zfSnap"; `internal/storage/snapshots.go:6` "grid has run zfSnap v1.11.1"; `internal/api/guests.go:8` "Mitchell, 2026-09-27"; `internal/network/network.go:306` "the public address here is a DHCP lease"; `internal/metrics/gpu.go:17` "Asahi, today"; `internal/metrics/pciids.go:4-6` "the router's GPU"; `web/src/Top.tsx:522, 576`; `web/src/hashsel.ts:1-9`; `web/src/Embed.tsx:3` `&machine=grid` | Comments explain decisions by naming lab hosts. | Harmless, but rewrite the ones in package doc comments so a reader of the source doesn't need the lab to understand the code. |
|
| 1.10 | **C** | `cmd/hamlab/main.go:1` "Proxmox-style web console for a bare-metal Debian host"; `internal/hardware/hardware.go:5` "what Debian and its kin already have"; `internal/hardware/hardware.go:29` `// "GMKtec NucBox G3"`; `internal/metrics/metrics.go:1358` "did, on grid, 2026-10-02"; `internal/api/server.go:1382-1383` `["grid", …]`, `?machines=grid,wyse`; `internal/api/stacks.go:10` `/home/core/apps/dockhand/stacks/…`; `internal/api/dockersched.go:5` "unbound, nginx and pihole"; `internal/api/zfs.go:5` "grid already runs zfSnap"; `internal/storage/snapshots.go:6` "grid has run zfSnap v1.11.1"; `internal/api/guests.go:8` "Mitchell, 2026-09-27"; `internal/network/network.go:306` "the public address here is a DHCP lease"; `internal/metrics/gpu.go:17` "Asahi, today"; `internal/metrics/pciids.go:4-6` "the router's GPU"; `web/src/Top.tsx:522, 576`; `web/src/hashsel.ts:1-9`; `web/src/Embed.tsx:3` `&machine=grid` | Comments explain decisions by naming lab hosts. | Harmless, but rewrite the ones in package doc comments so a reader of the source doesn't need the lab to understand the code. |
|
||||||
|
|
||||||
@@ -32,7 +32,6 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments
|
|||||||
| # | Sev | Where | Text | General form |
|
| # | Sev | Where | Text | General form |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 2.1 | **C** | `web/src/Machines.tsx:116-118` | `placeholder="grid"`, `"https://10.0.0.214:8100"`, `"core"` | `nas`, `https://192.0.2.10:8100`, `admin` or the hub's own user. |
|
| 2.1 | **C** | `web/src/Machines.tsx:116-118` | `placeholder="grid"`, `"https://10.0.0.214:8100"`, `"core"` | `nas`, `https://192.0.2.10:8100`, `admin` or the hub's own user. |
|
||||||
| 2.2 | **C** | `web/src/KVM.tsx:166` | `placeholder="10.0.0.217"` (the NanoKVM on `grid`) | `192.0.2.20` or `kvm.local`. |
|
|
||||||
| 2.3 | **C** | `web/src/Settings.tsx:242` | `placeholder="cpu.*, temp.*, zfs.tank.*"` | `cpu.*, temp.*, fs.*`. |
|
| 2.3 | **C** | `web/src/Settings.tsx:242` | `placeholder="cpu.*, temp.*, zfs.tank.*"` | `cpu.*, temp.*, fs.*`. |
|
||||||
| 2.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. |
|
| 2.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. |
|
||||||
| 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. |
|
| 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. |
|
||||||
@@ -146,7 +145,6 @@ Listed so the sweep is known to be complete, not because they need work.
|
|||||||
- `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
|
- `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
|
||||||
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
|
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
|
||||||
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`.
|
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`.
|
||||||
- `internal/api/kvm.go`: "nothing in it knows anything about any particular KVM" — verified.
|
|
||||||
- `internal/compose` (modulo 3.5).
|
- `internal/compose` (modulo 3.5).
|
||||||
- The React UI: machine-switcher model, hash routing, per-account prefs, themes.
|
- The React UI: machine-switcher model, hash routing, per-account prefs, themes.
|
||||||
|
|
||||||
|
|||||||
@@ -97,5 +97,5 @@ page flags machines running an older version and offers the same push per machin
|
|||||||
Mixed architectures are fine: `make deploy` also cross-compiles every other architecture,
|
Mixed architectures are fine: `make deploy` also cross-compiles every other architecture,
|
||||||
and the hub sends each machine the build for its own.
|
and the hub sends each machine the build for its own.
|
||||||
|
|
||||||
- On `core` it is public at `https://hamlab.bigham.dev` behind Nginx Proxy Manager.
|
- On `core` it is public at `https://lab.bigham.dev` behind Nginx Proxy Manager.
|
||||||
- Installable as a PWA.
|
- Installable as a PWA.
|
||||||
|
|||||||
+2
-2
@@ -203,13 +203,13 @@ facts, never live. An `iframe` of the embed is live.
|
|||||||
```yaml
|
```yaml
|
||||||
- type: iframe
|
- type: iframe
|
||||||
title: core CPU
|
title: core CPU
|
||||||
source: https://hamlab.bigham.dev/embed/widget?type=gauge&sensor=cpu.usage&bare=1&token=<read-only token>
|
source: https://lab.bigham.dev/embed/widget?type=gauge&sensor=cpu.usage&bare=1&token=<read-only token>
|
||||||
height: 130
|
height: 130
|
||||||
|
|
||||||
- type: custom-api
|
- type: custom-api
|
||||||
title: grid
|
title: grid
|
||||||
cache: 1m
|
cache: 1m
|
||||||
url: https://hamlab.bigham.dev/api/nodes/grid/sensors/query?pool=zfs.tank.used_pct&failed=services.failed&updates=updates.pending
|
url: https://lab.bigham.dev/api/nodes/grid/sensors/query?pool=zfs.tank.used_pct&failed=services.failed&updates=updates.pending
|
||||||
headers: { Authorization: Bearer <read-only token> }
|
headers: { Authorization: Bearer <read-only token> }
|
||||||
template: |
|
template: |
|
||||||
<div>tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates</div>
|
<div>tank {{ .JSON.Float "pool" }}% · {{ .JSON.Int "failed" }} failed · {{ .JSON.Int "updates" }} updates</div>
|
||||||
|
|||||||
@@ -1,420 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
// IP-KVMs: a device's own web page, shown inside Ham Lab.
|
|
||||||
//
|
|
||||||
// The whole difficulty is that Ham Lab is served over TLS and a KVM usually is not, so a
|
|
||||||
// browser refuses to put the device's page in an iframe — mixed content, blocked before
|
|
||||||
// anything loads. The fix is to serve the device from Ham Lab's own origin, which also
|
|
||||||
// means it inherits Ham Lab's login instead of needing a second one, and makes the iframe
|
|
||||||
// same-origin.
|
|
||||||
//
|
|
||||||
// Proxying an arbitrary web app under a path prefix is the hard part, and nothing here
|
|
||||||
// knows anything about any particular device. A device UI asks for its assets by absolute
|
|
||||||
// path — "/assets/app.js", "/api/whatever" — which a prefix cannot rewrite away. So the
|
|
||||||
// prefix is only the entry point: any request Ham Lab does not serve itself is routed to
|
|
||||||
// whichever KVM the asking page belongs to, worked out from the Referer, and from a cookie
|
|
||||||
// when there is no Referer (a WebSocket upgrade, say). Ham Lab's own routes always win, so
|
|
||||||
// a device can never shadow the console.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"hamlab/internal/config"
|
|
||||||
"hamlab/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
const kvmPrefix = "/kvm/"
|
|
||||||
const kvmCookie = "hamlab_kvm"
|
|
||||||
|
|
||||||
type kvmProxies struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
m map[string]*httputil.ReverseProxy
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) kvm(id string) (config.KVM, bool) {
|
|
||||||
var found config.KVM
|
|
||||||
ok := false
|
|
||||||
s.cfg.View(func(c *config.Config) {
|
|
||||||
for _, k := range c.KVMs {
|
|
||||||
if k.ID == id {
|
|
||||||
found, ok = k, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return found, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// kvmProxy is the reverse proxy for one device, made once and kept.
|
|
||||||
func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
|
||||||
s.kvms.mu.Lock()
|
|
||||||
defer s.kvms.mu.Unlock()
|
|
||||||
if s.kvms.m == nil {
|
|
||||||
s.kvms.m = map[string]*httputil.ReverseProxy{}
|
|
||||||
}
|
|
||||||
if p, ok := s.kvms.m[k.ID]; ok {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
target, err := url.Parse(k.URL)
|
|
||||||
if err != nil || target.Host == "" {
|
|
||||||
return nil, errors.New("this KVM's address is not a URL")
|
|
||||||
}
|
|
||||||
prefix := kvmPrefix + k.ID
|
|
||||||
p := &httputil.ReverseProxy{
|
|
||||||
Director: func(r *http.Request) {
|
|
||||||
r.URL.Scheme, r.URL.Host = target.Scheme, target.Host
|
|
||||||
// Entry through the prefix keeps the path after it; anything else arrived by its
|
|
||||||
// own absolute path and is passed through untouched.
|
|
||||||
if rest, ok := strings.CutPrefix(r.URL.Path, prefix); ok {
|
|
||||||
if rest == "" {
|
|
||||||
rest = "/"
|
|
||||||
}
|
|
||||||
r.URL.Path = rest
|
|
||||||
}
|
|
||||||
r.URL.RawPath = ""
|
|
||||||
r.Host = target.Host
|
|
||||||
// Whether the console itself is on TLS: the response rewriter needs to know, and by
|
|
||||||
// then only the outbound request is in hand.
|
|
||||||
if secure(r) {
|
|
||||||
r.Header.Set(kvmSecureHeader, "1")
|
|
||||||
} else {
|
|
||||||
r.Header.Del(kvmSecureHeader)
|
|
||||||
}
|
|
||||||
// The device gets its own host and none of Ham Lab's session.
|
|
||||||
r.Header.Del("Origin")
|
|
||||||
stripCookie(r, cookieName, kvmCookie)
|
|
||||||
// What is left is the device's own login. A browser that has it teaches the hub; one
|
|
||||||
// that has not is lent what the hub remembers, so the device sees a signed-in client.
|
|
||||||
if own := r.Cookies(); len(own) > 0 {
|
|
||||||
s.rememberKVMCookies(k.ID, own)
|
|
||||||
} else {
|
|
||||||
for name, val := range s.kvmCookies(k.ID) {
|
|
||||||
r.AddCookie(&http.Cookie{Name: name, Value: val})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
FlushInterval: -1, // a video stream must not be buffered
|
|
||||||
ModifyResponse: func(resp *http.Response) error {
|
|
||||||
// The device's own framing and transport policy would stop it being embedded, or
|
|
||||||
// send a browser off to plain HTTP. Ham Lab is the origin now, and says what it
|
|
||||||
// serves; its own headers are added on the way out.
|
|
||||||
for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} {
|
|
||||||
resp.Header.Del(h)
|
|
||||||
}
|
|
||||||
// A cookie the device sets by header is kept for Ham Lab's origin — so its Domain
|
|
||||||
// (the device's) goes, Secure follows the console rather than the device, and the
|
|
||||||
// hub remembers it too.
|
|
||||||
if set := resp.Cookies(); len(set) > 0 {
|
|
||||||
https := resp.Request != nil && resp.Request.Header.Get(kvmSecureHeader) == "1"
|
|
||||||
resp.Header.Del("Set-Cookie")
|
|
||||||
for _, c := range set {
|
|
||||||
c.Domain = ""
|
|
||||||
if c.Path == "" {
|
|
||||||
c.Path = "/"
|
|
||||||
}
|
|
||||||
c.Secure = c.Secure && https
|
|
||||||
if v := c.String(); v != "" {
|
|
||||||
resp.Header.Add("Set-Cookie", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.rememberKVMCookies(k.ID, set)
|
|
||||||
}
|
|
||||||
// A redirect to the device's own root has to come back through the prefix.
|
|
||||||
if loc := resp.Header.Get("Location"); loc != "" {
|
|
||||||
if u, err := url.Parse(loc); err == nil && (u.Host == "" || u.Host == target.Host) && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, prefix) {
|
|
||||||
u.Scheme, u.Host = "", ""
|
|
||||||
u.Path = prefix + u.Path
|
|
||||||
resp.Header.Set("Location", u.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": k.Name + " unreachable: " + err.Error()})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
s.kvms.m[k.ID] = p
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// kvmSecureHeader rides on the outbound request to say whether the console was reached
|
|
||||||
// over TLS; the device ignores it.
|
|
||||||
const kvmSecureHeader = "X-Hamlab-Secure"
|
|
||||||
|
|
||||||
// kvmCookies is what the hub remembers of a device's own cookies — its login, in practice.
|
|
||||||
func (s *Server) kvmCookies(id string) map[string]string {
|
|
||||||
var out map[string]string
|
|
||||||
s.store.View(func(st *store.State) {
|
|
||||||
if m := st.KVMCookies[id]; len(m) > 0 {
|
|
||||||
out = make(map[string]string, len(m))
|
|
||||||
for k, v := range m {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// rememberKVMCookies keeps the cookies a browser (or the device's own Set-Cookie) carries
|
|
||||||
// for a KVM, so that a browser without them can be lent them. A cookie emptied or expired
|
|
||||||
// by the device is forgotten. Written only when something changed: this runs on every
|
|
||||||
// proxied request.
|
|
||||||
func (s *Server) rememberKVMCookies(id string, cs []*http.Cookie) {
|
|
||||||
have := s.kvmCookies(id)
|
|
||||||
changed := false
|
|
||||||
for _, c := range cs {
|
|
||||||
if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
gone := c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now()))
|
|
||||||
if gone {
|
|
||||||
if _, ok := have[c.Name]; ok {
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if have[c.Name] != c.Value {
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = s.store.Update(func(st *store.State) {
|
|
||||||
if st.KVMCookies == nil {
|
|
||||||
st.KVMCookies = map[string]map[string]string{}
|
|
||||||
}
|
|
||||||
m := st.KVMCookies[id]
|
|
||||||
if m == nil {
|
|
||||||
m = map[string]string{}
|
|
||||||
}
|
|
||||||
for _, c := range cs {
|
|
||||||
if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) {
|
|
||||||
delete(m, c.Name)
|
|
||||||
} else {
|
|
||||||
m[c.Name] = c.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(m) == 0 {
|
|
||||||
delete(st.KVMCookies, id)
|
|
||||||
} else {
|
|
||||||
st.KVMCookies[id] = m
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// forgetKVMCookies drops a device's remembered login — when the device is removed.
|
|
||||||
func (s *Server) forgetKVMCookies(id string) {
|
|
||||||
_ = s.store.Update(func(st *store.State) { delete(st.KVMCookies, id) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// lendKVMCookies hands the browser the device cookies the hub remembers and it lacks, as
|
|
||||||
// the device's own page would have set them: readable by its scripts, for Ham Lab's whole
|
|
||||||
// origin, kept for half a year. The device's SPA checks for its token client-side before it
|
|
||||||
// ever asks the server, so lending it only on the wire would still land on the login form.
|
|
||||||
func (s *Server) lendKVMCookies(w http.ResponseWriter, r *http.Request, id string) {
|
|
||||||
for name, val := range s.kvmCookies(id) {
|
|
||||||
if _, err := r.Cookie(name); err == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: 180 * 24 * 3600, Secure: secure(r), SameSite: http.SameSiteLaxMode})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripCookie removes named cookies from a request, so Ham Lab's session is never handed to
|
|
||||||
// a device and the device never sees which KVM the browser is looking at.
|
|
||||||
func stripCookie(r *http.Request, names ...string) {
|
|
||||||
cs := r.Cookies()
|
|
||||||
kept := cs[:0]
|
|
||||||
for _, c := range cs {
|
|
||||||
drop := false
|
|
||||||
for _, n := range names {
|
|
||||||
if c.Name == n {
|
|
||||||
drop = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !drop {
|
|
||||||
kept = append(kept, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.Header.Del("Cookie")
|
|
||||||
for _, c := range kept {
|
|
||||||
r.AddCookie(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) forgetKVMProxy(id string) {
|
|
||||||
s.kvms.mu.Lock()
|
|
||||||
delete(s.kvms.m, id)
|
|
||||||
s.kvms.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveKVM is the entry point: /kvm/<id>/… . It also drops a cookie naming the device, so
|
|
||||||
// that requests the page makes later by absolute path can be traced back to it.
|
|
||||||
func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.PathValue("id")
|
|
||||||
k, ok := s.kvm(id)
|
|
||||||
if !ok {
|
|
||||||
jsonErr(w, 404, "no such KVM")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, err := s.kvmProxy(k)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode})
|
|
||||||
s.lendKVMCookies(w, r, id)
|
|
||||||
p.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// kvmFor works out which device a request belongs to when it arrived by absolute path
|
|
||||||
// rather than through the prefix: the Referer names the page that asked, and a cookie
|
|
||||||
// covers what has no Referer at all. Nothing device-specific: it never has to know which
|
|
||||||
// paths a given KVM uses.
|
|
||||||
func (s *Server) kvmFor(r *http.Request) (config.KVM, bool) {
|
|
||||||
if ref := r.Header.Get("Referer"); ref != "" {
|
|
||||||
if u, err := url.Parse(ref); err == nil {
|
|
||||||
if rest, ok := strings.CutPrefix(u.Path, kvmPrefix); ok {
|
|
||||||
if id, _, _ := strings.Cut(rest, "/"); id != "" {
|
|
||||||
if k, ok := s.kvm(id); ok {
|
|
||||||
return k, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// asked for by a page that is not a KVM: not ours to answer
|
|
||||||
return config.KVM{}, false
|
|
||||||
}
|
|
||||||
if c, err := r.Cookie(kvmCookie); err == nil && c.Value != "" {
|
|
||||||
return s.kvm(c.Value)
|
|
||||||
}
|
|
||||||
return config.KVM{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// kvmFallback answers a request Ham Lab does not serve itself, when it came from inside a
|
|
||||||
// KVM's page. It returns false when the request has nothing to do with a KVM, and the
|
|
||||||
// caller carries on with its own 404.
|
|
||||||
func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool {
|
|
||||||
k, ok := s.kvmFor(r)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
p, err := s.kvmProxy(k)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
p.ServeHTTP(w, r)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func machineLabel(m string) string {
|
|
||||||
if m == "" {
|
|
||||||
return "this host"
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the list ----
|
|
||||||
|
|
||||||
func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
out := []config.KVM{}
|
|
||||||
s.cfg.View(func(c *config.Config) { out = append(out, c.KVMs...) })
|
|
||||||
jsonOK(w, 200, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var body struct{ Name, URL, Machine string }
|
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
|
||||||
jsonErr(w, 400, "bad request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
raw := strings.TrimSpace(body.URL)
|
|
||||||
if raw != "" && !strings.Contains(raw, "://") {
|
|
||||||
raw = "http://" + raw // what you would type: an address, not a scheme
|
|
||||||
}
|
|
||||||
u, err := url.Parse(raw)
|
|
||||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
||||||
jsonErr(w, 400, "address: http(s)://host, or just the host")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(body.Name)
|
|
||||||
if name == "" {
|
|
||||||
name = u.Host
|
|
||||||
}
|
|
||||||
if len(name) > maxNameLen {
|
|
||||||
jsonErr(w, 400, "that name is too long")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b := make([]byte, 6)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
machine := strings.TrimSpace(body.Machine)
|
|
||||||
k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: machine, Added: time.Now()}
|
|
||||||
// One per machine: a second one for the same host replaces the first rather than sitting
|
|
||||||
// beside it, since the tab shows the machine you are looking at.
|
|
||||||
var replaced string
|
|
||||||
if err := s.cfg.Update(func(c *config.Config) {
|
|
||||||
kept := c.KVMs[:0]
|
|
||||||
for _, old := range c.KVMs {
|
|
||||||
if old.Machine == machine {
|
|
||||||
replaced = old.ID
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, old)
|
|
||||||
}
|
|
||||||
c.KVMs = append(kept, k)
|
|
||||||
}); err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if replaced != "" {
|
|
||||||
s.forgetKVMProxy(replaced)
|
|
||||||
}
|
|
||||||
log.Printf("kvm %q added for %s (%s) by %s", k.Name, machineLabel(machine), k.URL, clientIP(r))
|
|
||||||
jsonOK(w, 201, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.PathValue("id")
|
|
||||||
found := false
|
|
||||||
if err := s.cfg.Update(func(c *config.Config) {
|
|
||||||
kept := c.KVMs[:0]
|
|
||||||
for _, k := range c.KVMs {
|
|
||||||
if k.ID == id {
|
|
||||||
found = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, k)
|
|
||||||
}
|
|
||||||
c.KVMs = kept
|
|
||||||
}); err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
jsonErr(w, 404, "no such KVM")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.forgetKVMProxy(id)
|
|
||||||
s.forgetKVMCookies(id)
|
|
||||||
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
|
||||||
w.WriteHeader(204)
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"hamlab/internal/auth"
|
|
||||||
"hamlab/internal/config"
|
|
||||||
"hamlab/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A KVM's own login is a cookie its page sets in one browser. The hub remembers it from the
|
|
||||||
// requests that carry it, lends it to a browser that lacks it — both on the wire to the
|
|
||||||
// device and back to the browser, since the device's page checks for it client-side — and
|
|
||||||
// forgets it when the device says it is gone or the KVM is removed.
|
|
||||||
func TestKVMCookiesAreRememberedAndLent(t *testing.T) {
|
|
||||||
var seen []string // the Cookie header the fake device received, per request
|
|
||||||
device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
seen = append(seen, r.Header.Get("Cookie"))
|
|
||||||
if r.URL.Path == "/api/auth/login" {
|
|
||||||
// a device that sets its token by header, with its own domain and Secure
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "tok-1", Domain: "10.0.0.217", Secure: true})
|
|
||||||
}
|
|
||||||
if r.URL.Path == "/api/auth/logout" {
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "", MaxAge: -1})
|
|
||||||
}
|
|
||||||
w.WriteHeader(200)
|
|
||||||
}))
|
|
||||||
defer device.Close()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
cfg := config.Default(filepath.Join(dir, "config.json"))
|
|
||||||
if err := cfg.Update(func(c *config.Config) {
|
|
||||||
c.KVMs = []config.KVM{{ID: "abc123", Name: "test", URL: device.URL, Machine: ""}}
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
st, err := store.Open(filepath.Join(dir, "state.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)}
|
|
||||||
k, _ := s.kvm("abc123")
|
|
||||||
p, err := s.kvmProxy(k)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ask := func(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
|
||||||
r := httptest.NewRequest("GET", path, nil)
|
|
||||||
r.SetPathValue("id", "abc123")
|
|
||||||
for _, c := range cookies {
|
|
||||||
r.AddCookie(c)
|
|
||||||
}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
if strings.HasPrefix(path, kvmPrefix) {
|
|
||||||
s.serveKVM(w, r)
|
|
||||||
} else {
|
|
||||||
p.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. the device sets its token by header: it reaches the browser without the device's
|
|
||||||
// domain or Secure (plain-http console), and the hub remembers it
|
|
||||||
w := ask("/api/auth/login", &http.Cookie{Name: cookieName, Value: "hamlab-session"})
|
|
||||||
var set *http.Cookie
|
|
||||||
for _, c := range w.Result().Cookies() {
|
|
||||||
if c.Name == "nano-kvm-token" {
|
|
||||||
set = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if set == nil || set.Domain != "" || set.Secure || set.Value != "tok-1" || set.Path != "/" {
|
|
||||||
t.Fatalf("rewritten Set-Cookie = %+v, want nano-kvm-token=tok-1 for / without Domain or Secure", set)
|
|
||||||
}
|
|
||||||
if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-1" {
|
|
||||||
t.Fatalf("remembered = %v, want the token", got)
|
|
||||||
}
|
|
||||||
if strings.Contains(seen[0], "hamlab_session") {
|
|
||||||
t.Fatalf("the device was handed Ham Lab's session: %q", seen[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. a browser that has the cookie teaches a newer value
|
|
||||||
ask("/api/vm/info", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"}, &http.Cookie{Name: cookieName, Value: "x"})
|
|
||||||
if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-2" {
|
|
||||||
t.Fatalf("remembered = %v, want tok-2", got)
|
|
||||||
}
|
|
||||||
if seen[1] != "nano-kvm-token=tok-2" {
|
|
||||||
t.Fatalf("device saw %q, want the browser's own cookie only", seen[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. a browser without it is lent it: on the wire, and back as a Set-Cookie it can read
|
|
||||||
w = ask(kvmPrefix+"abc123/", &http.Cookie{Name: cookieName, Value: "x"})
|
|
||||||
if seen[2] != "nano-kvm-token=tok-2" {
|
|
||||||
t.Fatalf("device saw %q from a browser with no cookie, want the remembered token", seen[2])
|
|
||||||
}
|
|
||||||
lent := false
|
|
||||||
for _, c := range w.Result().Cookies() {
|
|
||||||
if c.Name == "nano-kvm-token" && c.Value == "tok-2" && !c.HttpOnly && c.Path == "/" && c.MaxAge > 0 {
|
|
||||||
lent = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !lent {
|
|
||||||
t.Fatalf("browser was not lent the token: %v", w.Result().Cookies())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. the device logging out forgets it
|
|
||||||
ask("/api/auth/logout", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"})
|
|
||||||
if got := s.kvmCookies("abc123"); got != nil {
|
|
||||||
t.Fatalf("after logout remembered = %v, want nothing", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. removing the KVM clears whatever is left
|
|
||||||
s.rememberKVMCookies("abc123", []*http.Cookie{{Name: "nano-kvm-token", Value: "tok-3"}})
|
|
||||||
s.forgetKVMCookies("abc123")
|
|
||||||
if got := s.kvmCookies("abc123"); got != nil {
|
|
||||||
t.Fatalf("after remove remembered = %v, want nothing", got)
|
|
||||||
}
|
|
||||||
_ = url.Parse
|
|
||||||
}
|
|
||||||
+4
-28
@@ -75,7 +75,6 @@ type Server struct {
|
|||||||
net *network.Watcher
|
net *network.Watcher
|
||||||
update updateState
|
update updateState
|
||||||
events eventLog
|
events eventLog
|
||||||
kvms kvmProxies
|
|
||||||
fsLocal files.FS
|
fsLocal files.FS
|
||||||
fsSudo files.FS // nil without passwordless sudo
|
fsSudo files.FS // nil without passwordless sudo
|
||||||
thumbs *thumbSvc
|
thumbs *thumbSvc
|
||||||
@@ -351,18 +350,7 @@ func (s *Server) routes() *http.ServeMux {
|
|||||||
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
||||||
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
||||||
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
||||||
m.HandleFunc("GET /api/kvms", s.auth(s.listKVMs))
|
|
||||||
m.HandleFunc("POST /api/kvms", s.auth(s.addKVM))
|
|
||||||
m.HandleFunc("DELETE /api/kvms/{id}", s.auth(s.removeKVM))
|
|
||||||
m.HandleFunc("/kvm/{id}/{rest...}", s.auth(s.serveKVM))
|
|
||||||
m.HandleFunc("/kvm/{id}", s.auth(s.serveKVM))
|
|
||||||
// A path Ham Lab does not serve may still belong to a KVM's page, which asks for its
|
|
||||||
// own assets by absolute path. Ham Lab's own routes matched first, so this can only
|
|
||||||
// catch what would otherwise have been a 404.
|
|
||||||
m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) {
|
m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.kvmFallback(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonErr(w, 404, "not found")
|
jsonErr(w, 404, "not found")
|
||||||
}))
|
}))
|
||||||
m.HandleFunc("/", s.static)
|
m.HandleFunc("/", s.static)
|
||||||
@@ -386,16 +374,10 @@ func sensorPath(p string) bool {
|
|||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
// The console must never be framed. Two exceptions: an embedded widget exists to be
|
// The console must never be framed. The one exception is an embedded widget, which
|
||||||
// (a page that can only read sensors, and only with a read-only token in its URL),
|
// exists to be (a page that can only read sensors, and only with a read-only token in
|
||||||
// and a proxied KVM is framed by the console itself — 'none' forbids that even from
|
// its URL).
|
||||||
// the same origin, so those paths say 'self' instead of opening up.
|
if r.URL.Path != "/embed/widget" {
|
||||||
switch {
|
|
||||||
case r.URL.Path == "/embed/widget":
|
|
||||||
case strings.HasPrefix(r.URL.Path, kvmPrefix):
|
|
||||||
h.Set("X-Frame-Options", "SAMEORIGIN")
|
|
||||||
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
|
|
||||||
default:
|
|
||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
}
|
}
|
||||||
@@ -2654,12 +2636,6 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A path with no file of ours behind it may belong to a KVM's page, which asks for its
|
|
||||||
// own assets by absolute path. Checked here rather than in the router because this
|
|
||||||
// handler is the catch-all — and gated on a session, since this handler is public.
|
|
||||||
if s.signedIn(r) && s.kvmFallback(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// SPA fallback
|
// SPA fallback
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
http.ServeFileFS(w, r, s.ui, "index.html")
|
http.ServeFileFS(w, r, s.ui, "index.html")
|
||||||
|
|||||||
@@ -77,23 +77,6 @@ type UpdateSource struct {
|
|||||||
Check string `json:"check,omitempty"`
|
Check string `json:"check,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access
|
|
||||||
// to a machine, including when that machine has no OS running. Ham Lab holds the list on
|
|
||||||
// the hub and proxies each one, because a KVM matters exactly when the machine it is
|
|
||||||
// attached to cannot answer for itself.
|
|
||||||
type KVM struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
// URL is the device's own address, as you would type it into a browser on the LAN.
|
|
||||||
URL string `json:"url"`
|
|
||||||
// Machine is the host this KVM is attached to, and is the key: one KVM per machine, shown
|
|
||||||
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
|
|
||||||
// for a node's KVM, because a KVM earns its keep when the machine is down and cannot
|
|
||||||
// answer for itself.
|
|
||||||
Machine string `json:"machine"`
|
|
||||||
Added time.Time `json:"added"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Listen string `json:"listen"`
|
Listen string `json:"listen"`
|
||||||
// PasswordHash is the single login of installs before users existed. On load it becomes
|
// PasswordHash is the single login of installs before users existed. On load it becomes
|
||||||
@@ -103,7 +86,6 @@ type Config struct {
|
|||||||
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
||||||
Tokens []Token `json:"tokens,omitempty"`
|
Tokens []Token `json:"tokens,omitempty"`
|
||||||
Nodes []Node `json:"nodes,omitempty"`
|
Nodes []Node `json:"nodes,omitempty"`
|
||||||
KVMs []KVM `json:"kvms,omitempty"`
|
|
||||||
// Updates: where a newer build comes from. See UpdateSource.
|
// Updates: where a newer build comes from. See UpdateSource.
|
||||||
Updates UpdateSource `json:"update,omitempty"`
|
Updates UpdateSource `json:"update,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -95,30 +95,41 @@ func readRAPL(root string, dirs []string) ([]raplCount, bool) {
|
|||||||
// is carried across its range. One that went backwards with no known range, or a set of
|
// is carried across its range. One that went backwards with no known range, or a set of
|
||||||
// counters that changed between readings, gives no answer rather than a wrong one.
|
// counters that changed between readings, gives no answer rather than a wrong one.
|
||||||
func raplWatts(prev, cur []raplCount, max map[string]uint64, dt float64) (float64, bool) {
|
func raplWatts(prev, cur []raplCount, max map[string]uint64, dt float64) (float64, bool) {
|
||||||
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
|
each, ok := raplWattsEach(prev, cur, max, dt)
|
||||||
return 0, false
|
var w float64
|
||||||
|
for _, x := range each {
|
||||||
|
w += x
|
||||||
}
|
}
|
||||||
var uj float64
|
return w, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// raplWattsEach is raplWatts per counter: one package each.
|
||||||
|
func raplWattsEach(prev, cur []raplCount, max map[string]uint64, dt float64) ([]float64, bool) {
|
||||||
|
if dt <= 0 || len(prev) == 0 || len(prev) != len(cur) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out := make([]float64, len(cur))
|
||||||
for i := range cur {
|
for i := range cur {
|
||||||
if cur[i].Dir != prev[i].Dir {
|
if cur[i].Dir != prev[i].Dir {
|
||||||
return 0, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if cur[i].UJ >= prev[i].UJ {
|
if cur[i].UJ >= prev[i].UJ {
|
||||||
uj += float64(cur[i].UJ - prev[i].UJ)
|
out[i] = float64(cur[i].UJ-prev[i].UJ) / 1e6 / dt
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m := max[cur[i].Dir]
|
m := max[cur[i].Dir]
|
||||||
if m == 0 || prev[i].UJ > m {
|
if m == 0 || prev[i].UJ > m {
|
||||||
return 0, false
|
return nil, false
|
||||||
}
|
}
|
||||||
uj += float64(m - prev[i].UJ + cur[i].UJ)
|
out[i] = float64(m-prev[i].UJ+cur[i].UJ) / 1e6 / dt
|
||||||
}
|
}
|
||||||
return uj / 1e6 / dt, true
|
return out, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpuPower is the CPU packages' power since the previous sample, in watts; 0 when this
|
// cpuPower is the CPU packages' power since the previous sample, in watts; 0 when this
|
||||||
// machine has no counters or no way to read them.
|
// machine has no counters or no way to read them. On a machine with more than one package
|
||||||
func (c *Collector) cpuPower(now time.Time) float64 {
|
// it also gives each one's, indexed by package id (the socket).
|
||||||
|
func (c *Collector) cpuPower(now time.Time) (float64, []float64) {
|
||||||
p := &c.power
|
p := &c.power
|
||||||
if !p.probed {
|
if !p.probed {
|
||||||
p.probed = true
|
p.probed = true
|
||||||
@@ -131,7 +142,7 @@ func (c *Collector) cpuPower(now time.Time) float64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(p.dirs) == 0 {
|
if len(p.dirs) == 0 {
|
||||||
return 0
|
return 0, nil
|
||||||
}
|
}
|
||||||
var cur []raplCount
|
var cur []raplCount
|
||||||
ok := false
|
ok := false
|
||||||
@@ -148,14 +159,40 @@ func (c *Collector) cpuPower(now time.Time) float64 {
|
|||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
p.prev = nil
|
p.prev = nil
|
||||||
return 0
|
return 0, nil
|
||||||
}
|
}
|
||||||
w, good := raplWatts(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
|
each, good := raplWattsEach(p.prev, cur, p.max, now.Sub(p.prevAt).Seconds())
|
||||||
p.prev, p.prevAt = cur, now
|
p.prev, p.prevAt = cur, now
|
||||||
if !good {
|
if !good {
|
||||||
return 0
|
return 0, nil
|
||||||
}
|
}
|
||||||
return round1(w)
|
var w float64
|
||||||
|
for _, x := range each {
|
||||||
|
w += x
|
||||||
|
}
|
||||||
|
return round1(w), perPackage(cur, each)
|
||||||
|
}
|
||||||
|
|
||||||
|
// perPackage puts each counter's watts at its package's id — intel-rapl:1 is "package-1" —
|
||||||
|
// and is nil for a single package, whose figure is the total.
|
||||||
|
func perPackage(cur []raplCount, each []float64) []float64 {
|
||||||
|
if len(cur) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []float64
|
||||||
|
for i, c := range cur {
|
||||||
|
id := i
|
||||||
|
if n, ok := strings.CutPrefix(strings.TrimSpace(readStr(filepath.Join(raplRoot, c.Dir, "name"))), "package-"); ok {
|
||||||
|
if v, err := strconv.Atoi(n); err == nil && v >= 0 && v < 64 {
|
||||||
|
id = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(out) <= id {
|
||||||
|
out = append(out, 0)
|
||||||
|
}
|
||||||
|
out[id] = round1(each[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// energyHelper is the root child that answers each newline with the package counters, one
|
// energyHelper is the root child that answers each newline with the package counters, one
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// a two-socket board: two coretemp chips, and the CPU's temperature is the hotter package
|
||||||
|
func TestCPUTempHottestPackage(t *testing.T) {
|
||||||
|
temps := []Temp{
|
||||||
|
{Chip: "coretemp", Label: "Core 0", C: 31},
|
||||||
|
{Chip: "coretemp", Label: "Core 0", C: 44, Pkg: 1},
|
||||||
|
{Chip: "coretemp", Label: "Package id 0", C: 35},
|
||||||
|
{Chip: "coretemp", Label: "Package id 1", C: 41, Pkg: 1},
|
||||||
|
}
|
||||||
|
if got := cpuTemp(temps); got != 41 {
|
||||||
|
t.Fatalf("cpuTemp = %v, want 41", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package metrics
|
package metrics
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
// One kept history point is the average of the samples behind it. At the default ten-minute
|
// 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
|
// 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
|
// of them, and the difference matters — a graph of the last day drawn from whichever instant
|
||||||
@@ -43,6 +45,21 @@ func fold(b []Sample) Sample {
|
|||||||
if pn > 0 {
|
if pn > 0 {
|
||||||
out.CPUPower = round1(pw / pn)
|
out.CPUPower = round1(pw / pn)
|
||||||
}
|
}
|
||||||
|
// and each package's, the same way
|
||||||
|
out.CPUPowerPkgs = nil
|
||||||
|
for _, s := range b {
|
||||||
|
for i, w := range s.CPUPowerPkgs {
|
||||||
|
for len(out.CPUPowerPkgs) <= i {
|
||||||
|
out.CPUPowerPkgs = append(out.CPUPowerPkgs, 0)
|
||||||
|
}
|
||||||
|
out.CPUPowerPkgs[i] += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pn > 0 {
|
||||||
|
for i := range out.CPUPowerPkgs {
|
||||||
|
out.CPUPowerPkgs[i] = round1(out.CPUPowerPkgs[i] / pn)
|
||||||
|
}
|
||||||
|
}
|
||||||
out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] })
|
out.Load[0] = sum(func(s Sample) float64 { return s.Load[0] })
|
||||||
out.Load[1] = sum(func(s Sample) float64 { return s.Load[1] })
|
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.Load[2] = sum(func(s Sample) float64 { return s.Load[2] })
|
||||||
@@ -70,13 +87,13 @@ func fold(b []Sample) Sample {
|
|||||||
temps := avgBy(b, func(s Sample) map[string][]float64 {
|
temps := avgBy(b, func(s Sample) map[string][]float64 {
|
||||||
m := make(map[string][]float64, len(s.Temps))
|
m := make(map[string][]float64, len(s.Temps))
|
||||||
for _, x := range s.Temps {
|
for _, x := range s.Temps {
|
||||||
m[x.Chip+"/"+x.Label] = []float64{x.C}
|
m[x.Chip+"/"+x.Label+"/"+strconv.Itoa(x.Pkg)] = []float64{x.C}
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
out.Temps = append([]Temp(nil), out.Temps...)
|
out.Temps = append([]Temp(nil), out.Temps...)
|
||||||
for i, x := range out.Temps {
|
for i, x := range out.Temps {
|
||||||
if v, ok := temps[x.Chip+"/"+x.Label]; ok {
|
if v, ok := temps[x.Chip+"/"+x.Label+"/"+strconv.Itoa(x.Pkg)]; ok {
|
||||||
out.Temps[i].C = v[0]
|
out.Temps[i].C = v[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-22
@@ -49,6 +49,7 @@ type GPU struct {
|
|||||||
PowerW float64 `json:"power_w,omitempty"`
|
PowerW float64 `json:"power_w,omitempty"`
|
||||||
PowerCapW float64 `json:"power_cap_w,omitempty"`
|
PowerCapW float64 `json:"power_cap_w,omitempty"`
|
||||||
FanRPM int `json:"fan_rpm,omitempty"`
|
FanRPM int `json:"fan_rpm,omitempty"`
|
||||||
|
FanPct *float64 `json:"fan_pct,omitempty"` // fan speed as a % of its range: nvidia-smi's fan.speed, or the hwmon's pwm1; nil when the card has no such reading (0 is a stopped fan)
|
||||||
MHz float64 `json:"mhz,omitempty"` // actual clock (0 when power-gated)
|
MHz float64 `json:"mhz,omitempty"` // actual clock (0 when power-gated)
|
||||||
ReqMHz float64 `json:"req_mhz,omitempty"` // what the driver asked for
|
ReqMHz float64 `json:"req_mhz,omitempty"` // what the driver asked for
|
||||||
MaxMHz float64 `json:"max_mhz,omitempty"`
|
MaxMHz float64 `json:"max_mhz,omitempty"`
|
||||||
@@ -56,7 +57,7 @@ type GPU struct {
|
|||||||
MemTotal uint64 `json:"mem_total,omitempty"`
|
MemTotal uint64 `json:"mem_total,omitempty"`
|
||||||
Clients []GPUClient `json:"clients"`
|
Clients []GPUClient `json:"clients"`
|
||||||
ClientsOK bool `json:"clients_ok"` // false when fdinfo of other users' processes could not be read
|
ClientsOK bool `json:"clients_ok"` // false when fdinfo of other users' processes could not be read
|
||||||
Stalled bool `json:"stalled,omitempty"` // a sysfs read of this card never returned; its readings are paused until restart
|
Stalled bool `json:"stalled,omitempty"` // a read of this card has been out for gpuStallAfter; its readings are paused until it returns
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPUClient is one process with the device open.
|
// GPUClient is one process with the device open.
|
||||||
@@ -78,7 +79,17 @@ type gpuDev struct {
|
|||||||
integrated, stalled bool
|
integrated, stalled bool
|
||||||
prevIdle, prevEnergy float64
|
prevIdle, prevEnergy float64
|
||||||
prevAt time.Time
|
prevAt time.Time
|
||||||
nv []GPUClient // nvidia: the process table from the last read (pmon), fdinfo says nothing there
|
nv []GPUClient // nvidia: the process table from the last read (pmon), fdinfo says nothing there
|
||||||
|
nvRead []GPUClient // the same, as the read in flight left it: only that read touches it
|
||||||
|
pending chan gpuRead // the read in flight, nil when none is
|
||||||
|
pendingAt time.Time
|
||||||
|
last *GPU // the last read that came back
|
||||||
|
readFn func(*GPU, time.Time) // nil is read (tests stand a slow one in)
|
||||||
|
}
|
||||||
|
|
||||||
|
type gpuRead struct {
|
||||||
|
x GPU
|
||||||
|
nv []GPUClient
|
||||||
}
|
}
|
||||||
|
|
||||||
type gpuState struct {
|
type gpuState struct {
|
||||||
@@ -348,28 +359,60 @@ func lspciName(desc string) string {
|
|||||||
return strings.TrimSpace(vendor + " " + desc)
|
return strings.TrimSpace(vendor + " " + desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readWithin is read with a deadline. A sysfs read that does not come back must not take
|
// readWithin is read with a deadline. A read that does not come back must not take the
|
||||||
// the whole sampler with it — every graph on every page of this machine stops — so the
|
// whole sampler with it — every graph on every page of this machine stops — so one still out
|
||||||
// device is marked stalled, its goroutine abandoned, and the rest of the sample goes on.
|
// after gpuReadWithin is left running, the sample goes on with the card's last readings, and
|
||||||
const gpuReadWithin = 3 * time.Second
|
// no second read starts until it returns. Late is normal for nvidia-smi on a busy card; only a
|
||||||
|
// read out for gpuStallAfter marks the card stalled, and that clears the moment it returns. A
|
||||||
|
// sysfs read that never returns keeps it stalled, at the cost of one goroutine.
|
||||||
|
var (
|
||||||
|
gpuReadWithin = 3 * time.Second
|
||||||
|
gpuStallAfter = 15 * time.Second // past both of readNvidia's nvidia-smi calls timing out (runOut, 5 s each)
|
||||||
|
)
|
||||||
|
|
||||||
func (d *gpuDev) readWithin(x *GPU, now time.Time) {
|
func (d *gpuDev) readWithin(x *GPU, now time.Time) {
|
||||||
if d.stalled {
|
var r gpuRead
|
||||||
x.Stalled = true
|
back := false
|
||||||
|
if d.pending == nil {
|
||||||
|
done := make(chan gpuRead, 1)
|
||||||
|
y := *x
|
||||||
|
read := d.read
|
||||||
|
if d.readFn != nil {
|
||||||
|
read = d.readFn
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
read(&y, now)
|
||||||
|
done <- gpuRead{y, d.nvRead}
|
||||||
|
}()
|
||||||
|
d.pending, d.pendingAt = done, now
|
||||||
|
select {
|
||||||
|
case r, back = <-done:
|
||||||
|
case <-time.After(gpuReadWithin):
|
||||||
|
}
|
||||||
|
} else { // started on an earlier sample: taken if it is back, never waited on again
|
||||||
|
select {
|
||||||
|
case r, back = <-d.pending:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok := x.ClientsOK
|
||||||
|
defer func() { x.ClientsOK = ok }()
|
||||||
|
if back {
|
||||||
|
if d.stalled {
|
||||||
|
log.Printf("gpu: %s (%s): its read came back after %s; readings resume", d.name, d.card, now.Sub(d.pendingAt).Round(time.Second))
|
||||||
|
}
|
||||||
|
d.pending, d.nv, d.stalled, d.last = nil, r.nv, false, &r.x
|
||||||
|
*x = r.x
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
done := make(chan GPU, 1)
|
if d.last != nil {
|
||||||
go func() {
|
*x = *d.last
|
||||||
y := *x
|
}
|
||||||
d.read(&y, now)
|
if now.Sub(d.pendingAt) >= gpuStallAfter {
|
||||||
done <- y
|
if !d.stalled {
|
||||||
}()
|
log.Printf("gpu: %s (%s): a read has not returned in %s; its readings are paused until it does", d.name, d.card, gpuStallAfter)
|
||||||
select {
|
}
|
||||||
case y := <-done:
|
|
||||||
*x = y
|
|
||||||
case <-time.After(gpuReadWithin):
|
|
||||||
d.stalled, x.Stalled = true, true
|
d.stalled, x.Stalled = true, true
|
||||||
log.Printf("gpu: %s (%s): a sysfs read did not return within %s; its readings are paused until restart", d.name, d.card, gpuReadWithin)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +480,14 @@ func (d *gpuDev) read(x *GPU, now time.Time) {
|
|||||||
x.PowerCapW = p / 1e6
|
x.PowerCapW = p / 1e6
|
||||||
}
|
}
|
||||||
x.FanRPM = int(readF(d.hwmon + "/fan1_input"))
|
x.FanRPM = int(readF(d.hwmon + "/fan1_input"))
|
||||||
|
if pwm, err := strconv.ParseFloat(strings.TrimSpace(readStr(d.hwmon+"/pwm1")), 64); err == nil {
|
||||||
|
top := readF(d.hwmon + "/pwm1_max")
|
||||||
|
if top <= 0 {
|
||||||
|
top = 255
|
||||||
|
}
|
||||||
|
v := round1(pwm / top * 100)
|
||||||
|
x.FanPct = &v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
d.prevAt = now
|
d.prevAt = now
|
||||||
}
|
}
|
||||||
@@ -720,11 +771,11 @@ func ueventValue(p, key string) string {
|
|||||||
// sample (~20 ms and ~75 ms on a GTX 965M), no root needed. The PCI address selects the
|
// sample (~20 ms and ~75 ms on a GTX 965M), no root needed. The PCI address selects the
|
||||||
// card, so two NVIDIA GPUs stay apart.
|
// card, so two NVIDIA GPUs stay apart.
|
||||||
func (d *gpuDev) readNvidia(x *GPU) {
|
func (d *gpuDev) readNvidia(x *GPU) {
|
||||||
if out, ok := runOut("nvidia-smi", "--id="+d.bus, "--query-gpu=utilization.gpu,utilization.encoder,utilization.decoder,memory.used,memory.total,clocks.sm,clocks.max.sm,temperature.gpu,power.draw,power.limit", "--format=csv,noheader,nounits"); ok {
|
if out, ok := runOut("nvidia-smi", "--id="+d.bus, "--query-gpu=utilization.gpu,utilization.encoder,utilization.decoder,memory.used,memory.total,clocks.sm,clocks.max.sm,temperature.gpu,power.draw,power.limit,fan.speed", "--format=csv,noheader,nounits"); ok {
|
||||||
parseNvidiaQuery(x, out)
|
parseNvidiaQuery(x, out)
|
||||||
}
|
}
|
||||||
if out, ok := runOut("nvidia-smi", "pmon", "-c", "1", "-s", "um", "--id="+d.bus); ok {
|
if out, ok := runOut("nvidia-smi", "pmon", "-c", "1", "-s", "um", "--id="+d.bus); ok {
|
||||||
d.nv = parseNvidiaPmon(out)
|
d.nvRead = parseNvidiaPmon(out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,7 +790,8 @@ func nvNum(s string) (float64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseNvidiaQuery fills a GPU from one --query-gpu line in the field order readNvidia asks
|
// parseNvidiaQuery fills a GPU from one --query-gpu line in the field order readNvidia asks
|
||||||
// for: gpu%, encoder%, decoder%, mem used MiB, mem total MiB, SM MHz, max SM MHz, °C, W, W cap.
|
// for: gpu%, encoder%, decoder%, mem used MiB, mem total MiB, SM MHz, max SM MHz, °C, W, W cap,
|
||||||
|
// fan % ("[N/A]" on a card with no fan of its own: a laptop's, a passive datacentre card).
|
||||||
func parseNvidiaQuery(x *GPU, line string) {
|
func parseNvidiaQuery(x *GPU, line string) {
|
||||||
f := strings.Split(strings.TrimSpace(line), ",")
|
f := strings.Split(strings.TrimSpace(line), ",")
|
||||||
if len(f) < 10 {
|
if len(f) < 10 {
|
||||||
@@ -775,6 +827,11 @@ func parseNvidiaQuery(x *GPU, line string) {
|
|||||||
if v, ok := nvNum(f[9]); ok {
|
if v, ok := nvNum(f[9]); ok {
|
||||||
x.PowerCapW = v
|
x.PowerCapW = v
|
||||||
}
|
}
|
||||||
|
if len(f) > 10 {
|
||||||
|
if v, ok := nvNum(f[10]); ok {
|
||||||
|
x.FanPct = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNvidiaPmon reads `nvidia-smi pmon -c 1 -s um`: a header naming the columns, then one
|
// parseNvidiaPmon reads `nvidia-smi pmon -c 1 -s um`: a header naming the columns, then one
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A read that comes back late carries the last readings meanwhile and is used when it lands;
|
||||||
|
// only one out past gpuStallAfter marks the card stalled, and its return clears that.
|
||||||
|
func TestGPUReadLateThenStalledThenBack(t *testing.T) {
|
||||||
|
defer func(w, s time.Duration) { gpuReadWithin, gpuStallAfter = w, s }(gpuReadWithin, gpuStallAfter)
|
||||||
|
gpuReadWithin, gpuStallAfter = 20*time.Millisecond, 10*time.Second
|
||||||
|
release := make(chan struct{})
|
||||||
|
n := 0.0 // only the read goroutine writes it, one read at a time
|
||||||
|
d := &gpuDev{name: "test", card: "card0"}
|
||||||
|
d.readFn = func(x *GPU, _ time.Time) {
|
||||||
|
n++
|
||||||
|
if n == 2 {
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
x.Usage = n * 10
|
||||||
|
}
|
||||||
|
t0 := time.Now()
|
||||||
|
sample := func(at time.Duration) GPU {
|
||||||
|
x := GPU{Name: "test"}
|
||||||
|
d.readWithin(&x, t0.Add(at))
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
if x := sample(0); x.Usage != 10 || x.Stalled {
|
||||||
|
t.Fatalf("first: %+v", x)
|
||||||
|
}
|
||||||
|
if x := sample(time.Second); x.Usage != 10 || x.Stalled { // late: the last readings
|
||||||
|
t.Fatalf("late: %+v", x)
|
||||||
|
}
|
||||||
|
if x := sample(5 * time.Second); x.Usage != 10 || x.Stalled {
|
||||||
|
t.Fatalf("still late: %+v", x)
|
||||||
|
}
|
||||||
|
if x := sample(12 * time.Second); !x.Stalled {
|
||||||
|
t.Fatalf("out past gpuStallAfter, not stalled: %+v", x)
|
||||||
|
}
|
||||||
|
if x := sample(12*time.Second + 1); !x.Stalled {
|
||||||
|
t.Fatal("stall did not hold")
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if x := sample(13 * time.Second); x.Usage != 20 || x.Stalled {
|
||||||
|
t.Fatalf("back: %+v", x)
|
||||||
|
}
|
||||||
|
if x := sample(14 * time.Second); x.Usage != 30 || x.Stalled { // 30: no extra read ran while one was out
|
||||||
|
t.Fatalf("next: %+v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,6 +182,20 @@ func TestParseNvidiaQuery(t *testing.T) {
|
|||||||
if g.Temp != 57 || g.TempSrc != "gpu" || g.PowerW != 23.4 || g.PowerCapW != 0 {
|
if g.Temp != 57 || g.TempSrc != "gpu" || g.PowerW != 23.4 || g.PowerCapW != 0 {
|
||||||
t.Errorf("temp/power (cap must stay 0 for [N/A]): %+v", g)
|
t.Errorf("temp/power (cap must stay 0 for [N/A]): %+v", g)
|
||||||
}
|
}
|
||||||
|
if g.FanPct != nil {
|
||||||
|
t.Errorf("no fan field must leave the fan unset: %+v", g)
|
||||||
|
}
|
||||||
|
var fan, nofan GPU
|
||||||
|
parseNvidiaQuery(&fan, "99, 0, 0, 20000, 24576, 1695, 2100, 83, 403.99, 420.00, 34\n")
|
||||||
|
parseNvidiaQuery(&nofan, "5, 0, 0, 500, 2048, 300, 1097, 45, 8.0, [N/A], [N/A]\n")
|
||||||
|
if fan.FanPct == nil || *fan.FanPct != 34 || nofan.FanPct != nil {
|
||||||
|
t.Errorf("fan: %v, [N/A]: %v", fan.FanPct, nofan.FanPct)
|
||||||
|
}
|
||||||
|
var stopped GPU
|
||||||
|
parseNvidiaQuery(&stopped, "0, 0, 0, 500, 24576, 210, 2100, 35, 20.0, 420.00, 0\n")
|
||||||
|
if stopped.FanPct == nil || *stopped.FanPct != 0 {
|
||||||
|
t.Errorf("a stopped fan must read 0, not nothing: %v", stopped.FanPct)
|
||||||
|
}
|
||||||
var short GPU
|
var short GPU
|
||||||
parseNvidiaQuery(&short, "garbage")
|
parseNvidiaQuery(&short, "garbage")
|
||||||
if short.UsageSrc != "" {
|
if short.UsageSrc != "" {
|
||||||
|
|||||||
+107
-45
@@ -60,6 +60,7 @@ type Temp struct {
|
|||||||
C float64 `json:"c"`
|
C float64 `json:"c"`
|
||||||
Max float64 `json:"max,omitempty"`
|
Max float64 `json:"max,omitempty"`
|
||||||
Crit float64 `json:"crit,omitempty"`
|
Crit float64 `json:"crit,omitempty"`
|
||||||
|
Pkg int `json:"pkg,omitempty"` // coretemp: the CPU package the chip belongs to — a two-socket board has two chips, both with a "Core 0"
|
||||||
}
|
}
|
||||||
|
|
||||||
type Fan struct {
|
type Fan struct {
|
||||||
@@ -101,6 +102,9 @@ type FS struct {
|
|||||||
// said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and
|
// said honestly: a ZFS pool or an md array spreads a filesystem over several drives, and
|
||||||
// a remote mount is on none of them.
|
// a remote mount is on none of them.
|
||||||
Disk string `json:"disk,omitempty"`
|
Disk string `json:"disk,omitempty"`
|
||||||
|
// Source is the device the mount is of ("/dev/nvme0n1p1"). A bind mount has the same
|
||||||
|
// source as the filesystem it shows, so a sum over a drive counts each source once.
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UPS struct {
|
type UPS struct {
|
||||||
@@ -135,24 +139,25 @@ type Proc struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Sample struct {
|
type Sample struct {
|
||||||
T int64 `json:"t"` // unix ms
|
T int64 `json:"t"` // unix ms
|
||||||
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
Interval int64 `json:"interval_ms"` // sampling interval in effect
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Cores []float64 `json:"cores"`
|
Cores []float64 `json:"cores"`
|
||||||
CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading
|
CPUPower float64 `json:"cpu_power_w,omitempty"` // the CPU packages' own energy counters; 0 = no reading
|
||||||
Load [3]float64 `json:"load"`
|
CPUPowerPkgs []float64 `json:"cpu_power_pkgs_w,omitempty"` // each package's share, by socket; only with more than one
|
||||||
Mem Mem `json:"mem"`
|
Load [3]float64 `json:"load"`
|
||||||
Swap Mem `json:"swap"`
|
Mem Mem `json:"mem"`
|
||||||
Temps []Temp `json:"temps"`
|
Swap Mem `json:"swap"`
|
||||||
Fans []Fan `json:"fans"`
|
Temps []Temp `json:"temps"`
|
||||||
Net []Net `json:"net"`
|
Fans []Fan `json:"fans"`
|
||||||
Disks []Disk `json:"disks"`
|
Net []Net `json:"net"`
|
||||||
FS []FS `json:"fs"`
|
Disks []Disk `json:"disks"`
|
||||||
UPS *UPS `json:"ups"`
|
FS []FS `json:"fs"`
|
||||||
GPUs []GPU `json:"gpus"`
|
UPS *UPS `json:"ups"`
|
||||||
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module
|
GPUs []GPU `json:"gpus"`
|
||||||
ZFSWrite *ZFSWrites `json:"zfs_writes"` // the ZFS write path; nil without the module
|
ARC *ARC `json:"arc"` // the ZFS read cache; nil without the module
|
||||||
Uptime float64 `json:"uptime"`
|
ZFSWrite *ZFSWrites `json:"zfs_writes"` // the ZFS write path; nil without the module
|
||||||
|
Uptime float64 `json:"uptime"`
|
||||||
|
|
||||||
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
|
fsT, upsT int64 // when the slow sections were really read; sensors report these, not T
|
||||||
}
|
}
|
||||||
@@ -349,7 +354,7 @@ func (c *Collector) sample() {
|
|||||||
|
|
||||||
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
|
s := Sample{T: now.UnixMilli(), Interval: c.Interval().Milliseconds(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
|
||||||
s.CPU, s.Cores = c.cpu()
|
s.CPU, s.Cores = c.cpu()
|
||||||
s.CPUPower = c.cpuPower(now)
|
s.CPUPower, s.CPUPowerPkgs = c.cpuPower(now)
|
||||||
s.Load = loadavg()
|
s.Load = loadavg()
|
||||||
s.Mem, s.Swap = meminfo()
|
s.Mem, s.Swap = meminfo()
|
||||||
s.Uptime = uptime()
|
s.Uptime = uptime()
|
||||||
@@ -544,6 +549,19 @@ func hwmonDir(d string) (chip string, temps []Temp, fans []Fan) {
|
|||||||
fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)})
|
fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// coretemp is one chip per CPU package, and only its "Package id N" says which
|
||||||
|
if chip == "coretemp" {
|
||||||
|
for _, t := range temps {
|
||||||
|
if n, ok := strings.CutPrefix(t.Label, "Package id "); ok {
|
||||||
|
if pkg, err := strconv.Atoi(n); err == nil {
|
||||||
|
for i := range temps {
|
||||||
|
temps[i].Pkg = pkg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +577,10 @@ func hwmon() ([]Temp, []Fan) {
|
|||||||
if temps[i].Chip != temps[j].Chip {
|
if temps[i].Chip != temps[j].Chip {
|
||||||
return temps[i].Chip < temps[j].Chip
|
return temps[i].Chip < temps[j].Chip
|
||||||
}
|
}
|
||||||
return temps[i].Label < temps[j].Label
|
if temps[i].Label != temps[j].Label {
|
||||||
|
return temps[i].Label < temps[j].Label
|
||||||
|
}
|
||||||
|
return temps[i].Pkg < temps[j].Pkg
|
||||||
})
|
})
|
||||||
return temps, fans
|
return temps, fans
|
||||||
}
|
}
|
||||||
@@ -845,7 +866,7 @@ func filesystems() []FS {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
bs := uint64(st.Bsize)
|
bs := uint64(st.Bsize)
|
||||||
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0])}
|
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs, Remote: remoteFS[fs[2]], Disk: diskOf(fs[0]), Source: fs[0]}
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
case r := <-done:
|
case r := <-done:
|
||||||
@@ -1112,27 +1133,61 @@ func coreIDs(n int) []int {
|
|||||||
return coreIDsVal[:n]
|
return coreIDsVal[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// corePkgs maps logical CPUs to the CPU package (socket) each is on, and is nil on a
|
||||||
|
// one-socket machine: there is nothing to tell apart.
|
||||||
|
var (
|
||||||
|
corePkgsOnce sync.Once
|
||||||
|
corePkgsVal []int
|
||||||
|
)
|
||||||
|
|
||||||
|
func corePkgs(n int) []int {
|
||||||
|
corePkgsOnce.Do(func() {
|
||||||
|
seen := map[int]bool{}
|
||||||
|
var out []int
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
b, err := os.ReadFile("/sys/devices/system/cpu/cpu" + strconv.Itoa(i) + "/topology/physical_package_id")
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(strings.TrimSpace(string(b)))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out, seen[id] = append(out, id), true
|
||||||
|
}
|
||||||
|
if len(seen) > 1 {
|
||||||
|
corePkgsVal = out
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if len(corePkgsVal) < n {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return corePkgsVal[:n]
|
||||||
|
}
|
||||||
|
|
||||||
// ProcTable is the Processes tab payload.
|
// ProcTable is the Processes tab payload.
|
||||||
type ProcTable struct {
|
type ProcTable struct {
|
||||||
T int64 `json:"t"`
|
T int64 `json:"t"`
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Cores []float64 `json:"cores"`
|
Cores []float64 `json:"cores"`
|
||||||
CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first
|
CPUHist []float64 `json:"cpu_hist"` // recent total-CPU samples, oldest first
|
||||||
CPUModel string `json:"cpu_model,omitempty"`
|
CPUModel string `json:"cpu_model,omitempty"`
|
||||||
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
CPUMHz float64 `json:"cpu_mhz,omitempty"`
|
||||||
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
CPUMaxMHz float64 `json:"cpu_max_mhz,omitempty"`
|
||||||
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
|
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
|
||||||
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
CPUPowerPkgsW []float64 `json:"cpu_power_pkgs_w,omitempty"`
|
||||||
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
CPUTemp float64 `json:"cpu_temp,omitempty"`
|
||||||
Load [3]float64 `json:"load"`
|
CoreIDs []int `json:"core_ids,omitempty"` // for each logical CPU, the physical core it is a thread of (what the temperature sensors are named by)
|
||||||
Mem Mem `json:"mem"`
|
CorePkgs []int `json:"core_pkgs,omitempty"` // for each logical CPU, the socket it is on; only on a machine with more than one
|
||||||
Swap Mem `json:"swap"`
|
Load [3]float64 `json:"load"`
|
||||||
Net []Net `json:"net"`
|
Mem Mem `json:"mem"`
|
||||||
GPUs []GPU `json:"gpus"`
|
Swap Mem `json:"swap"`
|
||||||
Uptime float64 `json:"uptime"`
|
Net []Net `json:"net"`
|
||||||
Procs []Proc `json:"procs"`
|
GPUs []GPU `json:"gpus"`
|
||||||
Total int `json:"total"`
|
Uptime float64 `json:"uptime"`
|
||||||
Running int `json:"running"`
|
Procs []Proc `json:"procs"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Running int `json:"running"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var cpuModel = func() string {
|
var cpuModel = func() string {
|
||||||
@@ -1182,18 +1237,24 @@ func cpuMHz() float64 {
|
|||||||
|
|
||||||
// cpuTemp picks the sensor that best represents the CPU package.
|
// cpuTemp picks the sensor that best represents the CPU package.
|
||||||
func cpuTemp(temps []Temp) float64 {
|
func cpuTemp(temps []Temp) float64 {
|
||||||
best, max := 0.0, 0.0
|
// the hottest package: a two-socket board has two, and the hotter one is the one to watch
|
||||||
|
pkg, max := 0.0, 0.0
|
||||||
for _, t := range temps {
|
for _, t := range temps {
|
||||||
l := strings.ToLower(t.Label)
|
l := strings.ToLower(t.Label)
|
||||||
if t.Chip == "coretemp" && strings.HasPrefix(l, "package") || t.Chip == "k10temp" && (l == "tctl" || l == "tdie") ||
|
if t.Chip == "coretemp" && strings.HasPrefix(l, "package") || t.Chip == "k10temp" && (l == "tctl" || l == "tdie") ||
|
||||||
t.Chip == "thermal" && strings.Contains(l, "cpu") || t.Chip == "cpu_thermal" || t.Chip == "soc_thermal" {
|
t.Chip == "thermal" && strings.Contains(l, "cpu") || t.Chip == "cpu_thermal" || t.Chip == "soc_thermal" {
|
||||||
return t.C
|
if t.C > pkg {
|
||||||
|
pkg = t.C
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (t.Chip == "coretemp" || t.Chip == "k10temp") && t.C > max {
|
if (t.Chip == "coretemp" || t.Chip == "k10temp") && t.C > max {
|
||||||
max, best = t.C, t.C
|
max = t.C
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best
|
if pkg > 0 {
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
return max
|
||||||
}
|
}
|
||||||
|
|
||||||
var bootTime = func() int64 {
|
var bootTime = func() int64 {
|
||||||
@@ -1321,8 +1382,9 @@ func (c *Collector) Processes() ProcTable {
|
|||||||
t.Mem, t.Swap = meminfo()
|
t.Mem, t.Swap = meminfo()
|
||||||
if n := len(c.ring); n > 0 {
|
if n := len(c.ring); n > 0 {
|
||||||
last := c.ring[n-1]
|
last := c.ring[n-1]
|
||||||
t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower
|
t.CPU, t.Cores, t.Net, t.CPUTemp, t.CPUPowerW, t.CPUPowerPkgsW = last.CPU, last.Cores, last.Net, cpuTemp(last.Temps), last.CPUPower, last.CPUPowerPkgs
|
||||||
t.CoreIDs = coreIDs(len(last.Cores))
|
t.CoreIDs = coreIDs(len(last.Cores))
|
||||||
|
t.CorePkgs = corePkgs(len(last.Cores))
|
||||||
from := n - 90
|
from := n - 90
|
||||||
if from < 0 {
|
if from < 0 {
|
||||||
from = 0
|
from = 0
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
|||||||
if s.CPUPower > 0 {
|
if s.CPUPower > 0 {
|
||||||
f.add("cpu.power", "CPU power", "power", "W", s.CPUPower, t, none)
|
f.add("cpu.power", "CPU power", "power", "W", s.CPUPower, t, none)
|
||||||
}
|
}
|
||||||
|
for i, w := range s.CPUPowerPkgs {
|
||||||
|
n := strconv.Itoa(i)
|
||||||
|
f.add("cpu.package."+n+".power", "CPU "+strconv.Itoa(i+1)+" power", "power", "W", w, t, none, "package", n)
|
||||||
|
}
|
||||||
for i, w := range []string{"1", "5", "15"} {
|
for i, w := range []string{"1", "5", "15"} {
|
||||||
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
|
f.add("load."+w, "Load "+w+" min", "load", "", s.Load[i], t, none)
|
||||||
}
|
}
|
||||||
@@ -92,7 +96,11 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
|||||||
if x.Crit > 0 {
|
if x.Crit > 0 {
|
||||||
b.crit = sensors.F(x.Crit)
|
b.crit = sensors.F(x.Crit)
|
||||||
}
|
}
|
||||||
f.add(sensors.ID("temp", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "temperature", "°C", x.C, t, b, "chip", x.Chip, "label", x.Label)
|
labels := []string{"chip", x.Chip, "label", x.Label}
|
||||||
|
if x.Chip == "coretemp" { // a two-socket board has a coretemp chip per CPU, both with a "Core 0"
|
||||||
|
labels = append(labels, "package", strconv.Itoa(x.Pkg))
|
||||||
|
}
|
||||||
|
f.add(sensors.ID("temp", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "temperature", "°C", x.C, t, b, labels...)
|
||||||
}
|
}
|
||||||
for _, x := range s.Fans {
|
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)
|
f.add(sensors.ID("fan", sensors.Seg(x.Chip), sensors.Seg(x.Label)), x.Chip+" "+x.Label, "fan", "rpm", float64(x.RPM), t, none, "chip", x.Chip, "label", x.Label)
|
||||||
@@ -129,13 +137,22 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
|||||||
if x.Disk != "" {
|
if x.Disk != "" {
|
||||||
lbl = append(lbl, "disk", x.Disk)
|
lbl = append(lbl, "disk", x.Disk)
|
||||||
}
|
}
|
||||||
|
if x.Source != "" {
|
||||||
|
lbl = append(lbl, "source", x.Source)
|
||||||
|
}
|
||||||
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
|
f.add(p+".size", x.Mount+" size", "bytes", "B", float64(x.Size), s.fsT, none, lbl...)
|
||||||
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
|
f.add(p+".used", x.Mount+" used", "bytes", "B", float64(x.Used), s.fsT, upTo(float64(x.Size)), lbl...)
|
||||||
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
f.add(p+".used_pct", x.Mount+" used", "usage", "%", ratio(x.Used, x.Size), s.fsT, pct, lbl...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gpuSeen := map[string]int{}
|
||||||
for _, g := range s.GPUs {
|
for _, g := range s.GPUs {
|
||||||
|
// two identical cards are gpu.<name> and gpu.<name>_2, every sensor of the second under
|
||||||
|
// its own stem (not add's suffix on the end of each id, which the page can't find)
|
||||||
p := "gpu." + sensors.Seg(g.Name)
|
p := "gpu." + sensors.Seg(g.Name)
|
||||||
|
if gpuSeen[p]++; gpuSeen[p] > 1 {
|
||||||
|
p = fmt.Sprintf("%s_%d", p, gpuSeen[p])
|
||||||
|
}
|
||||||
lbl := []string{"name", g.Name, "driver", g.Driver}
|
lbl := []string{"name", g.Name, "driver", g.Driver}
|
||||||
if g.Bus != "" {
|
if g.Bus != "" {
|
||||||
lbl = append(lbl, "bus", g.Bus)
|
lbl = append(lbl, "bus", g.Bus)
|
||||||
@@ -164,6 +181,9 @@ func flatten(s Sample, want func(id string) bool) []sensors.Sensor {
|
|||||||
if g.FanRPM > 0 {
|
if g.FanRPM > 0 {
|
||||||
f.add(p+".fan", g.Name+" fan", "fan", "rpm", float64(g.FanRPM), t, none, lbl...)
|
f.add(p+".fan", g.Name+" fan", "fan", "rpm", float64(g.FanRPM), t, none, lbl...)
|
||||||
}
|
}
|
||||||
|
if g.FanPct != nil {
|
||||||
|
f.add(p+".fan_pct", g.Name+" fan", "fan", "%", *g.FanPct, t, pct, lbl...)
|
||||||
|
}
|
||||||
if g.MaxMHz > 0 {
|
if g.MaxMHz > 0 {
|
||||||
f.add(p+".freq", g.Name+" clock", "frequency", "MHz", g.MHz, t, upTo(g.MaxMHz), lbl...)
|
f.add(p+".freq", g.Name+" clock", "frequency", "MHz", g.MHz, t, upTo(g.MaxMHz), lbl...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,9 @@ type State struct {
|
|||||||
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
||||||
// orchestrator that has since been removed can still be found on disk. See
|
// orchestrator that has since been removed can still be found on disk. See
|
||||||
// internal/api/stacks.go.
|
// internal/api/stacks.go.
|
||||||
Mounts []RememberedMount `json:"mounts,omitempty"`
|
Mounts []RememberedMount `json:"mounts,omitempty"`
|
||||||
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so
|
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||||
// the next browser — or this one after it has forgotten them — is signed into the device
|
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
||||||
// already. See internal/api/kvm.go.
|
|
||||||
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
|
|
||||||
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
|
||||||
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
|
||||||
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
|
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
|
||||||
// a machine and a device on it. nil is the machine the file is on, its best device.
|
// a machine and a device on it. nil is the machine the file is on, its best device.
|
||||||
Transcode *Transcode `json:"transcode,omitempty"`
|
Transcode *Transcode `json:"transcode,omitempty"`
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"icons": "node scripts/icons.mjs",
|
"icons": "node scripts/icons.mjs",
|
||||||
"test": "node scripts/test-graphcols.mjs && node scripts/test-css-imports.mjs && node scripts/test-resolve-path.mjs"
|
"test": "node scripts/test-graphcols.mjs && node scripts/test-sockets.mjs && node scripts/test-css-imports.mjs && node scripts/test-resolve-path.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// node scripts/test-sockets.mjs — src/sockets.ts: each CPU's threads, counted under it.
|
||||||
|
import { buildSync } from 'esbuild'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { mkdtempSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const out = join(mkdtempSync(join(tmpdir(), 'sockets-')), 'sockets.mjs')
|
||||||
|
buildSync({ entryPoints: [new URL('../src/sockets.ts', import.meta.url).pathname], outfile: out, format: 'esm', logLevel: 'error' })
|
||||||
|
const { socketThreads } = await import(pathToFileURL(out).href)
|
||||||
|
|
||||||
|
const fails = []
|
||||||
|
const eq = (got, want, what) => { if (JSON.stringify(got) !== JSON.stringify(want)) fails.push(`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`) }
|
||||||
|
const range = (a, b) => Array.from({ length: b - a }, (_, i) => a + i)
|
||||||
|
|
||||||
|
// nexus: 2 × E5-2680 v2, as /sys/devices/system/cpu/cpu*/topology/physical_package_id reads
|
||||||
|
const nexus = [...Array(10).fill(0), ...Array(10).fill(1), ...Array(10).fill(0), ...Array(10).fill(1)]
|
||||||
|
const got = socketThreads(nexus)
|
||||||
|
eq(got.map((s) => s.pkg), [0, 1], 'two sockets, in order')
|
||||||
|
eq(got[0].cpus, [...range(0, 10), ...range(20, 30)], "CPU 1's threads")
|
||||||
|
eq(got[1].cpus, [...range(10, 20), ...range(30, 40)], "CPU 2's threads")
|
||||||
|
eq(got.map((s) => s.cpus.length), [20, 20], 'twenty threads each')
|
||||||
|
// every thread is under exactly one CPU
|
||||||
|
eq(got.flatMap((s) => s.cpus).sort((a, b) => a - b), range(0, 40), 'every thread once')
|
||||||
|
// a thread's number under its CPU runs 0…19 on both
|
||||||
|
for (const s of got) eq(s.cpus.map((_, n) => n), range(0, 20), `CPU ${s.pkg + 1} counts from 0`)
|
||||||
|
|
||||||
|
// a board that numbers each socket's threads together, and one whose socket ids are not 0 and 1
|
||||||
|
eq(socketThreads([0, 0, 0, 0, 1, 1, 1, 1]), [{ pkg: 0, cpus: [0, 1, 2, 3] }, { pkg: 1, cpus: [4, 5, 6, 7] }], 'contiguous numbering')
|
||||||
|
eq(socketThreads([3, 1, 3, 1]), [{ pkg: 1, cpus: [1, 3] }, { pkg: 3, cpus: [0, 2] }], 'socket ids sorted')
|
||||||
|
eq(socketThreads([]), [], 'nothing')
|
||||||
|
|
||||||
|
if (fails.length) { console.error('sockets: FAIL\n ' + fails.join('\n ')); process.exit(1) }
|
||||||
|
console.log('sockets: ok')
|
||||||
+58
-6
@@ -1,4 +1,4 @@
|
|||||||
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { DragEvent, PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
||||||
import { fmtBytes } from './fmt'
|
import { fmtBytes } from './fmt'
|
||||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||||
@@ -22,6 +22,7 @@ export function resolvePath(input: string, here: string): string {
|
|||||||
}
|
}
|
||||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||||
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
||||||
|
const isMedia = (e: FileEntry) => !!e.preview || isImage(e.name)
|
||||||
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
||||||
const MAX_EDIT = 2 * 1024 * 1024
|
const MAX_EDIT = 2 * 1024 * 1024
|
||||||
|
|
||||||
@@ -198,7 +199,9 @@ function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- editor / viewer ----
|
// ---- editor / viewer ----
|
||||||
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
|
// steps: the files ← and → go through, in the list's order (only the pictures, videos and
|
||||||
|
// sound when one of those is open).
|
||||||
|
function Viewer({ path, sudo, steps, onStep, onClose, onChanged }: { path: string; sudo: boolean; steps: string[]; onStep: (p: string) => void; onClose: () => void; onChanged: () => void }) {
|
||||||
const [info, setInfo] = useState<FileInfo | null>(null)
|
const [info, setInfo] = useState<FileInfo | null>(null)
|
||||||
const [text, setText] = useState<string | null>(null)
|
const [text, setText] = useState<string | null>(null)
|
||||||
const [orig, setOrig] = useState('')
|
const [orig, setOrig] = useState('')
|
||||||
@@ -251,10 +254,49 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
}, [dirty, name])
|
}, [dirty, name])
|
||||||
const dl = api.fileDownloadURL(path, sudo)
|
const dl = api.fileDownloadURL(path, sudo)
|
||||||
|
|
||||||
|
// Previous and next: the buttons, ← and → (unless typing, or the player has the focus and
|
||||||
|
// seeks with them), and a sideways swipe on a picture.
|
||||||
|
const at = steps.indexOf(path)
|
||||||
|
const prev = at > 0 ? steps[at - 1] : null
|
||||||
|
const next = at >= 0 && at < steps.length - 1 ? steps[at + 1] : null
|
||||||
|
const step = useCallback(async (to: string | null) => {
|
||||||
|
if (!to) return
|
||||||
|
if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return
|
||||||
|
setUnsaved(''); onStep(to)
|
||||||
|
}, [dirty, onStep])
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||||
|
const t = e.target as HTMLElement | null
|
||||||
|
if (t?.closest('input, textarea, select, [contenteditable], .vp')) return
|
||||||
|
if (e.key === 'ArrowLeft' && prev) { e.preventDefault(); step(prev) }
|
||||||
|
else if (e.key === 'ArrowRight' && next) { e.preventDefault(); step(next) }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', h)
|
||||||
|
return () => window.removeEventListener('keydown', h)
|
||||||
|
}, [prev, next, step])
|
||||||
|
// The next and previous pictures load now, so stepping onto them is instant.
|
||||||
|
useEffect(() => {
|
||||||
|
for (const p of [prev, next]) if (p && isImage(p)) new Image().src = api.fileReadURL(p, sudo)
|
||||||
|
}, [prev, next, sudo])
|
||||||
|
const swipe = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
const swipeEnd = (e: PointerEvent) => {
|
||||||
|
const s = swipe.current
|
||||||
|
swipe.current = null
|
||||||
|
if (!s) return
|
||||||
|
const dx = e.clientX - s.x, dy = e.clientY - s.y
|
||||||
|
if (Math.abs(dx) > 50 && Math.abs(dx) > 1.5 * Math.abs(dy)) step(dx < 0 ? next : prev)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="viewer">
|
<div className="viewer">
|
||||||
<div className="viewer-bar">
|
<div className="viewer-bar">
|
||||||
<button className="btn" onClick={close}>‹ Back</button>
|
<button className="btn" onClick={close}>‹ Back</button>
|
||||||
|
{at >= 0 && steps.length > 1 && <>
|
||||||
|
<button className="btn step" disabled={!prev} title="previous (←)" onClick={() => step(prev)}>←</button>
|
||||||
|
<span className="muted num">{at + 1}/{steps.length}</span>
|
||||||
|
<button className="btn step" disabled={!next} title="next (→)" onClick={() => step(next)}>→</button>
|
||||||
|
</>}
|
||||||
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
||||||
@@ -266,11 +308,17 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
{info && text !== null && (
|
{info && text !== null && (
|
||||||
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
||||||
)}
|
)}
|
||||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
{info && text === null && isImage(name) && (
|
||||||
|
<div className="preview pic" onPointerDown={(e) => { if (e.pointerType !== 'mouse' && e.isPrimary) swipe.current = { x: e.clientX, y: e.clientY } }} onPointerUp={swipeEnd} onPointerCancel={() => { swipe.current = null }}>
|
||||||
|
<img src={api.fileReadURL(path, sudo)} alt={name} draggable={false} />
|
||||||
|
{prev && <button className="step-edge prev" title="previous (←)" onClick={() => step(prev)}>‹</button>}
|
||||||
|
{next && <button className="step-edge next" title="next (→)" onClick={() => step(next)}>›</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />}
|
{info && text === null && !isImage(name) && info.preview === 'video' && <Player key={path} path={path} sudo={sudo} name={name} />}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
||||||
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls autoPlay preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
||||||
)}
|
)}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
|
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
|
||||||
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
|
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
|
||||||
@@ -399,7 +447,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
if (files.length) uploadFiles(files)
|
if (files.length) uploadFiles(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
if (open) {
|
||||||
|
const cur = shown.find((e) => join(path, e.name) === open)
|
||||||
|
const steps = shown.filter((e) => e.type !== 'dir' && e.type !== 'other' && (!cur || !isMedia(cur) || isMedia(e))).map((e) => join(path, e.name))
|
||||||
|
return <div className="files"><Viewer path={open} sudo={sudo} steps={steps} onStep={setOpen} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
|
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
|
||||||
|
|||||||
+13
-4
@@ -158,6 +158,10 @@ export default function Hardware() {
|
|||||||
const capacity = drives.reduce((n, d) => n + d.size, 0)
|
const capacity = drives.reduce((n, d) => n + d.size, 0)
|
||||||
const threads = cpu.threads ?? 0
|
const threads = cpu.threads ?? 0
|
||||||
const cores = cpu.cores ?? 0
|
const cores = cpu.cores ?? 0
|
||||||
|
// a two-socket board is two processors, not one with twice the cores: count and name each
|
||||||
|
const sockets = Math.max(1, cpu.sockets ?? 1)
|
||||||
|
const multi = sockets > 1
|
||||||
|
const per = (n: number) => Math.round(n / sockets)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash">
|
<div className="dash">
|
||||||
@@ -171,8 +175,11 @@ export default function Hardware() {
|
|||||||
<div className="kpis">
|
<div className="kpis">
|
||||||
<div className="tile">
|
<div className="tile">
|
||||||
<div className="label">Processor</div>
|
<div className="label">Processor</div>
|
||||||
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > 20 ? { fontSize: 16 } : undefined}>{cpu.model}</div>
|
<div className="value" title={cpu.model} style={(cpu.model?.length ?? 0) > (multi ? 16 : 20) ? { fontSize: 16 } : undefined}>{multi ? `${sockets} × ${cpu.model}` : cpu.model}</div>
|
||||||
<div className="sub">{[cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : ''].filter(Boolean).join(' · ')}</div>
|
<div className="sub">{(multi
|
||||||
|
? [`${sockets} CPUs`, cores ? `${per(cores)} cores${threads && threads !== cores ? ` / ${per(threads)} threads` : ''} each` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : '']
|
||||||
|
: [cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : '']
|
||||||
|
).filter(Boolean).join(' · ')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tile">
|
<div className="tile">
|
||||||
<div className="label">Memory</div>
|
<div className="label">Memory</div>
|
||||||
@@ -199,9 +206,11 @@ export default function Hardware() {
|
|||||||
<div className="props">
|
<div className="props">
|
||||||
<P k="Model" v={cpu.model} />
|
<P k="Model" v={cpu.model} />
|
||||||
<P k="Vendor" v={cpu.vendor} />
|
<P k="Vendor" v={cpu.vendor} />
|
||||||
<P k="Cores" v={cores ? `${cores}${cpu.threads_per_core && cpu.threads_per_core > 1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} />
|
{multi && <P k="CPUs" v={`${sockets}`} />}
|
||||||
|
<P k="Cores" v={cores ? `${multi ? `${per(cores)} per CPU · ${cores} in all` : cores}${cpu.threads_per_core && cpu.threads_per_core > 1 ? ` · ${cpu.threads_per_core} threads each` : ''}` : ''} />
|
||||||
|
{multi && threads > 0 && <P k="Threads" v={`${per(threads)} per CPU · ${threads} in all`} />}
|
||||||
<P k="Clock" v={cpu.min_mhz && cpu.max_mhz ? `${ghz(cpu.min_mhz)} – ${ghz(cpu.max_mhz)}` : ghz(cpu.max_mhz)} />
|
<P k="Clock" v={cpu.min_mhz && cpu.max_mhz ? `${ghz(cpu.min_mhz)} – ${ghz(cpu.max_mhz)}` : ghz(cpu.max_mhz)} />
|
||||||
<P k="Socket" v={(cpu.socket_names ?? []).join(', ')} />
|
<P k={multi ? 'Sockets' : 'Socket'} v={(cpu.socket_names ?? []).join(', ')} />
|
||||||
<P k="Arch" v={cpu.arch} />
|
<P k="Arch" v={cpu.arch} />
|
||||||
<P k="Family" v={[cpu.family && `family ${cpu.family}`, cpu.model_id && `model ${cpu.model_id}`, cpu.stepping && `stepping ${cpu.stepping}`].filter(Boolean).join(' · ')} />
|
<P k="Family" v={[cpu.family && `family ${cpu.family}`, cpu.model_id && `model ${cpu.model_id}`, cpu.stepping && `stepping ${cpu.stepping}`].filter(Boolean).join(' · ')} />
|
||||||
<P k="Microcode" v={cpu.microcode} mono />
|
<P k="Microcode" v={cpu.microcode} mono />
|
||||||
|
|||||||
-187
@@ -1,187 +0,0 @@
|
|||||||
// The KVM of the machine you are looking at: the device's own web page, shown in the tab.
|
|
||||||
//
|
|
||||||
// One per machine, and it follows the machine switcher — switch to `grid` and you get
|
|
||||||
// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns
|
|
||||||
// its keep exactly when that machine is down and cannot answer for itself; the hub proxies
|
|
||||||
// the device either way.
|
|
||||||
//
|
|
||||||
// Nothing here knows anything about any particular KVM. It is an iframe pointed at Ham
|
|
||||||
// Lab's own proxy, so whatever web UI the device serves is what you get.
|
|
||||||
//
|
|
||||||
// On the keyboard: a page cannot capture keys and forward them into an iframe, and does not
|
|
||||||
// need to. An iframe with focus receives them directly. What the browser itself swallows —
|
|
||||||
// Escape, Tab, Ctrl-W, the function keys — is what `navigator.keyboard.lock()` releases,
|
|
||||||
// and that needs fullscreen and only exists in Chromium. Everywhere else you get the plain
|
|
||||||
// version, which is most keys, and the device's own on-screen buttons for the rest.
|
|
||||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { KVM as Device, api } from './api'
|
|
||||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
|
||||||
|
|
||||||
// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a
|
|
||||||
// browser without it simply does not get the extra keys.
|
|
||||||
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
|
|
||||||
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
|
||||||
|
|
||||||
// Fullscreen, across the three ways browsers spell it. Safari on a Mac wants the webkit
|
|
||||||
// prefix; Safari on iOS has no fullscreen for an ordinary element at all — only for a
|
|
||||||
// <video> — so there the page expands inside the viewport instead, which is the same thing
|
|
||||||
// minus the browser's own chrome.
|
|
||||||
type FSElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void }
|
|
||||||
type FSDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise<void> | void }
|
|
||||||
const nativeFullscreen = () => {
|
|
||||||
if (typeof document === 'undefined') return false
|
|
||||||
const el = document.documentElement as FSElement
|
|
||||||
return typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'
|
|
||||||
}
|
|
||||||
const fullscreenElement = () => (document as FSDocument).fullscreenElement ?? (document as FSDocument).webkitFullscreenElement ?? null
|
|
||||||
async function enterFullscreen(el: FSElement) {
|
|
||||||
if (typeof el.requestFullscreen === 'function') return el.requestFullscreen()
|
|
||||||
if (typeof el.webkitRequestFullscreen === 'function') return el.webkitRequestFullscreen()
|
|
||||||
throw new Error('this browser has no fullscreen for a page element')
|
|
||||||
}
|
|
||||||
function exitFullscreen() {
|
|
||||||
const d = document as FSDocument
|
|
||||||
if (typeof d.exitFullscreen === 'function') return d.exitFullscreen()
|
|
||||||
if (typeof d.webkitExitFullscreen === 'function') return d.webkitExitFullscreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
|
||||||
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
|
||||||
const [adding, setAdding] = useState(false)
|
|
||||||
const [err, setErr] = useState('')
|
|
||||||
const [full, setFull] = useState(false) // native fullscreen is on
|
|
||||||
const [expanded, setExpanded] = useState(false) // the fallback: filling the viewport in-page
|
|
||||||
const box = useRef<HTMLDivElement>(null)
|
|
||||||
const frame = useRef<HTMLIFrameElement>(null)
|
|
||||||
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try { setKvm((await api.kvms()).find((k) => k.machine === key) ?? null) }
|
|
||||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
||||||
}, [key])
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
// Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather
|
|
||||||
// than the browser. Both are undone together when fullscreen ends, however it ends.
|
|
||||||
useEffect(() => {
|
|
||||||
const onChange = () => {
|
|
||||||
const on = !!fullscreenElement()
|
|
||||||
setFull(on)
|
|
||||||
if (!on) keyboardAPI()?.unlock?.()
|
|
||||||
}
|
|
||||||
document.addEventListener('fullscreenchange', onChange)
|
|
||||||
document.addEventListener('webkitfullscreenchange', onChange)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('fullscreenchange', onChange)
|
|
||||||
document.removeEventListener('webkitfullscreenchange', onChange)
|
|
||||||
keyboardAPI()?.unlock?.()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
// Expanding is in-page, so Escape is ours to handle.
|
|
||||||
useEscape(() => setExpanded(false), expanded)
|
|
||||||
|
|
||||||
const goBig = async () => {
|
|
||||||
if (!nativeFullscreen()) { setExpanded(true); frame.current?.focus(); return }
|
|
||||||
try {
|
|
||||||
if (box.current) await enterFullscreen(box.current)
|
|
||||||
await keyboardAPI()?.lock?.()
|
|
||||||
frame.current?.focus()
|
|
||||||
} catch {
|
|
||||||
setExpanded(true) // refused (an iframe policy, a permissions policy): fill the page instead
|
|
||||||
frame.current?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const goSmall = () => { setExpanded(false); if (fullscreenElement()) exitFullscreen() }
|
|
||||||
|
|
||||||
const remove = async () => {
|
|
||||||
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
|
||||||
try { await api.removeKVM(kvm.id); toast('removed'); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
|
|
||||||
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
|
||||||
|
|
||||||
const locks = !!keyboardAPI()?.lock
|
|
||||||
const big = full || expanded
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="kvm">
|
|
||||||
<div className="kvm-bar">
|
|
||||||
<span className="dname">{title}</span>
|
|
||||||
{kvm
|
|
||||||
? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
|
|
||||||
: <span className="muted small">no KVM</span>}
|
|
||||||
<span className="spacer" />
|
|
||||||
{kvm && <>
|
|
||||||
<Btn className="btn" onClick={goBig} title={!nativeFullscreen() ? 'fill the screen — this browser has no true fullscreen for a page element' : locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>{nativeFullscreen() ? 'Fullscreen' : 'Expand'}</Btn>
|
|
||||||
<a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
|
|
||||||
<Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
|
|
||||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
|
||||||
</>}
|
|
||||||
{!kvm && <Btn className="btn on" onClick={() => setAdding(true)}>+ Add a KVM for {title}</Btn>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{kvm ? (
|
|
||||||
<div className={'kvm-frame' + (full ? ' full' : '') + (expanded ? ' expanded' : '')} ref={box}>
|
|
||||||
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
|
|
||||||
allow="fullscreen; clipboard-read; clipboard-write" />
|
|
||||||
{expanded && <button className="btn kvm-shrink" onClick={goSmall} title="back to the console">Done</button>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="svc-empty muted kvm-none">
|
|
||||||
<div>
|
|
||||||
<p>No KVM for <b>{title}</b>.</p>
|
|
||||||
<p className="small">Add one with its address and its page appears here. The address is kept on the hub,
|
|
||||||
so it still works when {title} does not — which is when you will want it.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{kvm && !big && (
|
|
||||||
<div className="kvm-hint muted small">
|
|
||||||
Click the picture, then type — the keys go to {title}.
|
|
||||||
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{adding && <AddDialog machine={key} title={title} existing={kvm} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load() }} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
|
|
||||||
const [url, setUrl] = useState(existing?.url ?? '')
|
|
||||||
const [name, setName] = useState(existing?.name ?? '')
|
|
||||||
const [err, setErr] = useState('')
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
useEscape(onClose)
|
|
||||||
const save = async (e: FormEvent) => {
|
|
||||||
e.preventDefault(); setErr(''); setBusy(true)
|
|
||||||
try { await api.addKVM(name.trim(), url.trim(), machine); onAdded() }
|
|
||||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="modal-back" onClick={onClose}>
|
|
||||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
|
||||||
<h3>{existing ? `Change ${title}'s KVM` : `A KVM for ${title}`}</h3>
|
|
||||||
<label className="muted">Address — whatever you would type into a browser
|
|
||||||
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
|
|
||||||
</label>
|
|
||||||
<label className="muted">Name — optional; the address is used otherwise
|
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={`${title} KVM`} maxLength={64} />
|
|
||||||
</label>
|
|
||||||
<div className="muted small">
|
|
||||||
Ham Lab serves the device from its own address, so a KVM that speaks only plain HTTP
|
|
||||||
still works here, and it is behind this login rather than needing a second one. The
|
|
||||||
address is kept on the hub, so it still reaches the device when {title} is down. The
|
|
||||||
device itself is not changed and is never reachable from outside.
|
|
||||||
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
|
|
||||||
</div>
|
|
||||||
{err && <div className="error">{err}</div>}
|
|
||||||
<div className="svc-actions">
|
|
||||||
<button type="submit" className="btn on" disabled={busy || !url.trim()}>{existing ? 'Save' : 'Add'}</button>
|
|
||||||
<span className="spacer" />
|
|
||||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+3
-6
@@ -15,7 +15,6 @@ import Hardware from './Hardware'
|
|||||||
import Network from './Network'
|
import Network from './Network'
|
||||||
import VPN from './VPN'
|
import VPN from './VPN'
|
||||||
import ZFS from './ZFS'
|
import ZFS from './ZFS'
|
||||||
import KVM from './KVM'
|
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
import Top from './Top'
|
import Top from './Top'
|
||||||
import Machines from './Machines'
|
import Machines from './Machines'
|
||||||
@@ -81,7 +80,6 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
|
||||||
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
||||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
@@ -91,8 +89,8 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||||
|
|
||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
@@ -324,7 +322,6 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
||||||
{view === 'settings' && <Settings me={hubMe} />}
|
{view === 'settings' && <Settings me={hubMe} />}
|
||||||
{view === 'users' && <Users me={hubMe} />}
|
{view === 'users' && <Users me={hubMe} />}
|
||||||
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
|
||||||
{view === 'usage' && <Usage key={machine ?? ''} />}
|
{view === 'usage' && <Usage key={machine ?? ''} />}
|
||||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||||
{view === 'widgets' && <Gallery />}
|
{view === 'widgets' && <Gallery />}
|
||||||
@@ -366,7 +363,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+59
-25
@@ -3,7 +3,7 @@
|
|||||||
// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and
|
// as they climb. The numbers are Ham Lab's own — the sensor stream for everything measured and
|
||||||
// /api/procs for the table — so this works on any machine the console can see, whether or not
|
// /api/procs for the table — so this works on any machine the console can see, whether or not
|
||||||
// btop is installed on it.
|
// btop is installed on it.
|
||||||
import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { Fragment, createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, GPU, ProcTable, Proc, Sample } from './api'
|
import { api, GPU, ProcTable, Proc, Sample } from './api'
|
||||||
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
|
import { Catalog, MONITOR_SHAPE, Values, mergeValues, sampleOf, stale } from './sampleAdapter'
|
||||||
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
import { fetchCatalog, fetchHistories, useSensorStream } from './sensors'
|
||||||
@@ -11,6 +11,7 @@ import { fmtMs, nextPoint, REFRESH_OPTIONS, setRefreshMs, strideMs, useHistorySe
|
|||||||
import { Loading, askYes, toast } from './ui'
|
import { Loading, askYes, toast } from './ui'
|
||||||
import { Theme, currentThemeId, themeById } from './themes'
|
import { Theme, currentThemeId, themeById } from './themes'
|
||||||
import { columnsAt } from './graphcols'
|
import { columnsAt } from './graphcols'
|
||||||
|
import { socketThreads } from './sockets'
|
||||||
import { savePrefs } from './accents'
|
import { savePrefs } from './accents'
|
||||||
|
|
||||||
const MAX = 300 // points kept per curve, as the machines keep them
|
const MAX = 300 // points kept per curve, as the machines keep them
|
||||||
@@ -294,6 +295,13 @@ export default function Top() {
|
|||||||
// the process table, and the CPU model, come from the same place the Processes page reads,
|
// the process table, and the CPU model, come from the same place the Processes page reads,
|
||||||
// on the sample's beat (above)
|
// on the sample's beat (above)
|
||||||
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
|
const [pickedIf, setPickedIf] = useState<string | null>(null) // which NIC the net box draws
|
||||||
|
// which sockets' threads are folded away on a two-socket machine; remembered in this browser
|
||||||
|
const [folded, setFolded] = useState<number[]>(() => { try { return JSON.parse(localStorage.getItem('top.folded-cpus') ?? '[]') } catch { return [] } })
|
||||||
|
const fold = (pk: number) => setFolded((f) => {
|
||||||
|
const n = f.includes(pk) ? f.filter((x) => x !== pk) : [...f, pk]
|
||||||
|
try { localStorage.setItem('top.folded-cpus', JSON.stringify(n)) } catch { /* private window */ }
|
||||||
|
return n
|
||||||
|
})
|
||||||
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
|
// Which pool each drive belongs to, asked once: a drive in a ZFS pool has no filesystem of
|
||||||
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
|
// its own, and "nothing is on it" would be a lie about eight drives holding a NAS.
|
||||||
const [pools, setPools] = useState<Record<string, string>>({})
|
const [pools, setPools] = useState<Record<string, string>>({})
|
||||||
@@ -360,7 +368,16 @@ export default function Top() {
|
|||||||
const model = (pt?.cpu_model ?? '').replace(/\(R\)|\(TM\)|CPU|Processor/g, '').replace(/@.*$/, '').replace(/^\d+th Gen\s*/i, '').replace(/\s+/g, ' ').trim()
|
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;
|
// 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
|
// a second thread of a core (CPU 8 on an 8-core chip with hyper-threading) wears its core's
|
||||||
const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label))?.c }
|
// On a two-socket board each CPU has its own sensor chip, both counting from "Core 0", so a
|
||||||
|
// thread's core is looked up on its own socket's chip.
|
||||||
|
const pkgs = pt?.core_pkgs?.length === cores.length ? pt.core_pkgs : undefined
|
||||||
|
// each CPU's own threads, counted from 0 under it (sockets.ts): the kernel's one numbering
|
||||||
|
// of the whole machine puts cpu 0–9 and 20–29 on the first and 10–19 and 30–39 on the second
|
||||||
|
const socks = pkgs ? socketThreads(pkgs) : []
|
||||||
|
const sockets = socks.map((x) => x.pkg)
|
||||||
|
const coreTemp = (i: number) => { const id = pt?.core_ids?.[i] ?? i, pk = pkgs?.[i]; return s?.temps.find((t) => new RegExp(`core ${id}\\b`, 'i').test(t.label) && (pk === undefined || (t.pkg ?? 0) === pk))?.c }
|
||||||
|
const sockTemp = (pk: number) => s?.temps.find((t) => t.chip === 'coretemp' && new RegExp(`^package id ${pk}$`, 'i').test(t.label))?.c
|
||||||
|
const sockUse = (pk: number) => { const v = cores.filter((_, i) => pkgs?.[i] === pk); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0 }
|
||||||
const cpuTemp = pt?.cpu_temp ?? s?.temps.find((t) => /package|tctl|tdie|cpu/i.test(t.label))?.c ?? coreTemp(0)
|
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
|
// 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 anyTemp = cpuTemp !== undefined || cores.some((_, i) => coreTemp(i) !== undefined)
|
||||||
@@ -403,26 +420,43 @@ export default function Top() {
|
|||||||
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
|
<Graph values={curves.get('cpu.usage') ?? []} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
|
||||||
<div className="t-up">up {upfor(uptime)}</div>
|
<div className="t-up">up {upfor(uptime)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="t-cores">
|
<div className={'t-cores' + (sockets.length > 1 ? ' multi' : '')}>
|
||||||
<div className="t-cores-head"><b>{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
|
<div className="t-cores-head"><b>{sockets.length > 1 ? `${sockets.length} × ` : ''}{model || 'cpu'}</b><span className="spacer" />{pt?.cpu_mhz ? `${Math.round(pt.cpu_mhz)} MHz` : ''}
|
||||||
{pt?.cpu_power_w ? <span title="the CPU's own power reading: cores, built-in graphics and memory controller, not the whole machine">{pt.cpu_mhz ? ' · ' : ''}{pt.cpu_power_w < 10 ? pt.cpu_power_w.toFixed(1) : Math.round(pt.cpu_power_w)} W</span> : null}</div>
|
{pt?.cpu_power_w && sockets.length < 2 ? <span title="the CPU's own power reading: cores, built-in graphics and memory controller, not the whole machine">{pt.cpu_mhz ? ' · ' : ''}{pt.cpu_power_w < 10 ? pt.cpu_power_w.toFixed(1) : Math.round(pt.cpu_power_w)} W</span> : null}</div>
|
||||||
<div className="t-core">
|
{sockets.length < 2 && <div className="t-core">
|
||||||
<span className="t-core-n">CPU</span>
|
<span className="t-core-n">CPU</span>
|
||||||
<Meter pct={cpu} grad="cpu" />
|
<Meter pct={cpu} grad="cpu" />
|
||||||
<span className="t-core-p">{Math.round(cpu)}%</span>
|
<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></>}
|
{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>
|
</div>}
|
||||||
{cores.map((v, i) => {
|
{(sockets.length > 1 ? socks : [{ pkg: undefined, cpus: cores.map((_, i) => i) }]).map(({ pkg: pk, cpus }) => (
|
||||||
const t = coreTemp(i)
|
<Fragment key={pk ?? 'all'}>
|
||||||
return (
|
{pk !== undefined && (() => {
|
||||||
<div className="t-core" key={i}>
|
const u = sockUse(pk), t = sockTemp(pk), w = pt?.cpu_power_pkgs_w?.[pk], open = !folded.includes(pk)
|
||||||
<span className="t-core-n">C{i}</span>
|
return (
|
||||||
<span className="t-core-g" title={pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(recent.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
|
<div className="t-core t-sock" role="button" aria-expanded={open} title={open ? 'hide its threads' : 'show its threads'} onClick={() => fold(pk)}>
|
||||||
<span className="t-core-p">{Math.round(v)}%</span>
|
<span className="t-core-n"><span className="t-fold">{open ? '▾' : '▸'}</span>CPU{pk + 1}</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></>}
|
<span className="t-core-w" title="this CPU's own power reading: its cores, cache and memory controller">{w ? `${w < 10 ? w.toFixed(1) : Math.round(w)} W` : ''}</span>
|
||||||
</div>
|
<Meter pct={u} grad="cpu" />
|
||||||
)
|
<span className="t-core-p">{Math.round(u)}%</span>
|
||||||
})}
|
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{(pk !== undefined && folded.includes(pk) ? [] : cpus).map((i, n) => {
|
||||||
|
// n is the thread's number under its own CPU, i the kernel's for the whole machine
|
||||||
|
const t = coreTemp(i), v = cores[i] ?? 0
|
||||||
|
return (
|
||||||
|
<div className="t-core" key={i}>
|
||||||
|
<span className="t-core-n">C{n}</span>
|
||||||
|
<span className="t-core-g" title={pk !== undefined ? `CPU ${pk + 1} · thread ${n} · cpu ${i} to the kernel${pt?.core_ids ? ` · a thread of core ${pt.core_ids[i]}` : ''}` : pt?.core_ids && pt.core_ids[i] !== i ? `cpu ${i} · a thread of core ${pt.core_ids[i]}` : `core ${i}`}>{braille(recent.get(`cpu.core.${i}.usage`) ?? [], 9)}</span>
|
||||||
|
<span className="t-core-p">{Math.round(v)}%</span>
|
||||||
|
{anyTemp && <><span className="t-core-t">{t !== undefined && <Meter pct={Math.min(100, t)} grad="temp" />}</span><span className="t-core-c">{t !== undefined ? `${Math.round(t)}°C` : ''}</span></>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
<div className="t-load">Load AVG:<span className="spacer" />{load.map((x) => x.toFixed(2)).join(' ')}</div>
|
<div className="t-load">Load AVG:<span className="spacer" />{load.map((x) => x.toFixed(2)).join(' ')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,7 +490,9 @@ export default function Top() {
|
|||||||
// pool or an md array spans several drives, so nothing is attributed and the row
|
// pool or an md array spans several drives, so nothing is attributed and the row
|
||||||
// shows what is true of the hardware — how busy it is, and how hot.
|
// shows what is true of the hardware — how busy it is, and how hot.
|
||||||
const on = fs.filter((f) => f.disk === d.name)
|
const on = fs.filter((f) => f.disk === d.name)
|
||||||
const used = on.reduce((a, f) => a + f.used, 0)
|
// a bind mount (nexus's /var/log is /data/var-log) is the same filesystem twice:
|
||||||
|
// count each source device once, or the drive reads double
|
||||||
|
const used = [...new Map(on.map((f) => [f.source || f.mount, f.used])).values()].reduce((a, b) => a + b, 0)
|
||||||
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
|
return <Disk key={d.name} name={d.name} note={d.model} size={d.size} used={on.length ? used : undefined} io={d.util_pct} temp={d.temp} mounts={on.map((f) => f.mount)} pool={pools[d.name]} />
|
||||||
})}
|
})}
|
||||||
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
|
{(s?.disks ?? []).length === 0 && <div className="t-dim">no drives reported</div>}
|
||||||
@@ -560,13 +596,11 @@ const ENGINES: [string, string][] = [['render', 'render'], ['compute', 'compute'
|
|||||||
function GPUBox({ g, values, tick, advance, now, cap }: { g: GPU; values: number[]; tick: number; advance: number; now: number; cap: number }) {
|
function GPUBox({ g, values, tick, advance, now, cap }: { g: GPU; values: number[]; tick: number; advance: number; now: number; cap: number }) {
|
||||||
const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
|
const engines = ENGINES.filter(([k]) => g.engines && g.engines[k] !== undefined)
|
||||||
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined
|
const temp = g.temp !== undefined && g.temp > 0 ? g.temp : undefined
|
||||||
const how = g.stalled ? 'a sysfs read of this card stopped answering; its readings are paused until the console restarts'
|
|
||||||
: g.usage_src === 'idle' ? 'busy, from idle residency' : g.usage_src === 'busy' ? 'busy, as the driver reports it' : g.usage_src === 'clients' ? 'the busiest engine, summed over its clients' : 'this driver exposes no activity counter'
|
|
||||||
return (
|
return (
|
||||||
<div className="t-gpu">
|
<div className="t-gpu">
|
||||||
<div className="t-gpu-graph">
|
<div className="t-gpu-graph">
|
||||||
<Graph values={values} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
|
<Graph values={values} tick={tick} advance={advance} now={now} cap={cap} max={100} grad="cpu" />
|
||||||
<div className="t-up">{g.usage_src ? `${Math.round(g.usage)}% ` : ''}{how}</div>
|
{g.stalled && <div className="t-up">a read of this card stopped answering; its readings are paused until it does</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="t-gpu-stats">
|
<div className="t-gpu-stats">
|
||||||
<div className="t-cores-head"><b>{g.name}</b><span className="spacer" />{g.driver}{g.integrated ? ' · integrated' : ''}</div>
|
<div className="t-cores-head"><b>{g.name}</b><span className="spacer" />{g.driver}{g.integrated ? ' · integrated' : ''}</div>
|
||||||
@@ -590,7 +624,7 @@ function GPUBox({ g, values, tick, advance, now, cap }: { g: GPU; values: number
|
|||||||
<span className="t-fact" title={g.req_mhz ? `${Math.round(g.req_mhz)} MHz requested${g.max_mhz ? `, ${Math.round(g.max_mhz)} MHz max` : ''}` : undefined}>{Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz</span></div> : null}
|
<span className="t-fact" title={g.req_mhz ? `${Math.round(g.req_mhz)} MHz requested${g.max_mhz ? `, ${Math.round(g.max_mhz)} MHz max` : ''}` : undefined}>{Math.round(g.mhz ?? 0)}{g.max_mhz ? ` / ${Math.round(g.max_mhz)}` : ''} MHz</span></div> : null}
|
||||||
{(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ? <div className="t-row t-sub"><span>Power</span><span className="spacer" />
|
{(g.power_w !== undefined && g.power_w > 0) || g.power_cap_w ? <div className="t-row t-sub"><span>Power</span><span className="spacer" />
|
||||||
<span className="t-fact">{(g.power_w ?? 0).toFixed(1)}{g.power_cap_w ? ` / ${Math.round(g.power_cap_w)}` : ''} W</span></div> : null}
|
<span className="t-fact">{(g.power_w ?? 0).toFixed(1)}{g.power_cap_w ? ` / ${Math.round(g.power_cap_w)}` : ''} W</span></div> : null}
|
||||||
{g.fan_rpm ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{g.fan_rpm} rpm</span></div> : null}
|
{g.fan_rpm || g.fan_pct !== undefined ? <div className="t-row t-sub"><span>Fan</span><span className="spacer" /><span className="t-fact">{[g.fan_pct !== undefined ? `${Math.round(g.fan_pct)}%` : '', g.fan_rpm ? `${g.fan_rpm} rpm` : ''].filter(Boolean).join(' · ')}</span></div> : null}
|
||||||
{g.mem_total ? <div className="t-row t-sub"><span>Mem</span><span className="t-pct">{Math.round(((g.mem_used ?? 0) / g.mem_total) * 100)}%</span><Meter pct={((g.mem_used ?? 0) / g.mem_total) * 100} grad="used" /><span className="t-amt">{bytes(g.mem_used ?? 0, 1)}</span></div>
|
{g.mem_total ? <div className="t-row t-sub"><span>Mem</span><span className="t-pct">{Math.round(((g.mem_used ?? 0) / g.mem_total) * 100)}%</span><Meter pct={((g.mem_used ?? 0) / g.mem_total) * 100} grad="used" /><span className="t-amt">{bytes(g.mem_used ?? 0, 1)}</span></div>
|
||||||
: g.mem_used ? <div className="t-row t-sub"><span>Mem</span><span className="spacer" /><span className="t-fact" title="resident memory of the processes that have it open">{bytes(g.mem_used, 1)}</span></div> : null}
|
: g.mem_used ? <div className="t-row t-sub"><span>Mem</span><span className="spacer" /><span className="t-fact" title="resident memory of the processes that have it open">{bytes(g.mem_used, 1)}</span></div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -611,13 +645,13 @@ function GPUBox({ g, values, tick, advance, now, cap }: { g: GPU; values: number
|
|||||||
// Nothing on this GPU can be read — no busy counter, engine, temperature, clock, power, memory,
|
// Nothing on this GPU can be read — no busy counter, engine, temperature, clock, power, memory,
|
||||||
// nor a process with it open: the Mac's Asahi, today. Its box would be a graph that can never
|
// nor a process with it open: the Mac's Asahi, today. Its box would be a graph that can never
|
||||||
// draw beside a column of dashes, so it is one line instead. A client opening it brings the box back.
|
// draw beside a column of dashes, so it is one line instead. A client opening it brings the box back.
|
||||||
const unmeasured = (g: GPU) => !g.usage_src && !g.engines && !(g.temp && g.temp > 0) && !g.mhz && !g.max_mhz && !g.power_w && !g.power_cap_w && !g.fan_rpm && !g.mem_total && !g.mem_used && g.clients.length === 0
|
const unmeasured = (g: GPU) => !g.usage_src && !g.engines && !(g.temp && g.temp > 0) && !g.mhz && !g.max_mhz && !g.power_w && !g.power_cap_w && !g.fan_rpm && g.fan_pct === undefined && !g.mem_total && !g.mem_used && g.clients.length === 0
|
||||||
function GPULine({ g }: { g: GPU }) {
|
function GPULine({ g }: { g: GPU }) {
|
||||||
return (
|
return (
|
||||||
<div className="t-row t-gpu-line">
|
<div className="t-row t-gpu-line">
|
||||||
<span>{g.driver}{g.integrated ? ' · integrated' : ''}</span>
|
<span>{g.driver}{g.integrated ? ' · integrated' : ''}</span>
|
||||||
{g.stalled
|
{g.stalled
|
||||||
? <span className="t-dim" title="a sysfs read of this card stopped answering">readings paused until the console restarts</span>
|
? <span className="t-dim" title="a read of this card stopped answering">readings paused until it answers</span>
|
||||||
: <span className="t-dim" title="the driver exposes no busy counter, clock, temperature, power or memory reading">nothing measurable</span>}
|
: <span className="t-dim" title="the driver exposes no busy counter, clock, temperature, power or memory reading">nothing measurable</span>}
|
||||||
<span className="t-dim t-gpu-who" title={g.clients_ok ? undefined : 'the console has no root here'}>{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden'}</span>
|
<span className="t-dim t-gpu-who" title={g.clients_ok ? undefined : 'the console has no root here'}>{g.clients_ok ? 'no process has it open' : 'other users’ processes are hidden'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-9
@@ -71,11 +71,11 @@ export const titleOf = (name: string | null, machines: MachineStatus[], hubHost:
|
|||||||
export interface MachineStatus { name: string; url: string; fingerprint?: string; added: string; online: boolean; host?: string; version?: string; load?: string; uptime?: number; reboot_needed?: boolean; last_seen?: string; error?: string }
|
export interface 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 Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
|
||||||
|
|
||||||
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
|
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number; pkg?: number }
|
||||||
export interface Fan { chip: string; label: string; rpm: number }
|
export interface 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 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 Disk { name: string; model?: string; size: number; read_bps: number; write_bps: number; util_pct: number; temp?: number }
|
||||||
export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string }
|
export interface FS { mount: string; type: string; size: number; used: number; remote?: boolean; disk?: string; source?: string }
|
||||||
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
export interface UPS { driver: string; model: string; state: string; source: string; on_battery: boolean; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
|
||||||
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; gpu?: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
export interface Proc { pid: number; ppid: number; name: string; cmd?: string; user: string; cpu: number; gpu?: number; cpu_time: number; rss: number; threads: number; state: string; nice: number; started: number }
|
||||||
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
|
export interface MemInfo { total: number; used: number; avail: number; free?: number; cached?: number; buffers?: number }
|
||||||
@@ -86,11 +86,11 @@ export interface GPUClient { pid: number; name: string; usage: number; engines?:
|
|||||||
export interface GPU {
|
export interface GPU {
|
||||||
name: string; driver: string; bus?: string; integrated?: boolean
|
name: string; driver: string; bus?: string; integrated?: boolean
|
||||||
usage: number; usage_src?: 'idle' | 'busy' | 'clients'; engines?: Record<string, number>
|
usage: number; usage_src?: 'idle' | 'busy' | 'clients'; engines?: Record<string, number>
|
||||||
temp?: number; temp_src?: 'gpu' | 'package'; power_w?: number; power_cap_w?: number; fan_rpm?: number
|
temp?: number; temp_src?: 'gpu' | 'package'; power_w?: number; power_cap_w?: number; fan_rpm?: number; fan_pct?: number
|
||||||
mhz?: number; req_mhz?: number; max_mhz?: number; mem_used?: number; mem_total?: number
|
mhz?: number; req_mhz?: number; max_mhz?: number; mem_used?: number; mem_total?: number
|
||||||
clients: GPUClient[]; clients_ok: boolean; stalled?: boolean
|
clients: GPUClient[]; clients_ok: boolean; stalled?: boolean
|
||||||
}
|
}
|
||||||
export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_temp?: number; core_ids?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number }
|
export interface ProcTable { t: number; cpu: number; cores: number[]; cpu_hist: number[]; cpu_model?: string; cpu_mhz?: number; cpu_max_mhz?: number; cpu_power_w?: number; cpu_power_pkgs_w?: number[]; cpu_temp?: number; core_ids?: number[]; core_pkgs?: number[]; load: [number, number, number]; mem: MemInfo; swap: MemInfo; net: NetIf[]; gpus?: GPU[]; uptime: number; procs: Proc[]; total: number; running: number }
|
||||||
export interface Sample {
|
export interface Sample {
|
||||||
t: number; interval_ms: number; cpu: number; cores: number[]; load: [number, number, number]
|
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 }
|
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
|
||||||
@@ -185,7 +185,6 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
|
|||||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
||||||
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||||
|
|
||||||
export interface KVM { id: string; name: string; url: string; machine: string; added: string }
|
|
||||||
|
|
||||||
// ---- the Network tab ----
|
// ---- the Network tab ----
|
||||||
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
||||||
@@ -360,10 +359,6 @@ export const api = {
|
|||||||
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
||||||
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
||||||
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
||||||
// Always the hub's: it holds every machine's KVM, so the tab works when the machine does not.
|
|
||||||
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
|
|
||||||
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
|
|
||||||
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
|
||||||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||||
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||||||
zfsLanding: () => req<LandingCheck>('GET', '/api/zfs/landing'),
|
zfsLanding: () => req<LandingCheck>('GET', '/api/zfs/landing'),
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ export function sampleOf(v: Values, cat: Catalog): Sample {
|
|||||||
t: Date.now(), interval_ms: num(v, 'hamlab.sample_interval'),
|
t: Date.now(), interval_ms: num(v, 'hamlab.sample_interval'),
|
||||||
cpu: num(v, 'cpu.usage'), cores, load: [num(v, 'load.1'), num(v, 'load.5'), num(v, 'load.15')],
|
cpu: num(v, 'cpu.usage'), cores, load: [num(v, 'load.1'), num(v, 'load.5'), num(v, 'load.15')],
|
||||||
mem: mem('mem'), swap: mem('swap'), uptime: num(v, 'uptime'),
|
mem: mem('mem'), swap: mem('swap'), uptime: num(v, 'uptime'),
|
||||||
temps: hw('temp.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, c: num(v, id), max: s.warn, crit: s.crit })),
|
temps: hw('temp.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, c: num(v, id), max: s.warn, crit: s.crit, pkg: s.labels!.package !== undefined ? Number(s.labels!.package) : undefined })),
|
||||||
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
|
fans: hw('fan.').map(({ id, s }) => ({ chip: s.labels!.chip, label: s.labels!.label, rpm: num(v, id) })),
|
||||||
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
|
net: [...groups(v, cat, 'net.', 'iface')].map(([iface, p]) => ({ iface, up: v[p + '.up'] === true, rx_bps: num(v, p + '.rx'), tx_bps: num(v, p + '.tx'), rx_total: num(v, p + '.rx_total'), tx_total: num(v, p + '.tx_total') })),
|
||||||
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
|
disks: [...groups(v, cat, 'disk.', 'device')].map(([name, p]) => ({ name, model: cat.get(p + '.size')?.labels?.model || undefined, size: num(v, p + '.size'), read_bps: num(v, p + '.read'), write_bps: num(v, p + '.write'), util_pct: num(v, p + '.util'), temp: num(v, p + '.temp') || undefined })),
|
||||||
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk })),
|
fs: [...groups(v, cat, 'fs.', 'mount')].map(([mount, p]) => ({ mount, type: cat.get(p + '.size')?.labels?.fstype ?? '', size: num(v, p + '.size'), used: num(v, p + '.used'), remote: cat.get(p + '.size')?.labels?.remote === '1', disk: cat.get(p + '.size')?.labels?.disk, source: cat.get(p + '.size')?.labels?.source })),
|
||||||
ups: v['ups.battery'] === undefined ? null : {
|
ups: v['ups.battery'] === undefined ? null : {
|
||||||
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
|
driver: cat.get('ups.last_event')?.labels?.driver ?? '', model: cat.get('ups.battery')?.labels?.model ?? '', state: str(v, 'ups.state'), source: str(v, 'ups.source'),
|
||||||
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
|
on_battery: v['ups.on_battery'] === true, in_v: num(v, 'ups.in_v'), out_v: num(v, 'ups.out_v'), battery_pct: num(v, 'ups.battery'), runtime_min: num(v, 'ups.runtime'),
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Which threads belong to which CPU, on a machine with more than one.
|
||||||
|
//
|
||||||
|
// The kernel numbers every thread of the machine in one run, and on a two-socket board with
|
||||||
|
// hyper-threading it deals them out a socket at a time and then goes round again: cpu 0–9 are
|
||||||
|
// CPU 1's cores, 10–19 CPU 2's, 20–29 the second threads of CPU 1's, 30–39 of CPU 2's. Listed
|
||||||
|
// under its CPU by that number, a 20-thread processor reads "C0…C9, C20…C29" and the one
|
||||||
|
// beside it "C10…C19, C30…C39" — neither looks like twenty of anything. So each CPU's threads
|
||||||
|
// are counted from 0 under it, in the kernel's order, the way a one-socket machine's are: the
|
||||||
|
// first half are its cores, the second half their second threads.
|
||||||
|
//
|
||||||
|
// `pkgs[i]` is the socket logical CPU `i` is on. The answer is, for each socket (ascending),
|
||||||
|
// the logical CPUs on it (ascending); a thread's number under its CPU is its place in that list.
|
||||||
|
export function socketThreads(pkgs: number[]): { pkg: number; cpus: number[] }[] {
|
||||||
|
const by = new Map<number, number[]>()
|
||||||
|
pkgs.forEach((pk, i) => { const l = by.get(pk); if (l) l.push(i); else by.set(pk, [i]) })
|
||||||
|
return [...by.entries()].sort((a, b) => a[0] - b[0]).map(([pkg, cpus]) => ({ pkg, cpus }))
|
||||||
|
}
|
||||||
+9
-16
@@ -378,6 +378,10 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
||||||
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
||||||
|
.preview.pic { position: relative; touch-action: pan-y pinch-zoom; }
|
||||||
|
.step-edge { position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 72px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, .35); color: #fff; font-size: 34px; line-height: 1; cursor: pointer; opacity: .55; }
|
||||||
|
.step-edge:hover { opacity: 1; } .step-edge.prev { left: 8px; } .step-edge.next { right: 8px; }
|
||||||
|
.btn.step:disabled { opacity: .4; }
|
||||||
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
||||||
on an iPhone the whole window */
|
on an iPhone the whole window */
|
||||||
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
||||||
@@ -790,6 +794,11 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.t-cores-head b { color: var(--fg); font-weight: 400; overflow: hidden; text-overflow: ellipsis; }
|
.t-cores-head b { color: var(--fg); font-weight: 400; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.t-core { display: flex; align-items: center; gap: 6px; }
|
.t-core { display: flex; align-items: center; gap: 6px; }
|
||||||
.t-core-n { width: 30px; } .t-core-p { width: 34px; text-align: right; }
|
.t-core-n { width: 30px; } .t-core-p { width: 34px; text-align: right; }
|
||||||
|
/* a two-socket board: a row per socket — arrow, name, watts — with its threads indented under it */
|
||||||
|
.t-cores.multi .t-core-n { width: 50px; } .t-cores.multi .t-core:not(.t-sock) .t-core-n { padding-left: 14px; box-sizing: border-box; }
|
||||||
|
.t-sock { color: var(--fg); margin-top: 2px; cursor: pointer; user-select: none; }
|
||||||
|
.t-fold { display: inline-block; width: 12px; color: var(--muted); }
|
||||||
|
.t-core-w { width: 40px; flex-shrink: 0; text-align: right; color: var(--muted); }
|
||||||
.t-core-g { flex: 1; color: var(--ok); letter-spacing: 0; overflow: hidden; white-space: nowrap; }
|
.t-core-g { flex: 1; color: var(--ok); letter-spacing: 0; overflow: hidden; white-space: nowrap; }
|
||||||
.t-core .tm { flex: 1; } .t-core-t { flex: 0 0 62px; display: flex; } .t-core-c { width: 42px; text-align: right; color: var(--t-blue); }
|
.t-core .tm { flex: 1; } .t-core-t { flex: 0 0 62px; display: flex; } .t-core-c { width: 42px; text-align: right; color: var(--t-blue); }
|
||||||
.t-load { display: flex; margin-top: 2px; }
|
.t-load { display: flex; margin-top: 2px; }
|
||||||
@@ -959,25 +968,9 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||||
.modal .chips { margin-top: 2px; }
|
.modal .chips { margin-top: 2px; }
|
||||||
|
|
||||||
/* ---- KVM: a device's own web page, framed ---- */
|
|
||||||
.kvm { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
|
||||||
.kvm-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-wrap: wrap; }
|
|
||||||
.kvm-bar .kvm-url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 28ch; }
|
|
||||||
.kvm-frame { flex: 1; min-height: 0; background: #000; position: relative; }
|
|
||||||
.kvm-frame iframe { width: 100%; height: 100%; border: 0; display: block; background: #000; }
|
|
||||||
.kvm-frame.full { background: #000; }
|
|
||||||
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
|
||||||
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
|
||||||
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
|
|
||||||
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
|
|
||||||
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; padding-top: calc(env(safe-area-inset-top) + var(--blur)); }
|
|
||||||
.kvm-frame.expanded iframe { height: 100%; }
|
|
||||||
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
|
|
||||||
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
|
||||||
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
||||||
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
||||||
|
|
||||||
|
|
||||||
/* a phone: the time goes above the seek bar, so the buttons fit in one row */
|
/* a phone: the time goes above the seek bar, so the buttons fit in one row */
|
||||||
.vp-time.above { display: none; }
|
.vp-time.above { display: none; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user