Multi-host phase 1: API tokens, auto TLS with pinned fingerprints, hub-side node manager + reverse proxy (WebSockets included), machine switcher in the rail, Machines page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
fc42675e9f
commit
7dcdb5dc98
+45
-9
@@ -10,6 +10,9 @@ import Updates from './Updates'
|
||||
import Disks from './Disks'
|
||||
import Files from './Files'
|
||||
import Procs from './Procs'
|
||||
import Machines from './Machines'
|
||||
import MachineSwitcher from './MachineSwitcher'
|
||||
import { MachineStatus, setMachine } from './api'
|
||||
import Power from './Power'
|
||||
import { Btn, Toasts, toast } from './ui'
|
||||
|
||||
@@ -29,16 +32,24 @@ const GROUPS: Group[] = [
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪', page: true },
|
||||
{ id: 'disks', label: 'Disks', icon: '▤', page: true },
|
||||
{ id: 'files', label: 'Files', icon: FolderIcon, page: true },
|
||||
{ id: 'machines', label: 'Machines', icon: '⌂', page: true },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs'
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines'
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
export default function Shell({ me, 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
|
||||
// every page's first request already goes to the right place.
|
||||
const [machine, setMachineState] = useState<string | null>(() => { const m = sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
|
||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||
const [remoteErr, setRemoteErr] = useState('')
|
||||
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: machine, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
|
||||
const [terms, setTerms] = useState<Term[]>([])
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [view, setView] = useState<View>('dashboard')
|
||||
@@ -48,13 +59,35 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
const handles = useRef<Record<string, TermHandle>>({})
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const list = await api.terminals()
|
||||
setTerms(list)
|
||||
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
||||
}, [])
|
||||
try {
|
||||
const list = await api.terminals()
|
||||
setTerms(list)
|
||||
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
||||
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
|
||||
}, [machine])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
// Machine list for the switcher (hub-local), refreshed while the console is open.
|
||||
const loadMachines = useCallback(async () => { try { setMachines((await api.machines()).nodes) } catch { /* not a hub or offline */ } }, [])
|
||||
useEffect(() => { loadMachines(); const t = setInterval(loadMachines, 15000); return () => clearInterval(t) }, [loadMachines])
|
||||
|
||||
// Switching machines: reset everything that belongs to a host, then re-fetch its identity.
|
||||
const switchMachine = (name: string | null) => {
|
||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false)
|
||||
setMachineState(name)
|
||||
if (view === 'machines') setView('dashboard')
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!machine) { setRemoteMe(null); return }
|
||||
let stop = false
|
||||
const tick = async () => { try { const m = await api.me(); if (!stop) { setRemoteMe(m); setRemoteErr('') } } catch (e) { if (!stop) setRemoteErr(e instanceof Error ? e.message : 'unreachable') } }
|
||||
tick()
|
||||
const t = setInterval(tick, 30000)
|
||||
return () => { stop = true; clearInterval(t) }
|
||||
}, [machine])
|
||||
|
||||
async function create() {
|
||||
setCreating(true)
|
||||
try {
|
||||
@@ -100,6 +133,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
|
||||
<span className="brand">Ham Lab</span>
|
||||
<span className="host">{me.user}@{me.host}</span>
|
||||
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
|
||||
<span className="spacer" />
|
||||
<span className="stat" title="load average">{me.load}</span>
|
||||
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
|
||||
@@ -109,12 +143,14 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
|
||||
<div className="body">
|
||||
<Rail groups={GROUPS} terms={terms} active={active} view={view} creating={creating}
|
||||
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||
onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }}
|
||||
onView={(id) => { setView(id as View); setDrawer(false) }}
|
||||
onNew={create} onRename={rename} onClose={close} />
|
||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||
|
||||
<main className="main">
|
||||
<main className="main" key={machine ?? 'hub'}>
|
||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{view === 'procs' && <Procs />}
|
||||
{view === 'services' && <Services />}
|
||||
@@ -141,10 +177,10 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
|
||||
|
||||
<footer className="footer">
|
||||
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user