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[]; }; /** Every schedule anybody has actually wanted, and their expressions. */ const CHOICES = [ { 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 }: { prodName: string; devName: string }) => { 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]); 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. The buttons write into it; they do not hide it. */} 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)}
  • ))}
)}
); };