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; }
+}