MVP: Go daemon, password auth, persistent PTY terminals, React/xterm UI, PWA

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 10:25:35 -07:00
co-authored by Claude Fable 5.1
parent 190604247f
commit 436732dac0
35 changed files with 3576 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, Me, Term } from './api'
import Rail, { Group } from './Rail'
import TerminalPane, { TermHandle } from './TerminalPane'
import KeyBar from './KeyBar'
const GROUPS: Group[] = [
{ id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '⚙', soon: true },
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
{ id: 'dashboard', label: 'Dashboard', icon: '◔', soon: true },
]
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 }) {
const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null)
const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true)
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))
}, [])
useEffect(() => { reload() }, [reload])
async function create() {
const t = await api.createTerminal()
await reload()
setActive(t.id)
setDrawer(false)
}
async function rename(t: Term) {
const name = window.prompt('Rename terminal', t.name)
if (name && name.trim() && name !== t.name) { await api.renameTerminal(t.id, name.trim()); await reload() }
}
async function close(t: Term) {
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
await api.deleteTerminal(t.id)
delete handles.current[t.id]
await reload()
}
async function logout() { await api.logout(); onLoggedOut() }
const activeTerm = terms.find((t) => t.id === active)
return (
<div className={'app' + (drawer ? ' drawer-open' : '')}>
<header className="header">
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
<span className="brand">Hamlet</span>
<span className="host">{me.user}@{me.host}</span>
<span className="spacer" />
<span className="stat" title="load average">{me.load}</span>
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
<button className="link" onClick={logout}>logout</button>
</header>
<div className="body">
<Rail groups={GROUPS} terms={terms} active={active}
onSelect={(id) => { setActive(id); setDrawer(false) }}
onNew={create} onRename={rename} onClose={close} />
<div className="backdrop" onClick={() => setDrawer(false)} />
<main className="main">
{terms.length === 0 && (
<div className="center muted">
<div>No terminals.</div>
<button className="primary" onClick={create}>+ New Terminal</button>
</div>
)}
{terms.map((t) => (
<TerminalPane key={t.id} id={t.id} active={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>
{activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
<footer className="footer">
<span>v{me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
}