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);
}
@@ -0,0 +1,278 @@
<script setup lang="ts">
/**
* Restore: put the whole instance back to a full backup.
*
* Choose a backup (one at the backup destination, or a file from this
* computer), see what it would change, type the confirmation, run it. On a
* live instance this is a rollback to the instance's own earlier backup; on a
* new, empty one it takes the backup in. Either way the server backs up the
* current state first and signs everyone out afterwards.
*/
import { computed, onMounted, ref } from "vue";
import { apiErrorMessage } from "@/api/client";
import {
backupTakenAt, listRestoreSources, previewRestore, restoreBackup,
type RestoreFrom, type RestorePreview, type RestoreResult, type StoredBackup,
} from "@/api/backups";
import { formatBytes } from "@/utils/bytes";
const emit = defineEmits<{ close: [] }>();
const CONFIRM_WORDS = "roll back";
type Step = "choose" | "checking" | "preview" | "running" | "done";
const step = ref<Step>("choose");
const sources = ref<StoredBackup[]>([]);
const sourcesError = ref("");
const sourcesLoading = ref(true);
const chosen = ref<RestoreFrom | null>(null);
const chosenLabel = ref("");
const preview = ref<RestorePreview | null>(null);
const result = ref<RestoreResult | null>(null);
const error = ref("");
const typed = ref("");
const fileInput = ref<HTMLInputElement | null>(null);
const confirmed = computed(() => typed.value.trim().toLowerCase() === CONFIRM_WORDS);
function when(value: Date | string | null | undefined): string {
if (!value) return "an unknown date";
const date = typeof value === "string" ? new Date(value) : value;
return Number.isNaN(date.getTime()) ? "an unknown date" : date.toLocaleString();
}
onMounted(async () => {
try {
const answer = await listRestoreSources();
sources.value = answer.backups;
sourcesError.value = answer.error ?? "";
} catch (e) {
sourcesError.value = apiErrorMessage(e, "Could not list the backups at the destination.");
} finally {
sourcesLoading.value = false;
}
});
async function check(from: RestoreFrom, label: string) {
chosen.value = from;
chosenLabel.value = label;
error.value = "";
typed.value = "";
step.value = "checking";
try {
preview.value = await previewRestore(from);
step.value = "preview";
} catch (e) {
error.value = apiErrorMessage(e, "That backup could not be read.");
step.value = "choose";
}
}
function pickFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (fileInput.value) fileInput.value.value = "";
if (file) check({ file }, file.name);
}
async function run() {
if (!chosen.value || !confirmed.value) return;
error.value = "";
step.value = "running";
try {
result.value = await restoreBackup(chosen.value);
step.value = "done";
} catch (e) {
error.value = apiErrorMessage(e, "The restore failed. Nothing was changed.");
step.value = "preview";
}
}
function finish() {
// Every session ended with the restore, this one included.
window.location.assign("/login");
}
</script>
<template>
<teleport to="body">
<div class="modal-overlay" @click="step === 'running' || step === 'done' ? null : emit('close')">
<div class="modal-card restore-card" @click.stop>
<template v-if="step === 'choose' || step === 'checking'">
<h3 class="modal-title">Restore from a backup</h3>
<p class="modal-message">
Puts the whole instance back to the moment the backup was taken, for every user.
You'll see what changes before anything happens.
</p>
<p v-if="error" class="restore-error" role="alert">{{ error }}</p>
<h4 class="restore-subhead">At the backup destination</h4>
<p v-if="sourcesLoading" class="restore-muted">Looking for backups…</p>
<p v-else-if="sourcesError" class="restore-muted">{{ sourcesError }}</p>
<p v-else-if="!sources.length" class="restore-muted">No backups there yet.</p>
<ul v-else class="restore-sources">
<li v-for="b in sources" :key="b.name">
<button
class="restore-source"
:disabled="step === 'checking'"
@click="check({ source: b.name }, when(backupTakenAt(b.name)))"
>
<span>{{ when(backupTakenAt(b.name)) }}</span>
<span class="restore-muted">{{ formatBytes(b.size_bytes) }}</span>
</button>
</li>
</ul>
<h4 class="restore-subhead">From this computer</h4>
<p class="restore-muted">
A backup file (.tar) or the Full Backup download (.json). Uploads are size-limited;
a large file restores from the destination above, or with
<code>python -m grimoire.restore</code>.
</p>
<div class="modal-actions restore-actions-left">
<button class="modal-btn" :disabled="step === 'checking'" @click="fileInput?.click()">
Choose a file…
</button>
<input ref="fileInput" type="file" accept=".json,.tar" class="hidden-file-input" @change="pickFile" />
</div>
<p v-if="step === 'checking'" class="restore-muted">Reading {{ chosenLabel }}…</p>
<div class="modal-actions">
<button class="modal-btn" @click="emit('close')">Cancel</button>
</div>
</template>
<template v-else-if="(step === 'preview' || step === 'running') && preview">
<h3 class="modal-title">
{{ preview.mode === "rollback" ? "Roll back to this backup?" : "Restore this backup?" }}
</h3>
<p class="modal-message">
<template v-if="preview.mode === 'rollback'">
Everything goes back to {{ when(preview.exported_at) }}, for every user. Anything made
since is removed, edits since are undone, and records deleted since come back.
</template>
<template v-else>
This instance has no records yet, so it takes the backup from {{ when(preview.exported_at) }} as it is.
</template>
The current state is backed up first, and everyone is signed out afterwards.
</p>
<table class="restore-counts">
<thead><tr><th></th><th>Now</th><th>After</th></tr></thead>
<tbody>
<tr v-for="c in preview.counts" :key="c.kind">
<td>{{ c.kind }}</td><td>{{ c.now }}</td><td>{{ c.after }}</td>
</tr>
</tbody>
</table>
<p v-if="error" class="restore-error" role="alert">{{ error }}</p>
<label class="restore-confirm">
Type <strong>{{ CONFIRM_WORDS }}</strong> to continue
<input v-model="typed" class="fs-input input" :disabled="step === 'running'" autocomplete="off" />
</label>
<div class="modal-actions">
<button class="modal-btn" :disabled="step === 'running'" @click="step = 'choose'">Back</button>
<button
class="modal-btn modal-btn-danger"
:disabled="!confirmed || step === 'running'"
@click="run"
>{{ step === "running" ? "Restoring…" : preview.mode === "rollback" ? "Roll back" : "Restore" }}</button>
</div>
</template>
<template v-else-if="step === 'done' && result">
<h3 class="modal-title">Restored</h3>
<p class="modal-message">
The instance is back to {{ when(result.exported_at) }}.
The state it replaced was saved as <code>{{ result.safety_backup.file }}</code>
in {{ result.safety_backup.where }}.
<template v-if="result.safety_backup.note"> {{ result.safety_backup.note }}</template>
Search is being rebuilt in the background. Everyone has been signed out, so sign in again.
</p>
<div class="modal-actions">
<button class="modal-btn modal-btn-primary" @click="finish">Sign in again</button>
</div>
</template>
</div>
</div>
</teleport>
</template>
<style scoped>
.restore-card {
max-width: 34rem;
max-height: 85vh;
overflow-y: auto;
}
.restore-subhead {
margin: 1rem 0 0.4rem;
font-size: 0.9rem;
}
.restore-muted {
font-size: 0.85rem;
color: var(--fs-text-secondary);
margin: 0 0 0.5rem;
}
.restore-error {
font-size: 0.85rem;
color: var(--fs-action-destructive);
margin: 0 0 0.75rem;
}
.restore-sources {
list-style: none;
margin: 0 0 0.5rem;
padding: 0;
max-height: 14rem;
overflow-y: auto;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
}
.restore-source {
display: flex;
justify-content: space-between;
width: 100%;
padding: 0.45rem 0.7rem;
border: none;
border-bottom: 1px solid var(--fs-border-color);
background: none;
color: var(--fs-text-primary);
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
}
.restore-sources li:last-child .restore-source {
border-bottom: none;
}
.restore-source:hover:not(:disabled) {
background: var(--fs-surface-hover);
}
.restore-actions-left {
justify-content: flex-start;
margin-bottom: 1rem;
}
.restore-counts {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
margin: 0 0 1rem;
}
.restore-counts th,
.restore-counts td {
text-align: right;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.restore-counts th:first-child,
.restore-counts td:first-child {
text-align: left;
}
.hidden-file-input {
display: none;
}
.restore-confirm {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.875rem;
margin: 0 0 1rem;
}
</style>
+5 -52
View File
@@ -11,6 +11,7 @@ import PaginationBar from "@/components/PaginationBar.vue";
import MomentsSettings from "@/components/MomentsSettings.vue";
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
import BackupScheduleSettings from "@/components/BackupScheduleSettings.vue";
import RestoreBackupDialog from "@/components/RestoreBackupDialog.vue";
import { formatBytes } from "@/utils/bytes";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
import { fetchVersion, type VersionPayload } from "@/api/version";
@@ -467,7 +468,7 @@ const confirmNewPassword = ref("");
const changingPassword = ref(false);
const invalidatingSessions = ref(false);
const exporting = ref(false);
const restoring = ref(false);
const showRestore = ref(false);
// Backup, export and restore walk the whole store, so they are slow BY DESIGN
// and the client's ordinary 30s default would cut them off mid-work. They are
// still bounded — every wait has a deadline, not necessarily a short one — and
@@ -509,7 +510,6 @@ async function copyCommit() {
commitCopied.value = true;
setTimeout(() => { commitCopied.value = false; }, 2000);
}
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
@@ -1079,10 +1079,6 @@ async function exportNotes(format: "markdown" | "json") {
}
}
function triggerRestoreUpload() {
restoreFileInput.value?.click();
}
async function saveNotifications() {
savingNotifications.value = true;
notificationsSaved.value = false;
@@ -1279,42 +1275,6 @@ async function runDbMaintenanceNow() {
}
}
async function handleRestoreFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
restoring.value = true;
try {
// 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": 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 grimoire.restore <file>` inside the Grimoire container instead.",
);
}
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const result = await res.json();
toastStore.show(
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
);
} catch (e) {
toastStore.show("Restore failed: " + (e as Error).message, "error");
} finally {
restoring.value = false;
if (restoreFileInput.value) restoreFileInput.value.value = "";
}
}
async function testSearch() {
const q = searchQuery.value.trim();
if (!q) return;
@@ -2429,17 +2389,11 @@ async function deleteUser(userId: number) {
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
{{ exporting ? "Exporting..." : "Full Backup" }}
</button>
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
{{ restoring ? "Restoring..." : "Restore from Backup" }}
<button class="btn-secondary btn-warn" @click="showRestore = true">
Restore from Backup
</button>
<input
ref="restoreFileInput"
type="file"
accept=".json,.tar"
class="hidden-file-input"
@change="handleRestoreFile"
/>
</div>
<RestoreBackupDialog v-if="showRestore" @close="showRestore = false" />
</section>
<section class="settings-section full-width">
<h2>Scheduled backups</h2>
@@ -3597,7 +3551,6 @@ async function deleteUser(userId: number) {
flex-wrap: wrap;
gap: 0.5rem;
}
.hidden-file-input { display: none; }
/* Checkboxes */
.checkbox-field { margin-bottom: 1rem; }