feat(backup): add one-shot database backups
This commit is contained in:
1 parent
f0cc5aefcf
commit
0facea2740
17 files changed
+888
-179
No files matched your search
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { FolderOpen, Loader2 } from "@lucide/vue";
|
||||
import type { DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
draft: DatabaseBackupExecutionConfig;
|
||||
connections: Array<Pick<ConnectionConfig, "id" | "name">>;
|
||||
allDatabases: boolean;
|
||||
selectedDatabases: string[];
|
||||
databaseOptions: string[];
|
||||
tablePatternsInput: string;
|
||||
loadingDatabases: boolean;
|
||||
}>(),
|
||||
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [] },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
changeConnection: [connectionId: string];
|
||||
chooseDestination: [];
|
||||
toggleDatabase: [database: string];
|
||||
"update:allDatabases": [value: boolean];
|
||||
"update:tablePatternsInput": [value: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-5 py-1">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.connection") }}</Label>
|
||||
<Select :model-value="draft.connectionId" @update:model-value="(value: any) => emit('changeConnection', String(value))">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="connection in connections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.destination") }}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="draft.destinationDirectory" readonly class="min-w-0" />
|
||||
<Button variant="outline" size="icon" class="shrink-0" :title="t('databaseBackup.selectDestination')" @click="emit('chooseDestination')">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Label>{{ t("databaseBackup.databases") }}</Label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input :checked="allDatabases" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" @change="emit('update:allDatabases', ($event.target as HTMLInputElement).checked)" />
|
||||
{{ t("databaseBackup.allDatabases") }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!allDatabases" class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
|
||||
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
|
||||
<label v-for="database in databaseOptions" v-else :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
|
||||
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="emit('toggleDatabase', database)" />
|
||||
<span class="truncate">{{ database }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-4 sm:grid-cols-[minmax(0,200px)_minmax(0,1fr)]">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.tableScope") }}</Label>
|
||||
<Select v-model="draft.tableFilterMode">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{{ t("databaseBackup.allTables") }}</SelectItem>
|
||||
<SelectItem value="include">{{ t("databaseBackup.includeTables") }}</SelectItem>
|
||||
<SelectItem value="exclude">{{ t("databaseBackup.excludeTables") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="draft.tableFilterMode !== 'all'" class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.tablePatterns") }}</Label>
|
||||
<Input :model-value="tablePatternsInput" :placeholder="t('databaseBackup.tablePatternsPlaceholder')" @update:model-value="(value: any) => emit('update:tablePatternsInput', String(value))" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="draft.tableFilterMode !== 'all'" class="text-xs text-muted-foreground">{{ t("databaseBackup.tablePatternsHint") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label>{{ t("databaseBackup.contents") }}</Label>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeStructure" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeStructure") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeData" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeData") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeObjects" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeObjects") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.dropTableIfExists" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.dropTableIfExists") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,19 +11,21 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Square, Trash2 } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
|
||||
import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfigFields.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||||
import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import { 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, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, cancelRun } = useScheduledDatabaseBackups();
|
||||
const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
|
||||
|
||||
const scheduleDialogOpen = ref(false);
|
||||
const oneShotDialogOpen = ref(false);
|
||||
const deleteScheduleDialogOpen = ref(false);
|
||||
const deleteRunDialogOpen = ref(false);
|
||||
const renameRunDialogOpen = ref(false);
|
||||
@@ -34,6 +36,7 @@ const pendingRenameRun = ref<DatabaseBackupRun | null>(null);
|
||||
const renameRunName = ref("");
|
||||
const loadingDatabases = ref(false);
|
||||
const saving = ref(false);
|
||||
const oneShotStarting = ref(false);
|
||||
const databaseOptions = ref<string[]>([]);
|
||||
const allDatabases = ref(true);
|
||||
const selectedDatabases = ref<string[]>([]);
|
||||
@@ -53,25 +56,31 @@ const weekdays = computed(() => [
|
||||
{ value: 6, label: t("databaseBackup.weekdays.saturday") },
|
||||
]);
|
||||
|
||||
function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupSchedule {
|
||||
const now = new Date();
|
||||
const draft: DatabaseBackupSchedule = {
|
||||
id: generateDatabaseExportId(),
|
||||
name: t("databaseBackup.defaultScheduleName"),
|
||||
enabled: true,
|
||||
function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupExecutionConfig {
|
||||
return {
|
||||
connectionId,
|
||||
databases: [],
|
||||
tableFilterMode: "all",
|
||||
tablePatterns: [],
|
||||
destinationDirectory: "",
|
||||
frequency: "daily",
|
||||
intervalHours: 6,
|
||||
timeOfDay: "02:00",
|
||||
weekday: 1,
|
||||
includeStructure: true,
|
||||
includeData: true,
|
||||
includeObjects: true,
|
||||
dropTableIfExists: false,
|
||||
};
|
||||
}
|
||||
|
||||
function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupSchedule {
|
||||
const now = new Date();
|
||||
const draft: DatabaseBackupSchedule = {
|
||||
...newBackupConfig(connectionId),
|
||||
id: generateDatabaseExportId(),
|
||||
name: t("databaseBackup.defaultScheduleName"),
|
||||
enabled: true,
|
||||
frequency: "daily",
|
||||
intervalHours: 6,
|
||||
timeOfDay: "02:00",
|
||||
weekday: 1,
|
||||
retentionCount: 10,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
@@ -82,6 +91,18 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat
|
||||
}
|
||||
|
||||
const draft = ref<DatabaseBackupSchedule>(newScheduleDraft());
|
||||
const oneShotDraft = ref<DatabaseBackupExecutionConfig>(newBackupConfig());
|
||||
const activeDraft = computed<DatabaseBackupExecutionConfig>(() => (oneShotDialogOpen.value ? oneShotDraft.value : draft.value));
|
||||
const activeOneShotRun = computed(() => activeRuns.value.find((run) => run.source === "one-shot"));
|
||||
type BackupDialogKind = "schedule" | "one-shot";
|
||||
let databaseLoadGeneration = 0;
|
||||
|
||||
function databaseLoadIsCurrent(generation: number, dialog: BackupDialogKind, targetDraft: DatabaseBackupExecutionConfig, connectionId: string): boolean {
|
||||
if (generation !== databaseLoadGeneration) return false;
|
||||
if (dialog === "one-shot") return oneShotDialogOpen.value && oneShotDraft.value === targetDraft && targetDraft.connectionId === connectionId;
|
||||
return scheduleDialogOpen.value && draft.value === targetDraft && targetDraft.connectionId === connectionId;
|
||||
}
|
||||
|
||||
const canSave = computed(() => {
|
||||
const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects;
|
||||
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
|
||||
@@ -89,6 +110,12 @@ const canSave = computed(() => {
|
||||
return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value;
|
||||
});
|
||||
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
|
||||
const canStartOneShot = computed(() => {
|
||||
const hasContent = oneShotDraft.value.includeStructure || oneShotDraft.value.includeData || oneShotDraft.value.includeObjects;
|
||||
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
|
||||
const hasTableScope = oneShotDraft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0;
|
||||
return !!oneShotDraft.value.connectionId && !!oneShotDraft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !oneShotStarting.value && !loadingDatabases.value;
|
||||
});
|
||||
|
||||
function connectionName(connectionId: string): string {
|
||||
return connectionStore.getConfig(connectionId)?.name || t("databaseBackup.missingConnection");
|
||||
@@ -135,55 +162,67 @@ function activeRunForSchedule(scheduleId: string): DatabaseBackupRun | undefined
|
||||
return activeRuns.value.find((run) => run.scheduleId === scheduleId);
|
||||
}
|
||||
|
||||
async function loadDatabases(connectionId: string, preserveSelection: boolean) {
|
||||
async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBackupExecutionConfig, preserveSelection: boolean) {
|
||||
const generation = ++databaseLoadGeneration;
|
||||
const connectionId = targetDraft.connectionId;
|
||||
databaseOptions.value = [];
|
||||
if (!connectionId) return;
|
||||
if (!preserveSelection) {
|
||||
selectedDatabases.value = [];
|
||||
allDatabases.value = true;
|
||||
targetDraft.tableFilterMode = "all";
|
||||
targetDraft.tablePatterns = [];
|
||||
tablePatternsInput.value = "";
|
||||
}
|
||||
if (!connectionId) {
|
||||
loadingDatabases.value = false;
|
||||
return;
|
||||
}
|
||||
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);
|
||||
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));
|
||||
} else {
|
||||
selectedDatabases.value = [];
|
||||
allDatabases.value = true;
|
||||
draft.value.tableFilterMode = "all";
|
||||
draft.value.tablePatterns = [];
|
||||
tablePatternsInput.value = "";
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
if (databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) toast(error?.message || String(error), 5000);
|
||||
} finally {
|
||||
loadingDatabases.value = false;
|
||||
if (generation === databaseLoadGeneration) loadingDatabases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreateSchedule() {
|
||||
oneShotDialogOpen.value = false;
|
||||
editingScheduleId.value = "";
|
||||
draft.value = newScheduleDraft();
|
||||
const nextDraft = newScheduleDraft();
|
||||
draft.value = nextDraft;
|
||||
allDatabases.value = true;
|
||||
selectedDatabases.value = [];
|
||||
tablePatternsInput.value = "";
|
||||
scheduleDialogOpen.value = true;
|
||||
await loadDatabases(draft.value.connectionId, false);
|
||||
await loadDatabases("schedule", draft.value, false);
|
||||
}
|
||||
|
||||
async function openEditSchedule(schedule: DatabaseBackupSchedule) {
|
||||
oneShotDialogOpen.value = false;
|
||||
editingScheduleId.value = schedule.id;
|
||||
draft.value = { ...schedule, databases: [...schedule.databases], tablePatterns: [...schedule.tablePatterns] };
|
||||
allDatabases.value = schedule.databases.length === 0;
|
||||
selectedDatabases.value = [...schedule.databases];
|
||||
tablePatternsInput.value = schedule.tablePatterns.join(", ");
|
||||
scheduleDialogOpen.value = true;
|
||||
await loadDatabases(schedule.connectionId, true);
|
||||
await loadDatabases("schedule", draft.value, true);
|
||||
}
|
||||
|
||||
async function changeConnection(connectionId: string) {
|
||||
draft.value.connectionId = connectionId;
|
||||
await loadDatabases(connectionId, false);
|
||||
const dialog: BackupDialogKind = oneShotDialogOpen.value ? "one-shot" : "schedule";
|
||||
const targetDraft = dialog === "one-shot" ? oneShotDraft.value : draft.value;
|
||||
targetDraft.connectionId = connectionId;
|
||||
await loadDatabases(dialog, targetDraft, false);
|
||||
}
|
||||
|
||||
function toggleDatabase(database: string) {
|
||||
@@ -196,7 +235,7 @@ function toggleDatabase(database: string) {
|
||||
async function chooseDestination() {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({ directory: true, multiple: false, title: t("databaseBackup.selectDestination") });
|
||||
if (typeof selected === "string") draft.value.destinationDirectory = selected;
|
||||
if (typeof selected === "string") activeDraft.value.destinationDirectory = selected;
|
||||
}
|
||||
|
||||
async function submitSchedule() {
|
||||
@@ -218,6 +257,47 @@ async function submitSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openOneShotBackup() {
|
||||
scheduleDialogOpen.value = false;
|
||||
const nextDraft = newBackupConfig();
|
||||
oneShotDraft.value = nextDraft;
|
||||
allDatabases.value = true;
|
||||
selectedDatabases.value = [];
|
||||
tablePatternsInput.value = "";
|
||||
oneShotDialogOpen.value = true;
|
||||
await loadDatabases("one-shot", oneShotDraft.value, false);
|
||||
}
|
||||
|
||||
async function startOneShotBackup() {
|
||||
if (!canStartOneShot.value) return;
|
||||
oneShotStarting.value = true;
|
||||
try {
|
||||
await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
|
||||
const run = await runOneShot(
|
||||
{
|
||||
...oneShotDraft.value,
|
||||
databases: allDatabases.value ? [] : [...selectedDatabases.value],
|
||||
tablePatterns: oneShotDraft.value.tableFilterMode === "all" ? [] : normalizeDatabaseBackupTablePatterns(tablePatternsInput.value),
|
||||
},
|
||||
t("databaseBackup.oneShotName"),
|
||||
);
|
||||
if (!run) return;
|
||||
oneShotDialogOpen.value = false;
|
||||
if (run.status === "success") toast(t("databaseBackup.runSuccess", { count: run.files.length }), 3000);
|
||||
else if (run.status === "cancelled") toast(t("databaseBackup.runCancelled"), 3000);
|
||||
else toast(t("databaseBackup.runFailed", { error: run.error ? translateBackendError(t, run.error) : t("databaseBackup.unknownError") }), 5000);
|
||||
} catch (error: any) {
|
||||
toast(translateBackendError(t, error), 5000);
|
||||
} finally {
|
||||
oneShotStarting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelActiveOneShotBackup() {
|
||||
const run = activeOneShotRun.value;
|
||||
if (run) await cancelRun(run.id);
|
||||
}
|
||||
|
||||
async function runNow(schedule: DatabaseBackupSchedule) {
|
||||
try {
|
||||
const run = await runSchedule(schedule.id, "manual");
|
||||
@@ -309,16 +389,29 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ t("databaseBackup.runtimeRequirement") }}</p>
|
||||
<p v-if="!canCreateSchedule" class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.noSupportedConnections") }}</p>
|
||||
</div>
|
||||
<Button size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.addSchedule') : t('databaseBackup.noSupportedConnections')" @click="openCreateSchedule">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
{{ t("databaseBackup.addSchedule") }}
|
||||
</Button>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.oneShotBackup') : t('databaseBackup.noSupportedConnections')" @click="openOneShotBackup">
|
||||
<Play class="mr-2 h-4 w-4" />
|
||||
{{ t("databaseBackup.oneShotBackup") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!canCreateSchedule" :title="canCreateSchedule ? t('databaseBackup.addSchedule') : t('databaseBackup.noSupportedConnections')" @click="openCreateSchedule">
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
{{ t("databaseBackup.addSchedule") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-md border border-border/70">
|
||||
<div v-if="schedules.length === 0" class="flex min-h-36 flex-col items-center justify-center gap-2 px-4 py-8 text-center text-muted-foreground">
|
||||
<div v-if="schedules.length === 0" class="flex min-h-44 flex-col items-center justify-center gap-3 px-4 py-8 text-center text-muted-foreground">
|
||||
<DatabaseBackup class="h-8 w-8 opacity-60" />
|
||||
<span class="text-sm">{{ t("databaseBackup.noSchedules") }}</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-foreground">{{ t("databaseBackup.noSchedules") }}</div>
|
||||
<p class="mt-1 text-sm">{{ t("databaseBackup.noSchedulesHint") }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" size="sm" :disabled="!canCreateSchedule" @click="openOneShotBackup"><Play class="mr-2 h-4 w-4" />{{ t("databaseBackup.oneShotBackup") }}</Button>
|
||||
<Button size="sm" :disabled="!canCreateSchedule" @click="openCreateSchedule"><Plus class="mr-2 h-4 w-4" />{{ t("databaseBackup.addSchedule") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="schedule in schedules" :key="schedule.id" class="grid gap-3 border-b border-border/70 px-4 py-3 last:border-b-0 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||
<div class="min-w-0">
|
||||
@@ -367,7 +460,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="truncate text-sm font-medium">{{ run.displayName || run.scheduleName }}</span>
|
||||
<Badge :variant="runStatusVariant(run.status)" class="font-normal">{{ runStatusLabel(run.status) }}</Badge>
|
||||
<Badge variant="outline" class="font-normal">{{ run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
|
||||
<Badge variant="outline" class="font-normal">{{ run.source === "one-shot" ? t("databaseBackup.oneShotTrigger") : run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{{ run.connectionName || connectionName(run.connectionId) }}</span>
|
||||
@@ -383,7 +476,10 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Loader2 v-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||
<Button v-if="activeRunIds.has(run.id) && run.source === 'one-shot'" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.cancel')" @click="cancelRun(run.id)">
|
||||
<Square class="h-4 w-4" />
|
||||
</Button>
|
||||
<Loader2 v-else-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="activeRunIds.has(run.id)" :title="t('databaseBackup.renameBackup')" @click="requestRenameRun(run)">
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -424,69 +520,25 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-5 py-1">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
|
||||
<Input v-model="draft.name" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.connection") }}</Label>
|
||||
<Select :model-value="draft.connectionId" @update:model-value="(value: any) => changeConnection(String(value))">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="connection in sqlConnections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.destination") }}</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="draft.destinationDirectory" readonly class="min-w-0" />
|
||||
<Button variant="outline" size="icon" class="shrink-0" :title="t('databaseBackup.selectDestination')" @click="chooseDestination">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
|
||||
<Input v-model="draft.name" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Label>{{ t("databaseBackup.databases") }}</Label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input v-model="allDatabases" type="checkbox" class="h-4 w-4 rounded border-border accent-primary" />
|
||||
{{ t("databaseBackup.allDatabases") }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!allDatabases" class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
|
||||
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
|
||||
<label v-for="database in databaseOptions" v-else :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
|
||||
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="toggleDatabase(database)" />
|
||||
<span class="truncate">{{ database }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-4 sm:grid-cols-[minmax(0,200px)_minmax(0,1fr)]">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.tableScope") }}</Label>
|
||||
<Select v-model="draft.tableFilterMode">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{{ t("databaseBackup.allTables") }}</SelectItem>
|
||||
<SelectItem value="include">{{ t("databaseBackup.includeTables") }}</SelectItem>
|
||||
<SelectItem value="exclude">{{ t("databaseBackup.excludeTables") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="draft.tableFilterMode !== 'all'" class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.tablePatterns") }}</Label>
|
||||
<Input v-model="tablePatternsInput" :placeholder="t('databaseBackup.tablePatternsPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="draft.tableFilterMode !== 'all'" class="text-xs text-muted-foreground">{{ t("databaseBackup.tablePatternsHint") }}</p>
|
||||
</div>
|
||||
<DatabaseBackupConfigFields
|
||||
:draft="draft"
|
||||
:connections="sqlConnections"
|
||||
:all-databases="allDatabases"
|
||||
:selected-databases="selectedDatabases"
|
||||
:database-options="databaseOptions"
|
||||
:table-patterns-input="tablePatternsInput"
|
||||
:loading-databases="loadingDatabases"
|
||||
@change-connection="changeConnection"
|
||||
@choose-destination="chooseDestination"
|
||||
@toggle-database="toggleDatabase"
|
||||
@update:all-databases="(value: boolean) => (allDatabases = value)"
|
||||
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
@@ -524,16 +576,6 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label>{{ t("databaseBackup.contents") }}</Label>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeStructure" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeStructure") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeData" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeData") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.includeObjects" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.includeObjects") }}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input v-model="draft.dropTableIfExists" type="checkbox" class="h-4 w-4 accent-primary" />{{ t("databaseExport.dropTableIfExists") }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 border-t border-border/70 pt-4">
|
||||
<div>
|
||||
<Label>{{ t("databaseBackup.enabled") }}</Label>
|
||||
@@ -553,6 +595,41 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="oneShotDialogOpen">
|
||||
<DialogContent class="dbx-form-dialog dbx-form-dialog--lg max-h-[min(760px,calc(var(--dbx-viewport-height)-32px))] max-w-[min(720px,calc(100vw-32px))] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("databaseBackup.oneShotBackup") }}</DialogTitle>
|
||||
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.oneShotDescription") }}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<DatabaseBackupConfigFields
|
||||
:draft="oneShotDraft"
|
||||
:connections="sqlConnections"
|
||||
:all-databases="allDatabases"
|
||||
:selected-databases="selectedDatabases"
|
||||
:database-options="databaseOptions"
|
||||
:table-patterns-input="tablePatternsInput"
|
||||
:loading-databases="loadingDatabases"
|
||||
@change-connection="changeConnection"
|
||||
@choose-destination="chooseDestination"
|
||||
@toggle-database="toggleDatabase"
|
||||
@update:all-databases="(value: boolean) => (allDatabases = value)"
|
||||
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="oneShotDialogOpen = false">{{ oneShotStarting ? t("common.close") : t("common.cancel") }}</Button>
|
||||
<Button v-if="oneShotStarting" variant="destructive" :disabled="!activeOneShotRun" :title="t('databaseBackup.cancel')" @click="cancelActiveOneShotBackup">
|
||||
<Square class="mr-2 h-4 w-4" />
|
||||
{{ t("databaseBackup.cancel") }}
|
||||
</Button>
|
||||
<Button v-else :disabled="!canStartOneShot" @click="startOneShotBackup">
|
||||
{{ t("databaseBackup.startBackup") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="deleteScheduleDialogOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader
|
||||
|
||||
+212
-8
@@ -2,15 +2,18 @@
|
||||
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
|
||||
import type { DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
|
||||
import i18n from "../../../i18n";
|
||||
import ScheduledDatabaseBackupSettings from "../ScheduledDatabaseBackupSettings.vue";
|
||||
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../../lib/backup/scheduledDatabaseBackup";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connections: [] as Array<{ id: string; name: string; db_type: string }>,
|
||||
schedules: [] as DatabaseBackupSchedule[],
|
||||
runs: [] as DatabaseBackupRun[],
|
||||
activeRunIds: new Set<string>(),
|
||||
activeRuns: [] as DatabaseBackupRun[],
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
listDatabases: vi.fn(async () => [{ name: "app" }]),
|
||||
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
|
||||
recordDatabaseExportDestination: vi.fn(async (_directory: string) => {}),
|
||||
toast: vi.fn(),
|
||||
saveSchedule: vi.fn(),
|
||||
@@ -18,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
||||
deleteSchedule: vi.fn(),
|
||||
deleteRun: vi.fn(),
|
||||
runSchedule: vi.fn(),
|
||||
runOneShot: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -33,15 +37,16 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
|
||||
useScheduledDatabaseBackups: () => ({
|
||||
schedules: { __v_isRef: true, value: mocks.schedules },
|
||||
runs: { __v_isRef: true, value: [] },
|
||||
runs: { __v_isRef: true, value: mocks.runs },
|
||||
activeScheduleIds: new Set<string>(),
|
||||
activeRunIds: new Set<string>(),
|
||||
activeRuns: { __v_isRef: true, value: [] },
|
||||
activeRunIds: mocks.activeRunIds,
|
||||
activeRuns: { __v_isRef: true, value: mocks.activeRuns },
|
||||
saveSchedule: mocks.saveSchedule,
|
||||
setScheduleEnabled: mocks.setScheduleEnabled,
|
||||
deleteSchedule: mocks.deleteSchedule,
|
||||
deleteRun: mocks.deleteRun,
|
||||
runSchedule: mocks.runSchedule,
|
||||
runOneShot: mocks.runOneShot,
|
||||
cancelRun: mocks.cancelRun,
|
||||
}),
|
||||
}));
|
||||
@@ -50,6 +55,10 @@ vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(async () => "/backups"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listDatabases: mocks.listDatabases,
|
||||
deleteDatabaseBackupFiles: vi.fn(),
|
||||
@@ -75,6 +84,58 @@ async function mountSettings() {
|
||||
await flush();
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function currentDialog(): HTMLElement {
|
||||
const dialog = document.body.querySelector<HTMLElement>('[data-slot="dialog-content"]');
|
||||
if (!dialog) throw new Error("Dialog not found");
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function selectDialogOption(triggerIndex: number, optionText: string) {
|
||||
const trigger = currentDialog().querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[triggerIndex];
|
||||
if (!trigger) throw new Error(`Select trigger not found: ${triggerIndex}`);
|
||||
trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
|
||||
await flush();
|
||||
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find((item) => item.textContent?.trim() === optionText);
|
||||
if (!option) throw new Error(`Select option not found: ${optionText}`);
|
||||
option.focus();
|
||||
option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function setTablePattern(pattern: string) {
|
||||
await selectDialogOption(1, String(i18n.global.t("databaseBackup.includeTables")));
|
||||
const input = currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`);
|
||||
if (!input) throw new Error("Table pattern input not found");
|
||||
input.value = pattern;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function showDatabaseOptions() {
|
||||
const label = String(i18n.global.t("databaseBackup.allDatabases"));
|
||||
const checkbox = Array.from(currentDialog().querySelectorAll<HTMLInputElement>('input[type="checkbox"]')).find((item) => item.parentElement?.textContent?.includes(label));
|
||||
if (!checkbox) throw new Error("All databases checkbox not found");
|
||||
checkbox.checked = false;
|
||||
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
function buttonWithText(text: string): HTMLButtonElement {
|
||||
const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.includes(text));
|
||||
if (!button) throw new Error(`Button not found: ${text}`);
|
||||
return button;
|
||||
}
|
||||
|
||||
function addScheduleButton(): HTMLButtonElement {
|
||||
const label = String(i18n.global.t("databaseBackup.addSchedule"));
|
||||
const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.includes(label));
|
||||
@@ -127,12 +188,19 @@ afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
mocks.connections.splice(0);
|
||||
mocks.schedules.splice(0);
|
||||
mocks.runs.splice(0);
|
||||
mocks.activeRuns.splice(0);
|
||||
mocks.activeRunIds.clear();
|
||||
mocks.ensureConnected.mockClear();
|
||||
mocks.listDatabases.mockClear();
|
||||
mocks.listDatabases.mockReset();
|
||||
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
|
||||
mocks.recordDatabaseExportDestination.mockReset();
|
||||
mocks.recordDatabaseExportDestination.mockResolvedValue(undefined);
|
||||
mocks.toast.mockClear();
|
||||
mocks.saveSchedule.mockClear();
|
||||
mocks.cancelRun.mockClear();
|
||||
mocks.runOneShot.mockReset();
|
||||
mocks.runOneShot.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
@@ -153,6 +221,142 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-1");
|
||||
});
|
||||
|
||||
it("opens an independent one-shot dialog without schedule fields", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
|
||||
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
|
||||
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.oneShotDescription")));
|
||||
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.connection")));
|
||||
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.contents")));
|
||||
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.scheduleName")));
|
||||
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.frequency")));
|
||||
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.retention")));
|
||||
expect(dialog?.textContent).not.toContain(String(i18n.global.t("databaseBackup.enabled")));
|
||||
});
|
||||
|
||||
it("validates one-shot fields and starts without creating a schedule", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
mocks.runOneShot.mockResolvedValueOnce({ id: "run-1", status: "success", files: [], scheduleName: "One-time backup" });
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
|
||||
const startLabel = String(i18n.global.t("databaseBackup.startBackup"));
|
||||
const startButton = Array.from(dialog?.querySelectorAll("button") ?? []).find((item) => item.textContent?.trim() === startLabel) as HTMLButtonElement | undefined;
|
||||
expect(startButton?.disabled).toBe(true);
|
||||
|
||||
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
|
||||
await flush();
|
||||
expect(startButton?.disabled).toBe(false);
|
||||
startButton?.click();
|
||||
await flush();
|
||||
|
||||
expect(mocks.schedules).toHaveLength(0);
|
||||
expect(mocks.saveSchedule).not.toHaveBeenCalled();
|
||||
expect(mocks.runOneShot).toHaveBeenCalledWith(expect.objectContaining({ connectionId: "mysql-1", destinationDirectory: "/backups", databases: [] }), String(i18n.global.t("databaseBackup.oneShotName")));
|
||||
});
|
||||
|
||||
it("keeps the newer connection database list when an older response finishes last", async () => {
|
||||
mocks.connections.push({ id: "mysql-a", name: "MySQL A", db_type: "mysql" }, { id: "mysql-b", name: "MySQL B", db_type: "mysql" });
|
||||
const firstLoad = deferred<Array<{ name: string }>>();
|
||||
const secondLoad = deferred<Array<{ name: string }>>();
|
||||
mocks.listDatabases.mockImplementation((connectionId: string) => (connectionId === "mysql-a" ? firstLoad.promise : secondLoad.promise));
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-a"));
|
||||
await selectDialogOption(0, "MySQL B");
|
||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-b"));
|
||||
|
||||
secondLoad.resolve([{ name: "new_database" }]);
|
||||
await flush();
|
||||
await showDatabaseOptions();
|
||||
expect(currentDialog().textContent).toContain("new_database");
|
||||
|
||||
firstLoad.resolve([{ name: "stale_database" }]);
|
||||
await flush();
|
||||
expect(currentDialog().textContent).toContain("new_database");
|
||||
expect(currentDialog().textContent).not.toContain("stale_database");
|
||||
});
|
||||
|
||||
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);
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
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 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_*");
|
||||
});
|
||||
|
||||
it("keeps closing the one-shot dialog separate from cancelling the active run", async () => {
|
||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||
const activeRun = {
|
||||
id: "one-shot-active",
|
||||
scheduleName: "One-time backup",
|
||||
connectionId: "mysql-1",
|
||||
connectionName: "Local MySQL",
|
||||
trigger: "manual",
|
||||
source: "one-shot",
|
||||
status: "running",
|
||||
startedAt: "2026-08-18T00:00:00.000Z",
|
||||
files: [],
|
||||
progressPercent: 25,
|
||||
} satisfies DatabaseBackupRun;
|
||||
mocks.runs.push(activeRun);
|
||||
mocks.activeRuns.push(activeRun);
|
||||
mocks.activeRunIds.add(activeRun.id);
|
||||
const pendingRun = deferred<DatabaseBackupRun>();
|
||||
mocks.runOneShot.mockReturnValueOnce(pendingRun.promise);
|
||||
await mountSettings();
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
|
||||
await flush();
|
||||
const startButton = Array.from(currentDialog().querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("databaseBackup.startBackup")));
|
||||
startButton?.click();
|
||||
await flush();
|
||||
|
||||
const runningDialog = currentDialog();
|
||||
const closeButton = Array.from(runningDialog.querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("common.close")));
|
||||
const cancelButton = Array.from(runningDialog.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.title === String(i18n.global.t("databaseBackup.cancel")));
|
||||
expect(closeButton).toBeTruthy();
|
||||
expect(cancelButton?.disabled).toBe(false);
|
||||
|
||||
closeButton?.click();
|
||||
await flush();
|
||||
expect(mocks.cancelRun).not.toHaveBeenCalled();
|
||||
|
||||
buttonWithTitle(String(i18n.global.t("databaseBackup.cancel"))).click();
|
||||
await flush();
|
||||
expect(mocks.cancelRun).toHaveBeenCalledWith(activeRun.id);
|
||||
|
||||
pendingRun.resolve({ ...activeRun, status: "cancelled", completedAt: "2026-08-18T00:01:00.000Z" });
|
||||
await flush();
|
||||
});
|
||||
|
||||
it("disables create schedule when there are no supported backup connections", async () => {
|
||||
mocks.connections.push({ id: "oracle-1", name: "Oracle", db_type: "oracle" });
|
||||
await mountSettings();
|
||||
|
||||
Reference in new issue
Block a user