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:
+243
@@ -0,0 +1,243 @@
|
|||||||
|
import { mkdir, readdir, stat } from 'node:fs/promises';
|
||||||
|
import { isAbsolute, join } from 'node:path';
|
||||||
|
import { CronJob, CronTime, validateCronExpression } from 'cron';
|
||||||
|
import { db } from 'officerdb/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fofosAdminSettings } from '../db/schema';
|
||||||
|
import { readDatabases } from './databases';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backups of the site's databases: when, where, and the thing that actually runs them.
|
||||||
|
*
|
||||||
|
* ── Cron for the schedule, a due-check for the durability ──
|
||||||
|
*
|
||||||
|
* `cron` fires at an HOUR, which is what a backup wants — an interval counted from whenever somebody
|
||||||
|
* pressed save drifts across the clock until it is running mid-afternoon.
|
||||||
|
*
|
||||||
|
* What cron does NOT do is run a job it was down for. A daily backup at three on a machine that
|
||||||
|
* restarts overnight is skipped silently, and nothing looks wrong: the schedule is still there, the
|
||||||
|
* next fire is still in the future, and a month later there is one backup. So the schedule is only half
|
||||||
|
* of this. On every start, and on every fire, the last run is compared against the schedule's own
|
||||||
|
* period, and a backup that should already have happened happens now.
|
||||||
|
*
|
||||||
|
* That is why `lastBackupAt` is in the database rather than in memory. It is the only thing that knows
|
||||||
|
* a run was missed.
|
||||||
|
*
|
||||||
|
* ── One job, replaced rather than accumulated ──
|
||||||
|
*
|
||||||
|
* The handle is module-level and stopped before a new one is made. Without that, saving the schedule
|
||||||
|
* twice leaves two jobs running and the third save leaves three — a bug that shows up as backups
|
||||||
|
* arriving in pairs, long after anybody would connect it to a settings form.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How often to look for a missed run when nothing is scheduled to fire. */
|
||||||
|
const CATCHUP_TICK_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export type BackupSettings = {
|
||||||
|
cron: string | null;
|
||||||
|
dir: string | null;
|
||||||
|
lastBackupAt: string | null;
|
||||||
|
/** From the schedule, so the panel does not have to know how to read cron. */
|
||||||
|
nextRunAt: string | null;
|
||||||
|
/** Newest first. What is actually on disk, rather than what should be. */
|
||||||
|
recent: { file: string; bytes: number; at: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRow = async () => (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null;
|
||||||
|
|
||||||
|
/** Two successive fires, which is the schedule's period — what "a run was missed" is measured against. */
|
||||||
|
function periodMs(expression: string): number | null {
|
||||||
|
try {
|
||||||
|
const time = new CronTime(expression);
|
||||||
|
const first = time.getNextDateFrom(new Date());
|
||||||
|
const second = time.getNextDateFrom(first.toJSDate());
|
||||||
|
return +second.toJSDate() - +first.toJSDate();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkCron(expression: string): string {
|
||||||
|
const trimmed = expression.trim();
|
||||||
|
if (!trimmed) throw new Error('A schedule is required.');
|
||||||
|
const result = validateCronExpression(trimmed);
|
||||||
|
if (!result.valid) throw new Error(`"${trimmed}" is not a schedule cron understands.`);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDir(path: string): string {
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (!trimmed) throw new Error('A location is required.');
|
||||||
|
if (!isAbsolute(trimmed)) throw new Error('The location must be an absolute path.');
|
||||||
|
return trimmed.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBackups(): Promise<BackupSettings> {
|
||||||
|
const row = await readRow();
|
||||||
|
const cron = row?.backupCron ?? null;
|
||||||
|
const dir = row?.backupDir ?? null;
|
||||||
|
|
||||||
|
let nextRunAt: string | null = null;
|
||||||
|
if (cron) {
|
||||||
|
try {
|
||||||
|
nextRunAt = new CronTime(cron).getNextDateFrom(new Date()).toISO();
|
||||||
|
} catch {
|
||||||
|
// A stored expression that no longer parses. Reported as no next run rather than thrown: the
|
||||||
|
// panel has to be able to render the settings in order to fix them.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cron,
|
||||||
|
dir,
|
||||||
|
lastBackupAt: row?.lastBackupAt?.toISOString() ?? null,
|
||||||
|
nextRunAt,
|
||||||
|
recent: dir ? await listBackups(dir) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listBackups(dir: string): Promise<{ file: string; bytes: number; at: string }[]> {
|
||||||
|
const names = await readdir(dir).catch(() => []);
|
||||||
|
const found = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (!name.endsWith('.sql')) continue;
|
||||||
|
const info = await stat(join(dir, name)).catch(() => null);
|
||||||
|
if (info) found.push({ file: name, bytes: info.size, at: info.mtime.toISOString() });
|
||||||
|
}
|
||||||
|
return found.sort((a, b) => b.at.localeCompare(a.at)).slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBackups(expression: string, directory: string): Promise<BackupSettings> {
|
||||||
|
const cron = checkCron(expression);
|
||||||
|
const dir = checkDir(directory);
|
||||||
|
|
||||||
|
// Made now rather than at the first run, so a location that cannot be created is a message beside the
|
||||||
|
// form instead of a failure at three in the morning.
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const row = await readRow();
|
||||||
|
if (row) {
|
||||||
|
await db
|
||||||
|
.update(fofosAdminSettings)
|
||||||
|
.set({ backupCron: cron, backupDir: dir, updatedAt: new Date() })
|
||||||
|
.where(eq(fofosAdminSettings.id, row.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(fofosAdminSettings).values({ backupCron: cron, backupDir: dir });
|
||||||
|
}
|
||||||
|
|
||||||
|
await schedule();
|
||||||
|
return readBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dump every named database, once.
|
||||||
|
*
|
||||||
|
* `pg_dump` per database rather than `pg_dumpall`: the two databases here are the site's, and a cluster
|
||||||
|
* dump would also carry Officer's own, which is not this plugin's to copy around.
|
||||||
|
*/
|
||||||
|
export async function runBackup(): Promise<{ ok: boolean; files: string[]; output: string }> {
|
||||||
|
const row = await readRow();
|
||||||
|
const dir = row?.backupDir;
|
||||||
|
if (!dir) throw new Error('No backup location is set.');
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const raw = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
|
||||||
|
if (!raw) throw new Error('The platform has no Postgres connection configured.');
|
||||||
|
|
||||||
|
const status = await readDatabases();
|
||||||
|
const targets = [status.production, status.development].filter((entry) => entry.name && entry.exists);
|
||||||
|
if (!targets.length) throw new Error('Neither database exists yet.');
|
||||||
|
|
||||||
|
// Sortable, and the same shape as the file this plugin's first manual dump used.
|
||||||
|
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-');
|
||||||
|
|
||||||
|
const files: string[] = [];
|
||||||
|
const lines: string[] = [];
|
||||||
|
let ok = true;
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const url = new URL(raw);
|
||||||
|
url.pathname = `/${target.name}`;
|
||||||
|
const out = join(dir, `${target.name}-${stamp}.sql`);
|
||||||
|
|
||||||
|
const proc = Bun.spawn(['pg_dump', url.toString(), '-f', out], {
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
stdin: 'ignore',
|
||||||
|
});
|
||||||
|
const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
files.push(`${target.name}-${stamp}.sql`);
|
||||||
|
const info = await stat(out).catch(() => null);
|
||||||
|
lines.push(`${target.name} -> ${files.at(-1)} (${info?.size ?? 0} bytes)`);
|
||||||
|
} else {
|
||||||
|
ok = false;
|
||||||
|
lines.push(`${target.name} FAILED: ${stderr.trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Recorded even when one of them failed.
|
||||||
|
|
||||||
|
Otherwise a database that cannot be dumped makes every subsequent tick think a run is overdue, and
|
||||||
|
the panel spends the rest of the week trying and failing at fifteen-minute intervals. The failure is
|
||||||
|
in the output where somebody will see it; the schedule is not the place to express it.
|
||||||
|
*/
|
||||||
|
if (row) {
|
||||||
|
await db
|
||||||
|
.update(fofosAdminSettings)
|
||||||
|
.set({ lastBackupAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(fofosAdminSettings.id, row.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok, files, output: lines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
let job: CronJob | null = null;
|
||||||
|
let catchup: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** Is a scheduled run already overdue — because the machine was off when it should have happened? */
|
||||||
|
async function runIfMissed(): Promise<void> {
|
||||||
|
const row = await readRow();
|
||||||
|
if (!row?.backupCron || !row.backupDir) return;
|
||||||
|
|
||||||
|
const period = periodMs(row.backupCron);
|
||||||
|
if (!period) return;
|
||||||
|
|
||||||
|
const last = row.lastBackupAt ? +row.lastBackupAt : 0;
|
||||||
|
if (Date.now() - last < period) return;
|
||||||
|
|
||||||
|
await runBackup().catch(() => {
|
||||||
|
// Swallowed: this runs on a timer with nobody watching, and the panel reports the state from disk.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start, restart, or stop the timer to match what is stored.
|
||||||
|
*
|
||||||
|
* Called on save and once at startup. Idempotent, and it always stops what was running first — 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.
|
||||||
|
*/
|
||||||
|
export async function schedule(): Promise<void> {
|
||||||
|
job?.stop();
|
||||||
|
job = null;
|
||||||
|
if (catchup) clearInterval(catchup);
|
||||||
|
catchup = null;
|
||||||
|
|
||||||
|
const row = await readRow();
|
||||||
|
if (!row?.backupCron || !row.backupDir) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
job = new CronJob(row.backupCron, () => void runBackup().catch(() => {}), null, true);
|
||||||
|
} catch {
|
||||||
|
// A stored expression that will not build. Left unscheduled rather than crashing the router import,
|
||||||
|
// which would take the whole plugin down over a bad string in one column.
|
||||||
|
job = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
catchup = setInterval(() => void runIfMissed(), CATCHUP_TICK_MS);
|
||||||
|
// And once now, because the most likely moment to discover a missed run is the restart after it.
|
||||||
|
void runIfMissed();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { readSync, startSync } from './sync';
|
|||||||
import { readRuns } from './ledger';
|
import { readRuns } from './ledger';
|
||||||
import { reconcile } from './derive';
|
import { reconcile } from './derive';
|
||||||
import { createDatabases, pushSchema, readDatabases } from './databases';
|
import { createDatabases, pushSchema, readDatabases } from './databases';
|
||||||
|
import { readBackups, runBackup, saveBackups, schedule } from './backups';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
||||||
|
|
||||||
@@ -320,6 +321,32 @@ router.post('/_config/databases/create', async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backups — the schedule, the location, and one on demand.
|
||||||
|
*
|
||||||
|
* `POST /run` exists so somebody can prove the location and the credentials work without waiting until
|
||||||
|
* three in the morning to find out they do not.
|
||||||
|
*/
|
||||||
|
router.get('/_config/backups', async (ctx) => ctx.json(await readBackups()));
|
||||||
|
|
||||||
|
router.put('/_config/backups', async (ctx) => {
|
||||||
|
const { cron, dir } = (ctx.get('body') ?? {}) as { cron?: string; dir?: string };
|
||||||
|
try {
|
||||||
|
return ctx.json(await saveBackups(cron ?? '', dir ?? ''));
|
||||||
|
} catch (error) {
|
||||||
|
return ctx.json({ error: (error as Error).message }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/_config/backups/run', async (ctx) => {
|
||||||
|
try {
|
||||||
|
const result = await runBackup();
|
||||||
|
return ctx.json({ ...result, settings: await readBackups() }, result.ok ? 200 : 400);
|
||||||
|
} catch (error) {
|
||||||
|
return ctx.json({ error: (error as Error).message }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/_config/summary', async (ctx) => {
|
router.get('/_config/summary', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const settings = await readSettings(user.email);
|
const settings = await readSettings(user.email);
|
||||||
@@ -418,3 +445,13 @@ router.all('/*', async (ctx) => {
|
|||||||
return unreachable(ctx, error);
|
return unreachable(ctx, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Start the backup timer when this router is imported, which is once, at boot.
|
||||||
|
|
||||||
|
Not awaited: the server must not wait on a database read to finish mounting a router, and a schedule
|
||||||
|
that starts a moment late costs nothing. Failures are swallowed for the same reason the timer swallows
|
||||||
|
them — a bad expression in one column must not stop the plugin from loading, because the panel is
|
||||||
|
where somebody would go to fix it.
|
||||||
|
*/
|
||||||
|
void schedule().catch(() => {});
|
||||||
|
|||||||
@@ -102,6 +102,30 @@ export const fofosAdminSettings = pgTable('fofos_admin_settings', {
|
|||||||
dbProduction: text('db_production'),
|
dbProduction: text('db_production'),
|
||||||
dbDevelopment: text('db_development'),
|
dbDevelopment: text('db_development'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backups: when, and where they land.
|
||||||
|
*
|
||||||
|
* A cron expression, because a backup wants to happen at an HOUR — "every day at three" — and an
|
||||||
|
* interval measured from whenever somebody last pressed save drifts across the clock until it is
|
||||||
|
* running at two in the afternoon. The panel offers the handful of schedules anybody wants, and the
|
||||||
|
* expression is validated on the way in so a malformed one cannot be stored and then fail at a time
|
||||||
|
* nobody is watching.
|
||||||
|
*
|
||||||
|
* Null in either means backups are not set up. Both are needed, so one without the other is not a
|
||||||
|
* half-configured state to interpret — it is off.
|
||||||
|
*/
|
||||||
|
backupCron: text('backup_cron'),
|
||||||
|
backupDir: text('backup_dir'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the last one finished, which is what makes the schedule survive a restart.
|
||||||
|
*
|
||||||
|
* The timer does not measure elapsed time from when the process started; it asks whether a backup is
|
||||||
|
* DUE, against this. A daily backup on a machine that restarts every few hours would otherwise never
|
||||||
|
* fire once — the interval would be reset before it ever elapsed, and nothing would look wrong.
|
||||||
|
*/
|
||||||
|
lastBackupAt: timestamp('last_backup_at', { withTimezone: true }),
|
||||||
|
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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 { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { readableError } from './errors';
|
import { readableError } from './errors';
|
||||||
import { Block } from './Block';
|
import { Block } from './Block';
|
||||||
import { Console } from './Console';
|
import { Console } from './Console';
|
||||||
|
import { BackupsRow } from './BackupsRow';
|
||||||
import { useFlash } from './useFlash';
|
import { useFlash } from './useFlash';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,9 +186,6 @@ export const DatabaseBlock = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const row = (label: string, target: 'production' | 'development', state: Named) => {
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -196,7 +194,7 @@ export const DatabaseBlock = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground/70">
|
<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>
|
</span>
|
||||||
|
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
@@ -215,19 +213,6 @@ export const DatabaseBlock = () => {
|
|||||||
Push schema
|
Push schema
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -255,6 +240,9 @@ export const DatabaseBlock = () => {
|
|||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
<Console output={output} failed={failed} />
|
<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. */}
|
{/* 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">
|
<div className="flex justify-end border-t border-border pt-3">
|
||||||
<Button size="sm" onClick={() => void push('both')} disabled={busy !== null}>
|
<Button size="sm" onClick={() => void push('both')} disabled={busy !== null}>
|
||||||
|
|||||||
Reference in New Issue
Block a user