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>
279 lines
9.5 KiB
Vue
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>
|