From f093b322ee0ba78a0685bb9d27e7b7609e3e0b3a Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 18 Sep 2026 17:46:16 -0700 Subject: [PATCH] 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 --- Makefile | 4 ++++ cmd/hamlab/main.go | 5 +++-- internal/api/server.go | 25 ++++++++++++++++++++++--- internal/cron/cron.go | 11 ++++++----- internal/nodes/nodes.go | 12 ++++++++---- internal/term/manager.go | 9 +++++++++ web/src/Cron.tsx | 2 +- web/src/Dashboard.tsx | 13 ++++++++----- web/src/Machines.tsx | 12 ++++++++++-- web/src/Power.tsx | 13 +++++++------ web/src/Procs.tsx | 1 + web/src/Shell.tsx | 31 ++++++++++++++++++++++++++----- web/src/api.ts | 15 ++++++++++++--- web/src/styles.css | 9 +++++++++ 14 files changed, 126 insertions(+), 36 deletions(-) diff --git a/Makefile b/Makefile index c21cd1c..f7145bd 100644 --- a/Makefile +++ b/Makefile @@ -36,5 +36,9 @@ deploy: build cross push-nodes: $(PREFIX)/hamlab push-nodes +# same, interrupting running tasks and attached terminals on the nodes +push-nodes-force: + $(PREFIX)/hamlab push-nodes --force + clean: rm -rf bin internal/web/dist web/dist diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 23968aa..a3e4cd1 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -46,7 +46,7 @@ func main() { } cfgPath := flag.String("config", defaultCfg, "path to config.json") 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.Parse() @@ -73,7 +73,8 @@ func main() { exe, _ := os.Executable() host, _ := os.Hostname() 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 { fmt.Println("no managed machines in", cfg.Path()) } diff --git a/internal/api/server.go b/internal/api/server.go index 14437dc..5c0e2b2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "io/fs" "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 // current executable (keeping a .prev copy) and restarts the service. 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() if err != nil { 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) { 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)) if !res.OK { - jsonErr(w, 400, res.Error) + code := 400 + if strings.HasPrefix(res.Error, "busy:") { + code = 409 + } + jsonErr(w, code, res.Error) return } 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) { 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 { log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r)) } diff --git a/internal/cron/cron.go b/internal/cron/cron.go index b19d547..5bf9ec9 100644 --- a/internal/cron/cron.go +++ b/internal/cron/cron.go @@ -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. 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") if err != nil { return []Timer{} } - unix := func(v string) int64 { // "@1789711405" (seconds) or "n/a" - if strings.HasPrefix(v, "@") { - s, _ := strconv.ParseInt(strings.TrimPrefix(v, "@"), 10, 64) - return s * 1000 + unix := func(v string) int64 { // "Sat 2026-09-19 05:47:13 PDT", "n/a" or "" + for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05"} { + if t, err := time.ParseInLocation(layout, v, time.Local); err == nil { + return t.UnixMilli() + } } return 0 } diff --git a/internal/nodes/nodes.go b/internal/nodes/nodes.go index 9218bef..3f9d5d5 100644 --- a/internal/nodes/nodes.go +++ b/internal/nodes/nodes.go @@ -291,7 +291,7 @@ type PushResult struct { // Push uploads binPath (normally the hub's own executable) to the node's self-update // endpoint. The node verifies it, installs it and restarts itself. Refused when the // node's architecture differs from ours. -func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushResult { +func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string, force bool) PushResult { res := PushResult{Name: name} n, ok := m.node(name) if !ok { @@ -328,7 +328,11 @@ func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushR } defer f.Close() 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.Header.Set("Authorization", "Bearer "+n.Token) 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. -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 m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) }) out := make([]PushResult, len(list)) @@ -365,7 +369,7 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushRe wg.Add(1) go func(i int, name string) { defer wg.Done() - out[i] = m.Push(ctx, name, binPath, ourArch) + out[i] = m.Push(ctx, name, binPath, ourArch, force) }(i, n.Name) } wg.Wait() diff --git a/internal/term/manager.go b/internal/term/manager.go index bb48a01..fc53256 100644 --- a/internal/term/manager.go +++ b/internal/term/manager.go @@ -130,6 +130,15 @@ func (m *Manager) pump(s *Session) { s.mu.Unlock() _ = s.ptmx.Close() m.changed() + // An exited shell stays listed for a while so the exit is visible, then reaps itself. + time.AfterFunc(10*time.Minute, func() { + m.mu.Lock() + cur, ok := m.sessions[s.ID] + m.mu.Unlock() + if ok && cur == s && !s.running { + _ = m.Close(s.ID) + } + }) } // broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY. diff --git a/web/src/Cron.tsx b/web/src/Cron.tsx index c56a522..dfe711b 100644 --- a/web/src/Cron.tsx +++ b/web/src/Cron.tsx @@ -122,7 +122,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob; {tab === 'logs' && (
{logs === null ?
loading…
: logs.length === 0 ?
{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.'}
- : logs.map((l, i) =>
{fmtTime(l.t)}{l.msg}
)} + : [...logs].reverse().map((l, i) =>
{fmtTime(l.t)}{l.msg}
)}
)} {tab === 'output' &&
{out ? out.text || '(empty)' :  reading…}
} diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx index 57c6e62..be8fa6f 100644 --- a/web/src/Dashboard.tsx +++ b/web/src/Dashboard.tsx @@ -176,17 +176,18 @@ function body(id: string, { s, h, edit }: Ctx): ReactNode {
{x.iface} + {!x.up && down} - ↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)} + {x.up && ↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}}
- {h?.net[x.iface] && ( + {x.up && h?.net[x.iface] && ( )} -
+ {x.up &&
rx · {fmtBytes(x.rx_total)} total tx · {fmtBytes(x.tx_total)} total -
+
} ))} 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 } }), } 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)) // Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards). diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index 46638f7..da05018 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -38,13 +38,21 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange } 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 - 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() { const n = list?.filter(outdated).length ?? 0 if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return 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') setTimeout(load, 4000) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } diff --git a/web/src/Power.tsx b/web/src/Power.tsx index 451947b..aa9fa52 100644 --- a/web/src/Power.tsx +++ b/web/src/Power.tsx @@ -3,16 +3,17 @@ import { api } from './api' import { Btn, Spinner } from './ui' // 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 [typed, setTyped] = useState('') const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('') const [err, setErr] = useState('') - const ok = typed.trim() === host + const ok = typed.trim() === label async function go(action: 'reboot' | 'poweroff') { 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') } } @@ -22,14 +23,14 @@ export default function Power({ host, rebootNeeded }: { host: string; rebootNeed {open && (
!sent && setOpen(false)}>
e.stopPropagation()}> -

