feat(backup): support cross-platform background database backups
This commit is contained in:
1 parent
250e0c0a10
commit
2f960f104c
56 files changed
+4195
-962
No files matched your search
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { computed, nextTick, ref, watch, type ComponentPublicInstance } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -136,8 +137,8 @@ watch(
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.destination") }}</Label>
|
||||
<div class="backup-destination-field">
|
||||
<Input v-model="draft.destinationDirectory" readonly :title="draft.destinationDirectory" />
|
||||
<Button variant="outline" size="icon" class="backup-destination-picker" :title="t('databaseBackup.selectDestination')" @click="emit('chooseDestination')">
|
||||
<Input v-model="draft.destinationDirectory" :readonly="isTauriRuntime()" :title="draft.destinationDirectory" />
|
||||
<Button v-if="isTauriRuntime()" variant="outline" size="icon" class="backup-destination-picker" :title="t('databaseBackup.selectDestination')" @click="emit('chooseDestination')">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,7 +10,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Check, ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Search, Square, Trash2 } from "@lucide/vue";
|
||||
import { Check, ChevronDown, ChevronRight, DatabaseBackup, Download, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Search, Square, Trash2 } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
|
||||
import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfigFields.vue";
|
||||
@@ -37,7 +38,34 @@ import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOpt
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
|
||||
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, heartbeat, destinationRoot, error: backupError, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
|
||||
const desktop = isTauriRuntime();
|
||||
const backgroundEnabled = ref(false);
|
||||
const backgroundBusy = ref(false);
|
||||
onMounted(async () => {
|
||||
try {
|
||||
backgroundEnabled.value = (await api.databaseBackupBackground()).enabled;
|
||||
} catch (error) {
|
||||
toast(String(error), 5000);
|
||||
}
|
||||
});
|
||||
async function changeBackground(enabled: boolean) {
|
||||
backgroundBusy.value = true;
|
||||
try {
|
||||
backgroundEnabled.value = (await api.databaseBackupBackground(enabled)).enabled;
|
||||
} catch (error) {
|
||||
toast(String(error), 5000);
|
||||
} finally {
|
||||
backgroundBusy.value = false;
|
||||
}
|
||||
}
|
||||
async function changeScheduleEnabled(id: string, enabled: boolean) {
|
||||
try {
|
||||
await setScheduleEnabled(id, enabled);
|
||||
} catch (error) {
|
||||
toast(String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleDialogOpen = ref(false);
|
||||
const oneShotDialogOpen = ref(false);
|
||||
@@ -111,7 +139,7 @@ function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): Data
|
||||
databases: [],
|
||||
tableFilterMode: "all",
|
||||
tablePatterns: [],
|
||||
destinationDirectory: "",
|
||||
destinationDirectory: destinationRoot.value || "",
|
||||
includeStructure: true,
|
||||
includeData: true,
|
||||
includeObjects: true,
|
||||
@@ -131,6 +159,7 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat
|
||||
frequency: "daily",
|
||||
intervalHours: 6,
|
||||
timeOfDay: "02:00",
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
weekday: 1,
|
||||
retentionCount: 10,
|
||||
runDirectoryPattern: DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN,
|
||||
@@ -172,11 +201,33 @@ const canSave = computed(() => {
|
||||
!loadingDatabases.value
|
||||
);
|
||||
});
|
||||
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
|
||||
const nextRunPreview = ref(new Date());
|
||||
const previewError = ref("");
|
||||
const previewZone = ref(draft.value.timeZone);
|
||||
watch(
|
||||
() => [draft.value.frequency, draft.value.intervalHours, draft.value.timeOfDay, draft.value.weekday, draft.value.timeZone],
|
||||
async (_, __, onCleanup) => {
|
||||
let current = true;
|
||||
onCleanup(() => {
|
||||
current = false;
|
||||
});
|
||||
try {
|
||||
const time = await api.databaseBackupCommand<string>({ action: "preview", schedule: draft.value });
|
||||
if (current) {
|
||||
nextRunPreview.value = new Date(time);
|
||||
previewZone.value = draft.value.timeZone;
|
||||
previewError.value = "";
|
||||
}
|
||||
} catch (error) {
|
||||
if (current) previewError.value = String(error);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const scheduleOutputPathPreview = computed(() => {
|
||||
if (!draft.value.destinationDirectory.trim() || !databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") || !databaseBackupFileNamePatternIsValid(draft.value.fileNamePattern || "")) return "";
|
||||
const directory = databaseBackupRunDirectory(draft.value.destinationDirectory, draft.value.runDirectoryPattern || "", draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), nextRunPreview.value, "preview01");
|
||||
return databaseBackupFilePath(directory, draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), "database", nextRunPreview.value, "preview01", draft.value.outputCompression, draft.value.fileNamePattern);
|
||||
const directory = databaseBackupRunDirectory(draft.value.destinationDirectory, draft.value.runDirectoryPattern || "", draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), nextRunPreview.value, "preview01", previewZone.value);
|
||||
return databaseBackupFilePath(directory, draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), "database", nextRunPreview.value, "preview01", draft.value.outputCompression, draft.value.fileNamePattern, previewZone.value);
|
||||
});
|
||||
const oneShotOutputPathPreview = computed(() => {
|
||||
if (!oneShotDraft.value.destinationDirectory.trim() || !databaseBackupFileNamePatternIsValid(oneShotDraft.value.fileNamePattern || "")) return "";
|
||||
@@ -381,11 +432,11 @@ async function chooseDestination() {
|
||||
}
|
||||
|
||||
async function submitSchedule() {
|
||||
if (!canSave.value) return;
|
||||
if (!canSave.value || previewError.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.recordDatabaseExportDestination(draft.value.destinationDirectory);
|
||||
saveSchedule({
|
||||
if (desktop) await api.recordDatabaseExportDestination(draft.value.destinationDirectory);
|
||||
await saveSchedule({
|
||||
...draft.value,
|
||||
databases: allDatabases.value ? [] : [...selectedDatabases.value],
|
||||
tablePatterns: draft.value.tableFilterMode === "all" ? [] : normalizeDatabaseBackupTablePatterns(tablePatternsInput.value),
|
||||
@@ -411,7 +462,7 @@ async function startOneShotBackup() {
|
||||
if (!canStartOneShot.value) return;
|
||||
oneShotStarting.value = true;
|
||||
try {
|
||||
await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
|
||||
if (desktop) await api.recordDatabaseExportDestination(oneShotDraft.value.destinationDirectory);
|
||||
const run = await runOneShot(
|
||||
{
|
||||
...oneShotDraft.value,
|
||||
@@ -442,6 +493,7 @@ async function requestCancelRun(runId: string) {
|
||||
}
|
||||
|
||||
async function confirmLegacyDestination(schedule: DatabaseBackupSchedule): Promise<DatabaseBackupSchedule | null> {
|
||||
if (!desktop) return schedule;
|
||||
if (!(await api.databaseExportDestinationNeedsConfirmation(schedule.destinationDirectory))) return schedule;
|
||||
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
@@ -462,7 +514,7 @@ async function runNow(schedule: DatabaseBackupSchedule) {
|
||||
try {
|
||||
const confirmedSchedule = await confirmLegacyDestination(schedule);
|
||||
if (!confirmedSchedule) return;
|
||||
const run = await runSchedule(confirmedSchedule.id, "manual");
|
||||
const run = await runSchedule(confirmedSchedule.id);
|
||||
if (!run) return;
|
||||
if (run.status === "success") toast(t("databaseBackup.runSuccess", { count: run.files.length }), 3000);
|
||||
else if (run.status === "cancelled") toast(t("databaseBackup.runCancelled"), 3000);
|
||||
@@ -477,14 +529,18 @@ function requestDeleteSchedule(schedule: DatabaseBackupSchedule) {
|
||||
deleteScheduleDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmDeleteSchedule() {
|
||||
async function confirmDeleteSchedule() {
|
||||
if (!pendingDeleteSchedule.value) return;
|
||||
if (!deleteSchedule(pendingDeleteSchedule.value.id)) {
|
||||
toast(t("databaseBackup.cannotDeleteRunningSchedule"), 3000);
|
||||
return;
|
||||
try {
|
||||
if (!(await deleteSchedule(pendingDeleteSchedule.value.id))) {
|
||||
toast(t("databaseBackup.cannotDeleteRunningSchedule"), 3000);
|
||||
return;
|
||||
}
|
||||
deleteScheduleDialogOpen.value = false;
|
||||
pendingDeleteSchedule.value = null;
|
||||
} catch (error) {
|
||||
toast(String(error), 5000);
|
||||
}
|
||||
deleteScheduleDialogOpen.value = false;
|
||||
pendingDeleteSchedule.value = null;
|
||||
}
|
||||
|
||||
function toggleRunSelected(runId: string, selected: boolean) {
|
||||
@@ -518,13 +574,17 @@ function requestRenameRun(run: DatabaseBackupRun) {
|
||||
renameRunDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmRenameRun() {
|
||||
async function confirmRenameRun() {
|
||||
const run = pendingRenameRun.value;
|
||||
if (!run || !renameRunName.value.trim()) return;
|
||||
if (!renameRun(run.id, renameRunName.value)) return;
|
||||
renameRunDialogOpen.value = false;
|
||||
pendingRenameRun.value = null;
|
||||
toast(t("databaseBackup.backupRenamed"), 2500);
|
||||
try {
|
||||
if (!(await renameRun(run.id, renameRunName.value))) return;
|
||||
renameRunDialogOpen.value = false;
|
||||
pendingRenameRun.value = null;
|
||||
toast(t("databaseBackup.backupRenamed"), 2500);
|
||||
} catch (error) {
|
||||
toast(String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteRuns() {
|
||||
@@ -551,18 +611,28 @@ function toggleRunExpanded(runId: string) {
|
||||
|
||||
async function revealBackup(file: DatabaseBackupFile) {
|
||||
try {
|
||||
if (!desktop) {
|
||||
const run = runs.value.find((item) => item.files.includes(file));
|
||||
if (run) await api.downloadDatabaseBackupFile(run.id, run.files.indexOf(file));
|
||||
return;
|
||||
}
|
||||
await api.revealPathInFileManager(file.filePath);
|
||||
} catch (error: any) {
|
||||
toast(translateBackendError(t, error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
connectionStore.sqlFileSource = {
|
||||
connectionId: run.connectionId,
|
||||
database: file.database,
|
||||
filePath: file.filePath,
|
||||
};
|
||||
async function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
try {
|
||||
const prepared = desktop ? file.filePath : await api.prepareDatabaseBackupRestore(run.id, run.files.indexOf(file));
|
||||
connectionStore.sqlFileSource = {
|
||||
connectionId: run.connectionId,
|
||||
database: file.database,
|
||||
...(typeof prepared === "string" ? { filePath: prepared } : { preview: prepared }),
|
||||
};
|
||||
} catch (error) {
|
||||
toast(String(error), 5000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -571,7 +641,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold">{{ t("databaseBackup.schedules") }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ t("databaseBackup.runtimeRequirement") }}</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{{ t(desktop ? "databaseBackup.backgroundLoginScope" : "databaseBackup.serverRuntime") }}</p>
|
||||
<p v-if="!canCreateSchedule" class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.noSupportedConnections") }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
@@ -586,6 +656,15 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b pb-4">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label>{{ t("databaseBackup.backgroundWorker") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ heartbeat ? t("databaseBackup.workerLastSeen", { time: formatDate(heartbeat) }) : t("databaseBackup.workerStarting") }}</p>
|
||||
<p v-if="destinationRoot" class="break-all text-xs text-muted-foreground">{{ t("databaseBackup.serverDestination", { path: destinationRoot }) }}</p>
|
||||
</div>
|
||||
<Switch v-if="desktop" :model-value="backgroundEnabled" :disabled="backgroundBusy" :aria-label="t('databaseBackup.backgroundWorker')" @update:model-value="changeBackground" />
|
||||
</div>
|
||||
<p v-if="backupError" class="break-words text-sm text-destructive">{{ backupError }}</p>
|
||||
<div class="overflow-hidden rounded-md border border-border/70">
|
||||
<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" />
|
||||
@@ -614,7 +693,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Switch :model-value="schedule.enabled" :disabled="activeScheduleIds.has(schedule.id)" :title="schedule.enabled ? t('databaseBackup.disable') : t('databaseBackup.enable')" @update:model-value="(value: boolean) => setScheduleEnabled(schedule.id, value)" />
|
||||
<Switch :model-value="schedule.enabled" :disabled="activeScheduleIds.has(schedule.id)" :title="schedule.enabled ? t('databaseBackup.disable') : t('databaseBackup.enable')" @update:model-value="(value: boolean) => changeScheduleEnabled(schedule.id, value)" />
|
||||
<Button
|
||||
v-if="activeRunForSchedule(schedule.id)"
|
||||
variant="ghost"
|
||||
@@ -760,8 +839,8 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
<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>
|
||||
<Button v-if="run.files[0]" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.revealFile')" @click="revealBackup(run.files[0])">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
<Button v-if="run.files[0]" variant="ghost" size="icon" class="h-8 w-8" :title="t(desktop ? 'databaseBackup.revealFile' : 'common.download')" @click="revealBackup(run.files[0])">
|
||||
<component :is="desktop ? FolderOpen : Download" class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-destructive" :disabled="activeRunIds.has(run.id)" :title="t('databaseBackup.deleteBackup')" @click="requestDeleteRun(run)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
@@ -775,10 +854,10 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
<div class="truncate text-xs text-muted-foreground" :title="file.filePath">{{ file.filePath }}</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('databaseBackup.revealFile')" @click="revealBackup(file)">
|
||||
<FolderOpen class="h-3.5 w-3.5" />
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t(desktop ? 'databaseBackup.revealFile' : 'common.download')" @click="revealBackup(file)">
|
||||
<component :is="desktop ? FolderOpen : Download" class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7" @click="restoreBackup(run, file)">
|
||||
<Button variant="outline" size="sm" class="h-7" :disabled="run.status !== 'success'" @click="restoreBackup(run, file)">
|
||||
<RotateCcw class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("databaseBackup.restore") }}
|
||||
</Button>
|
||||
@@ -856,12 +935,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
||||
<Input v-model.number="draft.retentionCount" type="number" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">{{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("databaseBackup.timeZone") }}</Label>
|
||||
<Input v-model="draft.timeZone" :aria-invalid="!!previewError" />
|
||||
<p v-if="previewError" class="text-xs text-destructive">{{ previewError }}</p>
|
||||
<div v-else class="text-xs text-muted-foreground">{{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 border-t border-border/70 pt-4">
|
||||
<div>
|
||||
<Label>{{ t("databaseBackup.enabled") }}</Label>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.enabledHint") }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ t("databaseBackup.savedTimeZoneHint") }}</div>
|
||||
</div>
|
||||
<Switch v-model="draft.enabled" />
|
||||
</div>
|
||||
|
||||
+83
-2
@@ -7,6 +7,9 @@ import ScheduledDatabaseBackupSettings from "../ScheduledDatabaseBackupSettings.
|
||||
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../../lib/backup/scheduledDatabaseBackup";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
desktop: true,
|
||||
sqlFileSource: null as any,
|
||||
prepareDatabaseBackupRestore: vi.fn(),
|
||||
connections: [] as Array<{ id: string; name: string; db_type: string }>,
|
||||
schedules: [] as DatabaseBackupSchedule[],
|
||||
runs: [] as DatabaseBackupRun[],
|
||||
@@ -25,6 +28,7 @@ const mocks = vi.hoisted(() => ({
|
||||
deleteSchedule: vi.fn(),
|
||||
deleteRun: vi.fn(),
|
||||
deleteRuns: vi.fn(),
|
||||
renameRun: vi.fn(),
|
||||
runSchedule: vi.fn(),
|
||||
runOneShot: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
@@ -36,9 +40,15 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
ensureConnected: mocks.ensureConnected,
|
||||
recordConnectionLostError: mocks.recordConnectionLostError,
|
||||
getConfig: (connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId),
|
||||
sqlFileSource: null,
|
||||
get sqlFileSource() {
|
||||
return mocks.sqlFileSource;
|
||||
},
|
||||
set sqlFileSource(value) {
|
||||
mocks.sqlFileSource = value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
|
||||
|
||||
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
|
||||
useScheduledDatabaseBackups: () => ({
|
||||
@@ -48,11 +58,15 @@ vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
|
||||
activeRunIds: mocks.activeRunIds,
|
||||
cancellingRunIds: mocks.cancellingRunIds,
|
||||
activeRuns: { __v_isRef: true, value: mocks.activeRuns },
|
||||
heartbeat: { __v_isRef: true, value: null },
|
||||
destinationRoot: { __v_isRef: true, value: null },
|
||||
error: { __v_isRef: true, value: "" },
|
||||
saveSchedule: mocks.saveSchedule,
|
||||
setScheduleEnabled: mocks.setScheduleEnabled,
|
||||
deleteSchedule: mocks.deleteSchedule,
|
||||
deleteRun: mocks.deleteRun,
|
||||
deleteRuns: mocks.deleteRuns,
|
||||
renameRun: mocks.renameRun,
|
||||
runSchedule: mocks.runSchedule,
|
||||
runOneShot: mocks.runOneShot,
|
||||
cancelRun: mocks.cancelRun,
|
||||
@@ -78,6 +92,9 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
prepareDatabaseBackupRestore: mocks.prepareDatabaseBackupRestore,
|
||||
databaseBackupBackground: vi.fn(async () => ({ enabled: false, platform: "windows" })),
|
||||
databaseBackupCommand: vi.fn(async () => "2026-09-13T02:00:00Z"),
|
||||
listDatabases: mocks.listDatabases,
|
||||
deleteDatabaseBackupFiles: vi.fn(),
|
||||
revealPathInFileManager: vi.fn(),
|
||||
@@ -257,6 +274,9 @@ function scheduleRunNowButton(): HTMLButtonElement {
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
mocks.desktop = true;
|
||||
mocks.sqlFileSource = null;
|
||||
mocks.prepareDatabaseBackupRestore.mockReset();
|
||||
mocks.connections.splice(0);
|
||||
mocks.schedules.splice(0);
|
||||
mocks.runs.splice(0);
|
||||
@@ -278,6 +298,7 @@ afterEach(() => {
|
||||
mocks.saveSchedule.mockClear();
|
||||
mocks.deleteRun.mockClear();
|
||||
mocks.deleteRuns.mockClear();
|
||||
mocks.renameRun.mockReset();
|
||||
mocks.cancelRun.mockClear();
|
||||
mocks.runSchedule.mockReset();
|
||||
mocks.runSchedule.mockResolvedValue(null);
|
||||
@@ -286,6 +307,61 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
it.each([true, false])("restores with the correct desktop=%s source", async (desktop) => {
|
||||
mocks.desktop = desktop;
|
||||
const preview = { fileName: "backup.sql", filePath: "/server/tmp/sql_file/restore-token/backup.sql", preview: "SELECT 1;", sizeBytes: 9, canExecuteWithoutSelectedDatabase: true, cleanupToken: "restore-token" };
|
||||
mocks.prepareDatabaseBackupRestore.mockResolvedValue(preview);
|
||||
mocks.runs.push({
|
||||
id: "restore-run",
|
||||
scheduleName: "Nightly",
|
||||
connectionId: "mysql-1",
|
||||
trigger: "manual",
|
||||
source: "scheduled",
|
||||
status: "success",
|
||||
startedAt: "2026-08-18T00:00:00.000Z",
|
||||
files: [{ displayName: "backup.sql", filePath: "/backups/backup.sql", database: "app" }],
|
||||
});
|
||||
await mountSettings();
|
||||
buttonWithTitle(String(i18n.global.t("databaseBackup.showFiles"))).click();
|
||||
await flush();
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.restore"))).click();
|
||||
await flush();
|
||||
expect(mocks.sqlFileSource).toEqual({ connectionId: "mysql-1", database: "app", ...(desktop ? { filePath: "/backups/backup.sql" } : { preview }) });
|
||||
if (desktop) expect(mocks.prepareDatabaseBackupRestore).not.toHaveBeenCalled();
|
||||
else expect(mocks.prepareDatabaseBackupRestore).toHaveBeenCalledWith("restore-run", 0);
|
||||
});
|
||||
|
||||
it("shows a backup display name and saves edits through the rename dialog", async () => {
|
||||
mocks.runs.push({
|
||||
id: "renamed-run",
|
||||
scheduleName: "Nightly backup",
|
||||
displayName: "Before migration",
|
||||
connectionId: "mysql-1",
|
||||
connectionName: "Local MySQL",
|
||||
trigger: "manual",
|
||||
source: "scheduled",
|
||||
status: "success",
|
||||
startedAt: "2026-08-18T00:00:00.000Z",
|
||||
files: [],
|
||||
});
|
||||
mocks.renameRun.mockResolvedValue(true);
|
||||
await mountSettings();
|
||||
|
||||
expect(document.body.textContent).toContain("Before migration");
|
||||
buttonWithTitle(String(i18n.global.t("databaseBackup.renameBackup"))).click();
|
||||
await flush();
|
||||
const input = currentDialog().querySelector<HTMLInputElement>("input")!;
|
||||
expect(input.value).toBe("Before migration");
|
||||
input.value = "After migration";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flush();
|
||||
saveScheduleButton().click();
|
||||
await flush();
|
||||
|
||||
expect(mocks.renameRun).toHaveBeenCalledWith("renamed-run", "After migration");
|
||||
expect(document.body.querySelector('[data-slot="dialog-content"]')).toBeNull();
|
||||
});
|
||||
|
||||
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" }]);
|
||||
@@ -616,6 +692,11 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
mocks.runOneShot.mockReturnValueOnce(pendingRun.promise);
|
||||
await mountSettings();
|
||||
|
||||
const progress = document.body.querySelector('[role="progressbar"]');
|
||||
expect(progress?.getAttribute("aria-valuenow")).toBe("25");
|
||||
expect(document.body.textContent).toContain("25%");
|
||||
expect(buttonWithTitle(String(i18n.global.t("databaseBackup.renameBackup"))).disabled).toBe(true);
|
||||
|
||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||
await flush();
|
||||
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
|
||||
@@ -674,7 +755,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||
expect(mocks.databaseExportDestinationNeedsConfirmation).toHaveBeenCalledWith("/backups");
|
||||
expect(mocks.openDirectory).toHaveBeenCalledWith(expect.objectContaining({ directory: true, defaultPath: "/backups" }));
|
||||
expect(mocks.recordDatabaseExportDestination).toHaveBeenCalledWith("/backups");
|
||||
expect(mocks.runSchedule).toHaveBeenCalledWith("schedule-1", "manual");
|
||||
expect(mocks.runSchedule).toHaveBeenCalledWith("schedule-1");
|
||||
});
|
||||
|
||||
it("does not run a legacy schedule when destination confirmation is cancelled", async () => {
|
||||
|
||||
@@ -2914,7 +2914,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[
|
||||
{ value: "formatter", label: t("settings.sqlFormatterTab") },
|
||||
{ value: "navigation", label: t("settings.navigationTab") },
|
||||
{ value: "data", label: t("settings.dataTab") },
|
||||
...(isWeb ? [] : [{ value: "backups" as const, label: t("databaseBackup.title") }]),
|
||||
{ value: "backups" as const, label: t("databaseBackup.title") },
|
||||
{ value: "tunnels", label: t("settings.tunnelsTab") },
|
||||
{ value: "shortcuts", label: t("settings.shortcutsTab") },
|
||||
{ value: "snippets", label: t("settings.snippetsTab") },
|
||||
@@ -8616,7 +8616,7 @@ LIMIT 100;</pre
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSettingsTab === 'backups' && !isWeb" data-settings-search-id="backups" :class="['py-2', settingsSearchTargetClass('backups')]">
|
||||
<section v-else-if="activeSettingsTab === 'backups'" data-settings-search-id="backups" :class="['py-2', settingsSearchTargetClass('backups')]">
|
||||
<ScheduledDatabaseBackupSettings />
|
||||
</section>
|
||||
|
||||
|
||||
@@ -262,7 +262,13 @@ watch(
|
||||
:prefill-table="dialogs.dataComparePrefillTable.value"
|
||||
:session-id="dialogs.dataCompareSessionId.value"
|
||||
/>
|
||||
<SqlFileExecutionDialog v-model:open="dialogs.showSqlFileDialog.value" :prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value" :prefill-database="dialogs.sqlFilePrefillDatabase.value" :prefill-file-path="dialogs.sqlFilePrefillFilePath.value" />
|
||||
<SqlFileExecutionDialog
|
||||
v-model:open="dialogs.showSqlFileDialog.value"
|
||||
:prefill-connection-id="dialogs.sqlFilePrefillConnectionId.value"
|
||||
:prefill-database="dialogs.sqlFilePrefillDatabase.value"
|
||||
:prefill-file-path="dialogs.sqlFilePrefillFilePath.value"
|
||||
:prefill-preview="dialogs.sqlFilePrefillPreview.value"
|
||||
/>
|
||||
<SchemaDiagramDialog
|
||||
v-if="dialogs.showDiagramDialog.value"
|
||||
v-model:open="dialogs.showDiagramDialog.value"
|
||||
|
||||
@@ -4,6 +4,9 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
desktop: true,
|
||||
releaseSqlFilePreview: vi.fn(),
|
||||
previewWebSqlFile: vi.fn(),
|
||||
addSqlFileTask: vi.fn(),
|
||||
beginManualTransaction: vi.fn(),
|
||||
commitManualTransaction: vi.fn(),
|
||||
@@ -40,7 +43,14 @@ function passthrough(tag: string) {
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/lib/common/utils", () => ({ uuid: mocks.uuid }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
|
||||
vi.mock("@/lib/backend/http", () => ({
|
||||
previewSqlFile: mocks.previewWebSqlFile,
|
||||
loadSqlFileUploadMaxBytes: vi.fn(async () => 200 * 1024 * 1024),
|
||||
}));
|
||||
vi.mock("@/lib/sql/httpSqlFileProgress", () => ({
|
||||
listenSqlFileProgressById: (_id: string, handler: (progress: Record<string, unknown>) => void) => mocks.listenSqlFileProgress(handler),
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.openFileDialog }));
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: mocks.highlight }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
@@ -69,6 +79,7 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
executeSqlFiles: mocks.executeSqlFiles,
|
||||
listenSqlFileProgress: mocks.listenSqlFileProgress,
|
||||
previewSqlFile: mocks.previewSqlFile,
|
||||
releaseSqlFilePreview: mocks.releaseSqlFilePreview,
|
||||
inspectSqlFileTables: mocks.inspectSqlFileTables,
|
||||
}));
|
||||
vi.mock("@lucide/vue", () => {
|
||||
@@ -176,6 +187,8 @@ function deferred() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.desktop = true;
|
||||
mocks.releaseSqlFilePreview.mockResolvedValue(undefined);
|
||||
mocks.progressHandler = undefined;
|
||||
mocks.highlighterState = reactive({ ready: false, appearance: "light" });
|
||||
mocks.highlight.mockReset().mockImplementation((sql: string) => (mocks.highlighterState!.ready ? `<span data-theme="${mocks.highlighterState!.appearance}">${sql}</span>` : sql));
|
||||
@@ -245,6 +258,57 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SqlFileExecutionDialog retries", () => {
|
||||
async function mountRestoredPreview() {
|
||||
mocks.desktop = false;
|
||||
const preview = {
|
||||
fileName: "backup.sql",
|
||||
filePath: "/server/tmp/sql_file/restore-token/backup.sql",
|
||||
preview: "SELECT 42;",
|
||||
sizeBytes: 10,
|
||||
canExecuteWithoutSelectedDatabase: true,
|
||||
cleanupToken: "restore-token",
|
||||
};
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(SqlFileExecutionDialog, { open: true, prefillPreview: preview });
|
||||
app.mount(root);
|
||||
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
|
||||
expect(root.textContent).toContain("SELECT 42;");
|
||||
expect(mocks.previewSqlFile).not.toHaveBeenCalled();
|
||||
expect(mocks.previewWebSqlFile).not.toHaveBeenCalled();
|
||||
return preview;
|
||||
}
|
||||
|
||||
it("renders a prepared Web preview without uploading a server path and releases it on unmount", async () => {
|
||||
await mountRestoredPreview();
|
||||
app!.unmount();
|
||||
app = undefined;
|
||||
await vi.waitFor(() => expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token"));
|
||||
});
|
||||
|
||||
it.each(["done", "error", "cancelled"])("executes the prepared server object and releases it after %s", async (status) => {
|
||||
const preview = await mountRestoredPreview();
|
||||
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
|
||||
expect(mocks.releaseSqlFilePreview).not.toHaveBeenCalled();
|
||||
mocks.progressHandler?.(progress(request.executionId, status, status === "error" ? { error: "restore failed" } : {}));
|
||||
});
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledWith(expect.objectContaining({ filePath: preview.filePath }), [preview.filePath]));
|
||||
await vi.waitFor(() => expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token"));
|
||||
expect(mocks.previewWebSqlFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps browser uploads on the File-only preview path", async () => {
|
||||
await mountRestoredPreview();
|
||||
const file = new File(["SELECT 2;"], "upload.sql", { type: "text/plain" });
|
||||
mocks.previewWebSqlFile.mockResolvedValueOnce({ fileName: file.name, filePath: "/server/tmp/sql_file/upload.sql", sizeBytes: file.size, preview: "SELECT 2;", canExecuteWithoutSelectedDatabase: true });
|
||||
const input = root!.querySelector<HTMLInputElement>('input[type="file"]')!;
|
||||
Object.defineProperty(input, "files", { value: [file] });
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await vi.waitFor(() => expect(mocks.previewWebSqlFile).toHaveBeenCalledWith(file));
|
||||
expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token");
|
||||
});
|
||||
|
||||
it("reuses the unchanged preview across progress and failure updates while keeping controls responsive", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
await mountReadyDialog(onOpenChange);
|
||||
|
||||
@@ -39,6 +39,7 @@ const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillFilePath?: string;
|
||||
prefillPreview?: SqlFilePreview;
|
||||
}>();
|
||||
|
||||
const store = useConnectionStore();
|
||||
@@ -239,6 +240,7 @@ watch(canUseManualTransaction, (supported) => {
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
if (!running.value) void releaseManagedPreviews();
|
||||
if (running.value && manualTransaction.value) {
|
||||
cancelRequested.value = true;
|
||||
if (executionStarted.value) void cancelSqlFileExecution(executionId.value).catch(() => {});
|
||||
@@ -469,6 +471,7 @@ async function previewSelectedSqlFile(fileOrPath: string | File) {
|
||||
|
||||
async function loadPreviews(filesOrPaths: Array<string | File>) {
|
||||
if (executionLocked.value) return;
|
||||
await releaseManagedPreviews();
|
||||
loadingPreview.value = true;
|
||||
previews.value = [];
|
||||
resetExecution();
|
||||
@@ -485,6 +488,18 @@ async function loadPreviews(filesOrPaths: Array<string | File>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseManagedPreviews() {
|
||||
const tokens = previews.value.flatMap((preview) => (preview.cleanupToken ? [preview.cleanupToken] : []));
|
||||
if (!tokens.length) return;
|
||||
previews.value = previews.value.filter((preview) => !preview.cleanupToken);
|
||||
try {
|
||||
const { releaseSqlFilePreview } = await import("@/lib/backend/api");
|
||||
await Promise.all(tokens.map((token) => releaseSqlFilePreview(token)));
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFile() {
|
||||
if (executionLocked.value) return;
|
||||
if (!isTauriRuntime()) {
|
||||
@@ -721,6 +736,7 @@ async function startExecution() {
|
||||
running.value = false;
|
||||
cancelling.value = false;
|
||||
executionStarted.value = false;
|
||||
await releaseManagedPreviews();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,6 +779,7 @@ watch(
|
||||
open,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
if (!running.value) void releaseManagedPreviews();
|
||||
if (manualTransaction.value && running.value) void cancelExecution();
|
||||
else if (txnSessionId.value) void finishTransaction(false);
|
||||
return;
|
||||
@@ -774,7 +791,9 @@ watch(
|
||||
}
|
||||
// When opened from the SQL Files panel with a pre-selected file, load its
|
||||
// preview automatically so the user can review statements before running.
|
||||
if (props.prefillFilePath) {
|
||||
if (props.prefillPreview) {
|
||||
previews.value = [props.prefillPreview];
|
||||
} else if (props.prefillFilePath) {
|
||||
void loadPreviews([props.prefillFilePath]);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user