feat(6848): Settings restore rolls back, from the destination or a file, after a preview
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Python lint (push) Successful in 1s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m42s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Build & push image (push) Canceled after 22s

The web restore no longer copies a backup in beside the live data.

- GET /api/admin/restore/sources lists the backups at the backup destination,
  newest first.
- POST /api/admin/restore/preview says what a restore would do, changing
  nothing: fresh or rollback, when the backup was taken, and counts now and
  after per kind. A backup that cannot apply here gets a 409 with the reason.
- POST /api/admin/restore?confirm=rollback runs backup_job.rollback_to_backup:
  safety backup, replace, then search rebuilt in the background.

Each takes ?source=<name> (a backup at the destination) or an upload. The
destination path exists because uploads are held to the request-size limit,
and a real instance's backup is far over it. backup_s3.get_file downloads
from a bucket. backup_destination.list_backups/open_backup only accept names
the scheduled backup writes, so a request cannot reach any other file.

Settings -> Data -> Restore from Backup opens RestoreBackupDialog. The dialog:
1. Pick a backup at the destination or a file.
2. Read the preview.
3. Type "roll back".
4. Run it. The dialog then says where the replaced state was saved, and sends
   the user to sign in again.

Closes #6840. Milestone 582, step 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 20:06:07 -05:00
co-authored by Claude Opus 5.5
parent 34f8d7060b
commit 1f5e963c35
12 changed files with 657 additions and 80 deletions
+70 -1
View File
@@ -1,4 +1,4 @@
import { apiGet, apiPost, apiPut } from "@/api/client";
import { apiGet, apiPost, apiPostRaw, apiPut } from "@/api/client";
/**
* Scheduled backups — `/api/admin/backups`. One dated,
@@ -71,3 +71,72 @@ export function checkBackupDestination(
export function runBackupNow(): Promise<{ status: string }> {
return apiPost<{ status: string }>("/api/admin/backups/run", {});
}
// ── Restore: put the whole instance back to a full backup ─────────────────
export interface StoredBackup {
name: string;
size_bytes: number;
}
export interface RestoreCount {
kind: string;
now: number;
after: number;
}
export interface RestorePreview {
/** "fresh": an empty instance takes the backup. "rollback": this instance
* goes back to its own earlier backup. */
mode: "fresh" | "rollback";
exported_at: string | null;
counts: RestoreCount[];
}
export interface RestoreResult extends RestorePreview {
status: "ok";
restored: Record<string, number>;
safety_backup: { file: string; size_bytes: number; where: string; note?: string };
}
/** A backup at the destination, by name, or a file picked from this computer. */
export type RestoreFrom = { source: string } | { file: File };
// Reading, checking and writing the whole store is slow by design; bounded,
// but in minutes.
const RESTORE_TIMEOUT_MS = 10 * 60 * 1000;
export function listRestoreSources(): Promise<{ backups: StoredBackup[]; error?: string }> {
return apiGet("/api/admin/restore/sources", { timeoutMs: 60_000 });
}
async function restoreCall<T>(path: string, from: RestoreFrom, query: Record<string, string> = {}): Promise<T> {
const params = new URLSearchParams(query);
if ("source" in from) {
params.set("source", from.source);
return apiPostRaw<T>(`${path}?${params}`, null, null, { timeoutMs: RESTORE_TIMEOUT_MS });
}
const qs = params.toString() ? `?${params}` : "";
const isTar = from.file.name.toLowerCase().endsWith(".tar");
// The JSON download is parsed first, so a damaged file fails here, before
// the upload.
const body = isTar ? from.file : JSON.stringify(JSON.parse(await from.file.text()));
return apiPostRaw<T>(`${path}${qs}`, body, isTar ? "application/x-tar" : "application/json",
{ timeoutMs: RESTORE_TIMEOUT_MS });
}
export function previewRestore(from: RestoreFrom): Promise<RestorePreview> {
return restoreCall<RestorePreview>("/api/admin/restore/preview", from);
}
export function restoreBackup(from: RestoreFrom): Promise<RestoreResult> {
return restoreCall<RestoreResult>("/api/admin/restore", from, { confirm: "rollback" });
}
/** When a backup named `grimoire-20261015T020000Z.tar` was taken. */
export function backupTakenAt(name: string): Date | null {
const m = /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/.exec(name);
if (!m) return null;
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
}
+9
View File
@@ -156,6 +156,15 @@ export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Pro
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
/** A POST whose body is sent as it is (a file, or text already serialised),
* under the same deadline and error handling as every other call. */
export function apiPostRaw<T>(
path: string, body: BodyInit | null, contentType: string | null, opts?: RequestOpts,
): Promise<T> {
const headers = contentType ? { "Content-Type": contentType } : undefined;
return request<T>(path, { method: "POST", headers, body }, opts);
}
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}