From 69ef418d5fb9145e52ed7276ca537e1958daa63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Wed, 9 Sep 2026 00:15:12 +0800 Subject: [PATCH] fix(backup): enhance backup configuration and history management --- .../backup/DatabaseBackupConfigFields.vue | 4 +- .../ScheduledDatabaseBackupSettings.vue | 190 +++++++++++++++--- .../ScheduledDatabaseBackupSettings.spec.ts | 129 +++++++++++- .../sidebar/SidebarTreeRuntimeHost.vue | 8 +- .../useScheduledDatabaseBackups.spec.ts | 38 ++++ .../useScheduledDatabaseBackups.ts | 35 +++- apps/desktop/src/i18n/locales/en.ts | 11 + apps/desktop/src/i18n/locales/es.ts | 12 ++ apps/desktop/src/i18n/locales/it.ts | 12 ++ apps/desktop/src/i18n/locales/ja.ts | 12 ++ apps/desktop/src/i18n/locales/ko.ts | 12 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 12 ++ apps/desktop/src/i18n/locales/tr.ts | 11 + apps/desktop/src/i18n/locales/zh-CN.ts | 11 + apps/desktop/src/i18n/locales/zh-TW.ts | 11 + .../src/lib/backup/scheduledDatabaseBackup.ts | 42 ++++ crates/dbx-core/src/database_export.rs | 91 +++++++-- .../dbx-core/src/db/elasticsearch_driver.rs | 39 ++-- docs/content/docs/database-backup.cn.mdx | 6 +- docs/content/docs/database-backup.mdx | 6 +- .../productionGuardEntrypoints.test.ts | 3 +- .../app-tests/scheduledDatabaseBackup.test.ts | 18 ++ .../app-tests/tableInfoDefaultValue.test.ts | 2 +- 23 files changed, 623 insertions(+), 92 deletions(-) diff --git a/apps/desktop/src/components/backup/DatabaseBackupConfigFields.vue b/apps/desktop/src/components/backup/DatabaseBackupConfigFields.vue index 60b1d6531b..5750c0b2a2 100644 --- a/apps/desktop/src/components/backup/DatabaseBackupConfigFields.vue +++ b/apps/desktop/src/components/backup/DatabaseBackupConfigFields.vue @@ -19,8 +19,9 @@ const props = withDefaults( databaseOptions: string[]; tablePatternsInput: string; loadingDatabases: boolean; + databaseLoadError?: string; }>(), - { connections: () => [], selectedDatabases: () => [], databaseOptions: () => [] }, + { connections: () => [], selectedDatabases: () => [], databaseOptions: () => [], databaseLoadError: "" }, ); const emit = defineEmits<{ @@ -139,6 +140,7 @@ watch(
{{ t("databaseBackup.noMatchingDatabases") }}
+

{{ databaseLoadError }}

diff --git a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue index 36de5814ae..38281a5047 100644 --- a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue +++ b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue @@ -16,14 +16,25 @@ import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfig import { useToast } from "@/composables/useToast"; import { translateBackendError } from "@/i18n/backend-errors"; import { generateDatabaseExportId } from "@/lib/export/databaseExport"; -import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupExecutionConfig, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup"; +import { + DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN, + databaseBackupRunDirectory, + databaseBackupRunDirectoryPatternIsValid, + nextDatabaseBackupRunAt, + normalizeDatabaseBackupTablePatterns, + supportsScheduledDatabaseBackup, + type DatabaseBackupExecutionConfig, + type DatabaseBackupFile, + type DatabaseBackupRun, + type DatabaseBackupSchedule, +} from "@/lib/backup/scheduledDatabaseBackup"; import { useConnectionStore } from "@/stores/connectionStore"; import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions"; const { t, locale } = useI18n(); const { toast } = useToast(); const connectionStore = useConnectionStore(); -const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups(); +const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups(); const scheduleDialogOpen = ref(false); const oneShotDialogOpen = ref(false); @@ -32,10 +43,11 @@ const deleteRunDialogOpen = ref(false); const renameRunDialogOpen = ref(false); const editingScheduleId = ref(""); const pendingDeleteSchedule = ref(null); -const pendingDeleteRun = ref(null); +const pendingDeleteRunIds = ref([]); const pendingRenameRun = ref(null); const renameRunName = ref(""); const loadingDatabases = ref(false); +const databaseLoadError = ref(""); const saving = ref(false); const oneShotStarting = ref(false); const databaseOptions = ref([]); @@ -43,6 +55,7 @@ const allDatabases = ref(true); const selectedDatabases = ref([]); const tablePatternsInput = ref(""); const expandedRunIds = reactive(new Set()); +const selectedRunIds = reactive(new Set()); const historyConnectionId = ref(""); const historyConnectionPickerOpen = ref(false); const historyConnectionSearch = ref(""); @@ -71,6 +84,9 @@ const filteredRuns = computed(() => return historyStatus.value === "all" || historyStatus.value === run.status; }), ); +const selectableFilteredRuns = computed(() => filteredRuns.value.filter((run) => !activeRunIds.has(run.id))); +const selectedRuns = computed(() => sortedRuns.value.filter((run) => selectedRunIds.has(run.id) && !activeRunIds.has(run.id))); +const allSelectableRunsSelected = computed(() => selectableFilteredRuns.value.length > 0 && selectableFilteredRuns.value.every((run) => selectedRunIds.has(run.id))); const selectedHistoryConnectionName = computed(() => historyConnections.value.find((connection) => connection.id === historyConnectionId.value)?.name || t("databaseBackup.allConnections")); const weekdays = computed(() => [ { value: 0, label: t("databaseBackup.weekdays.sunday") }, @@ -113,6 +129,7 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat timeOfDay: "02:00", weekday: 1, retentionCount: 10, + runDirectoryPattern: DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN, createdAt: now.toISOString(), updatedAt: now.toISOString(), nextRunAt: "", @@ -138,9 +155,13 @@ const canSave = computed(() => { const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects; const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0; const hasTableScope = draft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0; - return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value; + return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value; }); const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date())); +const runDirectoryPreview = computed(() => { + if (!draft.value.destinationDirectory.trim() || !databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "")) return ""; + return databaseBackupRunDirectory(draft.value.destinationDirectory, draft.value.runDirectoryPattern || "", draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), nextRunPreview.value, "preview01"); +}); const canStartOneShot = computed(() => { const hasContent = oneShotDraft.value.includeStructure || oneShotDraft.value.includeData || oneShotDraft.value.includeObjects; const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0; @@ -221,6 +242,7 @@ async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBack const generation = ++databaseLoadGeneration; const connectionId = targetDraft.connectionId; databaseOptions.value = []; + databaseLoadError.value = ""; if (!preserveSelection) { selectedDatabases.value = []; allDatabases.value = true; @@ -234,32 +256,52 @@ async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBack } loadingDatabases.value = true; try { - await connectionStore.ensureConnected(connectionId); - const config = connectionStore.getConfig(connectionId); - const names = config?.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(connectionId, config) : (await api.listDatabases(connectionId)).map((database) => database.name); + const loadNames = async () => { + await connectionStore.ensureConnected(connectionId); + const config = connectionStore.getConfig(connectionId); + return config?.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(connectionId, config) : (await api.listDatabases(connectionId)).map((database) => database.name); + }; + let names: string[]; + try { + names = await loadNames(); + } catch (error) { + // A connection can be closed by MySQL after the store's recent health + // check. Mark it stale and retry the read once through a fresh pool. + if (!connectionStore.recordConnectionLostError(connectionId, error)) throw error; + names = await loadNames(); + } if (!databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) return; databaseOptions.value = names; if (preserveSelection) { const selected = new Set(selectedDatabases.value); selectedDatabases.value = names.filter((database) => selected.has(database)); } - } catch (error: any) { - if (databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) toast(error?.message || String(error), 5000); + } catch { + if (databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) { + databaseLoadError.value = t("databaseBackup.databaseListLoadFailed", { name: connectionName(connectionId) }); + } } finally { if (generation === databaseLoadGeneration) loadingDatabases.value = false; } } +function resetDatabaseScope(targetDraft: DatabaseBackupExecutionConfig) { + databaseOptions.value = []; + databaseLoadError.value = ""; + allDatabases.value = true; + selectedDatabases.value = []; + targetDraft.tableFilterMode = "all"; + targetDraft.tablePatterns = []; + tablePatternsInput.value = ""; +} + async function openCreateSchedule() { oneShotDialogOpen.value = false; editingScheduleId.value = ""; const nextDraft = newScheduleDraft(); draft.value = nextDraft; - allDatabases.value = true; - selectedDatabases.value = []; - tablePatternsInput.value = ""; + resetDatabaseScope(nextDraft); scheduleDialogOpen.value = true; - await loadDatabases("schedule", draft.value, false); } async function openEditSchedule(schedule: DatabaseBackupSchedule) { @@ -269,15 +311,40 @@ async function openEditSchedule(schedule: DatabaseBackupSchedule) { allDatabases.value = schedule.databases.length === 0; selectedDatabases.value = [...schedule.databases]; tablePatternsInput.value = schedule.tablePatterns.join(", "); + databaseLoadError.value = ""; scheduleDialogOpen.value = true; - await loadDatabases("schedule", draft.value, true); + if (!allDatabases.value) await loadDatabases("schedule", draft.value, true); } async function changeConnection(connectionId: string) { const dialog: BackupDialogKind = oneShotDialogOpen.value ? "one-shot" : "schedule"; const targetDraft = dialog === "one-shot" ? oneShotDraft.value : draft.value; + const wasSelectingSpecificDatabases = !allDatabases.value; targetDraft.connectionId = connectionId; - await loadDatabases(dialog, targetDraft, false); + databaseOptions.value = []; + databaseLoadError.value = ""; + selectedDatabases.value = []; + targetDraft.tableFilterMode = "all"; + targetDraft.tablePatterns = []; + tablePatternsInput.value = ""; + if (!wasSelectingSpecificDatabases) { + allDatabases.value = true; + return; + } + allDatabases.value = false; + await loadDatabases(dialog, targetDraft, true); +} + +async function setAllDatabases(value: boolean) { + allDatabases.value = value; + databaseLoadError.value = ""; + if (value) { + selectedDatabases.value = []; + return; + } + const dialog: BackupDialogKind = oneShotDialogOpen.value ? "one-shot" : "schedule"; + const targetDraft = dialog === "one-shot" ? oneShotDraft.value : draft.value; + await loadDatabases(dialog, targetDraft, true); } function toggleDatabase(database: string) { @@ -316,11 +383,8 @@ async function openOneShotBackup() { scheduleDialogOpen.value = false; const nextDraft = newBackupConfig(); oneShotDraft.value = nextDraft; - allDatabases.value = true; - selectedDatabases.value = []; - tablePatternsInput.value = ""; + resetDatabaseScope(nextDraft); oneShotDialogOpen.value = true; - await loadDatabases("one-shot", oneShotDraft.value, false); } async function startOneShotBackup() { @@ -403,11 +467,30 @@ function confirmDeleteSchedule() { pendingDeleteSchedule.value = null; } -function requestDeleteRun(run: DatabaseBackupRun) { - pendingDeleteRun.value = run; +function toggleRunSelected(runId: string, selected: boolean) { + if (selected) selectedRunIds.add(runId); + else selectedRunIds.delete(runId); +} + +function toggleAllFilteredRuns(selected: boolean) { + for (const run of selectableFilteredRuns.value) toggleRunSelected(run.id, selected); +} + +function requestDeleteRuns(runIds: readonly string[]) { + const ids = runIds.filter((runId) => !activeRunIds.has(runId)); + if (ids.length === 0) return; + pendingDeleteRunIds.value = ids; deleteRunDialogOpen.value = true; } +function requestDeleteRun(run: DatabaseBackupRun) { + requestDeleteRuns([run.id]); +} + +function requestDeleteSelectedRuns() { + requestDeleteRuns(selectedRuns.value.map((run) => run.id)); +} + function requestRenameRun(run: DatabaseBackupRun) { if (activeRunIds.has(run.id)) return; pendingRenameRun.value = run; @@ -424,14 +507,18 @@ function confirmRenameRun() { toast(t("databaseBackup.backupRenamed"), 2500); } -async function confirmDeleteRun() { - const run = pendingDeleteRun.value; - if (!run) return; +async function confirmDeleteRuns() { + const runIds = pendingDeleteRunIds.value; + if (runIds.length === 0) return; try { - await deleteRun(run.id); + await deleteRuns(runIds); + for (const runId of runIds) { + selectedRunIds.delete(runId); + expandedRunIds.delete(runId); + } deleteRunDialogOpen.value = false; - pendingDeleteRun.value = null; - toast(t("databaseBackup.backupDeleted"), 2500); + pendingDeleteRunIds.value = []; + toast(runIds.length === 1 ? t("databaseBackup.backupDeleted") : t("databaseBackup.backupsDeleted", { count: runIds.length }), 2500); } catch (error: any) { toast(error?.message || String(error), 5000); } @@ -589,10 +676,30 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) { +
+ + {{ t("databaseBackup.selectedRuns", { count: selectedRuns.length }) }} + +
{{ historyConnectionId || historyBackupMethod !== "all" || historyStatus !== "all" ? t("databaseBackup.noFilteredHistory") : t("databaseBackup.noHistory") }}