Snapshot schedules are real cron jobs, written and owned by Ham Lab
A schedule is now a line in /etc/cron.d/hamlab-zfs, which Ham Lab owns and rewrites whole whenever the schedules change; the line calls `hamlab zfs-snapshot <id>` as the account Ham Lab runs as, reaching zfs through sudo exactly as the daemon does. The daemon's own ticker is gone. Snapshots are backup-shaped and should not stop because a web console is down, being upgraded, or uninstalled — and a cron line is greppable by anyone looking at the machine the normal way, which a schedule hidden inside a daemon is not. A cron expression is now the single description of when a schedule fires: the four common shapes render to one, a custom schedule states it outright, and the next run is read back with the same evaluator the Jobs tab uses on everyone else's lines. So the page, the file and the prediction cannot disagree. Schedules gain a custom cron expression and an optional snapshot name prefix. What a run did moves out of the policy into zfs-runs/<id>.json beside the config, because the cron job is a separate process and must never write state.json behind the daemon's back. The detected external job can be adopted: its schedule, retention and dataset are read out of the cron line into a new Ham Lab schedule, ready to check rather than retype. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4dc25fdd13
commit
09b7558d44
+50
-16
@@ -9,7 +9,7 @@
|
||||
// disagree over. An existing cron job is detected and shown, so the page tells the truth
|
||||
// about the machine whether Ham Lab or something else is doing the work.
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSSnapshot } from './api'
|
||||
import { api, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot } from './api'
|
||||
import { fmtBytes } from './fmt'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { askText, askYes, Btn, Loading, Meter, Spinner, toast, useEscape } from './ui'
|
||||
@@ -26,7 +26,8 @@ const soon = (t?: string) => {
|
||||
}
|
||||
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const describe = (p: ZFSPolicy) => {
|
||||
const when = p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}`
|
||||
const when = p.every === 'custom' ? `on ${p.cron}`
|
||||
: p.every === 'hourly' ? `every hour at :${(p.at.split(':')[1] ?? '00').padStart(2, '0')}`
|
||||
: p.every === 'daily' ? `every day at ${p.at}`
|
||||
: p.every === 'weekly' ? `every ${WEEKDAYS[p.weekday] ?? 'Sunday'} at ${p.at}`
|
||||
: `on day ${p.day} of the month at ${p.at}`
|
||||
@@ -107,7 +108,7 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
|
||||
|
||||
// ---- schedules ----
|
||||
|
||||
function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) {
|
||||
function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; onAdopt: () => void }) {
|
||||
return (
|
||||
<div className={'notice' + (mine > 0 ? ' attn' : '')} style={{ marginBottom: 8 }}>
|
||||
<b>{x.tool}</b> is already taking snapshots on this machine, from cron as <b>{x.user}</b>:
|
||||
@@ -115,13 +116,16 @@ function ExternalNotice({ x, mine }: { x: ExternalJob; mine: number }) {
|
||||
<span className="muted">in {x.source}{x.last ? ` · last ran ${ago(x.last)}` : ''}</span>
|
||||
{mine > 0
|
||||
? <div className="warn" style={{ marginTop: 6 }}>A Ham Lab schedule is running as well. Both use the same names and both only remove snapshots that have passed the expiry written into them, so they will not fight — but they will take twice as many. Keep one.</div>
|
||||
: <div className="muted small" style={{ marginTop: 6 }}>Ham Lab reads the snapshots it makes and shows when each expires. Add a schedule here only if you mean to replace it; edit this one on the Jobs tab.</div>}
|
||||
: <div className="svc-actions" style={{ marginTop: 6 }}>
|
||||
<Btn className="btn" onClick={onAdopt}>Adopt as a schedule</Btn>
|
||||
<span className="muted small">Copies it into a Ham Lab schedule you can edit here. Turn the old cron job off on the Jobs tab once you are happy with it.</span>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
type ExternalJob = ZFSExternal
|
||||
|
||||
function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: string; onChanged: () => void; onEdit: () => void }) {
|
||||
function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) {
|
||||
const [expired, setExpired] = useState<ZFSSnapshot[] | null>(null)
|
||||
const [busy, setBusy] = useState('')
|
||||
const preview = async () => {
|
||||
@@ -146,7 +150,7 @@ function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: str
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (p.last_error ? 'bad' : p.enabled ? 'ok' : '')} />
|
||||
<span className={'dot ' + (last.error ? 'bad' : p.enabled ? 'ok' : '')} />
|
||||
<span className="dname">{p.name || p.dataset}</span>
|
||||
{!p.enabled && <span className="badge">paused</span>}
|
||||
<span className="spacer" />
|
||||
@@ -155,11 +159,12 @@ function ScheduleCard({ p, next, onChanged, onEdit }: { p: ZFSPolicy; next?: str
|
||||
<div className="props" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Dataset</span><span className="mono">{p.dataset}</span></div>
|
||||
<div><span className="k">When</span><span>{describe(p)}</span></div>
|
||||
<div><span className="k">Last run</span><span>{ago(p.last_run)}{p.pruned ? ` · ${p.pruned} expired removed` : ''}</span></div>
|
||||
{p.last_taken && <div><span className="k">Last snapshot</span><span className="mono small">{p.last_taken.split('@')[1]}</span></div>}
|
||||
<div><span className="k">Cron</span><span className="mono small" title={`in ${'/etc/cron.d/hamlab-zfs'}`}>{cron}</span></div>
|
||||
<div><span className="k">Last run</span><span>{ago(last.at)}{last.pruned?.length ? ` · ${last.pruned.length} expired removed` : ''}</span></div>
|
||||
{last.taken && <div><span className="k">Last snapshot</span><span className="mono small">{last.taken.split('@')[1]}</span></div>}
|
||||
{p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
|
||||
</div>
|
||||
{p.last_error && <div className="error" style={{ marginTop: 8 }}>{p.last_error}</div>}
|
||||
{last.error && <div className="error" style={{ marginTop: 8 }}>{last.error}</div>}
|
||||
{expired && (
|
||||
<div className="notice" style={{ marginTop: 8 }}>
|
||||
{expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : (
|
||||
@@ -210,14 +215,20 @@ function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolic
|
||||
</label>
|
||||
<label className="muted">How often
|
||||
<span className="chips">
|
||||
{(['hourly', 'daily', 'weekly', 'monthly'] as const).map((k) => (
|
||||
{(['hourly', 'daily', 'weekly', 'monthly', 'custom'] as const).map((k) => (
|
||||
<button type="button" key={k} className={'chip' + (f.every === k ? ' on' : '')} onClick={() => set('every', k)}>{k}</button>
|
||||
))}
|
||||
</span>
|
||||
</label>
|
||||
<label className="muted">At
|
||||
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
|
||||
</label>
|
||||
{f.every === 'custom' ? (
|
||||
<label className="muted">Cron expression — minute hour day month weekday
|
||||
<input className="mono" value={f.cron ?? ''} onChange={(e) => set('cron', e.target.value)} placeholder="0 */6 * * *" spellCheck={false} required />
|
||||
</label>
|
||||
) : (
|
||||
<label className="muted">At
|
||||
<input type="time" value={f.at} onChange={(e) => set('at', e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
{f.every === 'weekly' && (
|
||||
<label className="muted">On
|
||||
<select value={f.weekday} onChange={(e) => set('weekday', Number(e.target.value))}>
|
||||
@@ -230,6 +241,9 @@ function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolic
|
||||
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
|
||||
</label>
|
||||
)}
|
||||
<label className="muted">Snapshot name prefix — optional; the timestamp and retention follow it
|
||||
<input value={f.prefix ?? ''} onChange={(e) => set('prefix', e.target.value)} placeholder="none" spellCheck={false} />
|
||||
</label>
|
||||
<label className="muted">Keep for — written into each snapshot's name, as zfSnap does it: 6w, 30d, 12h
|
||||
<input value={f.keep_for} onChange={(e) => set('keep_for', e.target.value)} placeholder="6w" spellCheck={false} required />
|
||||
</label>
|
||||
@@ -255,6 +269,26 @@ function ScheduleDialog({ p, datasets, onClose, onSaved }: { p: Partial<ZFSPolic
|
||||
)
|
||||
}
|
||||
|
||||
// adopt reads an existing cron job into the fields of a new schedule, so replacing it is a
|
||||
// matter of checking the numbers rather than retyping them. zfSnap's own flags are read
|
||||
// where they can be: -a <age> is the retention, -r is recursive.
|
||||
function adopt(x: ExternalJob, fallbackDataset: string): Partial<ZFSPolicy> {
|
||||
const cmd = x.command
|
||||
const keep = /-a\s+(\d+[mhdwMy])/.exec(cmd)?.[1] ?? /--\s*(\d+[mhdw])\b/.exec(cmd)?.[1] ?? '6w'
|
||||
const f = x.schedule.trim().split(/\s+/)
|
||||
const min = Number(f[0]), hour = Number(f[1])
|
||||
const simple = f.length === 5 && Number.isInteger(min) && Number.isInteger(hour) && f[2] === '*' && f[3] === '*' && f[4] === '*'
|
||||
const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset
|
||||
return {
|
||||
...BLANK, name: `${x.tool} (adopted)`, dataset: ds,
|
||||
recursive: /\s-\w*r/.test(cmd) || true,
|
||||
every: simple ? 'daily' : 'custom',
|
||||
at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30',
|
||||
cron: simple ? '' : x.schedule.trim(),
|
||||
keep_for: keep, keep_min: 1, enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function ZFS() {
|
||||
@@ -286,10 +320,10 @@ export default function ZFS() {
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshot schedules</h2><span className="spacer" />
|
||||
<Btn className="btn on" onClick={() => setEditing({ ...BLANK, dataset: zfs.pools[0]?.name ?? '' })}>+ New schedule</Btn>
|
||||
</div>
|
||||
{ov.external && <ExternalNotice x={ov.external} mine={mine} />}
|
||||
{ov.external && <ExternalNotice x={ov.external} mine={mine} onAdopt={() => setEditing(adopt(ov.external!, zfs.pools[0]?.name ?? ''))} />}
|
||||
{ov.policies.length === 0 && !ov.external && <div className="notice">Nothing is taking snapshots on a schedule. Snapshots are written the way zfSnap writes them, with the retention in the name, so anything already here is understood.</div>}
|
||||
<div className="cards">{ov.policies.map(({ policy, next }) => (
|
||||
<ScheduleCard key={policy.id} p={policy} next={next} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
<div className="cards">{ov.policies.map(({ policy, cron, next, last }) => (
|
||||
<ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
))}</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Datasets</h2><span className="spacer" /><span className="muted small">used · referenced · in snapshots · count · compression · mount</span></div>
|
||||
|
||||
+3
-2
@@ -133,9 +133,10 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
|
||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
||||
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||
|
||||
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly'; at: string; weekday: number; day: number; keep_for: string; keep_min: number; prefix?: string; enabled: boolean; last_run?: string; last_error?: string; last_taken?: string; pruned?: number }
|
||||
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
|
||||
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
||||
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||
export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; next?: string }[]; external?: ZFSExternal | null }
|
||||
export interface ZFSOverview { zfs: ZFSInfo; policies: { policy: ZFSPolicy; cron: string; next?: string; last: ZFSRun }[]; external?: ZFSExternal | null }
|
||||
|
||||
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
|
||||
export interface FileInfo extends FileEntry { path: string; text: boolean }
|
||||
|
||||
Reference in New Issue
Block a user