A pg_dump carries CREATE TABLE, so it cannot be layered onto a database that already has the schema — it fails on every object. The only honest restore empties the target first, and that is not a detail to bury behind a button. So choosing a destination and confirming are two separate clicks, and the second names what it is about to destroy. `confirm` is required by the function, not merely by the panel, so no code path can reach a restore by accident. The file is matched against what listDumps actually found in the backup directory rather than taken as given. There is no path to traverse out of and no way to point psql at a file this module did not write: an unknown name is refused before anything is opened, rather than sanitised and hoped about. Emptied with DROP SCHEMA public CASCADE through psql, not by dropping the DATABASE — that would need a connection to another one, fail while anything is attached, and lose whatever was granted on it. Replacing the schema does the same job to the part that matters. psql rather than pg_restore, because these are plain-SQL dumps, which is what pg_dump writes without -F c. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
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<Settings | null>(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<string | null>(null);
|
|
const [output, setOutput] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const current = await clientRef.current.get<Settings>('/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<Settings>('/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 (
|
|
<div className="flex flex-col gap-3 border-t border-border pt-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs text-muted-foreground">Backups</p>
|
|
<p className="truncate font-mono text-xs">
|
|
{configured ? (chosen?.label ?? settings.cron) : 'Not set up'}
|
|
{configured && settings.sshHost ? (
|
|
<span className="text-muted-foreground">
|
|
{' '}
|
|
→ {settings.sshHost}:{settings.sshPath?.startsWith('/') ? settings.sshPath : `~/${settings.sshPath}`}
|
|
</span>
|
|
) : null}
|
|
</p>
|
|
</div>
|
|
|
|
<span className="shrink-0 text-xs text-muted-foreground/70">
|
|
{settings.recent.length ? `${settings.recent.length} on disk` : configured ? 'none yet' : ''}
|
|
</span>
|
|
|
|
<div className="flex shrink-0 gap-2">
|
|
{settings.recent.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setBrowsing((shown) => !shown);
|
|
setRestoring(null);
|
|
}}
|
|
disabled={busy !== null}
|
|
>
|
|
<History className="size-3.5" />
|
|
Restore
|
|
<ChevronDown className={`size-3.5 transition-transform ${browsing ? '' : '-rotate-90'}`} />
|
|
</Button>
|
|
)}
|
|
{configured && (
|
|
<Button variant="outline" size="sm" onClick={() => void run()} disabled={busy !== null}>
|
|
{busy === 'run' ? <Loader2 className="size-3.5 animate-spin" /> : <PlayCircle className="size-3.5" />}
|
|
Back up now
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" size="sm" onClick={() => setOpen((shown) => !shown)} disabled={busy !== null}>
|
|
<DatabaseBackup className="size-3.5" />
|
|
{configured ? 'Change' : 'Set up backups'}
|
|
<ChevronDown className={`size-3.5 transition-transform ${open ? '' : '-rotate-90'}`} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Only what has actually happened. A schedule with no runs behind it says so. */}
|
|
{configured && !open && (
|
|
<p className="text-xs text-muted-foreground/70">
|
|
{settings.lastBackupAt ? `Last ${when(settings.lastBackupAt)}` : 'Never run'}
|
|
{settings.nextRunAt ? ` · next ${when(settings.nextRunAt)}` : ''}
|
|
</p>
|
|
)}
|
|
|
|
{open && (
|
|
<div className="flex flex-col gap-3 rounded-md border border-border/60 p-3">
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium">When</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{CHOICES.map((choice) => (
|
|
<Button
|
|
key={choice.cron}
|
|
variant={cron === choice.cron ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setCron(choice.cron)}
|
|
>
|
|
{choice.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
{/* The expression stays visible and editable. The buttons write into it; they do not hide it. */}
|
|
<Input
|
|
value={cron}
|
|
onChange={(event) => setCron(event.target.value)}
|
|
className="w-full font-mono text-xs"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium">
|
|
Staged locally <span className="font-normal text-muted-foreground/70">before being sent</span>
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={dir}
|
|
onChange={(event) => setDir(event.target.value)}
|
|
placeholder="/home/you/backups"
|
|
className="w-full font-mono text-xs"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setPicking((shown) => !shown)}>
|
|
{picking ? 'Close' : 'Browse'}
|
|
</Button>
|
|
</div>
|
|
{picking && (
|
|
<DirectoryPicker
|
|
onCancel={() => setPicking(false)}
|
|
onPick={(absolutePath) => {
|
|
setDir(absolutePath);
|
|
setPicking(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium">
|
|
Sent to <span className="font-normal text-muted-foreground/70">another machine, over SSH</span>
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={sshHost}
|
|
onChange={(event) => setSshHost(event.target.value)}
|
|
placeholder="user@host"
|
|
className="w-48 shrink-0 font-mono text-xs"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<Input
|
|
value={sshPath}
|
|
onChange={(event) => setSshPath(event.target.value)}
|
|
placeholder="db-backups/fofos"
|
|
className="w-full font-mono text-xs"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={() => void test()}
|
|
disabled={busy !== null || !sshHost.trim() || !sshPath.trim()}
|
|
>
|
|
{busy === 'test' ? <Loader2 className="size-3.5 animate-spin" /> : <PlugZap className="size-3.5" />}
|
|
Test
|
|
</Button>
|
|
</div>
|
|
{/* 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. */}
|
|
<p className="text-xs text-muted-foreground/70">
|
|
Authenticated with this machine’s SSH key. A path without a leading slash is relative to
|
|
the remote home.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium">
|
|
Keep <span className="font-normal text-muted-foreground/70">newest per database, both ends</span>
|
|
</p>
|
|
<Input
|
|
value={keep}
|
|
onChange={(event) => setKeep(event.target.value.replace(/[^0-9]/g, ''))}
|
|
placeholder="all"
|
|
className="w-24 font-mono text-xs"
|
|
autoComplete="off"
|
|
inputMode="numeric"
|
|
/>
|
|
</div>
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setOpen(false)} disabled={busy !== null}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void save()}
|
|
disabled={busy !== null || !cron.trim() || !dir.trim() || !sshHost.trim() || !sshPath.trim()}
|
|
>
|
|
{busy === 'save' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Console output={output} failed={failed} />
|
|
|
|
{/*
|
|
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 && (
|
|
<ul className="space-y-1 rounded-md border border-border/60 p-2">
|
|
{settings.recent.map((backup) => (
|
|
<li key={backup.file} className="flex flex-wrap items-center gap-2 text-xs">
|
|
<span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">{backup.file}</span>
|
|
<span className="shrink-0 text-muted-foreground/70">{size(backup.bytes)}</span>
|
|
|
|
{restoring?.file === backup.file ? (
|
|
<span className="flex shrink-0 items-center gap-2">
|
|
<span className="text-destructive">
|
|
Replace everything in {restoring.target === 'production' ? prodName : devName}?
|
|
</span>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => void restore(backup.file, restoring.target)}
|
|
disabled={busy !== null}
|
|
>
|
|
{busy === 'restore' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
|
Yes, restore
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setRestoring(null)} disabled={busy !== null}>
|
|
Cancel
|
|
</Button>
|
|
</span>
|
|
) : (
|
|
<span className="flex shrink-0 gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setRestoring({ file: backup.file, target: 'production' })}
|
|
disabled={busy !== null}
|
|
>
|
|
Into production
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setRestoring({ file: backup.file, target: 'development' })}
|
|
disabled={busy !== null}
|
|
>
|
|
Into development
|
|
</Button>
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* A quiet summary when the list is closed, so the count above is not the only evidence. */}
|
|
{!browsing && settings.recent.length > 0 && !open && (
|
|
<ul className="space-y-0.5">
|
|
{settings.recent.slice(0, 3).map((backup) => (
|
|
<li key={backup.file} className="flex items-baseline gap-2 text-xs text-muted-foreground/70">
|
|
<span className="min-w-0 flex-1 truncate font-mono">{backup.file}</span>
|
|
<span className="shrink-0">{size(backup.bytes)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|