restore: pick a dump, pick a database, say yes
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>
This commit is contained in:
+105
-5
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, DatabaseBackup, Loader2, PlayCircle, PlugZap } from 'lucide-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';
|
||||
@@ -57,7 +57,7 @@ const size = (bytes: number) =>
|
||||
|
||||
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null);
|
||||
|
||||
export const BackupsRow = () => {
|
||||
export const BackupsRow = ({ prodName, devName }: { prodName: string; devName: string }) => {
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
@@ -70,7 +70,12 @@ export const BackupsRow = () => {
|
||||
const [sshPath, setSshPath] = useState('');
|
||||
const [keep, setKeep] = useState('7');
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [busy, setBusy] = useState<'save' | 'run' | 'test' | null>(null);
|
||||
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);
|
||||
@@ -137,6 +142,28 @@ export const BackupsRow = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -186,6 +213,21 @@ export const BackupsRow = () => {
|
||||
</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" />}
|
||||
@@ -336,9 +378,67 @@ export const BackupsRow = () => {
|
||||
|
||||
<Console output={output} failed={failed} />
|
||||
|
||||
{settings.recent.length > 0 && !open && (
|
||||
{/*
|
||||
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, 5).map((backup) => (
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user