Files
hamlab/web/src/Shell.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 91b7f4f009 Terminal sessions move from the rail into a tab strip on the terminal page
The rail is now a flat list of pages; Terminal is one of them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:10:22 -07:00

215 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { api, Me, Term } from './api'
import Rail, { Group } from './Rail'
import TerminalPane, { TermHandle } from './TerminalPane'
import TermTabs from './TermTabs'
import KeyBar from './KeyBar'
import Dashboard from './Dashboard'
import Services from './Services'
import Cron from './Cron'
import Updates from './Updates'
import Disks from './Disks'
import Files from './Files'
import Procs from './Procs'
import Machines from './Machines'
import Settings from './Settings'
import MachineSwitcher from './MachineSwitcher'
import { MachineStatus, setMachine } from './api'
import { LIST_FLOOR, useRefreshMs } from './prefs'
import Power from './Power'
import { Btn, Toasts, toast } from './ui'
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
const FolderIcon = (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
<path d="M1.5 4a1 1 0 0 1 1-1h3.2l1.6 1.6h6.2a1 1 0 0 1 1 1V12a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4z" />
</svg>
)
const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔' },
{ id: 'procs', label: 'Processes', icon: '≡' },
{ id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' },
{ id: 'cron', label: 'Cron', icon: '⏱' },
{ id: 'updates', label: 'Updates', icon: '⇪' },
{ id: 'disks', label: 'Disks', icon: '▤' },
{ id: 'files', label: 'Files', icon: FolderIcon },
{ id: 'machines', label: 'Machines', icon: '⌂' },
{ id: 'settings', label: 'Settings', icon: '⚙' },
]
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
const HUB_ONLY: View[] = ['machines', 'settings']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
// forward work, reload restores the tab, and links can point at a page on a machine.
function parseHash(): { machine: string | null; view: View } | null {
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)$/)
if (!m || !VIEWS.includes(m[2] as View)) return null
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as 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.
const [machine, setMachineState] = useState<string | null>(() => { const m = parseHash()?.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('')
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: machine, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
const listRate = useRefreshMs(LIST_FLOOR)
const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null)
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'dashboard')
const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true)
const [creating, setCreating] = useState(false)
const handles = useRef<Record<string, TermHandle>>({})
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])
// 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 = (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); 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
useEffect(() => {
const want = `#/${machine ?? 'hub'}/${view}`
if (location.hash !== want) history.pushState(null, '', want)
}, [machine, view])
useEffect(() => {
const h = () => { const p = parseHash(); if (!p) return; 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 = window.prompt('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 && !window.confirm(`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)
}
const activeTerm = terms.find((t) => t.id === active)
const termView = view === 'terminal'
return (
<div className={'app' + (drawer ? ' drawer-open' : '')}>
<Toasts />
<header className="header">
<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, 1 · 5 · 15 min">load {me.load}</span>
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${machine} unreachable` : online ? 'connected' : 'disconnected'} />
<Power host={me.host} label={machine ?? me.host} rebootNeeded={me.reboot_needed} />
<Btn className="link" onClick={logout}>logout</Btn>
</header>
<div className="body">
<Rail groups={GROUPS} view={view}
header={<MachineSwitcher hub={hubMe.host} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
onView={(id) => { setView(id as View); setDrawer(false) }} />
<div className="backdrop" onClick={() => setDrawer(false)} />
<main className="main" key={machine ?? 'hub'}>
{view === 'settings' && <Settings me={hubMe} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
{view === 'dashboard' && <Dashboard />}
{view === 'procs' && <Procs />}
{view === 'services' && <Services />}
{view === 'cron' && <Cron />}
{view === 'updates' && <Updates />}
{view === 'disks' && <Disks />}
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
{termView && <TermTabs terms={terms} active={active} creating={creating}
onSelect={setActive} onNew={create} onRename={rename} onClose={close} />}
{termView && terms.length === 0 && (
<div className="center muted">
<div>No terminals.</div>
<Btn className="primary" busy={creating} onClick={create}>+ New Terminal</Btn>
</div>
)}
{terms.map((t) => (
<TerminalPane key={t.id} id={t.id} active={termView && t.id === active}
register={(h) => { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }}
onStatus={(ok) => { if (t.id === active) setOnline(ok) }}
onExit={reload} />
))}
</main>
</div>
{termView && activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
<footer className="footer">
<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' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
}