Two databases with identical structure look identical until you count what is in them — one is the live site and one is a copy. So a row now reads "18 tables · 155 rows" with the time anything was last written, rather than only reporting that a schema exists. Counted with query_to_xml over information_schema, which is exact. reltuples from the planner would be free and is an ESTIMATE: it reads as zero on a table nobody has analysed, which is precisely the freshly restored database this is meant to reassure somebody about. That is not a hypothetical — it is why pg_stat_user_tables reported an empty database earlier today that had 155 rows in it. And the block collapses with a check once everything is in order, on the same terms as Root location: both databases existing with a schema, and backups configured. Not backups having RUN — a site set up an hour ago has taken none, and calling that incomplete is telling somebody to fix what is not broken. Seeded from /summary so the first paint is already right, then kept live by what the block and BackupsRow know. The `null` sentinel in `collapsed` is left alone while unsettled, which is the bug that stopped Root location folding at all. BackupsRow reports its bit from an EFFECT, not during render: calling the parent's setter mid-render is an update to a different component, which React refuses outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
489 lines
18 KiB
TypeScript
489 lines
18 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[];
|
|
};
|
|
|
|
/** 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<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]);
|
|
|
|
/*
|
|
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<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 for a SCHEDULE. "On publish" is not one, so the
|
|
box would be a field with nothing to put in it. */}
|
|
{cron === ON_PUBLISH ? (
|
|
<p className="text-xs text-muted-foreground/70">
|
|
A backup is taken every time the site is published, and at no other time.
|
|
</p>
|
|
) : (
|
|
<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>
|
|
);
|
|
};
|