The Updates tab could tell you eleven images were behind and offer nothing to do about it; updating meant knowing which stack each image belonged to and going to find it. Now each row has Update and the header has Update all. Updating is per stack, because that is the unit compose deals in, so a row acts on whichever stacks use that image — postgres:15-alpine is wikijs's, postgres:17 is vikunja's — and Update all walks the affected stacks through a queue one at a time, with each task's output on screen, rather than recreating half the machine at once. A stack that finishes says "updated just now" rather than re-checking, since every check is a registry request and Docker Hub counts them. Also: cron expressions read as English. "daily at 3:15am", with 15 3 * * * kept beside it. Jobs.tsx had a smaller version of this for its own list; it now uses the shared one, which handles lists, ranges, steps, names, weekdays/weekends, midnight and noon, and says nothing at all when it is not certain — the caller falls back to the expression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
14 KiB
TypeScript
232 lines
14 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { describeCron } from './crontext'
|
||
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
||
import { Logs } from './Services'
|
||
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||
import { useHashSel } from './hashsel'
|
||
|
||
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`
|
||
}
|
||
|
||
const describe = (expr: string) => describeCron(expr) || expr
|
||
|
||
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">
|
||
<CloseBtn onClick={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 ? <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 && !await askYes(`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 (!await askYes(`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">
|
||
<CloseBtn onClick={onBack} />
|
||
<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') && !await askYes(`${a} ${t.unit}?`)) return
|
||
if (a === 'run' && !await askYes(`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">
|
||
<CloseBtn onClick={onBack} />
|
||
<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 Jobs() {
|
||
const [ov, setOv] = useState<CronOverview | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [q, setQ] = useState('')
|
||
const [hashSel, setHashSel] = useHashSel('jobs') // "job:<id>" or "timer:<unit>", in the URL
|
||
const sel = /^(job|timer):./.test(hashSel) ? { kind: hashSel.split(':')[0] as 'job' | 'timer', id: hashSel.slice(hashSel.indexOf(':') + 1) } : null
|
||
const setSel = (v: { kind: 'job' | 'timer'; id: string } | null) => setHashSel(v ? `${v.kind}:${v.id}` : '')
|
||
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 jobs' + (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>Scheduled 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>
|
||
)
|
||
}
|