fix(backup): enhance backup configuration and history management
This commit is contained in:
23 files changed
+623
-92
No files matched your search
@@ -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(
|
||||
</template>
|
||||
<div v-else class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingDatabases") }}</div>
|
||||
</div>
|
||||
<p v-if="databaseLoadError" data-backup-database-load-error class="text-xs text-destructive">{{ databaseLoadError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<DatabaseBackupSchedule | null>(null);
|
||||
const pendingDeleteRun = ref<DatabaseBackupRun | null>(null);
|
||||
const pendingDeleteRunIds = ref<string[]>([]);
|
||||
const pendingRenameRun = ref<DatabaseBackupRun | null>(null);
|
||||
const renameRunName = ref("");
|
||||
const loadingDatabases = ref(false);
|
||||
const databaseLoadError = ref("");
|
||||
const saving = ref(false);
|
||||
const oneShotStarting = ref(false);
|
||||
const databaseOptions = ref<string[]>([]);
|
||||
@@ -43,6 +55,7 @@ const allDatabases = ref(true);
|
||||
const selectedDatabases = ref<string[]>([]);
|
||||
const tablePatternsInput = ref("");
|
||||
const expandedRunIds = reactive(new Set<string>());
|
||||
const selectedRunIds = reactive(new Set<string>());
|
||||
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) {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectableFilteredRuns.length > 0" class="flex flex-wrap items-center gap-3 rounded-md border border-border/70 bg-muted/20 px-3 py-2">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input data-backup-history-select-all type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="allSelectableRunsSelected" @change="toggleAllFilteredRuns(($event.target as HTMLInputElement).checked)" />
|
||||
{{ t("databaseBackup.selectAllFiltered") }}
|
||||
</label>
|
||||
<span v-if="selectedRuns.length > 0" class="text-sm text-muted-foreground">{{ t("databaseBackup.selectedRuns", { count: selectedRuns.length }) }}</span>
|
||||
<Button v-if="selectedRuns.length > 0" data-backup-delete-selected variant="destructive" size="sm" @click="requestDeleteSelectedRuns">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{{ t("databaseBackup.deleteSelected") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-md border border-border/70">
|
||||
<div v-if="filteredRuns.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">{{ historyConnectionId || historyBackupMethod !== "all" || historyStatus !== "all" ? t("databaseBackup.noFilteredHistory") : t("databaseBackup.noHistory") }}</div>
|
||||
<template v-for="run in filteredRuns" :key="run.id">
|
||||
<div class="grid gap-2 border-b border-border/70 px-3 py-3 last:border-b-0 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
|
||||
<div class="grid gap-2 border-b border-border/70 px-3 py-3 last:border-b-0 md:grid-cols-[auto_auto_minmax(0,1fr)_auto] md:items-center">
|
||||
<input
|
||||
data-backup-history-select
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-border accent-primary"
|
||||
:checked="selectedRunIds.has(run.id)"
|
||||
:disabled="activeRunIds.has(run.id)"
|
||||
:aria-label="t('databaseBackup.selectRun', { name: run.displayName || run.scheduleName })"
|
||||
@change="toggleRunSelected(run.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="run.files.length === 0" :title="t('databaseBackup.showFiles')" @click="toggleRunExpanded(run.id)">
|
||||
<ChevronDown v-if="expandedRunIds.has(run.id)" class="h-4 w-4" />
|
||||
<ChevronRight v-else class="h-4 w-4" />
|
||||
@@ -683,13 +790,25 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
:database-options="databaseOptions"
|
||||
:table-patterns-input="tablePatternsInput"
|
||||
:loading-databases="loadingDatabases"
|
||||
:database-load-error="databaseLoadError"
|
||||
@change-connection="changeConnection"
|
||||
@choose-destination="chooseDestination"
|
||||
@toggle-database="toggleDatabase"
|
||||
@update:all-databases="(value: boolean) => (allDatabases = value)"
|
||||
@update:all-databases="setAllDatabases"
|
||||
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.runDirectoryPattern") }}</Label>
|
||||
<Input v-model="draft.runDirectoryPattern" data-backup-run-directory-pattern :aria-invalid="!databaseBackupRunDirectoryPatternIsValid(draft.runDirectoryPattern || '')" />
|
||||
<p class="text-xs text-muted-foreground">{{ t("databaseBackup.runDirectoryPatternHint") }}</p>
|
||||
<div v-if="runDirectoryPreview" data-backup-run-directory-preview class="space-y-1 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<div class="text-xs font-medium text-muted-foreground">{{ t("databaseBackup.runDirectoryPreview") }}</div>
|
||||
<div class="break-all font-mono text-xs text-foreground">{{ runDirectoryPreview }}</div>
|
||||
</div>
|
||||
<p v-if="!databaseBackupRunDirectoryPatternIsValid(draft.runDirectoryPattern || '')" class="text-xs text-destructive">{{ t("databaseBackup.runDirectoryPatternInvalid") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.frequency") }}</Label>
|
||||
@@ -760,10 +879,11 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
:database-options="databaseOptions"
|
||||
:table-patterns-input="tablePatternsInput"
|
||||
:loading-databases="loadingDatabases"
|
||||
:database-load-error="databaseLoadError"
|
||||
@change-connection="changeConnection"
|
||||
@choose-destination="chooseDestination"
|
||||
@toggle-database="toggleDatabase"
|
||||
@update:all-databases="(value: boolean) => (allDatabases = value)"
|
||||
@update:all-databases="setAllDatabases"
|
||||
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
|
||||
/>
|
||||
|
||||
@@ -799,10 +919,16 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("databaseBackup.deleteBackup") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.deleteBackupConfirm", { count: pendingDeleteRun?.files.length || 0 }) }}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{
|
||||
pendingDeleteRunIds.length === 1
|
||||
? t("databaseBackup.deleteBackupConfirm", { count: runs.find((run) => run.id === pendingDeleteRunIds[0])?.files.length || 0 })
|
||||
: t("databaseBackup.deleteBackupsConfirm", { count: pendingDeleteRunIds.length, files: pendingDeleteRunIds.reduce((total, runId) => total + (runs.find((run) => run.id === runId)?.files.length || 0), 0) })
|
||||
}}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="deleteRunDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteRun">{{ t("databaseBackup.delete") }}</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteRuns">{{ t("databaseBackup.delete") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
+120
-9
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
cancellingRunIds: new Set<string>(),
|
||||
activeRuns: [] as DatabaseBackupRun[],
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
recordConnectionLostError: vi.fn(() => false),
|
||||
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
|
||||
databaseExportDestinationNeedsConfirmation: vi.fn(async (_directory: string) => false),
|
||||
recordDatabaseExportDestination: vi.fn(async (_directory: string) => {}),
|
||||
@@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({
|
||||
setScheduleEnabled: vi.fn(),
|
||||
deleteSchedule: vi.fn(),
|
||||
deleteRun: vi.fn(),
|
||||
deleteRuns: vi.fn(),
|
||||
runSchedule: vi.fn(),
|
||||
runOneShot: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
@@ -32,6 +34,7 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
connections: mocks.connections,
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
recordConnectionLostError: mocks.recordConnectionLostError,
|
||||
getConfig: (connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId),
|
||||
sqlFileSource: null,
|
||||
}),
|
||||
@@ -49,6 +52,7 @@ vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
|
||||
setScheduleEnabled: mocks.setScheduleEnabled,
|
||||
deleteSchedule: mocks.deleteSchedule,
|
||||
deleteRun: mocks.deleteRun,
|
||||
deleteRuns: mocks.deleteRuns,
|
||||
runSchedule: mocks.runSchedule,
|
||||
runOneShot: mocks.runOneShot,
|
||||
cancelRun: mocks.cancelRun,
|
||||
@@ -250,6 +254,8 @@ afterEach(() => {
|
||||
mocks.activeRunIds.clear();
|
||||
mocks.cancellingRunIds.clear();
|
||||
mocks.ensureConnected.mockClear();
|
||||
mocks.recordConnectionLostError.mockReset();
|
||||
mocks.recordConnectionLostError.mockReturnValue(false);
|
||||
mocks.listDatabases.mockReset();
|
||||
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
|
||||
mocks.databaseExportDestinationNeedsConfirmation.mockReset();
|
||||
@@ -260,6 +266,8 @@ afterEach(() => {
|
||||
mocks.openDirectory.mockResolvedValue("/backups");
|
||||
mocks.toast.mockClear();
|
||||
mocks.saveSchedule.mockClear();
|
||||
mocks.deleteRun.mockClear();
|
||||
mocks.deleteRuns.mockClear();
|
||||
mocks.cancelRun.mockClear();
|
||||
mocks.runSchedule.mockReset();
|
||||
mocks.runSchedule.mockResolvedValue(null);
|
||||
@@ -268,6 +276,83 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
it("reconnects once when loading databases finds a closed connection", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
mocks.listDatabases.mockRejectedValueOnce(new Error("MySQL connection failed: Input/output error: connection closed")).mockResolvedValueOnce([{ name: "app" }]);
|
||||
mocks.recordConnectionLostError.mockReturnValue(true);
|
||||
await mountSettings();
|
||||
|
||||
addScheduleButton().click();
|
||||
await flush();
|
||||
await showDatabaseOptions();
|
||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(mocks.recordConnectionLostError).toHaveBeenCalledWith("mysql-1", expect.any(Error));
|
||||
expect(mocks.ensureConnected).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows connection guidance inline when explicitly loading databases fails", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
mocks.listDatabases.mockRejectedValueOnce(new Error("MySQL connection failed: Input/output error: connection closed"));
|
||||
await mountSettings();
|
||||
|
||||
addScheduleButton().click();
|
||||
await flush();
|
||||
await showDatabaseOptions();
|
||||
await vi.waitFor(() => expect(currentDialog().querySelector("[data-backup-database-load-error]")?.textContent).toContain("Local MySQL"));
|
||||
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selects filtered backup history and deletes the selected runs together", async () => {
|
||||
mocks.runs.push(
|
||||
{
|
||||
id: "run-batch-one",
|
||||
scheduleName: "First backup",
|
||||
connectionId: "mysql-1",
|
||||
connectionName: "Local MySQL",
|
||||
trigger: "scheduled",
|
||||
source: "scheduled",
|
||||
status: "success",
|
||||
startedAt: "2026-09-08T02:00:00.000Z",
|
||||
files: [{ displayName: "first.sql", filePath: "/backups/first.sql" }],
|
||||
},
|
||||
{
|
||||
id: "run-batch-two",
|
||||
scheduleName: "Second backup",
|
||||
connectionId: "mysql-1",
|
||||
connectionName: "Local MySQL",
|
||||
trigger: "scheduled",
|
||||
source: "scheduled",
|
||||
status: "success",
|
||||
startedAt: "2026-09-08T01:00:00.000Z",
|
||||
files: [{ displayName: "second.sql", filePath: "/backups/second.sql" }],
|
||||
},
|
||||
);
|
||||
await mountSettings();
|
||||
|
||||
const selectAll = document.body.querySelector<HTMLInputElement>("[data-backup-history-select-all]");
|
||||
if (!selectAll) throw new Error("Select-all checkbox not found");
|
||||
selectAll.checked = true;
|
||||
selectAll.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(document.body.querySelectorAll<HTMLInputElement>("[data-backup-history-select]")).toHaveLength(2);
|
||||
const deleteSelected = document.body.querySelector<HTMLButtonElement>("[data-backup-delete-selected]");
|
||||
if (!deleteSelected) throw new Error("Delete selected button not found");
|
||||
deleteSelected.click();
|
||||
await flush();
|
||||
|
||||
expect(currentDialog().textContent).toContain(String(i18n.global.t("databaseBackup.deleteBackupsConfirm", { count: 2, files: 2 })));
|
||||
const confirm = Array.from(currentDialog().querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(i18n.global.t("databaseBackup.delete")));
|
||||
if (!confirm) throw new Error("Delete confirmation button not found");
|
||||
confirm.click();
|
||||
await flush();
|
||||
|
||||
expect(mocks.deleteRuns).toHaveBeenCalledWith(["run-batch-one", "run-batch-two"]);
|
||||
});
|
||||
|
||||
it("filters backup history by a searched connection", async () => {
|
||||
mocks.runs.push(
|
||||
{
|
||||
@@ -335,7 +420,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
expect(document.body.textContent).not.toContain("Manual success backup");
|
||||
});
|
||||
|
||||
it("opens the create schedule dialog for supported SQL connections", async () => {
|
||||
it("opens the create schedule dialog without probing its default connection", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
await mountSettings();
|
||||
|
||||
@@ -348,8 +433,31 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
|
||||
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.addSchedule")));
|
||||
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.scheduleName")));
|
||||
expect(mocks.ensureConnected).toHaveBeenCalledWith("mysql-1");
|
||||
expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-1");
|
||||
expect(mocks.ensureConnected).not.toHaveBeenCalled();
|
||||
expect(mocks.listDatabases).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews the rendered run directory and updates it when the template changes", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
await mountSettings();
|
||||
|
||||
addScheduleButton().click();
|
||||
await flush();
|
||||
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
|
||||
await flush();
|
||||
|
||||
const preview = currentDialog().querySelector<HTMLElement>("[data-backup-run-directory-preview]");
|
||||
expect(preview?.textContent).toContain("/backups/dbx-backup__");
|
||||
expect(preview?.textContent).toContain("preview0");
|
||||
|
||||
const input = currentDialog().querySelector<HTMLInputElement>("[data-backup-run-directory-pattern]");
|
||||
if (!input) throw new Error("Run directory template input not found");
|
||||
input.value = "archive/{date}/{timestamp}";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
|
||||
expect(currentDialog().textContent).toContain("{timestamp}");
|
||||
expect(currentDialog().querySelector<HTMLElement>("[data-backup-run-directory-preview]")?.textContent).toContain("/backups/archive/");
|
||||
});
|
||||
|
||||
it("opens an independent one-shot dialog without schedule fields", async () => {
|
||||
@@ -400,6 +508,8 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
await showDatabaseOptions();
|
||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-a"));
|
||||
await selectBackupConnection("MySQL B");
|
||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-b"));
|
||||
@@ -423,6 +533,8 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
await showDatabaseOptions();
|
||||
await flush();
|
||||
await showDatabaseOptions();
|
||||
|
||||
const search = currentDialog().querySelector<HTMLInputElement>("[data-backup-database-search]");
|
||||
if (!search) throw new Error("Backup database search not found");
|
||||
@@ -438,27 +550,26 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
it("does not let a closed one-shot request clear a schedule draft", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
const oneShotLoad = deferred<Array<{ name: string }>>();
|
||||
const scheduleLoad = deferred<Array<{ name: string }>>();
|
||||
mocks.listDatabases.mockImplementationOnce(() => oneShotLoad.promise).mockImplementationOnce(() => scheduleLoad.promise);
|
||||
mocks.listDatabases.mockImplementationOnce(() => oneShotLoad.promise);
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
await showDatabaseOptions();
|
||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(1));
|
||||
const closeOneShot = Array.from(currentDialog().querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("common.cancel")));
|
||||
closeOneShot?.click();
|
||||
await flush();
|
||||
|
||||
addScheduleButton().click();
|
||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(2));
|
||||
await flush();
|
||||
await setTablePattern("schedule_*");
|
||||
|
||||
oneShotLoad.resolve([{ name: "one_shot_database" }]);
|
||||
await flush();
|
||||
expect(currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`)?.value).toBe("schedule_*");
|
||||
|
||||
scheduleLoad.resolve([{ name: "schedule_database" }]);
|
||||
await flush();
|
||||
expect(currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`)?.value).toBe("schedule_*");
|
||||
expect(mocks.listDatabases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps closing the one-shot dialog separate from cancelling the active run", async () => {
|
||||
|
||||
Reference in new issue
Block a user