From be59829dde09cff83ba404216dc36b874cda85f4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 15 Oct 2026 17:40:24 -0400 Subject: [PATCH] feat(523): Settings -> Data -> Scheduled backups - the verdict first, then destination, schedule, Test, Back up now and recent runs BackupScheduleSettings.vue leads with whether the data is backed up (last good backup, or Overdue), then what is wrong (last failure in words, an S3 secret this SECRET_KEY cannot open, incomplete config), then the form: daily toggle and UTC hour, folder or S3 bucket, Test destination with unsaved values, Back up now (polls while running, re-armed in finally), and the last runs. The secret is write-only. formatBytes moves to utils/bytes.ts for both views. Restore from Backup now also takes the .tar, and says to use the CLI on a 413. Co-Authored-By: Claude Opus 5.5 --- frontend/src/api/backups.ts | 73 ++++ .../src/components/BackupScheduleSettings.vue | 388 ++++++++++++++++++ frontend/src/utils/bytes.ts | 8 + frontend/src/views/SettingsView.vue | 30 +- 4 files changed, 487 insertions(+), 12 deletions(-) create mode 100644 frontend/src/api/backups.ts create mode 100644 frontend/src/components/BackupScheduleSettings.vue create mode 100644 frontend/src/utils/bytes.ts 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

+ +