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:
co-authored by
Claude Fable 5.1
parent
d08b246b5f
commit
724f30c02b
+17
-15
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>}
|
||||||
|
|||||||
+16
-7
@@ -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() {
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
const t = await api.createTerminal()
|
const t = await api.createTerminal()
|
||||||
await reload()
|
await reload()
|
||||||
setActive(t.id)
|
setActive(t.id)
|
||||||
setView('terminal')
|
setView('terminal')
|
||||||
setDrawer(false)
|
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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -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)
|
||||||
|
let r: Response
|
||||||
|
try {
|
||||||
|
r = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
body: body !== undefined ? JSON.stringify(body) : 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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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' : '')} />
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user