ZFS tab: landing tier checks — stale files, verify passes against the mover's ledger, verify schedules
A verify pass reads the pool's copy of every file the mover moved back against the SHA-256 the mover took of the landing copy (its ledger, named in its status file): match, mismatch, edited since, gone, waiting, unreadable, unhashed. 'New moves' checks what moved since the last pass, 'everything' the whole ledger. Passes run as 'hamlab landing-verify' in their own session — the same command a schedule's line in /etc/cron.d/hamlab-landing runs — so a console restart does not cut one short. Stale files: anything waiting on the landing branch over an hour, listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f518df239f
commit
829617c932
+249
-1
@@ -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, Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { api, ARCView, CacheDev, Landing, PoolCaching, PoolWrites, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot, ZFSWrites } from './api'
|
||||
import { api, ARCView, CacheDev, Landing, LandingCheck, PoolCaching, PoolWrites, VerifyPolicy, VerifyResult, ZFSExternal, ZFSInfo, ZFSOverview, ZFSPolicy, ZFSRun, ZFSSnapshot, ZFSWrites } from './api'
|
||||
import { fmtBytes, fmtRate } from './fmt'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { fetchHistories } from './sensors'
|
||||
@@ -588,6 +588,253 @@ function ARCSection() {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- landing-tier checks: what has waited too long, and verify passes ----
|
||||
//
|
||||
// A verify pass reads the pool's copy of every file the mover moved back against the SHA-256
|
||||
// the mover took of the landing copy, from its ledger. "New moves" checks what has moved since
|
||||
// the last pass; "everything" re-reads the whole ledger. Passes run as their own process, the
|
||||
// same one a schedule's cron line starts, so a console restart does not cut one short.
|
||||
|
||||
const vDescribe = (p: VerifyPolicy) => {
|
||||
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}`
|
||||
return `${when} · ${p.scope === 'all' ? 'everything in the ledger' : 'what moved since the last pass'}`
|
||||
}
|
||||
const vOK = (r: VerifyResult) => !r.error && r.mismatched === 0 && r.gone === 0 && r.unreadable === 0
|
||||
const secs = (a: string, b: string) => (new Date(b).getTime() - new Date(a).getTime()) / 1000
|
||||
|
||||
function VerifySummary({ r }: { r: VerifyResult }) {
|
||||
if (r.error) return <span className="warn-text" style={{ whiteSpace: 'normal' }}>{r.error}</span>
|
||||
const extra = [r.changed && `${fmtN(r.changed)} edited since (not compared)`, r.waiting && `${fmtN(r.waiting)} waiting to move again`, r.unhashed && `${fmtN(r.unhashed)} without a hash`].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<span>
|
||||
{vOK(r)
|
||||
? <>{r.checked === 0 ? 'nothing new to check' : <><b>{fmtN(r.matched)}</b> of {fmtN(r.checked)} files match</>}</>
|
||||
: <span className="warn-text">{[r.mismatched && `${fmtN(r.mismatched)} DIFFER`, r.gone && `${fmtN(r.gone)} not on the pool`, r.unreadable && `${fmtN(r.unreadable)} unreadable`].filter(Boolean).join(' · ')} · {fmtN(r.matched)} match</span>}
|
||||
<span className="muted"> · {fmtBytes(r.bytes_read)} read in {dur(secs(r.started, r.finished)) === '—' ? '0 s' : dur(secs(r.started, r.finished))} · {r.scope === 'all' ? 'everything' : 'new moves'} · {ago(r.finished)}</span>
|
||||
{extra && <span className="muted"> · {extra}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Issues({ r }: { r: VerifyResult }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (!r.issues?.length) return null
|
||||
return (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<button className="btn" onClick={() => setOpen(!open)}>{open ? 'Hide' : 'Show'} the {r.issues.length} file{r.issues.length === 1 ? '' : 's'} to look at</button>
|
||||
{open && <div className="snap-list" style={{ marginTop: 6 }}>{r.issues.map((i) => (
|
||||
<div key={i.path + i.kind} className="snap">
|
||||
<span className={'badge ' + (i.kind === 'mismatch' ? 'bad' : 'warn')}>{i.kind === 'mismatch' ? 'differs' : i.kind === 'gone' ? 'not on pool' : 'unreadable'}</span>
|
||||
<span className="mono snap-name" title={i.path}>{i.path}</span>
|
||||
<span className="muted snap-when">moved {new Date(i.moved).toLocaleString()}</span>
|
||||
<span className="num muted">{fmtBytes(i.size)}</span>
|
||||
</div>
|
||||
))}</div>}
|
||||
{open && r.gone > 0 && <div className="muted small" style={{ marginTop: 4 }}>"Not on the pool" is a file that was moved and is no longer there — deleted or renamed since, most often. The pass cannot tell that from a loss, so it lists them.</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifyScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: VerifyPolicy; cron: string; next?: string; last: VerifyResult | null; onChanged: () => void; onEdit: () => void }) {
|
||||
const run = async () => {
|
||||
try { await api.runVerifySchedule(p.id); toast('verify pass started'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!await askYes(`Remove the verify schedule "${p.name}"? Passes it already ran stay in the history.`, 'Remove')) return
|
||||
try { await api.deleteVerifySchedule(p.id); toast('schedule removed'); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const toggle = async () => {
|
||||
try { await api.saveVerifySchedule({ ...p, enabled: !p.enabled }); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (last && !vOK(last) ? 'bad' : p.enabled ? 'ok' : '')} />
|
||||
<span className="dname">{p.name || 'verify'}</span>
|
||||
{!p.enabled && <span className="badge">paused</span>}
|
||||
<span className="spacer" />
|
||||
<span className="muted small">{p.enabled ? soon(next) : 'not scheduled'}</span>
|
||||
</div>
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">When</span><span>{vDescribe(p)}</span></div>
|
||||
<div><span className="k">Cron</span><span className="mono small">{cron}</span></div>
|
||||
<div><span className="k">Last pass</span>{last ? <VerifySummary r={last} /> : <span>never run</span>}</div>
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" onClick={run}>Run now</Btn>
|
||||
<button className="btn" onClick={onEdit}>Edit</button>
|
||||
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const VBLANK: Partial<VerifyPolicy> = { name: '', every: 'daily', at: '05:10', weekday: 0, day: 1, scope: 'new', enabled: true }
|
||||
|
||||
function VerifyDialog({ p, onClose, onSaved }: { p: Partial<VerifyPolicy>; onClose: () => void; onSaved: () => void }) {
|
||||
const [f, setF] = useState<Partial<VerifyPolicy>>({ ...VBLANK, ...p })
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
const set = (k: keyof VerifyPolicy, v: unknown) => setF((x) => ({ ...x, [k]: v }))
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault(); setErr('')
|
||||
try { await api.saveVerifySchedule({ ...f, name: (f.name || '').trim() || (f.scope === 'all' ? 'verify everything' : 'verify new moves') }); onSaved() }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||
<h3>{f.id ? 'Edit verify schedule' : 'New verify schedule'}</h3>
|
||||
<label className="muted">Name — what this schedule is for
|
||||
<input value={f.name} onChange={(e) => set('name', e.target.value)} placeholder="nightly verify" maxLength={64} />
|
||||
</label>
|
||||
<label className="muted">What to check
|
||||
<span className="chips">
|
||||
<button type="button" className={'chip' + (f.scope === 'new' ? ' on' : '')} onClick={() => set('scope', 'new')}>new moves</button>
|
||||
<button type="button" className={'chip' + (f.scope === 'all' ? ' on' : '')} onClick={() => set('scope', 'all')}>everything</button>
|
||||
</span>
|
||||
<span className="small">{f.scope === 'all' ? 'Re-reads every file the mover has ever moved from the pool. Thorough, and as slow as the ledger is large.' : 'Reads back what has moved since the last pass — every file checked once, soon after it lands.'}</span>
|
||||
</label>
|
||||
<label className="muted">How often
|
||||
<span className="chips">
|
||||
{(['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>
|
||||
{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))}>
|
||||
{WEEKDAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{f.every === 'monthly' && (
|
||||
<label className="muted">Day of the month — 1 to 28, so every month has one
|
||||
<input type="number" min={1} max={28} value={f.day} onChange={(e) => set('day', Number(e.target.value))} />
|
||||
</label>
|
||||
)}
|
||||
<label className="muted" style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={!!f.enabled} onChange={(e) => set('enabled', e.target.checked)} style={{ width: 'auto' }} />
|
||||
Enabled
|
||||
</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button type="submit" className="btn on">Save</button>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LandingChecks() {
|
||||
const [c, setC] = useState<LandingCheck | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [showStale, setShowStale] = useState(false)
|
||||
const [editing, setEditing] = useState<Partial<VerifyPolicy> | null>(null)
|
||||
const rate = useRefreshMs()
|
||||
const load = useCallback(async () => {
|
||||
try { setC(await api.zfsLanding()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
const running = !!c?.running
|
||||
useEffect(() => { const t = setInterval(load, running ? 2000 : Math.max(10000, rate * 5)); return () => clearInterval(t) }, [load, rate, running])
|
||||
|
||||
if (err && !c) return <div className="error">{err}</div>
|
||||
if (!c || (c.tiers.length === 0 && c.policies.length === 0 && c.history.length === 0)) return null
|
||||
const verify = async (scope: 'new' | 'all') => {
|
||||
if (scope === 'all' && !await askYes('Re-read every file the mover has ever moved, from the pool, and check each against its hash? It reads as much as the ledger holds.', 'Verify everything')) return
|
||||
try { await api.landingVerify(scope); toast('verify pass started'); setTimeout(load, 800) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
}
|
||||
const last = c.history[0]
|
||||
const hours = Math.round(c.stale_after_sec / 3600)
|
||||
const r = c.running
|
||||
return (
|
||||
<>
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Landing tier checks</h2><span className="spacer" />
|
||||
<span className="muted small">did everything that landed reach the pool, intact?</span>
|
||||
</div>
|
||||
<div className="cards full">
|
||||
{c.tiers.length === 0 && <div className="notice">No landing mover is reporting right now (nothing in <span className="mono">/run/landing-mover/</span>), so there is nothing to check. Past passes are below.</div>}
|
||||
{c.tiers.map((t) => (
|
||||
<section className="card" key={t.src}>
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (last && !vOK(last) ? 'bad' : t.stale_count ? 'warn' : 'ok')} />
|
||||
<span className="dname">{t.dst}</span>
|
||||
<span className="badge">landing checks</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted small mono">{t.src} → {t.dst}</span>
|
||||
</div>
|
||||
<div className="props rows" style={{ marginTop: 6 }}>
|
||||
<div><span className="k">Stale</span><span>
|
||||
{t.stale_count
|
||||
? <><span className="warn-text">{fmtN(t.stale_count)} file{t.stale_count === 1 ? '' : 's'}, {fmtBytes(t.stale_bytes)}, waiting over {hours} h</span>{' '}<button className="btn" onClick={() => setShowStale(!showStale)}>{showStale ? 'Hide' : 'Show'}</button></>
|
||||
: `nothing has waited on the landing tier over ${hours} h`}
|
||||
</span></div>
|
||||
<div><span className="k">Last pass</span>{last ? <VerifySummary r={last} /> : <span>never verified</span>}</div>
|
||||
<div><span className="k">Covered</span><span>{t.cursor ? `every file moved up to ${new Date(t.cursor).toLocaleString()} has been checked at least once` : 'nothing checked yet'}<span className="muted"> · ledger {t.ledger}</span></span></div>
|
||||
</div>
|
||||
{showStale && t.stale.length > 0 && (
|
||||
<div className="snap-list" style={{ marginTop: 6 }}>{t.stale.map((f) => (
|
||||
<div key={f.path} className="snap">
|
||||
<span className="mono snap-name" title={f.path}>{f.path}</span>
|
||||
<span className="muted snap-when">landed {new Date(f.mtime).toLocaleString()}</span>
|
||||
<span className="muted snap-exp">waiting {dur((Date.now() - f.mtime) / 1000)}</span>
|
||||
<span className="num muted">{fmtBytes(f.size)}</span>
|
||||
</div>
|
||||
))}{t.stale_count > t.stale.length && <div className="muted small">and {fmtN(t.stale_count - t.stale.length)} more</div>}</div>
|
||||
)}
|
||||
{r && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="sub-head"><span className="muted small">Verifying {r.scope === 'all' ? 'everything' : 'new moves'} · {fmtN(r.done)} of {fmtN(r.total)} files · {fmtBytes(r.bytes_read)} of {fmtBytes(r.bytes_all)}</span><span className="spacer" /><span className="mono small muted" title={r.current}>{r.current}</span></div>
|
||||
<Meter frac={r.bytes_all > 0 ? r.bytes_read / r.bytes_all : r.total > 0 ? r.done / r.total : 0} cls="" />
|
||||
</div>
|
||||
)}
|
||||
{last && <Issues r={last} />}
|
||||
<div className="svc-actions">
|
||||
<Btn className="btn on" disabled={running} onClick={() => verify('new')}>{running ? 'Verifying…' : 'Verify new moves'}</Btn>
|
||||
<Btn className="btn" disabled={running} onClick={() => verify('all')}>Verify everything</Btn>
|
||||
</div>
|
||||
{c.history.length > 1 && (
|
||||
<details style={{ marginTop: 6 }}>
|
||||
<summary className="muted small">Earlier passes</summary>
|
||||
<div className="props rows" style={{ marginTop: 4 }}>{c.history.slice(1).map((h, i) => (
|
||||
<div key={i}><span className="k">{new Date(h.finished).toLocaleString()}</span><VerifySummary r={h} /></div>
|
||||
))}</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Verify schedules</h2><span className="spacer" />
|
||||
<Btn className="btn on" onClick={() => setEditing({ ...VBLANK })}>+ New schedule</Btn>
|
||||
</div>
|
||||
{c.policies.length === 0 && <div className="notice">No verify runs on a schedule. A daily pass of new moves checks every file once, soon after it lands.</div>}
|
||||
<div className="cards full">{c.policies.map(({ policy, cron, next, last }) => (
|
||||
<VerifyScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||
))}</div>
|
||||
{editing && <VerifyDialog p={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load() }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function ZFS() {
|
||||
@@ -620,6 +867,7 @@ export default function ZFS() {
|
||||
<span className="muted small">the ARC in RAM, and what each pool has in front of its disks</span>
|
||||
</div>
|
||||
<ARCSection />
|
||||
<LandingChecks />
|
||||
|
||||
<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>
|
||||
|
||||
@@ -268,6 +268,20 @@ export interface ZFSWrites {
|
||||
dirty: number; dirty_max: number; delay_min_pct: number; limit: number; delayed_per_sec: number; over_max_per_sec: number; txg_timeout_sec: number
|
||||
pools: PoolWrites[]
|
||||
}
|
||||
export interface VerifyPolicy { id: string; name: string; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; scope: 'new' | 'all'; enabled: boolean }
|
||||
export interface VerifyIssue { path: string; kind: 'mismatch' | 'gone' | 'unreadable'; moved: number; size: number; note?: string }
|
||||
export interface VerifyResult {
|
||||
id: string; scope: 'new' | 'all'; started: string; finished: string; src: string; dst: string; since: number
|
||||
entries: number; checked: number; matched: number; mismatched: number; changed: number; gone: number; waiting: number; unreadable: number; unhashed: number
|
||||
bytes_read: number; issues: VerifyIssue[]; error?: string; cursor: number
|
||||
}
|
||||
export interface VerifyProgress { id: string; scope: string; pid: number; started: string; updated: string; total: number; done: number; bytes_read: number; bytes_all: number; current: string }
|
||||
export interface StaleFile { path: string; size: number; mtime: number }
|
||||
export interface LandingCheck {
|
||||
tiers: { src: string; dst: string; ledger: string; cursor: number; stale_count: number; stale_bytes: number; stale: StaleFile[] }[]
|
||||
stale_after_sec: number; running: VerifyProgress | null; history: VerifyResult[]; cron_file: string
|
||||
policies: { policy: VerifyPolicy; cron: string; next?: string; last: VerifyResult | null }[]
|
||||
}
|
||||
export interface ARCView { detail: ARCDetail; live: ARCLive | null; writes: ZFSWrites | 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; preview?: 'image' | 'video' | 'audio' }
|
||||
@@ -352,6 +366,11 @@ export const api = {
|
||||
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
||||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||||
zfsLanding: () => req<LandingCheck>('GET', '/api/zfs/landing'),
|
||||
landingVerify: (scope: 'new' | 'all') => req<{ started: string }>('POST', '/api/zfs/landing/verify', { scope }),
|
||||
saveVerifySchedule: (p: Partial<VerifyPolicy>) => req<VerifyPolicy>('PUT', '/api/zfs/landing/schedules', p),
|
||||
deleteVerifySchedule: (id: string) => req<void>('DELETE', `/api/zfs/landing/schedules/${encodeURIComponent(id)}`),
|
||||
runVerifySchedule: (id: string) => req<{ started: string }>('POST', `/api/zfs/landing/schedules/${encodeURIComponent(id)}/run`),
|
||||
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||||
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
||||
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
|
||||
|
||||
Reference in New Issue
Block a user