import { useCallback, useEffect, useRef, useState } from 'react'; import { Check, ChevronDown, DatabaseBackup, History, Loader2, PlayCircle, PlugZap } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DirectoryPicker } from 'officerdev'; import { readableError } from './errors'; import { Console } from './Console'; /** * Backups: when they run, where they land, and what is actually there. * * ── A schedule somebody picks, not a cron expression somebody writes ── * * The field underneath IS a cron expression, and it is editable — but the four choices above it are * every schedule anybody setting up a small site has ever wanted. Making people compose "0 3 * * *" * from memory is how a backup ends up running every minute of the third hour. * * ── "Back up now" is not a convenience ── * * It is how you find out the location is writable and pg_dump can reach the databases, WITHOUT waiting * until three in the morning to discover it cannot. A scheduled job that has never once been run by * hand is a guess. * * ── What is on disk, not what should be ── * * The list is a `readdir` of the location. A schedule that has been silently failing looks exactly like * a working one until you count the files, so the files are what is shown. */ type Backup = { file: string; bytes: number; at: string }; type Settings = { cron: string | null; dir: string; dirIsSet: boolean; sshHost: string | null; sshPath: string | null; keep: number | null; lastBackupAt: string | null; nextRunAt: string | null; recent: Backup[]; }; /** The sentinel the API stores for "not on a clock". Kept in step with `ON_PUBLISH` in api/backups.ts. */ const ON_PUBLISH = 'on-publish'; /** * When to back up. * * "On publish" is first because it is the right answer for a site that only changes when somebody * changes it: a nightly backup spends most of its life copying a database identical to yesterday's, * and misses the one moment worth capturing. */ const CHOICES = [ { label: 'On publish', cron: ON_PUBLISH }, { label: 'Hourly', cron: '0 * * * *' }, // Midnight, 6am, noon and 6pm — anchored to the clock rather than to whenever this was saved. { label: 'Every 6 hours', cron: '0 */6 * * *' }, { label: 'Daily, 3am', cron: '0 3 * * *' }, { label: 'Weekly, Sun 3am', cron: '0 3 * * 0' }, { label: 'Monthly, 1st 3am', cron: '0 3 1 * *' }, ] as const; const size = (bytes: number) => bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1048576).toFixed(1)} MB`; const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null); export const BackupsRow = ({ prodName, devName, onSettled, }: { prodName: string; devName: string; onSettled?: (settled: boolean) => void; }) => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [settings, setSettings] = useState(null); const [open, setOpen] = useState(false); const [cron, setCron] = useState('0 3 * * *'); const [dir, setDir] = useState(''); const [sshHost, setSshHost] = useState(''); const [sshPath, setSshPath] = useState(''); const [keep, setKeep] = useState('7'); const [picking, setPicking] = useState(false); const [busy, setBusy] = useState<'save' | 'run' | 'test' | 'restore' | null>(null); /* Which dump is being restored, and into what — null until somebody opens the list. */ const [restoring, setRestoring] = useState<{ file: string; target: 'production' | 'development' } | null>( null, ); const [browsing, setBrowsing] = useState(false); const [error, setError] = useState(null); const [output, setOutput] = useState(null); const [failed, setFailed] = useState(false); const load = useCallback(async () => { try { const current = await clientRef.current.get('/fofos-admin/_config/backups'); setSettings(current); setCron((typed) => current.cron ?? typed); // The server computes the default when nobody has chosen one, so the field is never empty and // never shows a path that is only a guess without it also being the one that would be used. setDir((typed) => typed || current.dir); setSshHost((typed) => typed || (current.sshHost ?? '')); setSshPath((typed) => typed || (current.sshPath ?? '')); setKeep((typed) => (current.keep === null ? typed : String(current.keep))); } catch { setSettings(null); } }, []); useEffect(() => { void load(); }, [load]); /* One bit upward, so the block above can decide whether the whole section is finished. In an effect and not during render. Calling the parent's setter while this component renders is an update to a DIFFERENT component mid-render, which React refuses outright — and the version of this that did it would have thrown on the first paint. */ const settledUp = Boolean(settings?.cron && settings?.sshHost && settings?.sshPath); useEffect(() => { onSettled?.(settledUp); }, [settledUp, onSettled]); const save = async () => { setBusy('save'); setError(null); try { setSettings( await clientRef.current.put('/fofos-admin/_config/backups', { cron, dir, sshHost, sshPath, keep: keep.trim() ? Number(keep) : null, }), ); setOpen(false); } catch (failure) { setError(readableError(failure)); } finally { setBusy(null); } }; /** Connection first, then the directory. Different failures, different fixes — see the API. */ const test = async () => { setBusy('test'); setError(null); setFailed(false); setOutput(null); try { const result = await clientRef.current.post<{ ok: boolean; output: string }>( '/fofos-admin/_config/backups/test', { sshHost, sshPath }, ); setOutput(result.output); setFailed(!result.ok); } catch (failure) { setOutput(readableError(failure)); setFailed(true); } finally { setBusy(null); } }; const restore = async (file: string, target: 'production' | 'development') => { setBusy('restore'); setError(null); setFailed(false); setOutput(null); try { const result = await clientRef.current.post<{ ok: boolean; output: string }>( '/fofos-admin/_config/backups/restore', { file, target, confirm: true }, ); setOutput(result.output); setFailed(!result.ok); } catch (failure) { setOutput(readableError(failure)); setFailed(true); } finally { setBusy(null); setRestoring(null); void load(); } }; const run = async () => { setBusy('run'); setError(null); setFailed(false); setOutput(null); try { const result = await clientRef.current.post<{ ok: boolean; output: string; settings: Settings }>( '/fofos-admin/_config/backups/run', {}, ); setSettings(result.settings); setOutput(result.output); setFailed(!result.ok); } catch (failure) { setOutput(readableError(failure)); setFailed(true); void load(); } finally { setBusy(null); } }; if (!settings) return null; // A schedule with nowhere to send it is not set up — the point of this is the copy off the box. const configured = Boolean(settings.cron && settings.sshHost && settings.sshPath); const chosen = CHOICES.find((choice) => choice.cron === settings.cron); return (

Backups

{configured ? (chosen?.label ?? settings.cron) : 'Not set up'} {configured && settings.sshHost ? ( {' '} → {settings.sshHost}:{settings.sshPath?.startsWith('/') ? settings.sshPath : `~/${settings.sshPath}`} ) : null}

{settings.recent.length ? `${settings.recent.length} on disk` : configured ? 'none yet' : ''}
{settings.recent.length > 0 && ( )} {configured && ( )}
{/* Only what has actually happened. A schedule with no runs behind it says so. */} {configured && !open && (

{settings.lastBackupAt ? `Last ${when(settings.lastBackupAt)}` : 'Never run'} {settings.nextRunAt ? ` · next ${when(settings.nextRunAt)}` : ''}

)} {open && (

When

{CHOICES.map((choice) => ( ))}
{/* The expression stays visible and editable for a SCHEDULE. "On publish" is not one, so the box would be a field with nothing to put in it. */} {cron === ON_PUBLISH ? (

A backup is taken every time the site is published, and at no other time.

) : ( setCron(event.target.value)} className="w-full font-mono text-xs" autoComplete="off" spellCheck={false} /> )}

Staged locally before being sent

setDir(event.target.value)} placeholder="/home/you/backups" className="w-full font-mono text-xs" autoComplete="off" spellCheck={false} />
{picking && ( setPicking(false)} onPick={(absolutePath) => { setDir(absolutePath); setPicking(false); }} /> )}

