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 { 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' : '')} />
|
||||
|
||||
Reference in New Issue
Block a user