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:
co-authored by
Claude Fable 5.1
parent
190604247f
commit
436732dac0
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#111418" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Hamlet" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<title>Hamlet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1777
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "hamlet-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"icons": "node scripts/icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Hamlet",
|
||||
"short_name": "Hamlet",
|
||||
"description": "Linux management console",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#111418",
|
||||
"theme_color": "#111418",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Hamlet service worker: cache hashed assets, never the API, network-first for the shell.
|
||||
const CACHE = 'hamlet-v1';
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
||||
});
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url);
|
||||
if (e.request.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/')) return;
|
||||
if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/icons/')) {
|
||||
e.respondWith(caches.open(CACHE).then(async (c) => (await c.match(e.request)) || fetch(e.request).then((r) => { if (r.ok) c.put(e.request, r.clone()); return r; })));
|
||||
return;
|
||||
}
|
||||
// navigations + everything else: network first, fall back to cached index
|
||||
e.respondWith(fetch(e.request).then((r) => { if (r.ok && e.request.mode === 'navigate') caches.open(CACHE).then((c) => c.put('/', r.clone())); return r; })
|
||||
.catch(() => caches.match(e.request.mode === 'navigate' ? '/' : e.request)));
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Renders the PWA icons with node-canvas (borrowed from ../../calorie-counter/node_modules).
|
||||
import { createRequire } from 'node:module'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createCanvas } = require('/home/core/projects/calorie-counter/node_modules/canvas')
|
||||
|
||||
function draw(size, { pad = 0, radius = 0 }) {
|
||||
const c = createCanvas(size, size), g = c.getContext('2d')
|
||||
g.fillStyle = '#111418'
|
||||
if (radius) { g.beginPath(); g.roundRect(0, 0, size, size, radius); g.fill() } else g.fillRect(0, 0, size, size)
|
||||
const inner = size - pad * 2
|
||||
g.fillStyle = '#e3a33b'
|
||||
g.font = `bold ${inner * 0.62}px "DejaVu Serif", serif`
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle'
|
||||
g.fillText('H', size / 2, size / 2 + inner * 0.03)
|
||||
// prompt caret
|
||||
g.fillStyle = '#58a6ff'
|
||||
g.font = `bold ${inner * 0.22}px "DejaVu Sans Mono", monospace`
|
||||
g.fillText('>_', size / 2 + inner * 0.27, size / 2 + inner * 0.27)
|
||||
return c.toBuffer('image/png')
|
||||
}
|
||||
writeFileSync('public/icons/icon-192.png', draw(192, {}))
|
||||
writeFileSync('public/icons/icon-512.png', draw(512, {}))
|
||||
writeFileSync('public/icons/icon-512-maskable.png', draw(512, { pad: 80 }))
|
||||
writeFileSync('public/icons/apple-touch-icon.png', draw(180, {}))
|
||||
console.log('icons written')
|
||||
@@ -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)} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }} />
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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(() => {}) })
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: { outDir: '../internal/web/dist', emptyOutDir: true },
|
||||
server: {
|
||||
proxy: { '/api': { target: 'http://127.0.0.1:8100', ws: true } },
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user