backups go to another machine, and old ones are pruned
Three things. The local directory defaults to <officerdev root>/dockers/postgres/dumps, derived from OFFICER_ROOT rather than written down — the same reasoning as rootPath: a path fixed at install freezes the layout of the machine it was installed on, and a restore elsewhere then points somewhere that is not there while looking deliberately chosen. The destination is SSH, and it is now what "set up" MEANS: a dump sitting on the machine it came from is a second copy on one disk, lost to the same failure that would make anybody want it. So local is staging and the copy off the box is the backup, and a schedule with nowhere to send it does not count as configured. Testable, in two steps reported separately, because they fail for different reasons and the fixes differ: the connection is keys and networking, the directory is permissions on the far side. It tests what is in the FORM rather than what is stored — testing a saved destination while somebody looks at a different one is worse than not testing. Everything goes out with BatchMode=yes. Nobody is at a terminal when this runs, so a key that has lost access must FAIL rather than block forever on a prompt nothing will answer. It is also why no credential is stored: there is none to store and none to leak. The remote path is the one value here a shell interprets — `ssh host mkdir -p <path>` runs through it — so shell characters are refused rather than escaped. Refusing is checkable by reading it; escaping is checkable by being right about every shell. And rotation keeps the newest N per database at both ends. Per database, because two sharing one budget means a busy one evicts the other's history. Sorted by NAME rather than mtime: the timestamp is in the filename and is sortable, while mtime stops meaning "when this was taken" the moment a file is copied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+114
-10
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, DatabaseBackup, Loader2, PlayCircle } from 'lucide-react';
|
||||
import { Check, ChevronDown, DatabaseBackup, Loader2, PlayCircle, PlugZap } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -32,7 +32,11 @@ type Backup = { file: string; bytes: number; at: string };
|
||||
|
||||
type Settings = {
|
||||
cron: string | null;
|
||||
dir: string | null;
|
||||
dir: string;
|
||||
dirIsSet: boolean;
|
||||
sshHost: string | null;
|
||||
sshPath: string | null;
|
||||
keep: number | null;
|
||||
lastBackupAt: string | null;
|
||||
nextRunAt: string | null;
|
||||
recent: Backup[];
|
||||
@@ -62,8 +66,11 @@ export const BackupsRow = () => {
|
||||
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' | null>(null);
|
||||
const [busy, setBusy] = useState<'save' | 'run' | 'test' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [output, setOutput] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -72,8 +79,13 @@ export const BackupsRow = () => {
|
||||
try {
|
||||
const current = await clientRef.current.get<Settings>('/fofos-admin/_config/backups');
|
||||
setSettings(current);
|
||||
setCron((typed) => (current.cron ?? typed));
|
||||
setDir((typed) => typed || (current.dir ?? ''));
|
||||
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);
|
||||
}
|
||||
@@ -87,7 +99,15 @@ export const BackupsRow = () => {
|
||||
setBusy('save');
|
||||
setError(null);
|
||||
try {
|
||||
setSettings(await clientRef.current.put<Settings>('/fofos-admin/_config/backups', { cron, dir }));
|
||||
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));
|
||||
@@ -96,6 +116,27 @@ export const BackupsRow = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 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 run = async () => {
|
||||
setBusy('run');
|
||||
setError(null);
|
||||
@@ -120,7 +161,8 @@ export const BackupsRow = () => {
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const configured = Boolean(settings.cron && settings.dir);
|
||||
// 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 (
|
||||
@@ -130,7 +172,12 @@ export const BackupsRow = () => {
|
||||
<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.dir ? <span className="text-muted-foreground"> · {settings.dir}</span> : null}
|
||||
{configured && settings.sshHost ? (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
→ {settings.sshHost}:{settings.sshPath}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -188,7 +235,9 @@ export const BackupsRow = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Where</p>
|
||||
<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}
|
||||
@@ -213,13 +262,68 @@ export const BackupsRow = () => {
|
||||
)}
|
||||
</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="/srv/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.</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()}>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user