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