Cron tab: crontabs, cron.d, user crontabs, run-parts dirs and systemd timers in one list; next/last run, run now, enable/disable, in-place editing, cron log and output file

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 16:21:35 -07:00
co-authored by Claude Fable 5.1
parent bc891b1042
commit 41c2f05535
12 changed files with 1415 additions and 9 deletions
+235
View File
@@ -0,0 +1,235 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
import { Logs } from './Services'
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); 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 ? <div className="muted">…</div> : <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" 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); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } 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 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' ? '…' : '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" 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">…</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 === 'run' && run && (
<>
<div className="muted">{run.running ? '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); onChanged() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') }
}
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a ? '…' : 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">
<input placeholder="filter jobs and timers" value={q} onChange={(e) => setQ(e.target.value)} />
<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">
<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>}
<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>
)
}
+1 -1
View File
@@ -32,7 +32,7 @@ function stateClass(u: ServiceUnit): string {
}
// ---- log viewer: seeded from the REST fetch, then followed over a WebSocket ----
function Logs({ unit }: { unit: string }) {
export function Logs({ unit }: { unit: string }) {
const [lines, setLines] = useState<LogEntry[]>([])
const [live, setLive] = useState(true)
const [paused, setPaused] = useState(false)
+5 -3
View File
@@ -5,16 +5,17 @@ import TerminalPane, { TermHandle } from './TerminalPane'
import KeyBar from './KeyBar'
import Dashboard from './Dashboard'
import Services from './Services'
import Cron from './Cron'
const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
{ id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '⚙', page: true },
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
]
type View = 'terminal' | 'dashboard' | 'services'
type View = 'terminal' | 'dashboard' | 'services' | 'cron'
function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
@@ -81,6 +82,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<main className="main">
{view === 'dashboard' && <Dashboard />}
{view === 'services' && <Services />}
{view === 'cron' && <Cron />}
{termView && terms.length === 0 && (
<div className="center muted">
<div>No terminals.</div>
@@ -103,7 +105,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : activeTerm ? activeTerm.name : ''}</span>
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
+14
View File
@@ -46,7 +46,21 @@ export interface ServiceUnit { name: string; description: string; load: string;
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
export interface LogEntry { t: number; p: number; ident: string; pid?: string; msg: string }
export interface CronJob { id: string; source: string; line: number; user: string; schedule: string; command: string; enabled: boolean; next?: number; last?: number; out_file?: string; note?: string; editable: boolean }
export interface CronTimer { unit: string; activates: string; description: string; active: string; sub: string; enabled: string; next?: number; last?: number }
export interface CronOverview { jobs: CronJob[]; timers: CronTimer[]; logging: boolean; anacron: boolean; sudo: boolean }
export interface CronRun { id: string; job_id: string; command: string; user: string; started: string; finished?: string; running: boolean; exit_code: number; output: string }
export const api = {
cron: () => req<CronOverview>('GET', '/api/cron'),
cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`),
cronWriteSource: (name: string, text: string) => req<void>('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }),
cronJobLogs: (id: string, n = 200) => req<LogEntry[]>('GET', `/api/cron/jobs/${id}/logs?n=${n}`),
cronJobOutput: (id: string, n = 200) => req<{ file: string; text: string }>('GET', `/api/cron/jobs/${id}/output?n=${n}`),
cronJobToggle: (id: string, enable: boolean) => req<void>('POST', `/api/cron/jobs/${id}/${enable ? 'enable' : 'disable'}`),
cronJobRun: (id: string) => req<CronRun>('POST', `/api/cron/jobs/${id}/run`),
cronRun: (id: string) => req<CronRun>('GET', `/api/cron/runs/${id}`),
cronTimerAction: (unit: string, action: string) => req<void>('POST', `/api/cron/timers/${encodeURIComponent(unit)}/${action}`),
services: async () => (await req<{ units: ServiceUnit[]; sudo: boolean }>('GET', '/api/services')).units,
service: (unit: string) => req<ServiceDetail>('GET', `/api/services/${encodeURIComponent(unit)}`),
serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`),
+22
View File
@@ -160,3 +160,25 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
.svc-detail .back { display: inline-block; } .props { grid-template-columns: 1fr; }
}
/* cron */
.cron { position: absolute; inset: 0; display: flex; background: var(--bg); }
.cron-list { width: 420px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--bg2); min-height: 0; }
.cron-rows { flex: 1; overflow-y: auto; }
.cron-rows h3 { margin: 0; padding: 8px 12px 4px; font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; background: var(--bg2); position: sticky; top: 0; }
.cron-row { display: grid; grid-template-columns: 8px 1fr auto; gap: 4px 10px; align-items: center; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
.cron-row:hover { background: var(--bg3); } .cron-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
.cron-row .cmd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
.cron-row .meta { grid-column: 2 / 4; font-size: 11px; color: var(--muted); display: flex; gap: 8px; flex-wrap: wrap; }
.cron-row .when { font-size: 11px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.cron-row.off .cmd { color: var(--muted); text-decoration: line-through; }
.cron-detail { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 12px; overflow-y: auto; }
.cron-detail .back { display: none; align-self: flex-start; }
.cron-detail h2 { margin: 0; font-size: 15px; font-weight: 600; font-family: ui-monospace, monospace; word-break: break-all; white-space: pre-wrap; }
.notice { padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; font-size: 12px; color: var(--muted); background: var(--bg2); }
.editor { width: 100%; min-height: 260px; padding: 10px; background: #0d1117; color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; resize: vertical; }
.run-out { margin: 0; padding: 10px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; }
@media (max-width: 800px) {
.cron-list { width: 100%; border-right: 0; } .has-sel .cron-list { display: none; }
.cron-detail .back { display: inline-block; }
}