Sent to another machine, over SSH

setSshHost(event.target.value)} placeholder="user@host" className="w-48 shrink-0 font-mono text-xs" autoComplete="off" spellCheck={false} /> setSshPath(event.target.value)} placeholder="db-backups/fofos" className="w-full font-mono text-xs" autoComplete="off" spellCheck={false} />
{/* No password field, and there will not be one: this runs with nobody at a terminal, so it authenticates with the owner's SSH key and stores no credential of its own. */}

Authenticated with this machine’s SSH key. A path without a leading slash is relative to the remote home.

Keep newest per database, both ends

setKeep(event.target.value.replace(/[^0-9]/g, ''))} placeholder="all" className="w-24 font-mono text-xs" autoComplete="off" inputMode="numeric" />
{error &&

{error}

}
)} {/* The dumps that are actually on disk, and what can be done with one. A restore EMPTIES the target first — a pg_dump carries CREATE TABLE and cannot be layered onto a database that already has the schema — so choosing a destination and confirming it are two separate clicks, and the second one says what it is about to destroy by name. */} {browsing && settings.recent.length > 0 && !open && (
    {settings.recent.map((backup) => (
  • {backup.file} {size(backup.bytes)} {restoring?.file === backup.file ? ( Replace everything in {restoring.target === 'production' ? prodName : devName}? ) : ( )}
  • ))}
)} {/* A quiet summary when the list is closed, so the count above is not the only evidence. */} {!browsing && settings.recent.length > 0 && !open && (
    {settings.recent.slice(0, 3).map((backup) => (
  • {backup.file} {size(backup.bytes)}
  • ))}
)}
); };