Bug bash 5: the URL, the fleet loop, and an empty card

A link to a hub page no longer reloads onto the last machine; a link from a hub-only page to
a machine's page arrives at that page; a page being left no longer asks about the next page's
selection; the hub holds its machines to the rate it is itself running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-21 23:26:36 -07:00
co-authored by Claude Opus 5
parent a7b8319974
commit 3c7e46104c
9 changed files with 84 additions and 27 deletions
+48
View File
@@ -544,3 +544,51 @@ drawn over the page behind.
Firefox: nothing leaves its card, no number is broken across lines, and the fallback Firefox: nothing leaves its card, no number is broken across lines, and the fallback
scrollbar is never reached. The same measurement was run over every other page's cards scrollbar is never reached. The same measurement was run over every other page's cards
and boxes — Dashboard, Top, Docker, Alerts, Machines and the rest — and found nothing. and boxes — Dashboard, Top, Docker, Alerts, Machines and the rest — and found nothing.
# Bug bash 5 — 2026-09-21: navigation, and the fleet setting nobody had set
A sweep of the whole console after a week of changes, not of any one page: every page on every
machine at four widths in **Chromium and Firefox**, then the everyday flows, then the URL.
Measured rather than looked at — a heading against its neighbour and its own text, a box
against what it holds, a request against the page that should have made it.
## Bugs — all ✅ fixed and verified by the script that found them
1. ✅ **A link to a page of the hub reloaded onto whichever machine you last had open.** The hub
is `null` internally, so `parseHash()?.machine ?? sessionStorage…` read straight past it to
the remembered machine: open `#/hub/docker/stack:gitea` after looking at `grid`, reload, and
you were on `#/grid/docker`. The URL wins when it has an opinion, and "hub" is an opinion;
the remembered machine is for a bare address only. Every link shape now survives a reload,
and a bare address still lands where you left off.
2. ✅ **From Settings, Machines or Alerts, a link to a machine's page arrived at its Dashboard.**
Those three pages only exist on the hub, so moving to a machine bounces off them — but the
bounce was decided from inside the machine change, which read the page being *left*, and
landed after the URL's own page had been set. It is judged now once the machine and the page
have both settled. `#/hub/settings` → `#/grid/disks` arrives at Disks; `#/grid/settings`
still falls back to that machine's Dashboard.
3. ✅ **A page being navigated away from briefly adopted the next page's selection**, and asked
about it: leaving `#/grid/services/ssh.service` for `#/hub/docker/stack:gitea`, Services —
still mounted for a moment — opened a log socket for a *service* called `stack:gitea`, on
`grid`. Firefox says so in the console; Chromium swallows it. A page now reads the URL's
selection only while the URL still points at that page. Checked over every hop between the
five pages that keep a selection in the URL, in both browsers.
4. ✅ **The hub only held its machines to a sampling rate if somebody had been into Settings.**
The re-assert loop read the rate out of the saved UI prefs, which are written on change and
not before — so on a hub where the rate had never been touched, a node left on a different
rate was never pulled back. It compares against what the hub itself is running, which always
exists. Verified by setting a node behind the hub's back: corrected within seconds.
5. ✅ **"Everything is up to date." sat in a 20px sliver of a card**, flush to its border, where
the package list it shares the card with is meant to be.
## Checked and fine
Machine titles on every page and against an offline or never-reached machine; the colour a
machine wears stays with the name it was added under, not its hostname. Files: create, open,
edit, save, rename, delete. Services with its unit and log, cron jobs, containers and stacks,
alert rules edited and put back, the terminal, the widget gallery. Login, a wrong password, a
session cleared under an open page, logging back in onto the page you were on, and logout
leaving nothing behind. The sampler, window and UI prefs endpoints under nonsense and hostile
input, and a read-only token refused by all of them. The public demo page and installer. The
Top tab on all four machines — including `grid`'s sixteen cores and thirteen filesystems, which
scroll inside their boxes — its rate control, its filter, and three skins repainting it.
`hamtv`'s eMMC really is called `hB8aP>`; that is what the device reports.
+7 -15
View File
@@ -1986,21 +1986,13 @@ func (s *Server) keepFleetInStep() {
} else { } else {
time.Sleep(2 * time.Minute) time.Sleep(2 * time.Minute)
} }
var ui struct { // What the hub itself is running is the fleet's answer — not what happens to be written
RefreshMs float64 `json:"refreshMs"` // in the UI prefs, which are only written when somebody changes a setting. A hub nobody
} // has been into Settings on still has a sampling rate and a window, and its machines
var histSec int // should be on them.
s.store.View(func(st *store.State) { _ = json.Unmarshal(st.UI, &ui); histSec = st.HistorySec }) want := map[string][2]any{ // path → {field, value}
want := map[string][2]any{} // path → {field, value} "/api/prefs/sampler": {"ms", int(s.metrics.Interval().Milliseconds())},
if ui.RefreshMs >= 100 { "/api/prefs/history": {"seconds", int(s.metrics.Window().Seconds())},
ms := int(ui.RefreshMs)
if hi := int(metrics.MaxInterval.Milliseconds()); ms > hi {
ms = hi
}
want["/api/prefs/sampler"] = [2]any{"ms", ms}
}
if histSec > 0 {
want["/api/prefs/history"] = [2]any{"seconds", histSec}
} }
hdr := http.Header{"X-Hamlab-From-Hub": []string{"1"}, "Content-Type": []string{"application/json"}} hdr := http.Header{"X-Hamlab-From-Hub": []string{"1"}, "Content-Type": []string{"application/json"}}
for _, name := range s.nodes.Names() { for _, name := range s.nodes.Names() {
+1 -1
View File
@@ -176,7 +176,7 @@ export default function Cron() {
const [ov, setOv] = useState<CronOverview | null>(null) const [ov, setOv] = useState<CronOverview | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [hashSel, setHashSel] = useHashSel() // "job:<id>" or "timer:<unit>", in the URL const [hashSel, setHashSel] = useHashSel('cron') // "job:<id>" or "timer:<unit>", in the URL
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '') const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
const [editing, setEditing] = useState<string | null>(null) const [editing, setEditing] = useState<string | null>(null)
+1 -1
View File
@@ -183,7 +183,7 @@ export default function Docker() {
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [tab, setTab] = useState<Tab>('containers') const [tab, setTab] = useState<Tab>('containers')
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const [sel, setSel] = useHashSel() const [sel, setSel] = useHashSel('docker')
const [making, setMaking] = useState<'' | 'stack' | 'container'>('') const [making, setMaking] = useState<'' | 'stack' | 'container'>('')
const [dup, setDup] = useState<DupFrom | null>(null) const [dup, setDup] = useState<DupFrom | null>(null)
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null) const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
+1 -1
View File
@@ -101,7 +101,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
// ---- page ---- // ---- page ----
export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) { export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) {
const [hashPath, setHashPath] = useHashSel() // the folder is in the URL; without one, the last folder that opened const [hashPath, setHashPath] = useHashSel('files') // the folder is in the URL; without one, the last folder that opened
const [path, setPath] = useState<string>(() => (hashPath.startsWith('/') ? hashPath : sessionStorage.getItem('hamlab.files.path') || '/')) const [path, setPath] = useState<string>(() => (hashPath.startsWith('/') ? hashPath : sessionStorage.getItem('hamlab.files.path') || '/'))
useEffect(() => { setHashPath(path) }, [path, setHashPath]) useEffect(() => { setHashPath(path) }, [path, setHashPath])
useEffect(() => { if (hashPath.startsWith('/') && hashPath !== path) setPath(hashPath) }, [hashPath]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (hashPath.startsWith('/') && hashPath !== path) setPath(hashPath) }, [hashPath]) // eslint-disable-line react-hooks/exhaustive-deps
+1 -1
View File
@@ -177,7 +177,7 @@ export default function Services() {
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [filter, setFilter] = useState<Filter>('all') const [filter, setFilter] = useState<Filter>('all')
const [hashSel, setHashSel] = useHashSel() // the selected unit lives in the URL const [hashSel, setHashSel] = useHashSel('services') // the selected unit lives in the URL
const sel = hashSel || null, setSel = (v: string | null) => setHashSel(v ?? '') const sel = hashSel || null, setSel = (v: string | null) => setHashSel(v ?? '')
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
+10 -2
View File
@@ -86,7 +86,10 @@ 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 = parseHash()?.machine ?? sessionStorage.getItem('hamlab.machine'); setMachine(m); return m }) // The URL wins when it has an opinion; the last machine is only for a bare address. "hub" is
// an opinion — it is null here — so ?? would have read straight past it to the remembered
// machine, and a link to a page of the hub reloaded onto whichever machine was last open.
const [machine, setMachineState] = useState<string | null>(() => { const h = parseHash(); const m = h ? h.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('')
@@ -123,7 +126,6 @@ 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 (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 // keep the hash in step, and follow it when the browser navigates
@@ -134,6 +136,12 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) } return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
}, []) }, [])
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up. // The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
// Hub-only pages make no sense on a remote machine. Judged once both have settled, not while
// 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('dashboard') }, [machine, view])
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host]) useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
// The tab's title says where you are, so history entries and open tabs can be told apart. // The tab's title says where you are, so history entries and open tabs can be told apart.
useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${title} — Ham Lab` }, [view, title]) useEffect(() => { document.title = `${GROUPS.find((g) => g.id === view)?.label ?? view} · ${title} — Ham Lab` }, [view, title])
+12 -6
View File
@@ -2,20 +2,26 @@
// which unit, which job, which folder. Reload keeps it and a link can carry it. // which unit, which job, which folder. Reload keeps it and a link can carry it.
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
const read = () => { try { return decodeURIComponent(location.hash.split('/').slice(3).join('/')) } catch { return '' } } const viewOf = () => location.hash.split('/')[2] ?? ''
// The selection belongs to one page. A page reads it only while the URL is still pointing at
// that page: moving from #/grid/services/ssh.service to #/hub/docker/stack:gitea changes the
// hash in one step, and the page being left is still mounted for a moment — long enough, before
// this, for Services to go and ask grid about a "service" called stack:gitea.
const read = (view?: string) => { try { return view !== undefined && viewOf() !== view ? '' : decodeURIComponent(location.hash.split('/').slice(3).join('/')) } catch { return '' } }
export function useHashSel(): [string, (v: string) => void] { export function useHashSel(view?: string): [string, (v: string) => void] {
const [sel, setSel] = useState(read) const [sel, setSel] = useState(() => read(view))
useEffect(() => { useEffect(() => {
const h = () => setSel(read()) const h = () => setSel(read(view))
window.addEventListener('popstate', h); window.addEventListener('hashchange', h) window.addEventListener('popstate', h); window.addEventListener('hashchange', h)
return () => { window.removeEventListener('popstate', h); window.removeEventListener('hashchange', h) } return () => { window.removeEventListener('popstate', h); window.removeEventListener('hashchange', h) }
}, []) }, [view])
const set = useCallback((v: string) => { const set = useCallback((v: string) => {
if (view !== undefined && viewOf() !== view) return // the URL has already moved on
const base = location.hash.split('/').slice(0, 3).join('/') const base = location.hash.split('/').slice(0, 3).join('/')
const want = v ? `${base}/${encodeURIComponent(v)}` : base const want = v ? `${base}/${encodeURIComponent(v)}` : base
if (location.hash !== want) history.replaceState(null, '', want) if (location.hash !== want) history.replaceState(null, '', want)
setSel(v) setSel(v)
}, []) }, [view])
return [sel, set] return [sel, set]
} }
+3
View File
@@ -201,6 +201,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
/* updates */ /* updates */
.updates .pkgs { padding: 0; } .updates .pkgs { padding: 0; }
/* the package list is flush to the card's edge, but a one-line "nothing to do" in the same
card should sit in it like every other empty state, not as a 20px sliver */
.updates .pkgs:has(> .empty:only-child) { padding: 12px; }
.pkg { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--border); } .pkg { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.pkg.sel { background: var(--bg3); } .pkg.sel { background: var(--bg3); }
.pkg input { width: 16px; height: 16px; } .pkg input { width: 16px; height: 16px; }