feat(backup): support custom backup file name templates and path preview

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-10 00:29:15 +08:00
1 parent 38be5ba35c
commit ce3bd1c8ed
31 files changed
+744 -125

No files matched your search

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, ref, watch, type ComponentPublicInstance } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 { HelpTooltip } from "@/components/ui/tooltip";
import { Check, ChevronDown, FolderOpen, Loader2, Search } from "@lucide/vue";
import type { DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
import { databaseBackupFileNamePatternIsValid, type DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
import type { ConnectionConfig } from "@/types/database";
const props = withDefaults(
@@ -20,8 +21,11 @@ const props = withDefaults(
tablePatternsInput: string;
loadingDatabases: boolean;
databaseLoadError?: string;
runDirectoryPattern?: string;
runDirectoryPatternValid?: boolean;
outputPathPreview?: string;
}>(),
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [], databaseLoadError: "" },
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [], databaseLoadError: "", runDirectoryPatternValid: true, outputPathPreview: "" },
);
const emit = defineEmits<{
@@ -30,12 +34,15 @@ const emit = defineEmits<{
toggleDatabase: [database: string];
"update:allDatabases": [value: boolean];
"update:tablePatternsInput": [value: string];
"update:runDirectoryPattern": [value: string];
}>();
const { t } = useI18n();
const connectionPickerOpen = ref(false);
const connectionSearch = ref("");
const databaseSearch = ref("");
const connectionPickerTrigger = ref<ComponentPublicInstance | null>(null);
const connectionPickerWidth = ref<number>();
const selectedConnectionName = computed(() => props.connections.find((connection) => connection.id === props.draft.connectionId)?.name || props.draft.connectionId);
const filteredConnections = computed(() => {
@@ -48,14 +55,39 @@ const filteredDatabaseOptions = computed(() => {
if (!query) return props.databaseOptions;
return props.databaseOptions.filter((database) => database.toLocaleLowerCase().includes(query));
});
const runDirectoryTemplateVariables = computed(() => [
{ token: "{schedule}", description: t("databaseBackup.templateVariableSchedule") },
{ token: "{date}", description: t("databaseBackup.templateVariableDate") },
{ token: "{timestamp}", description: t("databaseBackup.templateVariableTimestamp") },
{ token: "{runId}", description: t("databaseBackup.templateVariableRunId") },
]);
const fileNameTemplateVariables = computed(() => [
{ token: "{schedule}", description: t("databaseBackup.templateVariableSchedule") },
{ token: "{date}", description: t("databaseBackup.templateVariableDate") },
{ token: "{timestamp}", description: t("databaseBackup.templateVariableTimestamp") },
{ token: "{database}", description: t("databaseBackup.templateVariableDatabase") },
{ token: "{runId}", description: t("databaseBackup.templateVariableRunId") },
]);
function selectConnection(connectionId: string) {
emit("changeConnection", connectionId);
connectionPickerOpen.value = false;
}
watch(connectionPickerOpen, (open) => {
if (!open) connectionSearch.value = "";
function syncConnectionPickerWidth() {
const element = connectionPickerTrigger.value?.$el;
if (!(element instanceof HTMLElement)) return;
const width = Math.ceil(element.getBoundingClientRect().width);
if (width > 0) connectionPickerWidth.value = width;
}
watch(connectionPickerOpen, async (open) => {
if (!open) {
connectionSearch.value = "";
return;
}
await nextTick();
syncConnectionPickerWidth();
});
watch(
@@ -67,17 +99,17 @@ watch(
</script>
<template>
<div class="grid gap-5 py-1">
<div class="backup-config-fields grid gap-5 py-1">
<div class="space-y-2">
<Label>{{ t("databaseBackup.connection") }}</Label>
<Popover v-model:open="connectionPickerOpen">
<PopoverTrigger as-child>
<Button data-backup-connection-picker type="button" variant="outline" role="combobox" :aria-expanded="connectionPickerOpen" class="w-full justify-between font-normal">
<Button ref="connectionPickerTrigger" data-backup-connection-picker type="button" variant="outline" role="combobox" :aria-expanded="connectionPickerOpen" class="w-full justify-between font-normal" @click="syncConnectionPickerWidth">
<span class="truncate">{{ selectedConnectionName }}</span>
<ChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[var(--reka-popover-trigger-width)] p-1">
<PopoverContent align="start" :collision-padding="24" class="w-[var(--reka-popover-trigger-width)] max-w-[calc(100vw-48px)] p-1" :style="connectionPickerWidth ? { width: `${connectionPickerWidth}px` } : undefined">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="connectionSearch" data-backup-connection-search class="h-9 pl-8" :aria-label="t('databaseBackup.searchConnections')" :placeholder="t('databaseBackup.searchConnections')" />
@@ -101,14 +133,59 @@ watch(
<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')">
<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')">
<FolderOpen class="h-4 w-4" />
</Button>
</div>
</div>
<div class="space-y-4">
<Label>{{ t("databaseBackup.outputPathTemplate") }}</Label>
<div v-if="runDirectoryPattern !== undefined" class="space-y-2">
<div class="flex items-center gap-1.5">
<Label class="text-sm font-normal">{{ t("databaseBackup.runDirectoryPattern") }}</Label>
<HelpTooltip :label="t('databaseBackup.templateVariablesHelp')" side="right" content-class="w-80 max-w-[calc(100vw-32px)]">
<div class="space-y-2">
<div class="font-medium">{{ t("databaseBackup.templateVariablesTitle") }}</div>
<div v-for="variable in runDirectoryTemplateVariables" :key="variable.token" class="flex items-start gap-2">
<code class="w-20 shrink-0 font-mono font-medium">{{ variable.token }}</code>
<span>{{ variable.description }}</span>
</div>
</div>
</HelpTooltip>
</div>
<Input :model-value="runDirectoryPattern" data-backup-run-directory-pattern :aria-invalid="!runDirectoryPatternValid" @update:model-value="(value: string | number) => emit('update:runDirectoryPattern', String(value))" />
<p class="text-xs text-muted-foreground">{{ t("databaseBackup.runDirectoryPatternHint") }}</p>
<p v-if="!runDirectoryPatternValid" class="text-xs text-destructive">{{ t("databaseBackup.runDirectoryPatternInvalid") }}</p>
</div>
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label class="text-sm font-normal">{{ t("databaseBackup.fileNamePattern") }}</Label>
<HelpTooltip :label="t('databaseBackup.templateVariablesHelp')" side="right" content-class="w-80 max-w-[calc(100vw-32px)]">
<div class="space-y-2">
<div class="font-medium">{{ t("databaseBackup.templateVariablesTitle") }}</div>
<div v-for="variable in fileNameTemplateVariables" :key="variable.token" class="flex items-start gap-2">
<code class="w-20 shrink-0 font-mono font-medium">{{ variable.token }}</code>
<span>{{ variable.description }}</span>
</div>
</div>
</HelpTooltip>
</div>
<Input v-model="draft.fileNamePattern" data-backup-file-name-pattern :aria-invalid="!databaseBackupFileNamePatternIsValid(draft.fileNamePattern || '')" />
<p class="text-xs text-muted-foreground">{{ t("databaseBackup.fileNamePatternHint") }}</p>
<p v-if="!databaseBackupFileNamePatternIsValid(draft.fileNamePattern || '')" class="text-xs text-destructive">{{ t("databaseBackup.fileNamePatternInvalid") }}</p>
</div>
<div v-if="outputPathPreview" data-backup-output-path-preview class="backup-output-path-preview 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.outputPathPreview") }}</div>
<div class="backup-output-path-value font-mono text-xs text-foreground" :title="outputPathPreview">{{ outputPathPreview }}</div>
</div>
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.compression") }}</Label>
<label class="flex items-center gap-2 text-sm">
@@ -176,3 +253,62 @@ watch(
</div>
</div>
</template>
<style scoped>
.backup-config-fields {
grid-template-columns: minmax(0, 1fr);
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: hidden;
}
.backup-config-fields > * {
min-width: 0;
max-width: 100%;
}
.backup-destination-field {
position: relative;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
padding-right: 2.5rem;
}
.backup-destination-field > [data-slot="input"] {
display: block;
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.backup-destination-picker {
position: absolute;
top: 0;
right: 0;
width: 2rem;
height: 2rem;
padding: 0;
}
.backup-output-path-preview {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.backup-output-path-value {
display: block;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-all;
}
</style>
@@ -18,6 +18,9 @@ import { translateBackendError } from "@/i18n/backend-errors";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import {
DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN,
DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN,
databaseBackupFileNamePatternIsValid,
databaseBackupFilePath,
databaseBackupRunDirectory,
databaseBackupRunDirectoryPatternIsValid,
nextDatabaseBackupRunAt,
@@ -114,6 +117,7 @@ function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): Data
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
fileNamePattern: DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN,
};
}
@@ -155,18 +159,34 @@ 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() && databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value;
return (
!!draft.value.name.trim() &&
!!draft.value.connectionId &&
!!draft.value.destinationDirectory.trim() &&
databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") &&
databaseBackupFileNamePatternIsValid(draft.value.fileNamePattern || "") &&
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 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 oneShotOutputPathPreview = computed(() => {
if (!oneShotDraft.value.destinationDirectory.trim() || !databaseBackupFileNamePatternIsValid(oneShotDraft.value.fileNamePattern || "")) return "";
return databaseBackupFilePath(oneShotDraft.value.destinationDirectory, t("databaseBackup.oneShotName"), "database", new Date(), "preview01", oneShotDraft.value.outputCompression, oneShotDraft.value.fileNamePattern);
});
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;
return !!oneShotDraft.value.connectionId && !!oneShotDraft.value.destinationDirectory.trim() && databaseBackupFileNamePatternIsValid(oneShotDraft.value.fileNamePattern || "") && hasContent && hasDatabaseScope && hasTableScope && !oneShotStarting.value && !loadingDatabases.value;
});
function connectionName(connectionId: string): string {
@@ -771,12 +791,12 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<Dialog v-model:open="scheduleDialogOpen">
<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">
<DialogContent class="dbx-backup-dialog 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-x-hidden overflow-y-auto pr-8 [scrollbar-gutter:stable]">
<DialogHeader>
<DialogTitle>{{ editingScheduleId ? t("databaseBackup.editSchedule") : t("databaseBackup.addSchedule") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-5 py-1">
<div class="backup-schedule-form grid gap-5 py-1">
<div class="space-y-2">
<Label>{{ t("databaseBackup.scheduleName") }}</Label>
<Input v-model="draft.name" />
@@ -791,24 +811,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
:table-patterns-input="tablePatternsInput"
:loading-databases="loadingDatabases"
:database-load-error="databaseLoadError"
:run-directory-pattern="draft.runDirectoryPattern"
:run-directory-pattern-valid="databaseBackupRunDirectoryPatternIsValid(draft.runDirectoryPattern || '')"
:output-path-preview="scheduleOutputPathPreview"
@change-connection="changeConnection"
@choose-destination="chooseDestination"
@toggle-database="toggleDatabase"
@update:all-databases="setAllDatabases"
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
@update:run-directory-pattern="(value: string) => (draft.runDirectoryPattern = 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>
@@ -865,7 +878,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</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">
<DialogContent class="dbx-backup-dialog 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-x-hidden overflow-y-auto pr-8 [scrollbar-gutter:stable]">
<DialogHeader>
<DialogTitle>{{ t("databaseBackup.oneShotBackup") }}</DialogTitle>
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.oneShotDescription") }}</p>
@@ -880,6 +893,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
:table-patterns-input="tablePatternsInput"
:loading-databases="loadingDatabases"
:database-load-error="databaseLoadError"
:output-path-preview="oneShotOutputPathPreview"
@change-connection="changeConnection"
@choose-destination="chooseDestination"
@toggle-database="toggleDatabase"
@@ -949,3 +963,12 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</DialogContent>
</Dialog>
</template>
<style scoped>
.backup-schedule-form {
grid-template-columns: minmax(0, 1fr);
width: 100%;
min-width: 0;
max-width: 100%;
}
</style>
@@ -437,7 +437,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(mocks.listDatabases).not.toHaveBeenCalled();
});
it("previews the rendered run directory and updates it when the template changes", async () => {
it("previews the rendered output path and updates it when the directory template changes", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
await mountSettings();
@@ -446,7 +446,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
await flush();
const preview = currentDialog().querySelector<HTMLElement>("[data-backup-run-directory-preview]");
const preview = currentDialog().querySelector<HTMLElement>("[data-backup-output-path-preview]");
expect(preview?.textContent).toContain("/backups/dbx-backup__");
expect(preview?.textContent).toContain("preview0");
@@ -457,7 +457,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
await flush();
expect(currentDialog().textContent).toContain("{timestamp}");
expect(currentDialog().querySelector<HTMLElement>("[data-backup-run-directory-preview]")?.textContent).toContain("/backups/archive/");
expect(currentDialog().querySelector<HTMLElement>("[data-backup-output-path-preview]")?.textContent).toContain("/backups/archive/");
});
it("opens an independent one-shot dialog without schedule fields", async () => {