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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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]
}
+3
View File
@@ -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; }