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