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:
co-authored by
Claude Opus 5
parent
a7b8319974
commit
3c7e46104c
@@ -544,3 +544,51 @@ drawn over the page behind.
|
||||
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
|
||||
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
@@ -1986,21 +1986,13 @@ func (s *Server) keepFleetInStep() {
|
||||
} else {
|
||||
time.Sleep(2 * time.Minute)
|
||||
}
|
||||
var ui struct {
|
||||
RefreshMs float64 `json:"refreshMs"`
|
||||
}
|
||||
var histSec int
|
||||
s.store.View(func(st *store.State) { _ = json.Unmarshal(st.UI, &ui); histSec = st.HistorySec })
|
||||
want := map[string][2]any{} // path → {field, value}
|
||||
if ui.RefreshMs >= 100 {
|
||||
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}
|
||||
// What the hub itself is running is the fleet's answer — not what happens to be written
|
||||
// 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
|
||||
// should be on them.
|
||||
want := map[string][2]any{ // path → {field, value}
|
||||
"/api/prefs/sampler": {"ms", int(s.metrics.Interval().Milliseconds())},
|
||||
"/api/prefs/history": {"seconds", int(s.metrics.Window().Seconds())},
|
||||
}
|
||||
hdr := http.Header{"X-Hamlab-From-Hub": []string{"1"}, "Content-Type": []string{"application/json"}}
|
||||
for _, name := range s.nodes.Names() {
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ export default function Cron() {
|
||||
const [ov, setOv] = useState<CronOverview | null>(null)
|
||||
const [err, setErr] = 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 setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ export default function Docker() {
|
||||
const [err, setErr] = useState('')
|
||||
const [tab, setTab] = useState<Tab>('containers')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [sel, setSel] = useHashSel()
|
||||
const [sel, setSel] = useHashSel('docker')
|
||||
const [making, setMaking] = useState<'' | 'stack' | 'container'>('')
|
||||
const [dup, setDup] = useState<DupFrom | null>(null)
|
||||
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
||||
|
||||
// ---- page ----
|
||||
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') || '/'))
|
||||
useEffect(() => { setHashPath(path) }, [path, setHashPath])
|
||||
useEffect(() => { if (hashPath.startsWith('/') && hashPath !== path) setPath(hashPath) }, [hashPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function Services() {
|
||||
const [err, setErr] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
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 [loaded, setLoaded] = useState(false)
|
||||
|
||||
|
||||
+10
-2
@@ -86,7 +86,10 @@ 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<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 [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||
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')
|
||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
||||
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
|
||||
@@ -134,6 +136,12 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
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.
|
||||
// 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])
|
||||
// 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])
|
||||
|
||||
+12
-6
@@ -2,20 +2,26 @@
|
||||
// which unit, which job, which folder. Reload keeps it and a link can carry it.
|
||||
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] {
|
||||
const [sel, setSel] = useState(read)
|
||||
export function useHashSel(view?: string): [string, (v: string) => void] {
|
||||
const [sel, setSel] = useState(() => read(view))
|
||||
useEffect(() => {
|
||||
const h = () => setSel(read())
|
||||
const h = () => setSel(read(view))
|
||||
window.addEventListener('popstate', h); window.addEventListener('hashchange', h)
|
||||
return () => { window.removeEventListener('popstate', h); window.removeEventListener('hashchange', h) }
|
||||
}, [])
|
||||
}, [view])
|
||||
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 want = v ? `${base}/${encodeURIComponent(v)}` : base
|
||||
if (location.hash !== want) history.replaceState(null, '', want)
|
||||
setSel(v)
|
||||
}, [])
|
||||
}, [view])
|
||||
return [sel, set]
|
||||
}
|
||||
|
||||
@@ -201,6 +201,9 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
|
||||
/* updates */
|
||||
.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.sel { background: var(--bg3); }
|
||||
.pkg input { width: 16px; height: 16px; }
|
||||
|
||||
Reference in New Issue
Block a user