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