backups: a schedule, a location, and a timer that survives a restart
Restore goes. What it would read from was never decided, and a disabled button with a tooltip explaining it is not built is worse than no button. In its place, backups. `cron` was already a dependency and used nowhere, so the schedule is a real cron expression — a backup wants to happen at an HOUR, and an interval counted from whenever somebody pressed save drifts across the clock until it runs mid-afternoon. Cron alone is not enough, and this is the part worth writing down: it does not run a job it was down for. A daily backup at three on a machine that restarts overnight is skipped silently — the schedule is still there, the next fire is still in the future, and a month later there is one file. So `lastBackupAt` is in the database, and both the startup and a fifteen-minute tick compare it against the schedule's own period, computed from two successive fires. A backup that should already have happened happens now. The job handle is module-level and stopped before a new one is made. Two saves leaving two jobs is a bug that surfaces as backups arriving in pairs, weeks later, with nothing connecting it to a settings form. Back up now exists because a scheduled job nobody has ever run by hand is a guess — it is how you learn the location is writable and pg_dump can reach the databases without waiting until three to find out it cannot. The list underneath is a readdir of the location rather than a record of what should be there: a schedule that has been failing quietly looks exactly like a working one until you count the files. pg_dump per database, not pg_dumpall — a cluster dump would also carry Officer's own, which is not this plugin's to copy around. And a failed run still records the timestamp, or one undumpable database would have the catch-up tick retrying every fifteen minutes for the rest of the week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, DatabaseBackup, Loader2, PlayCircle } 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 | null;
|
||||
lastBackupAt: string | null;
|
||||
nextRunAt: string | null;
|
||||
recent: Backup[];
|
||||
};
|
||||
|
||||
/** Every schedule anybody has actually wanted, and their expressions. */
|
||||
const CHOICES = [
|
||||
{ label: 'Hourly', cron: '0 * * * *' },
|
||||
{ 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 [picking, setPicking] = useState(false);
|
||||
const [busy, setBusy] = useState<'save' | 'run' | 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));
|
||||
setDir((typed) => typed || (current.dir ?? ''));
|
||||
} 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 }));
|
||||
setOpen(false);
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
} 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;
|
||||
|
||||
const configured = Boolean(settings.cron && settings.dir);
|
||||
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.dir ? <span className="text-muted-foreground"> · {settings.dir}</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">Where</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>
|
||||
|
||||
{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()}>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
+6
-18
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowUpFromLine, Check, CircleCheck, CircleDashed, Database, DatabaseBackup, Loader2, Plus } from 'lucide-react';
|
||||
import { ArrowUpFromLine, Check, CircleCheck, CircleDashed, Database, Loader2, Plus } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { readableError } from './errors';
|
||||
import { Block } from './Block';
|
||||
import { Console } from './Console';
|
||||
import { BackupsRow } from './BackupsRow';
|
||||
import { useFlash } from './useFlash';
|
||||
|
||||
/**
|
||||
@@ -185,9 +186,6 @@ export const DatabaseBlock = () => {
|
||||
);
|
||||
|
||||
const row = (label: string, target: 'production' | 'development', state: Named) => {
|
||||
// "Schema pushed" is simply: something is in it. See the note on `tables`.
|
||||
const pushed = (state.tables ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -196,7 +194,7 @@ export const DatabaseBlock = () => {
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 text-xs text-muted-foreground/70">
|
||||
{state.tables === null ? '' : pushed ? `${state.tables} tables` : 'empty'}
|
||||
{state.tables === null ? '' : state.tables > 0 ? `${state.tables} tables` : 'empty'}
|
||||
</span>
|
||||
|
||||
<div className="flex shrink-0 gap-2">
|
||||
@@ -215,19 +213,6 @@ export const DatabaseBlock = () => {
|
||||
Push schema
|
||||
</Button>
|
||||
|
||||
{/* Nothing to restore INTO until there are tables to restore into — a data-only load against
|
||||
an empty database fails on every insert, and that failure would read as a bad backup.
|
||||
Still disabled beyond that: what a backup IS here has not been decided, and a button that
|
||||
guesses at the source of a restore is not a thing to put near a client's data. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled
|
||||
title={pushed ? 'Not built yet — no backup source chosen' : 'Push the schema first'}
|
||||
>
|
||||
<DatabaseBackup className="size-3.5" />
|
||||
Restore from backup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -255,6 +240,9 @@ export const DatabaseBlock = () => {
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<Console output={output} failed={failed} />
|
||||
|
||||
{/* Below the two databases, because it is about both of them rather than either. */}
|
||||
<BackupsRow />
|
||||
|
||||
{/* The common case is both. Doing them one at a time is still there for when it is not. */}
|
||||
<div className="flex justify-end border-t border-border pt-3">
|
||||
<Button size="sm" onClick={() => void push('both')} disabled={busy !== null}>
|
||||
|
||||
Reference in New Issue
Block a user