Files
hamlab/web/src/Cron.tsx
T

240 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { LIST_FLOOR, useRefreshMs } from './prefs'
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].reverse().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') }
}, [])
const rate = useRefreshMs(LIST_FLOOR)
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, rate); return () => clearInterval(t) }, [load, rate])
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>
)
}