Power · {host}

+

Power · {label}

{rebootNeeded &&
A reboot is pending from package updates.
} {sent ? (
{sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}
) : ( <> -
+ {rows.length === 0 &&
Nothing matches.
} {rows.map((p) => (
setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}> {p.pid} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 4cf7f0f..afe0263 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -39,6 +39,16 @@ const GROUPS: Group[] = [ ] 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: #//. 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 { 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 }) { // 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. - const [machine, setMachineState] = useState(() => { const m = sessionStorage.getItem('hamlab.machine'); setMachine(m); return m }) + const [machine, setMachineState] = useState(() => { const m = parseHash()?.machine ?? sessionStorage.getItem('hamlab.machine'); setMachine(m); return m }) const [machines, setMachines] = useState([]) const [remoteMe, setRemoteMe] = useState(null) const [remoteErr, setRemoteErr] = useState('') @@ -56,7 +66,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: const listRate = useRefreshMs(LIST_FLOOR) const [terms, setTerms] = useState([]) const [active, setActive] = useState(null) - const [view, setView] = useState('dashboard') + const [view, setView] = useState(() => parseHash()?.view ?? 'dashboard') const [drawer, setDrawer] = useState(false) const [online, setOnline] = useState(true) 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') handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true) 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(() => { if (!machine) { setRemoteMe(null); return } let stop = false @@ -139,9 +160,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {me.user}@{me.host} {machine && remoteErr && offline} - {me.load} + load {me.load} - + logout diff --git a/web/src/api.ts b/web/src/api.ts index c8ef2e5..50ee8c0 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -7,6 +7,11 @@ let base = '' export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' } export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p) +// A machine that just failed to answer is left alone for a few seconds instead of +// being hit again on every tick of every page. +const downUntil: Record = {} +function machineOf(p: string): string | null { const m = p.match(/^\/api\/nodes\/([a-z0-9-]+)\//); return m ? m[1] : null } + let inflight = 0 function track(delta: number) { inflight += delta @@ -14,10 +19,13 @@ function track(delta: number) { } async function req(method: string, path: string, body?: unknown, local = false): Promise { + const target = local ? path : apiPath(path) + const mach = machineOf(target) + if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`) track(1) let r: Response try { - r = await fetch(local ? path : apiPath(path), { + r = await fetch(target, { method, credentials: 'same-origin', headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, @@ -28,6 +36,7 @@ async function req(method: string, path: string, body?: unknown, local = fals throw new Error('network error') } track(-1) + if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000 if (r.status === 401) { let msg = 'unauthenticated' try { msg = (await r.json()).error ?? msg } catch { /* ignore */ } @@ -167,8 +176,8 @@ export const api = { me: () => req('GET', '/api/me'), machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true), addMachine: (name: string, url: string, password: string) => req('POST', '/api/nodes', { name, url, password }, true), - updateMachine: (name: string) => req('POST', `/api/machines/${encodeURIComponent(name)}/update`, undefined, true), - updateAllMachines: () => req('POST', '/api/machines-update-all', undefined, true), + updateMachine: (name: string, force = false) => req('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true), + updateAllMachines: (force = false) => req('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true), changePassword: (current: string, password: string) => req('POST', '/api/password', { current, password }, true), tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true), revokeToken: (name: string) => req('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true), diff --git a/web/src/styles.css b/web/src/styles.css index 26f0c73..2b55988 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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); } .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; } +}