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
+23
View File
@@ -0,0 +1,23 @@
import { useCallback, useEffect, useState } from 'react'
import { api, Me, Unauthenticated } from './api'
import Login from './Login'
import Shell from './Shell'
export default function App() {
const [me, setMe] = useState<Me | null | undefined>(undefined)
const refresh = useCallback(async () => {
try { setMe(await api.me()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
}, [])
useEffect(() => { refresh() }, [refresh])
useEffect(() => {
if (!me) return
const t = setInterval(refresh, 30000)
return () => clearInterval(t)
}, [me, refresh])
if (me === undefined) return <div className="center muted">…</div>
if (me === null) return <Login onLogin={refresh} />
return <Shell me={me} onLoggedOut={() => setMe(null)} />
}
+18
View File
@@ -0,0 +1,18 @@
import { useState } from 'react'
const KEYS: [string, string][] = [
['Esc', '\x1b'], ['Tab', '\t'], ['↑', '\x1b[A'], ['↓', '\x1b[B'], ['←', '\x1b[D'], ['→', '\x1b[C'],
['Home', '\x1b[H'], ['End', '\x1b[F'], ['|', '|'], ['~', '~'], ['-', '-'], ['/', '/'], ['^C', '\x03'], ['^D', '\x04'],
]
export default function KeyBar({ send, setCtrl }: { send: (s: string) => void; setCtrl: (v: boolean) => void }) {
const [ctrl, setCtrlState] = useState(false)
const toggle = () => { const v = !ctrl; setCtrlState(v); setCtrl(v) }
const press = (s: string) => { send(s); if (ctrl) { setCtrlState(false); setCtrl(false) } }
return (
<div className="keybar" onMouseDown={(e) => e.preventDefault()}>
<button className={ctrl ? 'on' : ''} onClick={toggle}>Ctrl</button>
{KEYS.map(([label, seq]) => <button key={label} onClick={() => press(seq)}>{label}</button>)}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { FormEvent, useState } from 'react'
import { api } from './api'
export default function Login({ onLogin }: { onLogin: () => void }) {
const [pw, setPw] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true); setErr('')
try { await api.login(pw); onLogin() }
catch (e) { setErr(e instanceof Error ? e.message : 'login failed') }
finally { setBusy(false) }
}
return (
<div className="center">
<form className="login" onSubmit={submit}>
<h1>Hamlet</h1>
<input type="password" autoFocus autoComplete="current-password" placeholder="password"
value={pw} onChange={(e) => setPw(e.target.value)} />
<button disabled={busy || !pw}>{busy ? '…' : 'Sign in'}</button>
{err && <div className="error">{err}</div>}
</form>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useState } from 'react'
import { Term } from './api'
export interface Group { id: string; label: string; icon: string; soon?: boolean }
interface Props {
groups: Group[]; terms: Term[]; active: string | null
onSelect: (id: string) => void; onNew: () => void
onRename: (t: Term) => void; onClose: (t: Term) => void
}
export default function Rail({ groups, terms, active, onSelect, onNew, onRename, onClose }: Props) {
const [open, setOpen] = useState<string>('terminal')
return (
<nav className="rail">
{groups.map((g) => {
const expanded = open === g.id
return (
<div key={g.id} className={'group' + (expanded ? ' open' : '') + (g.soon ? ' soon' : '')}>
<button className="group-head" onClick={() => !g.soon && setOpen(expanded ? '' : g.id)} disabled={g.soon}>
<span className="chev">{expanded ? '▾' : '▸'}</span>
<span className="gicon">{g.icon}</span>
<span>{g.label}</span>
{g.soon && <span className="badge">soon</span>}
</button>
{expanded && g.id === 'terminal' && (
<div className="leaves">
{terms.map((t) => (
<div key={t.id} className={'leaf' + (t.id === active ? ' active' : '') + (t.running ? '' : ' exited')}
onClick={() => onSelect(t.id)}>
<span className={'sdot' + (t.running ? ' run' : '')} />
<span className="lname" title={t.name}>{t.name}</span>
{t.id === active && (
<span className="leaf-actions">
<button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
</span>
)}
</div>
))}
<button className="leaf new" onClick={onNew}>+ New Terminal</button>
</div>
)}
</div>
)
})}
</nav>
)
}
+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>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { wsURL } from './api'
export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void }
interface Props {
id: string; active: boolean
register: (h: TermHandle | null) => void
onStatus: (ok: boolean) => void
onExit: () => void
}
const THEME = {
background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78',
black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff',
magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198',
brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
}
export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) {
const box = useRef<HTMLDivElement>(null)
const activeRef = useRef(active)
activeRef.current = active
const cbs = useRef({ onStatus, onExit })
cbs.current = { onStatus, onExit }
const fitRef = useRef<() => void>(() => {})
useEffect(() => {
const el = box.current!
const term = new Terminal({
theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14,
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
})
const fit = new FitAddon()
term.loadAddon(fit)
term.loadAddon(new WebLinksAddon())
term.open(el)
let ws: WebSocket | null = null
let closed = false
let ctrlArmed = false
let retry = 0
let retryTimer: number | undefined
let lastSize = ''
const enc = new TextEncoder()
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)) }
const sendResize = () => {
const key = `${term.cols}x${term.rows}`
if (key === lastSize) return
lastSize = key
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
const doFit = () => {
if (!activeRef.current || el.clientWidth === 0 || el.clientHeight === 0) return
fit.fit()
sendResize()
}
const connect = () => {
if (closed) return
const sock = new WebSocket(wsURL(id))
sock.binaryType = 'arraybuffer'
ws = sock
sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() }
sock.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
try {
const m = JSON.parse(ev.data)
if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; cbs.current.onExit() }
if (m.type === 'closed') closed = true
} catch { /* ignore */ }
}
sock.onclose = () => {
if (ws !== sock) return
cbs.current.onStatus(false)
if (closed) return
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
}
sock.onerror = () => sock.close()
}
connect()
term.onData((d) => {
if (ctrlArmed && d.length === 1) {
ctrlArmed = false
const c = d.toUpperCase().charCodeAt(0)
if (c >= 64 && c <= 95) { send(String.fromCharCode(c & 0x1f)); return }
}
send(d)
})
term.onBinary((d) => send(d))
term.onResize(sendResize)
fitRef.current = () => { doFit(); term.focus() }
const ro = new ResizeObserver(() => doFit())
ro.observe(el)
const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } }
document.addEventListener('visibilitychange', onVis)
register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } })
return () => {
register(null)
closed = true
clearTimeout(retryTimer)
document.removeEventListener('visibilitychange', onVis)
ro.disconnect()
ws?.close()
term.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
// Refit + focus whenever this pane becomes the visible one (after the display change applies).
useEffect(() => {
if (!active) return
const t = window.setTimeout(() => fitRef.current(), 0)
return () => clearTimeout(t)
}, [active])
return <div ref={box} className="term" style={{ display: active ? 'block' : 'none' }} />
}
+36
View File
@@ -0,0 +1,36 @@
export class Unauthenticated extends Error {}
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const r = await fetch(path, {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (r.status === 401) throw new Unauthenticated()
if (!r.ok) {
let msg = r.statusText
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
throw new Error(msg)
}
if (r.status === 204) return undefined as T
return r.json()
}
export interface Me { host: string; user: string; version: string; load: string; uptime: number }
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
export const api = {
login: (password: string) => req<void>('POST', '/api/login', { password }),
logout: () => req<void>('POST', '/api/logout'),
me: () => req<Me>('GET', '/api/me'),
terminals: () => req<Term[]>('GET', '/api/terminals'),
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
}
export function wsURL(id: string): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${location.host}/api/terminals/${id}/attach`
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import '@xterm/xterm/css/xterm.css'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
if ('serviceWorker' in navigator && location.protocol === 'https:') {
window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) })
}
+78
View File
@@ -0,0 +1,78 @@
:root {
--bg: #111418; --bg2: #171b21; --bg3: #1e242c; --border: #2a313b;
--fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff;
--ok: #3fb950; --bad: #ff7b72;
--header-h: 44px; --footer-h: 28px; --rail-w: 220px;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overscroll-behavior: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.spacer { flex: 1; }
.muted { color: var(--muted); }
.center { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
/* login */
.login { display: flex; flex-direction: column; gap: 10px; width: min(320px, 90vw); }
.login h1 { margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; }
.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
.login button, .primary { padding: 10px 12px; background: var(--accent); color: #111; border-radius: 6px; font-weight: 600; }
.login button:disabled { opacity: .5; }
.error { color: var(--bad); font-size: 13px; }
/* layout */
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
.header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); }
.brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; }
.stat { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); }
.link { color: var(--muted); } .link:hover { color: var(--fg); }
.icon-btn { padding: 2px 6px; color: var(--muted); border-radius: 4px; } .icon-btn:hover { color: var(--fg); background: var(--bg3); }
.menu { display: none; font-size: 18px; }
.body { flex: 1; display: flex; min-height: 0; position: relative; }
.footer { height: var(--footer-h); display: flex; align-items: center; gap: 16px; padding: 0 12px; background: var(--bg2); border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); padding-bottom: env(safe-area-inset-bottom); box-sizing: content-box; }
/* rail */
.rail { width: var(--rail-w); background: var(--bg2); border-right: 1px solid var(--border); overflow-y: auto; padding: 6px 0; flex-shrink: 0; }
.group-head { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-align: left; }
.group-head:hover { background: var(--bg3); }
.group.soon .group-head { color: var(--muted); cursor: default; }
.group.soon .group-head:hover { background: none; }
.chev { width: 10px; color: var(--muted); font-size: 11px; }
.gicon { width: 18px; text-align: center; color: var(--accent2); font-family: ui-monospace, monospace; font-size: 12px; }
.badge { margin-left: auto; font-size: 10px; text-transform: uppercase; border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; }
.leaves { padding: 2px 0 6px; }
.leaf { display: flex; align-items: center; gap: 8px; padding: 7px 12px 7px 30px; cursor: pointer; width: 100%; text-align: left; }
.leaf:hover { background: var(--bg3); }
.leaf.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
.leaf.exited .lname { color: var(--muted); text-decoration: line-through; }
.leaf.new { color: var(--accent2); }
.lname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.leaf-actions { display: flex; gap: 2px; }
.sdot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
.sdot.run { background: var(--ok); }
/* main + terminal */
.main { flex: 1; min-width: 0; position: relative; background: #0d1117; }
.term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
.term .xterm { height: 100%; }
.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); }
/* key bar (touch devices / narrow screens) */
.keybar { display: none; gap: 4px; padding: 4px 6px; background: var(--bg2); border-top: 1px solid var(--border); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; }
.keybar button { flex: 0 0 auto; padding: 8px 10px; min-width: 40px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-family: ui-monospace, monospace; font-size: 13px; }
.keybar button.on { background: var(--accent); color: #111; border-color: var(--accent); }
@media (pointer: coarse), (max-width: 800px) { .keybar { display: flex; } }
/* mobile: rail becomes a drawer */
.backdrop { display: none; }
@media (max-width: 800px) {
.menu { display: inline-block; }
.host, .stat { display: none; }
.rail { position: absolute; top: 0; bottom: 0; left: 0; z-index: 10; transform: translateX(-100%); transition: transform .15s ease; box-shadow: 4px 0 16px rgba(0,0,0,.5); }
.drawer-open .rail { transform: none; }
.drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); }
.footer { font-size: 11px; }
}