Files
FabledGrimoire/frontend/src/components/RestoreBackupDialog.vue
T
bvandeusenandClaude Opus 5.5 1f5e963c35
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
feat(6848): Settings restore rolls back, from the destination or a file, after a preview
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>
2026-11-04 20:06:07 -05:00

279 lines
9.5 KiB
Vue

<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>