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
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:
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user