import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { api, Me, Term, titleOf } from './api' import Rail, { Group } from './Rail' import TerminalPane, { TermHandle } from './TerminalPane' import TermTabs from './TermTabs' import KeyBar from './KeyBar' import Gallery from './Gallery' import Docker from './Docker' import Alerts from './Alerts' import Services from './Services' import Jobs from './Jobs' import Updates from './Updates' import Disks from './Disks' import Hardware from './Hardware' import Network from './Network' import VPN from './VPN' import ZFS from './ZFS' import KVM from './KVM' import Files from './Files' import Top from './Top' import Machines from './Machines' import Settings from './Settings' import Usage from './Usage' import Users from './Users' import MachineSwitcher from './MachineSwitcher' import { MachineStatus, setMachine } from './api' import { LIST_FLOOR, useRefreshMs } from './prefs' import Power from './Power' import { applyAccent } from './accents' import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast, useEscape } from './ui' import { fmtLeft, useTimeLeft } from './guest' // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). const FolderIcon = ( ) // ⏱ is an emoji on phones (full colour, its own size); an outline clock matches the rest. const ClockIcon = ( ) const DockerIcon = ( ) const BellIcon = ( ) const GlobeIcon = ( ) // A padlock-ish shield: the tab is about who may come in, and a keyhole reads at 14px where // a padlock's shackle does not. const ShieldIcon = ( ) const GROUPS: Group[] = [ { id: 'procs', label: 'Processes', icon: '▚' }, { id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'services', label: 'Services', icon: '◈' }, { id: 'jobs', label: 'Jobs', icon: ClockIcon }, { id: 'disks', label: 'Disks', icon: '▤' }, { id: 'files', label: 'Files', icon: FolderIcon }, { id: 'network', label: 'Network', icon: GlobeIcon }, { id: 'vpn', label: 'VPN', icon: ShieldIcon }, { id: 'usage', label: 'Usage', icon: '▧' }, { id: 'zfs', label: 'ZFS', icon: '⛁' }, { id: 'docker', label: 'Docker', icon: DockerIcon }, { id: 'kvm', label: 'KVM', icon: '⌨' }, { id: 'hardware', label: 'Hardware', icon: '⬡' }, { id: 'updates', label: 'Updates', icon: '⇪' }, { id: 'alerts', label: 'Alerts', icon: BellIcon }, { id: 'machines', label: 'Machines', icon: '⌂' }, { id: 'widgets', label: 'Widgets', icon: '▦' }, { id: 'users', label: 'Users', icon: '⚇' }, { id: 'settings', label: 'Settings', icon: '⚙' }, ] type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and // forward work, reload restores the tab, and links can point at a page on a machine. // Pages that were renamed, so a link or a bookmark made before it still lands where it meant. const RENAMED_VIEWS: Record = { space: 'usage', cron: 'jobs', top: 'procs', dashboard: 'procs' } function parseHash(): { machine: string | null; view: View } | null { const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)(?:\/.*)?$/) // anything after the page is that page's own selection if (!m) return null const view = VIEWS.includes(m[2] as View) ? (m[2] as View) : RENAMED_VIEWS[m[2]] if (!view) return null return { machine: m[1] === 'hub' ? null : m[1], view } } 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: 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. // 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('') // A guest login is an account with a clock on it: nothing is hidden from it, but the header // says how long it has, for whoever is holding the phone. const leftMs = useTimeLeft(hubMe.guest_until) // A machine is titled by its own hostname; the name it was added under stays its identity // (the URL, its accent, what the hub's API calls it). See titleOf. const title = titleOf(machine, machines, hubMe.host) const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe const listRate = useRefreshMs(LIST_FLOOR) const [terms, setTerms] = useState([]) const [active, setActive] = useState(null) const [view, setView] = useState(() => parseHash()?.view ?? 'procs') const [drawer, setDrawer] = useState(false) useEscape(() => setDrawer(false), drawer) // the phone's drawer closes like every other overlay const [online, setOnline] = useState(true) const [creating, setCreating] = useState(false) const handles = useRef>({}) const reload = useCallback(async () => { 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]) // One page sending you to another — the Network tab handing a tunnel over to the VPN tab. // A custom event rather than a hash assignment, because assigning location.hash does not // reliably raise popstate, which is what this shell navigates by. useEffect(() => { const h = (e: Event) => { const id = (e as CustomEvent).detail if (VIEWS.includes(id as View)) { setView(id as View); setDrawer(false) } } window.addEventListener('hamlab:view', h) return () => window.removeEventListener('hamlab:view', h) }, []) // A shell opened inside a container from the Docker tab is a terminal like any other: // pick it up, show the Terminal tab, and make it the active one. useEffect(() => { const h = async (e: Event) => { const id = (e as CustomEvent).detail if (!id) return await reload() setActive(id) setView('terminal') } window.addEventListener('hamlab:terminal', h) return () => window.removeEventListener('hamlab:terminal', h) }, [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, listRate); return () => clearInterval(t) }, [loadMachines, listRate]) // Switching machines: reset everything that belongs to a host, then re-fetch its identity. const switchMachine = async (name: string | null) => { if (name !== machine && !(await okToLeave())) return 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) } // keep the hash in step, and follow it when the browser navigates const [firing, setFiring] = useState(0) useEffect(() => { const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) } tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick) 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('procs') }, [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]) useEffect(() => { const want = `#/${machine ?? 'hub'}/${view}` if (location.hash !== want && !location.hash.startsWith(want + '/')) history.pushState(null, '', want) }, [machine, view]) useEffect(() => { const h = async () => { const p = parseHash(); if (!p) return if (hasUnsaved() && (p.machine !== machine || p.view !== view)) { // Back/Forward away from unsaved work: stay put until answered const there = location.hash history.pushState(null, '', `#/${machine ?? 'hub'}/${view}`) if (!(await okToLeave())) return history.replaceState(null, '', there) } if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view) } window.addEventListener('popstate', h) return () => window.removeEventListener('popstate', h) }) // no deps: needs the latest switchMachine/view each time 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, listRate) return () => { stop = true; clearInterval(t) } }, [machine, listRate]) async function create() { setCreating(true) try { const t = await api.createTerminal() await reload() setActive(t.id) setView('terminal') setDrawer(false) } catch (e) { toast(`new terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err') } finally { setCreating(false) } } async function rename(t: Term) { const name = await askText('Rename terminal', t.name) if (name && name.trim() && name !== t.name) { try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') } } } async function close(t: Term) { if (t.running && !await askYes(`Close "${t.name}"? The shell will be hung up.`)) return try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() } catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') } } async function logout() { await api.logout(); onLoggedOut() } // Files → "open in terminal": type into the active shell (creating one if needed) and switch to it. async function runInTerminal(cmd: string) { let id = active if (!id || !terms.some((t) => t.id === id && t.running)) { try { const t = await api.createTerminal(); await reload(); id = t.id; setActive(t.id) } catch (e) { toast(`terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return } } setView('terminal'); setDrawer(false) const send = (tries: number) => { const h = handles.current[id!]; if (h) h.input(cmd + '\n'); else if (tries > 0) window.setTimeout(() => send(tries - 1), 100) } window.setTimeout(() => send(20), 50) } // Tab order is the user's, kept per machine in this browser; new terminals join at the end. const orderKey = `hamlab.termorder.${machine ?? 'hub'}` const [order, setOrder] = useState([]) useEffect(() => { try { setOrder(JSON.parse(localStorage.getItem(orderKey) ?? '[]')) } catch { setOrder([]) } }, [orderKey]) const ordered = useMemo(() => [...terms].sort((a, b) => { const i = order.indexOf(a.id), j = order.indexOf(b.id); return (i < 0 ? 1e9 : i) - (j < 0 ? 1e9 : j) }), [terms, order]) const moveTab = (id: string, before: string | null) => { const ids = ordered.map((t) => t.id).filter((x) => x !== id) const at = before ? ids.indexOf(before) : -1 ids.splice(at < 0 ? ids.length : at, 0, id) setOrder(ids); localStorage.setItem(orderKey, JSON.stringify(ids)) } // Alt+Shift+←/→ switches tabs, Alt+Shift+T opens one. Caught before xterm sees the keys. useEffect(() => { if (view !== 'terminal') return const h = (e: KeyboardEvent) => { if (!e.altKey || !e.shiftKey || e.ctrlKey || e.metaKey) return const dir = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0 if (dir && ordered.length > 1) { const i = ordered.findIndex((t) => t.id === active); setActive(ordered[(i + dir + ordered.length) % ordered.length].id) } else if (e.key.toLowerCase() === 't') create() else return e.preventDefault(); e.stopPropagation() } window.addEventListener('keydown', h, true) return () => window.removeEventListener('keydown', h, true) }) // no deps: needs the latest tabs each time const activeRef = useRef(active); activeRef.current = active const activeTerm = terms.find((t) => t.id === active) const termView = view === 'terminal' return (
Ham Lab {me.user}@{me.host} {machine && remoteErr && offline} load {me.load} {hubMe.guest && guest · {fmtLeft(leftMs)}} {hubMe.account && {hubMe.account}} logout
!HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }} header={ { setView('machines'); setDrawer(false) }} />} onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
setDrawer(false)} />
g.id === view)?.label ?? view}> {view === 'settings' && } {view === 'users' && } {view === 'kvm' && } {view === 'usage' && } {view === 'machines' && { switchMachine(n); setView('procs') }} />} {view === 'widgets' && } {view === 'docker' && } {view === 'alerts' && m.name)} titleOf={(n) => titleOf(n === hubMe.host ? null : n, machines, hubMe.host)} />} {view === 'procs' && } {view === 'services' && } {view === 'jobs' && } {view === 'updates' && } {view === 'disks' && } {view === 'hardware' && } {view === 'network' && } {view === 'vpn' && } {view === 'zfs' && } {view === 'files' && } {termView && window.setTimeout(() => { const id = activeRef.current; if (id) handles.current[id]?.armCtrl(false) }, 0)} />} {termView && terms.length === 0 && (
No terminals.
+ New Terminal
)} {terms.map((t) => ( { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }} onStatus={(ok) => { if (t.id === active) setOnline(ok) }} onExit={reload} /> ))}
{termView && activeTerm && handles.current[activeTerm.id]?.input(s)} setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
) }