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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 18:04:45 -07:00
co-authored by Claude Fable 5.1
parent d08b246b5f
commit 724f30c02b
13 changed files with 261 additions and 93 deletions
+17 -15
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services'
import { Btn, Loading, Spinner, toast } from './ui'
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }
function rel(ms: number): string {
@@ -39,7 +40,7 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
async function save() {
if (text === null) return
setBusy(true); setErr('')
try { await api.cronWriteSource(name, text); onSaved(); onClose() }
try { await api.cronWriteSource(name, text); toast(`${srcLabel(name)} saved`); onSaved(); onClose() }
catch (e) { setErr(e instanceof Error ? e.message : 'save failed') }
finally { setBusy(false) }
}
@@ -48,10 +49,10 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
<button className="btn back" onClick={onClose}>‹ Back</button>
<h2>{srcLabel(name)}</h2>
{!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>}
{text === null ? <div className="muted">…</div> : <textarea className="editor" value={text} spellCheck={false} readOnly={!editable} onChange={(e) => setText(e.target.value)} />}
{text === null ? <Loading label="Loading crontab…" /> : <textarea className="editor" value={text} spellCheck={false} readOnly={!editable} onChange={(e) => setText(e.target.value)} />}
{err && <div className="error">{err}</div>}
<div className="svc-actions">
<button className="btn on" disabled={!editable || busy || text === null} onClick={save}>{busy ? '…' : 'Save'}</button>
<button className="btn on" disabled={!editable || busy || text === null} onClick={save}>{busy && <Spinner />}Save</button>
<button className="btn" onClick={onClose}>Cancel</button>
</div>
<div className="notice">Lines are validated before saving; user crontabs are installed with <code>crontab -</code> so cron picks them up immediately.</div>
@@ -77,7 +78,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
async function toggle() {
if (job.enabled && !window.confirm(`Disable this job? It will be commented out in ${srcLabel(job.source)}.`)) return
setBusy('toggle'); setErr('')
try { await api.cronJobToggle(job.id, !job.enabled); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
try { await api.cronJobToggle(job.id, !job.enabled); toast(job.enabled ? 'job disabled' : 'job enabled'); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
}
async function runNow() {
if (!window.confirm(`Run now as ${job.user}?\n\n${job.command}`)) return
@@ -86,7 +87,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
const r = await api.cronJobRun(job.id)
setRun(r); setTab('run')
const tick = async () => {
try { const cur = await api.cronRun(r.id); setRun(cur); if (cur.running) poll.current = window.setTimeout(tick, 1000); else onChanged() }
try { const cur = await api.cronRun(r.id); setRun(cur); if (cur.running) poll.current = window.setTimeout(tick, 1000); else { toast(`run finished (exit ${cur.exit_code})`, cur.exit_code === 0 ? 'ok' : 'err'); onChanged() } }
catch { /* run expired */ }
}
poll.current = window.setTimeout(tick, 1000)
@@ -107,8 +108,8 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
</div>
{job.note && <div className="notice">{job.note}</div>}
<div className="svc-actions">
<button className="btn" disabled={!!busy} onClick={runNow}>{busy === 'run' ? '…' : 'Run now'}</button>
{job.line > 0 && <button className="btn" disabled={!!busy || !job.editable} onClick={toggle}>{job.enabled ? 'Disable' : 'Enable'}</button>}
<button className="btn" disabled={!!busy} onClick={runNow}>{busy === 'run' && <Spinner />}Run now</button>
{job.line > 0 && <button className="btn" disabled={!!busy || !job.editable} onClick={toggle}>{busy === 'toggle' && <Spinner />}{job.enabled ? 'Disable' : 'Enable'}</button>}
{job.line > 0 && <button className="btn" onClick={() => onEdit(job.source)}>Edit {srcLabel(job.source)}</button>}
</div>
{err && <div className="error">{err}</div>}
@@ -119,14 +120,14 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
</div>
{tab === 'logs' && (
<div className="logs"><div className="log-lines">
{logs === null ? <div className="muted">…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
{logs === null ? <div className="muted"><Spinner /> loading…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
: logs.map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
</div></div>
)}
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : '…'}</pre>}
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : <span className="muted"><Spinner /> reading…</span>}</pre>}
{tab === 'run' && run && (
<>
<div className="muted">{run.running ? 'running…' : `exited ${run.exit_code} · ${new Date(run.finished!).toLocaleTimeString()}`}</div>
<div className="muted">{run.running ? <><Spinner /> running…</> : `exited ${run.exit_code} · ${new Date(run.finished!).toLocaleTimeString()}`}</div>
<pre className="run-out">{run.output || (run.running ? '' : '(no output)')}</pre>
</>
)}
@@ -142,9 +143,9 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
if ((a === 'stop' || a === 'disable') && !window.confirm(`${a} ${t.unit}?`)) return
if (a === 'run' && !window.confirm(`Start ${t.activates} now?`)) return
setBusy(a); setErr('')
try { await api.cronTimerAction(t.unit, a); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
try { await api.cronTimerAction(t.unit, a); toast(`${t.unit}: ${a} ok`); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${t.unit}: ${a} failed`, 'err') } finally { setBusy('') }
}
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a ? '…' : label}</button>
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
return (
<div className="cron-detail">
<button className="btn back" onClick={onBack}>‹ Cron</button>
@@ -200,7 +201,7 @@ export default function Cron() {
<div className={'cron' + (sel || editing ? ' has-sel' : '')}>
<div className="cron-list">
<div className="svc-tools">
<input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="tools-row"><input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
<div className="chips">
{sources.map((s) => <button key={s} className="chip" title="edit" onClick={() => { setSel(null); setEditing(s) }}>✎ {srcLabel(s)}</button>)}
</div>
@@ -208,7 +209,8 @@ export default function Cron() {
{err && <div className="error">{err}</div>}
{ov && !ov.logging && <div className="notice" style={{ margin: 8 }}>cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.</div>}
<div className="cron-rows">
<h3>Cron jobs · {jobs.length}</h3>
{!ov && !err && <Loading label="Reading crontabs…" />}
{ov && <h3>Cron jobs · {jobs.length}</h3>}
{jobs.map((j) => (
<div key={j.id} className={'cron-row' + (sel?.kind === 'job' && sel.id === j.id ? ' active' : '') + (j.enabled ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
<span className={'sdot ' + (j.enabled ? 'ok' : '')} />
@@ -218,7 +220,7 @@ export default function Cron() {
</div>
))}
{ov && jobs.length === 0 && <div className="muted" style={{ padding: 12 }}>No cron jobs.</div>}
<h3>systemd timers · {timers.length}</h3>
{ov && <h3>systemd timers · {timers.length}</h3>}
{timers.map((t) => (
<div key={t.unit} className={'cron-row' + (sel?.kind === 'timer' && sel.id === t.unit ? ' active' : '') + (t.active === 'active' ? '' : ' off')} onClick={() => { setEditing(null); setSel({ kind: 'timer', id: t.unit }) }}>
<span className={'sdot ' + (t.active === 'active' ? 'ok' : '')} />