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. |
| 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. |
| 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. |
## 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.
**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
pair to paste into a message. A guest sees the dashboard, the containers, the disks, the
hardware and the machines this hub manages, and can change none of it — no terminal, no
files, no logs or container environments, no settings, no power. It can be revoked in one
tap, and it disappears on its own when the time is up, wherever it is signed in. What a
guest may reach is a list of routes in `internal/api/guests.go`, so a route added later
is shut to guests until somebody says otherwise.
throwaway user name and password, good for half an hour by default (up to 24 hours), and
hands you the pair to paste into a message. A guest is an ordinary account with a clock on
it — the whole console, every machine, the terminal included — that stops working when the
time is up, wherever it is signed in, or the moment you revoke it. There is no reduced
guest: give one to somebody you would hand the password to, and revoke it if you change
your mind.
What adapts to the host, rather than assuming Debian:
+10 -97
View File
@@ -1,21 +1,22 @@
package api
// Guest logins: a temporary account for showing somebody the console. It expires by itself
// (half an hour by default), it can be revoked in one tap, and it may only look — it cannot
// change a thing, on this machine or on any machine this hub manages.
// Guest logins: a temporary account for handing to somebody you want to show around. It is
// an ordinary account with a clock on it — every tab, every action, on this machine and on
// every machine this hub manages — that stops working at its expiry (half an hour by
// default) or the moment it is revoked.
//
// The gate is a list of what a guest may reach, and nothing else, so a route added later is
// shut to guests until somebody decides otherwise. It is applied to the *scoped* path, which
// is what makes it hold for managed machines too: the hub proxies to a node as itself, with a
// token that is full control of that node, so the node cannot tell a guest from its owner —
// the decision has to be made here, before the request leaves.
// Deliberately not a reduced account: Mitchell, 2026-09-27, replacing a read-only version
// built the same day. He gives these to people he trusts and revokes them otherwise, and a
// console with half its buttons missing is not the console he wanted to show off. What that
// means in practice: a guest has the Terminal tab, which is a shell as the user the daemon
// runs as — root on every host here — and can make an account or a token of its own that
// outlives the guest login.
import (
"encoding/json"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
@@ -31,86 +32,6 @@ const (
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 ----
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.
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
me := actorOf(r)
if me.Guest {
jsonErr(w, 403, "a guest login can only look around")
return
}
var body struct {
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
// whole point of the thing is that ending it is one tap.
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")
found := false
if err := s.cfg.Update(func(c *config.Config) {
+4 -139
View File
@@ -3,9 +3,7 @@ package api
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
@@ -14,143 +12,10 @@ import (
"hamlab/internal/store"
)
// The router built without any of the machinery behind it: registering a route never calls
// its handler, so this is the real table, and what guestMay is asked about.
func gateServer(t *testing.T) *Server {
t.Helper()
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.
// A guest login is an ordinary account with a clock on it, so the clock is the thing worth
// testing: one whose time is up is stopped at the door, whether or not the minute-by-minute
// sweep has reached their account yet, and their session is dropped on the way — so the
// console they left open goes back to the login form rather than carrying on.
func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) {
for _, tc := range []struct {
name string
+5 -24
View File
@@ -405,26 +405,16 @@ 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,
// without the side effects of the auth middleware. A guest login is not one: this is what
// lets a KVM's own assets through, and a KVM is a keyboard on a machine.
// signedIn reports whether a request carries a live browser session, without the
// side effects of the auth middleware.
func (s *Server) signedIn(r *http.Request) bool {
c, err := r.Cookie(cookieName)
if err != nil {
return false
}
ses, ok := s.sessions.Lookup(c.Value, clientKey(r))
if !ok {
return false
_, ok := s.sessions.Lookup(c.Value, clientKey(r))
return ok
}
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
// already been sent an error and the handler must not run: the account behind a live session
@@ -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
}
// auth accepts the browser session cookie or a bearer API token issued to a hub. A guest
// login only reaches the handful of routes guestRoutes names (internal/api/guests.go).
// auth accepts the browser session cookie or a bearer API token issued to a hub.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
a, ok, answered := s.cookieActor(w, r)
@@ -464,10 +453,6 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return
}
if ok {
if a.Guest && !s.guestMay(r) {
jsonErr(w, 403, guestRefusal)
return
}
next(w, withActor(r, a))
return
}
@@ -506,10 +491,6 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
if a, live, answered := s.cookieActor(w, r); answered {
return
} else if live {
if a.Guest && !s.guestMay(r) {
jsonErr(w, 403, guestRefusal)
return
}
ok, trusted = true, true
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 { setAccents } from './accents'
import { setRailOrder } from './railorder'
import { setGuest } from './guest'
export default function App() {
const [me, setMe] = useState<Me | null | undefined>(undefined)
const refresh = useCallback(async () => {
try {
const m = await api.meHere()
setGuest(!!m.guest, m.guest_until) // before anything renders: pages ask this, not a prop
setMe(m)
const m = await api.meHere(); setMe(m)
api.uiPrefs().then((p) => {
if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme)
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
@@ -32,7 +29,7 @@ export default function App() {
useEffect(() => { refresh() }, [refresh])
// Any page that gets a 401 from the hub sends us straight back to the login form.
useEffect(() => {
const h = () => { setGuest(false); setMe((m) => (m ? null : m)) }
const h = () => setMe((m) => (m ? null : m))
window.addEventListener('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 { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel'
import { isGuest } from './guest'
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
import Sparkline from './Sparkline'
import { fmtBytes } from './fmt'
@@ -400,9 +399,6 @@ export default function Docker() {
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('')
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([])) }, [])
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) })
@@ -418,7 +414,7 @@ export default function Docker() {
// What the registry last said about each image, so a container can be marked without
// asking again. Only ever read from the cache here — checking is a deliberate act.
const [upd, setUpd] = useState<MachineUpdates | null>(null)
useEffect(() => { 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 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>
@@ -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-list">
<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>
{!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>}
{!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') } }}>
<div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<button className="btn on" disabled={!pull.trim()}>Pull</button>
{(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</Btn> })()}
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
</form>}
{!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> })()}
{!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 === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
{shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
</div>
<div className="svc-rows">
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => { 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')} />
<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>
@@ -459,7 +455,7 @@ export default function Docker() {
<div key={stack}>
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
{list.map((c) => (
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => { 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)} />
<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>
@@ -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 } }
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>
)}
{shownTab === 'networks' && (
<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
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>
)}
{shownTab === 'images' && (
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : 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>
{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) }} />}
{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>
)
}
+9 -15
View File
@@ -26,7 +26,7 @@ import { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power'
import { applyAccent } from './accents'
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).
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'
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']
// 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
// 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 [remoteMe, setRemoteMe] = useState<Me | null>(null)
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
// (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 me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
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 reload = useCallback(async () => {
if (guest) { setTerms([]); return } // a guest has no shell, here or anywhere
try {
const list = await api.terminals()
setTerms(list)
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
}, [machine, guest])
}, [machine])
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
const [firing, setFiring] = useState(0)
useEffect(() => {
if (guest) return
const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) }
tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick)
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
}, [guest])
}, [])
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
// Hub-only pages make no sense on a remote machine. Judged once both have settled, not while
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
// inside the machine change read the page we were leaving — so a link from Settings to a
// machine's Disks arrived at its Dashboard.
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
useEffect(() => { 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])
// 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="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'} />
{!guest && <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>}
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
{hubMe.guest && <span className="badge guest" title={`this guest login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
<Btn className="link" onClick={logout}>logout</Btn>
</header>
<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) }} />}
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); 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
// A login to hand somebody so they can look at the console: it expires on its own, it can be
// ended here at any moment, and it can only read. Making one is the only way to see its
// password, so it is shown until dismissed rather than in a toast.
// A login to hand somebody you are showing the console to: an ordinary account with a clock
// on it, which expires on its own and can be ended here at any moment. Making one is the only
// time its password is readable, so it is shown until dismissed rather than in a toast.
function Guests() {
const [list, setList] = useState<Guest[] | null>(null)
const [minutes, setMinutes] = useState<number>(30)
@@ -39,16 +39,16 @@ function Guests() {
finally { setBusy(false) }
}
const revoke = async (g: Guest) => {
if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once.`, '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() }
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
return (
<>
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can 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">
{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) => {
const left = new Date(g.expires).getTime() - Date.now()
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>)}
</select>
</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>
</>
)
+1 -9
View File
@@ -1,12 +1,5 @@
import { isGuest } from './guest'
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
// 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.
@@ -26,7 +19,6 @@ function track(delta: number) {
}
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 mach = machineOf(target)
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 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 }
// 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 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
// ask without it being threaded through every prop. Set from /api/me when the app loads.
//
// 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.
// A guest login's clock. That is the whole difference between a guest and an account: it
// can do everything, until it can't, so what the console has to show is how long is left.
import { useEffect, useState } from 'react'
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.
export function fmtLeft(ms: number): string {
const min = Math.floor(ms / 60000)
@@ -30,13 +10,15 @@ export function fmtLeft(ms: number): string {
return 'under a minute'
}
// useCountdown re-renders the caller every 15 seconds, for a clock that has to keep up.
export function useCountdown(active: boolean): number {
// useTimeLeft is the milliseconds until `until`, re-rendering the caller every 15 seconds so
// the number keeps up. 0 when there is no expiry, which is every normal account.
export function useTimeLeft(until?: string): number {
const [, tick] = useState(0)
const active = !!until
useEffect(() => {
if (!active) return
const t = setInterval(() => tick((n) => n + 1), 15000)
return () => clearInterval(t)
}, [active])
return msLeft() ?? 0
return until ? Math.max(0, new Date(until).getTime() - Date.now()) : 0
}