Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
150 lines
6.7 KiB
TypeScript
150 lines
6.7 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 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 Power from './Power'
|
|
import { Btn, NetBar, 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: '◔', page: true },
|
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
|
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
|
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
|
|
{ id: 'updates', label: 'Updates', icon: '⇪', page: true },
|
|
{ id: 'disks', label: 'Disks', icon: '▤', page: true },
|
|
{ id: 'files', label: 'Files', icon: FolderIcon, page: true },
|
|
]
|
|
|
|
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files'
|
|
|
|
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 [view, setView] = useState<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 () => {
|
|
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() {
|
|
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' : '')}>
|
|
<NetBar />
|
|
<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>
|
|
<span className="spacer" />
|
|
<span className="stat" title="load average">{me.load}</span>
|
|
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
|
|
<Power host={me.host} rebootNeeded={me.reboot_needed} />
|
|
<Btn className="link" onClick={logout}>logout</Btn>
|
|
</header>
|
|
|
|
<div className="body">
|
|
<Rail groups={GROUPS} terms={terms} active={active} view={view} creating={creating}
|
|
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">
|
|
{view === 'dashboard' && <Dashboard />}
|
|
{view === 'services' && <Services />}
|
|
{view === 'cron' && <Cron />}
|
|
{view === 'updates' && <Updates />}
|
|
{view === 'disks' && <Disks />}
|
|
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
|
|
{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>{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' : activeTerm ? activeTerm.name : ''}</span>
|
|
</footer>
|
|
</div>
|
|
)
|
|
}
|