Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
238 lines
15 KiB
TypeScript
238 lines
15 KiB
TypeScript
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 {
|
||
const s = Math.round((ms - Date.now()) / 1000), a = Math.abs(s)
|
||
const d = Math.floor(a / 86400), h = Math.floor((a % 86400) / 3600), m = Math.floor((a % 3600) / 60)
|
||
const txt = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m` : `${a}s`
|
||
return s >= 0 ? `in ${txt}` : `${txt} ago`
|
||
}
|
||
|
||
// Plain-English reading of the common five-field shapes; anything else shows the raw expression.
|
||
function describe(expr: string): string {
|
||
const alias: Record<string, string> = { '@reboot': 'at boot', '@hourly': 'every hour', '@daily': 'every day at 00:00', '@midnight': 'every day at 00:00', '@weekly': 'every Sunday at 00:00', '@monthly': 'on the 1st at 00:00', '@yearly': 'every 1 Jan', '@annually': 'every 1 Jan' }
|
||
if (alias[expr]) return alias[expr]
|
||
const f = expr.split(/\s+/)
|
||
if (f.length !== 5) return expr
|
||
const [mi, h, dom, mon, dow] = f
|
||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||
const at = /^\d+$/.test(mi) && /^\d+$/.test(h) ? `at ${h.padStart(2, '0')}:${mi.padStart(2, '0')}` : /^\d+$/.test(mi) && h === '*' ? `every hour at :${mi.padStart(2, '0')}` : mi.startsWith('*/') && h === '*' ? `every ${mi.slice(2)} min` : null
|
||
if (!at) return expr
|
||
const dowTxt = dow === '*' ? '' : /^\d$/.test(dow) ? `every ${days[+dow]}` : `on days ${dow}`
|
||
const domTxt = dom === '*' ? '' : /^\d+$/.test(dom) ? `on the ${dom}${['st', 'nd', 'rd'][+dom - 1] ?? 'th'}` : `on days ${dom}`
|
||
const monTxt = mon === '*' ? '' : `in month ${mon}`
|
||
const when = [dowTxt, domTxt, monTxt].filter(Boolean).join(', ')
|
||
return (when ? `${when} ` : dow === '*' && dom === '*' && !at.startsWith('every') ? 'every day ' : '') + at
|
||
}
|
||
|
||
const srcLabel = (s: string) => (s.startsWith('crontab:') ? `${s.slice(8)}'s crontab` : s)
|
||
|
||
// ---- source editor ----
|
||
function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () => void; onSaved: () => void }) {
|
||
const [text, setText] = useState<string | null>(null)
|
||
const [editable, setEditable] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
useEffect(() => { api.cronSource(name).then((r) => { setText(r.text); setEditable(r.editable) }).catch((e) => setErr(String(e))) }, [name])
|
||
async function save() {
|
||
if (text === null) return
|
||
setBusy(true); setErr('')
|
||
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) }
|
||
}
|
||
return (
|
||
<div className="cron-detail">
|
||
<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 ? <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 && <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>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- job detail ----
|
||
function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob; logging: boolean; onChanged: () => void; onBack: () => void; onEdit: (src: string) => void }) {
|
||
const [tab, setTab] = useState<'logs' | 'output' | 'run'>('logs')
|
||
const [logs, setLogs] = useState<LogEntry[] | null>(null)
|
||
const [out, setOut] = useState<{ file: string; text: string } | null>(null)
|
||
const [run, setRun] = useState<CronRun | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [busy, setBusy] = useState('')
|
||
const poll = useRef<number | undefined>(undefined)
|
||
|
||
useEffect(() => { setLogs(null); setOut(null); setRun(null); setTab('logs'); setErr('') }, [job.id])
|
||
useEffect(() => { if (tab === 'logs' && !logs) api.cronJobLogs(job.id).then(setLogs).catch((e) => setErr(String(e))) }, [tab, logs, job.id])
|
||
useEffect(() => { if (tab === 'output' && !out) api.cronJobOutput(job.id).then(setOut).catch((e) => setOut({ file: job.out_file ?? '', text: String(e) })) }, [tab, out, job.id, job.out_file])
|
||
useEffect(() => () => clearTimeout(poll.current), [])
|
||
|
||
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); 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
|
||
setBusy('run'); setErr('')
|
||
try {
|
||
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 { toast(`run finished (exit ${cur.exit_code})`, cur.exit_code === 0 ? 'ok' : 'err'); onChanged() } }
|
||
catch { /* run expired */ }
|
||
}
|
||
poll.current = window.setTimeout(tick, 1000)
|
||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
|
||
}
|
||
|
||
return (
|
||
<div className="cron-detail">
|
||
<button className="btn back" onClick={onBack}>‹ Cron</button>
|
||
<h2>{job.command}</h2>
|
||
<div className="props">
|
||
<div><span className="k">Schedule</span><span className="mono" title={job.schedule}>{job.schedule} · {describe(job.schedule)}</span></div>
|
||
<div><span className="k">Next</span><span>{job.next ? `${rel(job.next)} · ${fmtTime(job.next)}` : job.enabled ? '—' : 'disabled'}</span></div>
|
||
<div><span className="k">Last</span><span>{job.last ? `${rel(job.last)} · ${fmtTime(job.last)}` : logging ? 'not seen in the last 30 days' : 'unknown (cron isn’t logging here)'}</span></div>
|
||
<div><span className="k">User</span><span>{job.user}</span></div>
|
||
<div><span className="k">Source</span><span className="mono">{srcLabel(job.source)}{job.line ? `:${job.line}` : ''}</span></div>
|
||
{job.out_file && <div><span className="k">Output</span><span className="mono">{job.out_file}</span></div>}
|
||
</div>
|
||
{job.note && <div className="notice">{job.note}</div>}
|
||
<div className="svc-actions">
|
||
<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>}
|
||
<div className="tabs">
|
||
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Cron log</button>
|
||
{job.out_file && <button className={tab === 'output' ? 'on' : ''} onClick={() => setTab('output')}>Output file</button>}
|
||
{run && <button className={tab === 'run' ? 'on' : ''} onClick={() => setTab('run')}>Run output</button>}
|
||
</div>
|
||
{tab === 'logs' && (
|
||
<div className="logs"><div className="log-lines">
|
||
{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)' : <span className="muted"><Spinner /> reading…</span>}</pre>}
|
||
{tab === 'run' && run && (
|
||
<>
|
||
<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>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- timer detail ----
|
||
function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () => void; onBack: () => void }) {
|
||
const [busy, setBusy] = useState('')
|
||
const [err, setErr] = useState('')
|
||
async function act(a: string) {
|
||
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); 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 && <Spinner />}{label}</button>
|
||
return (
|
||
<div className="cron-detail">
|
||
<button className="btn back" onClick={onBack}>‹ Cron</button>
|
||
<h2>{t.unit}</h2>
|
||
<div className="muted">{t.description}</div>
|
||
<div className="props">
|
||
<div><span className="k">Runs</span><span className="mono">{t.activates}</span></div>
|
||
<div><span className="k">State</span><span>{t.active}{t.sub ? ` (${t.sub})` : ''} · {t.enabled || '—'}</span></div>
|
||
<div><span className="k">Next</span><span>{t.next ? `${rel(t.next)} · ${fmtTime(t.next)}` : '—'}</span></div>
|
||
<div><span className="k">Last</span><span>{t.last ? `${rel(t.last)} · ${fmtTime(t.last)}` : 'never'}</span></div>
|
||
</div>
|
||
<div className="svc-actions">
|
||
<B a="run" label="Run now" />
|
||
{t.active === 'active' ? <B a="stop" label="Stop timer" /> : <B a="start" label="Start timer" />}
|
||
{t.enabled === 'enabled' ? <B a="disable" label="Disable" /> : t.enabled === 'static' ? null : <B a="enable" label="Enable" />}
|
||
</div>
|
||
{err && <div className="error">{err}</div>}
|
||
<div className="tabs"><button className="on">Logs of {t.activates}</button></div>
|
||
<Logs unit={t.activates} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- page ----
|
||
export default function Cron() {
|
||
const [ov, setOv] = useState<CronOverview | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [q, setQ] = useState('')
|
||
const [sel, setSel] = useState<{ kind: 'job' | 'timer'; id: string } | null>(null)
|
||
const [editing, setEditing] = useState<string | null>(null)
|
||
|
||
const load = useCallback(async () => {
|
||
try { setOv(await api.cron()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||
}, [])
|
||
useEffect(() => { load() }, [load])
|
||
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 30000); return () => clearInterval(t) }, [load])
|
||
|
||
const needle = q.trim().toLowerCase()
|
||
const match = (...s: string[]) => !needle || s.some((x) => x.toLowerCase().includes(needle))
|
||
const jobs = (ov?.jobs ?? []).filter((j) => match(j.command, j.source, j.user, j.schedule))
|
||
const timers = (ov?.timers ?? []).filter((t) => match(t.unit, t.description, t.activates))
|
||
const selJob = sel?.kind === 'job' ? jobs.find((j) => j.id === sel.id) ?? ov?.jobs.find((j) => j.id === sel.id) : undefined
|
||
const selTimer = sel?.kind === 'timer' ? ov?.timers.find((t) => t.unit === sel.id) : undefined
|
||
const sources = [...new Set((ov?.jobs ?? []).filter((j) => j.line > 0).map((j) => j.source))]
|
||
|
||
let pane
|
||
if (editing) pane = <SourceEditor name={editing} onClose={() => setEditing(null)} onSaved={load} />
|
||
else if (selJob) pane = <JobDetail job={selJob} logging={!!ov?.logging} onChanged={load} onBack={() => setSel(null)} onEdit={setEditing} />
|
||
else if (selTimer) pane = <TimerDetail t={selTimer} onChanged={load} onBack={() => setSel(null)} />
|
||
else pane = <div className="svc-empty muted">Select a job or timer.</div>
|
||
|
||
return (
|
||
<div className={'cron' + (sel || editing ? ' has-sel' : '')}>
|
||
<div className="cron-list">
|
||
<div className="svc-tools">
|
||
<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>
|
||
</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>}
|
||
<div className="cron-rows">
|
||
{!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' : '')} />
|
||
<span className="cmd" title={j.command}>{j.command}</span>
|
||
<span className="when">{j.next ? rel(j.next) : j.enabled ? '' : 'off'}</span>
|
||
<span className="meta"><span>{describe(j.schedule)}</span><span>{j.user}</span><span>{srcLabel(j.source)}</span>{j.last && <span>last {rel(j.last)}</span>}</span>
|
||
</div>
|
||
))}
|
||
{ov && jobs.length === 0 && <div className="muted" style={{ padding: 12 }}>No cron jobs.</div>}
|
||
{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' : '')} />
|
||
<span className="cmd" title={t.unit}>{t.unit.replace(/\.timer$/, '')}</span>
|
||
<span className="when">{t.next ? rel(t.next) : t.active !== 'active' ? 'stopped' : ''}</span>
|
||
<span className="meta"><span>{t.description}</span>{t.last && <span>last {rel(t.last)}</span>}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{pane}
|
||
</div>
|
||
)
|
||
}
|