Files
fofos-admin/web/BackupsRow.tsx
T
pastilhasandClaude Opus 5 c429dc3455 a remote path without a leading slash is relative to the remote home
Requiring an absolute one was simply wrong. `scp file host:db-backups/fofos/`
lands under the remote account's home — that is what every ssh tool does, and
what anybody typing a destination expects, since the home directory is the one
place they are certain to be able to write.

What is still refused is unchanged and is the part that matters: the characters
a shell would act on. This value IS interpreted remotely, since `ssh host
mkdir -p <path>` runs through a shell, so they are rejected rather than escaped
— refusing is checkable by reading the function, escaping is checkable only by
being right about every shell.

The summary shows a relative destination as ~/… because that is where it lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:07:50 +01:00

352 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from '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';
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 = () => {
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' | null>(null);
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 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">
{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&rsquo;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} />
{settings.recent.length > 0 && !open && (
<ul className="space-y-0.5">
{settings.recent.slice(0, 5).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>
);
};