fix(backup): enhance backup configuration and history management
This commit is contained in:
23 files changed
+623
-92
No files matched your search
@@ -19,8 +19,9 @@ const props = withDefaults(
|
|||||||
databaseOptions: string[];
|
databaseOptions: string[];
|
||||||
tablePatternsInput: string;
|
tablePatternsInput: string;
|
||||||
loadingDatabases: boolean;
|
loadingDatabases: boolean;
|
||||||
|
databaseLoadError?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [] },
|
{ connections: () => [], selectedDatabases: () => [], databaseOptions: () => [], databaseLoadError: "" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -139,6 +140,7 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
<div v-else class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingDatabases") }}</div>
|
<div v-else class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingDatabases") }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="databaseLoadError" data-backup-database-load-error class="text-xs text-destructive">{{ databaseLoadError }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,25 @@ import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfig
|
|||||||
import { useToast } from "@/composables/useToast";
|
import { useToast } from "@/composables/useToast";
|
||||||
import { translateBackendError } from "@/i18n/backend-errors";
|
import { translateBackendError } from "@/i18n/backend-errors";
|
||||||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||||||
import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupExecutionConfig, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
|
import {
|
||||||
|
DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN,
|
||||||
|
databaseBackupRunDirectory,
|
||||||
|
databaseBackupRunDirectoryPatternIsValid,
|
||||||
|
nextDatabaseBackupRunAt,
|
||||||
|
normalizeDatabaseBackupTablePatterns,
|
||||||
|
supportsScheduledDatabaseBackup,
|
||||||
|
type DatabaseBackupExecutionConfig,
|
||||||
|
type DatabaseBackupFile,
|
||||||
|
type DatabaseBackupRun,
|
||||||
|
type DatabaseBackupSchedule,
|
||||||
|
} from "@/lib/backup/scheduledDatabaseBackup";
|
||||||
import { useConnectionStore } from "@/stores/connectionStore";
|
import { useConnectionStore } from "@/stores/connectionStore";
|
||||||
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
|
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRuns, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
|
||||||
|
|
||||||
const scheduleDialogOpen = ref(false);
|
const scheduleDialogOpen = ref(false);
|
||||||
const oneShotDialogOpen = ref(false);
|
const oneShotDialogOpen = ref(false);
|
||||||
@@ -32,10 +43,11 @@ const deleteRunDialogOpen = ref(false);
|
|||||||
const renameRunDialogOpen = ref(false);
|
const renameRunDialogOpen = ref(false);
|
||||||
const editingScheduleId = ref("");
|
const editingScheduleId = ref("");
|
||||||
const pendingDeleteSchedule = ref<DatabaseBackupSchedule | null>(null);
|
const pendingDeleteSchedule = ref<DatabaseBackupSchedule | null>(null);
|
||||||
const pendingDeleteRun = ref<DatabaseBackupRun | null>(null);
|
const pendingDeleteRunIds = ref<string[]>([]);
|
||||||
const pendingRenameRun = ref<DatabaseBackupRun | null>(null);
|
const pendingRenameRun = ref<DatabaseBackupRun | null>(null);
|
||||||
const renameRunName = ref("");
|
const renameRunName = ref("");
|
||||||
const loadingDatabases = ref(false);
|
const loadingDatabases = ref(false);
|
||||||
|
const databaseLoadError = ref("");
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const oneShotStarting = ref(false);
|
const oneShotStarting = ref(false);
|
||||||
const databaseOptions = ref<string[]>([]);
|
const databaseOptions = ref<string[]>([]);
|
||||||
@@ -43,6 +55,7 @@ const allDatabases = ref(true);
|
|||||||
const selectedDatabases = ref<string[]>([]);
|
const selectedDatabases = ref<string[]>([]);
|
||||||
const tablePatternsInput = ref("");
|
const tablePatternsInput = ref("");
|
||||||
const expandedRunIds = reactive(new Set<string>());
|
const expandedRunIds = reactive(new Set<string>());
|
||||||
|
const selectedRunIds = reactive(new Set<string>());
|
||||||
const historyConnectionId = ref("");
|
const historyConnectionId = ref("");
|
||||||
const historyConnectionPickerOpen = ref(false);
|
const historyConnectionPickerOpen = ref(false);
|
||||||
const historyConnectionSearch = ref("");
|
const historyConnectionSearch = ref("");
|
||||||
@@ -71,6 +84,9 @@ const filteredRuns = computed(() =>
|
|||||||
return historyStatus.value === "all" || historyStatus.value === run.status;
|
return historyStatus.value === "all" || historyStatus.value === run.status;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const selectableFilteredRuns = computed(() => filteredRuns.value.filter((run) => !activeRunIds.has(run.id)));
|
||||||
|
const selectedRuns = computed(() => sortedRuns.value.filter((run) => selectedRunIds.has(run.id) && !activeRunIds.has(run.id)));
|
||||||
|
const allSelectableRunsSelected = computed(() => selectableFilteredRuns.value.length > 0 && selectableFilteredRuns.value.every((run) => selectedRunIds.has(run.id)));
|
||||||
const selectedHistoryConnectionName = computed(() => historyConnections.value.find((connection) => connection.id === historyConnectionId.value)?.name || t("databaseBackup.allConnections"));
|
const selectedHistoryConnectionName = computed(() => historyConnections.value.find((connection) => connection.id === historyConnectionId.value)?.name || t("databaseBackup.allConnections"));
|
||||||
const weekdays = computed(() => [
|
const weekdays = computed(() => [
|
||||||
{ value: 0, label: t("databaseBackup.weekdays.sunday") },
|
{ value: 0, label: t("databaseBackup.weekdays.sunday") },
|
||||||
@@ -113,6 +129,7 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat
|
|||||||
timeOfDay: "02:00",
|
timeOfDay: "02:00",
|
||||||
weekday: 1,
|
weekday: 1,
|
||||||
retentionCount: 10,
|
retentionCount: 10,
|
||||||
|
runDirectoryPattern: DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN,
|
||||||
createdAt: now.toISOString(),
|
createdAt: now.toISOString(),
|
||||||
updatedAt: now.toISOString(),
|
updatedAt: now.toISOString(),
|
||||||
nextRunAt: "",
|
nextRunAt: "",
|
||||||
@@ -138,9 +155,13 @@ const canSave = computed(() => {
|
|||||||
const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects;
|
const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects;
|
||||||
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
|
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
|
||||||
const hasTableScope = draft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0;
|
const hasTableScope = draft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0;
|
||||||
return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value;
|
return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "") && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value;
|
||||||
});
|
});
|
||||||
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
|
const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date()));
|
||||||
|
const runDirectoryPreview = computed(() => {
|
||||||
|
if (!draft.value.destinationDirectory.trim() || !databaseBackupRunDirectoryPatternIsValid(draft.value.runDirectoryPattern || "")) return "";
|
||||||
|
return databaseBackupRunDirectory(draft.value.destinationDirectory, draft.value.runDirectoryPattern || "", draft.value.name.trim() || t("databaseBackup.defaultScheduleName"), nextRunPreview.value, "preview01");
|
||||||
|
});
|
||||||
const canStartOneShot = computed(() => {
|
const canStartOneShot = computed(() => {
|
||||||
const hasContent = oneShotDraft.value.includeStructure || oneShotDraft.value.includeData || oneShotDraft.value.includeObjects;
|
const hasContent = oneShotDraft.value.includeStructure || oneShotDraft.value.includeData || oneShotDraft.value.includeObjects;
|
||||||
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
|
const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0;
|
||||||
@@ -221,6 +242,7 @@ async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBack
|
|||||||
const generation = ++databaseLoadGeneration;
|
const generation = ++databaseLoadGeneration;
|
||||||
const connectionId = targetDraft.connectionId;
|
const connectionId = targetDraft.connectionId;
|
||||||
databaseOptions.value = [];
|
databaseOptions.value = [];
|
||||||
|
databaseLoadError.value = "";
|
||||||
if (!preserveSelection) {
|
if (!preserveSelection) {
|
||||||
selectedDatabases.value = [];
|
selectedDatabases.value = [];
|
||||||
allDatabases.value = true;
|
allDatabases.value = true;
|
||||||
@@ -234,32 +256,52 @@ async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBack
|
|||||||
}
|
}
|
||||||
loadingDatabases.value = true;
|
loadingDatabases.value = true;
|
||||||
try {
|
try {
|
||||||
await connectionStore.ensureConnected(connectionId);
|
const loadNames = async () => {
|
||||||
const config = connectionStore.getConfig(connectionId);
|
await connectionStore.ensureConnected(connectionId);
|
||||||
const names = config?.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(connectionId, config) : (await api.listDatabases(connectionId)).map((database) => database.name);
|
const config = connectionStore.getConfig(connectionId);
|
||||||
|
return config?.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(connectionId, config) : (await api.listDatabases(connectionId)).map((database) => database.name);
|
||||||
|
};
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await loadNames();
|
||||||
|
} catch (error) {
|
||||||
|
// A connection can be closed by MySQL after the store's recent health
|
||||||
|
// check. Mark it stale and retry the read once through a fresh pool.
|
||||||
|
if (!connectionStore.recordConnectionLostError(connectionId, error)) throw error;
|
||||||
|
names = await loadNames();
|
||||||
|
}
|
||||||
if (!databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) return;
|
if (!databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) return;
|
||||||
databaseOptions.value = names;
|
databaseOptions.value = names;
|
||||||
if (preserveSelection) {
|
if (preserveSelection) {
|
||||||
const selected = new Set(selectedDatabases.value);
|
const selected = new Set(selectedDatabases.value);
|
||||||
selectedDatabases.value = names.filter((database) => selected.has(database));
|
selectedDatabases.value = names.filter((database) => selected.has(database));
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
if (databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) toast(error?.message || String(error), 5000);
|
if (databaseLoadIsCurrent(generation, dialog, targetDraft, connectionId)) {
|
||||||
|
databaseLoadError.value = t("databaseBackup.databaseListLoadFailed", { name: connectionName(connectionId) });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (generation === databaseLoadGeneration) loadingDatabases.value = false;
|
if (generation === databaseLoadGeneration) loadingDatabases.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetDatabaseScope(targetDraft: DatabaseBackupExecutionConfig) {
|
||||||
|
databaseOptions.value = [];
|
||||||
|
databaseLoadError.value = "";
|
||||||
|
allDatabases.value = true;
|
||||||
|
selectedDatabases.value = [];
|
||||||
|
targetDraft.tableFilterMode = "all";
|
||||||
|
targetDraft.tablePatterns = [];
|
||||||
|
tablePatternsInput.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function openCreateSchedule() {
|
async function openCreateSchedule() {
|
||||||
oneShotDialogOpen.value = false;
|
oneShotDialogOpen.value = false;
|
||||||
editingScheduleId.value = "";
|
editingScheduleId.value = "";
|
||||||
const nextDraft = newScheduleDraft();
|
const nextDraft = newScheduleDraft();
|
||||||
draft.value = nextDraft;
|
draft.value = nextDraft;
|
||||||
allDatabases.value = true;
|
resetDatabaseScope(nextDraft);
|
||||||
selectedDatabases.value = [];
|
|
||||||
tablePatternsInput.value = "";
|
|
||||||
scheduleDialogOpen.value = true;
|
scheduleDialogOpen.value = true;
|
||||||
await loadDatabases("schedule", draft.value, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEditSchedule(schedule: DatabaseBackupSchedule) {
|
async function openEditSchedule(schedule: DatabaseBackupSchedule) {
|
||||||
@@ -269,15 +311,40 @@ async function openEditSchedule(schedule: DatabaseBackupSchedule) {
|
|||||||
allDatabases.value = schedule.databases.length === 0;
|
allDatabases.value = schedule.databases.length === 0;
|
||||||
selectedDatabases.value = [...schedule.databases];
|
selectedDatabases.value = [...schedule.databases];
|
||||||
tablePatternsInput.value = schedule.tablePatterns.join(", ");
|
tablePatternsInput.value = schedule.tablePatterns.join(", ");
|
||||||
|
databaseLoadError.value = "";
|
||||||
scheduleDialogOpen.value = true;
|
scheduleDialogOpen.value = true;
|
||||||
await loadDatabases("schedule", draft.value, true);
|
if (!allDatabases.value) await loadDatabases("schedule", draft.value, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeConnection(connectionId: string) {
|
async function changeConnection(connectionId: string) {
|
||||||
const dialog: BackupDialogKind = oneShotDialogOpen.value ? "one-shot" : "schedule";
|
const dialog: BackupDialogKind = oneShotDialogOpen.value ? "one-shot" : "schedule";
|
||||||
const targetDraft = dialog === "one-shot" ? oneShotDraft.value : draft.value;
|
const targetDraft = dialog === "one-shot" ? oneShotDraft.value : draft.value;
|
||||||
|
const wasSelectingSpecificDatabases = !allDatabases.value;
|
||||||
targetDraft.connectionId = connectionId;
|
targetDraft.connectionId = connectionId;
|
||||||
await loadDatabases(dialog, targetDraft, false);
|
databaseOptions.value = [];
|
||||||
|
databaseLoadError.value = "";
|
||||||
|
selectedDatabases.value = [];
|
||||||
|
targetDraft.tableFilterMode = "all";
|
||||||
|
targetDraft.tablePatterns = [];
|
||||||
|
tablePatternsInput.value = "";
|
||||||
|
if (!wasSelectingSpecificDatabases) {
|
||||||
|
allDatabases.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allDatabases.value = false;
|
||||||
|
await loadDatabases(dialog, targetDraft, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAllDatabases(value: boolean) {
|
||||||
|
allDatabases.value = value;
|
||||||
|
databaseLoadError.value = "";
|
||||||
|
if (value) {
|
||||||
|
selectedDatabases.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dialog: BackupDialogKind = oneShotDialogOpen.value ? "one-shot" : "schedule";
|
||||||
|
const targetDraft = dialog === "one-shot" ? oneShotDraft.value : draft.value;
|
||||||
|
await loadDatabases(dialog, targetDraft, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDatabase(database: string) {
|
function toggleDatabase(database: string) {
|
||||||
@@ -316,11 +383,8 @@ async function openOneShotBackup() {
|
|||||||
scheduleDialogOpen.value = false;
|
scheduleDialogOpen.value = false;
|
||||||
const nextDraft = newBackupConfig();
|
const nextDraft = newBackupConfig();
|
||||||
oneShotDraft.value = nextDraft;
|
oneShotDraft.value = nextDraft;
|
||||||
allDatabases.value = true;
|
resetDatabaseScope(nextDraft);
|
||||||
selectedDatabases.value = [];
|
|
||||||
tablePatternsInput.value = "";
|
|
||||||
oneShotDialogOpen.value = true;
|
oneShotDialogOpen.value = true;
|
||||||
await loadDatabases("one-shot", oneShotDraft.value, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startOneShotBackup() {
|
async function startOneShotBackup() {
|
||||||
@@ -403,11 +467,30 @@ function confirmDeleteSchedule() {
|
|||||||
pendingDeleteSchedule.value = null;
|
pendingDeleteSchedule.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestDeleteRun(run: DatabaseBackupRun) {
|
function toggleRunSelected(runId: string, selected: boolean) {
|
||||||
pendingDeleteRun.value = run;
|
if (selected) selectedRunIds.add(runId);
|
||||||
|
else selectedRunIds.delete(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllFilteredRuns(selected: boolean) {
|
||||||
|
for (const run of selectableFilteredRuns.value) toggleRunSelected(run.id, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDeleteRuns(runIds: readonly string[]) {
|
||||||
|
const ids = runIds.filter((runId) => !activeRunIds.has(runId));
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
pendingDeleteRunIds.value = ids;
|
||||||
deleteRunDialogOpen.value = true;
|
deleteRunDialogOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestDeleteRun(run: DatabaseBackupRun) {
|
||||||
|
requestDeleteRuns([run.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDeleteSelectedRuns() {
|
||||||
|
requestDeleteRuns(selectedRuns.value.map((run) => run.id));
|
||||||
|
}
|
||||||
|
|
||||||
function requestRenameRun(run: DatabaseBackupRun) {
|
function requestRenameRun(run: DatabaseBackupRun) {
|
||||||
if (activeRunIds.has(run.id)) return;
|
if (activeRunIds.has(run.id)) return;
|
||||||
pendingRenameRun.value = run;
|
pendingRenameRun.value = run;
|
||||||
@@ -424,14 +507,18 @@ function confirmRenameRun() {
|
|||||||
toast(t("databaseBackup.backupRenamed"), 2500);
|
toast(t("databaseBackup.backupRenamed"), 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteRun() {
|
async function confirmDeleteRuns() {
|
||||||
const run = pendingDeleteRun.value;
|
const runIds = pendingDeleteRunIds.value;
|
||||||
if (!run) return;
|
if (runIds.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await deleteRun(run.id);
|
await deleteRuns(runIds);
|
||||||
|
for (const runId of runIds) {
|
||||||
|
selectedRunIds.delete(runId);
|
||||||
|
expandedRunIds.delete(runId);
|
||||||
|
}
|
||||||
deleteRunDialogOpen.value = false;
|
deleteRunDialogOpen.value = false;
|
||||||
pendingDeleteRun.value = null;
|
pendingDeleteRunIds.value = [];
|
||||||
toast(t("databaseBackup.backupDeleted"), 2500);
|
toast(runIds.length === 1 ? t("databaseBackup.backupDeleted") : t("databaseBackup.backupsDeleted", { count: runIds.length }), 2500);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.message || String(error), 5000);
|
toast(error?.message || String(error), 5000);
|
||||||
}
|
}
|
||||||
@@ -589,10 +676,30 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectableFilteredRuns.length > 0" class="flex flex-wrap items-center gap-3 rounded-md border border-border/70 bg-muted/20 px-3 py-2">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input data-backup-history-select-all type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="allSelectableRunsSelected" @change="toggleAllFilteredRuns(($event.target as HTMLInputElement).checked)" />
|
||||||
|
{{ t("databaseBackup.selectAllFiltered") }}
|
||||||
|
</label>
|
||||||
|
<span v-if="selectedRuns.length > 0" class="text-sm text-muted-foreground">{{ t("databaseBackup.selectedRuns", { count: selectedRuns.length }) }}</span>
|
||||||
|
<Button v-if="selectedRuns.length > 0" data-backup-delete-selected variant="destructive" size="sm" @click="requestDeleteSelectedRuns">
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
{{ t("databaseBackup.deleteSelected") }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div class="overflow-hidden rounded-md border border-border/70">
|
<div class="overflow-hidden rounded-md border border-border/70">
|
||||||
<div v-if="filteredRuns.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">{{ historyConnectionId || historyBackupMethod !== "all" || historyStatus !== "all" ? t("databaseBackup.noFilteredHistory") : t("databaseBackup.noHistory") }}</div>
|
<div v-if="filteredRuns.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">{{ historyConnectionId || historyBackupMethod !== "all" || historyStatus !== "all" ? t("databaseBackup.noFilteredHistory") : t("databaseBackup.noHistory") }}</div>
|
||||||
<template v-for="run in filteredRuns" :key="run.id">
|
<template v-for="run in filteredRuns" :key="run.id">
|
||||||
<div class="grid gap-2 border-b border-border/70 px-3 py-3 last:border-b-0 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
|
<div class="grid gap-2 border-b border-border/70 px-3 py-3 last:border-b-0 md:grid-cols-[auto_auto_minmax(0,1fr)_auto] md:items-center">
|
||||||
|
<input
|
||||||
|
data-backup-history-select
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-border accent-primary"
|
||||||
|
:checked="selectedRunIds.has(run.id)"
|
||||||
|
:disabled="activeRunIds.has(run.id)"
|
||||||
|
:aria-label="t('databaseBackup.selectRun', { name: run.displayName || run.scheduleName })"
|
||||||
|
@change="toggleRunSelected(run.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="run.files.length === 0" :title="t('databaseBackup.showFiles')" @click="toggleRunExpanded(run.id)">
|
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="run.files.length === 0" :title="t('databaseBackup.showFiles')" @click="toggleRunExpanded(run.id)">
|
||||||
<ChevronDown v-if="expandedRunIds.has(run.id)" class="h-4 w-4" />
|
<ChevronDown v-if="expandedRunIds.has(run.id)" class="h-4 w-4" />
|
||||||
<ChevronRight v-else class="h-4 w-4" />
|
<ChevronRight v-else class="h-4 w-4" />
|
||||||
@@ -683,13 +790,25 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
|||||||
:database-options="databaseOptions"
|
:database-options="databaseOptions"
|
||||||
:table-patterns-input="tablePatternsInput"
|
:table-patterns-input="tablePatternsInput"
|
||||||
:loading-databases="loadingDatabases"
|
:loading-databases="loadingDatabases"
|
||||||
|
:database-load-error="databaseLoadError"
|
||||||
@change-connection="changeConnection"
|
@change-connection="changeConnection"
|
||||||
@choose-destination="chooseDestination"
|
@choose-destination="chooseDestination"
|
||||||
@toggle-database="toggleDatabase"
|
@toggle-database="toggleDatabase"
|
||||||
@update:all-databases="(value: boolean) => (allDatabases = value)"
|
@update:all-databases="setAllDatabases"
|
||||||
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
|
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t("databaseBackup.runDirectoryPattern") }}</Label>
|
||||||
|
<Input v-model="draft.runDirectoryPattern" data-backup-run-directory-pattern :aria-invalid="!databaseBackupRunDirectoryPatternIsValid(draft.runDirectoryPattern || '')" />
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t("databaseBackup.runDirectoryPatternHint") }}</p>
|
||||||
|
<div v-if="runDirectoryPreview" data-backup-run-directory-preview class="space-y-1 rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||||
|
<div class="text-xs font-medium text-muted-foreground">{{ t("databaseBackup.runDirectoryPreview") }}</div>
|
||||||
|
<div class="break-all font-mono text-xs text-foreground">{{ runDirectoryPreview }}</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!databaseBackupRunDirectoryPatternIsValid(draft.runDirectoryPattern || '')" class="text-xs text-destructive">{{ t("databaseBackup.runDirectoryPatternInvalid") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>{{ t("databaseBackup.frequency") }}</Label>
|
<Label>{{ t("databaseBackup.frequency") }}</Label>
|
||||||
@@ -760,10 +879,11 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
|||||||
:database-options="databaseOptions"
|
:database-options="databaseOptions"
|
||||||
:table-patterns-input="tablePatternsInput"
|
:table-patterns-input="tablePatternsInput"
|
||||||
:loading-databases="loadingDatabases"
|
:loading-databases="loadingDatabases"
|
||||||
|
:database-load-error="databaseLoadError"
|
||||||
@change-connection="changeConnection"
|
@change-connection="changeConnection"
|
||||||
@choose-destination="chooseDestination"
|
@choose-destination="chooseDestination"
|
||||||
@toggle-database="toggleDatabase"
|
@toggle-database="toggleDatabase"
|
||||||
@update:all-databases="(value: boolean) => (allDatabases = value)"
|
@update:all-databases="setAllDatabases"
|
||||||
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
|
@update:table-patterns-input="(value: string) => (tablePatternsInput = value)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -799,10 +919,16 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
|
|||||||
<DialogHeader
|
<DialogHeader
|
||||||
><DialogTitle>{{ t("databaseBackup.deleteBackup") }}</DialogTitle></DialogHeader
|
><DialogTitle>{{ t("databaseBackup.deleteBackup") }}</DialogTitle></DialogHeader
|
||||||
>
|
>
|
||||||
<p class="text-sm text-muted-foreground">{{ t("databaseBackup.deleteBackupConfirm", { count: pendingDeleteRun?.files.length || 0 }) }}</p>
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{
|
||||||
|
pendingDeleteRunIds.length === 1
|
||||||
|
? t("databaseBackup.deleteBackupConfirm", { count: runs.find((run) => run.id === pendingDeleteRunIds[0])?.files.length || 0 })
|
||||||
|
: t("databaseBackup.deleteBackupsConfirm", { count: pendingDeleteRunIds.length, files: pendingDeleteRunIds.reduce((total, runId) => total + (runs.find((run) => run.id === runId)?.files.length || 0), 0) })
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="deleteRunDialogOpen = false">{{ t("common.cancel") }}</Button>
|
<Button variant="outline" @click="deleteRunDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||||
<Button variant="destructive" @click="confirmDeleteRun">{{ t("databaseBackup.delete") }}</Button>
|
<Button variant="destructive" @click="confirmDeleteRuns">{{ t("databaseBackup.delete") }}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
+120
-9
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
cancellingRunIds: new Set<string>(),
|
cancellingRunIds: new Set<string>(),
|
||||||
activeRuns: [] as DatabaseBackupRun[],
|
activeRuns: [] as DatabaseBackupRun[],
|
||||||
ensureConnected: vi.fn(async () => {}),
|
ensureConnected: vi.fn(async () => {}),
|
||||||
|
recordConnectionLostError: vi.fn(() => false),
|
||||||
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
|
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
|
||||||
databaseExportDestinationNeedsConfirmation: vi.fn(async (_directory: string) => false),
|
databaseExportDestinationNeedsConfirmation: vi.fn(async (_directory: string) => false),
|
||||||
recordDatabaseExportDestination: vi.fn(async (_directory: string) => {}),
|
recordDatabaseExportDestination: vi.fn(async (_directory: string) => {}),
|
||||||
@@ -23,6 +24,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
setScheduleEnabled: vi.fn(),
|
setScheduleEnabled: vi.fn(),
|
||||||
deleteSchedule: vi.fn(),
|
deleteSchedule: vi.fn(),
|
||||||
deleteRun: vi.fn(),
|
deleteRun: vi.fn(),
|
||||||
|
deleteRuns: vi.fn(),
|
||||||
runSchedule: vi.fn(),
|
runSchedule: vi.fn(),
|
||||||
runOneShot: vi.fn(),
|
runOneShot: vi.fn(),
|
||||||
cancelRun: vi.fn(),
|
cancelRun: vi.fn(),
|
||||||
@@ -32,6 +34,7 @@ vi.mock("@/stores/connectionStore", () => ({
|
|||||||
useConnectionStore: () => ({
|
useConnectionStore: () => ({
|
||||||
connections: mocks.connections,
|
connections: mocks.connections,
|
||||||
ensureConnected: mocks.ensureConnected,
|
ensureConnected: mocks.ensureConnected,
|
||||||
|
recordConnectionLostError: mocks.recordConnectionLostError,
|
||||||
getConfig: (connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId),
|
getConfig: (connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId),
|
||||||
sqlFileSource: null,
|
sqlFileSource: null,
|
||||||
}),
|
}),
|
||||||
@@ -49,6 +52,7 @@ vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
|
|||||||
setScheduleEnabled: mocks.setScheduleEnabled,
|
setScheduleEnabled: mocks.setScheduleEnabled,
|
||||||
deleteSchedule: mocks.deleteSchedule,
|
deleteSchedule: mocks.deleteSchedule,
|
||||||
deleteRun: mocks.deleteRun,
|
deleteRun: mocks.deleteRun,
|
||||||
|
deleteRuns: mocks.deleteRuns,
|
||||||
runSchedule: mocks.runSchedule,
|
runSchedule: mocks.runSchedule,
|
||||||
runOneShot: mocks.runOneShot,
|
runOneShot: mocks.runOneShot,
|
||||||
cancelRun: mocks.cancelRun,
|
cancelRun: mocks.cancelRun,
|
||||||
@@ -250,6 +254,8 @@ afterEach(() => {
|
|||||||
mocks.activeRunIds.clear();
|
mocks.activeRunIds.clear();
|
||||||
mocks.cancellingRunIds.clear();
|
mocks.cancellingRunIds.clear();
|
||||||
mocks.ensureConnected.mockClear();
|
mocks.ensureConnected.mockClear();
|
||||||
|
mocks.recordConnectionLostError.mockReset();
|
||||||
|
mocks.recordConnectionLostError.mockReturnValue(false);
|
||||||
mocks.listDatabases.mockReset();
|
mocks.listDatabases.mockReset();
|
||||||
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
|
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
|
||||||
mocks.databaseExportDestinationNeedsConfirmation.mockReset();
|
mocks.databaseExportDestinationNeedsConfirmation.mockReset();
|
||||||
@@ -260,6 +266,8 @@ afterEach(() => {
|
|||||||
mocks.openDirectory.mockResolvedValue("/backups");
|
mocks.openDirectory.mockResolvedValue("/backups");
|
||||||
mocks.toast.mockClear();
|
mocks.toast.mockClear();
|
||||||
mocks.saveSchedule.mockClear();
|
mocks.saveSchedule.mockClear();
|
||||||
|
mocks.deleteRun.mockClear();
|
||||||
|
mocks.deleteRuns.mockClear();
|
||||||
mocks.cancelRun.mockClear();
|
mocks.cancelRun.mockClear();
|
||||||
mocks.runSchedule.mockReset();
|
mocks.runSchedule.mockReset();
|
||||||
mocks.runSchedule.mockResolvedValue(null);
|
mocks.runSchedule.mockResolvedValue(null);
|
||||||
@@ -268,6 +276,83 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
||||||
|
it("reconnects once when loading databases finds a closed connection", async () => {
|
||||||
|
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||||
|
mocks.listDatabases.mockRejectedValueOnce(new Error("MySQL connection failed: Input/output error: connection closed")).mockResolvedValueOnce([{ name: "app" }]);
|
||||||
|
mocks.recordConnectionLostError.mockReturnValue(true);
|
||||||
|
await mountSettings();
|
||||||
|
|
||||||
|
addScheduleButton().click();
|
||||||
|
await flush();
|
||||||
|
await showDatabaseOptions();
|
||||||
|
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
expect(mocks.recordConnectionLostError).toHaveBeenCalledWith("mysql-1", expect.any(Error));
|
||||||
|
expect(mocks.ensureConnected).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows connection guidance inline when explicitly loading databases fails", async () => {
|
||||||
|
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||||
|
mocks.listDatabases.mockRejectedValueOnce(new Error("MySQL connection failed: Input/output error: connection closed"));
|
||||||
|
await mountSettings();
|
||||||
|
|
||||||
|
addScheduleButton().click();
|
||||||
|
await flush();
|
||||||
|
await showDatabaseOptions();
|
||||||
|
await vi.waitFor(() => expect(currentDialog().querySelector("[data-backup-database-load-error]")?.textContent).toContain("Local MySQL"));
|
||||||
|
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects filtered backup history and deletes the selected runs together", async () => {
|
||||||
|
mocks.runs.push(
|
||||||
|
{
|
||||||
|
id: "run-batch-one",
|
||||||
|
scheduleName: "First backup",
|
||||||
|
connectionId: "mysql-1",
|
||||||
|
connectionName: "Local MySQL",
|
||||||
|
trigger: "scheduled",
|
||||||
|
source: "scheduled",
|
||||||
|
status: "success",
|
||||||
|
startedAt: "2026-09-08T02:00:00.000Z",
|
||||||
|
files: [{ displayName: "first.sql", filePath: "/backups/first.sql" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "run-batch-two",
|
||||||
|
scheduleName: "Second backup",
|
||||||
|
connectionId: "mysql-1",
|
||||||
|
connectionName: "Local MySQL",
|
||||||
|
trigger: "scheduled",
|
||||||
|
source: "scheduled",
|
||||||
|
status: "success",
|
||||||
|
startedAt: "2026-09-08T01:00:00.000Z",
|
||||||
|
files: [{ displayName: "second.sql", filePath: "/backups/second.sql" }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await mountSettings();
|
||||||
|
|
||||||
|
const selectAll = document.body.querySelector<HTMLInputElement>("[data-backup-history-select-all]");
|
||||||
|
if (!selectAll) throw new Error("Select-all checkbox not found");
|
||||||
|
selectAll.checked = true;
|
||||||
|
selectAll.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(document.body.querySelectorAll<HTMLInputElement>("[data-backup-history-select]")).toHaveLength(2);
|
||||||
|
const deleteSelected = document.body.querySelector<HTMLButtonElement>("[data-backup-delete-selected]");
|
||||||
|
if (!deleteSelected) throw new Error("Delete selected button not found");
|
||||||
|
deleteSelected.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(currentDialog().textContent).toContain(String(i18n.global.t("databaseBackup.deleteBackupsConfirm", { count: 2, files: 2 })));
|
||||||
|
const confirm = Array.from(currentDialog().querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === String(i18n.global.t("databaseBackup.delete")));
|
||||||
|
if (!confirm) throw new Error("Delete confirmation button not found");
|
||||||
|
confirm.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mocks.deleteRuns).toHaveBeenCalledWith(["run-batch-one", "run-batch-two"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("filters backup history by a searched connection", async () => {
|
it("filters backup history by a searched connection", async () => {
|
||||||
mocks.runs.push(
|
mocks.runs.push(
|
||||||
{
|
{
|
||||||
@@ -335,7 +420,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
|||||||
expect(document.body.textContent).not.toContain("Manual success backup");
|
expect(document.body.textContent).not.toContain("Manual success backup");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the create schedule dialog for supported SQL connections", async () => {
|
it("opens the create schedule dialog without probing its default connection", async () => {
|
||||||
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||||
await mountSettings();
|
await mountSettings();
|
||||||
|
|
||||||
@@ -348,8 +433,31 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
|||||||
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
|
const dialog = document.body.querySelector('[data-slot="dialog-content"]');
|
||||||
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.addSchedule")));
|
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.addSchedule")));
|
||||||
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.scheduleName")));
|
expect(dialog?.textContent).toContain(String(i18n.global.t("databaseBackup.scheduleName")));
|
||||||
expect(mocks.ensureConnected).toHaveBeenCalledWith("mysql-1");
|
expect(mocks.ensureConnected).not.toHaveBeenCalled();
|
||||||
expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-1");
|
expect(mocks.listDatabases).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews the rendered run directory and updates it when the template changes", async () => {
|
||||||
|
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||||
|
await mountSettings();
|
||||||
|
|
||||||
|
addScheduleButton().click();
|
||||||
|
await flush();
|
||||||
|
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const preview = currentDialog().querySelector<HTMLElement>("[data-backup-run-directory-preview]");
|
||||||
|
expect(preview?.textContent).toContain("/backups/dbx-backup__");
|
||||||
|
expect(preview?.textContent).toContain("preview0");
|
||||||
|
|
||||||
|
const input = currentDialog().querySelector<HTMLInputElement>("[data-backup-run-directory-pattern]");
|
||||||
|
if (!input) throw new Error("Run directory template input not found");
|
||||||
|
input.value = "archive/{date}/{timestamp}";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(currentDialog().textContent).toContain("{timestamp}");
|
||||||
|
expect(currentDialog().querySelector<HTMLElement>("[data-backup-run-directory-preview]")?.textContent).toContain("/backups/archive/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens an independent one-shot dialog without schedule fields", async () => {
|
it("opens an independent one-shot dialog without schedule fields", async () => {
|
||||||
@@ -400,6 +508,8 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
|||||||
await mountSettings();
|
await mountSettings();
|
||||||
|
|
||||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||||
|
await flush();
|
||||||
|
await showDatabaseOptions();
|
||||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-a"));
|
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-a"));
|
||||||
await selectBackupConnection("MySQL B");
|
await selectBackupConnection("MySQL B");
|
||||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-b"));
|
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-b"));
|
||||||
@@ -423,6 +533,8 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
|||||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||||
await flush();
|
await flush();
|
||||||
await showDatabaseOptions();
|
await showDatabaseOptions();
|
||||||
|
await flush();
|
||||||
|
await showDatabaseOptions();
|
||||||
|
|
||||||
const search = currentDialog().querySelector<HTMLInputElement>("[data-backup-database-search]");
|
const search = currentDialog().querySelector<HTMLInputElement>("[data-backup-database-search]");
|
||||||
if (!search) throw new Error("Backup database search not found");
|
if (!search) throw new Error("Backup database search not found");
|
||||||
@@ -438,27 +550,26 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
|
|||||||
it("does not let a closed one-shot request clear a schedule draft", async () => {
|
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" });
|
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
|
||||||
const oneShotLoad = deferred<Array<{ name: string }>>();
|
const oneShotLoad = deferred<Array<{ name: string }>>();
|
||||||
const scheduleLoad = deferred<Array<{ name: string }>>();
|
mocks.listDatabases.mockImplementationOnce(() => oneShotLoad.promise);
|
||||||
mocks.listDatabases.mockImplementationOnce(() => oneShotLoad.promise).mockImplementationOnce(() => scheduleLoad.promise);
|
|
||||||
await mountSettings();
|
await mountSettings();
|
||||||
|
|
||||||
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
|
||||||
|
await flush();
|
||||||
|
await showDatabaseOptions();
|
||||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(1));
|
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")));
|
const closeOneShot = Array.from(currentDialog().querySelectorAll("button")).find((item) => item.textContent?.trim() === String(i18n.global.t("common.cancel")));
|
||||||
closeOneShot?.click();
|
closeOneShot?.click();
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
addScheduleButton().click();
|
addScheduleButton().click();
|
||||||
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledTimes(2));
|
await flush();
|
||||||
await setTablePattern("schedule_*");
|
await setTablePattern("schedule_*");
|
||||||
|
|
||||||
oneShotLoad.resolve([{ name: "one_shot_database" }]);
|
oneShotLoad.resolve([{ name: "one_shot_database" }]);
|
||||||
await flush();
|
await flush();
|
||||||
expect(currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`)?.value).toBe("schedule_*");
|
expect(currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`)?.value).toBe("schedule_*");
|
||||||
|
|
||||||
scheduleLoad.resolve([{ name: "schedule_database" }]);
|
expect(mocks.listDatabases).toHaveBeenCalledTimes(1);
|
||||||
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 () => {
|
it("keeps closing the one-shot dialog separate from cancelling the active run", async () => {
|
||||||
|
|||||||
@@ -5313,6 +5313,9 @@ interface SidebarMenuFactoryContext {
|
|||||||
truncateMenuAction: (singleAction: () => void) => () => void;
|
truncateMenuAction: (singleAction: () => void) => () => void;
|
||||||
emptyMenuLabel: (singleLabel: string) => string;
|
emptyMenuLabel: (singleLabel: string) => string;
|
||||||
emptyMenuAction: (singleAction: () => void) => () => void;
|
emptyMenuAction: (singleAction: () => void) => () => void;
|
||||||
|
batchAutoIncrementCount: number;
|
||||||
|
autoIncrementMenuLabel: (singleLabel: string) => string;
|
||||||
|
autoIncrementMenuAction: (singleAction: () => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SidebarMenuFactory = (context: SidebarMenuFactoryContext) => boolean;
|
type SidebarMenuFactory = (context: SidebarMenuFactoryContext) => boolean;
|
||||||
@@ -5888,7 +5891,7 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||||
const { node, items, deleteMenuLabel, deleteMenuAction, truncateMenuLabel, truncateMenuAction, emptyMenuLabel, emptyMenuAction } = context;
|
const { node, items, deleteMenuLabel, deleteMenuAction, truncateMenuLabel, truncateMenuAction, emptyMenuLabel, emptyMenuAction, batchAutoIncrementCount, autoIncrementMenuLabel, autoIncrementMenuAction } = context;
|
||||||
// 6. Table / View / Materialized View
|
// 6. Table / View / Materialized View
|
||||||
if (node.type === "table" || node.type === "view" || node.type === "materialized_view") {
|
if (node.type === "table" || node.type === "view" || node.type === "materialized_view") {
|
||||||
if (currentDatabaseType() === "victoriametrics" && node.type === "table") {
|
if (currentDatabaseType() === "victoriametrics" && node.type === "table") {
|
||||||
@@ -6339,6 +6342,9 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||||||
truncateMenuAction,
|
truncateMenuAction,
|
||||||
emptyMenuLabel,
|
emptyMenuLabel,
|
||||||
emptyMenuAction,
|
emptyMenuAction,
|
||||||
|
batchAutoIncrementCount,
|
||||||
|
autoIncrementMenuLabel,
|
||||||
|
autoIncrementMenuAction,
|
||||||
};
|
};
|
||||||
for (const factory of sidebarMenuFactories) {
|
for (const factory of sidebarMenuFactories) {
|
||||||
if (factory(factoryContext)) return items;
|
if (factory(factoryContext)) return items;
|
||||||
|
|||||||
@@ -70,6 +70,44 @@ const config: DatabaseBackupExecutionConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("useScheduledDatabaseBackups one-shot execution", () => {
|
describe("useScheduledDatabaseBackups one-shot execution", () => {
|
||||||
|
it("deletes multiple completed runs and all of their backup files", async () => {
|
||||||
|
const backup = useScheduledDatabaseBackups();
|
||||||
|
const firstRun = {
|
||||||
|
id: "delete-many-first",
|
||||||
|
scheduleName: "First backup",
|
||||||
|
connectionId: "mysql-1",
|
||||||
|
connectionName: "Local MySQL",
|
||||||
|
trigger: "scheduled",
|
||||||
|
source: "scheduled",
|
||||||
|
status: "success",
|
||||||
|
startedAt: "2026-09-08T01:00:00.000Z",
|
||||||
|
files: [{ displayName: "first.sql", filePath: "/backups/first.sql" }],
|
||||||
|
} satisfies DatabaseBackupRun;
|
||||||
|
const secondRun = {
|
||||||
|
id: "delete-many-second",
|
||||||
|
scheduleName: "Second backup",
|
||||||
|
connectionId: "mysql-1",
|
||||||
|
connectionName: "Local MySQL",
|
||||||
|
trigger: "scheduled",
|
||||||
|
source: "scheduled",
|
||||||
|
status: "failed",
|
||||||
|
startedAt: "2026-09-08T02:00:00.000Z",
|
||||||
|
files: [{ displayName: "second.sql", filePath: "/backups/second.sql" }],
|
||||||
|
} satisfies DatabaseBackupRun;
|
||||||
|
backup.runs.value.push(firstRun, secondRun);
|
||||||
|
mocks.deleteFiles.mockClear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await backup.deleteRuns([firstRun.id, secondRun.id]);
|
||||||
|
|
||||||
|
expect(mocks.deleteFiles).toHaveBeenCalledWith(["/backups/first.sql", "/backups/second.sql"]);
|
||||||
|
expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: firstRun.id }));
|
||||||
|
expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: secondRun.id }));
|
||||||
|
} finally {
|
||||||
|
backup.runs.value = backup.runs.value.filter((run) => run.id !== firstRun.id && run.id !== secondRun.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("executes one-shot backups through the shared exporter without a schedule", async () => {
|
it("executes one-shot backups through the shared exporter without a schedule", async () => {
|
||||||
mocks.runDatabaseExport.mockImplementationOnce(async (_request: unknown, onProgress: (progress: unknown) => void) => {
|
mocks.runDatabaseExport.mockImplementationOnce(async (_request: unknown, onProgress: (progress: unknown) => void) => {
|
||||||
onProgress({ status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" });
|
onProgress({ status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DatabaseBackupConnectionQueue,
|
DatabaseBackupConnectionQueue,
|
||||||
databaseBackupAggregateExportStatus,
|
databaseBackupAggregateExportStatus,
|
||||||
databaseBackupFilePath,
|
databaseBackupFilePath,
|
||||||
|
databaseBackupRunDirectory,
|
||||||
databaseBackupProgressPercent,
|
databaseBackupProgressPercent,
|
||||||
databaseBackupRunsToPrune,
|
databaseBackupRunsToPrune,
|
||||||
databaseBackupScheduleIsDue,
|
databaseBackupScheduleIsDue,
|
||||||
@@ -127,14 +128,22 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRun(runId: string): Promise<void> {
|
async function deleteRuns(runIds: readonly string[]): Promise<void> {
|
||||||
const run = runs.value.find((item) => item.id === runId);
|
const ids = new Set(runIds);
|
||||||
if (!run || activeRunIds.has(runId)) return;
|
const selectedRuns = runs.value.filter((run) => ids.has(run.id) && !activeRunIds.has(run.id));
|
||||||
if (run.files.length > 0) await api.deleteDatabaseBackupFiles(run.files.map((file) => file.filePath));
|
if (selectedRuns.length === 0) return;
|
||||||
runs.value = runs.value.filter((item) => item.id !== runId);
|
|
||||||
|
const paths = selectedRuns.flatMap((run) => run.files.map((file) => file.filePath));
|
||||||
|
if (paths.length > 0) await api.deleteDatabaseBackupFiles(paths);
|
||||||
|
const deletedIds = new Set(selectedRuns.map((run) => run.id));
|
||||||
|
runs.value = runs.value.filter((run) => !deletedIds.has(run.id));
|
||||||
persistRuns();
|
persistRuns();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteRun(runId: string): Promise<void> {
|
||||||
|
await deleteRuns([runId]);
|
||||||
|
}
|
||||||
|
|
||||||
function renameRun(runId: string, displayName: string): boolean {
|
function renameRun(runId: string, displayName: string): boolean {
|
||||||
const run = runs.value.find((item) => item.id === runId);
|
const run = runs.value.find((item) => item.id === runId);
|
||||||
if (!run || activeRunIds.has(runId)) return false;
|
if (!run || activeRunIds.has(runId)) return false;
|
||||||
@@ -176,12 +185,13 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string }): Promise<FinishedDatabaseBackupRun | null> {
|
async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string; runDirectoryPattern?: string }): Promise<FinishedDatabaseBackupRun | null> {
|
||||||
const backupName = request.displayName;
|
const backupName = request.displayName;
|
||||||
const connection = connectionStore.getConfig(config.connectionId);
|
const connection = connectionStore.getConfig(config.connectionId);
|
||||||
|
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
const runId = generateDatabaseExportId();
|
const runId = generateDatabaseExportId();
|
||||||
|
const outputDirectory = request.runDirectoryPattern ? databaseBackupRunDirectory(config.destinationDirectory, request.runDirectoryPattern, backupName, startedAt, runId) : config.destinationDirectory;
|
||||||
const run: DatabaseBackupRun = {
|
const run: DatabaseBackupRun = {
|
||||||
id: runId,
|
id: runId,
|
||||||
scheduleId: request.scheduleId,
|
scheduleId: request.scheduleId,
|
||||||
@@ -198,7 +208,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||||||
replaceRun(run);
|
replaceRun(run);
|
||||||
activeRunIds.add(runId);
|
activeRunIds.add(runId);
|
||||||
cancellationRequested.delete(runId);
|
cancellationRequested.delete(runId);
|
||||||
addDatabaseExportTask(runId, backupName, config.destinationDirectory, request.source === "scheduled" ? "scheduled" : "manual");
|
addDatabaseExportTask(runId, backupName, outputDirectory, request.source === "scheduled" ? "scheduled" : "manual");
|
||||||
registerTaskCancelHandler(runId, async () => {
|
registerTaskCancelHandler(runId, async () => {
|
||||||
await cancelRun(runId);
|
await cancelRun(runId);
|
||||||
});
|
});
|
||||||
@@ -289,7 +299,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||||||
exportIndex += 1;
|
exportIndex += 1;
|
||||||
const childExportId = `${runId}-${exportIndex}`;
|
const childExportId = `${runId}-${exportIndex}`;
|
||||||
activeExportIds.set(runId, childExportId);
|
activeExportIds.set(runId, childExportId);
|
||||||
const filePath = databaseBackupFilePath(config.destinationDirectory, backupName, item.fileStem, startedAt, runId, config.outputCompression);
|
const filePath = databaseBackupFilePath(outputDirectory, backupName, item.fileStem, startedAt, runId, config.outputCompression);
|
||||||
generatedPaths.push(filePath);
|
generatedPaths.push(filePath);
|
||||||
const terminal = await runDatabaseExportUntilTerminal(
|
const terminal = await runDatabaseExportUntilTerminal(
|
||||||
{
|
{
|
||||||
@@ -447,7 +457,13 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||||||
if (!schedule || activeScheduleIds.has(scheduleId)) return null;
|
if (!schedule || activeScheduleIds.has(scheduleId)) return null;
|
||||||
activeScheduleIds.add(scheduleId);
|
activeScheduleIds.add(scheduleId);
|
||||||
try {
|
try {
|
||||||
const run = await runBackup(toDatabaseBackupExecutionConfig(schedule), { source: "scheduled", trigger, scheduleId, displayName: schedule.name });
|
const run = await runBackup(toDatabaseBackupExecutionConfig(schedule), {
|
||||||
|
source: "scheduled",
|
||||||
|
trigger,
|
||||||
|
scheduleId,
|
||||||
|
displayName: schedule.name,
|
||||||
|
runDirectoryPattern: schedule.runDirectoryPattern,
|
||||||
|
});
|
||||||
if (run) await finalizeScheduledRun(scheduleId, trigger, run);
|
if (run) await finalizeScheduledRun(scheduleId, trigger, run);
|
||||||
return run;
|
return run;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -521,6 +537,7 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
|
|||||||
setScheduleEnabled,
|
setScheduleEnabled,
|
||||||
deleteSchedule,
|
deleteSchedule,
|
||||||
deleteRun,
|
deleteRun,
|
||||||
|
deleteRuns,
|
||||||
renameRun,
|
renameRun,
|
||||||
runSchedule,
|
runSchedule,
|
||||||
runOneShot,
|
runOneShot,
|
||||||
|
|||||||
@@ -7824,6 +7824,10 @@ export default {
|
|||||||
missingConnection: "Missing connection",
|
missingConnection: "Missing connection",
|
||||||
destination: "Destination directory",
|
destination: "Destination directory",
|
||||||
selectDestination: "Select backup directory",
|
selectDestination: "Select backup directory",
|
||||||
|
runDirectoryPattern: "Run directory template",
|
||||||
|
runDirectoryPatternHint: "A relative path under the destination. Use {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, and {'{'}runId{'}'}. The default includes {'{'}runId{'}'} for extra isolation. Example: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "Directory preview",
|
||||||
|
runDirectoryPatternInvalid: "Use a relative path without . or .. segments.",
|
||||||
compression: "Compression",
|
compression: "Compression",
|
||||||
compressionNone: "No compression (.sql)",
|
compressionNone: "No compression (.sql)",
|
||||||
compressionGzip: "Gzip (.sql.gz)",
|
compressionGzip: "Gzip (.sql.gz)",
|
||||||
@@ -7831,6 +7835,7 @@ export default {
|
|||||||
noMatchingConnections: "No matching connections",
|
noMatchingConnections: "No matching connections",
|
||||||
searchDatabases: "Search databases…",
|
searchDatabases: "Search databases…",
|
||||||
noMatchingDatabases: "No matching databases",
|
noMatchingDatabases: "No matching databases",
|
||||||
|
databaseListLoadFailed: "Unable to load databases for {name}. Check this connection or choose another one.",
|
||||||
databases: "Databases",
|
databases: "Databases",
|
||||||
allDatabases: "All databases",
|
allDatabases: "All databases",
|
||||||
databaseCount: "{count} databases",
|
databaseCount: "{count} databases",
|
||||||
@@ -7883,6 +7888,10 @@ export default {
|
|||||||
backupStatus: "Backup status",
|
backupStatus: "Backup status",
|
||||||
allStatuses: "All statuses",
|
allStatuses: "All statuses",
|
||||||
noFilteredHistory: "No matching backup records",
|
noFilteredHistory: "No matching backup records",
|
||||||
|
selectAllFiltered: "Select all filtered",
|
||||||
|
selectRun: "Select {name}",
|
||||||
|
selectedRuns: "{count} selected",
|
||||||
|
deleteSelected: "Delete selected",
|
||||||
showFiles: "Show backup files",
|
showFiles: "Show backup files",
|
||||||
scheduledTrigger: "Scheduled",
|
scheduledTrigger: "Scheduled",
|
||||||
manualTrigger: "Manual",
|
manualTrigger: "Manual",
|
||||||
@@ -7896,7 +7905,9 @@ export default {
|
|||||||
restore: "Restore",
|
restore: "Restore",
|
||||||
deleteBackup: "Delete backup",
|
deleteBackup: "Delete backup",
|
||||||
deleteBackupConfirm: "Delete this backup and its {count} SQL files? This cannot be undone.",
|
deleteBackupConfirm: "Delete this backup and its {count} SQL files? This cannot be undone.",
|
||||||
|
deleteBackupsConfirm: "Delete {count} backups and their {files} SQL files? This cannot be undone.",
|
||||||
backupDeleted: "Backup deleted",
|
backupDeleted: "Backup deleted",
|
||||||
|
backupsDeleted: "Deleted {count} backups",
|
||||||
runSuccess: "Backup completed with {count} files",
|
runSuccess: "Backup completed with {count} files",
|
||||||
runCancelled: "Backup cancelled",
|
runCancelled: "Backup cancelled",
|
||||||
runFailed: "Backup failed: {error}",
|
runFailed: "Backup failed: {error}",
|
||||||
|
|||||||
@@ -8844,6 +8844,11 @@ export default withEnglishFallback({
|
|||||||
missingConnection: "La conexión ya no existe",
|
missingConnection: "La conexión ya no existe",
|
||||||
destination: "Directorio de copia de seguridad",
|
destination: "Directorio de copia de seguridad",
|
||||||
selectDestination: "Seleccionar directorio de copia de seguridad",
|
selectDestination: "Seleccionar directorio de copia de seguridad",
|
||||||
|
runDirectoryPattern: "Plantilla de directorio de ejecución",
|
||||||
|
runDirectoryPatternHint:
|
||||||
|
"Ruta relativa dentro del directorio de destino. Use {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'} y {'{'}runId{'}'}. La plantilla predeterminada incluye {'{'}runId{'}'} para un mejor aislamiento. Ejemplo: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "Vista previa del directorio",
|
||||||
|
runDirectoryPatternInvalid: "Use una ruta relativa sin segmentos . ni ..",
|
||||||
compression: "Compresión",
|
compression: "Compresión",
|
||||||
compressionNone: "Sin compresión (.sql)",
|
compressionNone: "Sin compresión (.sql)",
|
||||||
compressionGzip: "Compresión Gzip (.sql.gz)",
|
compressionGzip: "Compresión Gzip (.sql.gz)",
|
||||||
@@ -8851,6 +8856,7 @@ export default withEnglishFallback({
|
|||||||
noMatchingConnections: "No hay conexiones coincidentes",
|
noMatchingConnections: "No hay conexiones coincidentes",
|
||||||
searchDatabases: "Buscar bases de datos…",
|
searchDatabases: "Buscar bases de datos…",
|
||||||
noMatchingDatabases: "No hay bases de datos coincidentes",
|
noMatchingDatabases: "No hay bases de datos coincidentes",
|
||||||
|
databaseListLoadFailed: "No se pudieron cargar las bases de datos de {name}. Compruebe esta conexión o elija otra.",
|
||||||
databases: "Bases de datos",
|
databases: "Bases de datos",
|
||||||
allDatabases: "Todas las bases de datos",
|
allDatabases: "Todas las bases de datos",
|
||||||
databaseCount: "{count} bases de datos",
|
databaseCount: "{count} bases de datos",
|
||||||
@@ -8903,6 +8909,10 @@ export default withEnglishFallback({
|
|||||||
backupStatus: "Estado de la copia",
|
backupStatus: "Estado de la copia",
|
||||||
allStatuses: "Todos los estados",
|
allStatuses: "Todos los estados",
|
||||||
noFilteredHistory: "No hay registros de copia de seguridad coincidentes",
|
noFilteredHistory: "No hay registros de copia de seguridad coincidentes",
|
||||||
|
selectAllFiltered: "Seleccionar todos los resultados filtrados",
|
||||||
|
selectRun: "Seleccionar {name}",
|
||||||
|
selectedRuns: "{count} seleccionados",
|
||||||
|
deleteSelected: "Eliminar seleccionados",
|
||||||
showFiles: "Mostrar archivos de copia",
|
showFiles: "Mostrar archivos de copia",
|
||||||
scheduledTrigger: "Ejecución programada",
|
scheduledTrigger: "Ejecución programada",
|
||||||
manualTrigger: "Ejecución manual",
|
manualTrigger: "Ejecución manual",
|
||||||
@@ -8915,7 +8925,9 @@ export default withEnglishFallback({
|
|||||||
restore: "Restaurar",
|
restore: "Restaurar",
|
||||||
deleteBackup: "Eliminar copia de seguridad",
|
deleteBackup: "Eliminar copia de seguridad",
|
||||||
deleteBackupConfirm: "¿Está seguro de que desea eliminar esta copia de seguridad y sus {count} archivos SQL? Esta acción no se puede deshacer.",
|
deleteBackupConfirm: "¿Está seguro de que desea eliminar esta copia de seguridad y sus {count} archivos SQL? Esta acción no se puede deshacer.",
|
||||||
|
deleteBackupsConfirm: "¿Está seguro de que desea eliminar {count} copias de seguridad y sus {files} archivos SQL? Esta acción no se puede deshacer.",
|
||||||
backupDeleted: "Copia de seguridad eliminada",
|
backupDeleted: "Copia de seguridad eliminada",
|
||||||
|
backupsDeleted: "Se eliminaron {count} copias de seguridad",
|
||||||
runSuccess: "Copia de seguridad completada, se generaron {count} archivos",
|
runSuccess: "Copia de seguridad completada, se generaron {count} archivos",
|
||||||
runCancelled: "Copia de seguridad cancelada",
|
runCancelled: "Copia de seguridad cancelada",
|
||||||
runFailed: "Copia de seguridad fallida: {error}",
|
runFailed: "Copia de seguridad fallida: {error}",
|
||||||
|
|||||||
@@ -8845,6 +8845,11 @@ export default withEnglishFallback({
|
|||||||
missingConnection: "Connessione non più esistente",
|
missingConnection: "Connessione non più esistente",
|
||||||
destination: "Directory di backup",
|
destination: "Directory di backup",
|
||||||
selectDestination: "Seleziona directory di backup",
|
selectDestination: "Seleziona directory di backup",
|
||||||
|
runDirectoryPattern: "Modello directory di esecuzione",
|
||||||
|
runDirectoryPatternHint:
|
||||||
|
"Percorso relativo nella directory di destinazione. Usa {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'} e {'{'}runId{'}'}. Il modello predefinito include {'{'}runId{'}'} per un maggiore isolamento. Esempio: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "Anteprima directory",
|
||||||
|
runDirectoryPatternInvalid: "Usa un percorso relativo senza segmenti . o ..",
|
||||||
compression: "Compressione",
|
compression: "Compressione",
|
||||||
compressionNone: "Nessuna compressione (.sql)",
|
compressionNone: "Nessuna compressione (.sql)",
|
||||||
compressionGzip: "Compressione Gzip (.sql.gz)",
|
compressionGzip: "Compressione Gzip (.sql.gz)",
|
||||||
@@ -8852,6 +8857,7 @@ export default withEnglishFallback({
|
|||||||
noMatchingConnections: "Nessuna connessione corrispondente",
|
noMatchingConnections: "Nessuna connessione corrispondente",
|
||||||
searchDatabases: "Cerca database…",
|
searchDatabases: "Cerca database…",
|
||||||
noMatchingDatabases: "Nessun database corrispondente",
|
noMatchingDatabases: "Nessun database corrispondente",
|
||||||
|
databaseListLoadFailed: "Impossibile caricare i database per {name}. Controlla la connessione o scegline un'altra.",
|
||||||
databases: "Database",
|
databases: "Database",
|
||||||
allDatabases: "Tutti i database",
|
allDatabases: "Tutti i database",
|
||||||
databaseCount: "{count} database",
|
databaseCount: "{count} database",
|
||||||
@@ -8904,6 +8910,10 @@ export default withEnglishFallback({
|
|||||||
backupStatus: "Stato del backup",
|
backupStatus: "Stato del backup",
|
||||||
allStatuses: "Tutti gli stati",
|
allStatuses: "Tutti gli stati",
|
||||||
noFilteredHistory: "Nessun record di backup corrispondente",
|
noFilteredHistory: "Nessun record di backup corrispondente",
|
||||||
|
selectAllFiltered: "Seleziona tutti i risultati filtrati",
|
||||||
|
selectRun: "Seleziona {name}",
|
||||||
|
selectedRuns: "{count} selezionati",
|
||||||
|
deleteSelected: "Elimina selezionati",
|
||||||
showFiles: "Mostra file di backup",
|
showFiles: "Mostra file di backup",
|
||||||
scheduledTrigger: "Esecuzione programmata",
|
scheduledTrigger: "Esecuzione programmata",
|
||||||
manualTrigger: "Esecuzione manuale",
|
manualTrigger: "Esecuzione manuale",
|
||||||
@@ -8916,7 +8926,9 @@ export default withEnglishFallback({
|
|||||||
restore: "Ripristina",
|
restore: "Ripristina",
|
||||||
deleteBackup: "Elimina backup",
|
deleteBackup: "Elimina backup",
|
||||||
deleteBackupConfirm: "Eliminare questo backup e i suoi {count} file SQL? Questa operazione non può essere annullata.",
|
deleteBackupConfirm: "Eliminare questo backup e i suoi {count} file SQL? Questa operazione non può essere annullata.",
|
||||||
|
deleteBackupsConfirm: "Eliminare {count} backup e i loro {files} file SQL? Questa operazione non può essere annullata.",
|
||||||
backupDeleted: "Backup eliminato",
|
backupDeleted: "Backup eliminato",
|
||||||
|
backupsDeleted: "Eliminati {count} backup",
|
||||||
runSuccess: "Backup completato, generati {count} file",
|
runSuccess: "Backup completato, generati {count} file",
|
||||||
runCancelled: "Backup annullato",
|
runCancelled: "Backup annullato",
|
||||||
runFailed: "Backup fallito: {error}",
|
runFailed: "Backup fallito: {error}",
|
||||||
|
|||||||
@@ -8896,6 +8896,11 @@ export default withEnglishFallback({
|
|||||||
missingConnection: "接続が存在しません",
|
missingConnection: "接続が存在しません",
|
||||||
destination: "バックアップ先",
|
destination: "バックアップ先",
|
||||||
selectDestination: "バックアップ先を選択",
|
selectDestination: "バックアップ先を選択",
|
||||||
|
runDirectoryPattern: "実行ディレクトリテンプレート",
|
||||||
|
runDirectoryPatternHint:
|
||||||
|
"バックアップ先配下の相対パスです。{'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'}、{'{'}runId{'}'} を使用できます。既定のテンプレートには、より確実に分離するため {'{'}runId{'}'} が含まれます。例:{'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "ディレクトリのプレビュー",
|
||||||
|
runDirectoryPatternInvalid: ". または .. を含まない相対パスを入力してください。",
|
||||||
compression: "圧縮",
|
compression: "圧縮",
|
||||||
compressionNone: "圧縮なし(.sql)",
|
compressionNone: "圧縮なし(.sql)",
|
||||||
compressionGzip: "Gzip 圧縮(.sql.gz)",
|
compressionGzip: "Gzip 圧縮(.sql.gz)",
|
||||||
@@ -8903,6 +8908,7 @@ export default withEnglishFallback({
|
|||||||
noMatchingConnections: "一致する接続がありません",
|
noMatchingConnections: "一致する接続がありません",
|
||||||
searchDatabases: "データベースを検索…",
|
searchDatabases: "データベースを検索…",
|
||||||
noMatchingDatabases: "一致するデータベースがありません",
|
noMatchingDatabases: "一致するデータベースがありません",
|
||||||
|
databaseListLoadFailed: "{name} のデータベースを読み込めません。接続を確認するか、別の接続を選択してください。",
|
||||||
databases: "データベース",
|
databases: "データベース",
|
||||||
allDatabases: "すべてのデータベース",
|
allDatabases: "すべてのデータベース",
|
||||||
databaseCount: "{count} 個のデータベース",
|
databaseCount: "{count} 個のデータベース",
|
||||||
@@ -8955,6 +8961,10 @@ export default withEnglishFallback({
|
|||||||
backupStatus: "バックアップ状態",
|
backupStatus: "バックアップ状態",
|
||||||
allStatuses: "すべての状態",
|
allStatuses: "すべての状態",
|
||||||
noFilteredHistory: "一致するバックアップ記録がありません",
|
noFilteredHistory: "一致するバックアップ記録がありません",
|
||||||
|
selectAllFiltered: "絞り込み結果をすべて選択",
|
||||||
|
selectRun: "{name} を選択",
|
||||||
|
selectedRuns: "{count} 件を選択中",
|
||||||
|
deleteSelected: "選択項目を削除",
|
||||||
showFiles: "バックアップファイルを表示",
|
showFiles: "バックアップファイルを表示",
|
||||||
scheduledTrigger: "定期実行",
|
scheduledTrigger: "定期実行",
|
||||||
manualTrigger: "手動実行",
|
manualTrigger: "手動実行",
|
||||||
@@ -8967,7 +8977,9 @@ export default withEnglishFallback({
|
|||||||
restore: "復元",
|
restore: "復元",
|
||||||
deleteBackup: "バックアップを削除",
|
deleteBackup: "バックアップを削除",
|
||||||
deleteBackupConfirm: "このバックアップとその {count} 個の SQL ファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
deleteBackupConfirm: "このバックアップとその {count} 個の SQL ファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
|
deleteBackupsConfirm: "{count} 件のバックアップと、その {files} 個の SQL ファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
backupDeleted: "バックアップが削除されました",
|
backupDeleted: "バックアップが削除されました",
|
||||||
|
backupsDeleted: "{count} 件のバックアップを削除しました",
|
||||||
runSuccess: "バックアップが完了しました。{count} 個のファイルが生成されました。",
|
runSuccess: "バックアップが完了しました。{count} 個のファイルが生成されました。",
|
||||||
runCancelled: "バックアップがキャンセルされました",
|
runCancelled: "バックアップがキャンセルされました",
|
||||||
runFailed: "バックアップに失敗しました:{error}",
|
runFailed: "バックアップに失敗しました:{error}",
|
||||||
|
|||||||
@@ -7098,6 +7098,11 @@ export default withEnglishFallback({
|
|||||||
missingConnection: "연결 누락",
|
missingConnection: "연결 누락",
|
||||||
destination: "대상 디렉터리",
|
destination: "대상 디렉터리",
|
||||||
selectDestination: "백업 디렉터리 선택",
|
selectDestination: "백업 디렉터리 선택",
|
||||||
|
runDirectoryPattern: "실행 디렉터리 템플릿",
|
||||||
|
runDirectoryPatternHint:
|
||||||
|
"대상 디렉터리 아래의 상대 경로입니다. {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'}, {'{'}runId{'}'}를 사용할 수 있습니다. 기본 템플릿에는 더 나은 분리를 위해 {'{'}runId{'}'}가 포함됩니다. 예: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "디렉터리 미리 보기",
|
||||||
|
runDirectoryPatternInvalid: ". 또는 .. 경로 세그먼트가 없는 상대 경로를 사용하세요.",
|
||||||
databases: "데이터베이스",
|
databases: "데이터베이스",
|
||||||
allDatabases: "모든 데이터베이스",
|
allDatabases: "모든 데이터베이스",
|
||||||
databaseCount: "데이터베이스 {count}개",
|
databaseCount: "데이터베이스 {count}개",
|
||||||
@@ -7153,7 +7158,9 @@ export default withEnglishFallback({
|
|||||||
restore: "복원",
|
restore: "복원",
|
||||||
deleteBackup: "백업 삭제",
|
deleteBackup: "백업 삭제",
|
||||||
deleteBackupConfirm: "이 백업과 SQL 파일 {count}개를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
deleteBackupConfirm: "이 백업과 SQL 파일 {count}개를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||||
|
deleteBackupsConfirm: "{count}개의 백업과 SQL 파일 {files}개를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||||
backupDeleted: "백업을 삭제했습니다",
|
backupDeleted: "백업을 삭제했습니다",
|
||||||
|
backupsDeleted: "{count}개의 백업을 삭제했습니다",
|
||||||
runSuccess: "백업 완료 (파일 {count}개)",
|
runSuccess: "백업 완료 (파일 {count}개)",
|
||||||
runCancelled: "백업 취소됨",
|
runCancelled: "백업 취소됨",
|
||||||
runFailed: "백업 실패: {error}",
|
runFailed: "백업 실패: {error}",
|
||||||
@@ -7180,6 +7187,7 @@ export default withEnglishFallback({
|
|||||||
noMatchingConnections: "일치하는 연결을 찾을 수 없습니다.",
|
noMatchingConnections: "일치하는 연결을 찾을 수 없습니다.",
|
||||||
searchDatabases: "데이터베이스 검색…",
|
searchDatabases: "데이터베이스 검색…",
|
||||||
noMatchingDatabases: "일치하는 데이터베이스를 찾을 수 없습니다.",
|
noMatchingDatabases: "일치하는 데이터베이스를 찾을 수 없습니다.",
|
||||||
|
databaseListLoadFailed: "{name}의 데이터베이스를 불러올 수 없습니다. 연결을 확인하거나 다른 연결을 선택하세요.",
|
||||||
cancelling: "취소 중…",
|
cancelling: "취소 중…",
|
||||||
cancelRequested: "백업 취소가 요청되었습니다. 현재 작업을 중지하는 중입니다.",
|
cancelRequested: "백업 취소가 요청되었습니다. 현재 작업을 중지하는 중입니다.",
|
||||||
allConnections: "모든 연결",
|
allConnections: "모든 연결",
|
||||||
@@ -7189,6 +7197,10 @@ export default withEnglishFallback({
|
|||||||
backupStatus: "백업 상태",
|
backupStatus: "백업 상태",
|
||||||
allStatuses: "모든 상태",
|
allStatuses: "모든 상태",
|
||||||
noFilteredHistory: "일치하는 백업 기록이 없습니다.",
|
noFilteredHistory: "일치하는 백업 기록이 없습니다.",
|
||||||
|
selectAllFiltered: "현재 필터 결과 모두 선택",
|
||||||
|
selectRun: "{name} 선택",
|
||||||
|
selectedRuns: "{count}개 선택됨",
|
||||||
|
deleteSelected: "선택 항목 삭제",
|
||||||
},
|
},
|
||||||
mqTopics: {
|
mqTopics: {
|
||||||
title: "토픽",
|
title: "토픽",
|
||||||
|
|||||||
@@ -8846,6 +8846,11 @@ export default withEnglishFallback({
|
|||||||
missingConnection: "Conexão não existe mais",
|
missingConnection: "Conexão não existe mais",
|
||||||
destination: "Diretório de backup",
|
destination: "Diretório de backup",
|
||||||
selectDestination: "Selecionar diretório de backup",
|
selectDestination: "Selecionar diretório de backup",
|
||||||
|
runDirectoryPattern: "Modelo de diretório de execução",
|
||||||
|
runDirectoryPatternHint:
|
||||||
|
"Um caminho relativo dentro do diretório de destino. Use {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'} e {'{'}runId{'}'}. O modelo padrão inclui {'{'}runId{'}'} para maior isolamento. Exemplo: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "Prévia do diretório",
|
||||||
|
runDirectoryPatternInvalid: "Use um caminho relativo sem segmentos . ou ..",
|
||||||
compression: "Compactação",
|
compression: "Compactação",
|
||||||
compressionNone: "Sem compactação (.sql)",
|
compressionNone: "Sem compactação (.sql)",
|
||||||
compressionGzip: "Compactação Gzip (.sql.gz)",
|
compressionGzip: "Compactação Gzip (.sql.gz)",
|
||||||
@@ -8853,6 +8858,7 @@ export default withEnglishFallback({
|
|||||||
noMatchingConnections: "Nenhuma conexão correspondente",
|
noMatchingConnections: "Nenhuma conexão correspondente",
|
||||||
searchDatabases: "Pesquisar bancos de dados…",
|
searchDatabases: "Pesquisar bancos de dados…",
|
||||||
noMatchingDatabases: "Nenhum banco de dados correspondente",
|
noMatchingDatabases: "Nenhum banco de dados correspondente",
|
||||||
|
databaseListLoadFailed: "Não foi possível carregar os bancos de dados de {name}. Verifique esta conexão ou escolha outra.",
|
||||||
databases: "Bancos de dados",
|
databases: "Bancos de dados",
|
||||||
allDatabases: "Todos os bancos de dados",
|
allDatabases: "Todos os bancos de dados",
|
||||||
databaseCount: "{count} bancos de dados",
|
databaseCount: "{count} bancos de dados",
|
||||||
@@ -8905,6 +8911,10 @@ export default withEnglishFallback({
|
|||||||
backupStatus: "Status do backup",
|
backupStatus: "Status do backup",
|
||||||
allStatuses: "Todos os status",
|
allStatuses: "Todos os status",
|
||||||
noFilteredHistory: "Nenhum registro de backup correspondente",
|
noFilteredHistory: "Nenhum registro de backup correspondente",
|
||||||
|
selectAllFiltered: "Selecionar todos os resultados filtrados",
|
||||||
|
selectRun: "Selecionar {name}",
|
||||||
|
selectedRuns: "{count} selecionados",
|
||||||
|
deleteSelected: "Excluir selecionados",
|
||||||
showFiles: "Mostrar arquivos de backup",
|
showFiles: "Mostrar arquivos de backup",
|
||||||
scheduledTrigger: "Execução agendada",
|
scheduledTrigger: "Execução agendada",
|
||||||
manualTrigger: "Execução manual",
|
manualTrigger: "Execução manual",
|
||||||
@@ -8917,7 +8927,9 @@ export default withEnglishFallback({
|
|||||||
restore: "Restaurar",
|
restore: "Restaurar",
|
||||||
deleteBackup: "Excluir backup",
|
deleteBackup: "Excluir backup",
|
||||||
deleteBackupConfirm: "Tem certeza de que deseja excluir este backup e seus {count} arquivos SQL? Esta ação é irreversível.",
|
deleteBackupConfirm: "Tem certeza de que deseja excluir este backup e seus {count} arquivos SQL? Esta ação é irreversível.",
|
||||||
|
deleteBackupsConfirm: "Tem certeza de que deseja excluir {count} backups e seus {files} arquivos SQL? Esta ação é irreversível.",
|
||||||
backupDeleted: "Backup excluído",
|
backupDeleted: "Backup excluído",
|
||||||
|
backupsDeleted: "{count} backups excluídos",
|
||||||
runSuccess: "Backup concluído, {count} arquivos gerados",
|
runSuccess: "Backup concluído, {count} arquivos gerados",
|
||||||
runCancelled: "Backup cancelado",
|
runCancelled: "Backup cancelado",
|
||||||
runFailed: "Falha no backup: {error}",
|
runFailed: "Falha no backup: {error}",
|
||||||
|
|||||||
@@ -7682,6 +7682,10 @@ export default withEnglishFallback({
|
|||||||
missingConnection: "Bağlantı eksik",
|
missingConnection: "Bağlantı eksik",
|
||||||
destination: "Hedef dizin",
|
destination: "Hedef dizin",
|
||||||
selectDestination: "Yedekleme dizinini seçin",
|
selectDestination: "Yedekleme dizinini seçin",
|
||||||
|
runDirectoryPattern: "Çalıştırma dizini şablonu",
|
||||||
|
runDirectoryPatternHint: "Hedef dizin altındaki göreli yol. {'{'}schedule{'}'}, {'{'}date{'}'}, {'{'}timestamp{'}'} ve {'{'}runId{'}'} kullanın. Varsayılan şablon daha iyi yalıtım için {'{'}runId{'}'} içerir. Örnek: {'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "Dizin önizlemesi",
|
||||||
|
runDirectoryPatternInvalid: ". veya .. bölümleri içermeyen göreli bir yol kullanın.",
|
||||||
compression: "Sıkıştırma",
|
compression: "Sıkıştırma",
|
||||||
compressionNone: "Sıkıştırma yok (.sql)",
|
compressionNone: "Sıkıştırma yok (.sql)",
|
||||||
compressionGzip: "Gzip (.sql.gz)",
|
compressionGzip: "Gzip (.sql.gz)",
|
||||||
@@ -7689,6 +7693,7 @@ export default withEnglishFallback({
|
|||||||
noMatchingConnections: "Eşleşen bağlantı yok",
|
noMatchingConnections: "Eşleşen bağlantı yok",
|
||||||
searchDatabases: "Veritabanlarında ara…",
|
searchDatabases: "Veritabanlarında ara…",
|
||||||
noMatchingDatabases: "Eşleşen veritabanı yok",
|
noMatchingDatabases: "Eşleşen veritabanı yok",
|
||||||
|
databaseListLoadFailed: "{name} için veritabanları yüklenemedi. Bu bağlantıyı denetleyin veya başka bir bağlantı seçin.",
|
||||||
databases: "Veritabanları",
|
databases: "Veritabanları",
|
||||||
allDatabases: "Tüm veritabanları",
|
allDatabases: "Tüm veritabanları",
|
||||||
databaseCount: "{count} veritabanı",
|
databaseCount: "{count} veritabanı",
|
||||||
@@ -7741,6 +7746,10 @@ export default withEnglishFallback({
|
|||||||
backupStatus: "Yedekleme durumu",
|
backupStatus: "Yedekleme durumu",
|
||||||
allStatuses: "Tüm durumlar",
|
allStatuses: "Tüm durumlar",
|
||||||
noFilteredHistory: "Eşleşen yedekleme kaydı yok",
|
noFilteredHistory: "Eşleşen yedekleme kaydı yok",
|
||||||
|
selectAllFiltered: "Filtrelenmiş sonuçların tümünü seç",
|
||||||
|
selectRun: "{name} seç",
|
||||||
|
selectedRuns: "{count} seçildi",
|
||||||
|
deleteSelected: "Seçilenleri sil",
|
||||||
showFiles: "Yedekleme dosyalarını göster",
|
showFiles: "Yedekleme dosyalarını göster",
|
||||||
scheduledTrigger: "Zamanlanmış",
|
scheduledTrigger: "Zamanlanmış",
|
||||||
manualTrigger: "Elle",
|
manualTrigger: "Elle",
|
||||||
@@ -7754,7 +7763,9 @@ export default withEnglishFallback({
|
|||||||
restore: "Geri yükle",
|
restore: "Geri yükle",
|
||||||
deleteBackup: "Yedeği sil",
|
deleteBackup: "Yedeği sil",
|
||||||
deleteBackupConfirm: "Bu yedek ve {count} SQL dosyası silinsin mi? Bu işlem geri alınamaz.",
|
deleteBackupConfirm: "Bu yedek ve {count} SQL dosyası silinsin mi? Bu işlem geri alınamaz.",
|
||||||
|
deleteBackupsConfirm: "{count} yedek ve bunların {files} SQL dosyası silinsin mi? Bu işlem geri alınamaz.",
|
||||||
backupDeleted: "Yedek silindi",
|
backupDeleted: "Yedek silindi",
|
||||||
|
backupsDeleted: "{count} yedek silindi",
|
||||||
runSuccess: "Yedekleme {count} dosyayla tamamlandı",
|
runSuccess: "Yedekleme {count} dosyayla tamamlandı",
|
||||||
runCancelled: "Yedekleme iptal edildi",
|
runCancelled: "Yedekleme iptal edildi",
|
||||||
runFailed: "Yedekleme başarısız: {error}",
|
runFailed: "Yedekleme başarısız: {error}",
|
||||||
|
|||||||
@@ -7805,6 +7805,10 @@ export default withEnglishFallback({
|
|||||||
missingConnection: "连接已不存在",
|
missingConnection: "连接已不存在",
|
||||||
destination: "备份目录",
|
destination: "备份目录",
|
||||||
selectDestination: "选择备份目录",
|
selectDestination: "选择备份目录",
|
||||||
|
runDirectoryPattern: "运行目录模板",
|
||||||
|
runDirectoryPatternHint: "相对于备份目录的路径。可使用 {'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'} 和 {'{'}runId{'}'}。默认模板保留 {'{'}runId{'}'} 以获得更好的隔离性。例如:{'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "目录预览",
|
||||||
|
runDirectoryPatternInvalid: "请输入不含 . 或 .. 路径段的相对路径。",
|
||||||
compression: "压缩",
|
compression: "压缩",
|
||||||
compressionNone: "不压缩(.sql)",
|
compressionNone: "不压缩(.sql)",
|
||||||
compressionGzip: "Gzip 压缩(.sql.gz)",
|
compressionGzip: "Gzip 压缩(.sql.gz)",
|
||||||
@@ -7812,6 +7816,7 @@ export default withEnglishFallback({
|
|||||||
noMatchingConnections: "未找到匹配的连接",
|
noMatchingConnections: "未找到匹配的连接",
|
||||||
searchDatabases: "搜索数据库…",
|
searchDatabases: "搜索数据库…",
|
||||||
noMatchingDatabases: "未找到匹配的数据库",
|
noMatchingDatabases: "未找到匹配的数据库",
|
||||||
|
databaseListLoadFailed: "无法读取“{name}”的数据库。请检查该连接,或选择其他连接。",
|
||||||
databases: "数据库",
|
databases: "数据库",
|
||||||
allDatabases: "全部数据库",
|
allDatabases: "全部数据库",
|
||||||
databaseCount: "{count} 个数据库",
|
databaseCount: "{count} 个数据库",
|
||||||
@@ -7864,6 +7869,10 @@ export default withEnglishFallback({
|
|||||||
backupStatus: "备份状态",
|
backupStatus: "备份状态",
|
||||||
allStatuses: "全部状态",
|
allStatuses: "全部状态",
|
||||||
noFilteredHistory: "没有匹配的备份记录",
|
noFilteredHistory: "没有匹配的备份记录",
|
||||||
|
selectAllFiltered: "全选当前筛选结果",
|
||||||
|
selectRun: "选择 {name}",
|
||||||
|
selectedRuns: "已选择 {count} 项",
|
||||||
|
deleteSelected: "批量删除",
|
||||||
showFiles: "显示备份文件",
|
showFiles: "显示备份文件",
|
||||||
scheduledTrigger: "定时执行",
|
scheduledTrigger: "定时执行",
|
||||||
manualTrigger: "手动执行",
|
manualTrigger: "手动执行",
|
||||||
@@ -7877,7 +7886,9 @@ export default withEnglishFallback({
|
|||||||
restore: "恢复",
|
restore: "恢复",
|
||||||
deleteBackup: "删除备份",
|
deleteBackup: "删除备份",
|
||||||
deleteBackupConfirm: "确定删除此备份及其 {count} 个 SQL 文件吗?此操作不可撤销。",
|
deleteBackupConfirm: "确定删除此备份及其 {count} 个 SQL 文件吗?此操作不可撤销。",
|
||||||
|
deleteBackupsConfirm: "确定删除 {count} 个备份及其 {files} 个 SQL 文件吗?此操作不可撤销。",
|
||||||
backupDeleted: "备份已删除",
|
backupDeleted: "备份已删除",
|
||||||
|
backupsDeleted: "已删除 {count} 个备份",
|
||||||
runSuccess: "备份完成,共生成 {count} 个文件",
|
runSuccess: "备份完成,共生成 {count} 个文件",
|
||||||
runCancelled: "备份已取消",
|
runCancelled: "备份已取消",
|
||||||
runFailed: "备份失败:{error}",
|
runFailed: "备份失败:{error}",
|
||||||
|
|||||||
@@ -8834,6 +8834,10 @@ export default withEnglishFallback({
|
|||||||
missingConnection: "連線已不存在",
|
missingConnection: "連線已不存在",
|
||||||
destination: "備份目錄",
|
destination: "備份目錄",
|
||||||
selectDestination: "選擇備份目錄",
|
selectDestination: "選擇備份目錄",
|
||||||
|
runDirectoryPattern: "執行目錄範本",
|
||||||
|
runDirectoryPatternHint: "相對於備份目錄的路徑。可使用 {'{'}schedule{'}'}、{'{'}date{'}'}、{'{'}timestamp{'}'} 和 {'{'}runId{'}'}。預設範本保留 {'{'}runId{'}'} 以獲得更好的隔離性。例如:{'{'}schedule{'}'}/{'{'}date{'}'}/{'{'}timestamp{'}'}-{'{'}runId{'}'}",
|
||||||
|
runDirectoryPreview: "目錄預覽",
|
||||||
|
runDirectoryPatternInvalid: "請輸入不含 . 或 .. 路徑段的相對路徑。",
|
||||||
compression: "壓縮",
|
compression: "壓縮",
|
||||||
compressionNone: "不壓縮(.sql)",
|
compressionNone: "不壓縮(.sql)",
|
||||||
compressionGzip: "Gzip 壓縮(.sql.gz)",
|
compressionGzip: "Gzip 壓縮(.sql.gz)",
|
||||||
@@ -8841,6 +8845,7 @@ export default withEnglishFallback({
|
|||||||
noMatchingConnections: "找不到相符的連線",
|
noMatchingConnections: "找不到相符的連線",
|
||||||
searchDatabases: "搜尋資料庫…",
|
searchDatabases: "搜尋資料庫…",
|
||||||
noMatchingDatabases: "找不到相符的資料庫",
|
noMatchingDatabases: "找不到相符的資料庫",
|
||||||
|
databaseListLoadFailed: "無法讀取「{name}」的資料庫。請檢查該連線,或選擇其他連線。",
|
||||||
databases: "資料庫",
|
databases: "資料庫",
|
||||||
allDatabases: "全部資料庫",
|
allDatabases: "全部資料庫",
|
||||||
databaseCount: "{count} 個資料庫",
|
databaseCount: "{count} 個資料庫",
|
||||||
@@ -8893,6 +8898,10 @@ export default withEnglishFallback({
|
|||||||
backupStatus: "備份狀態",
|
backupStatus: "備份狀態",
|
||||||
allStatuses: "所有狀態",
|
allStatuses: "所有狀態",
|
||||||
noFilteredHistory: "沒有相符的備份記錄",
|
noFilteredHistory: "沒有相符的備份記錄",
|
||||||
|
selectAllFiltered: "全選目前篩選結果",
|
||||||
|
selectRun: "選擇 {name}",
|
||||||
|
selectedRuns: "已選擇 {count} 項",
|
||||||
|
deleteSelected: "批次刪除",
|
||||||
showFiles: "顯示備份檔案",
|
showFiles: "顯示備份檔案",
|
||||||
scheduledTrigger: "定時執行",
|
scheduledTrigger: "定時執行",
|
||||||
manualTrigger: "手動執行",
|
manualTrigger: "手動執行",
|
||||||
@@ -8905,7 +8914,9 @@ export default withEnglishFallback({
|
|||||||
restore: "回復",
|
restore: "回復",
|
||||||
deleteBackup: "刪除備份",
|
deleteBackup: "刪除備份",
|
||||||
deleteBackupConfirm: "確定刪除此備份及其 {count} 個 SQL 檔案嗎?此操作不可撤銷。",
|
deleteBackupConfirm: "確定刪除此備份及其 {count} 個 SQL 檔案嗎?此操作不可撤銷。",
|
||||||
|
deleteBackupsConfirm: "確定刪除 {count} 個備份及其 {files} 個 SQL 檔案嗎?此操作不可撤銷。",
|
||||||
backupDeleted: "備份已刪除",
|
backupDeleted: "備份已刪除",
|
||||||
|
backupsDeleted: "已刪除 {count} 個備份",
|
||||||
runSuccess: "備份完成,共產生 {count} 個檔案",
|
runSuccess: "備份完成,共產生 {count} 個檔案",
|
||||||
runCancelled: "備份已取消",
|
runCancelled: "備份已取消",
|
||||||
runFailed: "備份失敗:{error}",
|
runFailed: "備份失敗:{error}",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const DATABASE_BACKUP_SCHEDULES_STORAGE_KEY = "dbx-database-backup-schedu
|
|||||||
export const DATABASE_BACKUP_RUNS_STORAGE_KEY = "dbx-database-backup-runs";
|
export const DATABASE_BACKUP_RUNS_STORAGE_KEY = "dbx-database-backup-runs";
|
||||||
export const DATABASE_BACKUP_CONFIG_CHANGED_EVENT = "dbx:database-backup-config-changed";
|
export const DATABASE_BACKUP_CONFIG_CHANGED_EVENT = "dbx:database-backup-config-changed";
|
||||||
export const MAX_DATABASE_BACKUP_HISTORY = 200;
|
export const MAX_DATABASE_BACKUP_HISTORY = 200;
|
||||||
|
export const DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN = "dbx-backup__{schedule}__{timestamp}__{runId}";
|
||||||
|
|
||||||
export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly";
|
export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly";
|
||||||
export type DatabaseBackupExportStatus = "Running" | "Done" | "Error" | "Cancelled";
|
export type DatabaseBackupExportStatus = "Running" | "Done" | "Error" | "Cancelled";
|
||||||
@@ -130,6 +131,8 @@ export interface DatabaseBackupSchedule extends DatabaseBackupExecutionConfig {
|
|||||||
timeOfDay: string;
|
timeOfDay: string;
|
||||||
weekday: number;
|
weekday: number;
|
||||||
retentionCount: number;
|
retentionCount: number;
|
||||||
|
/** Relative directory template used to group the files created by one scheduled run. */
|
||||||
|
runDirectoryPattern?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
nextRunAt: string;
|
nextRunAt: string;
|
||||||
@@ -249,6 +252,7 @@ export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()
|
|||||||
dropTableIfExists: booleanValue(input.dropTableIfExists, false),
|
dropTableIfExists: booleanValue(input.dropTableIfExists, false),
|
||||||
outputCompression: input.outputCompression === "gzip" ? "gzip" : "none",
|
outputCompression: input.outputCompression === "gzip" ? "gzip" : "none",
|
||||||
retentionCount: normalizeDatabaseBackupRetention(input.retentionCount),
|
retentionCount: normalizeDatabaseBackupRetention(input.retentionCount),
|
||||||
|
runDirectoryPattern: normalizeDatabaseBackupRunDirectoryPattern(input.runDirectoryPattern),
|
||||||
createdAt: validIsoDate(input.createdAt, nowIso),
|
createdAt: validIsoDate(input.createdAt, nowIso),
|
||||||
updatedAt: validIsoDate(input.updatedAt, nowIso),
|
updatedAt: validIsoDate(input.updatedAt, nowIso),
|
||||||
nextRunAt: validIsoDate(input.nextRunAt, ""),
|
nextRunAt: validIsoDate(input.nextRunAt, ""),
|
||||||
@@ -387,6 +391,44 @@ export function sanitizeDatabaseBackupFileSegment(value: string): string {
|
|||||||
return sanitized || "database";
|
return sanitized || "database";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A run directory is always relative to the directory selected in the native
|
||||||
|
* file picker. Empty and legacy schedules keep the stable default template.
|
||||||
|
*/
|
||||||
|
export function normalizeDatabaseBackupRunDirectoryPattern(value: unknown): string {
|
||||||
|
return stringValue(value).trim() || DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDatabaseBackupRunDirectoryPattern(pattern: string, scheduleName: string, startedAt: Date | string, runId: string): string {
|
||||||
|
const timestamp = databaseBackupTimestamp(startedAt);
|
||||||
|
const compactTimestamp = timestamp.replace("-", "");
|
||||||
|
return normalizeDatabaseBackupRunDirectoryPattern(pattern).replaceAll("{schedule}", sanitizeDatabaseBackupFileSegment(scheduleName)).replaceAll("{date}", timestamp.slice(0, 8)).replaceAll("{timestamp}", compactTimestamp).replaceAll("{runId}", sanitizeDatabaseBackupFileSegment(runId).slice(0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function databaseBackupRunDirectory(directory: string, pattern: string, scheduleName: string, startedAt: Date | string, runId: string): string {
|
||||||
|
const normalizedPattern = normalizeDatabaseBackupRunDirectoryPattern(pattern);
|
||||||
|
const rendered = renderDatabaseBackupRunDirectoryPattern(normalizedPattern, scheduleName, startedAt, runId);
|
||||||
|
if (!rendered || /^[\\/]|^[a-zA-Z]:/.test(rendered) || /[\\/]$/.test(rendered)) {
|
||||||
|
throw new Error("Backup run directory template must be a relative path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = rendered.split(/[\\/]+/);
|
||||||
|
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
||||||
|
throw new Error("Backup run directory template cannot contain . or .. path segments.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.reduce((path, segment) => joinDatabaseBackupPath(path, sanitizeDatabaseBackupFileSegment(segment)), directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function databaseBackupRunDirectoryPatternIsValid(pattern: string): boolean {
|
||||||
|
try {
|
||||||
|
databaseBackupRunDirectory("/backups", pattern, "database-backup", new Date(2026, 0, 2, 3, 4, 5), "12345678");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function databaseBackupTimestamp(value: Date | string): string {
|
export function databaseBackupTimestamp(value: Date | string): string {
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
const part = (number: number) => String(number).padStart(2, "0");
|
const part = (number: number) => String(number).padStart(2, "0");
|
||||||
|
|||||||
@@ -2374,24 +2374,59 @@ pub async fn export_destination_identity_needs_confirmation(
|
|||||||
Ok(cfg!(target_os = "macos") && matches!(recorded_identity, Some(Some(ExportDestinationIdentity::LegacyDevice(_)))))
|
Ok(cfg!(target_os = "macos") && matches!(recorded_identity, Some(Some(ExportDestinationIdentity::LegacyDevice(_)))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifies the nearest persisted destination ancestor before a new child is
|
||||||
|
/// created. Scheduled runs always create new leaf directories, so checking
|
||||||
|
/// only the leaf would otherwise bypass the configured destination's mount
|
||||||
|
/// identity protection.
|
||||||
|
async fn ensure_recorded_export_destination_ancestor(
|
||||||
|
state: &crate::connection::AppState,
|
||||||
|
dir: &std::path::Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut ancestor = dir.parent();
|
||||||
|
while let Some(candidate) = ancestor {
|
||||||
|
let recorded_identity = state
|
||||||
|
.storage
|
||||||
|
.load_state(&export_destination_state_key(candidate))
|
||||||
|
.await?
|
||||||
|
.map(|(bytes, _content_type)| ExportDestinationIdentity::decode(&bytes))
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
if let Some(recorded_identity) = recorded_identity {
|
||||||
|
if !candidate.is_dir() {
|
||||||
|
return Err(format!(
|
||||||
|
"Backup directory {} is missing. Its parent {} was configured or previously used for exports, so dbx will not recreate a child directory automatically -- if this is on a removable or network drive, reconnect it and try again.",
|
||||||
|
dir.display(),
|
||||||
|
candidate.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(recorded_identity) = recorded_identity {
|
||||||
|
let current_identity = export_destination_identity_for_path(candidate);
|
||||||
|
if recorded_export_destination_identity_mismatch(&recorded_identity, current_identity.as_ref()) {
|
||||||
|
return Err(format!(
|
||||||
|
"Backup directory {} now resolves to a different filesystem than the configured parent {}. Refusing to create a child directory automatically -- make sure the correct removable or network drive is connected before running the backup.",
|
||||||
|
dir.display(),
|
||||||
|
candidate.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ancestor = candidate.parent();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensures `dir` exists for an export destination, without ever silently
|
/// Ensures `dir` exists for an export destination, without ever silently
|
||||||
/// recreating a directory that previously produced a successful export (or
|
/// recreating a directory that previously produced a successful export (or
|
||||||
/// was recorded via [`record_export_destination_identity`]) and has since
|
/// was recorded via [`record_export_destination_identity`]) and has since
|
||||||
/// disappeared. Auto-creating on every run is what the original fix for
|
/// disappeared. Auto-creating on every run is unsafe for a destination on a
|
||||||
/// #6109 did, but that is unsafe for a destination on a removable or network
|
/// removable or network drive: a missing mount could be recreated on the
|
||||||
/// drive: if the mount is temporarily gone when a run executes, blindly
|
/// local filesystem. A new directory is safe to create only after any
|
||||||
/// recreating the path resurrects it on the local root filesystem and the
|
/// recorded parent destination has been verified. See #6327.
|
||||||
/// export "succeeds" while silently writing to the wrong disk. A directory
|
|
||||||
/// dbx has never seen before is safe to create (normal first-time
|
|
||||||
/// configuration of a local folder); a directory dbx has seen before but
|
|
||||||
/// that is now missing, or that now resolves to a different filesystem than
|
|
||||||
/// last time, is refused instead. See #6327.
|
|
||||||
///
|
///
|
||||||
/// Returns the device identity that was just verified (or recorded for a
|
/// Returns the identity that was just verified (or recorded for a newly
|
||||||
/// newly created directory), if the current platform can determine one, so
|
/// created directory), so the caller can re-verify it against the file it
|
||||||
/// the caller can re-verify it against the file it actually opens -- the
|
/// actually opens.
|
||||||
/// directory check here and the later `File::create` are separate
|
|
||||||
/// operations, and the mount can change in between.
|
|
||||||
async fn ensure_export_destination_dir(
|
async fn ensure_export_destination_dir(
|
||||||
state: &crate::connection::AppState,
|
state: &crate::connection::AppState,
|
||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
@@ -2428,6 +2463,11 @@ async fn ensure_export_destination_dir(
|
|||||||
dir.display()
|
dir.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// A run-specific child directory is intentionally new on every
|
||||||
|
// scheduled backup. Before creating it, still honor a recorded parent
|
||||||
|
// destination so a missing external/NAS mount is never recreated on
|
||||||
|
// the local filesystem merely because this child has no state yet.
|
||||||
|
ensure_recorded_export_destination_ancestor(state, dir).await?;
|
||||||
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create backup directory: {e}"))?;
|
std::fs::create_dir_all(dir).map_err(|e| format!("Failed to create backup directory: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5921,6 +5961,29 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&scratch);
|
let _ = std::fs::remove_dir_all(&scratch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ensure_export_destination_dir_refuses_a_new_child_when_its_recorded_parent_is_missing() {
|
||||||
|
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-child-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&scratch).unwrap();
|
||||||
|
let state = test_app_state(&scratch).await;
|
||||||
|
|
||||||
|
let destination = scratch.join("mounted-drive").join("backups");
|
||||||
|
std::fs::create_dir_all(&destination).unwrap();
|
||||||
|
record_export_destination_identity(&state, &destination)
|
||||||
|
.await
|
||||||
|
.expect("the configured root should be recorded before scheduled runs begin");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(scratch.join("mounted-drive")).unwrap();
|
||||||
|
let run_directory = destination.join("dbx-backup__nightly__20260908-220000__12345678");
|
||||||
|
|
||||||
|
let result = ensure_export_destination_dir(&state, &run_directory).await;
|
||||||
|
|
||||||
|
assert!(result.is_err(), "a unique run directory must not recreate a missing configured parent");
|
||||||
|
assert!(!run_directory.exists(), "the run directory must not be created on the local filesystem");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&scratch);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn record_export_destination_identity_rejects_a_directory_that_disappeared_before_save() {
|
async fn record_export_destination_identity_rejects_a_directory_that_disappeared_before_save() {
|
||||||
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-eager-new-{}", uuid::Uuid::new_v4()));
|
let scratch = std::env::temp_dir().join(format!("dbx-export-dest-eager-new-{}", uuid::Uuid::new_v4()));
|
||||||
|
|||||||
@@ -3752,7 +3752,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn missing_shard_doc_support_falls_back_to_offset_paging() {
|
async fn missing_shard_doc_support_falls_back_to_scroll_paging() {
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let server = serve_responses(
|
let server = serve_responses(
|
||||||
@@ -3761,7 +3761,10 @@ mod tests {
|
|||||||
(200, r#"{"id":"pit-1"}"#.to_string()),
|
(200, r#"{"id":"pit-1"}"#.to_string()),
|
||||||
(400, missing_shard_doc_error_body()),
|
(400, missing_shard_doc_error_body()),
|
||||||
(200, "{}".to_string()),
|
(200, "{}".to_string()),
|
||||||
(200, one_hit_response()),
|
(
|
||||||
|
200,
|
||||||
|
r#"{"_scroll_id":"scroll-1","hits":{"total":{"value":42,"relation":"eq"},"hits":[{"_id":"1","_source":{"name":"one"}}]},"_shards":{"total":1,"successful":1,"skipped":0,"failed":0}}"#.to_string(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -3774,9 +3777,9 @@ mod tests {
|
|||||||
assert!(requests[1].starts_with("POST /_search "), "{}", requests[1]);
|
assert!(requests[1].starts_with("POST /_search "), "{}", requests[1]);
|
||||||
assert!(requests[1].contains("_shard_doc"), "{}", requests[1]);
|
assert!(requests[1].contains("_shard_doc"), "{}", requests[1]);
|
||||||
assert!(requests[2].starts_with("DELETE /_pit "), "{}", requests[2]);
|
assert!(requests[2].starts_with("DELETE /_pit "), "{}", requests[2]);
|
||||||
assert!(requests[3].starts_with("POST /products/_search "), "{}", requests[3]);
|
assert!(requests[3].starts_with("POST /products/_search?scroll=1m "), "{}", requests[3]);
|
||||||
assert!(!requests[3].contains("_shard_doc"), "fallback must not sort on _shard_doc: {}", requests[3]);
|
assert!(!requests[3].contains("_shard_doc"), "fallback must not sort on _shard_doc: {}", requests[3]);
|
||||||
assert!(requests[3].contains(r#""from":0"#), "{}", requests[3]);
|
assert!(!requests[3].contains(r#""from":0"#), "{}", requests[3]);
|
||||||
assert_eq!(result.documents.len(), 1);
|
assert_eq!(result.documents.len(), 1);
|
||||||
assert_eq!(result.total, 42);
|
assert_eq!(result.total, 42);
|
||||||
|
|
||||||
@@ -3785,11 +3788,14 @@ mod tests {
|
|||||||
super::EsSearchCursor::Page(page) => page,
|
super::EsSearchCursor::Page(page) => page,
|
||||||
super::EsSearchCursor::Sql { .. } => panic!("expected page cursor"),
|
super::EsSearchCursor::Sql { .. } => panic!("expected page cursor"),
|
||||||
};
|
};
|
||||||
assert_eq!(page.mode, super::EsPageMode::Offset { from: 1 });
|
assert_eq!(
|
||||||
|
page.mode,
|
||||||
|
super::EsPageMode::Scroll { scroll_id: "scroll-1".to_string(), keep_alive: "1m".to_string() }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn offset_fallback_is_remembered_for_the_connection() {
|
async fn scroll_fallback_is_remembered_for_the_connection() {
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let server = serve_responses(
|
let server = serve_responses(
|
||||||
@@ -3805,17 +3811,13 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
|
let client = EsClient::new(&format!("http://{addr}"), None, None, false, Duration::from_secs(1));
|
||||||
let first = super::find_documents_with_cursor(&client, "products", 1, None, None, None).await.unwrap();
|
let _first = super::find_documents_with_cursor(&client, "products", 1, None, None, None).await.unwrap();
|
||||||
let second =
|
let second = super::find_documents_with_cursor(&client, "products", 1, None, None, None).await.unwrap();
|
||||||
super::find_documents_with_cursor(&client, "products", 1, None, None, first.next_cursor.as_deref())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let requests = server.await.unwrap();
|
let requests = server.await.unwrap();
|
||||||
|
|
||||||
// 探测结果记在连接上:第二次查询不再尝试开 PIT。
|
// 探测结果记在连接上:第二次查询不再尝试开 PIT。
|
||||||
assert_eq!(requests.len(), 5);
|
assert_eq!(requests.len(), 5);
|
||||||
assert!(requests[4].starts_with("POST /products/_search "), "{}", requests[4]);
|
assert!(requests[4].starts_with("POST /products/_search?scroll=1m "), "{}", requests[4]);
|
||||||
assert!(requests[4].contains(r#""from":1"#), "{}", requests[4]);
|
|
||||||
assert_eq!(second.documents.len(), 1);
|
assert_eq!(second.documents.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3844,7 +3846,7 @@ mod tests {
|
|||||||
let requests = server.await.unwrap();
|
let requests = server.await.unwrap();
|
||||||
|
|
||||||
// 偶发 5xx 只回退这一次请求,下一次查询仍旧先试 PIT。
|
// 偶发 5xx 只回退这一次请求,下一次查询仍旧先试 PIT。
|
||||||
assert!(requests[3].starts_with("POST /products/_search "), "{}", requests[3]);
|
assert!(requests[3].starts_with("POST /products/_search?scroll=1m "), "{}", requests[3]);
|
||||||
assert!(requests[4].starts_with("POST /products/_pit?keep_alive=1m "), "{}", requests[4]);
|
assert!(requests[4].starts_with("POST /products/_pit?keep_alive=1m "), "{}", requests[4]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3918,7 +3920,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn offset_fallback_keeps_track_total_hits_off_and_breaks_sort_ties() {
|
async fn scroll_fallback_keeps_track_total_hits_off_and_preserves_requested_sort() {
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let server = serve_responses(
|
let server = serve_responses(
|
||||||
@@ -3939,8 +3941,9 @@ mod tests {
|
|||||||
let requests = server.await.unwrap();
|
let requests = server.await.unwrap();
|
||||||
|
|
||||||
let fallback = &requests[3];
|
let fallback = &requests[3];
|
||||||
// 多页 from/size 之间没有快照,需要 `_doc` 打散并列值造成的页边界抖动。
|
// Scroll 持有搜索快照,保留用户的排序即可,无需追加 `_doc`。
|
||||||
assert!(fallback.contains(r#""sort":[{"created_at":{"order":"desc"}},"_doc"]"#), "{fallback}");
|
assert!(fallback.starts_with("POST /products/_search?scroll=1m "), "{fallback}");
|
||||||
|
assert!(fallback.contains(r#""sort":[{"created_at":{"order":"desc"}}]"#), "{fallback}");
|
||||||
// 回退路径不加 track_total_hits:那会让每页都对整个索引做一次精确计数,
|
// 回退路径不加 track_total_hits:那会让每页都对整个索引做一次精确计数,
|
||||||
// 而这条路径针对的正是老的大集群。
|
// 而这条路径针对的正是老的大集群。
|
||||||
assert!(!fallback.contains("track_total_hits"), "{fallback}");
|
assert!(!fallback.contains("track_total_hits"), "{fallback}");
|
||||||
@@ -4001,7 +4004,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(requests[0].starts_with("POST /products/_pit?keep_alive=1m "), "{}", requests[0]);
|
assert!(requests[0].starts_with("POST /products/_pit?keep_alive=1m "), "{}", requests[0]);
|
||||||
assert!(requests[1].contains("_shard_doc"), "{}", requests[1]);
|
assert!(requests[1].contains("_shard_doc"), "{}", requests[1]);
|
||||||
assert!(requests[3].starts_with("POST /products/_search "), "{}", requests[3]);
|
assert!(requests[3].starts_with("POST /products/_search?scroll=1m "), "{}", requests[3]);
|
||||||
assert!(!requests[3].contains("_shard_doc"), "fallback must not sort on _shard_doc: {}", requests[3]);
|
assert!(!requests[3].contains("_shard_doc"), "fallback must not sort on _shard_doc: {}", requests[3]);
|
||||||
assert_eq!(result.rows.len(), 1);
|
assert_eq!(result.rows.len(), 1);
|
||||||
// 分页计划要拿索引真实总数来算总页数。
|
// 分页计划要拿索引真实总数来算总页数。
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ description: 在 DBX 桌面端为 MySQL 和 PostgreSQL 创建可手动运行或
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 数据库 | MySQL、PostgreSQL |
|
| 数据库 | MySQL、PostgreSQL |
|
||||||
| 平台 | DBX 桌面端 |
|
| 平台 | DBX 桌面端 |
|
||||||
| 输出 | 每个数据库/Schema 对应的 `.sql` 文件 |
|
| 输出 | 默认模板为每次计划运行创建独立目录;目录内按数据库/Schema 生成 `.sql` 文件,且可通过模板自定义目录结构 |
|
||||||
| 频率 | 每隔若干小时、每天指定时间、每周指定星期和时间 |
|
| 频率 | 每隔若干小时、每天指定时间、每周指定星期和时间 |
|
||||||
| 内容 | 表结构、表数据、数据库对象、可选 `DROP TABLE IF EXISTS` |
|
| 内容 | 表结构、表数据、数据库对象、可选 `DROP TABLE IF EXISTS` |
|
||||||
| 表范围 | 全部、按模式包含、按模式排除 |
|
| 表范围 | 全部、按模式包含、按模式排除 |
|
||||||
@@ -68,7 +68,7 @@ description: 在 DBX 桌面端为 MySQL 和 PostgreSQL 创建可手动运行或
|
|||||||
<Step>
|
<Step>
|
||||||
### 设置目录与频率
|
### 设置目录与频率
|
||||||
|
|
||||||
选择本机目标目录,并设置每小时、每天或每周计划。界面会预览下次运行时间。
|
选择本机目标目录,设置相对运行目录模板,并配置每小时、每天或每周计划。模板支持 `{schedule}`、`{date}`、`{timestamp}` 和 `{runId}`。默认模板保留 `{runId}` 以获得更好的隔离性;也可仅使用时间戳,例如 `dbx-backup_{timestamp}`。界面会预览下次运行时间。
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
@@ -118,7 +118,7 @@ orders_202?
|
|||||||
|
|
||||||
## 保留与历史
|
## 保留与历史
|
||||||
|
|
||||||
每个计划可以设置保留数量。超过数量的旧运行会从历史中清理,并删除相应文件。也可以手动删除某次备份记录和文件。
|
每个计划可以设置保留数量。超过数量的旧运行会从历史中清理,并删除相应文件。默认运行目录会使用唯一运行 ID;也可按实际需要自定义目录模板。也可以手动删除某次备份记录和文件。
|
||||||
|
|
||||||
<Callout type="warn">目标目录仍应纳入操作系统、NAS 或对象存储的独立备份。单机同盘保存无法防止磁盘损坏、勒索软件或整机丢失。</Callout>
|
<Callout type="warn">目标目录仍应纳入操作系统、NAS 或对象存储的独立备份。单机同盘保存无法防止磁盘损坏、勒索软件或整机丢失。</Callout>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ For a backup that should run only once, use **Back up now** in Settings instead
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Databases | MySQL and PostgreSQL |
|
| Databases | MySQL and PostgreSQL |
|
||||||
| Platform | DBX Desktop |
|
| Platform | DBX Desktop |
|
||||||
| Output | One `.sql` file per database/schema target |
|
| Output | The default template creates one directory per scheduled run, containing one `.sql` file per database/schema target; the directory structure can be customized with a template |
|
||||||
| Frequency | Every N hours, daily at a time, or weekly on a weekday and time |
|
| Frequency | Every N hours, daily at a time, or weekly on a weekday and time |
|
||||||
| Contents | Table structure, data, database objects, and optional `DROP TABLE IF EXISTS` |
|
| Contents | Table structure, data, database objects, and optional `DROP TABLE IF EXISTS` |
|
||||||
| Table scope | All, include by pattern, or exclude by pattern |
|
| Table scope | All, include by pattern, or exclude by pattern |
|
||||||
@@ -68,7 +68,7 @@ For a backup that should run only once, use **Back up now** in Settings instead
|
|||||||
<Step>
|
<Step>
|
||||||
### Set directory and frequency
|
### Set directory and frequency
|
||||||
|
|
||||||
Select a local destination directory and configure hourly, daily, or weekly timing. DBX previews the next run.
|
Select a local destination directory, choose a relative run-directory template, and configure hourly, daily, or weekly timing. Templates support `{schedule}`, `{date}`, `{timestamp}`, and `{runId}`. The default template includes `{runId}` for extra isolation, but you can use timestamp-only directories such as `dbx-backup_{timestamp}`. DBX previews the next run.
|
||||||
|
|
||||||
</Step>
|
</Step>
|
||||||
<Step>
|
<Step>
|
||||||
@@ -118,7 +118,7 @@ Cancellation stops remaining exports. On failure or cancellation, DBX attempts t
|
|||||||
|
|
||||||
## Retention and History
|
## Retention and History
|
||||||
|
|
||||||
Each schedule has a retention count. Older runs beyond the limit are pruned from history and their files are deleted. Individual backup runs and files can also be removed manually.
|
Each schedule has a retention count. Older runs beyond the limit are pruned from history and their files are deleted. The default run directory includes a unique run ID, while directory templates can be customized to fit your workflow. Individual backup runs and files can also be removed manually.
|
||||||
|
|
||||||
<Callout type="warn">The destination directory still needs independent OS, NAS, or object-storage backup. Saving to another folder on the same disk does not protect against disk failure, ransomware, or loss of the machine.</Callout>
|
<Callout type="warn">The destination directory still needs independent OS, NAS, or object-storage backup. Saving to another folder on the same disk does not protect against disk failure, ransomware, or loss of the machine.</Callout>
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -150,7 +150,8 @@ test("mongo data-grid index deletions stay production-gated and refresh metadata
|
|||||||
assert.match(body, /finally\s*\{[\s\S]*?refreshMongoIndexMetadataAfterMutation/, `${name} must refresh index metadata even when deletion fails`);
|
assert.match(body, /finally\s*\{[\s\S]*?refreshMongoIndexMetadataAfterMutation/, `${name} must refresh index metadata even when deletion fails`);
|
||||||
assert.ok(body.indexOf("runMongoMutation") < body.indexOf("api.mongoDropIndexes"), `production confirmation must wrap ${name}`);
|
assert.ok(body.indexOf("runMongoMutation") < body.indexOf("api.mongoDropIndexes"), `production confirmation must wrap ${name}`);
|
||||||
}
|
}
|
||||||
const refreshBody = functionBody(source, "refreshMongoIndexMetadataAfterMutation");
|
const metadataSource = readSource("apps/desktop/src/composables/useDataGridTableMetadataLoaders.ts");
|
||||||
|
const refreshBody = functionBody(metadataSource, "refreshMongoIndexMetadataAfterMutation");
|
||||||
assert.match(refreshBody, /reloadIndexes/, "data-grid metadata must refresh after index deletion");
|
assert.match(refreshBody, /reloadIndexes/, "data-grid metadata must refresh after index deletion");
|
||||||
assert.match(refreshBody, /refreshLoadedMongoIndexes/, "the loaded sidebar index tree must refresh after data-grid deletion");
|
assert.match(refreshBody, /refreshLoadedMongoIndexes/, "the loaded sidebar index tree must refresh after data-grid deletion");
|
||||||
assert.match(functionBody(source, "confirmDropAllMongoIndexes"), /api\.mongoDropIndexes\([^;]*undefined,\s*false\)/, "drop all must use MongoDB wildcard semantics instead of the currently loaded index nodes");
|
assert.match(functionBody(source, "confirmDropAllMongoIndexes"), /api\.mongoDropIndexes\([^;]*undefined,\s*false\)/, "drop all must use MongoDB wildcard semantics instead of the currently loaded index nodes");
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
DatabaseBackupConnectionQueue,
|
DatabaseBackupConnectionQueue,
|
||||||
databaseBackupAggregateExportStatus,
|
databaseBackupAggregateExportStatus,
|
||||||
databaseBackupFilePath,
|
databaseBackupFilePath,
|
||||||
|
databaseBackupRunDirectory,
|
||||||
|
databaseBackupRunDirectoryPatternIsValid,
|
||||||
databaseBackupProgressPercent,
|
databaseBackupProgressPercent,
|
||||||
databaseBackupRunsToPrune,
|
databaseBackupRunsToPrune,
|
||||||
databaseBackupScheduleIsDue,
|
databaseBackupScheduleIsDue,
|
||||||
@@ -133,6 +135,22 @@ test("backup file names are unique and safe for schema-aware exports", () => {
|
|||||||
assert.equal(path, "C:\\backups\\dbx-backup__Nightly_ prod__20260716-020304__app_private__12345678.sql");
|
assert.equal(path, "C:\\backups\\dbx-backup__Nightly_ prod__20260716-020304__app_private__12345678.sql");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("scheduled runs render a user-defined relative directory template", () => {
|
||||||
|
const directory = databaseBackupRunDirectory(
|
||||||
|
"C:\\backups",
|
||||||
|
"archives/{schedule}/{date}/{timestamp}-{runId}",
|
||||||
|
"Nightly: prod",
|
||||||
|
new Date(2026, 6, 16, 2, 3, 4),
|
||||||
|
"12345678-abcd",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(directory, "C:\\backups\\archives\\Nightly_ prod\\20260716\\20260716020304-12345678");
|
||||||
|
assert.equal(databaseBackupRunDirectoryPatternIsValid("{schedule}/{runId}"), true);
|
||||||
|
assert.equal(databaseBackupRunDirectoryPatternIsValid("dbx-backup_{timestamp}"), true);
|
||||||
|
assert.equal(databaseBackupRunDirectoryPatternIsValid("../{runId}"), false);
|
||||||
|
assert.equal(databaseBackupRunDirectoryPatternIsValid("daily"), true);
|
||||||
|
});
|
||||||
|
|
||||||
test("retention pruning keeps the newest successful runs", () => {
|
test("retention pruning keeps the newest successful runs", () => {
|
||||||
const runs = [run("new", "2026-07-16T03:00:00.000Z"), run("middle", "2026-07-16T02:00:00.000Z"), run("old", "2026-07-16T01:00:00.000Z")];
|
const runs = [run("new", "2026-07-16T03:00:00.000Z"), run("middle", "2026-07-16T02:00:00.000Z"), run("old", "2026-07-16T01:00:00.000Z")];
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { test } from "vitest";
|
|||||||
import { compileTemplate, parse } from "vue/compiler-sfc";
|
import { compileTemplate, parse } from "vue/compiler-sfc";
|
||||||
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
||||||
|
|
||||||
const tableInfoSurfaces = ["apps/desktop/src/components/objects/ObjectBrowser.vue", "apps/desktop/src/components/grid/DataGrid.vue"];
|
const tableInfoSurfaces = ["apps/desktop/src/components/objects/ObjectBrowser.vue", "apps/desktop/src/components/grid/DataGridTableInfoPanels.vue"];
|
||||||
|
|
||||||
test("table column defaults preserve raw database expressions", () => {
|
test("table column defaults preserve raw database expressions", () => {
|
||||||
const values = [null, "''", "0", "CURRENT_TIMESTAMP", "((1))", "('prefix (internal)')", "x".repeat(512)] as const;
|
const values = [null, "''", "0", "CURRENT_TIMESTAMP", "((1))", "('prefix (internal)')", "x".repeat(512)] as const;
|
||||||
|
|||||||
Reference in new issue
Block a user