Feedback sweep: spinners on every async button, toasts for action outcomes, top activity bar, loading states on every page, terminal connecting overlay, dashboard save indicator, task pane pinned above the package list with auto-scroll and completion toast

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 18:04:45 -07:00
co-authored by Claude Fable 5.1
parent d08b246b5f
commit 724f30c02b
13 changed files with 261 additions and 93 deletions
+17 -15
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services'
import { Btn, Loading, Spinner, toast } from './ui'
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }
function rel(ms: number): string {
@@ -39,7 +40,7 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
async function save() {
if (text === null) return
setBusy(true); setErr('')
try { await api.cronWriteSource(name, text); onSaved(); onClose() }
try { await api.cronWriteSource(name, text); toast(`${srcLabel(name)} saved`); onSaved(); onClose() }
catch (e) { setErr(e instanceof Error ? e.message : 'save failed') }
finally { setBusy(false) }
}
@@ -48,10 +49,10 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
<button className="btn back" onClick={onClose}>‹ Back</button>
<h2>{srcLabel(name)}</h2>
{!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>}
{text === null ? <div className="muted">…</div> : <textarea className="editor" value={text} spellCheck={false} readOnly={!editable} onChange={(e) => setText(e.target.value)} />}
{text === null ? <Loading label="Loading crontab…" /> : <textarea className="editor" value={text} spellCheck={false} readOnly={!editable} onChange={(e) => setText(e.target.value)} />}
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button className="btn on" disabled={!editable || busy || text === null} onClick={save}>{busy ? '…' : 'Save'}</button>
<button className="btn on" disabled={!editable || busy || text === null} onClick={save}>{busy && <Spinner />}Save</button>
<button className="btn" onClick={onClose}>Cancel</button>
</div>
<div className="notice">Lines are validated before saving; user crontabs are installed with <code>crontab -</code> so cron picks them up immediately.</div>
@@ -77,7 +78,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
async function toggle() {
if (job.enabled && !window.confirm(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
setBusy('toggle'); setErr('')
try { await api.cronJobToggle(job.id, !job.enabled); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
try { await api.cronJobToggle(job.id, !job.enabled); toast(job.enabled ? 'job disabled' : 'job enabled'); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
}
async function runNow() {
if (!window.confirm(`Run now as ${job.user}?\n\n${job.command}`)) return
@@ -86,7 +87,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
const r = await api.cronJobRun(job.id)
setRun(r); setTab('run')
const tick = async () => {
try { const cur = await api.cronRun(r.id); setRun(cur); if (cur.running) poll.current = window.setTimeout(tick, 1000); else onChanged() }
try { const cur = await api.cronRun(r.id); setRun(cur); if (cur.running) poll.current = window.setTimeout(tick, 1000); else { toast(`run finished (exit ${cur.exit_code})`, cur.exit_code === 0 ? 'ok' : 'err'); onChanged() } }
catch { /* run expired */ }
}
poll.current = window.setTimeout(tick, 1000)
@@ -107,8 +108,8 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
</div>
{job.note && <div className="notice">{job.note}</div>}
<div className="svc-actions">
<button className="btn" disabled={!!busy} onClick={runNow}>{busy === 'run' ? '…' : 'Run now'}</button>
{job.line > 0 && <button className="btn" disabled={!!busy || !job.editable} onClick={toggle}>{job.enabled ? 'Disable' : 'Enable'}</button>}
<button className="btn" disabled={!!busy} onClick={runNow}>{busy === 'run' && <Spinner />}Run now</button>
{job.line > 0 && <button className="btn" disabled={!!busy || !job.editable} onClick={toggle}>{busy === 'toggle' && <Spinner />}{job.enabled ? 'Disable' : 'Enable'}</button>}
{job.line > 0 && <button className="btn" onClick={() => onEdit(job.source)}>Edit {srcLabel(job.source)}</button>}
</div>
{err && <div className="error">{err}</div>}
@@ -119,14 +120,14 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
</div>
{tab === 'logs' && (
<div className="logs"><div className="log-lines">
{logs === null ? <div className="muted">…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
{logs === null ? <div className="muted"><Spinner /> loading…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
: logs.map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
</div></div>
)}
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : '…'}</pre>}
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : <span className="muted"><Spinner /> reading…</span>}</pre>}
{tab === 'run' && run && (
<>
<div className="muted">{run.running ? 'running…' : `exited ${run.exit_code} · ${new Date(run.finished!).toLocaleTimeString()}`}</div>
<div className="muted">{run.running ? <><Spinner /> running…</> : `exited ${run.exit_code} · ${new Date(run.finished!).toLocaleTimeString()}`}</div>
<pre className="run-out">{run.output || (run.running ? '' : '(no output)')}</pre>
</>
)}
@@ -142,9 +143,9 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
if ((a === 'stop' || a === 'disable') && !window.confirm(`${a} ${t.unit}?`)) return
if (a === 'run' && !window.confirm(`Start ${t.activates} now?`)) return
setBusy(a); setErr('')
try { await api.cronTimerAction(t.unit, a); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
try { await api.cronTimerAction(t.unit, a); toast(`${t.unit}: ${a} ok`); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${t.unit}: ${a} failed`, 'err') } finally { setBusy('') }
}
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a ? '…' : label}</button>
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
return (
<div className="cron-detail">
<button className="btn back" onClick={onBack}>‹ Cron</button>
@@ -200,7 +201,7 @@ export default function Cron() {
<div className={'cron' + (sel || editing ? ' has-sel' : '')}>
<div className="cron-list">
<div className="svc-tools">
<input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
<div className="chips">
{sources.map((s) => <button key={s} className="chip" title="edit" onClick={() => { setSel(null); setEditing(s) }}>✎ {srcLabel(s)}</button>)}
</div>
@@ -208,7 +209,8 @@ export default function Cron() {
{err && <div className="error">{err}</div>}
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
<div className="cron-rows">
<h3>Cron jobs · {jobs.length}</h3>
{!ov && !err && <Loading label="Reading crontabs…" />}
{ov && <h3>Cron jobs · {jobs.length}</h3>}
{jobs.map((j) => (
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
@@ -218,7 +220,7 @@ export default function Cron() {
</div>
))}
{ov && jobs.length === 0 && <div className="muted" style={{ padding: 12 }}>No cron jobs.</div>}
<h3>systemd timers · {timers.length}</h3>
{ov && <h3>systemd timers · {timers.length}</h3>}
{timers.map((t) => (
<div key={t.unit} className={'cron-row' + (sel?.kind === 'timer' && sel.id === t.unit ? ' active' : '') + (t.active === 'active' ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'timer', id: t.unit }) }}>
<span className={'sdot ' + (t.active === 'active' ? 'ok' : '')} />
+15 -2
View File
@@ -1,6 +1,7 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline'
import { Loading, Spinner, toast } from './ui'
const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)'
@@ -261,6 +262,8 @@ export default function Dashboard() {
const [layout, setLayout] = useState<Layout>(() => normalize({}))
const [editing, setEditing] = useState(false)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('')
const savedTimer = useRef<number | undefined>(undefined)
const hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(undefined)
const intervalRef = useRef(DEFAULT_INTERVAL)
@@ -271,7 +274,15 @@ export default function Dashboard() {
setLayout((prev) => {
const next = fn(prev)
clearTimeout(saveTimer.current)
saveTimer.current = window.setTimeout(() => { api.saveDashPrefs(toPrefs(next)).catch(() => setErr('could not save layout')) }, 300)
setSaving('saving')
saveTimer.current = window.setTimeout(async () => {
try {
await api.saveDashPrefs(toPrefs(next))
setSaving('saved')
clearTimeout(savedTimer.current)
savedTimer.current = window.setTimeout(() => setSaving(''), 1500)
} catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}, 300)
return next
})
}, [])
@@ -317,7 +328,7 @@ export default function Dashboard() {
return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) }
}, [])
if (!latest) return <div className="dash"><div className="center muted">{err || '…'}</div></div>
if (!latest) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Collecting metrics…" />}</div>
const edit: Edit = {
on: editing,
@@ -369,6 +380,8 @@ export default function Dashboard() {
</label>
) : <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<span className="spacer" />
{saving === 'saving' && <span className="saved"><Spinner /> saving</span>}
{saving === 'saved' && <span className="saved">saved ✓</span>}
{editing && <button className="btn" onClick={reset}>Reset</button>}
<button className={'btn' + (editing ? ' on' : '')} disabled={!loaded} title={loaded ? '' : 'layout not loaded'} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
</div>
+4 -4
View File
@@ -1,5 +1,6 @@
import { FormEvent, useState } from 'react'
import { api } from './api'
import { Spinner } from './ui'
export default function Login({ onLogin }: { onLogin: () => void }) {
const [pw, setPw] = useState('')
@@ -10,8 +11,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
e.preventDefault()
setBusy(true); setErr('')
try { await api.login(pw); onLogin() }
catch (e) { setErr(e instanceof Error ? e.message : 'login failed') }
finally { setBusy(false) }
catch (e) { setErr(e instanceof Error ? e.message : 'login failed'); setBusy(false) }
}
return (
@@ -20,8 +20,8 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
<img className="login-logo" src="/icons/logo.png" alt="" width={160} height={160} />
<h1>Ham Lab</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>
value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
<button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
{err && <div className="error">{err}</div>}
</form>
</div>
+11 -10
View File
@@ -1,30 +1,31 @@
import { useState } from 'react'
import { api } from './api'
import { Btn, Spinner } from './ui'
// Header power control: a small button, a dialog that needs the hostname typed back.
export default function Power({ host, rebootNeeded }: { host: string; rebootNeeded?: boolean }) {
const [open, setOpen] = useState(false)
const [typed, setTyped] = useState('')
const [busy, setBusy] = useState('')
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
const [err, setErr] = useState('')
const ok = typed.trim() === host
async function go(action: 'reboot' | 'poweroff') {
setBusy(action); setErr('')
try { await api.power(action, typed.trim()); setBusy('sent') }
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy('') }
setErr('')
try { await api.power(action, typed.trim()); setSent(action) }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
return (
<>
<button className={'icon-btn power' + (rebootNeeded ? ' attn' : '')} title={rebootNeeded ? 'reboot required' : 'power'} onClick={() => { setOpen(true); setTyped(''); setBusy(''); setErr('') }}>⏻</button>
<button className={'icon-btn power' + (rebootNeeded ? ' attn' : '')} title={rebootNeeded ? 'reboot required' : 'power'} onClick={() => { setOpen(true); setTyped(''); setSent(''); setErr('') }}>⏻</button>
{open && (
<div className="modal-back" onClick={() => busy !== 'sent' && setOpen(false)}>
<div className="modal-back" onClick={() => !sent && setOpen(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Power · {host}</h3>
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
{busy === 'sent' ? (
<div className="muted">Command sent. The console will reconnect when the host is back.</div>
{sent ? (
<div className="muted"><Spinner /> {sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}</div>
) : (
<>
<label className="muted">Type the hostname to confirm
@@ -32,8 +33,8 @@ export default function Power({ host, rebootNeeded }: { host: string; rebootNeed
</label>
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button className="btn on" disabled={!ok || !!busy} onClick={() => go('reboot')}>{busy === 'reboot' ? '…' : 'Reboot'}</button>
<button className="btn danger" disabled={!ok || !!busy} onClick={() => go('poweroff')}>{busy === 'poweroff' ? '…' : 'Shut down'}</button>
<Btn className="btn on" disabled={!ok} onClick={() => go('reboot')}>Reboot</Btn>
<Btn className="btn danger" disabled={!ok} onClick={() => go('poweroff')}>Shut down</Btn>
<span className="spacer" />
<button className="btn" onClick={() => setOpen(false)}>Cancel</button>
</div>
+4 -3
View File
@@ -1,17 +1,18 @@
import { useState } from 'react'
import { Term } from './api'
import { Spinner } from './ui'
// A group is either a page (clicking the head opens it — Dashboard) or a folder of
// leaves (Terminal). `soon` groups are inert placeholders for the roadmap.
export interface Group { id: string; label: string; icon: string; soon?: boolean; page?: boolean }
interface Props {
groups: Group[]; terms: Term[]; active: string | null; view: string
groups: Group[]; terms: Term[]; active: string | null; view: string; creating?: boolean
onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void
onRename: (t: Term) => void; onClose: (t: Term) => void
}
export default function Rail({ groups, terms, active, view, onSelect, onView, onNew, onRename, onClose }: Props) {
export default function Rail({ groups, terms, active, view, creating, onSelect, onView, onNew, onRename, onClose }: Props) {
const [open, setOpen] = useState<string>('terminal')
return (
<nav className="rail">
@@ -45,7 +46,7 @@ export default function Rail({ groups, terms, active, view, onSelect, onView, on
</div>
)
})}
<button className="leaf new" onClick={onNew}>+ New Terminal</button>
<button className="leaf new" disabled={creating} onClick={onNew}>{creating ? <><Spinner /> creating…</> : '+ New Terminal'}</button>
</div>
)}
</div>
+18 -11
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
import { Btn, Loading, Spinner, toast } from './ui'
type Filter = 'all' | 'running' | 'failed' | 'enabled'
@@ -37,22 +38,25 @@ export function Logs({ unit }: { unit: string }) {
const [live, setLive] = useState(true)
const [paused, setPaused] = useState(false)
const [err, setErr] = useState('')
const [ready, setReady] = useState(false)
const box = useRef<HTMLDivElement>(null)
const pausedRef = useRef(false)
pausedRef.current = paused
const pending = useRef<LogEntry[]>([])
useEffect(() => {
setLines([]); setErr(''); pending.current = []
setLines([]); setErr(''); setReady(false); pending.current = []
let closed = false
let readyTimer: number | undefined
let sock: WebSocket | null = null
let retry: number | undefined
const connect = () => {
if (closed) return
const s = new WebSocket(wsURLPath(`/api/services/${encodeURIComponent(unit)}/logs/tail?n=200`))
sock = s
s.onopen = () => { setLive(true); setErr('') }
s.onopen = () => { setLive(true); setErr(''); readyTimer = window.setTimeout(() => setReady(true), 1500) }
s.onmessage = (ev) => {
setReady(true)
const e = JSON.parse(ev.data) as LogEntry
if (pausedRef.current) { pending.current.push(e); return }
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), e] : [...prev, e]))
@@ -61,7 +65,7 @@ export function Logs({ unit }: { unit: string }) {
s.onerror = () => { setErr('log stream disconnected'); s.close() }
}
connect()
return () => { closed = true; clearTimeout(retry); sock?.close() }
return () => { closed = true; clearTimeout(retry); clearTimeout(readyTimer); sock?.close() }
}, [unit])
// Resume: flush what arrived while paused.
@@ -82,7 +86,7 @@ export function Logs({ unit }: { unit: string }) {
<button className="btn" onClick={() => setLines([])}>Clear</button>
</div>
<div className="log-lines" ref={box}>
{lines.length === 0 && <div className="muted">No journal entries.</div>}
{lines.length === 0 && (ready ? <div className="muted">No journal entries.</div> : <div className="muted"><Spinner /> loading journal…</div>)}
{lines.map((l, i) => (
<div key={i} className={'log' + (l.p <= 3 ? ' err' : l.p === 4 ? ' warn' : l.p >= 7 ? ' debug' : '')}>
<span className="lt">{fmtTime(l.t)}</span>
@@ -114,16 +118,16 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
if (confirmFor[action] && !window.confirm(`${confirmFor[action]} ${name}?`)) return
setBusy(action); setErr('')
try { await api.serviceAction(name, action); await load(); onChanged() }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
try { await api.serviceAction(name, action); await load(); onChanged(); toast(`${name}: ${action} ok`) }
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') }
finally { setBusy('') }
}
if (!d) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Services</button><div className="muted">{err || '…'}</div></div>
if (!d) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Services</button>{err ? <div className="error">{err}</div> : <Loading label="Loading service…" />}</div>
const p = d.props
const running = d.active === 'active'
const enabled = d.enabled === 'enabled'
const Btn = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a ? '…' : label}</button>
const Btn = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
return (
<div className="svc-detail">
@@ -159,7 +163,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
<button className={tab === 'file' ? 'on' : ''} onClick={() => setTab('file')}>Unit file</button>
</div>
{tab === 'logs' ? <Logs unit={name} /> : <pre className="unit-file">{file || '…'}</pre>}
{tab === 'logs' ? <Logs unit={name} /> : <pre className="unit-file">{file || <span className="muted"><Spinner /> loading…</span>}</pre>}
</div>
)
}
@@ -171,9 +175,11 @@ export default function Services() {
const [q, setQ] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [sel, setSel] = useState<string | null>(null)
const [loaded, setLoaded] = useState(false)
const load = useCallback(async () => {
try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
finally { setLoaded(true) }
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 10000); return () => clearInterval(t) }, [load])
@@ -195,7 +201,7 @@ export default function Services() {
<div className={'svc' + (sel ? ' has-sel' : '')}>
<div className="svc-list">
<div className="svc-tools">
<input placeholder="filter services" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="tools-row"><input placeholder="filter services" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
<div className="chips">
{([['all', `All ${units.length}`], ['running', `Running ${counts.running}`], ['failed', `Failed ${counts.failed}`], ['enabled', `Enabled ${counts.enabled}`]] as [Filter, string][]).map(([f, label]) => (
<button key={f} className={'chip' + (filter === f ? ' on' : '') + (f === 'failed' && counts.failed ? ' bad' : '')} onClick={() => setFilter(f)}>{label}</button>
@@ -204,6 +210,7 @@ export default function Services() {
</div>
{err && <div className="error">{err}</div>}
<div className="svc-rows">
{!loaded && <Loading label="Listing services…" />}
{shown.map((u) => (
<div key={u.name} className={'svc-row' + (u.name === sel ? ' active' : '')} onClick={() => setSel(u.name)}>
<span className={'sdot ' + stateClass(u)} />
@@ -214,7 +221,7 @@ export default function Services() {
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
</div>
))}
{shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
{loaded && shown.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
</div>
</div>
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</div>}
+21 -12
View File
@@ -8,6 +8,7 @@ import Services from './Services'
import Cron from './Cron'
import Updates from './Updates'
import Power from './Power'
import { Btn, NetBar, Toasts, toast } from './ui'
const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
@@ -31,6 +32,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
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 () => {
@@ -42,21 +44,26 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
useEffect(() => { reload() }, [reload])
async function create() {
const t = await api.createTerminal()
await reload()
setActive(t.id)
setView('terminal')
setDrawer(false)
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) { await api.renameTerminal(t.id, name.trim()); await reload() }
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
await api.deleteTerminal(t.id)
delete handles.current[t.id]
await reload()
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() }
@@ -65,6 +72,8 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
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>
@@ -73,11 +82,11 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<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} />
<button className="link" onClick={logout}>logout</button>
<Btn className="link" onClick={logout}>logout</Btn>
</header>
<div className="body">
<Rail groups={GROUPS} terms={terms} active={active} view={view}
<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} />
@@ -91,7 +100,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
{termView && terms.length === 0 && (
<div className="center muted">
<div>No terminals.</div>
<button className="primary" onClick={create}>+ New Terminal</button>
<Btn className="primary" busy={creating} onClick={create}>+ New Terminal</Btn>
</div>
)}
{terms.map((t) => (
+12 -8
View File
@@ -1,21 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { api, TaskInfo, wsURLPath } from './api'
import { Btn, Spinner } from './ui'
// Follows one background task: backlog, then live chunks over a WebSocket, until it exits.
export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: TaskInfo) => void }) {
const [task, setTask] = useState<TaskInfo | null>(null)
const [text, setText] = useState('')
const [connected, setConnected] = useState(false)
const box = useRef<HTMLPreElement>(null)
const doneRef = useRef(onDone)
doneRef.current = onDone
useEffect(() => {
setText(''); setTask(null)
let closed = false
setText(''); setTask(null); setConnected(false)
api.task(id).then(setTask).catch(() => { /* shown as unknown */ })
const s = new WebSocket(wsURLPath(`/api/tasks/${id}/stream`))
s.binaryType = 'arraybuffer'
const dec = new TextDecoder()
s.onopen = () => setConnected(true)
s.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { setText((t) => t + dec.decode(ev.data, { stream: true })); return }
try {
@@ -23,22 +25,24 @@ export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: Ta
if (m.type === 'exit') api.task(id).then((t) => { setTask(t); doneRef.current?.(t) }).catch(() => { /* ignore */ })
} catch { /* ignore */ }
}
return () => { closed = true; s.close(); void closed }
return () => s.close()
}, [id])
useEffect(() => { if (box.current) box.current.scrollTop = box.current.scrollHeight }, [text])
const status = !task ? '' : task.running ? 'running…' : task.error ? `failed: ${task.error}` : task.exit_code === 0 ? 'done' : `exited ${task.exit_code}`
const running = !task || task.running
const failed = !!task && !task.running && (task.exit_code !== 0 || !!task.error)
const status = !task ? 'starting…' : task.running ? 'running…' : task.error ? `failed: ${task.error}` : task.exit_code === 0 ? 'finished' : `exited ${task.exit_code}`
return (
<div className="task">
<div className={'task' + (running ? ' running' : failed ? ' failed' : ' done')}>
<div className="logs-bar">
<span className={'dot ' + (task?.running ? 'warn' : task && (task.exit_code !== 0 || task.error) ? 'bad' : 'ok')} />
{running ? <Spinner /> : <span className={'dot ' + (failed ? 'bad' : 'ok')} />}
<span>{task?.title ?? '…'}</span>
<span className="muted">· {status}</span>
<span className="spacer" />
{task?.running && <button className="btn" onClick={() => api.cancelTask(id)}>Cancel</button>}
{task?.running && <Btn className="btn" onClick={() => api.cancelTask(id)}>Cancel</Btn>}
</div>
<pre className="run-out task-out" ref={box}>{text || (task?.running ? '' : '(no output)')}</pre>
<pre className="run-out task-out" ref={box}>{text || (running ? (connected ? 'waiting for output…' : 'connecting…') : '(no output)')}</pre>
</div>
)
}
+14 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { wsURL } from './api'
import { Spinner } from './ui'
export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void }
@@ -28,6 +29,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
const cbs = useRef({ onStatus, onExit })
cbs.current = { onStatus, onExit }
const fitRef = useRef<() => void>(() => {})
const [conn, setConn] = useState<'connecting' | 'open' | 'reconnecting' | 'exited'>('connecting')
useEffect(() => {
const el = box.current!
@@ -67,12 +69,12 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
const sock = new WebSocket(wsURL(id))
sock.binaryType = 'arraybuffer'
ws = sock
sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() }
sock.onopen = () => { retry = 0; setConn('open'); 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 === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; setConn('exited'); cbs.current.onExit() }
if (m.type === 'closed') closed = true
} catch { /* ignore */ }
}
@@ -80,6 +82,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
if (ws !== sock) return
cbs.current.onStatus(false)
if (closed) return
setConn('reconnecting')
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
}
sock.onerror = () => sock.close()
@@ -124,5 +127,12 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
return () => clearTimeout(t)
}, [active])
return <div ref={box} className="term" style={{ display: active ? 'block' : 'none' }} />
return (
<div className="term" style={{ display: active ? 'block' : 'none' }}>
<div ref={box} className="term-inner" />
{(conn === 'connecting' || conn === 'reconnecting') && (
<div className="term-overlay"><Spinner /> {conn === 'connecting' ? 'connecting…' : 'reconnecting…'}</div>
)}
</div>
)
}
+33 -18
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { api, UpdatePackage, UpdatesStatus } from './api'
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, TaskInfo, UpdatePackage, UpdatesStatus } from './api'
import TaskOutput from './TaskOutput'
import { Btn, Loading, Spinner, toast } from './ui'
function ago(ms?: number): string {
if (!ms) return 'never'
@@ -12,39 +13,52 @@ function ago(ms?: number): string {
export default function Updates() {
const [st, setSt] = useState<UpdatesStatus | null>(null)
const [err, setErr] = useState('')
const [reloading, setReloading] = useState(false)
const [sel, setSel] = useState<Set<string>>(new Set())
const [task, setTask] = useState<string | null>(null)
const [taskRunning, setTaskRunning] = useState(false)
const [open, setOpen] = useState<string | null>(null)
const [changelog, setChangelog] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false)
const taskBox = useRef<HTMLDivElement>(null)
const load = useCallback(async () => {
setReloading(true)
try {
const s = await api.updates()
setSt(s); setErr('')
if (s.running_task) setTask(s.running_task)
if (s.running_task) { setTask(s.running_task); setTaskRunning(true) }
setSel((prev) => new Set([...prev].filter((n) => s.packages.some((p) => p.name === n))))
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
finally { setReloading(false) }
}, [])
useEffect(() => { load() }, [load])
async function start(fn: () => Promise<{ id: string }>) {
setBusy(true); setErr('')
try { const t = await fn(); setTask(t.id) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy(false) }
async function start(fn: () => Promise<TaskInfo>) {
setErr('')
try {
const t = await fn()
setTask(t.id); setTaskRunning(true)
window.setTimeout(() => taskBox.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50)
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
function done(t: TaskInfo) {
setTaskRunning(false)
const ok = t.exit_code === 0 && !t.error
toast(`${t.title}: ${ok ? 'finished' : t.error || `exited ${t.exit_code}`}`, ok ? 'ok' : 'err')
load()
}
async function showChangelog(p: UpdatePackage) {
if (open === p.name) { setOpen(null); return }
setOpen(p.name)
if (!changelog[p.name]) {
try { setChangelog((c) => ({ ...c, [p.name]: '…' })); const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
catch (e) { setChangelog((c) => ({ ...c, [p.name]: String(e) })) }
try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
catch (e) { setChangelog((c) => ({ ...c, [p.name]: e instanceof Error ? e.message : 'unavailable' })) }
}
}
const toggle = (n: string) => setSel((s) => { const x = new Set(s); if (x.has(n)) x.delete(n); else x.add(n); return x })
if (!st) return <div className="dash"><div className="center muted">{err || '…'}</div></div>
const running = !!st.running_task || busy
const canAct = st.can_upgrade && !running
if (!st) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Checking packages…" />}</div>
const canAct = st.can_upgrade && !taskRunning
return (
<div className="dash updates">
@@ -66,13 +80,16 @@ export default function Updates() {
</div>
<div className="svc-actions">
<button className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</button>
<button className="btn on" disabled={!canAct || st.packages.length === 0} onClick={() => window.confirm(`Upgrade all ${st.packages.length} packages?`) && start(() => api.upgrade([]))}>Upgrade all</button>
<button className="btn" disabled={!canAct || sel.size === 0} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</button>
<Btn className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</Btn>
<Btn className="btn on" disabled={!canAct || st.packages.length === 0} confirm={`Upgrade all ${st.packages.length} packages?`} onClick={() => start(() => api.upgrade([]))}>Upgrade all</Btn>
<Btn className="btn" disabled={!canAct || sel.size === 0} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</Btn>
<span className="spacer" />
{st.packages.length > 0 && <button className="btn" onClick={() => setSel(sel.size === st.packages.length ? new Set() : new Set(st.packages.map((p) => p.name)))}>{sel.size === st.packages.length ? 'None' : 'All'}</button>}
<button className="btn" title="reload" disabled={reloading} onClick={load}>{reloading ? <Spinner /> : '↻'}</button>
</div>
<div ref={taskBox}>{task && <TaskOutput id={task} onDone={done} />}</div>
<div className="card pkgs">
{st.packages.length === 0 && <div className="empty">Everything is up to date.</div>}
{st.packages.map((p) => (
@@ -85,12 +102,10 @@ export default function Updates() {
</span>
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span>
</div>
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? '…'}</pre>}
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
</div>
))}
</div>
{task && <TaskOutput id={task} onDone={() => load()} />}
</div>
)
}
+20 -6
View File
@@ -1,12 +1,26 @@
export class Unauthenticated extends Error {}
let inflight = 0
function track(delta: number) {
inflight += delta
window.dispatchEvent(new CustomEvent('hamlab:inflight', { detail: inflight }))
}
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,
})
track(1)
let r: Response
try {
r = await fetch(path, {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
} catch (e) {
track(-1)
throw new Error('network error')
}
track(-1)
if (r.status === 401) throw new Unauthenticated()
if (!r.ok) {
let msg = r.statusText
+22
View File
@@ -59,6 +59,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
/* main + terminal */
.main { flex: 1; min-width: 0; position: relative; background: #0d1117; }
.term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
.term-inner { height: 100%; }
.term .xterm { height: 100%; }
.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); }
@@ -207,3 +208,24 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.modal label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
.modal input { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
.btn.danger { color: var(--bad); border-color: var(--bad); } .btn.danger:disabled { opacity: .4; }
/* feedback: spinners, buttons, toasts, activity bar */
.spin { display: inline-block; border: 2px solid rgba(128,128,128,.35); border-top-color: currentColor; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: -2px; margin-right: 6px; flex-shrink: 0; box-sizing: border-box; }
@keyframes spin { to { transform: rotate(360deg); } }
.btn, .primary, .login button { display: inline-flex; align-items: center; justify-content: center; }
.btn.busy, .primary.busy { opacity: .85; cursor: progress; }
.loading { gap: 10px; padding: 24px; height: auto; min-height: 120px; }
.loading .spin { margin: 0; }
.tools-row { display: flex; gap: 6px; } .tools-row input { flex: 1; min-width: 0; }
.toasts { position: fixed; left: 50%; bottom: calc(var(--footer-h) + 16px + env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 60; display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; }
.toast { pointer-events: auto; padding: 8px 14px; border-radius: 8px; background: var(--bg3); border: 1px solid var(--border); font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,.45); max-width: 90vw; cursor: pointer; animation: toast-in .15s ease-out; }
.toast.ok { border-color: var(--ok); } .toast.err { border-color: var(--bad); color: var(--bad); }
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.netbar { position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 70; background: var(--accent2); opacity: 0; transition: opacity .2s .3s; pointer-events: none; }
.netbar.on { opacity: 1; animation: netbar 1.1s ease-in-out infinite; }
@keyframes netbar { 0% { transform: scaleX(0); transform-origin: left; } 50% { transform: scaleX(1); transform-origin: left; } 51% { transform-origin: right; } 100% { transform: scaleX(0); transform-origin: right; } }
.term-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(13,17,23,.55); color: var(--muted); z-index: 2; gap: 4px; font-size: 13px; }
.saved { font-size: 11px; color: var(--muted); display: inline-flex; align-items: center; }
.task.running .logs-bar { background: rgba(57,135,229,.12); }
.task.failed .logs-bar { background: rgba(255,123,114,.12); }
.task.done .logs-bar { background: rgba(63,185,80,.10); }
+70
View File
@@ -0,0 +1,70 @@
import { ReactNode, useEffect, useState } from 'react'
// ---- spinners ----
export function Spinner({ size = 14 }: { size?: number }) {
return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" />
}
export function Loading({ label = 'Loading…' }: { label?: string }) {
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
}
// Button that shows a spinner while its async handler runs; `confirm` asks first.
export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title, children }: {
onClick: () => unknown | Promise<unknown>; busy?: boolean; confirm?: string; className?: string; disabled?: boolean; title?: string; children: ReactNode
}) {
const [inner, setInner] = useState(false)
const b = !!busy || inner
return (
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
onClick={async () => {
if (confirm && !window.confirm(confirm)) return
const r = onClick()
if (r && typeof (r as Promise<unknown>).then === 'function') {
setInner(true)
try { await r } finally { setInner(false) }
}
}}>
{b && <Spinner />}{children}
</button>
)
}
// ---- toasts: short feedback for actions ----
interface Toast { id: number; kind: 'ok' | 'err' | 'info'; msg: string }
const listeners = new Set<(t: Toast[]) => void>()
let toasts: Toast[] = []
let seq = 0
function emit() { listeners.forEach((l) => l(toasts)) }
export function dismissToast(id: number) { toasts = toasts.filter((t) => t.id !== id); emit() }
export function toast(msg: string, kind: Toast['kind'] = 'ok') {
const t = { id: ++seq, kind, msg }
toasts = [...toasts.slice(-4), t]
emit()
window.setTimeout(() => dismissToast(t.id), kind === 'err' ? 8000 : 3500)
}
export function Toasts() {
const [list, setList] = useState(toasts)
useEffect(() => { listeners.add(setList); return () => { listeners.delete(setList) } }, [])
return (
<div className="toasts">
{list.map((t) => <div key={t.id} className={'toast ' + t.kind} onClick={() => dismissToast(t.id)}>{t.msg}</div>)}
</div>
)
}
// act runs an action and toasts its outcome. Returns whether it succeeded.
export async function act(label: string, fn: () => Promise<unknown>): Promise<boolean> {
try { await fn(); toast(label); return true }
catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
}
// ---- thin activity bar at the top while any request is in flight ----
export function NetBar() {
const [n, setN] = useState(0)
useEffect(() => {
const h = (e: Event) => setN((e as CustomEvent<number>).detail)
window.addEventListener('hamlab:inflight', h)
return () => window.removeEventListener('hamlab:inflight', h)
}, [])
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
}