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 { useCallback, useEffect, useRef, useState } from 'react'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api' import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services' 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' }) } 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 { function rel(ms: number): string {
@@ -39,7 +40,7 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
async function save() { async function save() {
if (text === null) return if (text === null) return
setBusy(true); setErr('') 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') } catch (e) { setErr(e instanceof Error ? e.message : 'save failed') }
finally { setBusy(false) } finally { setBusy(false) }
} }
@@ -48,10 +49,10 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
<button className="btn back" onClick={onClose}>‹ Back</button> <button className="btn back" onClick={onClose}>‹ Back</button>
<h2>{srcLabel(name)}</h2> <h2>{srcLabel(name)}</h2>
{!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>} {!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>} {err && <div className="error">{err}</div>}
<div className="svc-actions"> <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> <button className="btn" onClick={onClose}>Cancel</button>
</div> </div>
<div className="notice">Lines are validated before saving; user crontabs are installed with <code>crontab -</code> so cron picks them up immediately.</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() { async function toggle() {
if (job.enabled && !window.confirm(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return if (job.enabled && !window.confirm(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
setBusy('toggle'); setErr('') 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() { async function runNow() {
if (!window.confirm(`Run now as ${job.user}?\n\n${job.command}`)) return 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) const r = await api.cronJobRun(job.id)
setRun(r); setTab('run') setRun(r); setTab('run')
const tick = async () => { 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 */ } catch { /* run expired */ }
} }
poll.current = window.setTimeout(tick, 1000) poll.current = window.setTimeout(tick, 1000)
@@ -107,8 +108,8 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
</div> </div>
{job.note && <div className="notice">{job.note}</div>} {job.note && <div className="notice">{job.note}</div>}
<div className="svc-actions"> <div className="svc-actions">
<button className="btn" disabled={!!busy} onClick={runNow}>{busy === 'run' ? '…' : 'Run now'}</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}>{job.enabled ? 'Disable' : 'Enable'}</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>} {job.line > 0 && <button className="btn" onClick={() => onEdit(job.source)}>Edit {srcLabel(job.source)}</button>}
</div> </div>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
@@ -119,14 +120,14 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
</div> </div>
{tab === 'logs' && ( {tab === 'logs' && (
<div className="logs"><div className="log-lines"> <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>)} : 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> </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 && ( {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> <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 === 'stop' || a === 'disable') && !window.confirm(`${a} ${t.unit}?`)) return
if (a === 'run' && !window.confirm(`Start ${t.activates} now?`)) return if (a === 'run' && !window.confirm(`Start ${t.activates} now?`)) return
setBusy(a); setErr('') 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 ( return (
<div className="cron-detail"> <div className="cron-detail">
<button className="btn back" onClick={onBack}>‹ Cron</button> <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' + (sel || editing ? ' has-sel' : '')}>
<div className="cron-list"> <div className="cron-list">
<div className="svc-tools"> <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"> <div className="chips">
{sources.map((s) => <button key={s} className="chip" title="edit" onClick={() => { setSel(null); setEditing(s) }}>✎ {srcLabel(s)}</button>)} {sources.map((s) => <button key={s} className="chip" title="edit" onClick={() => { setSel(null); setEditing(s) }}>✎ {srcLabel(s)}</button>)}
</div> </div>
@@ -208,7 +209,8 @@ export default function Cron() {
{err && <div className="error">{err}</div>} {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>} {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"> <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) => ( {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 }) }}> <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' : '')} /> <span className={'sdot ' + (j.enabled ? 'ok' : '')} />
@@ -218,7 +220,7 @@ export default function Cron() {
</div> </div>
))} ))}
{ov && jobs.length === 0 && <div className="muted" style={{ padding: 12 }}>No cron jobs.</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) => ( {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 }) }}> <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' : '')} /> <span className={'sdot ' + (t.active === 'active' ? 'ok' : '')} />
+15 -2
View File
@@ -1,6 +1,7 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api' import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline' import Sparkline from './Sparkline'
import { Loading, Spinner, toast } from './ui'
const MAX = 300 const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)' const S1 = 'var(--s1)', S2 = 'var(--s2)'
@@ -261,6 +262,8 @@ export default function Dashboard() {
const [layout, setLayout] = useState<Layout>(() => normalize({})) const [layout, setLayout] = useState<Layout>(() => normalize({}))
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [loaded, setLoaded] = 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 hist = useRef<Series | null>(null)
const saveTimer = useRef<number | undefined>(undefined) const saveTimer = useRef<number | undefined>(undefined)
const intervalRef = useRef(DEFAULT_INTERVAL) const intervalRef = useRef(DEFAULT_INTERVAL)
@@ -271,7 +274,15 @@ export default function Dashboard() {
setLayout((prev) => { setLayout((prev) => {
const next = fn(prev) const next = fn(prev)
clearTimeout(saveTimer.current) 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 return next
}) })
}, []) }, [])
@@ -317,7 +328,7 @@ export default function Dashboard() {
return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) } 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 = { const edit: Edit = {
on: editing, on: editing,
@@ -369,6 +380,8 @@ export default function Dashboard() {
</label> </label>
) : <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>} ) : <span className="hint">last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}</span>}
<span className="spacer" /> <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>} {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> <button className={'btn' + (editing ? ' on' : '')} disabled={!loaded} title={loaded ? '' : 'layout not loaded'} onClick={() => setEditing(!editing)}>{editing ? 'Done' : 'Customize'}</button>
</div> </div>
+4 -4
View File
@@ -1,5 +1,6 @@
import { FormEvent, useState } from 'react' import { FormEvent, useState } from 'react'
import { api } from './api' import { api } from './api'
import { Spinner } from './ui'
export default function Login({ onLogin }: { onLogin: () => void }) { export default function Login({ onLogin }: { onLogin: () => void }) {
const [pw, setPw] = useState('') const [pw, setPw] = useState('')
@@ -10,8 +11,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
e.preventDefault() e.preventDefault()
setBusy(true); setErr('') setBusy(true); setErr('')
try { await api.login(pw); onLogin() } try { await api.login(pw); onLogin() }
catch (e) { setErr(e instanceof Error ? e.message : 'login failed') } catch (e) { setErr(e instanceof Error ? e.message : 'login failed'); setBusy(false) }
finally { setBusy(false) }
} }
return ( 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} /> <img className="login-logo" src="/icons/logo.png" alt="" width={160} height={160} />
<h1>Ham Lab</h1> <h1>Ham Lab</h1>
<input type="password" autoFocus autoComplete="current-password" placeholder="password" <input type="password" autoFocus autoComplete="current-password" placeholder="password"
value={pw} onChange={(e) => setPw(e.target.value)} /> value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
<button disabled={busy || !pw}>{busy ? '…' : 'Sign in'}</button> <button disabled={busy || !pw}>{busy && <Spinner />}{busy ? 'Signing in…' : 'Sign in'}</button>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
</form> </form>
</div> </div>
+11 -10
View File
@@ -1,30 +1,31 @@
import { useState } from 'react' import { useState } from 'react'
import { api } from './api' import { api } from './api'
import { Btn, Spinner } from './ui'
// Header power control: a small button, a dialog that needs the hostname typed back. // Header power control: a small button, a dialog that needs the hostname typed back.
export default function Power({ host, rebootNeeded }: { host: string; rebootNeeded?: boolean }) { export default function Power({ host, rebootNeeded }: { host: string; rebootNeeded?: boolean }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [typed, setTyped] = useState('') const [typed, setTyped] = useState('')
const [busy, setBusy] = useState('') const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const ok = typed.trim() === host const ok = typed.trim() === host
async function go(action: 'reboot' | 'poweroff') { async function go(action: 'reboot' | 'poweroff') {
setBusy(action); setErr('') setErr('')
try { await api.power(action, typed.trim()); setBusy('sent') } try { await api.power(action, typed.trim()); setSent(action) }
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
} }
return ( 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 && ( {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()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Power · {host}</h3> <h3>Power · {host}</h3>
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>} {rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
{busy === 'sent' ? ( {sent ? (
<div className="muted">Command sent. The console will reconnect when the host is back.</div> <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 <label className="muted">Type the hostname to confirm
@@ -32,8 +33,8 @@ export default function Power({ host, rebootNeeded }: { host: string; rebootNeed
</label> </label>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
<div className="svc-actions"> <div className="svc-actions">
<button className="btn on" disabled={!ok || !!busy} onClick={() => go('reboot')}>{busy === 'reboot' ? '…' : 'Reboot'}</button> <Btn className="btn on" disabled={!ok} onClick={() => go('reboot')}>Reboot</Btn>
<button className="btn danger" disabled={!ok || !!busy} onClick={() => go('poweroff')}>{busy === 'poweroff' ? '…' : 'Shut down'}</button> <Btn className="btn danger" disabled={!ok} onClick={() => go('poweroff')}>Shut down</Btn>
<span className="spacer" /> <span className="spacer" />
<button className="btn" onClick={() => setOpen(false)}>Cancel</button> <button className="btn" onClick={() => setOpen(false)}>Cancel</button>
</div> </div>
+4 -3
View File
@@ -1,17 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { Term } from './api' import { Term } from './api'
import { Spinner } from './ui'
// A group is either a page (clicking the head opens it — Dashboard) or a folder of // 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. // leaves (Terminal). `soon` groups are inert placeholders for the roadmap.
export interface Group { id: string; label: string; icon: string; soon?: boolean; page?: boolean } export interface Group { id: string; label: string; icon: string; soon?: boolean; page?: boolean }
interface Props { 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 onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void
onRename: (t: Term) => void; onClose: (t: Term) => 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') const [open, setOpen] = useState<string>('terminal')
return ( return (
<nav className="rail"> <nav className="rail">
@@ -45,7 +46,7 @@ export default function Rail({ groups, terms, active, view, onSelect, onView, on
</div> </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>
)} )}
</div> </div>
+18 -11
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api' import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
import { Btn, Loading, Spinner, toast } from './ui'
type Filter = 'all' | 'running' | 'failed' | 'enabled' type Filter = 'all' | 'running' | 'failed' | 'enabled'
@@ -37,22 +38,25 @@ export function Logs({ unit }: { unit: string }) {
const [live, setLive] = useState(true) const [live, setLive] = useState(true)
const [paused, setPaused] = useState(false) const [paused, setPaused] = useState(false)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [ready, setReady] = useState(false)
const box = useRef<HTMLDivElement>(null) const box = useRef<HTMLDivElement>(null)
const pausedRef = useRef(false) const pausedRef = useRef(false)
pausedRef.current = paused pausedRef.current = paused
const pending = useRef<LogEntry[]>([]) const pending = useRef<LogEntry[]>([])
useEffect(() => { useEffect(() => {
setLines([]); setErr(''); pending.current = [] setLines([]); setErr(''); setReady(false); pending.current = []
let closed = false let closed = false
let readyTimer: number | undefined
let sock: WebSocket | null = null let sock: WebSocket | null = null
let retry: number | undefined let retry: number | undefined
const connect = () => { const connect = () => {
if (closed) return if (closed) return
const s = new WebSocket(wsURLPath(`/api/services/${encodeURIComponent(unit)}/logs/tail?n=200`)) const s = new WebSocket(wsURLPath(`/api/services/${encodeURIComponent(unit)}/logs/tail?n=200`))
sock = s sock = s
s.onopen = () => { setLive(true); setErr('') } s.onopen = () => { setLive(true); setErr(''); readyTimer = window.setTimeout(() => setReady(true), 1500) }
s.onmessage = (ev) => { s.onmessage = (ev) => {
setReady(true)
const e = JSON.parse(ev.data) as LogEntry const e = JSON.parse(ev.data) as LogEntry
if (pausedRef.current) { pending.current.push(e); return } if (pausedRef.current) { pending.current.push(e); return }
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), e] : [...prev, e])) 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() } s.onerror = () => { setErr('log stream disconnected'); s.close() }
} }
connect() connect()
return () => { closed = true; clearTimeout(retry); sock?.close() } return () => { closed = true; clearTimeout(retry); clearTimeout(readyTimer); sock?.close() }
}, [unit]) }, [unit])
// Resume: flush what arrived while paused. // Resume: flush what arrived while paused.
@@ -82,7 +86,7 @@ export function Logs({ unit }: { unit: string }) {
<button className="btn" onClick={() => setLines([])}>Clear</button> <button className="btn" onClick={() => setLines([])}>Clear</button>
</div> </div>
<div className="log-lines" ref={box}> <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) => ( {lines.map((l, i) => (
<div key={i} className={'log' + (l.p <= 3 ? ' err' : l.p === 4 ? ' warn' : l.p >= 7 ? ' debug' : '')}> <div key={i} className={'log' + (l.p <= 3 ? ' err' : l.p === 4 ? ' warn' : l.p >= 7 ? ' debug' : '')}>
<span className="lt">{fmtTime(l.t)}</span> <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' } const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
if (confirmFor[action] && !window.confirm(`${confirmFor[action]} ${name}?`)) return if (confirmFor[action] && !window.confirm(`${confirmFor[action]} ${name}?`)) return
setBusy(action); setErr('') setBusy(action); setErr('')
try { await api.serviceAction(name, action); await load(); onChanged() } try { await api.serviceAction(name, action); await load(); onChanged(); toast(`${name}: ${action} ok`) }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') }
finally { setBusy('') } 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 p = d.props
const running = d.active === 'active' const running = d.active === 'active'
const enabled = d.enabled === 'enabled' 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 ( return (
<div className="svc-detail"> <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 === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
<button className={tab === 'file' ? 'on' : ''} onClick={() => setTab('file')}>Unit file</button> <button className={tab === 'file' ? 'on' : ''} onClick={() => setTab('file')}>Unit file</button>
</div> </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> </div>
) )
} }
@@ -171,9 +175,11 @@ export default function Services() {
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [filter, setFilter] = useState<Filter>('all') const [filter, setFilter] = useState<Filter>('all')
const [sel, setSel] = useState<string | null>(null) const [sel, setSel] = useState<string | null>(null)
const [loaded, setLoaded] = useState(false)
const load = useCallback(async () => { const load = useCallback(async () => {
try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
finally { setLoaded(true) }
}, []) }, [])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 10000); return () => clearInterval(t) }, [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' + (sel ? ' has-sel' : '')}>
<div className="svc-list"> <div className="svc-list">
<div className="svc-tools"> <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"> <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]) => ( {([['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> <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> </div>
{err && <div className="error">{err}</div>} {err && <div className="error">{err}</div>}
<div className="svc-rows"> <div className="svc-rows">
{!loaded && <Loading label="Listing services…" />}
{shown.map((u) => ( {shown.map((u) => (
<div key={u.name} className={'svc-row' + (u.name === sel ? ' active' : '')} onClick={() => setSel(u.name)}> <div key={u.name} className={'svc-row' + (u.name === sel ? ' active' : '')} onClick={() => setSel(u.name)}>
<span className={'sdot ' + stateClass(u)} /> <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> <span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
</div> </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>
</div> </div>
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</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 Cron from './Cron'
import Updates from './Updates' import Updates from './Updates'
import Power from './Power' import Power from './Power'
import { Btn, NetBar, Toasts, toast } from './ui'
const GROUPS: Group[] = [ const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true }, { 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 [view, setView] = useState<View>('dashboard')
const [drawer, setDrawer] = useState(false) const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true) const [online, setOnline] = useState(true)
const [creating, setCreating] = useState(false)
const handles = useRef<Record<string, TermHandle>>({}) const handles = useRef<Record<string, TermHandle>>({})
const reload = useCallback(async () => { const reload = useCallback(async () => {
@@ -42,21 +44,26 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
useEffect(() => { reload() }, [reload]) useEffect(() => { reload() }, [reload])
async function create() { async function create() {
const t = await api.createTerminal() setCreating(true)
await reload() try {
setActive(t.id) const t = await api.createTerminal()
setView('terminal') await reload()
setDrawer(false) 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) { async function rename(t: Term) {
const name = window.prompt('Rename terminal', t.name) 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) { async function close(t: Term) {
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
await api.deleteTerminal(t.id) try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
delete handles.current[t.id] catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
await reload()
} }
async function logout() { await api.logout(); onLoggedOut() } async function logout() { await api.logout(); onLoggedOut() }
@@ -65,6 +72,8 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
return ( return (
<div className={'app' + (drawer ? ' drawer-open' : '')}> <div className={'app' + (drawer ? ' drawer-open' : '')}>
<NetBar />
<Toasts />
<header className="header"> <header className="header">
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button> <button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
<span className="brand">Ham Lab</span> <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="stat" title="load average">{me.load}</span>
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} /> <span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
<Power host={me.host} rebootNeeded={me.reboot_needed} /> <Power host={me.host} rebootNeeded={me.reboot_needed} />
<button className="link" onClick={logout}>logout</button> <Btn className="link" onClick={logout}>logout</Btn>
</header> </header>
<div className="body"> <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) }} onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }}
onView={(id) => { setView(id as View); setDrawer(false) }} onView={(id) => { setView(id as View); setDrawer(false) }}
onNew={create} onRename={rename} onClose={close} /> onNew={create} onRename={rename} onClose={close} />
@@ -91,7 +100,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
{termView && terms.length === 0 && ( {termView && terms.length === 0 && (
<div className="center muted"> <div className="center muted">
<div>No terminals.</div> <div>No terminals.</div>
<button className="primary" onClick={create}>+ New Terminal</button> <Btn className="primary" busy={creating} onClick={create}>+ New Terminal</Btn>
</div> </div>
)} )}
{terms.map((t) => ( {terms.map((t) => (
+12 -8
View File
@@ -1,21 +1,23 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api, TaskInfo, wsURLPath } from './api' 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. // 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 }) { export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: TaskInfo) => void }) {
const [task, setTask] = useState<TaskInfo | null>(null) const [task, setTask] = useState<TaskInfo | null>(null)
const [text, setText] = useState('') const [text, setText] = useState('')
const [connected, setConnected] = useState(false)
const box = useRef<HTMLPreElement>(null) const box = useRef<HTMLPreElement>(null)
const doneRef = useRef(onDone) const doneRef = useRef(onDone)
doneRef.current = onDone doneRef.current = onDone
useEffect(() => { useEffect(() => {
setText(''); setTask(null) setText(''); setTask(null); setConnected(false)
let closed = false
api.task(id).then(setTask).catch(() => { /* shown as unknown */ }) api.task(id).then(setTask).catch(() => { /* shown as unknown */ })
const s = new WebSocket(wsURLPath(`/api/tasks/${id}/stream`)) const s = new WebSocket(wsURLPath(`/api/tasks/${id}/stream`))
s.binaryType = 'arraybuffer' s.binaryType = 'arraybuffer'
const dec = new TextDecoder() const dec = new TextDecoder()
s.onopen = () => setConnected(true)
s.onmessage = (ev) => { s.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { setText((t) => t + dec.decode(ev.data, { stream: true })); return } if (ev.data instanceof ArrayBuffer) { setText((t) => t + dec.decode(ev.data, { stream: true })); return }
try { 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 */ }) if (m.type === 'exit') api.task(id).then((t) => { setTask(t); doneRef.current?.(t) }).catch(() => { /* ignore */ })
} catch { /* ignore */ } } catch { /* ignore */ }
} }
return () => { closed = true; s.close(); void closed } return () => s.close()
}, [id]) }, [id])
useEffect(() => { if (box.current) box.current.scrollTop = box.current.scrollHeight }, [text]) 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 ( return (
<div className="task"> <div className={'task' + (running ? ' running' : failed ? ' failed' : ' done')}>
<div className="logs-bar"> <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>{task?.title ?? '…'}</span>
<span className="muted">· {status}</span> <span className="muted">· {status}</span>
<span className="spacer" /> <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> </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> </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 { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links' import { WebLinksAddon } from '@xterm/addon-web-links'
import { wsURL } from './api' import { wsURL } from './api'
import { Spinner } from './ui'
export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void } 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 }) const cbs = useRef({ onStatus, onExit })
cbs.current = { onStatus, onExit } cbs.current = { onStatus, onExit }
const fitRef = useRef<() => void>(() => {}) const fitRef = useRef<() => void>(() => {})
const [conn, setConn] = useState<'connecting' | 'open' | 'reconnecting' | 'exited'>('connecting')
useEffect(() => { useEffect(() => {
const el = box.current! const el = box.current!
@@ -67,12 +69,12 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
const sock = new WebSocket(wsURL(id)) const sock = new WebSocket(wsURL(id))
sock.binaryType = 'arraybuffer' sock.binaryType = 'arraybuffer'
ws = sock 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) => { sock.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return } if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
try { try {
const m = JSON.parse(ev.data) 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 if (m.type === 'closed') closed = true
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -80,6 +82,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
if (ws !== sock) return if (ws !== sock) return
cbs.current.onStatus(false) cbs.current.onStatus(false)
if (closed) return if (closed) return
setConn('reconnecting')
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)) retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
} }
sock.onerror = () => sock.close() sock.onerror = () => sock.close()
@@ -124,5 +127,12 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
return () => clearTimeout(t) return () => clearTimeout(t)
}, [active]) }, [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 { useCallback, useEffect, useRef, useState } from 'react'
import { api, UpdatePackage, UpdatesStatus } from './api' import { api, TaskInfo, UpdatePackage, UpdatesStatus } from './api'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import { Btn, Loading, Spinner, toast } from './ui'
function ago(ms?: number): string { function ago(ms?: number): string {
if (!ms) return 'never' if (!ms) return 'never'
@@ -12,39 +13,52 @@ function ago(ms?: number): string {
export default function Updates() { export default function Updates() {
const [st, setSt] = useState<UpdatesStatus | null>(null) const [st, setSt] = useState<UpdatesStatus | null>(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [reloading, setReloading] = useState(false)
const [sel, setSel] = useState<Set<string>>(new Set()) const [sel, setSel] = useState<Set<string>>(new Set())
const [task, setTask] = useState<string | null>(null) const [task, setTask] = useState<string | null>(null)
const [taskRunning, setTaskRunning] = useState(false)
const [open, setOpen] = useState<string | null>(null) const [open, setOpen] = useState<string | null>(null)
const [changelog, setChangelog] = useState<Record<string, string>>({}) const [changelog, setChangelog] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false) const taskBox = useRef<HTMLDivElement>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
setReloading(true)
try { try {
const s = await api.updates() const s = await api.updates()
setSt(s); setErr('') 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)))) setSel((prev) => new Set([...prev].filter((n) => s.packages.some((p) => p.name === n))))
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
finally { setReloading(false) }
}, []) }, [])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
async function start(fn: () => Promise<{ id: string }>) { async function start(fn: () => Promise<TaskInfo>) {
setBusy(true); setErr('') setErr('')
try { const t = await fn(); setTask(t.id) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy(false) } 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) { async function showChangelog(p: UpdatePackage) {
if (open === p.name) { setOpen(null); return } if (open === p.name) { setOpen(null); return }
setOpen(p.name) setOpen(p.name)
if (!changelog[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)' })) } try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
catch (e) { setChangelog((c) => ({ ...c, [p.name]: String(e) })) } 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 }) 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> if (!st) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Checking packages…" />}</div>
const running = !!st.running_task || busy const canAct = st.can_upgrade && !taskRunning
const canAct = st.can_upgrade && !running
return ( return (
<div className="dash updates"> <div className="dash updates">
@@ -66,13 +80,16 @@ export default function Updates() {
</div> </div>
<div className="svc-actions"> <div className="svc-actions">
<button className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</button> <Btn className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</Btn>
<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> <Btn className="btn on" disabled={!canAct || st.packages.length === 0} confirm={`Upgrade all ${st.packages.length} packages?`} onClick={() => start(() => api.upgrade([]))}>Upgrade all</Btn>
<button className="btn" disabled={!canAct || sel.size === 0} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</button> <Btn className="btn" disabled={!canAct || sel.size === 0} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</Btn>
<span className="spacer" /> <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>} {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>
<div ref={taskBox}>{task && <TaskOutput id={task} onDone={done} />}</div>
<div className="card pkgs"> <div className="card pkgs">
{st.packages.length === 0 && <div className="empty">Everything is up to date.</div>} {st.packages.length === 0 && <div className="empty">Everything is up to date.</div>}
{st.packages.map((p) => ( {st.packages.map((p) => (
@@ -85,12 +102,10 @@ export default function Updates() {
</span> </span>
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span> <span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span>
</div> </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>
))} ))}
</div> </div>
{task && <TaskOutput id={task} onDone={() => load()} />}
</div> </div>
) )
} }
+20 -6
View File
@@ -1,12 +1,26 @@
export class Unauthenticated extends Error {} 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> { async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const r = await fetch(path, { track(1)
method, let r: Response
credentials: 'same-origin', try {
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, r = await fetch(path, {
body: body !== undefined ? JSON.stringify(body) : undefined, 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.status === 401) throw new Unauthenticated()
if (!r.ok) { if (!r.ok) {
let msg = r.statusText 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 + terminal */
.main { flex: 1; min-width: 0; position: relative; background: #0d1117; } .main { flex: 1; min-width: 0; position: relative; background: #0d1117; }
.term { position: absolute; inset: 0; padding: 6px 0 0 6px; } .term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
.term-inner { height: 100%; }
.term .xterm { height: 100%; } .term .xterm { height: 100%; }
.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); } .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 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; } .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; } .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' : '')} />
}