feat(backup): add pre-migration backup, periodic backup, backfill warning, and backup management UI

Four improvements to the database backup mechanism:

1. Auto backup before schema migration - creates a snapshot when
   upgrading from an older database version, providing a safety net
   beyond the existing SAVEPOINT rollback mechanism.

2. Periodic startup backup - checks on app launch whether the latest
   backup is older than 24 hours and creates a new one if needed,
   ensuring all users have recent backups regardless of usage patterns.

3. Backfill failure notification - switch now returns SwitchResult with
   warnings instead of silently ignoring backfill errors, so users are
   informed when their manual config changes may not have been saved.

4. Backup management UI - new BackupListSection in Settings > Data
   Management showing all backup snapshots with restore capability,
   including a confirmation dialog and automatic safety backup before
   restore.
This commit is contained in:
Jason
2026-02-21 23:56:56 +08:00
parent 5ebc879f09
commit 3afec8a10f
18 changed files with 490 additions and 24 deletions
@@ -0,0 +1,166 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { HardDriveDownload, RotateCcw } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useBackupManager } from "@/hooks/useBackupManager";
import { extractErrorMessage } from "@/utils/errorUtils";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatBackupDate(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
export function BackupListSection() {
const { t } = useTranslation();
const { backups, isLoading, restore, isRestoring } = useBackupManager();
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
const handleRestore = async () => {
if (!confirmFilename) return;
try {
const safetyId = await restore(confirmFilename);
setConfirmFilename(null);
toast.success(
t("settings.backupManager.restoreSuccess", {
defaultValue: "Restore successful! Safety backup created",
}),
{
description: safetyId
? `${t("settings.backupManager.safetyBackupId", { defaultValue: "Safety Backup ID" })}: ${safetyId}`
: undefined,
duration: 6000,
closeButton: true,
},
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("settings.backupManager.restoreFailed", {
defaultValue: "Restore failed",
});
toast.error(detail);
}
};
return (
<div className="mt-4 pt-4 border-t border-border/50">
<div className="flex items-center gap-2 mb-3">
<HardDriveDownload className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">
{t("settings.backupManager.title", {
defaultValue: "Database Backups",
})}
</h4>
</div>
<p className="text-xs text-muted-foreground mb-3">
{t("settings.backupManager.description", {
defaultValue:
"Automatic database snapshots for restoring to a previous state",
})}
</p>
{isLoading ? (
<div className="text-sm text-muted-foreground py-2">Loading...</div>
) : backups.length === 0 ? (
<div className="text-sm text-muted-foreground py-2">
{t("settings.backupManager.empty", {
defaultValue: "No backups yet",
})}
</div>
) : (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{backups.map((backup) => (
<div
key={backup.filename}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors text-sm"
>
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate">
{formatBackupDate(backup.createdAt)}
</div>
<div className="text-xs text-muted-foreground">
{formatBytes(backup.sizeBytes)}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs shrink-0"
disabled={isRestoring}
onClick={() => setConfirmFilename(backup.filename)}
>
<RotateCcw className="h-3 w-3 mr-1" />
{isRestoring
? t("settings.backupManager.restoring", {
defaultValue: "Restoring...",
})
: t("settings.backupManager.restore", {
defaultValue: "Restore",
})}
</Button>
</div>
))}
</div>
)}
{/* Confirmation Dialog */}
<Dialog
open={!!confirmFilename}
onOpenChange={(open) => !open && setConfirmFilename(null)}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogHeader>
<DialogTitle>
{t("settings.backupManager.confirmTitle", {
defaultValue: "Confirm Restore",
})}
</DialogTitle>
<DialogDescription>
{t("settings.backupManager.confirmMessage", {
defaultValue:
"Restoring this backup will overwrite the current database. A safety backup will be created first.",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmFilename(null)}
disabled={isRestoring}
>
{t("common.cancel", { defaultValue: "Cancel" })}
</Button>
<Button onClick={handleRestore} disabled={isRestoring}>
{isRestoring
? t("settings.backupManager.restoring", {
defaultValue: "Restoring...",
})
: t("settings.backupManager.restore", {
defaultValue: "Restore",
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+2
View File
@@ -33,6 +33,7 @@ import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSe
import { TerminalSettings } from "@/components/settings/TerminalSettings";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { BackupListSection } from "@/components/settings/BackupListSection";
import { WebdavSyncSection } from "@/components/settings/WebdavSyncSection";
import { AboutSection } from "@/components/settings/AboutSection";
import { ProxyTabContent } from "@/components/settings/ProxyTabContent";
@@ -324,6 +325,7 @@ export function SettingsPage({
onExport={exportConfig}
onClear={clearSelection}
/>
<BackupListSection />
</AccordionContent>
</AccordionItem>
+32
View File
@@ -0,0 +1,32 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { backupsApi } from "@/lib/api";
export function useBackupManager() {
const queryClient = useQueryClient();
const {
data: backups = [],
isLoading,
refetch,
} = useQuery({
queryKey: ["db-backups"],
queryFn: () => backupsApi.listDbBackups(),
});
const restoreMutation = useMutation({
mutationFn: (filename: string) => backupsApi.restoreDbBackup(filename),
onSuccess: async () => {
// Invalidate all queries to refresh data from restored database
await queryClient.invalidateQueries();
// Refetch backup list
await refetch();
},
});
return {
backups,
isLoading,
restore: restoreMutation.mutateAsync,
isRestoring: restoreMutation.isPending,
};
}
+12 -1
View File
@@ -134,9 +134,20 @@ export function useProviderActions(activeApp: AppId) {
const switchProvider = useCallback(
async (provider: Provider) => {
try {
await switchProviderMutation.mutateAsync(provider.id);
const result = await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
// Show backfill warning if present
if (result?.warnings?.length) {
toast.warning(
t("notifications.backfillWarning", {
defaultValue:
"切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
}),
{ duration: 5000 },
);
}
// 根据供应商类型显示不同的成功提示
if (
activeApp === "claude" &&
+14 -1
View File
@@ -174,7 +174,8 @@
"openclawModelsRegistered": "Models have been registered to /model list",
"openclawDefaultModelSet": "Set as default model",
"openclawDefaultModelSetFailed": "Failed to set default model",
"openclawNoModels": "No models configured"
"openclawNoModels": "No models configured",
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider"
},
"confirm": {
"deleteProvider": "Delete Provider",
@@ -295,6 +296,18 @@
"selectFileFailed": "Please choose a valid SQL backup file",
"configCorrupted": "SQL file may be corrupted or invalid",
"backupId": "Backup ID",
"backupManager": {
"title": "Database Backups",
"description": "Automatic database snapshots for restoring to a previous state",
"empty": "No backups yet",
"restore": "Restore",
"restoring": "Restoring...",
"confirmTitle": "Confirm Restore",
"confirmMessage": "Restoring this backup will overwrite the current database. A safety backup will be created first.",
"restoreSuccess": "Restore successful! Safety backup created",
"restoreFailed": "Restore failed",
"safetyBackupId": "Safety Backup ID"
},
"webdavSync": {
"title": "WebDAV Cloud Sync",
"description": "Sync database and skill configurations across devices via WebDAV.",
+14 -1
View File
@@ -174,7 +174,8 @@
"openclawModelsRegistered": "モデルが /model リストに登録されました",
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
"openclawNoModels": "モデルが設定されていません"
"openclawNoModels": "モデルが設定されていません",
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
@@ -295,6 +296,18 @@
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
"backupId": "バックアップ ID",
"backupManager": {
"title": "データベースバックアップ",
"description": "以前の状態に復元するための自動データベーススナップショット",
"empty": "バックアップはまだありません",
"restore": "復元",
"restoring": "復元中...",
"confirmTitle": "バックアップの復元を確認",
"confirmMessage": "このバックアップを復元すると現在のデータベースが上書きされます。安全バックアップが先に作成されます。",
"restoreSuccess": "復元成功!安全バックアップが作成されました",
"restoreFailed": "復元に失敗しました",
"safetyBackupId": "安全バックアップID"
},
"webdavSync": {
"title": "WebDAV クラウド同期",
"description": "WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。",
+14 -1
View File
@@ -174,7 +174,8 @@
"openclawModelsRegistered": "模型已注册到 /model 列表",
"openclawDefaultModelSet": "已设为默认模型",
"openclawDefaultModelSetFailed": "设置默认模型失败",
"openclawNoModels": "该供应商没有配置模型"
"openclawNoModels": "该供应商没有配置模型",
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存"
},
"confirm": {
"deleteProvider": "删除供应商",
@@ -295,6 +296,18 @@
"selectFileFailed": "请选择有效的 SQL 备份文件",
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
"backupId": "备份ID",
"backupManager": {
"title": "数据库备份",
"description": "自动备份的数据库快照,可用于恢复到之前的状态",
"empty": "暂无备份",
"restore": "恢复",
"restoring": "恢复中...",
"confirmTitle": "确认恢复备份",
"confirmMessage": "恢复到此备份将覆盖当前数据库。恢复前会自动创建安全备份。",
"restoreSuccess": "恢复成功!安全备份已创建",
"restoreFailed": "恢复失败",
"safetyBackupId": "安全备份ID"
},
"webdavSync": {
"title": "WebDAV 云同步",
"description": "通过 WebDAV 在多设备间同步数据库和技能配置。",
+1
View File
@@ -1,6 +1,7 @@
export type { AppId } from "./types";
export { providersApi, universalProvidersApi } from "./providers";
export { settingsApi } from "./settings";
export { backupsApi } from "./settings";
export { mcpApi } from "./mcp";
export { promptsApi } from "./prompts";
export { skillsApi } from "./skills";
+5 -1
View File
@@ -17,6 +17,10 @@ export interface ProviderSwitchEvent {
providerId: string;
}
export interface SwitchResult {
warnings: string[];
}
export const providersApi = {
async getAll(appId: AppId): Promise<Record<string, Provider>> {
return await invoke("get_providers", { app: appId });
@@ -46,7 +50,7 @@ export const providersApi = {
return await invoke("remove_provider_from_live_config", { id, app: appId });
},
async switch(id: string, appId: AppId): Promise<boolean> {
async switch(id: string, appId: AppId): Promise<SwitchResult> {
return await invoke("switch_provider", { id, app: appId });
},
+16
View File
@@ -215,3 +215,19 @@ export interface LogConfig {
enabled: boolean;
level: "error" | "warn" | "info" | "debug" | "trace";
}
export interface BackupEntry {
filename: string;
sizeBytes: number;
createdAt: string;
}
export const backupsApi = {
async listDbBackups(): Promise<BackupEntry[]> {
return await invoke("list_db_backups");
},
async restoreDbBackup(filename: string): Promise<string> {
return await invoke("restore_db_backup", { filename });
},
};
+2 -1
View File
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { providersApi, settingsApi, type AppId } from "@/lib/api";
import type { SwitchResult } from "@/lib/api/providers";
import type { Provider, Settings } from "@/types";
import { extractErrorMessage } from "@/utils/errorUtils";
import { generateUUID } from "@/utils/uuid";
@@ -171,7 +172,7 @@ export const useSwitchProviderMutation = (appId: AppId) => {
const { t } = useTranslation();
return useMutation({
mutationFn: async (providerId: string) => {
mutationFn: async (providerId: string): Promise<SwitchResult> => {
return await providersApi.switch(providerId, appId);
},
onSuccess: async () => {