diff --git a/BUGS.md b/BUGS.md index 6e173b1..0cf5ec5 100644 --- a/BUGS.md +++ b/BUGS.md @@ -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. diff --git a/internal/api/server.go b/internal/api/server.go index cba89b0..0ec9d35 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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() { diff --git a/web/src/Cron.tsx b/web/src/Cron.tsx index e78310a..a309e43 100644 --- a/web/src/Cron.tsx +++ b/web/src/Cron.tsx @@ -176,7 +176,7 @@ export default function Cron() { const [ov, setOv] = useState(null) const [err, setErr] = useState('') const [q, setQ] = useState('') - const [hashSel, setHashSel] = useHashSel() // "job:" or "timer:", in the URL + const [hashSel, setHashSel] = useHashSel('cron') // "job:" or "timer:", 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(null) diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index d915c9f..81144aa 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -183,7 +183,7 @@ export default function Docker() { const [err, setErr] = useState('') const [tab, setTab] = useState('containers') const [filter, setFilter] = useState('') - const [sel, setSel] = useHashSel() + const [sel, setSel] = useHashSel('docker') const [making, setMaking] = useState<'' | 'stack' | 'container'>('') const [dup, setDup] = useState(null) const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null) diff --git a/web/src/Files.tsx b/web/src/Files.tsx index 717872f..9bd40d2 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -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(() => (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 diff --git a/web/src/Services.tsx b/web/src/Services.tsx index 261d9c3..98208fd 100644 --- a/web/src/Services.tsx +++ b/web/src/Services.tsx @@ -177,7 +177,7 @@ export default function Services() { const [err, setErr] = useState('') const [q, setQ] = useState('') const [filter, setFilter] = useState('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) diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 0c1098b..92425f8 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -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(() => { 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(() => { const h = parseHash(); const m = h ? h.machine : sessionStorage.getItem('hamlab.machine'); setMachine(m); return m }) const [machines, setMachines] = useState([]) const [remoteMe, setRemoteMe] = useState(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 , 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]) diff --git a/web/src/hashsel.ts b/web/src/hashsel.ts index da1bd07..aaf9e52 100644 --- a/web/src/hashsel.ts +++ b/web/src/hashsel.ts @@ -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] } diff --git a/web/src/styles.css b/web/src/styles.css index 7c82d2e..4c2fa1d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; }