Bug bash fixes: hash routing + back button, hub-only pages reset on machine switch, exited terminals reap after 10 min, self-update refuses while busy unless forced, touch-sized controls, power confirm by machine name, down interfaces collapsed and empty modules hidden, load label, offline backoff, cron log newest first, procs empty message, timer timestamp parsing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 17:46:16 -07:00
co-authored by Claude Fable 5.1
parent 27f9665606
commit f093b322ee
14 changed files with 126 additions and 36 deletions
+4
View File
@@ -36,5 +36,9 @@ deploy: build cross
push-nodes: push-nodes:
$(PREFIX)/hamlab push-nodes $(PREFIX)/hamlab push-nodes
# same, interrupting running tasks and attached terminals on the nodes
push-nodes-force:
$(PREFIX)/hamlab push-nodes --force
clean: clean:
rm -rf bin internal/web/dist web/dist rm -rf bin internal/web/dist web/dist
+3 -2
View File
@@ -46,7 +46,7 @@ func main() {
} }
cfgPath := flag.String("config", defaultCfg, "path to config.json") cfgPath := flag.String("config", defaultCfg, "path to config.json")
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|push-nodes|fs …]\n") fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|push-nodes [--force]|fs …]\n")
flag.PrintDefaults() flag.PrintDefaults()
} }
flag.Parse() flag.Parse()
@@ -73,7 +73,8 @@ func main() {
exe, _ := os.Executable() exe, _ := os.Executable()
host, _ := os.Hostname() host, _ := os.Hostname()
nm := nodes.New(cfg, host) nm := nodes.New(cfg, host)
results := nm.PushAll(context.Background(), exe, runtime.GOARCH) force := flag.NArg() > 1 && flag.Arg(1) == "--force"
results := nm.PushAll(context.Background(), exe, runtime.GOARCH, force)
if len(results) == 0 { if len(results) == 0 {
fmt.Println("no managed machines in", cfg.Path()) fmt.Println("no managed machines in", cfg.Path())
} }
+22 -3
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"io/fs" "io/fs"
"log" "log"
@@ -328,6 +329,20 @@ func (s *Server) removeNode(w http.ResponseWriter, r *http.Request) {
// selfUpdate receives a new binary from a hub, checks it runs, installs it over the // selfUpdate receives a new binary from a hub, checks it runs, installs it over the
// current executable (keeping a .prev copy) and restarts the service. // current executable (keeping a .prev copy) and restarts the service.
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) { func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("force") != "1" {
if t, ok := s.tasks.RunningOf("updates"); ok {
jsonErr(w, 409, "busy: a package task is running ("+t.Title+"); retry with force to interrupt it")
return
}
attached := 0
for _, t := range s.mgr.List() {
attached += t.Clients
}
if attached > 0 {
jsonErr(w, 409, fmt.Sprintf("busy: %d terminal client(s) attached; retry with force to drop them", attached))
return
}
}
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
@@ -396,10 +411,14 @@ func copyFile(src, dst string) error {
func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) { func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
exe, _ := os.Executable() exe, _ := os.Executable()
res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH) res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH, r.URL.Query().Get("force") == "1")
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", res.Name, res.OK, res.Version, res.Error, clientIP(r)) log.Printf("machines: update %s -> ok=%v %s %s (from %s)", res.Name, res.OK, res.Version, res.Error, clientIP(r))
if !res.OK { if !res.OK {
jsonErr(w, 400, res.Error) code := 400
if strings.HasPrefix(res.Error, "busy:") {
code = 409
}
jsonErr(w, code, res.Error)
return return
} }
jsonOK(w, 200, res) jsonOK(w, 200, res)
@@ -407,7 +426,7 @@ func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) { func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
exe, _ := os.Executable() exe, _ := os.Executable()
res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH) res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH, r.URL.Query().Get("force") == "1")
for _, x := range res { for _, x := range res {
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r)) log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r))
} }
+6 -5
View File
@@ -499,15 +499,16 @@ func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error)
// timers: one `systemctl show` over every loaded timer instead of three list commands. // timers: one `systemctl show` over every loaded timer instead of three list commands.
func (m *Manager) timers(ctx context.Context) []Timer { func (m *Manager) timers(ctx context.Context) []Timer {
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--timestamp=unix", "--no-pager", out, err := run(ctx, "", "systemctl", "show", "*.timer", "--no-pager",
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers") "-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
if err != nil { if err != nil {
return []Timer{} return []Timer{}
} }
unix := func(v string) int64 { // "@1789711405" (seconds) or "n/a" unix := func(v string) int64 { // "Sat 2026-09-19 05:47:13 PDT", "n/a" or ""
if strings.HasPrefix(v, "@") { for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05"} {
s, _ := strconv.ParseInt(strings.TrimPrefix(v, "@"), 10, 64) if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
return s * 1000 return t.UnixMilli()
}
} }
return 0 return 0
} }
+8 -4
View File
@@ -291,7 +291,7 @@ type PushResult struct {
// Push uploads binPath (normally the hub's own executable) to the node's self-update // Push uploads binPath (normally the hub's own executable) to the node's self-update
// endpoint. The node verifies it, installs it and restarts itself. Refused when the // endpoint. The node verifies it, installs it and restarts itself. Refused when the
// node's architecture differs from ours. // node's architecture differs from ours.
func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushResult { func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string, force bool) PushResult {
res := PushResult{Name: name} res := PushResult{Name: name}
n, ok := m.node(name) n, ok := m.node(name)
if !ok { if !ok {
@@ -328,7 +328,11 @@ func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushR
} }
defer f.Close() defer f.Close()
st, _ := f.Stat() st, _ := f.Stat()
up, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/self-update", f) target := n.URL + "/api/self-update"
if force {
target += "?force=1"
}
up, _ := http.NewRequestWithContext(ctx, "POST", target, f)
up.ContentLength = st.Size() up.ContentLength = st.Size()
up.Header.Set("Authorization", "Bearer "+n.Token) up.Header.Set("Authorization", "Bearer "+n.Token)
up.Header.Set("Content-Type", "application/octet-stream") up.Header.Set("Content-Type", "application/octet-stream")
@@ -356,7 +360,7 @@ func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushR
} }
// PushAll updates every node, in parallel. // PushAll updates every node, in parallel.
func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushResult { func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bool) []PushResult {
var list []config.Node var list []config.Node
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) }) m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
out := make([]PushResult, len(list)) out := make([]PushResult, len(list))
@@ -365,7 +369,7 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushRe
wg.Add(1) wg.Add(1)
go func(i int, name string) { go func(i int, name string) {
defer wg.Done() defer wg.Done()
out[i] = m.Push(ctx, name, binPath, ourArch) out[i] = m.Push(ctx, name, binPath, ourArch, force)
}(i, n.Name) }(i, n.Name)
} }
wg.Wait() wg.Wait()
+9
View File
@@ -130,6 +130,15 @@ func (m *Manager) pump(s *Session) {
s.mu.Unlock() s.mu.Unlock()
_ = s.ptmx.Close() _ = s.ptmx.Close()
m.changed() m.changed()
// An exited shell stays listed for a while so the exit is visible, then reaps itself.
time.AfterFunc(10*time.Minute, func() {
m.mu.Lock()
cur, ok := m.sessions[s.ID]
m.mu.Unlock()
if ok && cur == s && !s.running {
_ = m.Close(s.ID)
}
})
} }
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY. // broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
+1 -1
View File
@@ -122,7 +122,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
{tab === 'logs' && ( {tab === 'logs' && (
<div className="logs"><div className="log-lines"> <div className="logs"><div className="log-lines">
{logs === null ? <div className="muted"><Spinner /> loading…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div> {logs === null ? <div className="muted"><Spinner /> loading…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
: logs.map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)} : [...logs].reverse().map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
</div></div> </div></div>
)} )}
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : <span className="muted"><Spinner /> reading…</span>}</pre>} {tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : <span className="muted"><Spinner /> reading…</span>}</pre>}
+8 -5
View File
@@ -176,17 +176,18 @@ function body(id: string, { s, h, edit }: Ctx): ReactNode {
<div className="sub-head"> <div className="sub-head">
<span className={'dot ' + (x.up ? 'ok' : '')} /> <span className={'dot ' + (x.up ? 'ok' : '')} />
<span>{x.iface}</span> <span>{x.iface}</span>
{!x.up && <span className="muted small">down</span>}
<span className="spacer" /> <span className="spacer" />
<span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span> {x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
<HideBtn k={`net:${x.iface}`} edit={edit} /> <HideBtn k={`net:${x.iface}`} edit={edit} />
</div> </div>
{h?.net[x.iface] && ( {x.up && h?.net[x.iface] && (
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} /> <Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
)} )}
<div className="legend"> {x.up && <div className="legend">
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span> <span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span> <span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
</div> </div>}
</div> </div>
))} ))}
<HiddenChips prefix="net" edit={edit} label={(k) => k} /> <HiddenChips prefix="net" edit={edit} label={(k) => k} />
@@ -345,7 +346,9 @@ export default function Dashboard() {
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }), unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
} }
const ctx: Ctx = { s: latest, h: hist.current, edit } const ctx: Ctx = { s: latest, h: hist.current, edit }
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id)) // Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id)) const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards). // Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
+10 -2
View File
@@ -38,13 +38,21 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
} }
async function update(m: MachineStatus) { async function update(m: MachineStatus) {
if (!window.confirm(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return if (!window.confirm(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e) { toast(`${m.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') } try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) }
catch (e) {
const msg = e instanceof Error ? e.message : 'failed'
if (msg.startsWith('busy:') && window.confirm(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
try { const r = await api.updateMachine(m.name, true); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e2) { toast(`${m.name}: ${e2 instanceof Error ? e2.message : 'failed'}`, 'err') }
} else toast(`${m.name}: ${msg}`, 'err')
}
} }
async function updateAll() { async function updateAll() {
const n = list?.filter(outdated).length ?? 0 const n = list?.filter(outdated).length ?? 0
if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
try { try {
const res = await api.updateAllMachines() let res = await api.updateAllMachines()
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
if (busy.length && window.confirm(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err') for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
setTimeout(load, 4000) setTimeout(load, 4000)
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
+7 -6
View File
@@ -3,16 +3,17 @@ import { api } from './api'
import { Btn, Spinner } from './ui' import { Btn, Spinner } from './ui'
// Header power control: a small button, a dialog that needs the hostname typed back. // Header power control: a small button, a dialog that needs the hostname typed back.
export default function Power({ host, rebootNeeded }: { host: string; rebootNeeded?: boolean }) { // `label` is what the user types (the machine name on a remote); `host` is what the node checks.
export default function Power({ host, label, rebootNeeded }: { host: string; label: string; rebootNeeded?: boolean }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [typed, setTyped] = useState('') const [typed, setTyped] = useState('')
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('') const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const ok = typed.trim() === host const ok = typed.trim() === label
async function go(action: 'reboot' | 'poweroff') { async function go(action: 'reboot' | 'poweroff') {
setErr('') setErr('')
try { await api.power(action, typed.trim()); setSent(action) } try { await api.power(action, host); setSent(action) }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
} }
@@ -22,14 +23,14 @@ export default function Power({ host, rebootNeeded }: { host: string; rebootNeed
{open && ( {open && (
<div className="modal-back" onClick={() => !sent && setOpen(false)}> <div className="modal-back" onClick={() => !sent && setOpen(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Power · {host}</h3> <h3>Power · {label}</h3>
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>} {rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
{sent ? ( {sent ? (
<div className="muted"><Spinner /> {sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}</div> <div className="muted"><Spinner /> {sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}</div>
) : ( ) : (
<> <>
<label className="muted">Type the hostname to confirm <label className="muted">Type <b>{label}</b> to confirm
<input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={host} /> <input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={label} />
</label> </label>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
<div className="svc-actions"> <div className="svc-actions">
+1
View File
@@ -136,6 +136,7 @@ export default function Procs() {
</div> </div>
</div> </div>
<div className="ptable"> <div className="ptable">
{rows.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
{rows.map((p) => ( {rows.map((p) => (
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}> <div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
<span className="num muted">{p.pid}</span> <span className="num muted">{p.pid}</span>
+26 -5
View File
@@ -39,6 +39,16 @@ const GROUPS: Group[] = [
] ]
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
const HUB_ONLY: View[] = ['machines', 'settings']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
// forward work, reload restores the tab, and links can point at a page on a machine.
function parseHash(): { machine: string | null; view: View } | null {
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)$/)
if (!m || !VIEWS.includes(m[2] as View)) return null
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
}
function fmtUptime(s: number): string { function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
@@ -48,7 +58,7 @@ function fmtUptime(s: number): string {
export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) { export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
// Which machine we're looking at. null = the hub itself. Set before anything renders so // Which machine we're looking at. null = the hub itself. Set before anything renders so
// every page's first request already goes to the right place. // every page's first request already goes to the right place.
const [machine, setMachineState] = useState<string | null>(() => { const m = sessionStorage.getItem('hamlab.machine'); setMachine(m); return m }) const [machine, setMachineState] = useState<string | null>(() => { const m = parseHash()?.machine ?? sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
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('')
@@ -56,7 +66,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
const listRate = useRefreshMs(LIST_FLOOR) const listRate = useRefreshMs(LIST_FLOOR)
const [terms, setTerms] = useState<Term[]>([]) const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [view, setView] = useState<View>('dashboard') const [view, setView] = useState<View>(() => parseHash()?.view ?? 'dashboard')
const [drawer, setDrawer] = useState(false) const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true) const [online, setOnline] = useState(true)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@@ -81,8 +91,19 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine') setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true) handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
setMachineState(name) setMachineState(name)
if (view === 'machines') setView('dashboard') if (name && HUB_ONLY.includes(view)) setView('dashboard') // hub-only pages make no sense on a remote machine
} }
// keep the hash in step, and follow it when the browser navigates
useEffect(() => {
const want = `#/${machine ?? 'hub'}/${view}`
if (location.hash !== want) history.pushState(null, '', want)
}, [machine, view])
useEffect(() => {
const h = () => { const p = parseHash(); if (!p) return; if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view) }
window.addEventListener('popstate', h)
return () => window.removeEventListener('popstate', h)
}) // no deps: needs the latest switchMachine/view each time
useEffect(() => { useEffect(() => {
if (!machine) { setRemoteMe(null); return } if (!machine) { setRemoteMe(null); return }
let stop = false let stop = false
@@ -139,9 +160,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span className="host">{me.user}@{me.host}</span> <span className="host">{me.user}@{me.host}</span>
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>} {machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
<span className="spacer" /> <span className="spacer" />
<span className="stat" title="load average">{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 ? `${machine} unreachable` : online ? 'connected' : 'disconnected'} /> <span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${machine} unreachable` : online ? 'connected' : 'disconnected'} />
<Power host={me.host} rebootNeeded={me.reboot_needed} /> <Power host={me.host} label={machine ?? me.host} rebootNeeded={me.reboot_needed} />
<Btn className="link" onClick={logout}>logout</Btn> <Btn className="link" onClick={logout}>logout</Btn>
</header> </header>
+12 -3
View File
@@ -7,6 +7,11 @@ let base = ''
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' } export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p) export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
// A machine that just failed to answer is left alone for a few seconds instead of
// being hit again on every tick of every page.
const downUntil: Record<string, number> = {}
function machineOf(p: string): string | null { const m = p.match(/^\/api\/nodes\/([a-z0-9-]+)\//); return m ? m[1] : null }
let inflight = 0 let inflight = 0
function track(delta: number) { function track(delta: number) {
inflight += delta inflight += delta
@@ -14,10 +19,13 @@ 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> {
const target = local ? path : apiPath(path)
const mach = machineOf(target)
if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`)
track(1) track(1)
let r: Response let r: Response
try { try {
r = await fetch(local ? path : apiPath(path), { r = await fetch(target, {
method, method,
credentials: 'same-origin', credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
@@ -28,6 +36,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
throw new Error('network error') throw new Error('network error')
} }
track(-1) track(-1)
if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000
if (r.status === 401) { if (r.status === 401) {
let msg = 'unauthenticated' let msg = 'unauthenticated'
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ } try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
@@ -167,8 +176,8 @@ export const api = {
me: () => req<Me>('GET', '/api/me'), me: () => req<Me>('GET', '/api/me'),
machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true), machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true),
addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true), addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true),
updateMachine: (name: string) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update`, undefined, true), updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
updateAllMachines: () => req<PushResult[]>('POST', '/api/machines-update-all', undefined, true), updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true), changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true), tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true),
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
+9
View File
@@ -379,3 +379,12 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
[data-theme="light"] .modal-back { background: rgba(0,0,0,.35); } [data-theme="light"] .modal-back { background: rgba(0,0,0,.35); }
.disk-summary { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 2px 0 6px; } .disk-summary { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 2px 0 6px; }
/* touch: every control at least 36px tall (bug 22) */
@media (pointer: coarse) {
.btn, .chip, .icon-btn, .link, .th, .mod-tools button, .hide-item, .crumb, .files-tools select, .procs-tools select, .dash-bar select { min-height: 36px; padding-top: 6px; padding-bottom: 6px; }
.icon-btn { min-width: 36px; }
.leaf-actions .icon-btn { min-width: 34px; }
.header .link, .header .icon-btn { min-height: 36px; display: inline-flex; align-items: center; }
.mod-tools { margin-top: 0; }
}