diff --git a/frontend/src/api/backups.ts b/frontend/src/api/backups.ts new file mode 100644 index 00000000..f4d777f5 --- /dev/null +++ b/frontend/src/api/backups.ts @@ -0,0 +1,73 @@ +import { apiGet, apiPost, apiPut } from "@/api/client"; + +/** + * Scheduled backups (milestone 523) — `/api/admin/backups`. One dated, + * uncompressed tar per run, delivered to a folder or an S3 bucket. + */ +export interface BackupConfig { + enabled: boolean; + /** UTC hour the daily backup runs at, 0–23. */ + hour: number; + destination: "folder" | "s3"; + folder_path: string; + s3_endpoint: string; + s3_bucket: string; + s3_access_key: string; + s3_region: string; + s3_verify_tls: boolean; +} + +export interface BackupRun { + started_at: string; + finished_at: string; + trigger: "scheduled" | "manual"; + status: "ok" | "failed"; + destination?: string; + file?: string; + size_bytes?: number; + where?: string; + error?: string; + duration_s: number; +} + +export interface BackupsOverview { + config: BackupConfig; + /** Never the secret itself: whether one is kept, and whether this + * instance can still open it. */ + secret: { set: boolean; readable: boolean }; + /** What stops the saved config from sending a backup, in words. */ + problems: string[]; + running: boolean; + /** Newest first, at most 30. */ + runs: BackupRun[]; + last_run: BackupRun | null; + last_success: BackupRun | null; + overdue: boolean; +} + +/** What the form sends: config fields, plus the secret when one was typed. */ +export type BackupChanges = Partial & { + s3_secret?: string; + clear_s3_secret?: boolean; +}; + +export function getBackups(): Promise { + return apiGet("/api/admin/backups"); +} + +export function saveBackups(changes: BackupChanges): Promise { + return apiPut("/api/admin/backups", changes); +} + +/** Ask the destination whether it would take a backup, with the form's + * unsaved values. The server gives the destination 45s; this waits a little + * longer so its own answer, not the client's deadline, is what arrives. */ +export function checkBackupDestination( + changes: BackupChanges, +): Promise<{ ok: boolean; message: string }> { + return apiPost<{ ok: boolean; message: string }>("/api/admin/backups/check", changes, { timeoutMs: 60_000 }); +} + +export function runBackupNow(): Promise<{ status: string }> { + return apiPost<{ status: string }>("/api/admin/backups/run", {}); +} diff --git a/frontend/src/components/BackupScheduleSettings.vue b/frontend/src/components/BackupScheduleSettings.vue new file mode 100644 index 00000000..4a8f7248 --- /dev/null +++ b/frontend/src/components/BackupScheduleSettings.vue @@ -0,0 +1,388 @@ + + + + + diff --git a/frontend/src/utils/bytes.ts b/frontend/src/utils/bytes.ts new file mode 100644 index 00000000..760e581f --- /dev/null +++ b/frontend/src/utils/bytes.ts @@ -0,0 +1,8 @@ +/** "1.4 MB" — a byte count for people. Binary units, one decimal under 10. */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + const units = ["KB", "MB", "GB", "TB"]; + let v = n / 1024, i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; +} diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index eb5135f2..4cc4832e 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -11,6 +11,8 @@ import PaginationBar from "@/components/PaginationBar.vue"; import TagInput from "@/components/TagInput.vue"; import MomentsSettings from "@/components/MomentsSettings.vue"; import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue"; +import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue"; +import { formatBytes } from "@/utils/bytes"; import { fmtDate, fmtLogStamp } from "@/utils/dateFormat"; import { fetchVersion, type VersionPayload } from "@/api/version"; @@ -640,13 +642,6 @@ const dbHealth = ref(null); const loadingHealth = ref(false); const DEAD_PCT_WARN = 20; // dead-tuple ratio above this = autovacuum falling behind -function formatBytes(n: number): string { - if (n < 1024) return `${n} B`; - const units = ["KB", "MB", "GB", "TB"]; - let v = n / 1024, i = 0; - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } - return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`; -} const pluginInstallCommands = computed(() => { const mkt = (pluginMarketplaceUrl.value || '').trim() || serverMarketplaceUrl.value @@ -1368,14 +1363,21 @@ async function handleRestoreFile(event: Event) { if (!file) return; restoring.value = true; try { - const text = await file.text(); - const data = JSON.parse(text); + // The scheduled backup's .tar goes up as it is; the JSON download is + // parsed first so a damaged file fails here, before the upload. + const isTar = file.name.toLowerCase().endsWith(".tar"); const res = await fetch("/api/admin/restore", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), + headers: { "Content-Type": isTar ? "application/x-tar" : "application/json" }, + body: isTar ? file : JSON.stringify(JSON.parse(await file.text())), signal: bulkDeadline(), }); + if (res.status === 413) { + throw new Error( + "the file is bigger than the web upload allows. Restore it with " + + "`python -m scribe.restore ` inside the Scribe container instead.", + ); + } if (!res.ok) { const body = await res.json().catch(() => ({ error: `Error ${res.status}` })); throw new Error((body as Record).error || `Error ${res.status}`); @@ -2650,12 +2652,16 @@ async function deleteUser(userId: number) { +
+

Scheduled backups

+ +