A guest login is a full account with a clock on it

Mitchell, on the read-only version built earlier today: scratch it. He hands
these to people he trusts and revokes them otherwise, so a console with half
its tabs missing solved a problem he doesn't have.

So the allow-list, the middleware check and every "not for a guest" branch in
the UI are gone. What is left is the part that was the point: a throwaway
account that expires by itself, is checked on every request, swept once a
minute, and can be ended in one tap. A guest now gets the whole console on
every machine, terminal included.

Said once, since it is his call and he has made it: that means a guest has a
root-capable shell on every host, and can make an account or a token of its own
that outlives the guest login.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-27 17:53:55 -07:00
co-authored by Claude Opus 5
parent 8a7167bf03
commit 179f342a2c
11 changed files with 65 additions and 346 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ stub tab or a roadmap item.
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. | | Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. | | Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. | | Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry, so login, sessions and prefs need no special case. What it may *do* is one allow-list of routes checked in the auth middleware, not a flag each handler honours. Denied by default. | Built 2026-09-27, asked for the same day. There are ~190 routes; a per-handler check would be wrong the first time a route was added. The list is matched by asking the router which handler a request would reach, so it cannot drift from the routes it is about, and it is applied to the *scoped* path (`/api/nodes/grid/files/read` → `/api/files/read`) because the hub proxies to a node with a token that is full control of it — the node cannot tell a guest from its owner, so the decision has to be made on the hub. | | 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. |
## Layout ## Layout
+6 -7
View File
@@ -55,13 +55,12 @@ users` lists them, `hamlab user-rm <user>` removes one. Machine settings — ref
graph history, alert rules — stay the machine's and are shared by everyone. graph history, alert rules — stay the machine's and are shared by everyone.
**Guest logins**, for showing somebody the console: Users → Guest logins makes a **Guest logins**, for showing somebody the console: Users → Guest logins makes a
throwaway user name and password, good for half an hour by default, and hands you the throwaway user name and password, good for half an hour by default (up to 24 hours), and
pair to paste into a message. A guest sees the dashboard, the containers, the disks, the hands you the pair to paste into a message. A guest is an ordinary account with a clock on
hardware and the machines this hub manages, and can change none of it — no terminal, no it — the whole console, every machine, the terminal included — that stops working when the
files, no logs or container environments, no settings, no power. It can be revoked in one time is up, wherever it is signed in, or the moment you revoke it. There is no reduced
tap, and it disappears on its own when the time is up, wherever it is signed in. What a guest: give one to somebody you would hand the password to, and revoke it if you change
guest may reach is a list of routes in `internal/api/guests.go`, so a route added later your mind.
is shut to guests until somebody says otherwise.
What adapts to the host, rather than assuming Debian: What adapts to the host, rather than assuming Debian:
+10 -97
View File
@@ -1,21 +1,22 @@
package api package api
// Guest logins: a temporary account for showing somebody the console. It expires by itself // Guest logins: a temporary account for handing to somebody you want to show around. It is
// (half an hour by default), it can be revoked in one tap, and it may only look — it cannot // an ordinary account with a clock on it — every tab, every action, on this machine and on
// change a thing, on this machine or on any machine this hub manages. // every machine this hub manages — that stops working at its expiry (half an hour by
// default) or the moment it is revoked.
// //
// The gate is a list of what a guest may reach, and nothing else, so a route added later is // Deliberately not a reduced account: Mitchell, 2026-09-27, replacing a read-only version
// shut to guests until somebody decides otherwise. It is applied to the *scoped* path, which // built the same day. He gives these to people he trusts and revokes them otherwise, and a
// is what makes it hold for managed machines too: the hub proxies to a node as itself, with a // console with half its buttons missing is not the console he wanted to show off. What that
// token that is full control of that node, so the node cannot tell a guest from its owner — // means in practice: a guest has the Terminal tab, which is a shell as the user the daemon
// the decision has to be made here, before the request leaves. // runs as — root on every host here — and can make an account or a token of its own that
// outlives the guest login.
import ( import (
"encoding/json" "encoding/json"
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -31,86 +32,6 @@ const (
guestReapEvery = time.Minute guestReapEvery = time.Minute
) )
// guestRoutes is every route a guest login may reach, by the pattern it is registered under
// in New. Reading only, with three exceptions that are the guest's own: signing out, and the
// theme and page order that follow the login.
//
// Deliberately not here, though they only read:
// - terminals — attaching is a root-capable shell, and attaching is a GET
// - files — reading a file is reading every file, including keys and this config
// - a container's logs, inspect, events and health — env vars and logs are where secrets are
// - stack files and .env — same
// - cron/jobs, alerts — the jobs are commands; a channel URL is a key to his phone
// - KVM — a screen and a keyboard on a machine, and its assets load with a GET
// - updates — what is unpatched on his box is not a thing to show a visitor
var guestRoutes = map[string]bool{
"GET /api/me": true,
"POST /api/logout": true,
"GET /api/nodes": true, // the machine switcher: a guest may look at any of them
"GET /api/prefs/ui": true,
"PUT /api/prefs/ui": true, // the look is the login's own
"GET /api/prefs/history": true,
"GET /api/prefs/sampler": true,
"GET /api/procs": true,
"GET /api/hardware": true,
"GET /api/storage": true,
"GET /api/storage/smart/{dev}": true,
"GET /api/storage/zfs/snapshots": true,
"GET /api/zfs": true,
"GET /api/docker": true, // containers, images, volumes, networks — the lists only
"GET /api/docker/stacks": true,
"GET /api/services": true,
"GET /api/services/{unit}": true,
"GET /api/space": true,
"GET /api/space/{disk}": true,
"GET /api/space/{disk}/tree": true,
"GET /api/space/{disk}/largest": true,
"GET /api/tasks": true,
"GET /api/tasks/{id}": true,
// sensors: the read-only interface the dashboard and every graph are built on. A query
// is a POST when the list of ids is too long for a URL, and still only a read.
"GET /api/sensors": true,
"GET /api/sensors/query": true,
"POST /api/sensors/query": true,
"GET /api/sensors/stream": true,
"GET /api/sensors/history": true,
"GET /api/sensors/{id}": true,
"GET /api/sensors/{id}/history": true,
"GET /api/fleet/sensors/query": true,
"POST /api/fleet/sensors/query": true,
}
// guestScope is the path whose permission decides this request. A hub route for a managed
// machine is the same route on that machine — /api/nodes/grid/procs is /api/procs — so it is
// judged by the same line of guestRoutes. A path with nothing after the machine name is the
// machine record itself (adding or removing one), which is no guest's business.
func guestScope(p string) string {
rest, ok := strings.CutPrefix(p, "/api/nodes/")
if !ok {
return p
}
i := strings.IndexByte(rest, '/')
if i < 0 {
return ""
}
return "/api" + rest[i:]
}
// guestMay decides a request by asking the router which handler it would reach, so the
// answer can never drift from the route it is about. An unrouted path lands on the catch-all,
// which is not in the list, so it is refused.
func (s *Server) guestMay(r *http.Request) bool {
probe := r
if p := guestScope(r.URL.Path); p != r.URL.Path {
if p == "" {
return false
}
probe = &http.Request{Method: r.Method, URL: &url.URL{Path: p}, Host: r.Host}
}
_, pattern := s.mux.Handler(probe)
return guestRoutes[pattern]
}
// ---- the accounts themselves ---- // ---- the accounts themselves ----
func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) { func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
@@ -135,10 +56,6 @@ func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
// readable, here as everywhere else a password is generated. // readable, here as everywhere else a password is generated.
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) { func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
me := actorOf(r) me := actorOf(r)
if me.Guest {
jsonErr(w, 403, "a guest login can only look around")
return
}
var body struct { var body struct {
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
} }
@@ -194,10 +111,6 @@ func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
// revokeGuest ends a guest login now. No password: it only ever takes access away, and the // revokeGuest ends a guest login now. No password: it only ever takes access away, and the
// whole point of the thing is that ending it is one tap. // whole point of the thing is that ending it is one tap.
func (s *Server) revokeGuest(w http.ResponseWriter, r *http.Request) { func (s *Server) revokeGuest(w http.ResponseWriter, r *http.Request) {
if actorOf(r).Guest {
jsonErr(w, 403, "a guest login can only look around")
return
}
name := r.PathValue("name") name := r.PathValue("name")
found := false found := false
if err := s.cfg.Update(func(c *config.Config) { if err := s.cfg.Update(func(c *config.Config) {
+4 -139
View File
@@ -3,9 +3,7 @@ package api
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -14,143 +12,10 @@ import (
"hamlab/internal/store" "hamlab/internal/store"
) )
// The router built without any of the machinery behind it: registering a route never calls // A guest login is an ordinary account with a clock on it, so the clock is the thing worth
// its handler, so this is the real table, and what guestMay is asked about. // testing: one whose time is up is stopped at the door, whether or not the minute-by-minute
func gateServer(t *testing.T) *Server { // sweep has reached their account yet, and their session is dropped on the way — so the
t.Helper() // console they left open goes back to the login form rather than carrying on.
s := &Server{}
s.mux = s.routes()
return s
}
func may(t *testing.T, s *Server, method, path string) bool {
t.Helper()
return s.guestMay(&http.Request{Method: method, URL: &url.URL{Path: path}})
}
// Every line of guestRoutes has to name a route that exists. A pattern with a typo in it
// would quietly match nothing — safe, but the page that needs it would break with no clue
// why, so the mistake is caught here instead.
func TestGuestRoutesAreReal(t *testing.T) {
s := gateServer(t)
for pattern := range guestRoutes {
method, path, ok := strings.Cut(pattern, " ")
if !ok {
t.Errorf("%q is not \"METHOD /path\"", pattern)
continue
}
probe := strings.NewReplacer("{dev}", "sda", "{unit}", "ssh.service", "{disk}", "sda", "{id}", "x", "{name}", "grid").Replace(path)
_, got := s.mux.Handler(&http.Request{Method: method, URL: &url.URL{Path: probe}})
if got != pattern {
t.Errorf("guestRoutes has %q; %s %s routes to %q", pattern, method, probe, got)
}
}
}
func TestGuestMayOnlyLookAround(t *testing.T) {
s := gateServer(t)
for _, tc := range []struct {
method, path string
want bool
why string
}{
// what a guest is for
{"GET", "/api/me", true, "who am I"},
{"POST", "/api/logout", true, "signing out"},
{"GET", "/api/procs", true, "the Processes page"},
{"GET", "/api/docker", true, "the container list"},
{"GET", "/api/docker/stacks", true, "the stack list"},
{"GET", "/api/zfs", true, "the ZFS page"},
{"GET", "/api/storage/smart/sda", true, "drive health"},
{"GET", "/api/space/sda/tree", true, "the Usage page"},
{"GET", "/api/sensors/stream", true, "live graphs"},
{"POST", "/api/sensors/query", true, "a query too long for a URL is still a read"},
{"GET", "/api/nodes", true, "the machine switcher"},
{"PUT", "/api/prefs/ui", true, "the look is the login's own"},
// the shell, and every road to it
{"GET", "/api/terminals", false, "terminals"},
{"POST", "/api/terminals", false, "opening a shell"},
{"GET", "/api/terminals/abc/attach", false, "attaching is a GET, and it is a root shell"},
{"POST", "/api/docker/containers/gitea/exec", false, "a shell in a container"},
// reads that give away too much
{"GET", "/api/files", false, "the file tree"},
{"GET", "/api/files/read", false, "reading any file, including this config and his keys"},
{"GET", "/api/docker/containers/gitea/inspect", false, "a container's environment is where secrets live"},
{"GET", "/api/docker/containers/gitea/logs", false, "logs leak"},
{"GET", "/api/docker/stacks/gitea", false, "a stack's compose file and .env"},
{"GET", "/api/alerts", false, "a channel URL is a key to his phone"},
{"GET", "/api/cron", false, "cron jobs are commands"},
{"GET", "/api/updates", false, "what is unpatched here"},
{"GET", "/api/tokens", false, "tokens"},
{"GET", "/api/users", false, "the account list"},
{"GET", "/api/sessions", false, "sessions"},
{"GET", "/api/guests", false, "a guest cannot see or make guests"},
{"GET", "/kvm/1", false, "a KVM is a keyboard on a machine"},
{"GET", "/api/debug/pprof/heap", false, "the profiler"},
// anything that changes anything
{"POST", "/api/power/poweroff", false, "turning the box off"},
{"POST", "/api/docker/stacks/gitea/deploy", false, "deploying"},
{"POST", "/api/docker/containers/gitea/restart", false, "restarting a container"},
{"POST", "/api/services/ssh.service/restart", false, "restarting a service"},
{"PUT", "/api/files/write", false, "writing a file"},
{"POST", "/api/procs/1/signal", false, "killing a process"},
{"POST", "/api/users", false, "making an account"},
{"POST", "/api/guests", false, "making a guest login"},
{"DELETE", "/api/guests/guest-abcd", false, "revoking one"},
{"POST", "/api/password", false, "changing a password"},
{"POST", "/api/tokens/read", false, "issuing a token"},
{"POST", "/api/self-update", false, "replacing the binary"},
{"POST", "/api/updates/upgrade", false, "upgrading packages"},
{"PUT", "/api/prefs/history", false, "a machine-wide setting"},
{"PUT", "/api/prefs/sampler", false, "a machine-wide setting"},
{"POST", "/api/nodes", false, "adding a machine"},
{"DELETE", "/api/nodes/grid", false, "removing one"},
{"PUT", "/api/alerts", false, "alert rules"},
{"POST", "/api/space/sda/scan", false, "starting a scan"},
// a managed machine is judged by the same table: the hub proxies as itself, with a
// token that is full control of the node, so this is the only place it can be stopped
{"GET", "/api/nodes/grid/procs", true, "looking at grid"},
{"GET", "/api/nodes/grid/sensors/stream", true, "grid's graphs"},
{"GET", "/api/nodes/grid/docker/stacks", true, "grid's stacks"},
{"GET", "/api/nodes/grid/files/read", false, "reading a file on grid"},
{"GET", "/api/nodes/grid/terminals", false, "a shell on grid"},
{"POST", "/api/nodes/grid/docker/stacks/immich/deploy", false, "deploying on grid"},
{"POST", "/api/nodes/grid/power/reboot", false, "rebooting grid"},
{"PUT", "/api/nodes/grid/files/write", false, "writing a file on grid"},
// and nothing unrouted slips through the catch-alls
{"GET", "/api/nothing-like-this", false, "an unknown API path"},
{"GET", "/api/nodes/grid/nothing-like-this", false, "an unknown path on a machine"},
{"GET", "/anything", false, "a path off the API"},
} {
if got := may(t, s, tc.method, tc.path); got != tc.want {
t.Errorf("%s %s: guest may = %v, want %v (%s)", tc.method, tc.path, got, tc.want, tc.why)
}
}
}
func TestGuestScope(t *testing.T) {
for path, want := range map[string]string{
"/api/procs": "/api/procs",
"/api/nodes": "/api/nodes",
"/api/nodes/grid": "", // the machine record itself, not a route on the machine
"/api/nodes/grid/procs": "/api/procs",
"/api/nodes/grid/docker/containers/x/logs": "/api/docker/containers/x/logs",
"/kvm/1": "/kvm/1",
} {
if got := guestScope(path); got != want {
t.Errorf("guestScope(%q) = %q, want %q", path, got, want)
}
}
}
// A guest whose time is up is stopped at the door, whether or not the minute-by-minute
// sweep has got to their account yet — and their session is dropped on the way, so the
// console they left open goes back to the login form.
func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) { func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
+5 -24
View File
@@ -405,27 +405,17 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
}) })
} }
// signedIn reports whether a request carries a live browser session of a real account, // signedIn reports whether a request carries a live browser session, without the
// without the side effects of the auth middleware. A guest login is not one: this is what // side effects of the auth middleware.
// lets a KVM's own assets through, and a KVM is a keyboard on a machine.
func (s *Server) signedIn(r *http.Request) bool { func (s *Server) signedIn(r *http.Request) bool {
c, err := r.Cookie(cookieName) c, err := r.Cookie(cookieName)
if err != nil { if err != nil {
return false return false
} }
ses, ok := s.sessions.Lookup(c.Value, clientKey(r)) _, ok := s.sessions.Lookup(c.Value, clientKey(r))
if !ok { return ok
return false
}
var u config.User
var found bool
s.cfg.View(func(cf *config.Config) { u, found = cf.User(ses.User) })
return found && !u.Guest
} }
// guestRefusal is what a guest login is told, wherever it reaches for something.
const guestRefusal = "a guest login can only look around"
// cookieActor resolves the session cookie to who is calling. answered means the caller has // cookieActor resolves the session cookie to who is calling. answered means the caller has
// already been sent an error and the handler must not run: the account behind a live session // already been sent an error and the handler must not run: the account behind a live session
// has gone away, or a guest login's time is up — so a console left open on a visitor's phone // has gone away, or a guest login's time is up — so a console left open on a visitor's phone
@@ -455,8 +445,7 @@ func (s *Server) cookieActor(w http.ResponseWriter, r *http.Request) (a actor, o
return actor{User: ses.User, Session: ses.ID, token: c.Value, Guest: u.Guest, Expires: u.Expires}, true, false return actor{User: ses.User, Session: ses.ID, token: c.Value, Guest: u.Guest, Expires: u.Expires}, true, false
} }
// auth accepts the browser session cookie or a bearer API token issued to a hub. A guest // auth accepts the browser session cookie or a bearer API token issued to a hub.
// login only reaches the handful of routes guestRoutes names (internal/api/guests.go).
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
a, ok, answered := s.cookieActor(w, r) a, ok, answered := s.cookieActor(w, r)
@@ -464,10 +453,6 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return return
} }
if ok { if ok {
if a.Guest && !s.guestMay(r) {
jsonErr(w, 403, guestRefusal)
return
}
next(w, withActor(r, a)) next(w, withActor(r, a))
return return
} }
@@ -506,10 +491,6 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
if a, live, answered := s.cookieActor(w, r); answered { if a, live, answered := s.cookieActor(w, r); answered {
return return
} else if live { } else if live {
if a.Guest && !s.guestMay(r) {
jsonErr(w, 403, guestRefusal)
return
}
ok, trusted = true, true ok, trusted = true, true
r = withActor(r, a) r = withActor(r, a)
} }
+2 -5
View File
@@ -7,16 +7,13 @@ import { applyTheme, currentThemeId } from './themes'
import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs' import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs'
import { setAccents } from './accents' import { setAccents } from './accents'
import { setRailOrder } from './railorder' import { setRailOrder } from './railorder'
import { setGuest } from './guest'
export default function App() { export default function App() {
const [me, setMe] = useState<Me | null | undefined>(undefined) const [me, setMe] = useState<Me | null | undefined>(undefined)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
const m = await api.meHere() const m = await api.meHere(); setMe(m)
setGuest(!!m.guest, m.guest_until) // before anything renders: pages ask this, not a prop
setMe(m)
api.uiPrefs().then((p) => { api.uiPrefs().then((p) => {
if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme) if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme)
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs) if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
@@ -32,7 +29,7 @@ export default function App() {
useEffect(() => { refresh() }, [refresh]) useEffect(() => { refresh() }, [refresh])
// Any page that gets a 401 from the hub sends us straight back to the login form. // Any page that gets a 401 from the hub sends us straight back to the login form.
useEffect(() => { useEffect(() => {
const h = () => { setGuest(false); setMe((m) => (m ? null : m)) } const h = () => setMe((m) => (m ? null : m))
window.addEventListener('hamlab:unauthenticated', h) window.addEventListener('hamlab:unauthenticated', h)
return () => window.removeEventListener('hamlab:unauthenticated', h) return () => window.removeEventListener('hamlab:unauthenticated', h)
}, []) }, [])
+13 -17
View File
@@ -7,7 +7,6 @@ import TaskOutput from './TaskOutput'
import StackNew, { DupFrom } from './StackNew' import StackNew, { DupFrom } from './StackNew'
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors' import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
import { isGuest } from './guest'
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs' import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
import Sparkline from './Sparkline' import Sparkline from './Sparkline'
import { fmtBytes } from './fmt' import { fmtBytes } from './fmt'
@@ -400,9 +399,6 @@ export default function Docker() {
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null) const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('') const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('')
const rate = useRefreshMs() const rate = useRefreshMs()
// A guest login may read the lists and nothing else: no detail pane (a container's
// environment and logs are where the secrets are), no actions, no image checks.
const guest = isGuest()
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, []) const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, [])
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate]) useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) }) const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
@@ -418,7 +414,7 @@ export default function Docker() {
// What the registry last said about each image, so a container can be marked without // What the registry last said about each image, so a container can be marked without
// asking again. Only ever read from the cache here — checking is a deliberate act. // asking again. Only ever read from the cache here — checking is a deliberate act.
const [upd, setUpd] = useState<MachineUpdates | null>(null) const [upd, setUpd] = useState<MachineUpdates | null>(null)
useEffect(() => { if (!guest) api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [guest]) useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd]) const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div> if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
@@ -433,22 +429,22 @@ export default function Docker() {
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}> <div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
<div className="svc-list"> <div className="svc-list">
<div className="svc-tools"> <div className="svc-tools">
<div className="chips">{(guest ? ['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[] : ['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div> <div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
{!guest && (shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>} {(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
{!guest && shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}> {shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" /> <input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<button className="btn on" disabled={!pull.trim()}>Pull</button> <button className="btn on" disabled={!pull.trim()}>Pull</button>
{(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</Btn> })()} {(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</Btn> })()}
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn> <Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
</form>} </form>}
{!guest && shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()} {shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
{!guest && shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()} {shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />} {shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />} {shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
</div> </div>
<div className="svc-rows"> <div className="svc-rows">
{shownTab === 'stacks' && (stacks ?? []).map((st) => ( {shownTab === 'stacks' && (stacks ?? []).map((st) => (
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => { if (guest) return; st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info') }}> <div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /> <span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div> <div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div> <div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
@@ -459,7 +455,7 @@ export default function Docker() {
<div key={stack}> <div key={stack}>
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3> <h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
{list.map((c) => ( {list.map((c) => (
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => { if (!guest) setSel(c.name) }}> <div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
<span className={'sdot ' + dot(c)} /> <span className={'sdot ' + dot(c)} />
<div className="svc-name"><div className="n">{c.name}<UpdateDot state={imgState[c.image]} /></div><div className="d">{c.image}</div></div> <div className="svc-name"><div className="n">{c.name}<UpdateDot state={imgState[c.image]} /></div><div className="d">{c.image}</div></div>
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div> <div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
@@ -476,26 +472,26 @@ export default function Docker() {
else { const typed = await askText(`Delete the volume "${name}" and everything in it?\n\nNothing uses it right now — but if it belongs to a stack that is only down, this is that stack's data. It can't be brought back.\n\nType its name to confirm.`, '', 'Delete'); if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } } else { const typed = await askText(`Delete the volume "${name}" and everything in it?\n\nNothing uses it right now — but if it belongs to a stack that is only down, this is that stack's data. It can't be brought back.\n\nType its name to confirm.`, '', 'Delete'); if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } }
try { await api.removeVolume(name); toast('volume deleted'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } try { await api.removeVolume(name); toast('volume deleted'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') }
} }
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : guest ? null : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })} return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table> {d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
)} )}
{shownTab === 'networks' && ( {shownTab === 'networks' && (
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead> <table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined <tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own || guest ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })} return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table> {d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
)} )}
{shownTab === 'images' && ( {shownTab === 'images' && (
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead> <table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : guest ? null : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr>)}</tbody></table> <tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr>)}</tbody></table>
)} )}
</div> </div>
</div> </div>
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>} {shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
{shownTab === 'containers' && (cur && !guest ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack && !guest ? null : <div className="svc-empty muted">{guest ? 'A guest login sees the lists only.' : 'Select a container.'}</div>))} {shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />} {making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />} {shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
{shownTab === 'stacks' && (guest ? <div className="svc-empty muted">A guest login sees the lists only.</div> : selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)} {shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
</div> </div>
) )
} }
+9 -15
View File
@@ -26,7 +26,7 @@ import { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power' import Power from './Power'
import { applyAccent } from './accents' import { applyAccent } from './accents'
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui' import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
import { fmtLeft, useCountdown } from './guest' import { fmtLeft, useTimeLeft } from './guest'
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
const FolderIcon = ( const FolderIcon = (
@@ -76,10 +76,6 @@ const GROUPS: Group[] = [
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
// What a guest login is shown: the pages whose reads the server allows it (guestRoutes in
// internal/api/guests.go). Every other page would be a wall of 403s, and the two that are
// missing on purpose — Terminal and Files — are the ones a look-around login must not have.
const GUEST_VIEWS: View[] = ['procs', 'docker', 'disks', 'usage', 'zfs', 'hardware', 'widgets']
// 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
// forward work, reload restores the tab, and links can point at a page on a machine. // forward work, reload restores the tab, and links can point at a page on a machine.
@@ -109,10 +105,11 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
const [machines, setMachines] = useState<MachineStatus[]>([]) const [machines, setMachines] = useState<MachineStatus[]>([])
const [remoteMe, setRemoteMe] = useState<Me | null>(null) const [remoteMe, setRemoteMe] = useState<Me | null>(null)
const [remoteErr, setRemoteErr] = useState('') const [remoteErr, setRemoteErr] = useState('')
// A guest login is an account with a clock on it: nothing is hidden from it, but the header
// says how long it has, for whoever is holding the phone.
const leftMs = useTimeLeft(hubMe.guest_until)
// A machine is titled by its own hostname; the name it was added under stays its identity // A machine is titled by its own hostname; the name it was added under stays its identity
// (the URL, its accent, what the hub's API calls it). See titleOf. // (the URL, its accent, what the hub's API calls it). See titleOf.
const guest = !!hubMe.guest
const leftMs = useCountdown(guest)
const title = titleOf(machine, machines, hubMe.host) const title = titleOf(machine, machines, hubMe.host)
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
const listRate = useRefreshMs(LIST_FLOOR) const listRate = useRefreshMs(LIST_FLOOR)
@@ -125,13 +122,12 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
const handles = useRef<Record<string, TermHandle>>({}) const handles = useRef<Record<string, TermHandle>>({})
const reload = useCallback(async () => { const reload = useCallback(async () => {
if (guest) { setTerms([]); return } // a guest has no shell, here or anywhere
try { try {
const list = await api.terminals() const list = await api.terminals()
setTerms(list) setTerms(list)
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null)) setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') } } catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
}, [machine, guest]) }, [machine])
useEffect(() => { reload() }, [reload]) useEffect(() => { reload() }, [reload])
@@ -164,18 +160,16 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
// keep the hash in step, and follow it when the browser navigates // keep the hash in step, and follow it when the browser navigates
const [firing, setFiring] = useState(0) const [firing, setFiring] = useState(0)
useEffect(() => { useEffect(() => {
if (guest) return
const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) } const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) }
tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick) tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick)
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) } return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
}, [guest]) }, [])
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up. // The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
// Hub-only pages make no sense on a remote machine. Judged once both have settled, not while // Hub-only pages make no sense on a remote machine. Judged once both have settled, not while
// the machine is changing: the URL sets the machine and the page in one go, and deciding from // the machine is changing: the URL sets the machine and the page in one go, and deciding from
// inside the machine change read the page we were leaving — so a link from Settings to a // inside the machine change read the page we were leaving — so a link from Settings to a
// machine's Disks arrived at its Dashboard. // machine's Disks arrived at its Dashboard.
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view]) useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
useEffect(() => { if (guest && !GUEST_VIEWS.includes(view)) setView('procs') }, [guest, view])
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host]) useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
// The tab's title says where you are, so history entries and open tabs can be told apart. // The tab's title says where you are, so history entries and open tabs can be told apart.
@@ -283,14 +277,14 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span className="spacer" /> <span className="spacer" />
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span> <span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} /> <span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
{!guest && <Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />} <Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
{guest && <span className="badge guest" title={`this login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>} {hubMe.guest && <span className="badge guest" title={`this guest login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>} {hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
<Btn className="link" onClick={logout}>logout</Btn> <Btn className="link" onClick={logout}>logout</Btn>
</header> </header>
<div className="body"> <div className="body">
<Rail groups={GROUPS.filter((g) => (guest ? GUEST_VIEWS.includes(g.id as View) : !machine || !HUB_ONLY.includes(g.id as View)))} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }} <Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />} header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} /> onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
<div className="backdrop" onClick={() => setDrawer(false)} /> <div className="backdrop" onClick={() => setDrawer(false)} />
+7 -7
View File
@@ -20,9 +20,9 @@ export default function Users({ me }: { me: Me }) {
const GUEST_MINUTES = [15, 30, 60, 120, 480] as const const GUEST_MINUTES = [15, 30, 60, 120, 480] as const
// A login to hand somebody so they can look at the console: it expires on its own, it can be // A login to hand somebody you are showing the console to: an ordinary account with a clock
// ended here at any moment, and it can only read. Making one is the only way to see its // on it, which expires on its own and can be ended here at any moment. Making one is the only
// password, so it is shown until dismissed rather than in a toast. // time its password is readable, so it is shown until dismissed rather than in a toast.
function Guests() { function Guests() {
const [list, setList] = useState<Guest[] | null>(null) const [list, setList] = useState<Guest[] | null>(null)
const [minutes, setMinutes] = useState<number>(30) const [minutes, setMinutes] = useState<number>(30)
@@ -39,16 +39,16 @@ function Guests() {
finally { setBusy(false) } finally { setBusy(false) }
} }
const revoke = async (g: Guest) => { const revoke = async (g: Guest) => {
if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once.`, 'Revoke')) return if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once, everywhere.`, 'Revoke')) return
try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() } try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
} }
return ( return (
<> <>
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can only look</span></div> <div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can be revoked</span></div>
<section className="card"> <section className="card">
{list === null && <Loading label="Loading…" />} {list === null && <Loading label="Loading…" />}
{list?.length === 0 && <div className="empty">None. One lets somebody look around without an account.</div>} {list?.length === 0 && <div className="empty">None. One lets somebody in for a while without giving them an account.</div>}
{list?.map((g) => { {list?.map((g) => {
const left = new Date(g.expires).getTime() - Date.now() const left = new Date(g.expires).getTime() - Date.now()
return ( return (
@@ -67,7 +67,7 @@ function Guests() {
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)} {GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
</select> </select>
</label> </label>
<div className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A guest sees the dashboard, containers, disks and hardware, read-only. No terminal, no files, no settings, nothing it can change. It stops working when the time is up, or when you revoke it.</span></div> <div className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A guest can do everything you can, on every machine — including the terminal — until the time is up or you revoke it. Give one out accordingly.</span></div>
</div> </div>
</> </>
) )
+1 -9
View File
@@ -1,12 +1,5 @@
import { isGuest } from './guest'
export class Unauthenticated extends Error { constructor(msg = 'unauthenticated') { super(msg) } } export class Unauthenticated extends Error { constructor(msg = 'unauthenticated') { super(msg) } }
// What a guest login may send. Everything else that would change something is refused here
// as well as by the server, so a button a page forgot to hide fails without a round trip.
const GUEST_WRITES = new Set(['POST /api/logout', 'PUT /api/prefs/ui'])
export const guestRefusal = 'a guest login can only look around'
// Which machine the console is looking at. '' is this host; otherwise every /api/… call // Which machine the console is looking at. '' is this host; otherwise every /api/… call
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login, // is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
// the machine list itself) pass local=true and are never rewritten. // the machine list itself) pass local=true and are never rewritten.
@@ -26,7 +19,6 @@ function track(delta: number) {
} }
async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> { async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
if (isGuest() && method !== 'GET' && !GUEST_WRITES.has(`${method} ${path}`)) throw new Error(guestRefusal)
const target = local ? path : apiPath(path) const target = local ? path : apiPath(path)
const mach = machineOf(target) const mach = machineOf(target)
if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`) if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`)
@@ -166,7 +158,7 @@ export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record<
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean } export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean } export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string } export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
// A guest login: temporary, look-only, gone at `expires` or when revoked. // A guest login: a full account with a clock on it, gone at `expires` or when revoked.
export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean } export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean }
export interface FreshGuest { name: string; password: string; expires: string; minutes: number } export interface FreshGuest { name: string; password: string; expires: string; minutes: number }
+7 -25
View File
@@ -1,27 +1,7 @@
// Whether this console is being looked at through a guest login, kept here so any page can // A guest login's clock. That is the whole difference between a guest and an account: it
// ask without it being threaded through every prop. Set from /api/me when the app loads. // can do everything, until it can't, so what the console has to show is how long is left.
//
// This is politeness, not security: a guest is stopped by the server, which answers 403 to
// everything but the handful of reads it allows (internal/api/guests.go). What it buys is a
// console that doesn't offer a visitor buttons that would only fail.
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
let guest = false
let until = ''
export function setGuest(on: boolean, expires?: string) {
guest = on
until = expires ?? ''
}
export const isGuest = () => guest
// msLeft is how long this guest login has, or null when it isn't one.
export function msLeft(): number | null {
if (!guest || !until) return null
return Math.max(0, new Date(until).getTime() - Date.now())
}
// fmtLeft says how long is left, in the roundest terms that are still honest. // fmtLeft says how long is left, in the roundest terms that are still honest.
export function fmtLeft(ms: number): string { export function fmtLeft(ms: number): string {
const min = Math.floor(ms / 60000) const min = Math.floor(ms / 60000)
@@ -30,13 +10,15 @@ export function fmtLeft(ms: number): string {
return 'under a minute' return 'under a minute'
} }
// useCountdown re-renders the caller every 15 seconds, for a clock that has to keep up. // useTimeLeft is the milliseconds until `until`, re-rendering the caller every 15 seconds so
export function useCountdown(active: boolean): number { // the number keeps up. 0 when there is no expiry, which is every normal account.
export function useTimeLeft(until?: string): number {
const [, tick] = useState(0) const [, tick] = useState(0)
const active = !!until
useEffect(() => { useEffect(() => {
if (!active) return if (!active) return
const t = setInterval(() => tick((n) => n + 1), 15000) const t = setInterval(() => tick((n) => n + 1), 15000)
return () => clearInterval(t) return () => clearInterval(t)
}, [active]) }, [active])
return msLeft() ?? 0 return until ? Math.max(0, new Date(until).getTime() - Date.now()) : 0
} }