feat(backup): add gzip compression and history filtering

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-31 09:02:20 +08:00
1 parent c305325e37
commit b3fcb3a6f1
36 files changed
+1144 -137

No files matched your search

@@ -1,14 +1,16 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { FolderOpen, Loader2 } from "@lucide/vue";
import { Check, ChevronDown, FolderOpen, Loader2, Search } from "@lucide/vue";
import type { DatabaseBackupExecutionConfig } from "@/lib/backup/scheduledDatabaseBackup";
import type { ConnectionConfig } from "@/types/database";
withDefaults(
const props = withDefaults(
defineProps<{
draft: DatabaseBackupExecutionConfig;
connections: Array<Pick<ConnectionConfig, "id" | "name">>;
@@ -30,18 +32,70 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const connectionPickerOpen = ref(false);
const connectionSearch = ref("");
const databaseSearch = ref("");
const selectedConnectionName = computed(() => props.connections.find((connection) => connection.id === props.draft.connectionId)?.name || props.draft.connectionId);
const filteredConnections = computed(() => {
const query = connectionSearch.value.trim().toLocaleLowerCase();
if (!query) return props.connections;
return props.connections.filter((connection) => connection.name.toLocaleLowerCase().includes(query));
});
const filteredDatabaseOptions = computed(() => {
const query = databaseSearch.value.trim().toLocaleLowerCase();
if (!query) return props.databaseOptions;
return props.databaseOptions.filter((database) => database.toLocaleLowerCase().includes(query));
});
function selectConnection(connectionId: string) {
emit("changeConnection", connectionId);
connectionPickerOpen.value = false;
}
watch(connectionPickerOpen, (open) => {
if (!open) connectionSearch.value = "";
});
watch(
() => props.draft.connectionId,
() => {
databaseSearch.value = "";
},
);
</script>
<template>
<div class="grid gap-5 py-1">
<div class="space-y-2">
<Label>{{ t("databaseBackup.connection") }}</Label>
<Select :model-value="draft.connectionId" @update:model-value="(value: any) => emit('changeConnection', String(value))">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="connection in connections" :key="connection.id" :value="connection.id">{{ connection.name }}</SelectItem>
</SelectContent>
</Select>
<Popover v-model:open="connectionPickerOpen">
<PopoverTrigger as-child>
<Button data-backup-connection-picker type="button" variant="outline" role="combobox" :aria-expanded="connectionPickerOpen" class="w-full justify-between font-normal">
<span class="truncate">{{ selectedConnectionName }}</span>
<ChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[var(--reka-popover-trigger-width)] p-1">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="connectionSearch" data-backup-connection-search class="h-9 pl-8" :aria-label="t('databaseBackup.searchConnections')" :placeholder="t('databaseBackup.searchConnections')" />
</div>
<div class="max-h-60 overflow-y-auto py-1">
<button
v-for="connection in filteredConnections"
:key="connection.id"
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="selectConnection(connection.id)"
>
<Check class="h-4 w-4 shrink-0" :class="connection.id === draft.connectionId ? 'opacity-100' : 'opacity-0'" />
<span class="min-w-0 flex-1 truncate">{{ connection.name }}</span>
</button>
<div v-if="filteredConnections.length === 0" class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingConnections") }}</div>
</div>
</PopoverContent>
</Popover>
</div>
<div class="space-y-2">
@@ -54,6 +108,14 @@ const { t } = useI18n();
</div>
</div>
<div class="space-y-2">
<Label>{{ t("databaseBackup.compression") }}</Label>
<label class="flex items-center gap-2 text-sm">
<input :checked="draft.outputCompression === 'gzip'" type="checkbox" class="h-4 w-4 accent-primary" @change="draft.outputCompression = ($event.target as HTMLInputElement).checked ? 'gzip' : 'none'" />
{{ t("databaseBackup.compressionGzip") }}
</label>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<Label>{{ t("databaseBackup.databases") }}</Label>
@@ -62,12 +124,21 @@ const { t } = useI18n();
{{ t("databaseBackup.allDatabases") }}
</label>
</div>
<div v-if="!allDatabases" class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
<label v-for="database in databaseOptions" v-else :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="emit('toggleDatabase', database)" />
<span class="truncate">{{ database }}</span>
</label>
<div v-if="!allDatabases" class="space-y-2">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="databaseSearch" data-backup-database-search class="h-9 pl-8" :aria-label="t('databaseBackup.searchDatabases')" :placeholder="t('databaseBackup.searchDatabases')" />
</div>
<div class="max-h-40 overflow-y-auto rounded-md border border-border/70 p-2">
<div v-if="loadingDatabases" class="flex items-center justify-center gap-2 py-5 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("common.loading") }}</div>
<template v-else-if="filteredDatabaseOptions.length">
<label v-for="database in filteredDatabaseOptions" :key="database" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/60">
<input type="checkbox" class="h-4 w-4 rounded border-border accent-primary" :checked="selectedDatabases.includes(database)" @change="emit('toggleDatabase', database)" />
<span class="truncate">{{ database }}</span>
</label>
</template>
<div v-else class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingDatabases") }}</div>
</div>
</div>
</div>
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Square, Trash2 } from "@lucide/vue";
import { Check, ChevronDown, ChevronRight, DatabaseBackup, FolderOpen, Loader2, Pencil, Play, Plus, RotateCcw, Search, Square, Trash2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups";
import DatabaseBackupConfigFields from "@/components/backup/DatabaseBackupConfigFields.vue";
@@ -22,7 +23,7 @@ import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOpt
const { t, locale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const { schedules, runs, activeScheduleIds, activeRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const { schedules, runs, activeScheduleIds, activeRunIds, cancellingRunIds, activeRuns, saveSchedule, setScheduleEnabled, deleteSchedule, deleteRun, renameRun, runSchedule, runOneShot, cancelRun } = useScheduledDatabaseBackups();
const scheduleDialogOpen = ref(false);
const oneShotDialogOpen = ref(false);
@@ -42,10 +43,35 @@ const allDatabases = ref(true);
const selectedDatabases = ref<string[]>([]);
const tablePatternsInput = ref("");
const expandedRunIds = reactive(new Set<string>());
const historyConnectionId = ref("");
const historyConnectionPickerOpen = ref(false);
const historyConnectionSearch = ref("");
const historyBackupMethod = ref<"all" | "manual" | "scheduled" | "one-shot">("all");
const historyStatus = ref<"all" | DatabaseBackupRun["status"]>("all");
const sqlConnections = computed(() => connectionStore.connections.filter((connection) => supportsScheduledDatabaseBackup(connection.db_type)));
const canCreateSchedule = computed(() => sqlConnections.value.length > 0);
const sortedRuns = computed(() => [...runs.value].sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt)));
const historyConnections = computed(() => {
const connectionById = new Map<string, string>();
for (const run of sortedRuns.value) {
if (!connectionById.has(run.connectionId)) connectionById.set(run.connectionId, run.connectionName || connectionName(run.connectionId));
}
return [...connectionById].map(([id, name]) => ({ id, name })).sort((left, right) => left.name.localeCompare(right.name, locale.value));
});
const filteredHistoryConnections = computed(() => {
const query = historyConnectionSearch.value.trim().toLocaleLowerCase();
if (!query) return historyConnections.value;
return historyConnections.value.filter((connection) => connection.name.toLocaleLowerCase().includes(query));
});
const filteredRuns = computed(() =>
sortedRuns.value.filter((run) => {
if (historyConnectionId.value && run.connectionId !== historyConnectionId.value) return false;
if (historyBackupMethod.value !== "all" && historyBackupMethod.value !== runBackupMethod(run)) return false;
return historyStatus.value === "all" || historyStatus.value === run.status;
}),
);
const selectedHistoryConnectionName = computed(() => historyConnections.value.find((connection) => connection.id === historyConnectionId.value)?.name || t("databaseBackup.allConnections"));
const weekdays = computed(() => [
{ value: 0, label: t("databaseBackup.weekdays.sunday") },
{ value: 1, label: t("databaseBackup.weekdays.monday") },
@@ -56,6 +82,10 @@ const weekdays = computed(() => [
{ value: 6, label: t("databaseBackup.weekdays.saturday") },
]);
watch(historyConnectionPickerOpen, (open) => {
if (!open) historyConnectionSearch.value = "";
});
function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): DatabaseBackupExecutionConfig {
return {
connectionId,
@@ -67,6 +97,7 @@ function newBackupConfig(connectionId = sqlConnections.value[0]?.id ?? ""): Data
includeData: true,
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
};
}
@@ -121,6 +152,17 @@ function connectionName(connectionId: string): string {
return connectionStore.getConfig(connectionId)?.name || t("databaseBackup.missingConnection");
}
function selectHistoryConnection(connectionId: string) {
historyConnectionId.value = connectionId;
historyConnectionPickerOpen.value = false;
historyConnectionSearch.value = "";
}
function runBackupMethod(run: DatabaseBackupRun): "manual" | "scheduled" | "one-shot" {
if (run.source === "one-shot") return "one-shot";
return run.trigger;
}
function formatDate(value?: string): string {
if (!value || !Number.isFinite(Date.parse(value))) return t("databaseBackup.never");
return new Intl.DateTimeFormat(locale.value, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
@@ -151,6 +193,10 @@ function runStatusLabel(status: DatabaseBackupRun["status"]): string {
return t(`databaseBackup.status.${status}`);
}
function displayedRunStatusLabel(run: DatabaseBackupRun): string {
return cancellingRunIds.has(run.id) ? t("databaseBackup.cancelling") : runStatusLabel(run.status);
}
function runStatusVariant(status: DatabaseBackupRun["status"]): "default" | "secondary" | "destructive" | "outline" {
if (status === "success") return "default";
if (status === "failed") return "destructive";
@@ -162,6 +208,15 @@ function activeRunForSchedule(scheduleId: string): DatabaseBackupRun | undefined
return activeRuns.value.find((run) => run.scheduleId === scheduleId);
}
function scheduleCancellationRequested(scheduleId: string): boolean {
const run = activeRunForSchedule(scheduleId);
return !!run && cancellingRunIds.has(run.id);
}
function oneShotCancellationRequested(): boolean {
return !!activeOneShotRun.value && cancellingRunIds.has(activeOneShotRun.value.id);
}
async function loadDatabases(dialog: BackupDialogKind, targetDraft: DatabaseBackupExecutionConfig, preserveSelection: boolean) {
const generation = ++databaseLoadGeneration;
const connectionId = targetDraft.connectionId;
@@ -295,7 +350,11 @@ async function startOneShotBackup() {
async function cancelActiveOneShotBackup() {
const run = activeOneShotRun.value;
if (run) await cancelRun(run.id);
if (run && (await cancelRun(run.id))) toast(t("databaseBackup.cancelRequested"), 2500);
}
async function requestCancelRun(runId: string) {
if (await cancelRun(runId)) toast(t("databaseBackup.cancelRequested"), 2500);
}
async function runNow(schedule: DatabaseBackupSchedule) {
@@ -418,7 +477,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="truncate text-sm font-medium">{{ schedule.name }}</span>
<Badge variant="outline" class="font-normal">{{ connectionName(schedule.connectionId) }}</Badge>
<Badge v-if="activeScheduleIds.has(schedule.id)" variant="secondary" class="font-normal">{{ t("databaseBackup.status.running") }}</Badge>
<Badge v-if="activeScheduleIds.has(schedule.id)" variant="secondary" class="font-normal">{{ scheduleCancellationRequested(schedule.id) ? t("databaseBackup.cancelling") : t("databaseBackup.status.running") }}</Badge>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{{ frequencyLabel(schedule) }}</span>
@@ -430,8 +489,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<div class="flex items-center justify-end gap-1">
<Switch :model-value="schedule.enabled" :disabled="activeScheduleIds.has(schedule.id)" :title="schedule.enabled ? t('databaseBackup.disable') : t('databaseBackup.enable')" @update:model-value="(value: boolean) => setScheduleEnabled(schedule.id, value)" />
<Button v-if="activeRunForSchedule(schedule.id)" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.cancel')" @click="cancelRun(activeRunForSchedule(schedule.id)!.id)">
<Square class="h-4 w-4" />
<Button
v-if="activeRunForSchedule(schedule.id)"
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="scheduleCancellationRequested(schedule.id)"
:title="scheduleCancellationRequested(schedule.id) ? t('databaseBackup.cancelling') : t('databaseBackup.cancel')"
@click="requestCancelRun(activeRunForSchedule(schedule.id)!.id)"
>
<Loader2 v-if="scheduleCancellationRequested(schedule.id)" class="h-4 w-4 animate-spin" />
<Square v-else class="h-4 w-4" />
</Button>
<Button v-else variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.runNow')" @click="runNow(schedule)">
<Play class="h-4 w-4" />
@@ -447,10 +515,64 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
<div class="flex flex-col gap-3">
<h3 class="text-base font-semibold">{{ t("databaseBackup.history") }}</h3>
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-base font-semibold">{{ t("databaseBackup.history") }}</h3>
<div class="flex flex-wrap items-center justify-end gap-2">
<Popover v-model:open="historyConnectionPickerOpen">
<PopoverTrigger as-child>
<Button data-backup-history-connection-picker type="button" variant="outline" role="combobox" :aria-expanded="historyConnectionPickerOpen" class="min-w-52 justify-between font-normal">
<span class="truncate">{{ selectedHistoryConnectionName }}</span>
<ChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-[var(--reka-popover-trigger-width)] p-1">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="historyConnectionSearch" data-backup-history-connection-search class="h-9 pl-8" :aria-label="t('databaseBackup.searchHistoryConnections')" :placeholder="t('databaseBackup.searchHistoryConnections')" />
</div>
<div class="max-h-60 overflow-y-auto py-1">
<button type="button" class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none" @click="selectHistoryConnection('')">
<Check class="h-4 w-4 shrink-0" :class="historyConnectionId ? 'opacity-0' : 'opacity-100'" />
<span class="min-w-0 flex-1 truncate">{{ t("databaseBackup.allConnections") }}</span>
</button>
<button
v-for="connection in filteredHistoryConnections"
:key="connection.id"
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="selectHistoryConnection(connection.id)"
>
<Check class="h-4 w-4 shrink-0" :class="connection.id === historyConnectionId ? 'opacity-100' : 'opacity-0'" />
<span class="min-w-0 flex-1 truncate">{{ connection.name }}</span>
</button>
<div v-if="filteredHistoryConnections.length === 0" class="px-2 py-2 text-sm text-muted-foreground">{{ t("databaseBackup.noMatchingConnections") }}</div>
</div>
</PopoverContent>
</Popover>
<Select v-model="historyBackupMethod">
<SelectTrigger class="w-36" :aria-label="t('databaseBackup.backupMethod')"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allBackupMethods") }}</SelectItem>
<SelectItem value="manual">{{ t("databaseBackup.manualTrigger") }}</SelectItem>
<SelectItem value="scheduled">{{ t("databaseBackup.scheduledTrigger") }}</SelectItem>
<SelectItem value="one-shot">{{ t("databaseBackup.oneShotTrigger") }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="historyStatus">
<SelectTrigger class="w-32" :aria-label="t('databaseBackup.backupStatus')"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allStatuses") }}</SelectItem>
<SelectItem value="running">{{ t("databaseBackup.status.running") }}</SelectItem>
<SelectItem value="success">{{ t("databaseBackup.status.success") }}</SelectItem>
<SelectItem value="failed">{{ t("databaseBackup.status.failed") }}</SelectItem>
<SelectItem value="cancelled">{{ t("databaseBackup.status.cancelled") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="overflow-hidden rounded-md border border-border/70">
<div v-if="sortedRuns.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">{{ t("databaseBackup.noHistory") }}</div>
<template v-for="run in sortedRuns" :key="run.id">
<div v-if="filteredRuns.length === 0" class="px-4 py-8 text-center text-sm text-muted-foreground">{{ historyConnectionId || historyBackupMethod !== "all" || historyStatus !== "all" ? t("databaseBackup.noFilteredHistory") : t("databaseBackup.noHistory") }}</div>
<template v-for="run in filteredRuns" :key="run.id">
<div class="grid gap-2 border-b border-border/70 px-3 py-3 last:border-b-0 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
<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" />
@@ -459,7 +581,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<div class="min-w-0">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="truncate text-sm font-medium">{{ run.displayName || run.scheduleName }}</span>
<Badge :variant="runStatusVariant(run.status)" class="font-normal">{{ runStatusLabel(run.status) }}</Badge>
<Badge :variant="runStatusVariant(run.status)" class="font-normal">{{ displayedRunStatusLabel(run) }}</Badge>
<Badge variant="outline" class="font-normal">{{ run.source === "one-shot" ? t("databaseBackup.oneShotTrigger") : run.trigger === "scheduled" ? t("databaseBackup.scheduledTrigger") : t("databaseBackup.manualTrigger") }}</Badge>
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
@@ -476,8 +598,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
</div>
<div class="flex items-center justify-end gap-1">
<Button v-if="activeRunIds.has(run.id) && run.source === 'one-shot'" variant="ghost" size="icon" class="h-8 w-8" :title="t('databaseBackup.cancel')" @click="cancelRun(run.id)">
<Square class="h-4 w-4" />
<Button
v-if="activeRunIds.has(run.id) && run.source === 'one-shot'"
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="cancellingRunIds.has(run.id)"
:title="cancellingRunIds.has(run.id) ? t('databaseBackup.cancelling') : t('databaseBackup.cancel')"
@click="requestCancelRun(run.id)"
>
<Loader2 v-if="cancellingRunIds.has(run.id)" class="h-4 w-4 animate-spin" />
<Square v-else class="h-4 w-4" />
</Button>
<Loader2 v-else-if="activeRunIds.has(run.id)" class="mr-2 h-4 w-4 animate-spin text-primary" />
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="activeRunIds.has(run.id)" :title="t('databaseBackup.renameBackup')" @click="requestRenameRun(run)">
@@ -619,9 +750,10 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
<DialogFooter>
<Button variant="outline" @click="oneShotDialogOpen = false">{{ oneShotStarting ? t("common.close") : t("common.cancel") }}</Button>
<Button v-if="oneShotStarting" variant="destructive" :disabled="!activeOneShotRun" :title="t('databaseBackup.cancel')" @click="cancelActiveOneShotBackup">
<Square class="mr-2 h-4 w-4" />
{{ t("databaseBackup.cancel") }}
<Button v-if="oneShotStarting" variant="destructive" :disabled="!activeOneShotRun || oneShotCancellationRequested()" :title="oneShotCancellationRequested() ? t('databaseBackup.cancelling') : t('databaseBackup.cancel')" @click="cancelActiveOneShotBackup">
<Loader2 v-if="oneShotCancellationRequested()" class="mr-2 h-4 w-4 animate-spin" />
<Square v-else class="mr-2 h-4 w-4" />
{{ oneShotCancellationRequested() ? t("databaseBackup.cancelling") : t("databaseBackup.cancel") }}
</Button>
<Button v-else :disabled="!canStartOneShot" @click="startOneShotBackup">
{{ t("databaseBackup.startBackup") }}
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
schedules: [] as DatabaseBackupSchedule[],
runs: [] as DatabaseBackupRun[],
activeRunIds: new Set<string>(),
cancellingRunIds: new Set<string>(),
activeRuns: [] as DatabaseBackupRun[],
ensureConnected: vi.fn(async () => {}),
listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]),
@@ -40,6 +41,7 @@ vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
runs: { __v_isRef: true, value: mocks.runs },
activeScheduleIds: new Set<string>(),
activeRunIds: mocks.activeRunIds,
cancellingRunIds: mocks.cancellingRunIds,
activeRuns: { __v_isRef: true, value: mocks.activeRuns },
saveSchedule: mocks.saveSchedule,
setScheduleEnabled: mocks.setScheduleEnabled,
@@ -113,7 +115,7 @@ async function selectDialogOption(triggerIndex: number, optionText: string) {
}
async function setTablePattern(pattern: string) {
await selectDialogOption(1, String(i18n.global.t("databaseBackup.includeTables")));
await selectDialogOption(0, String(i18n.global.t("databaseBackup.includeTables")));
const input = currentDialog().querySelector<HTMLInputElement>(`input[placeholder="${String(i18n.global.t("databaseBackup.tablePatternsPlaceholder"))}"]`);
if (!input) throw new Error("Table pattern input not found");
input.value = pattern;
@@ -121,6 +123,50 @@ async function setTablePattern(pattern: string) {
await flush();
}
async function selectBackupConnection(connectionName: string) {
const trigger = currentDialog().querySelector<HTMLButtonElement>("[data-backup-connection-picker]");
if (!trigger) throw new Error("Backup connection picker not found");
trigger.click();
await flush();
const search = document.body.querySelector<HTMLInputElement>("[data-backup-connection-search]");
if (!search) throw new Error("Backup connection search not found");
search.value = connectionName;
search.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
const option = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.trim() === connectionName);
if (!option) throw new Error(`Backup connection option not found: ${connectionName}`);
option.click();
await flush();
}
async function selectHistoryConnection(connectionName: string) {
const trigger = document.body.querySelector<HTMLButtonElement>("[data-backup-history-connection-picker]");
if (!trigger) throw new Error("History connection picker not found");
trigger.click();
await flush();
const search = document.body.querySelector<HTMLInputElement>("[data-backup-history-connection-search]");
if (!search) throw new Error("History connection search not found");
search.value = connectionName;
search.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
const option = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.trim() === connectionName);
if (!option) throw new Error(`History connection option not found: ${connectionName}`);
option.click();
await flush();
}
async function selectHistoryFilter(triggerIndex: number, optionText: string) {
const trigger = document.body.querySelectorAll<HTMLButtonElement>('[data-slot="select-trigger"]')[triggerIndex];
if (!trigger) throw new Error(`History filter trigger not found: ${triggerIndex}`);
trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
await flush();
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find((item) => item.textContent?.trim() === optionText);
if (!option) throw new Error(`History filter option not found: ${optionText}`);
option.focus();
option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flush();
}
async function showDatabaseOptions() {
const label = String(i18n.global.t("databaseBackup.allDatabases"));
const checkbox = Array.from(currentDialog().querySelectorAll<HTMLInputElement>('input[type="checkbox"]')).find((item) => item.parentElement?.textContent?.includes(label));
@@ -161,6 +207,7 @@ function schedule(overrides: Partial<DatabaseBackupSchedule> = {}): DatabaseBack
includeData: true,
includeObjects: true,
dropTableIfExists: false,
outputCompression: "none",
retentionCount: 10,
createdAt: "2026-08-12T00:00:00.000Z",
updatedAt: "2026-08-12T00:00:00.000Z",
@@ -191,6 +238,7 @@ afterEach(() => {
mocks.runs.splice(0);
mocks.activeRuns.splice(0);
mocks.activeRunIds.clear();
mocks.cancellingRunIds.clear();
mocks.ensureConnected.mockClear();
mocks.listDatabases.mockReset();
mocks.listDatabases.mockResolvedValue([{ name: "app" }]);
@@ -204,6 +252,73 @@ afterEach(() => {
});
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
it("filters backup history by a searched connection", async () => {
mocks.runs.push(
{
id: "run-primary",
scheduleName: "Primary backup",
connectionId: "mysql-primary",
connectionName: "Primary MySQL",
trigger: "manual",
source: "schedule",
status: "success",
startedAt: "2026-08-18T00:00:00.000Z",
files: [],
},
{
id: "run-archive",
scheduleName: "Archive backup",
connectionId: "mysql-archive",
connectionName: "Archive MySQL",
trigger: "manual",
source: "schedule",
status: "success",
startedAt: "2026-08-19T00:00:00.000Z",
files: [],
},
);
await mountSettings();
await selectHistoryConnection("Archive MySQL");
expect(document.body.textContent).toContain("Archive backup");
expect(document.body.textContent).not.toContain("Primary backup");
});
it("combines backup method and status filters for history", async () => {
mocks.runs.push(
{
id: "run-manual-success",
scheduleName: "Manual success backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "manual",
source: "scheduled",
status: "success",
startedAt: "2026-08-18T00:00:00.000Z",
files: [],
},
{
id: "run-scheduled-failed",
scheduleName: "Scheduled failed backup",
connectionId: "mysql-1",
connectionName: "Local MySQL",
trigger: "scheduled",
source: "scheduled",
status: "failed",
startedAt: "2026-08-19T00:00:00.000Z",
files: [],
},
);
await mountSettings();
await selectHistoryFilter(0, String(i18n.global.t("databaseBackup.scheduledTrigger")));
await selectHistoryFilter(1, String(i18n.global.t("databaseBackup.status.failed")));
expect(document.body.textContent).toContain("Scheduled failed backup");
expect(document.body.textContent).not.toContain("Manual success backup");
});
it("opens the create schedule dialog for supported SQL connections", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
await mountSettings();
@@ -270,7 +385,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-a"));
await selectDialogOption(0, "MySQL B");
await selectBackupConnection("MySQL B");
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("mysql-b"));
secondLoad.resolve([{ name: "new_database" }]);
@@ -284,6 +399,26 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(currentDialog().textContent).not.toContain("stale_database");
});
it("filters database choices locally without changing the loaded options", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.listDatabases.mockResolvedValue([{ name: "analytics" }, { name: "application" }, { name: "billing" }]);
await mountSettings();
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
await showDatabaseOptions();
const search = currentDialog().querySelector<HTMLInputElement>("[data-backup-database-search]");
if (!search) throw new Error("Backup database search not found");
search.value = "bill";
search.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
expect(currentDialog().textContent).toContain("billing");
expect(currentDialog().textContent).not.toContain("analytics");
expect(currentDialog().textContent).not.toContain("application");
});
it("does not let a closed one-shot request clear a schedule draft", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
const oneShotLoad = deferred<Array<{ name: string }>>();
@@ -40,7 +40,7 @@ const visibleTasks = computed(() => {
const hasMore = computed(() => tasks.value.length > MAX_VISIBLE);
const isActive = (status: string) => status === "Running" || status === "Writing";
const isActive = (status: string) => status === "Running" || status === "Writing" || status === "Cancelling";
const isFinished = (status: string) => status === "Done" || status === "Error" || status === "Cancelled";
const finishedCount = computed(() => tasks.value.filter((t) => isFinished(t.status)).length);
@@ -142,6 +142,8 @@ const rowsText = (task: ExportTask) => {
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
const taskStatusText = (task: ExportTask) => (task.status === "Cancelling" ? t("databaseBackup.cancelling") : "");
const elapsedText = (task: ExportTask) => {
if (task.startedAt === undefined) return "";
const finishedAt = task.finishedAt ?? currentTime.value;
@@ -170,6 +172,7 @@ const statusIcon = (task: ExportTask) => {
switch (task.status) {
case "Running":
case "Writing":
case "Cancelling":
return Loader2;
case "Done":
return CheckCircle2;
@@ -186,6 +189,7 @@ const statusColor = (status: string) => {
switch (status) {
case "Running":
case "Writing":
case "Cancelling":
return "text-primary";
case "Done":
return "text-green-500";
@@ -257,7 +261,7 @@ function openTask(task: ExportTask): void {
<div v-for="task in visibleTasks" :key="task.exportId" class="flex items-start gap-3 border-b px-4 py-3 text-xs last:border-b-0">
<div class="flex-1 min-w-0 flex flex-col gap-1.5">
<div class="flex items-center gap-1.5">
<component :is="statusIcon(task)" :class="[statusColor(task.status), task.kind === 'table-export' && isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
<component :is="statusIcon(task)" :class="[statusColor(task.status), (task.kind === 'table-export' || task.status === 'Cancelling') && isActive(task.status) ? 'animate-spin' : '']" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate font-medium" :title="task.filePath || undefined">{{ taskTitle(task) }}</span>
</div>
@@ -283,6 +287,7 @@ function openTask(task: ExportTask): void {
<div class="min-w-0 text-muted-foreground">
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.kind !== 'data-transfer' && task.startedAt !== undefined" class="ml-1 tabular-nums">{{ elapsedText(task) }}</span>
<span v-if="taskStatusText(task)" class="ml-1 font-medium text-primary">{{ taskStatusText(task) }}</span>
<span v-if="task.status === 'Error' && task.errorMessage" class="mt-1 block whitespace-normal break-words text-destructive" :title="translateBackendError(t, task.errorMessage)">
{{ translateBackendError(t, task.errorMessage) }}
</span>
@@ -311,8 +316,15 @@ function openTask(task: ExportTask): void {
<button v-if="canRevealTaskFile(task)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:opacity-50" :title="t('exportProgress.openFolder')" :disabled="revealingTaskIds.includes(task.exportId)" @click="revealTaskFile(task)">
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
<button v-if="isActive(task.status)" class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.cancel')" @click="cancelTask(task.exportId)">
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
<button
v-if="isActive(task.status)"
class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted disabled:cursor-not-allowed"
:disabled="task.status === 'Cancelling'"
:title="task.status === 'Cancelling' ? t('databaseBackup.cancelling') : t('exportProgress.cancel')"
@click="cancelTask(task.exportId)"
>
<Loader2 v-if="task.status === 'Cancelling'" class="h-3.5 w-3.5 animate-spin text-primary" />
<X v-else class="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</button>
<button v-else class="flex h-6 w-6 items-center justify-center rounded hover:bg-muted" :title="t('exportProgress.delete')" @click="removeTask(task.exportId)">
<X class="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
@@ -145,6 +145,29 @@ describe("ExportProgressPopover task duration", () => {
expect(document.body.textContent).toContain("Preparing: t_0001");
});
it("keeps a cancelling backup visible as cancelling until its terminal event arrives", async () => {
const tracker = useExportTracker();
const task = tracker.addDatabaseExportTask("cancelling-backup", "Nightly", "/tmp/backups", "scheduled");
tracker.markDatabaseExportTaskCancelling(task.exportId);
tracker.updateDatabaseExportTask(task.exportId, {
exportId: task.exportId,
currentObject: "app.users",
objectIndex: 8,
totalObjects: 27,
rowsExported: 0,
totalRows: null,
status: "Running",
error: null,
preparing: false,
});
await mountPopover();
expect(tracker.tasks.value.find((item) => item.exportId === task.exportId)?.status).toBe("Cancelling");
expect(document.body.textContent).toContain("Cancelling…");
expect(document.body.querySelector<HTMLButtonElement>("button[disabled]")).not.toBeNull();
});
it.each(["Done", "Error", "Cancelled"] as const)("hides stale database object text after the task reaches %s", async (status) => {
const tracker = useExportTracker();
const task = tracker.addDatabaseExportTask(`terminal-${status}`, "Nightly", "/tmp/backups", "scheduled");
@@ -308,7 +308,7 @@ async function selectFile() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: true,
filters: [{ name: "SQL", extensions: ["sql"] }],
filters: [{ name: "SQL", extensions: ["sql", "gz"] }],
});
const paths = Array.isArray(selected) ? selected : selected ? [selected] : [];
if (paths.length > 0) {
@@ -570,7 +570,7 @@ watch(
</div>
<div class="flex items-center gap-2">
<input ref="fileInput" type="file" accept=".sql,text/sql" multiple class="hidden" @change="handleFileInputChange" />
<input ref="fileInput" type="file" accept=".sql,.sql.gz,text/sql,application/gzip" multiple class="hidden" @change="handleFileInputChange" />
<Input :model-value="filePathDisplay" readonly class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" />
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />