Pools, datasets and snapshots move out of Disks — Disks is the hardware, this is the filesystem on top of it. New: snapshot schedules, hourly to monthly, with a retention period and a floor that keeps the newest few whatever their age. The naming is zfSnap's, deliberately. grid has run zfSnap from root cron since before this repo existed and it works, so rather than invent a second convention this writes and reads exactly what zfSnap does: 2026-09-24_02.30.01--6w, with the retention in the name. Ham Lab can therefore show and expire the snapshots zfSnap already took, the two cannot disagree about what a snapshot is, and changing a schedule never re-dates old ones — each carries the rule it was taken under. An existing cron job is detected and shown on the page, with a warning if a Ham Lab schedule is running as well. Nothing is destroyed unseen. Every schedule answers what it would delete, with the list, before Run now touches anything, and only names that parse as a stamp and a TTL are ever candidates. A snapshot taken by hand is invisible to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
7.4 KiB
TypeScript
158 lines
7.4 KiB
TypeScript
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||
|
||
// ---- spinners ----
|
||
export function Spinner({ size = 14 }: { size?: number }) {
|
||
return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" />
|
||
}
|
||
// The × in the corner of a detail pane. A drawn cross rather than the × glyph, which sits
|
||
// differently in every font and theme.
|
||
export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; title?: string }) {
|
||
return (
|
||
<button type="button" className="detail-close" title={title} aria-label={title} onClick={onClick}>
|
||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" /></svg>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// A filled bar. Red past 95%, amber past 85%, unless the caller says otherwise.
|
||
export function Meter({ frac, cls }: { frac: number; cls?: string }) {
|
||
const c = cls ?? (frac >= 0.95 ? 'bad' : frac >= 0.85 ? 'warn' : '')
|
||
return <div className="meter"><div className={'fill ' + c} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
|
||
}
|
||
|
||
export function Loading({ label = 'Loading…' }: { label?: string }) {
|
||
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
|
||
}
|
||
|
||
// Button that shows a spinner while its async handler runs; `confirm` asks first.
|
||
export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title, children }: {
|
||
onClick: () => unknown | Promise<unknown>; busy?: boolean; confirm?: string; className?: string; disabled?: boolean; title?: string; children: ReactNode
|
||
}) {
|
||
const [inner, setInner] = useState(false)
|
||
const b = !!busy || inner
|
||
return (
|
||
<button className={className + (b ? ' busy' : '')} disabled={disabled || b} title={title}
|
||
onClick={async () => {
|
||
if (confirm && !(await askYes(confirm))) return
|
||
const r = onClick()
|
||
if (r && typeof (r as Promise<unknown>).then === 'function') {
|
||
setInner(true)
|
||
try { await r } finally { setInner(false) }
|
||
}
|
||
}}>
|
||
{b && <Spinner />}{children}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ---- toasts: short feedback for actions ----
|
||
interface Toast { id: number; kind: 'ok' | 'err' | 'info'; msg: string }
|
||
const listeners = new Set<(t: Toast[]) => void>()
|
||
let toasts: Toast[] = []
|
||
let seq = 0
|
||
function emit() { listeners.forEach((l) => l(toasts)) }
|
||
export function dismissToast(id: number) { toasts = toasts.filter((t) => t.id !== id); emit() }
|
||
export function toast(msg: string, kind: Toast['kind'] = 'ok') {
|
||
const t = { id: ++seq, kind, msg }
|
||
toasts = [...toasts.slice(-4), t]
|
||
emit()
|
||
window.setTimeout(() => dismissToast(t.id), kind === 'err' ? 8000 : 3500)
|
||
}
|
||
export function Toasts() {
|
||
const [list, setList] = useState(toasts)
|
||
useEffect(() => { listeners.add(setList); return () => { listeners.delete(setList) } }, [])
|
||
return (
|
||
<div className="toasts">
|
||
{list.map((t) => <div key={t.id} className={'toast ' + t.kind} onClick={() => dismissToast(t.id)}>{t.msg}</div>)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// act runs an action and toasts its outcome. Returns whether it succeeded.
|
||
export async function act(label: string, fn: () => Promise<unknown>): Promise<boolean> {
|
||
try { await fn(); toast(label); return true }
|
||
catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
|
||
}
|
||
|
||
// ---- thin activity bar at the top while any request is in flight ----
|
||
export function NetBar() {
|
||
const [n, setN] = useState(0)
|
||
useEffect(() => {
|
||
const h = (e: Event) => setN((e as CustomEvent<number>).detail)
|
||
window.addEventListener('hamlab:inflight', h)
|
||
return () => window.removeEventListener('hamlab:inflight', h)
|
||
}, [])
|
||
return <div className={'netbar' + (n > 0 ? ' on' : '')} />
|
||
}
|
||
|
||
// useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way.
|
||
// A question (askYes/askText) sits on top of whatever asked it: Escape answers the question and
|
||
// goes no further, or one key would cancel the question and close the dialog underneath as well.
|
||
let escapeTaken: Event | null = null
|
||
export function useEscape(fn: () => void, active = true, isAsk = false) {
|
||
const ref = useRef(fn); ref.current = fn
|
||
useEffect(() => {
|
||
if (!active) return
|
||
const h = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Escape') return
|
||
if (isAsk) { escapeTaken = e; ref.current() } else if (asks.length === 0 && escapeTaken !== e) ref.current()
|
||
}
|
||
window.addEventListener('keydown', h)
|
||
return () => window.removeEventListener('keydown', h)
|
||
}, [active])
|
||
}
|
||
|
||
// ---- dialogs: askYes / askText replace the browser's confirm() and prompt() ----
|
||
// Same promise shape as the natives (false / null on cancel), drawn like the rest of the
|
||
// console, usable on a phone, closed by Escape or a tap outside.
|
||
interface Ask { id: number; msg: string; input?: string; ok: string; danger: boolean; done: (v: string | boolean | null) => void }
|
||
let asks: Ask[] = []
|
||
let nextId = 0
|
||
const askListeners = new Set<(a: Ask[]) => void>()
|
||
const emitAsks = () => askListeners.forEach((l) => l(asks))
|
||
function ask(msg: string, input: string | undefined, ok?: string): Promise<string | boolean | null> {
|
||
return new Promise((resolve) => {
|
||
const id = ++nextId
|
||
const danger = /^(delete|remove|revoke|close|send sig|stop|disable|reset|discard|force)/i.test(msg) || /\bforce\b/i.test(msg)
|
||
asks = [...asks, { id, msg, input, ok: ok ?? (input !== undefined ? 'OK' : danger ? msg.split(/[ ?]/)[0] : 'OK'), danger, done: (v) => { asks = asks.filter((a) => a.id !== id); emitAsks(); resolve(v) } }]
|
||
emitAsks()
|
||
})
|
||
}
|
||
export const askYes = (msg: string, ok?: string) => ask(msg, undefined, ok) as Promise<boolean>
|
||
export const askText = (msg: string, initial = '', ok?: string) => ask(msg, initial, ok) as Promise<string | null>
|
||
|
||
function AskBox({ a }: { a: Ask }) {
|
||
const [val, setVal] = useState(a.input ?? '')
|
||
const cancel = () => a.done(a.input !== undefined ? null : false)
|
||
const accept = () => a.done(a.input !== undefined ? val : true)
|
||
useEscape(cancel, true, true)
|
||
return (
|
||
<div className="modal-back ask-back" onClick={cancel}>
|
||
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
|
||
<div className="ask-msg">{a.msg}</div>
|
||
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||
<div className="svc-actions">
|
||
<button type="submit" autoFocus={a.input === undefined} className={'btn ' + (a.danger ? 'danger' : 'on')}>{a.ok}</button>
|
||
<span className="spacer" />
|
||
<button type="button" className="btn" onClick={cancel}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
export function Dialogs() {
|
||
const [list, setList] = useState<Ask[]>(asks)
|
||
useEffect(() => { askListeners.add(setList); return () => { askListeners.delete(setList) } }, [])
|
||
const top = list[list.length - 1]
|
||
return top ? <AskBox key={top.id} a={top} /> : null
|
||
}
|
||
|
||
// Unsaved work: whoever holds some says so, and whoever is about to navigate away asks.
|
||
let unsaved = ''
|
||
export function setUnsaved(what: string) { unsaved = what }
|
||
export const hasUnsaved = () => unsaved !== ''
|
||
export async function okToLeave(): Promise<boolean> {
|
||
if (!unsaved || (await askYes(`Discard unsaved changes to ${unsaved}?`, 'Discard'))) { unsaved = ''; return true }
|
||
return false
|
||
}
|